Implement more information displays
This commit is contained in:
@@ -272,9 +272,29 @@
|
||||
Home
|
||||
</a>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
class="navbar-link"
|
||||
hx-get="{% url 'compose_contacts_dropdown' %}"
|
||||
hx-target="#nav-compose-contacts"
|
||||
hx-trigger="click once"
|
||||
hx-swap="innerHTML">
|
||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
<span style="margin-left: 0.35rem;">Message</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown" id="nav-compose-contacts">
|
||||
<a class="navbar-item is-disabled">Load contacts</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||
AI
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
|
||||
Search
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'queues' type='page' %}">
|
||||
Queue
|
||||
</a>
|
||||
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
@@ -350,28 +370,6 @@
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
class="navbar-link"
|
||||
hx-get="{% url 'compose_contacts_dropdown' %}"
|
||||
hx-target="#nav-compose-contacts"
|
||||
hx-trigger="click once"
|
||||
hx-swap="innerHTML">
|
||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
<span style="margin-left: 0.35rem;">Message</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown" id="nav-compose-contacts">
|
||||
<a class="navbar-item is-disabled">Load contacts</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||
AI
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'queues' type='page' %}">
|
||||
Queue
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
{% if not user.is_authenticated %}
|
||||
|
||||
92
core/templates/pages/ai-workspace-information.html
Normal file
92
core/templates/pages/ai-workspace-information.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
||||
<li class="is-active"><a aria-current="page">Information</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors.</p>
|
||||
<div class="tags has-addons" style="margin-top: 0.6rem;">
|
||||
<a class="tag is-link is-light" href="{{ graphs_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>Insight Graphs</span>
|
||||
</a>
|
||||
<a class="tag is-dark" href="#">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
<span>Data View</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-5">
|
||||
<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 class="buttons are-small">
|
||||
<a class="button is-light" href="{{ graphs_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>All Graphs</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{{ help_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<p class="heading">Factor Inputs</p>
|
||||
<div class="columns is-multiline" style="margin: 0 -0.2rem;">
|
||||
{% for factor in directionality.factors %}
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.2rem;">
|
||||
<article class="box" style="margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">
|
||||
<span class="icon is-small"><i class="{{ factor.icon }}"></i></span>
|
||||
<span>{{ factor.title }}</span>
|
||||
</p>
|
||||
<p class="is-size-7"><strong>Weight:</strong> {{ factor.weight }}</p>
|
||||
<p class="is-size-7"><strong>Value:</strong> {{ factor.value|default:"-" }}</p>
|
||||
<p style="margin-top: 0.35rem;">
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=factor.slug %}">
|
||||
View Metric
|
||||
</a>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="heading">Linked Graphs</p>
|
||||
<div class="tags">
|
||||
{% for ref in directionality.graph_refs %}
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=ref.slug %}">
|
||||
{{ ref.title }}: {{ ref.value|default:"-" }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -38,6 +38,10 @@
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>All Graphs</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{{ information_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
<span>Information</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{{ help_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
Historical metrics for workspace {{ workspace_conversation.id }}
|
||||
</p>
|
||||
<div class="buttons are-small" style="margin-top: 0.6rem;">
|
||||
<a class="button is-light" href="{{ information_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
<span>Information</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{{ help_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
Combined explanation for each metric collection group and what it can
|
||||
imply in relationship dynamics.
|
||||
</p>
|
||||
<div class="buttons are-small" style="margin-top: 0.6rem;">
|
||||
<a class="button is-light" href="{{ graphs_url }}">
|
||||
<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="{{ information_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
<span>Information</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for group_key, group in groups.items %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
data-index="{{ forloop.counter0 }}"
|
||||
onclick="giaWorkspaceUseDraft('{{ person.id }}', '{{ operation }}', {{ forloop.counter0 }}); return false;"
|
||||
style="height: 100%; padding: 0.6rem; border-radius: 9px; border: 1px solid rgba(0, 0, 0, 0.16); background: #fff; cursor: pointer; transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;">
|
||||
<p class="is-size-7 has-text-weight-semibold is-flex is-align-items-center" style="margin-bottom: 0.35rem; gap: 0.35rem;">
|
||||
<p class="draft-tone-line">
|
||||
{% with tone=option.label|default:""|lower %}
|
||||
{% if tone == "soft" %}
|
||||
<span class="icon is-small has-text-success"><i class="fa-solid fa-leaf"></i></span>
|
||||
@@ -301,6 +301,20 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(54, 54, 54, 0.18);
|
||||
background-color: rgba(54, 54, 54, 0.06) !important;
|
||||
}
|
||||
.draft-tone-line {
|
||||
margin-bottom: 0.26rem;
|
||||
color: #6f7680;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div id="ai-person-timeline-{{ person.id }}">
|
||||
<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">Timeline</p>
|
||||
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
|
||||
<p class="is-size-7">Showing last {{ limit }} messages.</p>
|
||||
</div>
|
||||
|
||||
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
|
||||
{% if message_rows %}
|
||||
{% for row in message_rows %}
|
||||
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
|
||||
<div class="media-content">
|
||||
<div
|
||||
class="content"
|
||||
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
|
||||
<div
|
||||
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
|
||||
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
|
||||
<p class="is-size-7">
|
||||
{{ row.ts_label }}
|
||||
{% if row.message.custom_author %}
|
||||
| {{ row.message.custom_author }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="has-text-grey">No messages found for this contact.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,6 @@
|
||||
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
|
||||
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
|
||||
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
|
||||
<p class="is-size-7">Showing last {{ limit }} messages.</p>
|
||||
<div class="tags" style="margin-top: 0.35rem;">
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='platform' %}">Platform {{ workspace_conversation.platform_type|title }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='thread' %}">Thread {{ workspace_conversation.platform_thread_id|default:"-" }}</a>
|
||||
@@ -126,72 +125,14 @@
|
||||
</div>
|
||||
|
||||
<div id="ai-stage-{{ person.id }}" style="min-height: 7rem;">
|
||||
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-warning is-light is-small is-rounded"
|
||||
onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
|
||||
<span>Plan</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-link is-light is-small is-rounded"
|
||||
onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary is-light is-small is-rounded"
|
||||
onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
<span>Draft</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-info is-light is-small is-rounded"
|
||||
onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-wave-square"></i></span>
|
||||
<span>Patterns</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;"></div>
|
||||
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;"></div>
|
||||
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane"></div>
|
||||
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
|
||||
{% if message_rows %}
|
||||
{% for row in message_rows %}
|
||||
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
|
||||
<div class="media-content">
|
||||
<div
|
||||
class="content"
|
||||
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
|
||||
<div
|
||||
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
|
||||
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
|
||||
<p class="is-size-7">
|
||||
{{ row.ts_label }}
|
||||
{% if row.message.custom_author %}
|
||||
| {{ row.message.custom_author }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="has-text-grey">No messages found for this contact.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -266,88 +207,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatUtcLabel(tsMs) {
|
||||
const ts = Number(tsMs || 0);
|
||||
if (!ts) {
|
||||
return "";
|
||||
}
|
||||
const dt = new Date(ts);
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
return (
|
||||
dt.getUTCFullYear()
|
||||
+ "-" + pad(dt.getUTCMonth() + 1)
|
||||
+ "-" + pad(dt.getUTCDate())
|
||||
+ " " + pad(dt.getUTCHours())
|
||||
+ ":" + pad(dt.getUTCMinutes())
|
||||
+ " UTC"
|
||||
);
|
||||
}
|
||||
|
||||
function appendOutgoingMessage(tsMs, text, author) {
|
||||
const host = document.getElementById("ai-message-list-" + personId);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const noMessages = host.querySelector("p.has-text-grey");
|
||||
if (noMessages) {
|
||||
noMessages.remove();
|
||||
}
|
||||
|
||||
const article = document.createElement("article");
|
||||
article.className = "media ai-message-row";
|
||||
article.dataset.ts = String(Number(tsMs || Date.now()));
|
||||
article.style.marginBottom = "0.75rem";
|
||||
|
||||
const mediaContent = document.createElement("div");
|
||||
mediaContent.className = "media-content";
|
||||
|
||||
const contentWrap = document.createElement("div");
|
||||
contentWrap.className = "content";
|
||||
contentWrap.style.marginLeft = "15%";
|
||||
contentWrap.style.marginRight = "0";
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.style.marginBottom = "0.25rem";
|
||||
bubble.style.padding = "0.6rem";
|
||||
bubble.style.borderRadius = "6px";
|
||||
bubble.style.border = "1px solid rgba(0, 0, 0, 0.15)";
|
||||
bubble.style.background = "#f0f7ff";
|
||||
bubble.style.boxShadow = "none";
|
||||
|
||||
const bodyP = document.createElement("p");
|
||||
bodyP.style.whiteSpace = "pre-wrap";
|
||||
bodyP.style.marginBottom = "0.35rem";
|
||||
bodyP.textContent = text || "(no text)";
|
||||
|
||||
const metaP = document.createElement("p");
|
||||
metaP.className = "is-size-7";
|
||||
metaP.textContent = formatUtcLabel(tsMs);
|
||||
if (author) {
|
||||
metaP.textContent += " | " + author;
|
||||
}
|
||||
|
||||
bubble.appendChild(bodyP);
|
||||
bubble.appendChild(metaP);
|
||||
contentWrap.appendChild(bubble);
|
||||
mediaContent.appendChild(contentWrap);
|
||||
article.appendChild(mediaContent);
|
||||
host.appendChild(article);
|
||||
|
||||
const maxRows = Math.max(5, Math.min(parseInt(widget.dataset.limit || "20", 10) || 20, 200));
|
||||
const rows = host.querySelectorAll(".ai-message-row");
|
||||
if (rows.length > maxRows) {
|
||||
const removeCount = rows.length - maxRows;
|
||||
for (let i = 0; i < removeCount; i += 1) {
|
||||
if (rows[i] && rows[i].parentNode) {
|
||||
rows[i].parentNode.removeChild(rows[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
host.scrollTop = host.scrollHeight;
|
||||
}
|
||||
|
||||
function getCacheEntry(operation) {
|
||||
const key = cacheKey(operation);
|
||||
const raw = window.giaWorkspaceCache[key];
|
||||
@@ -725,25 +584,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
window.giaWorkspaceMessageListeners = window.giaWorkspaceMessageListeners || {};
|
||||
const existingListener = window.giaWorkspaceMessageListeners[personId];
|
||||
if (existingListener) {
|
||||
document.body.removeEventListener("gia-message-sent", existingListener);
|
||||
}
|
||||
const messageSentListener = function(evt) {
|
||||
const detail = (evt && evt.detail) ? evt.detail : {};
|
||||
if (!detail || String(detail.person_id || "") !== personId) {
|
||||
return;
|
||||
}
|
||||
appendOutgoingMessage(
|
||||
Number(detail.ts || Date.now()),
|
||||
String(detail.text || ""),
|
||||
String(detail.author || "BOT")
|
||||
);
|
||||
};
|
||||
document.body.addEventListener("gia-message-sent", messageSentListener);
|
||||
window.giaWorkspaceMessageListeners[personId] = messageSentListener;
|
||||
|
||||
window.giaWorkspaceRun(personId, "artifacts", false);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<span class="icon is-small"><i class="fa-solid fa-handshake"></i></span>
|
||||
<span>Engage</span>
|
||||
</button>
|
||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="quick_insights">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>Quick Insights</span>
|
||||
</button>
|
||||
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>AI Workspace</span>
|
||||
@@ -57,6 +61,15 @@
|
||||
</div>
|
||||
<div class="compose-ai-content"></div>
|
||||
</div>
|
||||
<div class="compose-ai-card" data-kind="quick_insights">
|
||||
<p class="compose-ai-title">Quick Insights</p>
|
||||
<div class="compose-ai-loading">
|
||||
<div class="compose-ai-skel"></div>
|
||||
<div class="compose-ai-skel"></div>
|
||||
<div class="compose-ai-skel"></div>
|
||||
</div>
|
||||
<div class="compose-ai-content"></div>
|
||||
</div>
|
||||
<div class="compose-ai-card" data-kind="engage">
|
||||
<p class="compose-ai-title">Quick Engage (Shared Framing)</p>
|
||||
<div class="compose-engage-source-row">
|
||||
@@ -103,6 +116,7 @@
|
||||
data-ws-url="{{ compose_ws_url }}"
|
||||
data-drafts-url="{{ compose_drafts_url }}"
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
{% for msg in serialized_messages %}
|
||||
@@ -381,19 +395,25 @@
|
||||
margin-bottom: 0.45rem;
|
||||
border-radius: 8px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
line-height: 1.35;
|
||||
padding: 0.58rem 0.62rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-draft-option strong {
|
||||
display: inline;
|
||||
margin-right: 0.2rem;
|
||||
white-space: normal;
|
||||
#{{ panel_id }} .compose-draft-tone {
|
||||
margin: 0 0 0.2rem 0;
|
||||
color: #6e7782;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#{{ panel_id }} .compose-draft-option span {
|
||||
white-space: normal;
|
||||
#{{ panel_id }} .compose-draft-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -406,11 +426,12 @@
|
||||
#{{ panel_id }} .js-ai-trigger {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
#{{ panel_id }} .js-ai-trigger.is-expanded {
|
||||
background: #eef6ff;
|
||||
border-color: #8bb2e6;
|
||||
}
|
||||
#{{ panel_id }} .js-ai-trigger.is-expanded {
|
||||
font-weight: 600;
|
||||
}
|
||||
#{{ panel_id }} .js-ai-trigger.is-expanded::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -424,6 +445,83 @@
|
||||
border-top: 6px solid #8bb2e6;
|
||||
pointer-events: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-head {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.32rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-chip {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.42rem;
|
||||
background: #fff;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-chip p {
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-chip .k {
|
||||
color: #657283;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-chip .v {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-list {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0.42rem 0.46rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
min-width: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.44rem;
|
||||
font-size: 0.68rem;
|
||||
color: #657283;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #202835;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-docs {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: #657283;
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-docs li {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-image-fallback.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -515,6 +613,9 @@
|
||||
if (previousState && previousState.eventHandler) {
|
||||
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
|
||||
}
|
||||
if (previousState && previousState.sendResultHandler) {
|
||||
document.body.removeEventListener("composeSendResult", previousState.sendResultHandler);
|
||||
}
|
||||
if (previousState && previousState.docClickHandler) {
|
||||
document.removeEventListener("mousedown", previousState.docClickHandler);
|
||||
}
|
||||
@@ -554,6 +655,37 @@
|
||||
}
|
||||
};
|
||||
|
||||
const extractUrlCandidates = function (value) {
|
||||
const raw = String(value || "");
|
||||
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
matches.forEach(function (item) {
|
||||
const cleaned = String(item).trim().replace(/[.,);:!?\"']+$/g, "");
|
||||
if (!cleaned || seen.has(cleaned)) {
|
||||
return;
|
||||
}
|
||||
seen.add(cleaned);
|
||||
output.push(cleaned);
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
const appendImageCandidates = function (bubble, candidates) {
|
||||
(candidates || []).forEach(function (candidateUrl) {
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "compose-media";
|
||||
const img = document.createElement("img");
|
||||
img.className = "compose-image";
|
||||
img.src = String(candidateUrl);
|
||||
img.alt = "Attachment";
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
figure.appendChild(img);
|
||||
bubble.insertBefore(figure, bubble.firstChild);
|
||||
});
|
||||
};
|
||||
|
||||
const wireImageFallbacks = function (rootNode) {
|
||||
const scope = rootNode || thread;
|
||||
if (!scope) {
|
||||
@@ -590,6 +722,28 @@
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateBodyUrlsAsImages = function (rootNode) {
|
||||
const scope = rootNode || thread;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.querySelectorAll(".compose-bubble").forEach(function (bubble) {
|
||||
if (bubble.querySelector(".compose-image")) {
|
||||
return;
|
||||
}
|
||||
const body = bubble.querySelector(".compose-body");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
const candidates = extractUrlCandidates(body.textContent || "");
|
||||
if (!candidates.length) {
|
||||
return;
|
||||
}
|
||||
appendImageCandidates(bubble, candidates);
|
||||
});
|
||||
wireImageFallbacks(scope);
|
||||
};
|
||||
|
||||
const appendBubble = function (msg) {
|
||||
const row = document.createElement("div");
|
||||
const outgoing = !!msg.outgoing;
|
||||
@@ -599,21 +753,13 @@
|
||||
const bubble = document.createElement("article");
|
||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||
|
||||
const imageCandidates = Array.isArray(msg.image_urls) && msg.image_urls.length
|
||||
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
|
||||
? msg.image_urls
|
||||
: (msg.image_url ? [msg.image_url] : []);
|
||||
imageCandidates.forEach(function (candidateUrl) {
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "compose-media";
|
||||
const img = document.createElement("img");
|
||||
img.className = "compose-image";
|
||||
img.src = String(candidateUrl);
|
||||
img.alt = "Attachment";
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
figure.appendChild(img);
|
||||
bubble.appendChild(figure);
|
||||
});
|
||||
const imageCandidates = imageCandidatesFromPayload.length
|
||||
? imageCandidatesFromPayload
|
||||
: extractUrlCandidates(msg.text || msg.display_text || "");
|
||||
appendImageCandidates(bubble, imageCandidates);
|
||||
|
||||
if (!msg.hide_text) {
|
||||
const body = document.createElement("p");
|
||||
@@ -908,11 +1054,13 @@
|
||||
const engageButton = document.createElement("button");
|
||||
engageButton.type = "button";
|
||||
engageButton.className = "button is-link is-light compose-draft-option";
|
||||
const engageStrong = document.createElement("strong");
|
||||
engageStrong.textContent = "Custom Engage: ";
|
||||
const engageText = document.createElement("span");
|
||||
const engageTone = document.createElement("p");
|
||||
engageTone.className = "compose-draft-tone";
|
||||
engageTone.textContent = "Custom Engage";
|
||||
const engageText = document.createElement("p");
|
||||
engageText.className = "compose-draft-text";
|
||||
engageText.textContent = "Choose a source or write your own engagement text.";
|
||||
engageButton.appendChild(engageStrong);
|
||||
engageButton.appendChild(engageTone);
|
||||
engageButton.appendChild(engageText);
|
||||
engageButton.addEventListener("click", function () {
|
||||
openEngage("custom");
|
||||
@@ -922,12 +1070,14 @@
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button is-light compose-draft-option";
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = String(item.label || "Option") + ": ";
|
||||
const span = document.createElement("span");
|
||||
span.textContent = String(item.text || "");
|
||||
button.appendChild(strong);
|
||||
button.appendChild(span);
|
||||
const tone = document.createElement("p");
|
||||
tone.className = "compose-draft-tone";
|
||||
tone.textContent = String(item.label || "Option");
|
||||
const body = document.createElement("p");
|
||||
body.className = "compose-draft-text";
|
||||
body.textContent = String(item.text || "");
|
||||
button.appendChild(tone);
|
||||
button.appendChild(body);
|
||||
button.addEventListener("click", function () {
|
||||
textarea.value = String(item.text || "");
|
||||
autosize();
|
||||
@@ -967,6 +1117,110 @@
|
||||
}
|
||||
};
|
||||
|
||||
const loadQuickInsights = async function () {
|
||||
const card = showCard("quick_insights");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
setCardLoading(card, true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
thread.dataset.quickInsightsUrl + "?" + queryParams().toString(),
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" }
|
||||
}
|
||||
);
|
||||
const payload = await response.json();
|
||||
setCardLoading(card, false);
|
||||
const container = card.querySelector(".compose-ai-content");
|
||||
if (!payload.ok) {
|
||||
container.textContent = payload.error || "Failed to load quick insights.";
|
||||
return;
|
||||
}
|
||||
const summary = payload.summary || {};
|
||||
const rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
const docs = Array.isArray(payload.docs) ? payload.docs : [];
|
||||
container.innerHTML = "";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "compose-qi-head";
|
||||
[
|
||||
["Platform", summary.platform || "-"],
|
||||
["State", summary.state || "-"],
|
||||
["Data Points", String(summary.snapshot_count || 0)],
|
||||
["Thread", summary.thread || "-"],
|
||||
].forEach(function (pair) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "compose-qi-chip";
|
||||
chip.innerHTML = (
|
||||
'<p class="k">' + pair[0] + "</p>"
|
||||
+ '<p class="v">' + pair[1] + "</p>"
|
||||
);
|
||||
head.appendChild(chip);
|
||||
});
|
||||
container.appendChild(head);
|
||||
|
||||
if (!rows.length) {
|
||||
const none = document.createElement("p");
|
||||
none.className = "is-size-7 has-text-grey";
|
||||
none.textContent = "No metric rows available yet.";
|
||||
container.appendChild(none);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "compose-qi-list";
|
||||
rows.forEach(function (row) {
|
||||
const node = document.createElement("article");
|
||||
node.className = "compose-qi-row";
|
||||
node.innerHTML = (
|
||||
'<div class="compose-qi-row-head">'
|
||||
+ '<p class="compose-qi-row-label"><span class="icon is-small"><i class="'
|
||||
+ String(row.icon || "fa-solid fa-square") + '"></i></span><span>'
|
||||
+ String(row.label || "") + "</span></p>"
|
||||
+ '<p class="compose-qi-row-meta"><span>' + String(row.point_count || 0)
|
||||
+ ' points</span><span class="' + String((row.trend || {}).class_name || "")
|
||||
+ '"><span class="icon is-small"><i class="' + String((row.trend || {}).icon || "")
|
||||
+ '"></i></span> ' + String(row.delta_label || "n/a")
|
||||
+ "</span></p></div>"
|
||||
+ '<div class="compose-qi-row-body">'
|
||||
+ '<p class="compose-qi-value">' + String(row.display_value || "-") + "</p>"
|
||||
+ '<p class="' + String(((row.emotion || {}).class_name) || "")
|
||||
+ '" style="margin:0; font-size:0.72rem;">'
|
||||
+ '<span class="icon is-small"><i class="' + String(((row.emotion || {}).icon) || "")
|
||||
+ '"></i></span> ' + String(((row.emotion || {}).label) || "Unknown")
|
||||
+ "</p></div>"
|
||||
);
|
||||
list.appendChild(node);
|
||||
});
|
||||
container.appendChild(list);
|
||||
}
|
||||
if (docs.length) {
|
||||
const docsList = document.createElement("ul");
|
||||
docsList.className = "compose-qi-docs";
|
||||
docs.forEach(function (item) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = String(item || "");
|
||||
docsList.appendChild(li);
|
||||
});
|
||||
container.appendChild(docsList);
|
||||
}
|
||||
const openBtn = document.createElement("a");
|
||||
openBtn.className = "button is-light is-small is-rounded";
|
||||
openBtn.href = "{{ ai_workspace_url }}";
|
||||
openBtn.innerHTML = (
|
||||
'<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>'
|
||||
+ "<span>Open Minimal AI Workspace</span>"
|
||||
);
|
||||
openBtn.style.marginTop = "0.45rem";
|
||||
container.appendChild(openBtn);
|
||||
} catch (err) {
|
||||
setCardLoading(card, false);
|
||||
card.querySelector(".compose-ai-content").textContent =
|
||||
"Failed to load quick insights.";
|
||||
}
|
||||
};
|
||||
|
||||
const loadEngage = async function (card, preferredSource) {
|
||||
card = card || showCard("engage");
|
||||
if (!card) {
|
||||
@@ -1157,6 +1411,8 @@
|
||||
loadDrafts();
|
||||
} else if (kind === "summary") {
|
||||
loadSummary();
|
||||
} else if (kind === "quick_insights") {
|
||||
loadQuickInsights();
|
||||
} else if (kind === "engage") {
|
||||
openEngage("auto");
|
||||
}
|
||||
@@ -1184,7 +1440,6 @@
|
||||
});
|
||||
}
|
||||
document.addEventListener("mousedown", panelState.docClickHandler);
|
||||
|
||||
textarea.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
@@ -1224,7 +1479,7 @@
|
||||
};
|
||||
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
|
||||
|
||||
wireImageFallbacks(thread);
|
||||
hydrateBodyUrlsAsImages(thread);
|
||||
scrollToBottom(true);
|
||||
setupWebSocket();
|
||||
panelState.timer = setInterval(function () {
|
||||
|
||||
@@ -27,6 +27,7 @@ from core.models import (
|
||||
PatternMitigationPlan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
WorkspaceConversation,
|
||||
)
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||
@@ -297,6 +298,279 @@ 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):
|
||||
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":
|
||||
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",
|
||||
"field": "stability_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-heart-pulse",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "stability_confidence",
|
||||
"label": "Stability Confidence",
|
||||
"field": "stability_confidence",
|
||||
"source": "conversation",
|
||||
"kind": "confidence",
|
||||
"icon": "fa-solid fa-shield-check",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "sample_messages",
|
||||
"label": "Sample Messages",
|
||||
"field": "stability_sample_messages",
|
||||
"source": "conversation",
|
||||
"kind": "count",
|
||||
"icon": "fa-solid fa-message",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "sample_days",
|
||||
"label": "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",
|
||||
"field": "commitment_inbound_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-inbox",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "commitment_outbound",
|
||||
"label": "Commit Out",
|
||||
"field": "commitment_outbound_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-paper-plane",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "commitment_confidence",
|
||||
"label": "Commit Confidence",
|
||||
"field": "commitment_confidence",
|
||||
"source": "conversation",
|
||||
"kind": "confidence",
|
||||
"icon": "fa-solid fa-badge-check",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "reciprocity",
|
||||
"label": "Reciprocity",
|
||||
"field": "reciprocity_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-right-left",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "continuity",
|
||||
"label": "Continuity",
|
||||
"field": "continuity_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-link",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "response",
|
||||
"label": "Response",
|
||||
"field": "response_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-gauge-high",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "volatility",
|
||||
"label": "Volatility",
|
||||
"field": "volatility_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
"icon": "fa-solid fa-wave-square",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "inbound_messages",
|
||||
"label": "Inbound Messages",
|
||||
"field": "inbound_messages",
|
||||
"source": "snapshot",
|
||||
"kind": "count",
|
||||
"icon": "fa-solid fa-arrow-down",
|
||||
"higher_better": True,
|
||||
},
|
||||
{
|
||||
"key": "outbound_messages",
|
||||
"label": "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"]
|
||||
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)
|
||||
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,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"rows": rows,
|
||||
"snapshot_count": conversation.metric_snapshots.count(),
|
||||
"latest_computed_at": latest.computed_at if latest else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_engage_prompt(owner_name, person_name, transcript):
|
||||
return [
|
||||
{
|
||||
@@ -513,6 +787,7 @@ def _panel_context(
|
||||
"compose_summary_url": reverse("compose_summary"),
|
||||
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_ws_url": ws_url,
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
@@ -794,6 +1069,94 @@ class ComposeSummary(LoginRequiredMixin, View):
|
||||
return JsonResponse({"ok": True, "cached": False, "summary": summary})
|
||||
|
||||
|
||||
class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
service = _default_service(request.GET.get("service"))
|
||||
identifier = str(request.GET.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.GET.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
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,
|
||||
},
|
||||
"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)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"empty": False,
|
||||
"summary": {
|
||||
"person_name": person.name,
|
||||
"platform": conversation.get_platform_type_display(),
|
||||
"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"],
|
||||
},
|
||||
"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.",
|
||||
"Face indicator maps value range to positive, mixed, or strained climate.",
|
||||
"Use this card for fast triage; open AI Workspace for full graphs and details.",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ComposeEngagePreview(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
service = _default_service(request.GET.get("service"))
|
||||
|
||||
@@ -657,14 +657,21 @@ class OSINTSearch(LoginRequiredMixin, View):
|
||||
) -> list[dict[str, str]]:
|
||||
options = []
|
||||
for field in model_cls._meta.get_fields():
|
||||
if field.auto_created and not field.concrete and not field.many_to_many:
|
||||
# Skip reverse/accessor relations (e.g. ManyToManyRel) that are not
|
||||
# directly searchable as user-facing fields in this selector.
|
||||
if field.auto_created and not field.concrete:
|
||||
continue
|
||||
if field.name == "user":
|
||||
continue
|
||||
label = getattr(
|
||||
field,
|
||||
"verbose_name",
|
||||
str(field.name).replace("_", " "),
|
||||
)
|
||||
options.append(
|
||||
{
|
||||
"value": field.name,
|
||||
"label": field.verbose_name.title(),
|
||||
"label": str(label).title(),
|
||||
}
|
||||
)
|
||||
options.sort(key=lambda item: item["label"])
|
||||
|
||||
@@ -629,6 +629,41 @@ def _compose_page_url_for_person(user, person):
|
||||
return f"{reverse('compose_page')}?{query}"
|
||||
|
||||
|
||||
def _message_rows_for_person(user, person, limit):
|
||||
sessions = ChatSession.objects.filter(user=user, identifier__person=person)
|
||||
identifiers = set(
|
||||
PersonIdentifier.objects.filter(user=user, person=person).values_list(
|
||||
"identifier", flat=True
|
||||
)
|
||||
)
|
||||
messages = (
|
||||
Message.objects.filter(user=user, session__in=sessions)
|
||||
.select_related("session", "session__identifier")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
|
||||
rows = []
|
||||
for message in reversed(list(messages)):
|
||||
rows.append(
|
||||
{
|
||||
"message": message,
|
||||
"direction": _infer_direction(message, identifiers),
|
||||
"ts_label": _format_unix_ms(message.ts),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _recent_messages_for_person(user, person, limit):
|
||||
sessions = ChatSession.objects.filter(user=user, identifier__person=person)
|
||||
messages = (
|
||||
Message.objects.filter(user=user, session__in=sessions)
|
||||
.select_related("session", "session__identifier")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
return list(reversed(list(messages)))
|
||||
|
||||
|
||||
def _is_truthy(value):
|
||||
return str(value or "").strip().lower() in {"1", "true", "on", "yes"}
|
||||
|
||||
@@ -807,6 +842,274 @@ def _all_graph_payload(conversation):
|
||||
return graphs
|
||||
|
||||
|
||||
def _commitment_directionality_payload(conversation):
|
||||
latest_snapshot = conversation.metric_snapshots.first()
|
||||
inbound = conversation.commitment_inbound_score
|
||||
outbound = conversation.commitment_outbound_score
|
||||
confidence = conversation.commitment_confidence or 0.0
|
||||
|
||||
if inbound is None or outbound is None:
|
||||
return {
|
||||
"direction_key": "calibrating",
|
||||
"direction_label": "Calibrating",
|
||||
"magnitude": None,
|
||||
"delta": None,
|
||||
"confidence": confidence,
|
||||
"conclusion": (
|
||||
"Directionality cannot be inferred yet. Collect more exchanges to "
|
||||
"stabilize directional signal."
|
||||
),
|
||||
"factors": [],
|
||||
"graph_refs": [],
|
||||
}
|
||||
|
||||
delta = round(float(outbound) - float(inbound), 2)
|
||||
magnitude = round(abs(delta), 2)
|
||||
if magnitude < 4:
|
||||
direction_key = "balanced"
|
||||
direction_label = "Balanced"
|
||||
conclusion = (
|
||||
"Commitment appears symmetric. Keep current cadence and focus on "
|
||||
"maintaining clarity."
|
||||
)
|
||||
elif delta > 0:
|
||||
direction_key = "outbound"
|
||||
direction_label = "Outbound-Leaning"
|
||||
conclusion = (
|
||||
"You are carrying relatively more directional effort right now. "
|
||||
"Consider reducing over-functioning and asking for explicit reciprocity."
|
||||
)
|
||||
else:
|
||||
direction_key = "inbound"
|
||||
direction_label = "Inbound-Leaning"
|
||||
conclusion = (
|
||||
"The other party is carrying relatively more directional effort right now. "
|
||||
"Acknowledge this and match consistency to reduce asymmetry."
|
||||
)
|
||||
|
||||
graph_refs = [
|
||||
{"slug": "commitment_inbound", "title": "Commit In"},
|
||||
{"slug": "commitment_outbound", "title": "Commit Out"},
|
||||
{"slug": "inbound_response_score", "title": "Inbound Response Score"},
|
||||
{"slug": "outbound_response_score", "title": "Outbound Response Score"},
|
||||
{"slug": "balance_inbound_score", "title": "Inbound Balance Score"},
|
||||
{"slug": "balance_outbound_score", "title": "Outbound Balance Score"},
|
||||
{"slug": "commitment_confidence", "title": "Commit Confidence"},
|
||||
]
|
||||
factor_lookup = {
|
||||
"inbound_response_score": (
|
||||
latest_snapshot.inbound_response_score if latest_snapshot else None
|
||||
),
|
||||
"outbound_response_score": (
|
||||
latest_snapshot.outbound_response_score if latest_snapshot else None
|
||||
),
|
||||
"balance_inbound_score": (
|
||||
latest_snapshot.balance_inbound_score if latest_snapshot else None
|
||||
),
|
||||
"balance_outbound_score": (
|
||||
latest_snapshot.balance_outbound_score if latest_snapshot else None
|
||||
),
|
||||
"commitment_confidence": confidence,
|
||||
}
|
||||
factors = [
|
||||
{
|
||||
"title": "Inbound Response",
|
||||
"icon": "fa-solid fa-inbox",
|
||||
"weight": "60% of Commit In",
|
||||
"value": factor_lookup["inbound_response_score"],
|
||||
"slug": "inbound_response_score",
|
||||
},
|
||||
{
|
||||
"title": "Inbound Balance",
|
||||
"icon": "fa-solid fa-scale-balanced",
|
||||
"weight": "40% of Commit In",
|
||||
"value": factor_lookup["balance_inbound_score"],
|
||||
"slug": "balance_inbound_score",
|
||||
},
|
||||
{
|
||||
"title": "Outbound Response",
|
||||
"icon": "fa-solid fa-paper-plane",
|
||||
"weight": "60% of Commit Out",
|
||||
"value": factor_lookup["outbound_response_score"],
|
||||
"slug": "outbound_response_score",
|
||||
},
|
||||
{
|
||||
"title": "Outbound Balance",
|
||||
"icon": "fa-solid fa-arrows-left-right",
|
||||
"weight": "40% of Commit Out",
|
||||
"value": factor_lookup["balance_outbound_score"],
|
||||
"slug": "balance_outbound_score",
|
||||
},
|
||||
{
|
||||
"title": "Confidence",
|
||||
"icon": "fa-solid fa-shield-check",
|
||||
"weight": "Applies To Direction",
|
||||
"value": confidence,
|
||||
"slug": "commitment_confidence",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"direction_key": direction_key,
|
||||
"direction_label": direction_label,
|
||||
"magnitude": magnitude,
|
||||
"delta": delta,
|
||||
"confidence": confidence,
|
||||
"conclusion": conclusion,
|
||||
"commit_in": inbound,
|
||||
"commit_out": outbound,
|
||||
"factors": factors,
|
||||
"graph_refs": graph_refs,
|
||||
}
|
||||
|
||||
|
||||
def _metric_pattern_context(conversation):
|
||||
latest_snapshot = conversation.metric_snapshots.first()
|
||||
directionality = _commitment_directionality_payload(conversation)
|
||||
confidence = conversation.stability_confidence or 0.0
|
||||
risk_signals = []
|
||||
|
||||
state_key = str(conversation.stability_state or "").lower()
|
||||
if state_key == WorkspaceConversation.StabilityState.FRAGILE:
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "stability_fragile",
|
||||
"label": "Fragile Stability",
|
||||
"severity": "high",
|
||||
"explanation": (
|
||||
"Stability is in fragile range. Bias corrections toward "
|
||||
"de-escalation and explicit repair loops."
|
||||
),
|
||||
}
|
||||
)
|
||||
elif state_key == WorkspaceConversation.StabilityState.WATCH:
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "stability_watch",
|
||||
"label": "Watch Stability",
|
||||
"severity": "medium",
|
||||
"explanation": (
|
||||
"Stability is watch-range. Reinforce concise requests and "
|
||||
"misinterpretation checks before escalation."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if confidence < 0.25:
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "low_confidence",
|
||||
"label": "Low Confidence Window",
|
||||
"severity": "low",
|
||||
"explanation": (
|
||||
"Confidence is low. Prefer reversible, low-risk corrections "
|
||||
"that can be validated quickly."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
magnitude = directionality.get("magnitude")
|
||||
if magnitude is not None:
|
||||
severity = "high" if magnitude >= 15 else "medium" if magnitude >= 8 else None
|
||||
if severity:
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "commitment_asymmetry",
|
||||
"label": "Commitment Asymmetry",
|
||||
"severity": severity,
|
||||
"explanation": (
|
||||
"Directional commitment is asymmetric. Add corrections "
|
||||
"that restore reciprocity and explicit confirmation."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if latest_snapshot:
|
||||
if (
|
||||
latest_snapshot.volatility_score is not None
|
||||
and latest_snapshot.volatility_score >= 70
|
||||
):
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "volatility_spike",
|
||||
"label": "Volatility Spike",
|
||||
"severity": "medium",
|
||||
"explanation": (
|
||||
"Volatility is elevated. Use short, bounded wording to "
|
||||
"reduce sudden interaction swings."
|
||||
),
|
||||
}
|
||||
)
|
||||
if (
|
||||
latest_snapshot.reciprocity_score is not None
|
||||
and latest_snapshot.reciprocity_score <= 35
|
||||
):
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "reciprocity_drop",
|
||||
"label": "Reciprocity Drop",
|
||||
"severity": "medium",
|
||||
"explanation": (
|
||||
"Reciprocity is low. Add corrections that request and "
|
||||
"acknowledge balanced turn-taking."
|
||||
),
|
||||
}
|
||||
)
|
||||
if (
|
||||
latest_snapshot.response_score is not None
|
||||
and latest_snapshot.response_score <= 35
|
||||
):
|
||||
risk_signals.append(
|
||||
{
|
||||
"key": "response_drag",
|
||||
"label": "Response Drag",
|
||||
"severity": "medium",
|
||||
"explanation": (
|
||||
"Response pace is slow. Prefer corrections that set timing "
|
||||
"expectations and explicit follow-up windows."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
state_label = (
|
||||
conversation.get_stability_state_display()
|
||||
if hasattr(conversation, "get_stability_state_display")
|
||||
else str(conversation.stability_state or "")
|
||||
)
|
||||
return {
|
||||
"stability": {
|
||||
"state": state_label,
|
||||
"score": conversation.stability_score,
|
||||
"confidence": confidence,
|
||||
"sample_messages": conversation.stability_sample_messages,
|
||||
"sample_days": conversation.stability_sample_days,
|
||||
"computed_at": conversation.stability_last_computed_at,
|
||||
},
|
||||
"commitment": {
|
||||
"inbound": conversation.commitment_inbound_score,
|
||||
"outbound": conversation.commitment_outbound_score,
|
||||
"confidence": conversation.commitment_confidence,
|
||||
"computed_at": conversation.commitment_last_computed_at,
|
||||
"directionality": directionality,
|
||||
},
|
||||
"components": (
|
||||
{
|
||||
"reciprocity": latest_snapshot.reciprocity_score,
|
||||
"continuity": latest_snapshot.continuity_score,
|
||||
"response": latest_snapshot.response_score,
|
||||
"volatility": latest_snapshot.volatility_score,
|
||||
"inbound_response": latest_snapshot.inbound_response_score,
|
||||
"outbound_response": latest_snapshot.outbound_response_score,
|
||||
"balance_inbound": latest_snapshot.balance_inbound_score,
|
||||
"balance_outbound": latest_snapshot.balance_outbound_score,
|
||||
"source_event_ts": latest_snapshot.source_event_ts,
|
||||
}
|
||||
if latest_snapshot
|
||||
else {}
|
||||
),
|
||||
"risk_signals": risk_signals[:8],
|
||||
}
|
||||
|
||||
|
||||
def _store_metric_snapshot(conversation, payload):
|
||||
compare_keys = [
|
||||
"source_event_ts",
|
||||
@@ -1476,6 +1779,7 @@ def _build_mitigation_artifacts(
|
||||
inspiration,
|
||||
fundamentals,
|
||||
output_profile,
|
||||
metric_context=None,
|
||||
):
|
||||
fallback = _default_artifacts_from_patterns(source_text, person, output_profile)
|
||||
|
||||
@@ -1489,11 +1793,15 @@ def _build_mitigation_artifacts(
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You design practical relationship mitigation protocols. "
|
||||
"Return strict JSON only with keys: title, objective, fundamental_items, rules, games. "
|
||||
"Return strict JSON only with keys: title, objective, "
|
||||
"fundamental_items, rules, games, corrections. "
|
||||
"Each rule item must have title and content. "
|
||||
"Each game item must have title and instructions. "
|
||||
"Each correction item must have title and clarification. "
|
||||
"If mode is auto, choose strongest artifacts. If mode is guided, strongly follow inspiration. "
|
||||
"Output profile controls emphasis: framework (balanced), rules (rules-first), games (games-first)."
|
||||
"Use provided metrics as risk context to tighten corrections. "
|
||||
"Output profile controls emphasis: framework (balanced), "
|
||||
"rules (rules-first), games (games-first)."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -1504,6 +1812,8 @@ def _build_mitigation_artifacts(
|
||||
f"Output profile: {output_profile}\n"
|
||||
f"User inspiration: {inspiration or 'None'}\n"
|
||||
f"Fundamental items (pre-agreed): {json.dumps(fundamentals)}\n\n"
|
||||
"Metric context:\n"
|
||||
f"{json.dumps(metric_context or {}, ensure_ascii=False, default=str)}\n\n"
|
||||
f"Pattern analysis:\n{source_text}"
|
||||
),
|
||||
},
|
||||
@@ -1551,6 +1861,28 @@ def _build_mitigation_artifacts(
|
||||
if title_i and instructions_i:
|
||||
games.append({"title": title_i, "instructions": instructions_i})
|
||||
|
||||
raw_corrections = parsed.get("corrections")
|
||||
corrections = []
|
||||
if isinstance(raw_corrections, list):
|
||||
for item in raw_corrections:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title_i = _normalize_correction_title(
|
||||
item.get("title") or "", fallback="Correction"
|
||||
)
|
||||
clarification_i = str(
|
||||
item.get("clarification") or item.get("content") or ""
|
||||
).strip()
|
||||
source_phrase_i = str(item.get("source_phrase") or "").strip()
|
||||
if title_i and clarification_i:
|
||||
corrections.append(
|
||||
{
|
||||
"title": title_i[:255],
|
||||
"clarification": clarification_i[:2000],
|
||||
"source_phrase": source_phrase_i[:1000],
|
||||
}
|
||||
)
|
||||
|
||||
if not rules:
|
||||
rules = fallback["rules"]
|
||||
if not games:
|
||||
@@ -1563,7 +1895,7 @@ def _build_mitigation_artifacts(
|
||||
"fundamental_items": merged_fundamentals,
|
||||
"rules": rules,
|
||||
"games": games,
|
||||
"corrections": [],
|
||||
"corrections": _normalize_violation_items(corrections),
|
||||
}
|
||||
|
||||
|
||||
@@ -2342,6 +2674,74 @@ def _get_or_create_auto_settings(user, conversation):
|
||||
return settings_obj
|
||||
|
||||
|
||||
def _metric_guided_artifact_candidates(plan, metric_context):
|
||||
signals = list((metric_context or {}).get("risk_signals") or [])
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
artifacts = []
|
||||
for rule in plan.rules.filter(enabled=True).order_by("created_at")[:10]:
|
||||
artifacts.append(
|
||||
{
|
||||
"kind": "rule",
|
||||
"title": str(rule.title or "").strip(),
|
||||
"body": str(rule.content or "").strip(),
|
||||
}
|
||||
)
|
||||
for game in plan.games.filter(enabled=True).order_by("created_at")[:10]:
|
||||
artifacts.append(
|
||||
{
|
||||
"kind": "game",
|
||||
"title": str(game.title or "").strip(),
|
||||
"body": str(game.instructions or "").strip(),
|
||||
}
|
||||
)
|
||||
if not artifacts:
|
||||
for item in (plan.fundamental_items or [])[:10]:
|
||||
text = str(item or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
artifacts.append(
|
||||
{
|
||||
"kind": "fundamental",
|
||||
"title": text[:100],
|
||||
"body": text,
|
||||
}
|
||||
)
|
||||
if not artifacts:
|
||||
return []
|
||||
|
||||
out = []
|
||||
for idx, signal in enumerate(signals[:8]):
|
||||
artifact = artifacts[idx % len(artifacts)]
|
||||
kind_label = {
|
||||
"rule": "Rule",
|
||||
"game": "Game",
|
||||
"fundamental": "Fundamental",
|
||||
}.get(artifact["kind"], "Artifact")
|
||||
title = _normalize_correction_title(
|
||||
f"{signal.get('label') or 'Metric Signal'} Safeguard"
|
||||
)
|
||||
clarification = (
|
||||
f"{str(signal.get('explanation') or '').strip()} "
|
||||
f"Apply {kind_label.lower()} '{artifact['title']}' in the next exchange: "
|
||||
f"{artifact['body']}"
|
||||
).strip()
|
||||
source_phrase = (
|
||||
f"Metric signal: {signal.get('label') or 'Metric Signal'}; "
|
||||
f"Artifact: {kind_label} '{artifact['title']}'"
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"title": title,
|
||||
"source_phrase": source_phrase[:1000],
|
||||
"clarification": clarification[:2000],
|
||||
"severity": str(signal.get("severity") or "medium"),
|
||||
}
|
||||
)
|
||||
return _normalize_violation_items(out)
|
||||
|
||||
|
||||
def _detect_violation_candidates(plan, recent_rows):
|
||||
candidates = []
|
||||
for row in recent_rows:
|
||||
@@ -2442,10 +2842,10 @@ def _existing_correction_signatures(plan, exclude_id=None):
|
||||
return signatures
|
||||
|
||||
|
||||
def _ai_detect_violations(user, plan, person, recent_rows):
|
||||
def _ai_detect_violations(user, plan, person, recent_rows, metric_context=None):
|
||||
ai_obj = AI.objects.filter(user=user).first()
|
||||
if ai_obj is None:
|
||||
return []
|
||||
return {"violations": [], "artifact_corrections": []}
|
||||
|
||||
rules_payload = [
|
||||
{"id": str(rule.id), "title": rule.title, "content": rule.content}
|
||||
@@ -2477,6 +2877,7 @@ def _ai_detect_violations(user, plan, person, recent_rows):
|
||||
"games": games_payload,
|
||||
"corrections": corrections_payload,
|
||||
},
|
||||
"metrics": metric_context or {},
|
||||
"recent_messages": recent_rows,
|
||||
"output_schema": {
|
||||
"violations": [
|
||||
@@ -2486,6 +2887,14 @@ def _ai_detect_violations(user, plan, person, recent_rows):
|
||||
"clarification": "correction-style guidance",
|
||||
"severity": "low|medium|high",
|
||||
}
|
||||
],
|
||||
"artifact_corrections": [
|
||||
{
|
||||
"title": "short string",
|
||||
"source_phrase": "artifact reference + metric rationale",
|
||||
"clarification": "proactive correction mapped to an artifact",
|
||||
"severity": "low|medium|high",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -2494,22 +2903,31 @@ def _ai_detect_violations(user, plan, person, recent_rows):
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You detect violations of mitigation patterns in a conversation. "
|
||||
"Use recent_messages for direct violations. "
|
||||
"Use plan artifacts plus metrics for proactive artifact_corrections. "
|
||||
"Return strict JSON only. No markdown. No prose wrapper. "
|
||||
"Use only schema keys requested."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(source_payload, ensure_ascii=False),
|
||||
"content": json.dumps(source_payload, ensure_ascii=False, default=str),
|
||||
},
|
||||
]
|
||||
try:
|
||||
raw = async_to_sync(ai_runner.run_prompt)(prompt, ai_obj)
|
||||
except Exception:
|
||||
return []
|
||||
return {"violations": [], "artifact_corrections": []}
|
||||
|
||||
parsed = _extract_json_object(raw) or {}
|
||||
return _normalize_violation_items(parsed.get("violations") or [])
|
||||
return {
|
||||
"violations": _normalize_violation_items(parsed.get("violations") or []),
|
||||
"artifact_corrections": _normalize_violation_items(
|
||||
parsed.get("artifact_corrections")
|
||||
or parsed.get("artifact_based_corrections")
|
||||
or []
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _maybe_send_auto_notification(user, auto_settings, title, body):
|
||||
@@ -2563,6 +2981,8 @@ def _run_auto_analysis_for_plan(
|
||||
}
|
||||
|
||||
limit = max(10, min(int(auto_settings.sample_message_window or 40), 200))
|
||||
_refresh_conversation_stability(conversation, user, person)
|
||||
metric_context = _metric_pattern_context(conversation)
|
||||
sessions = ChatSession.objects.filter(user=user, identifier__person=person)
|
||||
messages = (
|
||||
Message.objects.filter(user=user, session__in=sessions)
|
||||
@@ -2609,9 +3029,25 @@ def _run_auto_analysis_for_plan(
|
||||
"notified": False,
|
||||
}
|
||||
|
||||
ai_candidates = _ai_detect_violations(user, plan, person, recent_rows)
|
||||
ai_detection = _ai_detect_violations(
|
||||
user,
|
||||
plan,
|
||||
person,
|
||||
recent_rows,
|
||||
metric_context=metric_context,
|
||||
)
|
||||
ai_candidates = list(ai_detection.get("violations") or [])
|
||||
artifact_candidates_ai = list(ai_detection.get("artifact_corrections") or [])
|
||||
heuristic_candidates = _detect_violation_candidates(plan, recent_rows)
|
||||
violations = _normalize_violation_items(ai_candidates + heuristic_candidates)
|
||||
artifact_candidates_metric = _metric_guided_artifact_candidates(
|
||||
plan, metric_context
|
||||
)
|
||||
violations = _normalize_violation_items(
|
||||
ai_candidates
|
||||
+ heuristic_candidates
|
||||
+ artifact_candidates_ai
|
||||
+ artifact_candidates_metric
|
||||
)
|
||||
|
||||
created_corrections = 0
|
||||
if auto_settings.auto_create_corrections and violations:
|
||||
@@ -2847,40 +3283,6 @@ class AIWorkspaceContactsWidget(LoginRequiredMixin, View):
|
||||
class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
allowed_types = {"widget"}
|
||||
|
||||
def _message_rows(self, user, person, limit):
|
||||
sessions = ChatSession.objects.filter(user=user, identifier__person=person)
|
||||
identifiers = set(
|
||||
PersonIdentifier.objects.filter(user=user, person=person).values_list(
|
||||
"identifier", flat=True
|
||||
)
|
||||
)
|
||||
messages = (
|
||||
Message.objects.filter(user=user, session__in=sessions)
|
||||
.select_related("session", "session__identifier")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
|
||||
rows = []
|
||||
for message in reversed(list(messages)):
|
||||
inferred_direction = _infer_direction(message, identifiers)
|
||||
rows.append(
|
||||
{
|
||||
"message": message,
|
||||
"direction": inferred_direction,
|
||||
"ts_label": _format_unix_ms(message.ts),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def _recent_messages(self, user, person, limit):
|
||||
sessions = ChatSession.objects.filter(user=user, identifier__person=person)
|
||||
messages = (
|
||||
Message.objects.filter(user=user, session__in=sessions)
|
||||
.select_related("session", "session__identifier")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
return list(reversed(list(messages)))
|
||||
|
||||
def get(self, request, type, person_id):
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
@@ -2894,14 +3296,13 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
limit = max(5, min(limit, 200))
|
||||
|
||||
context = {
|
||||
"title": f"{person.name} Timeline",
|
||||
"title": f"{person.name} AI",
|
||||
"unique": f"ai-person-{person.id}",
|
||||
"window_content": "partials/ai-workspace-person-widget.html",
|
||||
"widget_options": 'gs-w="7" gs-h="16" gs-x="0" gs-y="0" gs-min-w="4"',
|
||||
"widget_options": 'gs-w="8" gs-h="11" gs-x="4" gs-y="0" gs-min-w="4"',
|
||||
"person": person,
|
||||
"workspace_conversation": conversation,
|
||||
"limit": limit,
|
||||
"message_rows": self._message_rows(request.user, person, limit),
|
||||
"ai_operations": [
|
||||
("artifacts", "Plan"),
|
||||
("summarise", "Summary"),
|
||||
@@ -2915,6 +3316,32 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
|
||||
class AIWorkspacePersonTimelineWidget(LoginRequiredMixin, View):
|
||||
allowed_types = {"widget"}
|
||||
|
||||
def get(self, request, type, person_id):
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
try:
|
||||
limit = int(request.GET.get("limit", 20))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
limit = max(5, min(limit, 200))
|
||||
|
||||
context = {
|
||||
"title": f"{person.name} Timeline",
|
||||
"unique": f"ai-timeline-{person.id}",
|
||||
"window_content": "partials/ai-workspace-person-timeline-widget.html",
|
||||
"widget_options": 'gs-w="8" gs-h="10" gs-x="4" gs-y="11" gs-min-w="4"',
|
||||
"person": person,
|
||||
"limit": limit,
|
||||
"message_rows": _message_rows_for_person(request.user, person, limit),
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
|
||||
class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
||||
allowed_types = {"page", "widget"}
|
||||
|
||||
@@ -2953,6 +3380,10 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
||||
"ai_workspace_insight_help",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"information_url": reverse(
|
||||
"ai_workspace_information",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"workspace_url": f"{reverse('ai_workspace')}?person={person.id}",
|
||||
}
|
||||
return render(request, "pages/ai-workspace-insight-detail.html", context)
|
||||
@@ -2976,11 +3407,62 @@ class AIWorkspaceInsightGraphs(LoginRequiredMixin, View):
|
||||
"ai_workspace_insight_help",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"information_url": reverse(
|
||||
"ai_workspace_information",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"workspace_url": f"{reverse('ai_workspace')}?person={person.id}",
|
||||
}
|
||||
return render(request, "pages/ai-workspace-insight-graphs.html", context)
|
||||
|
||||
|
||||
class AIWorkspaceInformation(LoginRequiredMixin, View):
|
||||
allowed_types = {"page", "widget"}
|
||||
|
||||
def get(self, request, type, person_id):
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
conversation = _conversation_for_person(request.user, person)
|
||||
latest_snapshot = conversation.metric_snapshots.first()
|
||||
directionality = _commitment_directionality_payload(conversation)
|
||||
commitment_graph_cards = [
|
||||
card for card in _all_graph_payload(conversation) if card["group"] == "commitment"
|
||||
]
|
||||
|
||||
graph_refs = []
|
||||
for ref in directionality.get("graph_refs", []):
|
||||
slug = ref.get("slug")
|
||||
if not slug:
|
||||
continue
|
||||
graph_refs.append(
|
||||
{
|
||||
**ref,
|
||||
"slug": slug,
|
||||
"value": _format_metric_value(conversation, slug, latest_snapshot),
|
||||
}
|
||||
)
|
||||
directionality["graph_refs"] = graph_refs
|
||||
|
||||
context = {
|
||||
"person": person,
|
||||
"workspace_conversation": conversation,
|
||||
"directionality": directionality,
|
||||
"commitment_graph_cards": commitment_graph_cards,
|
||||
"graphs_url": reverse(
|
||||
"ai_workspace_insight_graphs",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"help_url": reverse(
|
||||
"ai_workspace_insight_help",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"workspace_url": f"{reverse('ai_workspace')}?person={person.id}",
|
||||
}
|
||||
return render(request, "pages/ai-workspace-information.html", context)
|
||||
|
||||
|
||||
class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
||||
allowed_types = {"page", "widget"}
|
||||
|
||||
@@ -3018,6 +3500,10 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
||||
"ai_workspace_insight_graphs",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"information_url": reverse(
|
||||
"ai_workspace_information",
|
||||
kwargs={"type": "page", "person_id": person.id},
|
||||
),
|
||||
"workspace_url": f"{reverse('ai_workspace')}?person={person.id}",
|
||||
}
|
||||
return render(request, "pages/ai-workspace-insight-help.html", context)
|
||||
@@ -3164,7 +3650,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
and auto_settings.enabled
|
||||
and auto_settings.auto_create_mitigation
|
||||
):
|
||||
recent_messages = AIWorkspacePersonWidget()._recent_messages(
|
||||
recent_messages = _recent_messages_for_person(
|
||||
request.user,
|
||||
person,
|
||||
max(20, min(auto_settings.sample_message_window, 200)),
|
||||
@@ -3609,6 +4095,8 @@ class AIWorkspaceCreateMitigation(LoginRequiredMixin, View):
|
||||
else _conversation_for_person(request.user, person)
|
||||
)
|
||||
conversation.participants.add(person)
|
||||
_refresh_conversation_stability(conversation, request.user, person)
|
||||
metric_context = _metric_pattern_context(conversation)
|
||||
|
||||
source_text = ""
|
||||
if source_result is not None:
|
||||
@@ -3625,6 +4113,7 @@ class AIWorkspaceCreateMitigation(LoginRequiredMixin, View):
|
||||
inspiration=user_context,
|
||||
fundamentals=fundamentals,
|
||||
output_profile=output_profile,
|
||||
metric_context=metric_context,
|
||||
)
|
||||
# Deterministically seed from pasted context so long-form frameworks can
|
||||
# create fundamentals/rules/games in one pass, even when AI output is sparse.
|
||||
@@ -3657,6 +4146,31 @@ class AIWorkspaceCreateMitigation(LoginRequiredMixin, View):
|
||||
instructions=str(game.get("instructions") or "").strip(),
|
||||
)
|
||||
|
||||
existing_signatures = set()
|
||||
for correction in artifacts.get("corrections", []):
|
||||
title = _normalize_correction_title(
|
||||
correction.get("title") or "", fallback="Correction"
|
||||
)
|
||||
clarification = str(correction.get("clarification") or "").strip()
|
||||
source_phrase = str(correction.get("source_phrase") or "").strip()
|
||||
if not clarification:
|
||||
continue
|
||||
signature = _correction_signature(title, clarification)
|
||||
if signature in existing_signatures:
|
||||
continue
|
||||
PatternMitigationCorrection.objects.create(
|
||||
user=request.user,
|
||||
plan=plan,
|
||||
title=title[:255],
|
||||
clarification=clarification[:2000],
|
||||
source_phrase=source_phrase[:1000],
|
||||
perspective="second_person",
|
||||
share_target="both",
|
||||
language_style="adapted",
|
||||
enabled=True,
|
||||
)
|
||||
existing_signatures.add(signature)
|
||||
|
||||
PatternMitigationMessage.objects.create(
|
||||
user=request.user,
|
||||
plan=plan,
|
||||
|
||||
Reference in New Issue
Block a user