feat(audit): 增加审计日志与演示数据管理
This commit is contained in:
1
apps/audit/__init__.py
Normal file
1
apps/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
17
apps/audit/admin.py
Normal file
17
apps/audit/admin.py
Normal file
@@ -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")
|
||||||
6
apps/audit/apps.py
Normal file
6
apps/audit/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuditConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.audit"
|
||||||
46
apps/audit/migrations/0001_initial.py
Normal file
46
apps/audit/migrations/0001_initial.py
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
35
apps/audit/migrations/0002_demobusinessrecord.py
Normal file
35
apps/audit/migrations/0002_demobusinessrecord.py
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/audit/migrations/__init__.py
Normal file
0
apps/audit/migrations/__init__.py
Normal file
40
apps/audit/models.py
Normal file
40
apps/audit/models.py
Normal file
@@ -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
|
||||||
36
apps/audit/services.py
Normal file
36
apps/audit/services.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
11
apps/audit/urls.py
Normal file
11
apps/audit/urls.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "audit"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.log_list, name="list"),
|
||||||
|
path("<int:log_id>/", views.log_detail, name="detail"),
|
||||||
|
]
|
||||||
13
apps/audit/views.py
Normal file
13
apps/audit/views.py
Normal file
@@ -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})
|
||||||
37
templates/audit/log_detail.html
Normal file
37
templates/audit/log_detail.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>审计日志详情</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>审计日志详情 #{{ log.id }}</h1>
|
||||||
|
<nav><a href="{% url 'audit:list' %}">返回审计列表</a></nav>
|
||||||
|
<section>
|
||||||
|
<h2>用户输入</h2>
|
||||||
|
<p>{{ log.user_input }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>最终回答</h2>
|
||||||
|
<p>{{ log.final_answer }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>结构化输出</h2>
|
||||||
|
<pre>{{ log.structured_output }}</pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>引用来源</h2>
|
||||||
|
<pre>{{ log.retrieved_chunks }}</pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>工具调用</h2>
|
||||||
|
<pre>{{ log.tool_calls }}</pre>
|
||||||
|
</section>
|
||||||
|
{% if log.error_message %}
|
||||||
|
<section>
|
||||||
|
<h2>错误信息</h2>
|
||||||
|
<pre>{{ log.error_message }}</pre>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
templates/audit/log_list.html
Normal file
37
templates/audit/log_list.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>审计日志</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>审计日志</h1>
|
||||||
|
<nav><a href="/">返回首页</a></nav>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>场景</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>耗时</th>
|
||||||
|
<th>详情</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.id }}</td>
|
||||||
|
<td>{{ log.scenario_name }}</td>
|
||||||
|
<td>{{ log.status }}</td>
|
||||||
|
<td>{{ log.model_name }}</td>
|
||||||
|
<td>{{ log.latency_ms }} ms</td>
|
||||||
|
<td><a href="{% url 'audit:detail' log.id %}">查看</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="6">暂无审计日志。</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
tests/test_audit.py
Normal file
54
tests/test_audit.py
Normal file
@@ -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}
|
||||||
Reference in New Issue
Block a user