diff --git a/apps/audit/__init__.py b/apps/audit/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/audit/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/audit/admin.py b/apps/audit/admin.py new file mode 100644 index 0000000..921efe0 --- /dev/null +++ b/apps/audit/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import AgentAuditLog, DemoBusinessRecord + + +@admin.register(AgentAuditLog) +class AgentAuditLogAdmin(admin.ModelAdmin): + list_display = ("id", "scenario_name", "status", "model_name", "latency_ms", "created_at") + list_filter = ("status", "scenario_id") + search_fields = ("scenario_id", "scenario_name", "user_input", "final_answer") + + +@admin.register(DemoBusinessRecord) +class DemoBusinessRecordAdmin(admin.ModelAdmin): + list_display = ("id", "title", "scenario_id", "record_type", "created_at") + list_filter = ("scenario_id", "record_type") + search_fields = ("title", "scenario_id", "record_type") diff --git a/apps/audit/apps.py b/apps/audit/apps.py new file mode 100644 index 0000000..9ca5dc7 --- /dev/null +++ b/apps/audit/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuditConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.audit" diff --git a/apps/audit/migrations/0001_initial.py b/apps/audit/migrations/0001_initial.py new file mode 100644 index 0000000..ab1aa1d --- /dev/null +++ b/apps/audit/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.14 on 2026-05-29 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AgentAuditLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("scenario_id", models.CharField(db_index=True, max_length=100)), + ("scenario_name", models.CharField(blank=True, max_length=200)), + ("user_input", models.TextField()), + ("retrieved_chunks", models.JSONField(blank=True, default=list)), + ("tool_calls", models.JSONField(blank=True, default=list)), + ("structured_output", models.JSONField(blank=True, default=dict)), + ("final_answer", models.TextField(blank=True)), + ("raw_output", models.TextField(blank=True)), + ("model_name", models.CharField(blank=True, max_length=100)), + ("latency_ms", models.PositiveIntegerField(default=0)), + ( + "status", + models.CharField(db_index=True, default="success", max_length=20), + ), + ("error_message", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/audit/migrations/0002_demobusinessrecord.py b/apps/audit/migrations/0002_demobusinessrecord.py new file mode 100644 index 0000000..42d0387 --- /dev/null +++ b/apps/audit/migrations/0002_demobusinessrecord.py @@ -0,0 +1,35 @@ +# Generated for V1 demo business records. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DemoBusinessRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("scenario_id", models.CharField(db_index=True, max_length=100)), + ("record_type", models.CharField(db_index=True, max_length=100)), + ("title", models.CharField(max_length=255)), + ("payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/audit/migrations/__init__.py b/apps/audit/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/audit/models.py b/apps/audit/models.py new file mode 100644 index 0000000..5a2dc7d --- /dev/null +++ b/apps/audit/models.py @@ -0,0 +1,40 @@ +from django.db import models + + +class AgentAuditLog(models.Model): + STATUS_SUCCESS = "success" + STATUS_FAILED = "failed" + + scenario_id = models.CharField(max_length=100, db_index=True) + scenario_name = models.CharField(max_length=200, blank=True) + user_input = models.TextField() + retrieved_chunks = models.JSONField(default=list, blank=True) + tool_calls = models.JSONField(default=list, blank=True) + structured_output = models.JSONField(default=dict, blank=True) + final_answer = models.TextField(blank=True) + raw_output = models.TextField(blank=True) + model_name = models.CharField(max_length=100, blank=True) + latency_ms = models.PositiveIntegerField(default=0) + status = models.CharField(max_length=20, default=STATUS_SUCCESS, db_index=True) + error_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.scenario_name or self.scenario_id} #{self.pk}" + + +class DemoBusinessRecord(models.Model): + scenario_id = models.CharField(max_length=100, db_index=True) + record_type = models.CharField(max_length=100, db_index=True) + title = models.CharField(max_length=255) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.title diff --git a/apps/audit/services.py b/apps/audit/services.py new file mode 100644 index 0000000..120b41e --- /dev/null +++ b/apps/audit/services.py @@ -0,0 +1,36 @@ +from agent_core.results import AgentResult + +from .models import AgentAuditLog + + +def _mask_sensitive_text(value: str) -> str: + masked = value + for marker in ("LLM_API_KEY=", "EMBEDDING_API_KEY="): + if marker in masked: + prefix, _, suffix = masked.partition(marker) + secret, separator, rest = suffix.partition(" ") + masked_secret = "sk-***" if secret.startswith("sk-") else "***" + masked = f"{prefix}{marker}{masked_secret}{separator}{rest}" + return masked + + +def create_audit_log( + scenario_id: str, + scenario_name: str, + user_input: str, + agent_result: AgentResult, +) -> AgentAuditLog: + return AgentAuditLog.objects.create( + scenario_id=scenario_id, + scenario_name=scenario_name, + user_input=user_input, + retrieved_chunks=agent_result.references, + tool_calls=agent_result.tool_calls, + structured_output=agent_result.structured_output, + final_answer=agent_result.answer, + raw_output=agent_result.raw_output, + model_name=agent_result.model_name, + latency_ms=max(agent_result.latency_ms, 0), + status=agent_result.status, + error_message=_mask_sensitive_text(agent_result.error), + ) diff --git a/apps/audit/urls.py b/apps/audit/urls.py new file mode 100644 index 0000000..41443b9 --- /dev/null +++ b/apps/audit/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + + +app_name = "audit" + +urlpatterns = [ + path("", views.log_list, name="list"), + path("/", views.log_detail, name="detail"), +] diff --git a/apps/audit/views.py b/apps/audit/views.py new file mode 100644 index 0000000..11484fd --- /dev/null +++ b/apps/audit/views.py @@ -0,0 +1,13 @@ +from django.shortcuts import get_object_or_404, render + +from .models import AgentAuditLog + + +def log_list(request): + logs = AgentAuditLog.objects.all() + return render(request, "audit/log_list.html", {"logs": logs}) + + +def log_detail(request, log_id: int): + audit_log = get_object_or_404(AgentAuditLog, pk=log_id) + return render(request, "audit/log_detail.html", {"log": audit_log}) diff --git a/templates/audit/log_detail.html b/templates/audit/log_detail.html new file mode 100644 index 0000000..7f90f67 --- /dev/null +++ b/templates/audit/log_detail.html @@ -0,0 +1,37 @@ + + + + + 审计日志详情 + + +

审计日志详情 #{{ log.id }}

+ +
+

用户输入

+

{{ log.user_input }}

+
+
+

最终回答

+

{{ log.final_answer }}

+
+
+

结构化输出

+
{{ log.structured_output }}
+
+
+

引用来源

+
{{ log.retrieved_chunks }}
+
+
+

工具调用

+
{{ log.tool_calls }}
+
+ {% if log.error_message %} +
+

错误信息

+
{{ log.error_message }}
+
+ {% endif %} + + diff --git a/templates/audit/log_list.html b/templates/audit/log_list.html new file mode 100644 index 0000000..54c0823 --- /dev/null +++ b/templates/audit/log_list.html @@ -0,0 +1,37 @@ + + + + + 审计日志 + + +

审计日志

+ + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ID场景状态模型耗时详情
{{ log.id }}{{ log.scenario_name }}{{ log.status }}{{ log.model_name }}{{ log.latency_ms }} ms查看
暂无审计日志。
+ + diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..6a7d43b --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,54 @@ +from django.urls import reverse + +from agent_core.results import AgentResult +from apps.audit.models import AgentAuditLog, DemoBusinessRecord +from apps.audit.services import create_audit_log +from agent_core.tools.builtin_tools import query_demo_records + + +def test_create_audit_log_records_success_result(db): + result = AgentResult(answer="回答", structured_output={"x": 1}, status="success") + + log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result) + + assert AgentAuditLog.objects.count() == 1 + assert log.final_answer == "回答" + assert log.structured_output == {"x": 1} + assert log.status == "success" + + +def test_audit_list_page_shows_log(client, db): + result = AgentResult(answer="回答", status="success") + create_audit_log("knowledge_qa", "知识库问答助手", "问题", result) + + response = client.get(reverse("audit:list")) + + assert response.status_code == 200 + assert "知识库问答助手" in response.content.decode("utf-8") + + +def test_create_audit_log_masks_api_keys_from_error_message(db): + result = AgentResult( + answer="", + status="failed", + error="LLM_API_KEY=sk-secret-value 调用失败", + ) + + log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result) + + assert "sk-secret-value" not in log.error_message + assert "sk-***" in log.error_message + + +def test_query_demo_records_reads_demo_business_record_table(db): + DemoBusinessRecord.objects.create( + scenario_id="quality_analysis", + record_type="defect", + title="A线缺陷", + payload={"rate": 0.12}, + ) + + result = query_demo_records(user_input="quality_analysis defect") + + assert result["records"][0]["title"] == "A线缺陷" + assert result["records"][0]["payload"] == {"rate": 0.12}