Improve and condense related controls

This commit is contained in:
2026-02-15 22:11:17 +00:00
parent ae3365e165
commit 981ee56de7
18 changed files with 1340 additions and 209 deletions

View File

@@ -24,6 +24,7 @@ from core.models import (
AI,
ChatSession,
Message,
MessageEvent,
PatternMitigationPlan,
Person,
PersonIdentifier,
@@ -46,6 +47,12 @@ IMAGE_EXTENSIONS = (
".avif",
".svg",
)
EMPTY_TEXT_VALUES = {
"",
"[No Body]",
"[no body]",
"(no text)",
}
def _uniq_ordered(values):
@@ -144,6 +151,122 @@ def _image_urls_from_text(text_value: str) -> list[str]:
return []
def _looks_like_image_name(name_value: str) -> bool:
value = str(name_value or "").strip().lower()
return bool(value) and value.endswith(IMAGE_EXTENSIONS)
def _extract_attachment_image_urls(blob) -> list[str]:
urls = []
if isinstance(blob, str):
normalized = _clean_url(blob)
if normalized and _looks_like_image_url(normalized):
urls.append(normalized)
return urls
if isinstance(blob, dict):
content_type = str(
blob.get("content_type")
or blob.get("contentType")
or blob.get("mime_type")
or blob.get("mimetype")
or ""
).strip().lower()
filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
image_hint = content_type.startswith("image/") or _looks_like_image_name(filename)
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
normalized = _clean_url(blob.get(key))
if not normalized:
continue
if image_hint or _looks_like_image_url(normalized):
urls.append(normalized)
nested = blob.get("attachments")
if isinstance(nested, list):
for row in nested:
urls.extend(_extract_attachment_image_urls(row))
return urls
if isinstance(blob, list):
for row in blob:
urls.extend(_extract_attachment_image_urls(row))
return urls
def _attachment_image_urls_by_message(messages):
rows = list(messages or [])
if not rows:
return {}
by_message = {}
unresolved = []
for msg in rows:
text_value = str(msg.text or "").strip()
if text_value and text_value not in EMPTY_TEXT_VALUES:
continue
unresolved.append(msg)
if not unresolved:
return by_message
legacy_ids = [str(msg.id) for msg in unresolved]
linked_events = MessageEvent.objects.filter(
user=rows[0].user,
raw_payload_ref__legacy_message_id__in=legacy_ids,
).order_by("ts")
for event in linked_events:
legacy_id = str((event.raw_payload_ref or {}).get("legacy_message_id") or "").strip()
if not legacy_id:
continue
urls = _uniq_ordered(
_extract_attachment_image_urls(event.attachments)
+ _extract_attachment_image_urls(event.raw_payload_ref or {})
)
if urls:
by_message.setdefault(legacy_id, urls)
missing = [msg for msg in unresolved if str(msg.id) not in by_message]
if not missing:
return by_message
min_ts = min(int(msg.ts or 0) for msg in missing) - 3000
max_ts = max(int(msg.ts or 0) for msg in missing) + 3000
fallback_events = (
MessageEvent.objects.filter(
user=rows[0].user,
source_system="xmpp",
ts__gte=min_ts,
ts__lte=max_ts,
)
.exclude(attachments=[])
.order_by("ts")
)
fallback_list = list(fallback_events)
for msg in missing:
if str(msg.id) in by_message:
continue
msg_ts = int(msg.ts or 0)
candidates = [
event
for event in fallback_list
if abs(int(event.ts or 0) - msg_ts) <= 3000
]
if not candidates:
continue
event = candidates[0]
urls = _uniq_ordered(
_extract_attachment_image_urls(event.attachments)
+ _extract_attachment_image_urls(event.raw_payload_ref or {})
)
if urls:
by_message[str(msg.id)] = urls
return by_message
def _serialize_message(msg: Message) -> dict:
text_value = str(msg.text or "")
image_urls = _image_urls_from_text(text_value)
@@ -448,6 +571,21 @@ def _serialize_messages_with_artifacts(
):
rows = list(messages or [])
serialized = [_serialize_message(msg) for msg in rows]
attachment_images = _attachment_image_urls_by_message(rows)
for idx, msg in enumerate(rows):
item = serialized[idx]
if item.get("image_urls"):
continue
recovered = _uniq_ordered(attachment_images.get(str(msg.id)) or [])
if not recovered:
continue
item["image_urls"] = recovered
item["image_url"] = recovered[0]
text_value = str(msg.text or "").strip()
if text_value in EMPTY_TEXT_VALUES:
item["hide_text"] = True
item["display_text"] = ""
for item in serialized:
item["gap_fragments"] = []
item["metric_fragments"] = []
@@ -815,6 +953,7 @@ def _quick_insights_rows(conversation):
{
"key": "stability_score",
"label": "Stability Score",
"doc_slug": "stability_score",
"field": "stability_score",
"source": "conversation",
"kind": "score",
@@ -824,6 +963,7 @@ def _quick_insights_rows(conversation):
{
"key": "stability_confidence",
"label": "Stability Confidence",
"doc_slug": "stability_confidence",
"field": "stability_confidence",
"source": "conversation",
"kind": "confidence",
@@ -833,6 +973,7 @@ def _quick_insights_rows(conversation):
{
"key": "sample_messages",
"label": "Sample Messages",
"doc_slug": "sample_messages",
"field": "stability_sample_messages",
"source": "conversation",
"kind": "count",
@@ -842,6 +983,7 @@ def _quick_insights_rows(conversation):
{
"key": "sample_days",
"label": "Sample Days",
"doc_slug": "sample_days",
"field": "stability_sample_days",
"source": "conversation",
"kind": "count",
@@ -851,6 +993,7 @@ def _quick_insights_rows(conversation):
{
"key": "commitment_inbound",
"label": "Commit In",
"doc_slug": "commitment_inbound",
"field": "commitment_inbound_score",
"source": "conversation",
"kind": "score",
@@ -860,6 +1003,7 @@ def _quick_insights_rows(conversation):
{
"key": "commitment_outbound",
"label": "Commit Out",
"doc_slug": "commitment_outbound",
"field": "commitment_outbound_score",
"source": "conversation",
"kind": "score",
@@ -869,6 +1013,7 @@ def _quick_insights_rows(conversation):
{
"key": "commitment_confidence",
"label": "Commit Confidence",
"doc_slug": "commitment_confidence",
"field": "commitment_confidence",
"source": "conversation",
"kind": "confidence",
@@ -878,6 +1023,7 @@ def _quick_insights_rows(conversation):
{
"key": "reciprocity",
"label": "Reciprocity",
"doc_slug": "reciprocity_score",
"field": "reciprocity_score",
"source": "snapshot",
"kind": "score",
@@ -887,6 +1033,7 @@ def _quick_insights_rows(conversation):
{
"key": "continuity",
"label": "Continuity",
"doc_slug": "continuity_score",
"field": "continuity_score",
"source": "snapshot",
"kind": "score",
@@ -896,6 +1043,7 @@ def _quick_insights_rows(conversation):
{
"key": "response",
"label": "Response",
"doc_slug": "response_score",
"field": "response_score",
"source": "snapshot",
"kind": "score",
@@ -905,6 +1053,7 @@ def _quick_insights_rows(conversation):
{
"key": "volatility",
"label": "Volatility",
"doc_slug": "volatility_score",
"field": "volatility_score",
"source": "snapshot",
"kind": "score",
@@ -914,6 +1063,7 @@ def _quick_insights_rows(conversation):
{
"key": "inbound_messages",
"label": "Inbound Messages",
"doc_slug": "inbound_messages",
"field": "inbound_messages",
"source": "snapshot",
"kind": "count",
@@ -923,6 +1073,7 @@ def _quick_insights_rows(conversation):
{
"key": "outbound_messages",
"label": "Outbound Messages",
"doc_slug": "outbound_messages",
"field": "outbound_messages",
"source": "snapshot",
"kind": "count",
@@ -933,6 +1084,7 @@ def _quick_insights_rows(conversation):
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
@@ -964,6 +1116,8 @@ def _quick_insights_rows(conversation):
"point_count": point_count,
"trend": trend,
"emotion": emotion,
"calculation": metric_copy.get("calculation") or "",
"psychology": metric_copy.get("psychology") or "",
}
)
return {
@@ -1092,14 +1246,22 @@ def _engage_source_from_ref(plan, source_ref):
def _context_base(user, service, identifier, person):
person_identifier = None
if person is not None:
person_identifier = (
PersonIdentifier.objects.filter(
if identifier:
person_identifier = PersonIdentifier.objects.filter(
user=user,
person=person,
service=service,
identifier=identifier,
).first()
or PersonIdentifier.objects.filter(user=user, person=person).first()
)
if person_identifier is None:
person_identifier = (
PersonIdentifier.objects.filter(
user=user,
person=person,
service=service,
).first()
or PersonIdentifier.objects.filter(user=user, person=person).first()
)
if person_identifier is None and identifier:
person_identifier = PersonIdentifier.objects.filter(
user=user,
@@ -1190,7 +1352,9 @@ def _panel_context(
)
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique_raw = (
f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
)
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
typing_state = get_person_typing_state(
user_id=request.user.id,
@@ -1228,6 +1392,14 @@ def _panel_context(
if base["person"]
else reverse("ai_workspace")
),
"ai_workspace_widget_url": (
(
f"{reverse('ai_workspace_person', kwargs={'type': 'widget', 'person_id': base['person'].id})}"
f"?{urlencode({'limit': limit})}"
)
if base["person"]
else ""
),
"manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state),
@@ -1635,9 +1807,21 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"thread": "",
"last_event": "",
"last_ai_run": "",
"workspace_created": "",
"snapshot_count": 0,
"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.",
@@ -1673,6 +1857,18 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
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": [

View File

@@ -45,6 +45,14 @@ def _preferred_related_text_field(model: type[models.Model]) -> str | None:
return None
def _column_field_name(column: "OsintColumn") -> str:
if column.search_lookup:
return str(column.search_lookup).split("__", 1)[0]
if column.sort_field:
return str(column.sort_field).split("__", 1)[0]
return str(column.key)
def _url_with_query(base_url: str, query: dict[str, Any]) -> str:
params = {}
for key, value in query.items():
@@ -465,6 +473,8 @@ class OSINTListBase(ObjectList):
if not column.sort_field:
columns.append(
{
"key": column.key,
"field_name": _column_field_name(column),
"label": column.label,
"sortable": False,
"kind": column.kind,
@@ -482,6 +492,8 @@ class OSINTListBase(ObjectList):
)
columns.append(
{
"key": column.key,
"field_name": _column_field_name(column),
"label": column.label,
"sortable": True,
"kind": column.kind,
@@ -815,6 +827,8 @@ class OSINTSearch(LoginRequiredMixin, View):
if not column.sort_field:
columns.append(
{
"key": column.key,
"field_name": _column_field_name(column),
"label": column.label,
"sortable": False,
"kind": column.kind,
@@ -832,6 +846,8 @@ class OSINTSearch(LoginRequiredMixin, View):
)
columns.append(
{
"key": column.key,
"field_name": _column_field_name(column),
"label": column.label,
"sortable": True,
"kind": column.kind,
@@ -1019,3 +1035,51 @@ class OSINTSearch(LoginRequiredMixin, View):
return render(request, self.widget_template, widget_context)
return render(request, self.page_template, context)
class OSINTWorkspace(LoginRequiredMixin, View):
template_name = "pages/osint-workspace.html"
def get(self, request):
context = {
"tabs_widget_url": reverse("osint_workspace_tabs_widget"),
}
return render(request, self.template_name, context)
class OSINTWorkspaceTabsWidget(LoginRequiredMixin, View):
def get(self, request):
tabs = [
{
"key": "people",
"label": "People",
"icon": "fa-solid fa-user-group",
"widget_url": reverse("people", kwargs={"type": "widget"}),
},
{
"key": "groups",
"label": "Groups",
"icon": "fa-solid fa-users",
"widget_url": reverse("groups", kwargs={"type": "widget"}),
},
{
"key": "personas",
"label": "Personas",
"icon": "fa-solid fa-masks-theater",
"widget_url": reverse("personas", kwargs={"type": "widget"}),
},
{
"key": "manipulations",
"label": "Manipulations",
"icon": "fa-solid fa-sliders",
"widget_url": reverse("manipulations", kwargs={"type": "widget"}),
},
]
context = {
"title": "OSINT Workspace",
"unique": "osint-workspace-tabs",
"window_content": "partials/osint-workspace-tabs-widget.html",
"widget_options": 'gs-w="12" gs-h="4" gs-x="0" gs-y="0" gs-min-w="6"',
"tabs": tabs,
}
return render(request, "mixins/wm/widget.html", context)

View File

@@ -1,4 +1,5 @@
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from mixins.views import ObjectList, ObjectRead
@@ -68,13 +69,40 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_url_name = "whatsapp_account_add"
detail_url_args = ["type", "device"]
def _device_name(self) -> str:
form_args = self.request.POST.dict()
return form_args.get("device", "GIA Device")
def _refresh_only(self) -> bool:
form_args = self.request.POST.dict()
return str(form_args.get("refresh") or "") == "1"
def _detail_context(self, kwargs, obj):
detail_url_args = {
arg: kwargs[arg]
for arg in self.detail_url_args
if arg in kwargs
}
return {
"object": obj,
"detail_url": reverse(self.detail_url_name, kwargs=detail_url_args),
}
def post(self, request, *args, **kwargs):
self.request = request
if self._refresh_only() and request.htmx:
obj = self.get_object(**kwargs)
return render(
request,
self.detail_template,
self._detail_context(kwargs, obj),
)
return super().get(request, *args, **kwargs)
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args.get("device", "GIA Device")
device_name = self._device_name()
if not self._refresh_only():
transport.request_pairing(self.service, device_name)
try:
image_bytes = transport.get_link_qr(self.service, device_name)
return {
@@ -83,8 +111,11 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
"warning": transport.get_service_warning(self.service),
}
except Exception as exc:
error_text = str(exc)
return {
"ok": False,
"error": str(exc),
"pending": "pairing qr" in error_text.lower(),
"device": device_name,
"error": error_text,
"warning": transport.get_service_warning(self.service),
}

View File

@@ -640,6 +640,31 @@ def _compose_page_url_for_person(user, person):
return f"{reverse('compose_page')}?{query}"
def _compose_widget_url_for_person(user, person, limit=40):
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 ""
try:
safe_limit = int(limit or 40)
except (TypeError, ValueError):
safe_limit = 40
safe_limit = max(10, min(safe_limit, 200))
query = urlencode(
{
"service": identifier_row.service,
"identifier": identifier_row.identifier,
"person": str(person.id),
"limit": safe_limit,
}
)
return f"{reverse('compose_widget')}?{query}"
def _participant_feedback_display(conversation, person):
payload = conversation.participant_feedback or {}
if not isinstance(payload, dict):
@@ -3438,6 +3463,11 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
],
"send_state": _get_send_state(request.user, person),
"compose_page_url": _compose_page_url_for_person(request.user, person),
"compose_widget_url": _compose_widget_url_for_person(
request.user,
person,
limit=limit,
),
"manual_icon_class": "fa-solid fa-paper-plane",
}
return render(request, "mixins/wm/widget.html", context)