Improve chat experience and begin search implementation
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
@@ -14,7 +17,21 @@ from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import ChatSession, Message, Person, PersonIdentifier
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
AI,
|
||||
ChatSession,
|
||||
Message,
|
||||
PatternMitigationPlan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
)
|
||||
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||
|
||||
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||
COMPOSE_AI_CACHE_TTL = 60 * 30
|
||||
|
||||
|
||||
def _default_service(service: str | None) -> str:
|
||||
@@ -64,6 +81,185 @@ def _serialize_message(msg: Message) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _owner_name(user) -> str:
|
||||
return (
|
||||
user.first_name
|
||||
or user.get_full_name().strip()
|
||||
or user.username
|
||||
or "Me"
|
||||
)
|
||||
|
||||
|
||||
def _compose_ws_token(user_id, service, identifier, person_id):
|
||||
payload = {
|
||||
"u": int(user_id),
|
||||
"s": str(service or ""),
|
||||
"i": str(identifier or ""),
|
||||
"p": str(person_id) if person_id else "",
|
||||
"exp": int(time.time()) + (60 * 60 * 12),
|
||||
}
|
||||
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
|
||||
|
||||
|
||||
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
|
||||
raw = "|".join(
|
||||
[
|
||||
str(kind or ""),
|
||||
str(user_id),
|
||||
str(service or ""),
|
||||
str(identifier or ""),
|
||||
str(person_id or ""),
|
||||
str(last_ts or 0),
|
||||
str(limit or 0),
|
||||
]
|
||||
)
|
||||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
return f"compose:{kind}:{digest}"
|
||||
|
||||
|
||||
def _plain_text(value):
|
||||
cleaned = re.sub(r"\s+", " ", str(value or "").strip())
|
||||
cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
|
||||
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
|
||||
cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _engage_body_only(value):
|
||||
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||
if lines and lines[0].startswith("**"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[0].lower() == "guidance:":
|
||||
lines = lines[1:]
|
||||
return _plain_text(" ".join(lines))
|
||||
|
||||
|
||||
def _messages_for_ai(user, person_identifier, limit):
|
||||
if person_identifier is None:
|
||||
return []
|
||||
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
|
||||
rows = list(
|
||||
Message.objects.filter(user=user, session=session)
|
||||
.select_related("session", "session__identifier", "session__identifier__person")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
rows.reverse()
|
||||
return rows
|
||||
|
||||
|
||||
def _fallback_drafts():
|
||||
return [
|
||||
{
|
||||
"label": "Soft",
|
||||
"text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
|
||||
},
|
||||
{
|
||||
"label": "Neutral",
|
||||
"text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
|
||||
},
|
||||
{
|
||||
"label": "Firm",
|
||||
"text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_draft_prompt(owner_name, person_name, transcript):
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Generate exactly three short reply drafts for a chat. "
|
||||
"Return labels Soft, Neutral, Firm. "
|
||||
"Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
|
||||
"Each draft must be one to two sentences, plain text, no markdown."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Me: {owner_name}\n"
|
||||
f"Other: {person_name}\n"
|
||||
f"Conversation:\n{transcript}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_summary_prompt(owner_name, person_name, transcript):
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Create a concise conversation summary with three sections. "
|
||||
"Use this exact structure:\n"
|
||||
"Headlines:\n- ...\n"
|
||||
"Patterns:\n- ...\n"
|
||||
"Suggested Next Message:\n- ...\n"
|
||||
"Keep each bullet practical and specific."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Me: {owner_name}\n"
|
||||
f"Other: {person_name}\n"
|
||||
f"Conversation:\n{transcript}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_engage_prompt(owner_name, person_name, transcript):
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Write one short de-escalating outreach in shared framing. "
|
||||
"Use 'we/us/our' only. No names. One or two sentences."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Me: {owner_name}\n"
|
||||
f"Other: {person_name}\n"
|
||||
f"Conversation:\n{transcript}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _latest_plan_for_person(user, person):
|
||||
if person is None:
|
||||
return None
|
||||
conversation = (
|
||||
PatternMitigationPlan.objects.filter(
|
||||
user=user,
|
||||
conversation__participants=person,
|
||||
)
|
||||
.select_related("conversation")
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
def _best_engage_source(plan):
|
||||
if plan is None:
|
||||
return (None, "")
|
||||
correction = plan.corrections.order_by("-created_at").first()
|
||||
if correction:
|
||||
return (correction, "correction")
|
||||
rule = plan.rules.order_by("-created_at").first()
|
||||
if rule:
|
||||
return (rule, "rule")
|
||||
game = plan.games.order_by("-created_at").first()
|
||||
if game:
|
||||
return (game, "game")
|
||||
return (None, "")
|
||||
|
||||
|
||||
def _context_base(user, service, identifier, person):
|
||||
person_identifier = None
|
||||
if person is not None:
|
||||
@@ -143,6 +339,13 @@ def _panel_context(
|
||||
base["identifier"],
|
||||
base["person"].id if base["person"] else None,
|
||||
)
|
||||
ws_token = _compose_ws_token(
|
||||
user_id=request.user.id,
|
||||
service=base["service"],
|
||||
identifier=base["identifier"],
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||
|
||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
||||
@@ -164,6 +367,11 @@ def _panel_context(
|
||||
"render_mode": render_mode,
|
||||
"compose_page_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"compose_drafts_url": reverse("compose_drafts"),
|
||||
"compose_summary_url": reverse("compose_summary"),
|
||||
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||
"compose_ws_url": ws_url,
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
if base["person"]
|
||||
@@ -305,6 +513,285 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
return JsonResponse(payload)
|
||||
|
||||
|
||||
class ComposeDrafts(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)
|
||||
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||
if not messages:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"cached": False,
|
||||
"drafts": _fallback_drafts(),
|
||||
}
|
||||
)
|
||||
|
||||
last_ts = int(messages[-1].ts or 0)
|
||||
cache_key = _compose_ai_cache_key(
|
||||
"drafts",
|
||||
request.user.id,
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
base["person"].id if base["person"] else "",
|
||||
last_ts,
|
||||
limit,
|
||||
)
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return JsonResponse({"ok": True, "cached": True, "drafts": cached})
|
||||
|
||||
ai_obj = AI.objects.filter(user=request.user).first()
|
||||
transcript = messages_to_string(
|
||||
messages,
|
||||
author_rewrites={
|
||||
"USER": _owner_name(request.user),
|
||||
"BOT": "Assistant",
|
||||
},
|
||||
)
|
||||
drafts = _fallback_drafts()
|
||||
if ai_obj is not None:
|
||||
try:
|
||||
result = async_to_sync(ai_runner.run_prompt)(
|
||||
_build_draft_prompt(
|
||||
owner_name=_owner_name(request.user),
|
||||
person_name=base["person"].name if base["person"] else "Other",
|
||||
transcript=transcript,
|
||||
),
|
||||
ai_obj,
|
||||
)
|
||||
parsed = _parse_draft_options(result)
|
||||
if parsed:
|
||||
drafts = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL)
|
||||
return JsonResponse({"ok": True, "cached": False, "drafts": drafts})
|
||||
|
||||
|
||||
class ComposeSummary(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)
|
||||
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||
if not messages:
|
||||
return JsonResponse({"ok": True, "cached": False, "summary": ""})
|
||||
|
||||
last_ts = int(messages[-1].ts or 0)
|
||||
cache_key = _compose_ai_cache_key(
|
||||
"summary",
|
||||
request.user.id,
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
base["person"].id if base["person"] else "",
|
||||
last_ts,
|
||||
limit,
|
||||
)
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return JsonResponse({"ok": True, "cached": True, "summary": cached})
|
||||
|
||||
ai_obj = AI.objects.filter(user=request.user).first()
|
||||
transcript = messages_to_string(
|
||||
messages,
|
||||
author_rewrites={
|
||||
"USER": _owner_name(request.user),
|
||||
"BOT": "Assistant",
|
||||
},
|
||||
)
|
||||
if ai_obj is None:
|
||||
fallback = (
|
||||
"Headlines:\n"
|
||||
"- Conversation loaded.\n"
|
||||
"Patterns:\n"
|
||||
"- Not enough AI context configured yet.\n"
|
||||
"Suggested Next Message:\n"
|
||||
"- I want us to keep this clear and constructive."
|
||||
)
|
||||
cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
|
||||
return JsonResponse({"ok": True, "cached": False, "summary": fallback})
|
||||
|
||||
try:
|
||||
summary = async_to_sync(ai_runner.run_prompt)(
|
||||
_build_summary_prompt(
|
||||
owner_name=_owner_name(request.user),
|
||||
person_name=base["person"].name if base["person"] else "Other",
|
||||
transcript=transcript,
|
||||
),
|
||||
ai_obj,
|
||||
)
|
||||
except Exception as exc:
|
||||
return JsonResponse({"ok": False, "error": str(exc)})
|
||||
|
||||
summary = str(summary or "").strip()
|
||||
cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
|
||||
return JsonResponse({"ok": True, "cached": False, "summary": summary})
|
||||
|
||||
|
||||
class ComposeEngagePreview(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)
|
||||
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||
transcript = messages_to_string(
|
||||
messages,
|
||||
author_rewrites={
|
||||
"USER": _owner_name(request.user),
|
||||
"BOT": "Assistant",
|
||||
},
|
||||
)
|
||||
|
||||
owner_name = _owner_name(request.user)
|
||||
recipient_name = base["person"].name if base["person"] else "Other"
|
||||
plan = _latest_plan_for_person(request.user, base["person"])
|
||||
source_obj, source_kind = _best_engage_source(plan)
|
||||
|
||||
preview = ""
|
||||
outbound = ""
|
||||
artifact_label = "AI-generated"
|
||||
if source_obj is not None:
|
||||
payload = _build_engage_payload(
|
||||
source_obj=source_obj,
|
||||
source_kind=source_kind,
|
||||
share_target="other",
|
||||
framing="shared",
|
||||
context_note="",
|
||||
owner_name=owner_name,
|
||||
recipient_name=recipient_name,
|
||||
)
|
||||
preview = str(payload.get("preview") or "").strip()
|
||||
outbound = _engage_body_only(payload.get("outbound") or "")
|
||||
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
|
||||
else:
|
||||
ai_obj = AI.objects.filter(user=request.user).first()
|
||||
if ai_obj is not None:
|
||||
try:
|
||||
generated = async_to_sync(ai_runner.run_prompt)(
|
||||
_build_engage_prompt(owner_name, recipient_name, transcript),
|
||||
ai_obj,
|
||||
)
|
||||
outbound = _plain_text(generated)
|
||||
except Exception:
|
||||
outbound = ""
|
||||
if not outbound:
|
||||
outbound = (
|
||||
"We should slow down, clarify what we mean, and respond with care."
|
||||
)
|
||||
preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"
|
||||
|
||||
token = signing.dumps(
|
||||
{
|
||||
"u": request.user.id,
|
||||
"s": base["service"],
|
||||
"i": base["identifier"],
|
||||
"p": str(base["person"].id) if base["person"] else "",
|
||||
"outbound": outbound,
|
||||
"exp": int(time.time()) + (60 * 10),
|
||||
},
|
||||
salt=COMPOSE_ENGAGE_TOKEN_SALT,
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"preview": preview,
|
||||
"outbound": outbound,
|
||||
"token": token,
|
||||
"artifact": artifact_label,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ComposeEngageSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
identifier = str(request.POST.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.POST.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."})
|
||||
|
||||
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "Enable both send safety switches first."}
|
||||
)
|
||||
|
||||
token = str(request.POST.get("engage_token") or "").strip()
|
||||
if not token:
|
||||
return JsonResponse({"ok": False, "error": "Missing engage token."})
|
||||
try:
|
||||
payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
|
||||
except Exception:
|
||||
return JsonResponse({"ok": False, "error": "Invalid engage token."})
|
||||
|
||||
if int(payload.get("u") or 0) != int(request.user.id):
|
||||
return JsonResponse({"ok": False, "error": "Token does not match user."})
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
return JsonResponse({"ok": False, "error": "Engage token expired."})
|
||||
outbound = str(payload.get("outbound") or "").strip()
|
||||
if not outbound:
|
||||
return JsonResponse({"ok": False, "error": "Empty engage payload."})
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
ts = async_to_sync(transport.send_message_raw)(
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
text=outbound,
|
||||
attachments=[],
|
||||
)
|
||||
if not ts:
|
||||
return JsonResponse({"ok": False, "error": "Send failed."})
|
||||
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
||||
Message.objects.create(
|
||||
user=request.user,
|
||||
session=session,
|
||||
sender_uuid="",
|
||||
text=outbound,
|
||||
ts=ts_value,
|
||||
delivered_ts=ts_value if str(ts).isdigit() else None,
|
||||
custom_author="USER",
|
||||
)
|
||||
|
||||
return JsonResponse({"ok": True, "message": "Shared engage sent."})
|
||||
|
||||
|
||||
class ComposeSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
@@ -320,6 +807,18 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
if not identifier and person is None:
|
||||
return HttpResponseBadRequest("Missing contact identifier.")
|
||||
|
||||
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||
return render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{
|
||||
"notice_message": "Enable both send safety switches before sending.",
|
||||
"notice_level": "warning",
|
||||
},
|
||||
)
|
||||
|
||||
text = str(request.POST.get("text") or "").strip()
|
||||
if not text:
|
||||
return render(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
||||
|
||||
from core.forms import GroupForm
|
||||
from core.models import Group
|
||||
from core.views.osint import OSINTListBase
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
class GroupList(LoginRequiredMixin, ObjectList):
|
||||
list_template = "partials/group-list.html"
|
||||
class GroupList(LoginRequiredMixin, OSINTListBase):
|
||||
osint_scope = "groups"
|
||||
model = Group
|
||||
page_title = "Groups"
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
||||
|
||||
from core.forms import ManipulationForm
|
||||
from core.models import Manipulation
|
||||
from core.views.osint import OSINTListBase
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
class ManipulationList(LoginRequiredMixin, ObjectList):
|
||||
list_template = "partials/manipulation-list.html"
|
||||
class ManipulationList(LoginRequiredMixin, OSINTListBase):
|
||||
osint_scope = "manipulations"
|
||||
model = Manipulation
|
||||
page_title = "Manipulations"
|
||||
|
||||
|
||||
1013
core/views/osint.py
Normal file
1013
core/views/osint.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
||||
|
||||
from core.forms import PersonForm
|
||||
from core.models import Person
|
||||
from core.views.osint import OSINTListBase
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
class PersonList(LoginRequiredMixin, ObjectList):
|
||||
list_template = "partials/person-list.html"
|
||||
class PersonList(LoginRequiredMixin, OSINTListBase):
|
||||
osint_scope = "people"
|
||||
model = Person
|
||||
page_title = "People"
|
||||
# page_subtitle = "Add times here in order to permit trading."
|
||||
|
||||
list_url_name = "people"
|
||||
list_url_args = ["type"]
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
||||
|
||||
from core.forms import PersonaForm
|
||||
from core.models import Persona
|
||||
from core.views.osint import OSINTListBase
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
class PersonaList(LoginRequiredMixin, ObjectList):
|
||||
list_template = "partials/persona-list.html"
|
||||
class PersonaList(LoginRequiredMixin, OSINTListBase):
|
||||
osint_scope = "personas"
|
||||
model = Persona
|
||||
page_title = "Personas"
|
||||
|
||||
|
||||
@@ -1,29 +1,94 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
|
||||
class WhatsApp(Signal):
|
||||
class WhatsApp(SuperUserRequiredMixin, View):
|
||||
template_name = "pages/signal.html"
|
||||
service = "whatsapp"
|
||||
page_title = "WhatsApp"
|
||||
accounts_url_name = "whatsapp_accounts"
|
||||
|
||||
def get(self, request):
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"service": self.service,
|
||||
"service_label": self.page_title,
|
||||
"accounts_url_name": self.accounts_url_name,
|
||||
},
|
||||
)
|
||||
|
||||
class WhatsAppAccounts(SignalAccounts):
|
||||
|
||||
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
list_template = "partials/signal-accounts.html"
|
||||
service = "whatsapp"
|
||||
|
||||
context_object_name_singular = "WhatsApp Account"
|
||||
context_object_name = "WhatsApp Accounts"
|
||||
list_url_name = "whatsapp_accounts"
|
||||
list_url_args = ["type"]
|
||||
|
||||
def _normalize_accounts(self, rows):
|
||||
out = []
|
||||
for item in rows or []:
|
||||
if isinstance(item, dict):
|
||||
value = (
|
||||
item.get("number")
|
||||
or item.get("id")
|
||||
or item.get("jid")
|
||||
or item.get("account")
|
||||
)
|
||||
if value:
|
||||
out.append(str(value))
|
||||
elif item:
|
||||
out.append(str(item))
|
||||
return out
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
self.extra_context = self._service_context(
|
||||
service="whatsapp",
|
||||
label="WhatsApp",
|
||||
add_url_name="whatsapp_account_add",
|
||||
show_contact_actions=False,
|
||||
)
|
||||
self.extra_context = {
|
||||
"service": "whatsapp",
|
||||
"service_label": "WhatsApp",
|
||||
"account_add_url_name": "whatsapp_account_add",
|
||||
"show_contact_actions": False,
|
||||
"endpoint_base": str(
|
||||
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
|
||||
).rstrip("/"),
|
||||
"service_warning": transport.get_service_warning("whatsapp"),
|
||||
}
|
||||
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
||||
|
||||
|
||||
class WhatsAppAccountAdd(SignalAccountAdd):
|
||||
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
detail_template = "partials/whatsapp-account-add.html"
|
||||
service = "whatsapp"
|
||||
context_object_name_singular = "Add Account"
|
||||
context_object_name = "Add Account"
|
||||
detail_url_name = "whatsapp_account_add"
|
||||
detail_url_args = ["type", "device"]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
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")
|
||||
try:
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
return {
|
||||
"ok": True,
|
||||
"image_b64": transport.image_bytes_to_base64(image_bytes),
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
}
|
||||
|
||||
@@ -155,6 +155,55 @@ INSIGHT_METRICS = {
|
||||
"values often precede misunderstandings or withdrawal cycles."
|
||||
),
|
||||
},
|
||||
"reciprocity_score": {
|
||||
"title": "Reciprocity Component",
|
||||
"group": "stability",
|
||||
"history_field": "reciprocity_score",
|
||||
"calculation": (
|
||||
"100 * (1 - |inbound - outbound| / total_messages). Higher means "
|
||||
"more balanced participation."
|
||||
),
|
||||
"psychology": (
|
||||
"Lower reciprocity can reflect perceived asymmetry and rising pursuit/"
|
||||
"withdraw cycles."
|
||||
),
|
||||
},
|
||||
"continuity_score": {
|
||||
"title": "Continuity Component",
|
||||
"group": "stability",
|
||||
"history_field": "continuity_score",
|
||||
"calculation": (
|
||||
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
|
||||
"day-to-day continuity."
|
||||
),
|
||||
"psychology": (
|
||||
"Drops can signal communication becoming episodic or reactive."
|
||||
),
|
||||
},
|
||||
"response_score": {
|
||||
"title": "Response Component",
|
||||
"group": "stability",
|
||||
"history_field": "response_score",
|
||||
"calculation": (
|
||||
"Average of inbound and outbound response-lag scores, each mapped from "
|
||||
"median lag to a 0-100 curve."
|
||||
),
|
||||
"psychology": (
|
||||
"Lower response score can indicate delayed repair loops during tension."
|
||||
),
|
||||
},
|
||||
"volatility_score": {
|
||||
"title": "Volatility Component",
|
||||
"group": "stability",
|
||||
"history_field": "volatility_score",
|
||||
"calculation": (
|
||||
"Derived from coefficient of variation of daily message counts and "
|
||||
"inverted to a 0-100 stability signal."
|
||||
),
|
||||
"psychology": (
|
||||
"High volatility can suggest inconsistent rhythm and reduced predictability."
|
||||
),
|
||||
},
|
||||
"stability_confidence": {
|
||||
"title": "Stability Confidence",
|
||||
"group": "confidence",
|
||||
@@ -219,6 +268,52 @@ INSIGHT_METRICS = {
|
||||
"Estimates user follow-through and consistency toward the counterpart."
|
||||
),
|
||||
},
|
||||
"inbound_response_score": {
|
||||
"title": "Inbound Response Score",
|
||||
"group": "commitment",
|
||||
"history_field": "inbound_response_score",
|
||||
"calculation": (
|
||||
"Response-speed score built from median lag between user outbound and "
|
||||
"counterpart inbound replies."
|
||||
),
|
||||
"psychology": (
|
||||
"Lower values suggest delayed reciprocity from counterpart direction."
|
||||
),
|
||||
},
|
||||
"outbound_response_score": {
|
||||
"title": "Outbound Response Score",
|
||||
"group": "commitment",
|
||||
"history_field": "outbound_response_score",
|
||||
"calculation": (
|
||||
"Response-speed score built from median lag between counterpart inbound "
|
||||
"and user outbound replies."
|
||||
),
|
||||
"psychology": "Lower values suggest slower follow-through from user direction.",
|
||||
},
|
||||
"balance_inbound_score": {
|
||||
"title": "Inbound Balance Score",
|
||||
"group": "commitment",
|
||||
"history_field": "balance_inbound_score",
|
||||
"calculation": (
|
||||
"100 * min(1, inbound_messages / outbound_messages). Captures inbound "
|
||||
"participation parity."
|
||||
),
|
||||
"psychology": (
|
||||
"Lower values can indicate one-sided conversational load from user side."
|
||||
),
|
||||
},
|
||||
"balance_outbound_score": {
|
||||
"title": "Outbound Balance Score",
|
||||
"group": "commitment",
|
||||
"history_field": "balance_outbound_score",
|
||||
"calculation": (
|
||||
"100 * min(1, outbound_messages / inbound_messages). Captures outbound "
|
||||
"participation parity."
|
||||
),
|
||||
"psychology": (
|
||||
"Lower values can indicate one-sided conversational load from counterpart side."
|
||||
),
|
||||
},
|
||||
"commitment_confidence": {
|
||||
"title": "Commit Confidence",
|
||||
"group": "confidence",
|
||||
@@ -334,6 +429,78 @@ INSIGHT_GRAPH_SPECS = [
|
||||
"y_min": 0,
|
||||
"y_max": None,
|
||||
},
|
||||
{
|
||||
"slug": "reciprocity_score",
|
||||
"title": "Reciprocity Component",
|
||||
"field": "reciprocity_score",
|
||||
"group": "stability",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "continuity_score",
|
||||
"title": "Continuity Component",
|
||||
"field": "continuity_score",
|
||||
"group": "stability",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "response_score",
|
||||
"title": "Response Component",
|
||||
"field": "response_score",
|
||||
"group": "stability",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "volatility_score",
|
||||
"title": "Volatility Component",
|
||||
"field": "volatility_score",
|
||||
"group": "stability",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "inbound_response_score",
|
||||
"title": "Inbound Response Score",
|
||||
"field": "inbound_response_score",
|
||||
"group": "commitment",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "outbound_response_score",
|
||||
"title": "Outbound Response Score",
|
||||
"field": "outbound_response_score",
|
||||
"group": "commitment",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "balance_inbound_score",
|
||||
"title": "Inbound Balance Score",
|
||||
"field": "balance_inbound_score",
|
||||
"group": "commitment",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "balance_outbound_score",
|
||||
"title": "Outbound Balance Score",
|
||||
"field": "balance_outbound_score",
|
||||
"group": "commitment",
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
},
|
||||
{
|
||||
"slug": "last_event",
|
||||
"title": "Last Event Timestamp",
|
||||
"field": "source_event_ts",
|
||||
"group": "timeline",
|
||||
"y_min": None,
|
||||
"y_max": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -487,7 +654,10 @@ def _to_float(value):
|
||||
return float(value)
|
||||
|
||||
|
||||
def _format_metric_value(conversation, metric_slug):
|
||||
def _format_metric_value(conversation, metric_slug, latest_snapshot=None):
|
||||
snapshot = latest_snapshot
|
||||
if snapshot is None:
|
||||
snapshot = conversation.metric_snapshots.first()
|
||||
if metric_slug == "platform":
|
||||
return conversation.get_platform_type_display() or "-"
|
||||
if metric_slug == "thread":
|
||||
@@ -498,6 +668,14 @@ def _format_metric_value(conversation, metric_slug):
|
||||
return conversation.get_stability_state_display()
|
||||
if metric_slug == "stability_score":
|
||||
return conversation.stability_score
|
||||
if metric_slug == "reciprocity_score":
|
||||
return snapshot.reciprocity_score if snapshot else None
|
||||
if metric_slug == "continuity_score":
|
||||
return snapshot.continuity_score if snapshot else None
|
||||
if metric_slug == "response_score":
|
||||
return snapshot.response_score if snapshot else None
|
||||
if metric_slug == "volatility_score":
|
||||
return snapshot.volatility_score if snapshot else None
|
||||
if metric_slug == "stability_confidence":
|
||||
return conversation.stability_confidence
|
||||
if metric_slug == "sample_messages":
|
||||
@@ -510,6 +688,14 @@ def _format_metric_value(conversation, metric_slug):
|
||||
return conversation.commitment_inbound_score
|
||||
if metric_slug == "commitment_outbound":
|
||||
return conversation.commitment_outbound_score
|
||||
if metric_slug == "inbound_response_score":
|
||||
return snapshot.inbound_response_score if snapshot else None
|
||||
if metric_slug == "outbound_response_score":
|
||||
return snapshot.outbound_response_score if snapshot else None
|
||||
if metric_slug == "balance_inbound_score":
|
||||
return snapshot.balance_inbound_score if snapshot else None
|
||||
if metric_slug == "balance_outbound_score":
|
||||
return snapshot.balance_outbound_score if snapshot else None
|
||||
if metric_slug == "commitment_confidence":
|
||||
return conversation.commitment_confidence
|
||||
if metric_slug == "commitment_computed":
|
||||
@@ -2713,7 +2899,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
conversation = _conversation_for_person(request.user, person)
|
||||
value = _format_metric_value(conversation, metric)
|
||||
latest_snapshot = conversation.metric_snapshots.first()
|
||||
value = _format_metric_value(conversation, metric, latest_snapshot)
|
||||
group = INSIGHT_GROUPS[spec["group"]]
|
||||
points = []
|
||||
if spec["history_field"]:
|
||||
@@ -2773,6 +2960,7 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
conversation = _conversation_for_person(request.user, person)
|
||||
latest_snapshot = conversation.metric_snapshots.first()
|
||||
metrics = []
|
||||
for slug, spec in INSIGHT_METRICS.items():
|
||||
metrics.append(
|
||||
@@ -2783,7 +2971,11 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
||||
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
|
||||
"calculation": spec["calculation"],
|
||||
"psychology": spec["psychology"],
|
||||
"value": _format_metric_value(conversation, slug),
|
||||
"value": _format_metric_value(
|
||||
conversation,
|
||||
slug,
|
||||
latest_snapshot,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user