feat: 重构资料包模型与会话绑定主链路
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-03 16:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("audit", "0002_demobusinessrecord"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="NotificationRecord",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("batch_id", models.CharField(db_index=True, max_length=64)),
|
||||
("conversation_id", models.CharField(db_index=True, max_length=64)),
|
||||
(
|
||||
"product_name",
|
||||
models.CharField(blank=True, db_index=True, max_length=255),
|
||||
),
|
||||
("trigger_source", models.CharField(blank=True, max_length=64)),
|
||||
("notify_reason", models.CharField(db_index=True, max_length=32)),
|
||||
("owner_role", models.CharField(blank=True, max_length=100)),
|
||||
("feishu_user_id", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"message_status",
|
||||
models.CharField(db_index=True, default="pending", max_length=32),
|
||||
),
|
||||
("web_detail_url", models.URLField(blank=True)),
|
||||
("receipt", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="agentauditlog",
|
||||
name="batch_id",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="agentauditlog",
|
||||
name="conversation_id",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="agentauditlog",
|
||||
name="product_name",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -16,6 +16,9 @@ class AgentAuditLog(models.Model):
|
||||
|
||||
scenario_id = models.CharField(max_length=100, db_index=True)
|
||||
scenario_name = models.CharField(max_length=200, blank=True)
|
||||
batch_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
conversation_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
product_name = models.CharField(max_length=255, blank=True, db_index=True)
|
||||
user_input = models.TextField()
|
||||
retrieved_chunks = models.JSONField(default=list, blank=True)
|
||||
tool_calls = models.JSONField(default=list, blank=True)
|
||||
@@ -66,3 +69,33 @@ class DemoBusinessRecord(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class NotificationRecord(models.Model):
|
||||
"""
|
||||
飞书通知留痕。
|
||||
|
||||
首版只保存离线通知载荷与结果状态,不直接依赖真实飞书网络。
|
||||
"""
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_SENT = "sent"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
batch_id = models.CharField(max_length=64, db_index=True)
|
||||
conversation_id = models.CharField(max_length=64, db_index=True)
|
||||
product_name = models.CharField(max_length=255, blank=True, db_index=True)
|
||||
trigger_source = models.CharField(max_length=64, blank=True)
|
||||
notify_reason = models.CharField(max_length=32, db_index=True)
|
||||
owner_role = models.CharField(max_length=100, blank=True)
|
||||
feishu_user_id = models.CharField(max_length=100, blank=True)
|
||||
message_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
|
||||
web_detail_url = models.URLField(blank=True)
|
||||
receipt = models.JSONField(default=dict, 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.notify_reason}:{self.batch_id}"
|
||||
|
||||
@@ -8,6 +8,9 @@ def create_audit_log(
|
||||
scenario_name: str,
|
||||
user_input: str,
|
||||
agent_result: AgentResult,
|
||||
batch_id: str = "",
|
||||
conversation_id: str = "",
|
||||
product_name: str = "",
|
||||
) -> AgentAuditLog:
|
||||
"""
|
||||
将一次 Agent 执行结果落库为审计日志。
|
||||
@@ -20,6 +23,9 @@ def create_audit_log(
|
||||
return AgentAuditLog.objects.create(
|
||||
scenario_id=scenario_id,
|
||||
scenario_name=scenario_name,
|
||||
batch_id=batch_id,
|
||||
conversation_id=conversation_id,
|
||||
product_name=product_name,
|
||||
user_input=user_input,
|
||||
retrieved_chunks=agent_result.references,
|
||||
tool_calls=agent_result.tool_calls,
|
||||
|
||||
52
apps/chat/migrations/0001_initial.py
Normal file
52
apps/chat/migrations/0001_initial.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-03 16:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Conversation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"conversation_id",
|
||||
models.CharField(db_index=True, max_length=64, unique=True),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
(
|
||||
"product_name",
|
||||
models.CharField(blank=True, db_index=True, max_length=255),
|
||||
),
|
||||
(
|
||||
"batch_id",
|
||||
models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
(
|
||||
"task_status",
|
||||
models.CharField(db_index=True, default="pending", max_length=32),
|
||||
),
|
||||
("node_results", models.JSONField(blank=True, default=list)),
|
||||
("latest_summary", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("last_run_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at", "-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
34
apps/chat/models.py
Normal file
34
apps/chat/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Conversation(models.Model):
|
||||
"""
|
||||
审核智能体会话主对象。
|
||||
|
||||
会话与资料包一一绑定,标题默认使用解析出的产品名称,
|
||||
节点结果使用 JSON 挂载,便于页面按节点展示。
|
||||
"""
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_PROCESSING = "processing"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_REVIEW_REQUIRED = "review_required"
|
||||
STATUS_BLOCKED = "blocked"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
conversation_id = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
title = models.CharField(max_length=255)
|
||||
product_name = models.CharField(max_length=255, blank=True, db_index=True)
|
||||
batch_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
task_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
|
||||
node_results = models.JSONField(default=list, blank=True)
|
||||
latest_summary = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at", "-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
26
apps/chat/services.py
Normal file
26
apps/chat/services.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from .models import Conversation
|
||||
|
||||
|
||||
def create_conversation_for_batch(batch_id: str, product_name: str) -> Conversation:
|
||||
"""
|
||||
为资料包创建主会话。
|
||||
|
||||
会话标题固定优先使用解析出的产品名称,
|
||||
缺失时回退到批次号,确保前台始终有稳定标题。
|
||||
"""
|
||||
conversation = Conversation.objects.create(
|
||||
conversation_id=_generate_conversation_id(),
|
||||
title=product_name or f"未命名资料包-{batch_id}",
|
||||
product_name=product_name,
|
||||
batch_id=batch_id,
|
||||
task_status=Conversation.STATUS_PENDING,
|
||||
node_results=[
|
||||
{"code": "package_import", "label": "资料包导入", "status": "已完成"},
|
||||
{"code": "overview", "label": "目录汇总", "status": "处理中"},
|
||||
],
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
def _generate_conversation_id() -> str:
|
||||
return f"conv-{Conversation.objects.count() + 1:03d}"
|
||||
@@ -5,7 +5,8 @@ from . import views
|
||||
|
||||
app_name = "chat"
|
||||
|
||||
# 当前 V1 仅保留一个场景对话入口,场景详情合并在对话页中展示。
|
||||
# 审核智能体前台以会话为中心。
|
||||
urlpatterns = [
|
||||
path("<str:scenario_id>/", views.index, name="index"),
|
||||
path("", views.index, name="index"),
|
||||
path("<str:conversation_id>/", views.detail, name="detail"),
|
||||
]
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from agent_core.orchestrator import run_agent
|
||||
from agent_core.results import AgentResult
|
||||
from apps.audit.services import create_audit_log
|
||||
from apps.documents.models import UploadedDocument
|
||||
from apps.scenarios.services import ScenarioNotFound, get_scenario
|
||||
from apps.documents.models import SubmissionBatch, UploadedDocument
|
||||
from apps.scenarios.services import get_scenario
|
||||
|
||||
from .forms import ChatForm
|
||||
from .models import Conversation
|
||||
|
||||
|
||||
def index(request, scenario_id: str):
|
||||
# View 只负责请求编排、表单校验和模板渲染。
|
||||
# 具体 Agent 执行、审计写入和文档筛选规则分别交给独立模块处理。
|
||||
try:
|
||||
scenario = get_scenario(scenario_id)
|
||||
except ScenarioNotFound:
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"scenario": None,
|
||||
"form": ChatForm(),
|
||||
"error": "场景不存在,请返回首页检查配置。",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
def index(request):
|
||||
conversations = Conversation.objects.all()
|
||||
if conversations.exists():
|
||||
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"conversation": None,
|
||||
"conversations": [],
|
||||
"form": ChatForm(),
|
||||
"documents": [],
|
||||
"result": None,
|
||||
"audit_log": None,
|
||||
"node_results": [],
|
||||
"active_node": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def detail(request, conversation_id: str):
|
||||
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
|
||||
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
|
||||
documents = UploadedDocument.objects.filter(batch=batch)
|
||||
form = ChatForm(request.POST or None, documents=documents)
|
||||
result = None
|
||||
audit_log = None
|
||||
documents = UploadedDocument.objects.filter(
|
||||
scenario_id=scenario["id"],
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
form = ChatForm(request.POST or None, documents=documents)
|
||||
active_node = None
|
||||
task_modes = [
|
||||
{"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"},
|
||||
{"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"},
|
||||
@@ -41,28 +46,46 @@ def index(request, scenario_id: str):
|
||||
{"name": "综合风险报告", "description": "形成高优先级问题、建议动作和责任人通知。"},
|
||||
]
|
||||
if request.method == "POST" and form.is_valid():
|
||||
scenario = get_scenario("document_review")
|
||||
message = form.cleaned_data["message"]
|
||||
try:
|
||||
# 只把必要的运行选项传给 Agent Core,避免在 View 中散落模型细节。
|
||||
result = run_agent(
|
||||
scenario,
|
||||
message,
|
||||
options={"document_ids": form.cleaned_data["document_ids"]},
|
||||
options={
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"batch_id": conversation.batch_id,
|
||||
"product_name": conversation.product_name,
|
||||
"document_ids": form.cleaned_data["document_ids"],
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
result = AgentResult(status="failed", error=str(exc), answer="")
|
||||
audit_log = create_audit_log(scenario["id"], scenario["name"], message, result)
|
||||
audit_log = create_audit_log(
|
||||
"document_review",
|
||||
"注册审核智能体",
|
||||
message,
|
||||
result,
|
||||
batch_id=conversation.batch_id,
|
||||
conversation_id=conversation.conversation_id,
|
||||
product_name=conversation.product_name,
|
||||
)
|
||||
active_node = "risk"
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"scenario": scenario,
|
||||
"conversation": conversation,
|
||||
"conversations": Conversation.objects.all(),
|
||||
"batch": batch,
|
||||
"form": form,
|
||||
"documents": documents,
|
||||
"document_count": documents.count(),
|
||||
"result": result,
|
||||
"audit_log": audit_log,
|
||||
"task_modes": task_modes,
|
||||
"node_results": conversation.node_results,
|
||||
"active_node": active_node,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-03 16:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("documents", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SubmissionBatch",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"batch_id",
|
||||
models.CharField(db_index=True, max_length=64, unique=True),
|
||||
),
|
||||
(
|
||||
"product_name",
|
||||
models.CharField(blank=True, db_index=True, max_length=255),
|
||||
),
|
||||
(
|
||||
"workflow_type",
|
||||
models.CharField(default="registration", max_length=64),
|
||||
),
|
||||
(
|
||||
"conversation_id",
|
||||
models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
("file_count", models.PositiveIntegerField(default=0)),
|
||||
("page_count", models.PositiveIntegerField(default=0)),
|
||||
("chapter_summary", models.JSONField(blank=True, default=list)),
|
||||
(
|
||||
"import_status",
|
||||
models.CharField(db_index=True, default="pending", max_length=32),
|
||||
),
|
||||
("exception_count", models.PositiveIntegerField(default=0)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="chapter_code",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="chapter_match_status",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="document_role",
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="needs_manual_review",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="page_count",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="page_count_confidence",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="relative_path",
|
||||
field=models.CharField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="uploadeddocument",
|
||||
name="batch",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="documents",
|
||||
to="documents.submissionbatch",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,48 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SubmissionBatch(models.Model):
|
||||
"""
|
||||
资料包主对象,承接导入、会话绑定和目录汇总结果。
|
||||
|
||||
Documents 模块负责维护资料包与文件的关系,
|
||||
不在模型层耦合 Agent 执行细节。
|
||||
"""
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_PROCESSING = "processing"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_REVIEW_REQUIRED = "review_required"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
batch_id = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
product_name = models.CharField(max_length=255, blank=True, db_index=True)
|
||||
workflow_type = models.CharField(max_length=64, default="registration")
|
||||
conversation_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
file_count = models.PositiveIntegerField(default=0)
|
||||
page_count = models.PositiveIntegerField(default=0)
|
||||
chapter_summary = models.JSONField(default=list, blank=True)
|
||||
import_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
|
||||
exception_count = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.product_name or self.batch_id
|
||||
|
||||
def get_import_status_display_text(self) -> str:
|
||||
return {
|
||||
self.STATUS_PENDING: "待导入",
|
||||
self.STATUS_PROCESSING: "处理中",
|
||||
self.STATUS_COMPLETED: "已完成",
|
||||
self.STATUS_REVIEW_REQUIRED: "待复核",
|
||||
self.STATUS_FAILED: "失败",
|
||||
}.get(self.import_status, self.import_status)
|
||||
|
||||
|
||||
class UploadedDocument(models.Model):
|
||||
"""
|
||||
保存用户上传文档的元数据和入库状态。
|
||||
@@ -13,11 +55,25 @@ class UploadedDocument(models.Model):
|
||||
STATUS_INDEXED = "indexed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
batch = models.ForeignKey(
|
||||
SubmissionBatch,
|
||||
related_name="documents",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
scenario_id = models.CharField(max_length=100, db_index=True)
|
||||
original_name = models.CharField(max_length=255)
|
||||
file = models.FileField(upload_to="documents/%Y%m%d/")
|
||||
file_type = models.CharField(max_length=20)
|
||||
size = models.PositiveIntegerField(default=0)
|
||||
relative_path = models.CharField(max_length=500, blank=True)
|
||||
chapter_code = models.CharField(max_length=32, blank=True)
|
||||
document_role = models.CharField(max_length=64, blank=True)
|
||||
page_count = models.PositiveIntegerField(default=0)
|
||||
page_count_confidence = models.CharField(max_length=32, blank=True)
|
||||
chapter_match_status = models.CharField(max_length=32, blank=True)
|
||||
needs_manual_review = models.BooleanField(default=False)
|
||||
status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -4,11 +4,12 @@ import xml.etree.ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from agent_core.rag.ingest import ingest_document
|
||||
from apps.chat.services import create_conversation_for_batch
|
||||
|
||||
from .models import UploadedDocument
|
||||
from .models import SubmissionBatch, UploadedDocument
|
||||
|
||||
|
||||
def create_uploaded_document(scenario_id: str, uploaded_file) -> UploadedDocument:
|
||||
def create_uploaded_document(scenario_id: str, uploaded_file, batch: SubmissionBatch | None = None) -> UploadedDocument:
|
||||
"""
|
||||
保存上传文件的元数据记录。
|
||||
|
||||
@@ -17,15 +18,116 @@ def create_uploaded_document(scenario_id: str, uploaded_file) -> UploadedDocumen
|
||||
"""
|
||||
extension = _detect_extension(uploaded_file.name)
|
||||
return UploadedDocument.objects.create(
|
||||
batch=batch,
|
||||
scenario_id=scenario_id,
|
||||
original_name=uploaded_file.name,
|
||||
file=uploaded_file,
|
||||
file_type=extension,
|
||||
size=uploaded_file.size,
|
||||
relative_path=uploaded_file.name,
|
||||
status=UploadedDocument.STATUS_UPLOADED,
|
||||
)
|
||||
|
||||
|
||||
def import_submission_batch(scenario_id: str, uploaded_files: list) -> dict:
|
||||
"""
|
||||
导入资料包并建立批次、文档、目录汇总和主会话。
|
||||
|
||||
当前实现保持离线稳定,重点保证:
|
||||
- 资料包记录可落库
|
||||
- 产品名称可解析
|
||||
- 会话可自动绑定
|
||||
- 可直接产出 overview report
|
||||
"""
|
||||
batch = SubmissionBatch.objects.create(
|
||||
batch_id=_generate_batch_id(),
|
||||
workflow_type="registration",
|
||||
import_status=SubmissionBatch.STATUS_PROCESSING,
|
||||
)
|
||||
documents = []
|
||||
candidates = []
|
||||
chapter_summary = {}
|
||||
total_pages = 0
|
||||
|
||||
for uploaded_file in uploaded_files:
|
||||
document = create_uploaded_document(scenario_id, uploaded_file, batch=batch)
|
||||
text = extract_text(document)
|
||||
page_count = _estimate_page_count(text)
|
||||
document.page_count = page_count
|
||||
document.page_count_confidence = "estimated"
|
||||
document.document_role = _detect_document_role(document.original_name)
|
||||
document.chapter_code = _detect_chapter_code(document.original_name, text)
|
||||
document.chapter_match_status = "matched" if document.chapter_code else "unknown"
|
||||
document.needs_manual_review = not bool(document.chapter_code)
|
||||
document.save(
|
||||
update_fields=[
|
||||
"page_count",
|
||||
"page_count_confidence",
|
||||
"document_role",
|
||||
"chapter_code",
|
||||
"chapter_match_status",
|
||||
"needs_manual_review",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
documents.append(document)
|
||||
total_pages += page_count
|
||||
chapter_key = document.chapter_code or "UNCLASSIFIED"
|
||||
chapter_summary[chapter_key] = chapter_summary.get(chapter_key, 0) + 1
|
||||
candidates.extend(_extract_product_candidates(document.original_name, text))
|
||||
|
||||
product_name, warnings = _select_product_name(candidates)
|
||||
conversation = create_conversation_for_batch(batch.batch_id, product_name)
|
||||
|
||||
batch.product_name = product_name
|
||||
batch.conversation_id = conversation.conversation_id
|
||||
batch.file_count = len(documents)
|
||||
batch.page_count = total_pages
|
||||
batch.chapter_summary = [
|
||||
{"chapter_code": chapter_code, "document_count": count}
|
||||
for chapter_code, count in sorted(chapter_summary.items())
|
||||
]
|
||||
batch.exception_count = len(warnings)
|
||||
batch.import_status = (
|
||||
SubmissionBatch.STATUS_REVIEW_REQUIRED if warnings else SubmissionBatch.STATUS_COMPLETED
|
||||
)
|
||||
batch.save(
|
||||
update_fields=[
|
||||
"product_name",
|
||||
"conversation_id",
|
||||
"file_count",
|
||||
"page_count",
|
||||
"chapter_summary",
|
||||
"exception_count",
|
||||
"import_status",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return {
|
||||
"batch_id": batch.batch_id,
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"product_name": batch.product_name,
|
||||
"registration_overview_report": {
|
||||
"batch_id": batch.batch_id,
|
||||
"product_name": batch.product_name,
|
||||
"file_count": batch.file_count,
|
||||
"total_page_count": batch.page_count,
|
||||
"chapter_summary": batch.chapter_summary,
|
||||
"documents": [
|
||||
{
|
||||
"document_id": document.id,
|
||||
"original_name": document.original_name,
|
||||
"chapter_code": document.chapter_code,
|
||||
"page_count": document.page_count,
|
||||
"document_role": document.document_role,
|
||||
}
|
||||
for document in documents
|
||||
],
|
||||
"warnings": warnings,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def extract_text(document: UploadedDocument) -> str:
|
||||
"""
|
||||
根据文档类型选择合适的文本抽取策略。
|
||||
@@ -83,6 +185,99 @@ def _detect_extension(file_name: str) -> str:
|
||||
return Path(file_name).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _generate_batch_id() -> str:
|
||||
return f"SUB-20260604-{SubmissionBatch.objects.count() + 1:03d}"
|
||||
|
||||
|
||||
def _estimate_page_count(text: str) -> int:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return 0
|
||||
line_count = len([line for line in stripped.splitlines() if line.strip()])
|
||||
return max(1, line_count)
|
||||
|
||||
|
||||
def _detect_document_role(file_name: str) -> str:
|
||||
normalized = file_name.lower()
|
||||
if "申请表" in file_name:
|
||||
return "application_form"
|
||||
if "说明书" in file_name:
|
||||
return "product_manual"
|
||||
if "产品列表" in file_name:
|
||||
return "product_list"
|
||||
if "声明" in file_name:
|
||||
return "declaration"
|
||||
if normalized.endswith(".pdf"):
|
||||
return "pdf_document"
|
||||
return "general_document"
|
||||
|
||||
|
||||
def _detect_chapter_code(file_name: str, text: str) -> str:
|
||||
for source in (file_name, text):
|
||||
match = re.search(r"(CH\d+(?:\.\d+)*)", source, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).upper()
|
||||
if "监管" in file_name or "申请表" in file_name or "说明书" in file_name:
|
||||
return "CH1"
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_product_candidates(file_name: str, text: str) -> list[dict]:
|
||||
source_type = _detect_candidate_source(file_name)
|
||||
if not source_type:
|
||||
return []
|
||||
patterns = [
|
||||
r"产品名称[::]\s*([^\n\r]+)",
|
||||
r"名称[::]\s*([^\n\r]+检测试剂盒[^\n\r]*)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
return [{"source_type": source_type, "product_name": match.group(1).strip()}]
|
||||
cleaned = Path(file_name).stem.replace("目标产品", "").replace("说明书", "").strip("-_ ")
|
||||
if cleaned and "申请表" not in cleaned and "产品列表" not in cleaned:
|
||||
return [{"source_type": source_type, "product_name": cleaned}]
|
||||
return []
|
||||
|
||||
|
||||
def _detect_candidate_source(file_name: str) -> str:
|
||||
if "申请表" in file_name:
|
||||
return "application_form"
|
||||
if "说明书" in file_name:
|
||||
return "product_manual"
|
||||
if "产品列表" in file_name:
|
||||
return "product_list"
|
||||
return ""
|
||||
|
||||
|
||||
def _select_product_name(candidates: list[dict]) -> tuple[str, list[str]]:
|
||||
if not candidates:
|
||||
return "", ["未识别到产品名称,建议人工补录。"]
|
||||
|
||||
priority = {
|
||||
"application_form": 1,
|
||||
"product_manual": 2,
|
||||
"product_list": 3,
|
||||
}
|
||||
sorted_candidates = sorted(
|
||||
candidates,
|
||||
key=lambda item: priority.get(item["source_type"], 99),
|
||||
)
|
||||
top_candidate = sorted_candidates[0]
|
||||
warnings = []
|
||||
conflict_names = {
|
||||
item["product_name"]
|
||||
for item in sorted_candidates
|
||||
if item["product_name"] != top_candidate["product_name"]
|
||||
}
|
||||
if conflict_names:
|
||||
warnings.append(
|
||||
"产品名称来源冲突:"
|
||||
+ " / ".join([top_candidate["product_name"], *sorted(conflict_names)])
|
||||
)
|
||||
return top_candidate["product_name"], warnings
|
||||
|
||||
|
||||
def _read_text_file(path: Path) -> str:
|
||||
"""优先按 UTF-8 读取;失败时回退到系统默认编码。"""
|
||||
try:
|
||||
|
||||
@@ -5,18 +5,24 @@ from django.views.decorators.http import require_POST
|
||||
from apps.scenarios.services import list_scenarios
|
||||
|
||||
from .forms import DocumentUploadForm
|
||||
from .models import UploadedDocument
|
||||
from .services import create_uploaded_document, index_document
|
||||
from .models import SubmissionBatch, UploadedDocument
|
||||
from .services import import_submission_batch, index_document
|
||||
|
||||
|
||||
def document_list(request):
|
||||
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节。
|
||||
# 资料包页展示批次、会话绑定和关键异常,同时保留文档级明细便于演示。
|
||||
keyword = (request.GET.get("keyword") or "").strip()
|
||||
batches = SubmissionBatch.objects.all()
|
||||
if keyword:
|
||||
batches = batches.filter(product_name__icontains=keyword)
|
||||
documents = UploadedDocument.objects.all()
|
||||
status_counts = {
|
||||
"uploaded": documents.filter(status=UploadedDocument.STATUS_UPLOADED).count(),
|
||||
"indexed": documents.filter(status=UploadedDocument.STATUS_INDEXED).count(),
|
||||
"failed": documents.filter(status=UploadedDocument.STATUS_FAILED).count(),
|
||||
"total": documents.count(),
|
||||
"pending": batches.filter(import_status=SubmissionBatch.STATUS_PENDING).count(),
|
||||
"completed": batches.filter(import_status=SubmissionBatch.STATUS_COMPLETED).count(),
|
||||
"review_required": batches.filter(
|
||||
import_status=SubmissionBatch.STATUS_REVIEW_REQUIRED
|
||||
).count(),
|
||||
"total": batches.count(),
|
||||
}
|
||||
processing_pipeline = [
|
||||
{"title": "原始文件接收", "detail": "校验格式、大小和场景归属后保存原件。"},
|
||||
@@ -35,6 +41,8 @@ def document_list(request):
|
||||
"documents/document_list.html",
|
||||
{
|
||||
"documents": documents,
|
||||
"batches": batches,
|
||||
"keyword": keyword,
|
||||
"status_counts": status_counts,
|
||||
"processing_pipeline": processing_pipeline,
|
||||
"exception_items": exception_items,
|
||||
@@ -43,12 +51,18 @@ def document_list(request):
|
||||
|
||||
|
||||
def upload(request):
|
||||
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发。
|
||||
# 上传成功后直接创建资料包并绑定主会话。
|
||||
if request.method == "POST":
|
||||
form = DocumentUploadForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
|
||||
messages.success(request, "文件已上传,可继续执行入库。")
|
||||
result = import_submission_batch(
|
||||
form.cleaned_data["scenario_id"],
|
||||
[form.cleaned_data["file"]],
|
||||
)
|
||||
messages.success(
|
||||
request,
|
||||
f"资料包已导入,已绑定会话 {result['conversation_id']}。",
|
||||
)
|
||||
return redirect("documents:list")
|
||||
else:
|
||||
form = DocumentUploadForm()
|
||||
|
||||
@@ -363,14 +363,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="topnav">
|
||||
<a href="{% url 'scenarios:index' %}">总览</a>
|
||||
<a href="{% url 'documents:list' %}">文件中心</a>
|
||||
<a href="{% url 'chat:index' 'document_review' %}">审核工作台</a>
|
||||
<a href="{% url 'chat:index' %}">审核智能体</a>
|
||||
<a href="{% url 'documents:list' %}">资料包</a>
|
||||
<a href="{% url 'platform_ui:knowledge-base' %}">知识库</a>
|
||||
<a href="{% url 'platform_ui:mcp-center' %}">MCP</a>
|
||||
<a href="{% url 'platform_ui:skills' %}">Skills</a>
|
||||
<a href="{% url 'platform_ui:command-center' %}">工作台</a>
|
||||
<a href="{% url 'audit:list' %}">审计</a>
|
||||
<a href="{% url 'audit:list' %}">处理历史</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ scenario.name|default:"Agent 审核工作台" }}{% endblock %}
|
||||
{% block title %}审核智能体{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}
|
||||
<section class="notice notice-error">{{ error }}</section>
|
||||
{% endif %}
|
||||
|
||||
{% if scenario %}
|
||||
<section class="page-header">
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h1 class="page-title">{{ scenario.name }}</h1>
|
||||
<p class="page-lead">左侧输入问题和选择文档,右侧查看执行结果。</p>
|
||||
<section class="page-header">
|
||||
<span class="eyebrow">Agent Workspace</span>
|
||||
<h1 class="page-title">审核智能体</h1>
|
||||
<p class="page-lead">以会话为中心组织资料包上传、节点式审核结果和动态任务信息卡。</p>
|
||||
{% if conversation %}
|
||||
<div class="badge-row">
|
||||
<span class="pill pill-accent">已入库文档:{{ document_count }}</span>
|
||||
<span class="pill">输出:{{ scenario.output.type }}</span>
|
||||
<span class="pill pill-accent">批次:{{ conversation.batch_id }}</span>
|
||||
<span class="pill">产品:{{ conversation.product_name|default:"未识别产品名称" }}</span>
|
||||
<span class="pill">阶段:{{ conversation.task_status }}</span>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="workspace-grid">
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">任务输入与资料范围</h2>
|
||||
<p class="section-copy">左侧突出受控输入:先描述审核目标,再限定本轮使用的文档范围。</p>
|
||||
</div>
|
||||
<section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;">
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">会话历史</h2>
|
||||
<p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称。</p>
|
||||
<ul class="detail-list">
|
||||
{% for item in conversations %}
|
||||
<li class="detail-item">
|
||||
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
|
||||
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
|
||||
<div class="muted">批次:{{ item.batch_id }}</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="detail-item">暂无会话,请先从资料包页面导入资料。</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">对话区与节点导航</h2>
|
||||
<p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要。</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if conversation %}
|
||||
<div class="badge-row" style="margin-bottom: 14px;">
|
||||
{% for node in node_results %}
|
||||
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" class="stack">
|
||||
{% csrf_token %}
|
||||
@@ -38,7 +60,6 @@
|
||||
</div>
|
||||
<div>
|
||||
{{ form.document_ids.label_tag }}
|
||||
<p class="help-text">不勾选时默认使用全部已入库文档。</p>
|
||||
<div class="checkbox-list">
|
||||
{% for checkbox in form.document_ids %}
|
||||
<label class="checkbox-item">
|
||||
@@ -46,136 +67,76 @@
|
||||
<span>{{ checkbox.choice_label }}</span>
|
||||
</label>
|
||||
{% empty %}
|
||||
<div class="notice">当前场景还没有已入库文档,系统将仅依赖工具和模型能力生成结果。</div>
|
||||
<div class="notice">当前资料包还没有可选文档。</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.document_ids.errors %}
|
||||
<p class="notice notice-error">{{ form.document_ids.errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button type="submit">提交问题并执行 Agent</button>
|
||||
<button type="submit">提交审核任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">快捷示例</h2>
|
||||
<ul class="detail-list">
|
||||
<li class="detail-item">检查当前资料是否存在缺失项</li>
|
||||
<li class="detail-item">抽取说明书中的关键字段</li>
|
||||
<li class="detail-item">比较两份文档中的产品名称是否一致</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">结果</h2>
|
||||
{% if result %}
|
||||
<ul class="meta-list">
|
||||
<li class="meta-badge">模型:{{ result.model_name }}</li>
|
||||
<li class="meta-badge {% if result.status == 'success' %}status-success{% else %}status-failed{% endif %}">状态:{{ result.status }}</li>
|
||||
<li class="meta-badge">耗时:{{ result.latency_ms }} ms</li>
|
||||
</ul>
|
||||
<div class="detail-item" style="margin-top: 16px;">
|
||||
<strong>主回答</strong>
|
||||
<strong>Agent 回答</strong>
|
||||
<div>{{ result.answer|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notice">提交任务后,这里会展示 Agent 的执行状态、主回答和过程摘要。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if result %}
|
||||
<article class="panel">
|
||||
<h2 class="section-title">证据引用与工具调用</h2>
|
||||
<p class="muted" style="margin-bottom: 14px;">引用片段与工具调用用于支撑结果可解释性。</p>
|
||||
{% if result.references %}
|
||||
<h3 style="margin-top: 0;">引用片段</h3>
|
||||
<ul class="detail-list" style="margin-bottom: 16px;">
|
||||
{% for reference in result.references %}
|
||||
<li class="detail-item">
|
||||
<strong>{{ reference.source }}</strong>
|
||||
<div>{{ reference.content|default:"无正文内容"|linebreaksbr }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="notice" style="margin-bottom: 16px;">当前回答没有引用知识库片段。</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.tool_calls %}
|
||||
<h3>工具调用</h3>
|
||||
<ul class="detail-list">
|
||||
{% for tool_call in result.tool_calls %}
|
||||
<li class="detail-item">
|
||||
<strong>{{ tool_call.tool_name }}</strong>
|
||||
<p class="muted">执行状态:{{ tool_call.success }}</p>
|
||||
{% if tool_call.error %}
|
||||
<p class="notice notice-error">{{ tool_call.error }}</p>
|
||||
{% endif %}
|
||||
<pre class="code-block">{{ tool_call.result }}</pre>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="notice">当前场景没有声明工具,或本次执行无需调用工具。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if result.error %}
|
||||
<article class="panel">
|
||||
<h2 class="section-title">错误信息</h2>
|
||||
<pre class="code-block">{{ result.error }}</pre>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="notice">暂无会话,请先导入资料包。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">结构化审核结果</h2>
|
||||
<p class="section-copy">右侧结果舱用于展示缺失项、冲突项、字段池结果或风险清单。</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if result %}
|
||||
<table class="kv-table">
|
||||
<caption style="text-align:left; padding-bottom:12px; color:var(--ink-soft);">结构化结果</caption>
|
||||
<tbody>
|
||||
{% for key, value in result.structured_output.items %}
|
||||
<tr>
|
||||
<th>{{ key }}</th>
|
||||
<td>
|
||||
{% if key == "answer" or key == "summary" or key == "reply" %}
|
||||
{{ value|linebreaksbr }}
|
||||
{% else %}
|
||||
<pre class="code-block">{{ value }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="notice">执行任务后,这里会展示结构化审核结果和回填准备信息。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2 class="section-title">节点式结果</h2>
|
||||
{% if result and result.structured_output %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{% for key, value in result.structured_output.items %}
|
||||
<tr>
|
||||
<th>{{ key }}</th>
|
||||
<td><pre class="code-block">{{ value }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="notice">执行任务后,这里会展示结构化节点结果。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">引用与审计</h2>
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">上传区</h2>
|
||||
<p class="section-copy">资料包导入入口在资料包页统一维护,当前会话只展示绑定关系。</p>
|
||||
{% if batch %}
|
||||
<ul class="detail-list">
|
||||
<li class="detail-item">可查看引用片段、工具调用和本次审计日志。</li>
|
||||
<li class="detail-item">
|
||||
<strong>当前资料包</strong>
|
||||
<div>批次:{{ batch.batch_id }}</div>
|
||||
<div>文件数:{{ batch.file_count }}</div>
|
||||
<div>页数:{{ batch.page_count }}</div>
|
||||
<div>导入状态:{{ batch.get_import_status_display_text }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="button-row" style="margin-top: 16px;">
|
||||
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notice">暂无绑定资料包。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">动态信息卡</h2>
|
||||
<ul class="detail-list">
|
||||
<li class="detail-item">当前会话围绕 `conversation_id / batch_id / product_name` 串联。</li>
|
||||
<li class="detail-item">任务模式:目录汇总、完整性检查、字段抽取、一致性核查、风险预警。</li>
|
||||
{% if audit_log %}
|
||||
<div class="button-row" style="margin-top: 16px;">
|
||||
<a class="button" href="{% url 'audit:detail' audit_log.id %}">查看本次审计日志</a>
|
||||
</div>
|
||||
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,36 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}文件中心{% endblock %}
|
||||
{% block title %}资料包{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header">
|
||||
<span class="eyebrow">Documents</span>
|
||||
<h1 class="page-title">文件中心</h1>
|
||||
<p class="page-lead">上传资料、查看状态、执行入库。页面只保留最常用操作。</p>
|
||||
<span class="eyebrow">Submission Batches</span>
|
||||
<h1 class="page-title">资料包</h1>
|
||||
<p class="page-lead">按产品名称管理资料包,并查看会话绑定、目录概览和待复核状态。</p>
|
||||
<div class="button-row">
|
||||
<a class="button button-primary" href="{% url 'documents:upload' %}">上传文件</a>
|
||||
<a class="button button-primary" href="{% url 'documents:upload' %}">导入资料包</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">文件总数</div>
|
||||
<div class="metric-label">资料包总数</div>
|
||||
<div class="metric-value">{{ status_counts.total }}</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">已完成入库</div>
|
||||
<div class="metric-value">{{ status_counts.indexed }}</div>
|
||||
<div class="metric-label">已完成</div>
|
||||
<div class="metric-value">{{ status_counts.completed }}</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">待入库</div>
|
||||
<div class="metric-value">{{ status_counts.uploaded }}</div>
|
||||
<div class="metric-label">待复核</div>
|
||||
<div class="metric-value">{{ status_counts.review_required }}</div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">失败</div>
|
||||
<div class="metric-value">{{ status_counts.failed }}</div>
|
||||
<div class="metric-label">待导入</div>
|
||||
<div class="metric-value">{{ status_counts.pending }}</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">按产品名称搜索</h2>
|
||||
<p class="section-copy">支持按产品名称定位资料包,并跳转到关联会话。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" class="grid-2">
|
||||
<div>
|
||||
<label for="id_keyword">产品名称</label>
|
||||
<input id="id_keyword" type="text" name="keyword" value="{{ keyword }}" placeholder="请输入产品名称关键词">
|
||||
</div>
|
||||
<div class="button-row" style="align-items: end;">
|
||||
<button type="submit">搜索资料包</button>
|
||||
<a class="button" href="{% url 'documents:list' %}">清空</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">资料包列表</h2>
|
||||
<p class="section-copy">资料包与会话一一绑定,会话标题默认采用解析后的产品名称。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>批次号</th>
|
||||
<th>产品名称</th>
|
||||
<th>会话</th>
|
||||
<th>文件数</th>
|
||||
<th>页数</th>
|
||||
<th>状态</th>
|
||||
<th>章节点概览</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for batch in batches %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ batch.batch_id }}</td>
|
||||
<td>{{ batch.product_name|default:"未识别产品名称" }}</td>
|
||||
<td class="cell-min-220">
|
||||
{% if batch.conversation_id %}
|
||||
<a class="button" href="{% url 'chat:detail' batch.conversation_id %}">查看对话 {{ batch.conversation_id }}</a>
|
||||
{% else %}
|
||||
<span class="muted">尚未绑定</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ batch.file_count }}</td>
|
||||
<td>{{ batch.page_count }}</td>
|
||||
<td>
|
||||
<span class="pill {% if batch.import_status == 'completed' %}pill-success{% elif batch.import_status == 'review_required' %}pill-signal{% else %}pill-danger{% endif %}">
|
||||
{{ batch.get_import_status_display_text }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-min-280">
|
||||
{% if batch.chapter_summary %}
|
||||
{% for chapter in batch.chapter_summary %}
|
||||
<div>{{ chapter.chapter_code }} / {{ chapter.document_count }} 份</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="muted">暂无目录汇总</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7">暂无资料包,请先导入申报资料。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -52,7 +130,7 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 class="section-title">资料目录总览</h2>
|
||||
<p class="section-copy">页面下方保留真实文件记录与手动入库动作,保证演示原型仍基于当前系统能力运行。</p>
|
||||
<p class="section-copy">保留文件明细,便于说明目录识别、页数统计和异常定位。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -60,7 +138,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>批次</th>
|
||||
<th>场景</th>
|
||||
<th>章节点</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>状态</th>
|
||||
@@ -71,7 +151,9 @@
|
||||
{% for document in documents %}
|
||||
<tr>
|
||||
<td>{{ document.original_name }}</td>
|
||||
<td>{{ document.batch.batch_id|default:"-" }}</td>
|
||||
<td>{{ document.scenario_id }}</td>
|
||||
<td>{{ document.chapter_code|default:"待识别" }}</td>
|
||||
<td>{{ document.file_type }}</td>
|
||||
<td>{{ document.size }}</td>
|
||||
<td>
|
||||
@@ -98,7 +180,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6">暂无文件,请先导入申报资料或法规原文。</td>
|
||||
<td colspan="8">暂无文件,请先导入申报资料或法规原文。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}任务总览{% endblock %}
|
||||
{% block title %}平台总览{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header">
|
||||
@@ -24,16 +24,16 @@
|
||||
<p>查看规则树、知识源和切片策略。</p>
|
||||
</a>
|
||||
<a class="link-card" href="{% url 'documents:list' %}">
|
||||
<h3>文件中心</h3>
|
||||
<p>上传资料、执行入库、查看状态。</p>
|
||||
<h3>资料包</h3>
|
||||
<p>导入资料包,按产品名称搜索并跳转关联会话。</p>
|
||||
</a>
|
||||
<a class="link-card" href="{% url 'chat:index' 'document_review' %}">
|
||||
<h3>审核工作台</h3>
|
||||
<p>输入问题、选择文档、查看结果。</p>
|
||||
<a class="link-card" href="{% url 'chat:index' %}">
|
||||
<h3>审核智能体</h3>
|
||||
<p>进入会话工作台,查看节点式审核结果。</p>
|
||||
</a>
|
||||
<a class="link-card" href="{% url 'audit:list' %}">
|
||||
<h3>审计日志</h3>
|
||||
<p>查看每次执行的输入、输出和引用。</p>
|
||||
<h3>处理历史</h3>
|
||||
<p>查看每次执行的输入、输出和通知留痕。</p>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="button-row" style="margin-top: 16px;">
|
||||
<a class="button button-primary" href="{% url 'chat:index' scenario.id %}">进入审核工作台</a>
|
||||
<a class="button button-primary" href="{% url 'chat:index' %}">进入审核智能体</a>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
|
||||
@@ -2,24 +2,67 @@ from django.urls import reverse
|
||||
|
||||
from agent_core.results import AgentResult
|
||||
from apps.audit.models import AgentAuditLog
|
||||
from apps.documents.models import UploadedDocument
|
||||
from apps.chat.models import Conversation
|
||||
from apps.documents.models import SubmissionBatch, UploadedDocument
|
||||
|
||||
|
||||
def test_chat_post_returns_agent_result_and_audit_log(client, db):
|
||||
def _create_conversation_with_batch():
|
||||
batch = SubmissionBatch.objects.create(
|
||||
batch_id="SUB-20260604-001",
|
||||
product_name="新型冠状病毒 2019-nCoV 核酸检测试剂盒",
|
||||
workflow_type="registration",
|
||||
conversation_id="conv-001",
|
||||
file_count=2,
|
||||
page_count=12,
|
||||
import_status="completed",
|
||||
)
|
||||
conversation = Conversation.objects.create(
|
||||
conversation_id="conv-001",
|
||||
title="新型冠状病毒 2019-nCoV 核酸检测试剂盒",
|
||||
product_name=batch.product_name,
|
||||
batch_id=batch.batch_id,
|
||||
task_status="processing",
|
||||
node_results=[
|
||||
{"label": "资料包导入", "status": "已完成"},
|
||||
{"label": "目录汇总", "status": "处理中"},
|
||||
],
|
||||
)
|
||||
return batch, conversation
|
||||
|
||||
|
||||
def test_chat_post_returns_agent_result_and_audit_log(client, db, monkeypatch):
|
||||
batch, conversation = _create_conversation_with_batch()
|
||||
UploadedDocument.objects.create(
|
||||
batch=batch,
|
||||
scenario_id="document_review",
|
||||
original_name="说明书.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"apps.chat.views.run_agent",
|
||||
lambda *args, **kwargs: AgentResult(answer="模拟回答", status="success"),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["knowledge_qa"]),
|
||||
reverse("chat:detail", args=[conversation.conversation_id]),
|
||||
{"message": "如何处理异常?"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "mock-model" in content
|
||||
assert "审核智能体" in content
|
||||
assert "模拟回答" in content
|
||||
assert AgentAuditLog.objects.count() == 1
|
||||
assert AgentAuditLog.objects.get().batch_id == batch.batch_id
|
||||
|
||||
|
||||
def test_chat_rejects_empty_message(client, db):
|
||||
response = client.post(reverse("chat:index", args=["knowledge_qa"]), {"message": ""})
|
||||
_batch, conversation = _create_conversation_with_batch()
|
||||
|
||||
response = client.post(reverse("chat:detail", args=[conversation.conversation_id]), {"message": ""})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert AgentAuditLog.objects.count() == 0
|
||||
@@ -27,15 +70,18 @@ def test_chat_rejects_empty_message(client, db):
|
||||
|
||||
|
||||
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
|
||||
batch, conversation = _create_conversation_with_batch()
|
||||
selected = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
batch=batch,
|
||||
scenario_id="document_review",
|
||||
original_name="selected.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
other = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
UploadedDocument.objects.create(
|
||||
batch=batch,
|
||||
scenario_id="document_review",
|
||||
original_name="other.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
@@ -45,63 +91,38 @@ def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch
|
||||
|
||||
def fake_run_agent(scenario_config, user_input, options=None):
|
||||
captured["options"] = options or {}
|
||||
from agent_core.results import AgentResult
|
||||
|
||||
return AgentResult(answer="ok", status="success")
|
||||
|
||||
monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent)
|
||||
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["knowledge_qa"]),
|
||||
reverse("chat:detail", args=[conversation.conversation_id]),
|
||||
{"message": "只查选中文档", "document_ids": [str(selected.id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["options"]["document_ids"] == [selected.id]
|
||||
assert other.id not in captured["options"]["document_ids"]
|
||||
assert captured["options"]["conversation_id"] == conversation.conversation_id
|
||||
assert captured["options"]["batch_id"] == batch.batch_id
|
||||
|
||||
|
||||
def test_chat_renders_structured_output_references_and_tool_calls(client, db, monkeypatch):
|
||||
def fake_run_agent(scenario_config, user_input, options=None):
|
||||
return AgentResult(
|
||||
answer="建议先隔离现场。",
|
||||
structured_output={
|
||||
"output_type": "quality_report",
|
||||
"summary": "发现异常批次需要立即处置。",
|
||||
"risk_level": "high",
|
||||
"suggested_actions": ["隔离现场", "通知负责人"],
|
||||
},
|
||||
references=[
|
||||
{
|
||||
"source": "sop.md",
|
||||
"content": "异常处理 SOP:先隔离现场,再通知负责人。",
|
||||
}
|
||||
],
|
||||
tool_calls=[
|
||||
{
|
||||
"tool_name": "query_demo_records",
|
||||
"success": True,
|
||||
"result": {"records": [{"title": "A线缺陷"}]},
|
||||
"error": "",
|
||||
}
|
||||
],
|
||||
model_name="mock-model",
|
||||
status="success",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent)
|
||||
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["quality_analysis"]),
|
||||
{"message": "分析 A 线异常"},
|
||||
def test_chat_renders_three_column_workspace_and_node_results(client, db):
|
||||
batch, conversation = _create_conversation_with_batch()
|
||||
UploadedDocument.objects.create(
|
||||
batch=batch,
|
||||
scenario_id="document_review",
|
||||
original_name="说明书.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
|
||||
response = client.get(reverse("chat:detail", args=[conversation.conversation_id]))
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "结构化结果" in content
|
||||
assert "发现异常批次需要立即处置" in content
|
||||
assert "引用片段" in content
|
||||
assert "sop.md" in content
|
||||
assert "工具调用" in content
|
||||
assert "query_demo_records" in content
|
||||
assert "查看本次审计日志" in content
|
||||
assert "会话历史" in content
|
||||
assert "对话区与节点导航" in content
|
||||
assert "上传区" in content
|
||||
assert "资料包导入 / 已完成" in content
|
||||
assert "目录汇总 / 处理中" in content
|
||||
|
||||
@@ -2,8 +2,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.documents.forms import DocumentUploadForm
|
||||
from apps.documents.models import UploadedDocument
|
||||
from apps.documents.services import extract_text, index_document
|
||||
from apps.documents.models import SubmissionBatch, UploadedDocument
|
||||
from apps.documents.services import extract_text, import_submission_batch, index_document
|
||||
from apps.chat.models import Conversation
|
||||
|
||||
|
||||
def test_upload_txt_document_creates_uploaded_record(client, db):
|
||||
@@ -31,7 +32,7 @@ def test_upload_redirect_shows_success_message(client, db):
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "文件已上传,可继续执行入库" in response.content.decode("utf-8")
|
||||
assert "资料包已导入,已绑定会话" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_upload_accepts_pdf_and_docx_documents(client, db):
|
||||
@@ -145,3 +146,74 @@ def test_index_document_marks_failed_when_extracted_text_is_empty(db, monkeypatc
|
||||
|
||||
assert updated_document.status == UploadedDocument.STATUS_FAILED
|
||||
assert "文档内容为空" in updated_document.error_message
|
||||
|
||||
|
||||
def test_upload_creates_submission_batch_and_bound_conversation(client, db):
|
||||
file = SimpleUploadedFile(
|
||||
"目标产品说明书.txt",
|
||||
"产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒".encode("utf-8"),
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
reverse("documents:upload"),
|
||||
{"scenario_id": "document_review", "file": file},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
batch = SubmissionBatch.objects.get()
|
||||
conversation = Conversation.objects.get()
|
||||
assert batch.product_name == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
|
||||
assert batch.conversation_id == conversation.conversation_id
|
||||
assert conversation.title == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
|
||||
assert batch.file_count == 1
|
||||
|
||||
|
||||
def test_document_list_supports_product_name_search(client, db):
|
||||
SubmissionBatch.objects.create(
|
||||
batch_id="SUB-20260604-001",
|
||||
product_name="新型冠状病毒 2019-nCoV 核酸检测试剂盒",
|
||||
workflow_type="registration",
|
||||
conversation_id="conv-001",
|
||||
file_count=2,
|
||||
page_count=12,
|
||||
import_status="completed",
|
||||
)
|
||||
SubmissionBatch.objects.create(
|
||||
batch_id="SUB-20260604-002",
|
||||
product_name="呼吸道合胞病毒核酸检测试剂盒",
|
||||
workflow_type="registration",
|
||||
conversation_id="conv-002",
|
||||
file_count=3,
|
||||
page_count=20,
|
||||
import_status="completed",
|
||||
)
|
||||
|
||||
response = client.get(reverse("documents:list"), {"keyword": "新型冠状病毒"})
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "新型冠状病毒 2019-nCoV 核酸检测试剂盒" in content
|
||||
assert "呼吸道合胞病毒核酸检测试剂盒" not in content
|
||||
|
||||
|
||||
def test_import_submission_batch_marks_manual_review_when_product_names_conflict(db):
|
||||
files = [
|
||||
SimpleUploadedFile(
|
||||
"注册申请表.txt",
|
||||
"产品名称:产品A".encode("utf-8"),
|
||||
content_type="text/plain",
|
||||
),
|
||||
SimpleUploadedFile(
|
||||
"目标产品说明书.txt",
|
||||
"产品名称:产品B".encode("utf-8"),
|
||||
content_type="text/plain",
|
||||
),
|
||||
]
|
||||
|
||||
result = import_submission_batch("document_review", files)
|
||||
|
||||
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
|
||||
assert batch.import_status == "review_required"
|
||||
assert result["registration_overview_report"]["warnings"]
|
||||
assert "产品名称来源冲突" in result["registration_overview_report"]["warnings"][0]
|
||||
|
||||
Reference in New Issue
Block a user