diff --git a/app/urls.py b/app/urls.py index bd03011..793d4b4 100644 --- a/app/urls.py +++ b/app/urls.py @@ -66,6 +66,16 @@ urlpatterns = [ automation.CommandRoutingSettings.as_view(), name="command_routing", ), + path( + "settings/ai-execution/", + automation.AIExecutionLogSettings.as_view(), + name="ai_execution_log", + ), + path( + "settings/translation/", + automation.TranslationSettings.as_view(), + name="translation_settings", + ), path( "settings/business-plan//", automation.BusinessPlanEditor.as_view(), diff --git a/core/clients/signal.py b/core/clients/signal.py index d789fe1..9ea984c 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -588,7 +588,11 @@ class HandleMessage(Command): None, ) log.info("Running Signal mutate prompt") - result = await ai.run_prompt(prompt, manip.ai) + result = await ai.run_prompt( + prompt, + manip.ai, + operation="signal_mutate", + ) log.info( f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." ) @@ -733,7 +737,11 @@ class HandleMessage(Command): ) log.info("Running context prompt") - result = await ai.run_prompt(prompt, manip.ai) + result = await ai.run_prompt( + prompt, + manip.ai, + operation="signal_reply", + ) if manip.mode == "active": await history.store_own_message( session=chat_session, diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index 8b98ce2..8c3682e 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -1313,7 +1313,11 @@ class XMPPComponent(ComponentXMPP): chat_history, ) self.log.debug("Running XMPP context prompt") - result = await ai.run_prompt(prompt, manip.ai) + result = await ai.run_prompt( + prompt, + manip.ai, + operation="xmpp_mutate", + ) self.log.debug("Generated mutated response for XMPP message") await history.store_own_message( session=session, diff --git a/core/commands/handlers/bp.py b/core/commands/handlers/bp.py index 7c5c37a..5ded0ac 100644 --- a/core/commands/handlers/bp.py +++ b/core/commands/handlers/bp.py @@ -299,7 +299,12 @@ class BPCommandHandler(CommandHandler): }, ] try: - summary = str(await ai_runner.run_prompt(prompt, ai_obj) or "").strip() + summary = str( + await ai_runner.run_prompt( + prompt, ai_obj, operation="command_bp_extract" + ) + or "" + ).strip() if not summary: raise RuntimeError("empty_ai_response") except Exception as exc: diff --git a/core/messaging/ai.py b/core/messaging/ai.py index 7ed3d5d..c6e3ac4 100644 --- a/core/messaging/ai.py +++ b/core/messaging/ai.py @@ -1,20 +1,68 @@ +import time + +from asgiref.sync import sync_to_async +from django.utils import timezone from openai import AsyncOpenAI -from core.models import AI +from core.models import AI, AIRunLog + + +def _prompt_metrics(prompt: list[dict]) -> tuple[int, int]: + rows = list(prompt or []) + message_count = len(rows) + prompt_chars = 0 + for row in rows: + content = "" + if isinstance(row, dict): + content = str(row.get("content") or "") + else: + content = str(row or "") + prompt_chars += len(content) + return message_count, prompt_chars async def run_prompt( - prompt: list[str], + prompt: list[dict], ai: AI, + operation: str = "", ): + started_monotonic = time.perf_counter() + message_count, prompt_chars = _prompt_metrics(prompt) + run_log = await sync_to_async(AIRunLog.objects.create)( + user=ai.user, + ai=ai, + operation=str(operation or "").strip(), + model=str(ai.model or ""), + base_url=str(ai.base_url or ""), + status="running", + message_count=message_count, + prompt_chars=prompt_chars, + ) cast = {"api_key": ai.api_key} if ai.base_url is not None: cast["base_url"] = ai.base_url client = AsyncOpenAI(**cast) - response = await client.chat.completions.create( - model=ai.model, - messages=prompt, - ) - content = response.choices[0].message.content - - return content + try: + response = await client.chat.completions.create( + model=ai.model, + messages=prompt, + ) + content = response.choices[0].message.content + duration_ms = int((time.perf_counter() - started_monotonic) * 1000) + await sync_to_async(AIRunLog.objects.filter(id=run_log.id).update)( + status="ok", + response_chars=len(str(content or "")), + finished_at=timezone.now(), + duration_ms=duration_ms, + error="", + ) + return content + except Exception as exc: + duration_ms = int((time.perf_counter() - started_monotonic) * 1000) + await sync_to_async(AIRunLog.objects.filter(id=run_log.id).update)( + status="failed", + finished_at=timezone.now(), + duration_ms=duration_ms, + error=str(exc), + ) + raise diff --git a/core/messaging/analysis.py b/core/messaging/analysis.py index 495562d..42f0551 100644 --- a/core/messaging/analysis.py +++ b/core/messaging/analysis.py @@ -1,6 +1,6 @@ from django.utils import timezone -from openai import AsyncOpenAI +from core.messaging import ai as ai_runner from core.models import AI, Manipulation, Person @@ -50,17 +50,11 @@ def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history async def run_context_prompt( - prompt: list[str], + prompt: list[dict], ai: AI, ): - cast = {"api_key": ai.api_key} - if ai.base_url is not None: - cast["base_url"] = ai.base_url - client = AsyncOpenAI(**cast) - response = await client.chat.completions.create( - model=ai.model, - messages=prompt, + return await ai_runner.run_prompt( + prompt, + ai, + operation="analysis_context", ) - content = response.choices[0].message.content - - return content diff --git a/core/migrations/0028_airunlog.py b/core/migrations/0028_airunlog.py new file mode 100644 index 0000000..562d8a9 --- /dev/null +++ b/core/migrations/0028_airunlog.py @@ -0,0 +1,86 @@ +# Generated by Django 5.2.7 on 2026-03-02 00:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0027_businessplandocument_businessplanrevision_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AIRunLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("operation", models.CharField(blank=True, default="", max_length=64)), + ("model", models.CharField(blank=True, default="", max_length=255)), + ("base_url", models.CharField(blank=True, default="", max_length=255)), + ( + "status", + models.CharField( + choices=[("running", "Running"), ("ok", "OK"), ("failed", "Failed")], + default="running", + max_length=16, + ), + ), + ("message_count", models.PositiveIntegerField(default=0)), + ("prompt_chars", models.PositiveIntegerField(default=0)), + ("response_chars", models.PositiveIntegerField(default=0)), + ("error", models.TextField(blank=True, default="")), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ("duration_ms", models.PositiveIntegerField(blank=True, null=True)), + ( + "ai", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="run_logs", + to="core.ai", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_run_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddIndex( + model_name="airunlog", + index=models.Index(fields=["user", "started_at"], name="core_airunl_user_id_6f4700_idx"), + ), + migrations.AddIndex( + model_name="airunlog", + index=models.Index( + fields=["user", "status", "started_at"], + name="core_airunl_user_id_b4486e_idx", + ), + ), + migrations.AddIndex( + model_name="airunlog", + index=models.Index( + fields=["user", "operation", "started_at"], + name="core_airunl_user_id_4f0f5e_idx", + ), + ), + migrations.AddIndex( + model_name="airunlog", + index=models.Index(fields=["user", "model", "started_at"], name="core_airunl_user_id_953bff_idx"), + ), + ] diff --git a/core/models.py b/core/models.py index e1605a1..6136b17 100644 --- a/core/models.py +++ b/core/models.py @@ -128,6 +128,42 @@ class AI(models.Model): return f"{self.id} - {self.model}" +class AIRunLog(models.Model): + STATUS_CHOICES = ( + ("running", "Running"), + ("ok", "OK"), + ("failed", "Failed"), + ) + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ai_run_logs") + ai = models.ForeignKey( + AI, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="run_logs", + ) + operation = models.CharField(max_length=64, blank=True, default="") + model = models.CharField(max_length=255, blank=True, default="") + base_url = models.CharField(max_length=255, blank=True, default="") + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="running") + message_count = models.PositiveIntegerField(default=0) + prompt_chars = models.PositiveIntegerField(default=0) + response_chars = models.PositiveIntegerField(default=0) + error = models.TextField(blank=True, default="") + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + duration_ms = models.PositiveIntegerField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["user", "started_at"]), + models.Index(fields=["user", "status", "started_at"]), + models.Index(fields=["user", "operation", "started_at"]), + models.Index(fields=["user", "model", "started_at"]), + ] + + class Person(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/core/templates/base.html b/core/templates/base.html index cfc59a8..bc92840 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -390,11 +390,17 @@ Notifications - AI + Models Command Routing + + Translation + + + AI Execution Log + {% if user.is_superuser %} System diff --git a/core/templates/pages/ai-execution-log.html b/core/templates/pages/ai-execution-log.html new file mode 100644 index 0000000..c24654a --- /dev/null +++ b/core/templates/pages/ai-execution-log.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+

AI Execution Log

+

Tracked model calls and usage metrics for this account.

+ +
+
+

Total Runs

{{ stats.total_runs }}

+

OK

{{ stats.total_ok }}

+

Failed

{{ stats.total_failed }}

+

Success Rate

{{ stats.success_rate }}%

+

24h Runs

{{ stats.last_24h_runs }}

+

24h Failed

{{ stats.last_24h_failed }}

+

7d Runs

{{ stats.last_7d_runs }}

+

Avg Duration

{{ stats.avg_duration_ms }}ms

+

Prompt Chars

{{ stats.total_prompt_chars }}

+

Response Chars

{{ stats.total_response_chars }}

+

Avg Prompt

{{ stats.avg_prompt_chars }}

+

Avg Response

{{ stats.avg_response_chars }}

+
+
+ +
+
+
+

By Operation

+ + + + + + {% for row in operation_breakdown %} + + + + + + + {% empty %} + + {% endfor %} + +
OperationTotalOKFailed
{{ row.operation|default:"(none)" }}{{ row.total }}{{ row.ok }}{{ row.failed }}
No runs yet.
+
+
+
+
+

By Model

+ + + + + + {% for row in model_breakdown %} + + + + + + + {% empty %} + + {% endfor %} + +
ModelTotalOKFailed
{{ row.model|default:"(none)" }}{{ row.total }}{{ row.ok }}{{ row.failed }}
No runs yet.
+
+
+
+ +
+

Recent Runs

+
+ + + + + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
StartedStatusOperationModelMessagesPromptResponseDurationError
{{ run.started_at }}{{ run.status }}{{ run.operation|default:"-" }}{{ run.model|default:"-" }}{{ run.message_count }}{{ run.prompt_chars }}{{ run.response_chars }}{% if run.duration_ms %}{{ run.duration_ms }}ms{% else %}-{% endif %}{{ run.error|default:"-" }}
No runs yet.
+
+
+
+
+{% endblock %} diff --git a/core/templates/pages/command-routing.html b/core/templates/pages/command-routing.html index 53873ee..e6e47bc 100644 --- a/core/templates/pages/command-routing.html +++ b/core/templates/pages/command-routing.html @@ -8,59 +8,84 @@

Create Command Profile

-
+

Create reusable command behavior. Example: #bp# reply command for business-plan extraction.

+ {% csrf_token %}
- + + +

Stable command id, e.g. bp.

- + +
- + +
- - + + +
{% for profile in profiles %}

{{ profile.name }} ({{ profile.slug }})

-
+
+

Flag Definitions

+
    +
  • enabled: master on/off switch for this command profile.
  • +
  • reply required: command only runs when the trigger message is sent as a reply to another message.
  • +
  • exact match: message text must be exactly the trigger token (for example #bp#) with no extra text.
  • +
  • visibility = status_in_source: post command status updates back into the source channel.
  • +
  • visibility = silent: do not post status updates in the source channel.
  • +
  • binding direction ingress: channels where trigger messages are accepted.
  • +
  • binding direction egress: channels where command outputs are posted.
  • +
  • binding direction scratchpad_mirror: scratchpad/mirror channel used for relay-only behavior.
  • +
  • action extract_bp: run AI extraction to produce business plan content.
  • +
  • action save_document: save/editable document and revision history.
  • +
  • action post_result: fan out generated result to enabled egress bindings.
  • +
  • position: execution order (lower runs first).
  • +
+
+ {% csrf_token %}
- - + +
- - + +
- +
- + +
- - - - +
+ Flags + + + +
- - + +
@@ -69,18 +94,24 @@

Channel Bindings

+

A command runs only when the source channel is in ingress. Output is sent to all enabled egress bindings.

- + {% for binding in profile.channel_bindings.all %} - +
DirectionServiceChannel
DirectionServiceChannelActions
{{ binding.direction }} + {% if binding.direction == "ingress" %}Ingress (Accept Triggers) + {% elif binding.direction == "egress" %}Egress (Post Results) + {% else %}Scratchpad Mirror + {% endif %} + {{ binding.service }} {{ binding.channel_identifier }} - + {% csrf_token %} @@ -93,23 +124,30 @@ {% endfor %}
- + {% csrf_token %}
+
- {% for value in directions %} - + {% endfor %}
+
- {% for value in channel_services %} {% endfor %} @@ -117,7 +155,8 @@
- + +
@@ -128,23 +167,45 @@

Actions

+

Enable/disable each step and set execution order with position.

- + {% for action_row in profile.actions.all %} - - - + + + @@ -157,11 +218,11 @@ - + {% csrf_token %} - + {% empty %} @@ -172,7 +233,7 @@

Business Plan Documents

TypeEnabledPosition
TypeEnabledOrderActions
{{ action_row.action_type }}{{ action_row.enabled }}{{ action_row.position }} - + {% if action_row.action_type == "extract_bp" %}Extract Business Plan + {% elif action_row.action_type == "save_document" %}Save Document + {% elif action_row.action_type == "post_result" %}Post Result + {% else %}{{ action_row.action_type }} + {% endif %} + {{ action_row.enabled }}{{ forloop.counter }} +
+ + {% csrf_token %} + + + + + +
+ {% csrf_token %} + + + + +
+
+
{% csrf_token %} -
- + {% for doc in documents %} @@ -181,7 +242,7 @@ - + {% empty %} @@ -190,78 +251,6 @@
TitleStatusSourceUpdated
TitleStatusSourceUpdatedActions
{{ doc.status }} {{ doc.source_service }} · {{ doc.source_channel_identifier }} {{ doc.updated_at }}OpenOpen
No business plan documents yet.
-
-

Translation Bridges

-
- {% csrf_token %} - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - - {% for bridge in bridges %} - - - - - - - - {% empty %} - - {% endfor %} - -
NameABDirection
{{ bridge.name }}{{ bridge.a_service }} · {{ bridge.a_channel_identifier }} · {{ bridge.a_language }}{{ bridge.b_service }} · {{ bridge.b_channel_identifier }} · {{ bridge.b_language }}{{ bridge.direction }} -
- {% csrf_token %} - - - -
-
No translation bridges configured.
-
- -
-

Translation Event Log

- - - - - - {% for event in events %} - - - - - - - - {% empty %} - - {% endfor %} - -
BridgeStatusTargetErrorAt
{{ event.bridge.name }}{{ event.status }}{{ event.target_service }} · {{ event.target_channel }}{{ event.error|default:"-" }}{{ event.created_at }}
No events yet.
-
{% endblock %} diff --git a/core/templates/pages/osint-search.html b/core/templates/pages/osint-search.html index 9b9b634..caf194b 100644 --- a/core/templates/pages/osint-search.html +++ b/core/templates/pages/osint-search.html @@ -1,17 +1,20 @@ {% extends "base.html" %} {% block content %} -
-
-
-
-

Search

-

- Search across OSINT objects with sortable, paginated results. -

+
+
+
+

Search

+

+ Unified lookup across contacts, identifiers, and messages, with advanced filters for source, date range, sentiment, sort, dedup, and reverse. +

+
+ Default Scope: All + Contacts + Messages + SIQTSRSS/ADR Controls
{% include "partials/osint/search-panel.html" %}
-
+ {% endblock %} diff --git a/core/templates/pages/translation-settings.html b/core/templates/pages/translation-settings.html new file mode 100644 index 0000000..459ad49 --- /dev/null +++ b/core/templates/pages/translation-settings.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Translation Settings

+

Configure translation bridges, routing direction, and inspect sync events.

+ +
+

Translation Bridges

+
+ {% csrf_token %} + +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ + + + + + + {% for bridge in bridges %} + + + + + + + + {% empty %} + + {% endfor %} + +
Configured translation bridges
NameABDirectionActions
{{ bridge.name }}{{ bridge.a_service }} · {{ bridge.a_channel_identifier }} · {{ bridge.a_language }}{{ bridge.b_service }} · {{ bridge.b_channel_identifier }} · {{ bridge.b_language }} + {% if bridge.direction == "a_to_b" %}A to B + {% elif bridge.direction == "b_to_a" %}B to A + {% else %}Bidirectional + {% endif %} + +
+ {% csrf_token %} + + + +
+
No translation bridges configured.
+
+ +
+

Translation Event Log

+ + + + + + + {% for event in events %} + + + + + + + + {% empty %} + + {% endfor %} + +
Recent translation sync events
BridgeStatusTargetErrorAt
{{ event.bridge.name }}{{ event.status }}{{ event.target_service }} · {{ event.target_channel }}{{ event.error|default:"-" }}{{ event.created_at }}
No events yet.
+
+
+
+{% endblock %} diff --git a/core/templates/partials/osint/list-table.html b/core/templates/partials/osint/list-table.html index b0f782e..7f83bdf 100644 --- a/core/templates/partials/osint/list-table.html +++ b/core/templates/partials/osint/list-table.html @@ -1,11 +1,13 @@ {% include 'mixins/partials/notify.html' %}
+ class="osint-table-shell {% if osint_shell_borderless %}osint-table-shell--borderless{% endif %}" + {% if osint_event_name %} + hx-get="{{ osint_refresh_url }}" + hx-target="#{{ osint_table_id }}" + hx-swap="outerHTML" + hx-trigger="{{ osint_event_name }} from:body" + {% endif %}> {% if osint_show_search %}
-