586 lines
24 KiB
Python
586 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.db import transaction
|
|
from django.db.models import Avg, Count, Q, Sum
|
|
from django.http import JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.views import View
|
|
|
|
from core.commands.policies import BP_VARIANT_KEYS, BP_VARIANT_META, ensure_variant_policies_for_profile
|
|
from core.models import (
|
|
AIRunLog,
|
|
BusinessPlanDocument,
|
|
BusinessPlanRevision,
|
|
CommandAction,
|
|
CommandChannelBinding,
|
|
CommandProfile,
|
|
CommandVariantPolicy,
|
|
TranslationBridge,
|
|
TranslationEventLog,
|
|
)
|
|
from core.translation.engine import parse_quick_mode_title
|
|
|
|
|
|
def _channel_variants(service: str, identifier: str) -> list[str]:
|
|
value = str(identifier or "").strip()
|
|
if not value:
|
|
return []
|
|
variants = [value]
|
|
svc = str(service or "").strip().lower()
|
|
if svc == "whatsapp":
|
|
bare = value.split("@", 1)[0].strip()
|
|
if bare and bare not in variants:
|
|
variants.append(bare)
|
|
group = f"{bare}@g.us" if bare else ""
|
|
if group and group not in variants:
|
|
variants.append(group)
|
|
return variants
|
|
|
|
|
|
class CommandRoutingSettings(LoginRequiredMixin, View):
|
|
template_name = "pages/command-routing.html"
|
|
|
|
@staticmethod
|
|
def _normalize_action_positions(profile):
|
|
rows = list(profile.actions.order_by("position", "id"))
|
|
for idx, row in enumerate(rows):
|
|
if row.position != idx:
|
|
row.position = idx
|
|
row.save(update_fields=["position", "updated_at"])
|
|
return rows
|
|
|
|
@staticmethod
|
|
def _redirect_with_scope(request):
|
|
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
|
|
identifier = str(
|
|
request.GET.get("identifier") or request.POST.get("identifier") or ""
|
|
).strip()
|
|
if service and identifier:
|
|
return redirect(
|
|
f"{reverse('command_routing')}?service={service}&identifier={identifier}"
|
|
)
|
|
return redirect("command_routing")
|
|
|
|
def _context(self, request):
|
|
profiles_qs = (
|
|
CommandProfile.objects.filter(user=request.user)
|
|
.prefetch_related("channel_bindings", "actions", "variant_policies")
|
|
.order_by("slug")
|
|
)
|
|
scope_service = str(request.GET.get("service") or "").strip().lower()
|
|
scope_identifier = str(request.GET.get("identifier") or "").strip()
|
|
scope_variants = _channel_variants(scope_service, scope_identifier)
|
|
profiles = list(profiles_qs)
|
|
preview_profile_id = str(request.GET.get("preview_profile_id") or "").strip()
|
|
for profile in profiles:
|
|
policies = ensure_variant_policies_for_profile(profile)
|
|
profile.show_actions_editor = str(profile.slug or "").strip() != "bp"
|
|
if str(profile.slug or "").strip() == "bp":
|
|
keys = BP_VARIANT_KEYS
|
|
else:
|
|
keys = ("default",)
|
|
profile.variant_rows = []
|
|
for key in keys:
|
|
row = policies.get(key)
|
|
if row is None:
|
|
continue
|
|
meta = BP_VARIANT_META.get(key, {})
|
|
profile.variant_rows.append(
|
|
{
|
|
"variant_key": key,
|
|
"variant_label": str(meta.get("name") or key),
|
|
"trigger_token": str(meta.get("trigger_token") or profile.trigger_token or ""),
|
|
"template_supported": bool(meta.get("template_supported")),
|
|
"warn_verbatim_plan": bool(
|
|
key in {"bp", "bp_set_range"}
|
|
and str(getattr(row, "generation_mode", "") or "") == "verbatim"
|
|
and bool(getattr(row, "send_plan_to_egress", False))
|
|
),
|
|
"row": row,
|
|
}
|
|
)
|
|
bindings = list(profile.channel_bindings.all())
|
|
if scope_service and scope_variants:
|
|
profile.visible_bindings = [
|
|
row
|
|
for row in bindings
|
|
if str(row.service or "").strip().lower() == scope_service
|
|
and str(row.channel_identifier or "").strip() in scope_variants
|
|
]
|
|
else:
|
|
profile.visible_bindings = bindings
|
|
profile.enabled_egress_bindings = [
|
|
row
|
|
for row in bindings
|
|
if str(row.direction or "").strip() == "egress" and bool(row.enabled)
|
|
]
|
|
profile.preview_mode = preview_profile_id and str(profile.id) == preview_profile_id
|
|
documents = BusinessPlanDocument.objects.filter(user=request.user).order_by(
|
|
"-updated_at"
|
|
)[:30]
|
|
return {
|
|
"profiles": profiles,
|
|
"documents": documents,
|
|
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
|
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
|
"action_types": ("extract_bp", "post_result", "save_document"),
|
|
"command_choices": (
|
|
("bp", "Business Plan (bp)"),
|
|
("codex", "Codex (codex)"),
|
|
),
|
|
"scope_service": scope_service,
|
|
"scope_identifier": scope_identifier,
|
|
"scope_variants": scope_variants,
|
|
"preview_profile_id": preview_profile_id,
|
|
}
|
|
|
|
def get(self, request):
|
|
return render(request, self.template_name, self._context(request))
|
|
|
|
def post(self, request):
|
|
action = str(request.POST.get("action") or "").strip()
|
|
|
|
if action == "profile_create":
|
|
slug = (
|
|
str(request.POST.get("command_slug") or request.POST.get("slug") or "bp")
|
|
.strip()
|
|
.lower()
|
|
or "bp"
|
|
)
|
|
profile, _ = CommandProfile.objects.get_or_create(
|
|
user=request.user,
|
|
slug=slug,
|
|
defaults={
|
|
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
|
|
or ("Codex" if slug == "codex" else "Business Plan"),
|
|
"enabled": True,
|
|
"trigger_token": str(
|
|
request.POST.get("trigger_token")
|
|
or (".codex" if slug == "codex" else ".bp")
|
|
).strip()
|
|
or (".codex" if slug == "codex" else ".bp"),
|
|
"template_text": str(request.POST.get("template_text") or ""),
|
|
},
|
|
)
|
|
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
|
if slug == "bp":
|
|
profile.trigger_token = ".bp"
|
|
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
|
|
if slug == "codex":
|
|
profile.trigger_token = ".codex"
|
|
profile.reply_required = False
|
|
profile.exact_match_only = False
|
|
profile.save(
|
|
update_fields=[
|
|
"name",
|
|
"trigger_token",
|
|
"template_text",
|
|
"reply_required",
|
|
"exact_match_only",
|
|
"updated_at",
|
|
]
|
|
)
|
|
if slug == "bp":
|
|
CommandAction.objects.get_or_create(
|
|
profile=profile,
|
|
action_type="extract_bp",
|
|
defaults={"enabled": True, "position": 0},
|
|
)
|
|
# Keep legacy action rows in storage for compatibility and for
|
|
# potential reuse by non-bp commands; bp UI now relies on
|
|
# variant policies instead of exposing the generic action matrix.
|
|
CommandAction.objects.get_or_create(
|
|
profile=profile,
|
|
action_type="save_document",
|
|
defaults={"enabled": True, "position": 1},
|
|
)
|
|
CommandAction.objects.get_or_create(
|
|
profile=profile,
|
|
action_type="post_result",
|
|
defaults={"enabled": True, "position": 2},
|
|
)
|
|
ensure_variant_policies_for_profile(profile)
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "profile_update":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
profile.name = str(request.POST.get("name") or profile.name).strip()
|
|
profile.enabled = bool(request.POST.get("enabled"))
|
|
profile.trigger_token = (
|
|
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
|
|
or ".bp"
|
|
)
|
|
profile.reply_required = bool(request.POST.get("reply_required"))
|
|
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
|
profile.template_text = str(request.POST.get("template_text") or "")
|
|
# Legacy field retained for compatibility only.
|
|
profile.visibility_mode = profile.visibility_mode or "status_in_source"
|
|
profile.save()
|
|
ensure_variant_policies_for_profile(profile)
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "profile_delete":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
profile.delete()
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "binding_create":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
CommandChannelBinding.objects.create(
|
|
profile=profile,
|
|
direction=str(request.POST.get("direction") or "ingress").strip(),
|
|
service=str(request.POST.get("service") or "web").strip(),
|
|
channel_identifier=str(
|
|
request.POST.get("channel_identifier") or ""
|
|
).strip(),
|
|
enabled=bool(request.POST.get("enabled") or "1"),
|
|
)
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "binding_delete":
|
|
binding = get_object_or_404(
|
|
CommandChannelBinding,
|
|
id=request.POST.get("binding_id"),
|
|
profile__user=request.user,
|
|
)
|
|
binding.delete()
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "action_update":
|
|
row = get_object_or_404(
|
|
CommandAction,
|
|
id=request.POST.get("command_action_id"),
|
|
profile__user=request.user,
|
|
)
|
|
row.enabled = bool(request.POST.get("enabled"))
|
|
if request.POST.get("position") not in (None, ""):
|
|
row.position = int(request.POST.get("position") or 0)
|
|
row.save(update_fields=["enabled", "position", "updated_at"])
|
|
else:
|
|
row.save(update_fields=["enabled", "updated_at"])
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "action_move":
|
|
row = get_object_or_404(
|
|
CommandAction,
|
|
id=request.POST.get("command_action_id"),
|
|
profile__user=request.user,
|
|
)
|
|
direction = str(request.POST.get("direction") or "").strip().lower()
|
|
if direction not in {"up", "down"}:
|
|
return self._redirect_with_scope(request)
|
|
with transaction.atomic():
|
|
ordered = self._normalize_action_positions(row.profile)
|
|
action_ids = [entry.id for entry in ordered]
|
|
try:
|
|
idx = action_ids.index(row.id)
|
|
except ValueError:
|
|
return self._redirect_with_scope(request)
|
|
target_idx = idx - 1 if direction == "up" else idx + 1
|
|
if target_idx < 0 or target_idx >= len(ordered):
|
|
return self._redirect_with_scope(request)
|
|
other = ordered[target_idx]
|
|
current_pos = ordered[idx].position
|
|
ordered[idx].position = other.position
|
|
other.position = current_pos
|
|
ordered[idx].save(update_fields=["position", "updated_at"])
|
|
other.save(update_fields=["position", "updated_at"])
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "variant_policy_update":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
variant_key = str(request.POST.get("variant_key") or "").strip()
|
|
policy = get_object_or_404(
|
|
CommandVariantPolicy,
|
|
profile=profile,
|
|
variant_key=variant_key,
|
|
)
|
|
policy.enabled = bool(request.POST.get("enabled"))
|
|
mode = str(request.POST.get("generation_mode") or "verbatim").strip().lower()
|
|
policy.generation_mode = mode if mode in {"ai", "verbatim"} else "verbatim"
|
|
policy.send_plan_to_egress = bool(request.POST.get("send_plan_to_egress"))
|
|
policy.send_status_to_source = bool(request.POST.get("send_status_to_source"))
|
|
policy.send_status_to_egress = bool(request.POST.get("send_status_to_egress"))
|
|
policy.store_document = bool(request.POST.get("store_document"))
|
|
policy.save()
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "variant_policy_reset_defaults":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
profile.variant_policies.all().delete()
|
|
ensure_variant_policies_for_profile(profile)
|
|
return self._redirect_with_scope(request)
|
|
|
|
if action == "variant_preview":
|
|
profile = get_object_or_404(
|
|
CommandProfile,
|
|
id=request.POST.get("profile_id"),
|
|
user=request.user,
|
|
)
|
|
ensure_variant_policies_for_profile(profile)
|
|
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
|
|
identifier = str(
|
|
request.GET.get("identifier") or request.POST.get("identifier") or ""
|
|
).strip()
|
|
query = f"?preview_profile_id={profile.id}"
|
|
if service and identifier:
|
|
query += f"&service={service}&identifier={identifier}"
|
|
return redirect(f"{reverse('command_routing')}{query}")
|
|
|
|
return self._redirect_with_scope(request)
|
|
|
|
|
|
class TranslationSettings(LoginRequiredMixin, View):
|
|
template_name = "pages/translation-settings.html"
|
|
|
|
def _context(self, request):
|
|
bridges = TranslationBridge.objects.filter(user=request.user).order_by("-id")
|
|
events = (
|
|
TranslationEventLog.objects.filter(bridge__user=request.user)
|
|
.select_related("bridge")
|
|
.order_by("-created_at")[:50]
|
|
)
|
|
return {
|
|
"bridges": bridges,
|
|
"events": events,
|
|
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
|
"bridge_directions": ("a_to_b", "b_to_a", "bidirectional"),
|
|
}
|
|
|
|
def get(self, request):
|
|
return render(request, self.template_name, self._context(request))
|
|
|
|
def post(self, request):
|
|
action = str(request.POST.get("action") or "").strip()
|
|
|
|
if action == "bridge_create":
|
|
quick_title = str(request.POST.get("quick_mode_title") or "").strip()
|
|
inferred = parse_quick_mode_title(quick_title)
|
|
TranslationBridge.objects.create(
|
|
user=request.user,
|
|
name=str(request.POST.get("name") or "Translation Bridge").strip()
|
|
or "Translation Bridge",
|
|
enabled=bool(request.POST.get("enabled") or "1"),
|
|
a_service=str(request.POST.get("a_service") or "web").strip(),
|
|
a_channel_identifier=str(
|
|
request.POST.get("a_channel_identifier") or ""
|
|
).strip(),
|
|
a_language=str(
|
|
request.POST.get("a_language")
|
|
or inferred.get("a_language")
|
|
or "en"
|
|
).strip(),
|
|
b_service=str(request.POST.get("b_service") or "web").strip(),
|
|
b_channel_identifier=str(
|
|
request.POST.get("b_channel_identifier") or ""
|
|
).strip(),
|
|
b_language=str(
|
|
request.POST.get("b_language")
|
|
or inferred.get("b_language")
|
|
or "en"
|
|
).strip(),
|
|
direction=str(request.POST.get("direction") or "bidirectional").strip(),
|
|
quick_mode_title=quick_title,
|
|
settings={},
|
|
)
|
|
return redirect("translation_settings")
|
|
|
|
if action == "bridge_delete":
|
|
bridge = get_object_or_404(
|
|
TranslationBridge, id=request.POST.get("bridge_id"), user=request.user
|
|
)
|
|
bridge.delete()
|
|
return redirect("translation_settings")
|
|
|
|
return redirect("translation_settings")
|
|
|
|
|
|
class AIExecutionLogSettings(LoginRequiredMixin, View):
|
|
template_name = "pages/ai-execution-log.html"
|
|
|
|
def _context(self, request):
|
|
now = timezone.now()
|
|
runs_qs = AIRunLog.objects.filter(user=request.user)
|
|
runs = runs_qs.order_by("-started_at")[:300]
|
|
last_24h = runs_qs.filter(started_at__gte=now - timedelta(hours=24))
|
|
last_7d = runs_qs.filter(started_at__gte=now - timedelta(days=7))
|
|
|
|
total_runs = runs_qs.count()
|
|
total_ok = runs_qs.filter(status="ok").count()
|
|
total_failed = runs_qs.filter(status="failed").count()
|
|
avg_ms = runs_qs.aggregate(v=Avg("duration_ms")).get("v") or 0
|
|
success_rate = (float(total_ok) / float(total_runs) * 100.0) if total_runs else 0.0
|
|
|
|
usage_totals = runs_qs.aggregate(
|
|
prompt_chars_total=Sum("prompt_chars"),
|
|
response_chars_total=Sum("response_chars"),
|
|
avg_prompt_chars=Avg("prompt_chars"),
|
|
avg_response_chars=Avg("response_chars"),
|
|
)
|
|
|
|
stats = {
|
|
"total_runs": total_runs,
|
|
"total_ok": total_ok,
|
|
"total_failed": total_failed,
|
|
"last_24h_runs": last_24h.count(),
|
|
"last_24h_failed": last_24h.filter(status="failed").count(),
|
|
"last_7d_runs": last_7d.count(),
|
|
"avg_duration_ms": int(avg_ms),
|
|
"success_rate": round(success_rate, 1),
|
|
"total_prompt_chars": int(usage_totals.get("prompt_chars_total") or 0),
|
|
"total_response_chars": int(usage_totals.get("response_chars_total") or 0),
|
|
"avg_prompt_chars": int(usage_totals.get("avg_prompt_chars") or 0),
|
|
"avg_response_chars": int(usage_totals.get("avg_response_chars") or 0),
|
|
}
|
|
|
|
operation_breakdown = (
|
|
runs_qs.values("operation")
|
|
.annotate(
|
|
total=Count("id"),
|
|
failed=Count("id", filter=Q(status="failed")),
|
|
ok=Count("id", filter=Q(status="ok")),
|
|
)
|
|
.order_by("-total", "operation")[:20]
|
|
)
|
|
model_breakdown = (
|
|
runs_qs.values("model")
|
|
.annotate(
|
|
total=Count("id"),
|
|
failed=Count("id", filter=Q(status="failed")),
|
|
ok=Count("id", filter=Q(status="ok")),
|
|
)
|
|
.order_by("-total", "model")[:20]
|
|
)
|
|
|
|
return {
|
|
"stats": stats,
|
|
"runs": runs,
|
|
"operation_breakdown": operation_breakdown,
|
|
"model_breakdown": model_breakdown,
|
|
"settings_nav": {
|
|
"title": "AI",
|
|
"tabs": [
|
|
{
|
|
"label": "Models",
|
|
"href": reverse("ai_models"),
|
|
"active": str(getattr(request, "path", "") or "")
|
|
== reverse("ai_models"),
|
|
},
|
|
{
|
|
"label": "Traces",
|
|
"href": reverse("ai_execution_log"),
|
|
"active": str(getattr(request, "path", "") or "")
|
|
== reverse("ai_execution_log"),
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
def get(self, request):
|
|
return render(request, self.template_name, self._context(request))
|
|
|
|
|
|
class AIExecutionRunDetailView(LoginRequiredMixin, View):
|
|
template_name = "partials/ai-execution-run-detail.html"
|
|
|
|
def get(self, request, run_id):
|
|
run = get_object_or_404(AIRunLog, id=run_id, user=request.user)
|
|
return render(request, self.template_name, {"run": run})
|
|
|
|
|
|
class AIExecutionRunDetailTabView(LoginRequiredMixin, View):
|
|
template_name = "partials/ai-execution-run-detail-tab.html"
|
|
|
|
def get(self, request, run_id, tab_slug):
|
|
run = get_object_or_404(AIRunLog, id=run_id, user=request.user)
|
|
slug = str(tab_slug or "").strip().lower()
|
|
if slug not in {"error"}:
|
|
return JsonResponse({"ok": False, "error": "unknown_tab"}, status=404)
|
|
return render(
|
|
request,
|
|
self.template_name,
|
|
{
|
|
"run": run,
|
|
"tab_slug": slug,
|
|
},
|
|
)
|
|
|
|
|
|
class BusinessPlanEditor(LoginRequiredMixin, View):
|
|
template_name = "pages/business-plan-editor.html"
|
|
|
|
def get(self, request, doc_id):
|
|
document = get_object_or_404(BusinessPlanDocument, id=doc_id, user=request.user)
|
|
revisions = document.revisions.order_by("-created_at")[:100]
|
|
return render(
|
|
request,
|
|
self.template_name,
|
|
{
|
|
"document": document,
|
|
"revisions": revisions,
|
|
},
|
|
)
|
|
|
|
def post(self, request, doc_id):
|
|
document = get_object_or_404(BusinessPlanDocument, id=doc_id, user=request.user)
|
|
document.title = str(request.POST.get("title") or document.title).strip()
|
|
document.status = str(request.POST.get("status") or document.status).strip()
|
|
document.content_markdown = str(request.POST.get("content_markdown") or "")
|
|
document.save()
|
|
BusinessPlanRevision.objects.create(
|
|
document=document,
|
|
editor_user=request.user,
|
|
content_markdown=document.content_markdown,
|
|
structured_payload=document.structured_payload or {},
|
|
)
|
|
return redirect("business_plan_editor", doc_id=str(document.id))
|
|
|
|
|
|
class TranslationPreview(LoginRequiredMixin, View):
|
|
def post(self, request):
|
|
bridge = get_object_or_404(
|
|
TranslationBridge,
|
|
id=request.POST.get("bridge_id"),
|
|
user=request.user,
|
|
)
|
|
source = str(request.POST.get("source") or "").strip().lower()
|
|
text = str(request.POST.get("text") or "")
|
|
if source == "a":
|
|
target_language = bridge.b_language
|
|
else:
|
|
target_language = bridge.a_language
|
|
return JsonResponse(
|
|
{
|
|
"ok": True,
|
|
"bridge_id": str(bridge.id),
|
|
"target_language": target_language,
|
|
"preview": text,
|
|
"note": "Preview endpoint is non-mutating; final translation occurs in runtime sync.",
|
|
}
|
|
)
|