Files
GIA/core/views/compose.py

869 lines
30 KiB
Python

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
from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
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:
value = str(service or "").strip().lower()
if value in {"signal", "whatsapp", "instagram", "xmpp"}:
return value
return "signal"
def _safe_limit(raw) -> int:
try:
value = int(raw or 40)
except (TypeError, ValueError):
value = 40
return max(10, min(value, 200))
def _safe_after_ts(raw) -> int:
try:
value = int(raw or 0)
except (TypeError, ValueError):
value = 0
return max(0, value)
def _format_ts_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dj_timezone.localtime(as_dt).strftime("%H:%M")
except Exception:
return str(ts_value or "")
def _is_outgoing(msg: Message) -> bool:
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
def _serialize_message(msg: Message) -> dict:
author = str(msg.custom_author or "").strip()
return {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"display_ts": _format_ts_label(int(msg.ts or 0)),
"text": str(msg.text or ""),
"author": author,
"outgoing": _is_outgoing(msg),
}
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:
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,
service=service,
identifier=identifier,
).first()
if person_identifier:
service = person_identifier.service
identifier = person_identifier.identifier
person = person_identifier.person
return {
"person_identifier": person_identifier,
"service": service,
"identifier": identifier,
"person": person,
}
def _compose_urls(service, identifier, person_id):
query = {"service": service, "identifier": identifier}
if person_id:
query["person"] = str(person_id)
payload = urlencode(query)
return {
"page_url": f"{reverse('compose_page')}?{payload}",
"widget_url": f"{reverse('compose_widget')}?{payload}",
}
def _load_messages(user, person_identifier, limit):
if person_identifier is None:
return {"session": None, "messages": []}
session, _ = ChatSession.objects.get_or_create(
user=user,
identifier=person_identifier,
)
messages = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
.order_by("-ts")[:limit]
)
messages.reverse()
return {"session": session, "messages": messages}
def _panel_context(
request,
service: str,
identifier: str,
person: Person | None,
render_mode: str,
notice: str = "",
level: str = "success",
):
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
session_bundle = _load_messages(request.user, base["person_identifier"], limit)
last_ts = 0
if session_bundle["messages"]:
last_ts = int(session_bundle["messages"][-1].ts or 0)
urls = _compose_urls(
base["service"],
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]
return {
"service": base["service"],
"identifier": base["identifier"],
"person": base["person"],
"person_identifier": base["person_identifier"],
"session": session_bundle["session"],
"messages": session_bundle["messages"],
"serialized_messages": [
_serialize_message(msg) for msg in session_bundle["messages"]
],
"last_ts": last_ts,
"limit": limit,
"notice_message": notice,
"notice_level": level,
"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"]
else reverse("ai_workspace")
),
"manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}",
}
class ComposeContactsDropdown(LoginRequiredMixin, View):
def get(self, request):
rows = list(
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
items = []
for row in rows:
urls = _compose_urls(row.service, row.identifier, row.person_id)
items.append(
{
"person_name": row.person.name,
"service": row.service,
"identifier": row.identifier,
"compose_url": urls["page_url"],
}
)
return render(
request,
"partials/nav-contacts-dropdown.html",
{
"items": items,
"manual_icon_class": "fa-solid fa-paper-plane",
},
)
class ComposePage(LoginRequiredMixin, View):
template_name = "pages/compose.html"
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 HttpResponseBadRequest("Missing contact identifier.")
context = _panel_context(
request=request,
service=service,
identifier=identifier,
person=person,
render_mode="page",
)
return render(request, self.template_name, context)
class ComposeWidget(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 HttpResponseBadRequest("Missing contact identifier.")
panel_context = _panel_context(
request=request,
service=service,
identifier=identifier,
person=person,
render_mode="widget",
)
title_name = (
panel_context["person"].name
if panel_context["person"] is not None
else panel_context["identifier"]
)
context = {
"title": f"Manual Chat: {title_name}",
"unique": f"compose-{panel_context['panel_id']}",
"window_content": "partials/compose-panel.html",
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
**panel_context,
}
return render(request, "mixins/wm/widget.html", context)
class ComposeThread(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 HttpResponseBadRequest("Missing contact identifier.")
limit = _safe_limit(request.GET.get("limit") or 60)
after_ts = _safe_after_ts(request.GET.get("after_ts"))
base = _context_base(request.user, service, identifier, person)
latest_ts = after_ts
messages = []
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
queryset = Message.objects.filter(user=request.user, session=session)
if after_ts > 0:
queryset = queryset.filter(ts__gt=after_ts)
messages = list(
queryset.select_related(
"session",
"session__identifier",
"session__identifier__person",
)
.order_by("ts")[:limit]
)
newest = (
Message.objects.filter(user=request.user, session=session)
.order_by("-ts")
.values_list("ts", flat=True)
.first()
)
if newest:
latest_ts = max(latest_ts, int(newest))
payload = {
"messages": [_serialize_message(msg) for msg in messages],
"last_ts": latest_ts,
}
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"))
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)
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
if render_mode not in {"page", "widget"}:
render_mode = "page"
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(
request,
"partials/compose-send-status.html",
{"notice_message": "Message is empty.", "notice_level": "danger"},
)
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=text,
attachments=[],
)
if not ts:
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Send failed. Check service account state.",
"notice_level": "danger",
},
)
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=text,
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000),
delivered_ts=int(ts) if str(ts).isdigit() else None,
custom_author="USER",
)
response = render(
request,
"partials/compose-send-status.html",
{"notice_message": "Sent.", "notice_level": "success"},
)
response["HX-Trigger"] = "composeMessageSent"
return response