feat: 重构资料包模型与会话绑定主链路

This commit is contained in:
2026-06-04 00:43:13 +08:00
parent ddf5e7d15c
commit d0841e533f
18 changed files with 1000 additions and 263 deletions

View File

@@ -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),
),
]

View File

@@ -16,6 +16,9 @@ class AgentAuditLog(models.Model):
scenario_id = models.CharField(max_length=100, db_index=True) scenario_id = models.CharField(max_length=100, db_index=True)
scenario_name = models.CharField(max_length=200, blank=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() user_input = models.TextField()
retrieved_chunks = models.JSONField(default=list, blank=True) retrieved_chunks = models.JSONField(default=list, blank=True)
tool_calls = 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: def __str__(self) -> str:
return self.title 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}"

View File

@@ -8,6 +8,9 @@ def create_audit_log(
scenario_name: str, scenario_name: str,
user_input: str, user_input: str,
agent_result: AgentResult, agent_result: AgentResult,
batch_id: str = "",
conversation_id: str = "",
product_name: str = "",
) -> AgentAuditLog: ) -> AgentAuditLog:
""" """
将一次 Agent 执行结果落库为审计日志。 将一次 Agent 执行结果落库为审计日志。
@@ -20,6 +23,9 @@ def create_audit_log(
return AgentAuditLog.objects.create( return AgentAuditLog.objects.create(
scenario_id=scenario_id, scenario_id=scenario_id,
scenario_name=scenario_name, scenario_name=scenario_name,
batch_id=batch_id,
conversation_id=conversation_id,
product_name=product_name,
user_input=user_input, user_input=user_input,
retrieved_chunks=agent_result.references, retrieved_chunks=agent_result.references,
tool_calls=agent_result.tool_calls, tool_calls=agent_result.tool_calls,

View 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
View 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
View 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}"

View File

@@ -5,7 +5,8 @@ from . import views
app_name = "chat" app_name = "chat"
# 当前 V1 仅保留一个场景对话入口,场景详情合并在对话页中展示 # 审核智能体前台以会话为中心
urlpatterns = [ urlpatterns = [
path("<str:scenario_id>/", views.index, name="index"), path("", views.index, name="index"),
path("<str:conversation_id>/", views.detail, name="detail"),
] ]

View File

@@ -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.orchestrator import run_agent
from agent_core.results import AgentResult from agent_core.results import AgentResult
from apps.audit.services import create_audit_log from apps.audit.services import create_audit_log
from apps.documents.models import UploadedDocument from apps.documents.models import SubmissionBatch, UploadedDocument
from apps.scenarios.services import ScenarioNotFound, get_scenario from apps.scenarios.services import get_scenario
from .forms import ChatForm from .forms import ChatForm
from .models import Conversation
def index(request, scenario_id: str): def index(request):
# View 只负责请求编排、表单校验和模板渲染。 conversations = Conversation.objects.all()
# 具体 Agent 执行、审计写入和文档筛选规则分别交给独立模块处理。 if conversations.exists():
try: return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
scenario = get_scenario(scenario_id) return render(
except ScenarioNotFound: request,
return render( "chat/index.html",
request, {
"chat/index.html", "conversation": None,
{ "conversations": [],
"scenario": None, "form": ChatForm(),
"form": ChatForm(), "documents": [],
"error": "场景不存在,请返回首页检查配置。", "result": None,
}, "audit_log": None,
status=404, "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 result = None
audit_log = None audit_log = None
documents = UploadedDocument.objects.filter( active_node = None
scenario_id=scenario["id"],
status=UploadedDocument.STATUS_INDEXED,
)
form = ChatForm(request.POST or None, documents=documents)
task_modes = [ task_modes = [
{"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"}, {"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"},
{"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"}, {"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"},
@@ -41,28 +46,46 @@ def index(request, scenario_id: str):
{"name": "综合风险报告", "description": "形成高优先级问题、建议动作和责任人通知。"}, {"name": "综合风险报告", "description": "形成高优先级问题、建议动作和责任人通知。"},
] ]
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
scenario = get_scenario("document_review")
message = form.cleaned_data["message"] message = form.cleaned_data["message"]
try: try:
# 只把必要的运行选项传给 Agent Core避免在 View 中散落模型细节。
result = run_agent( result = run_agent(
scenario, scenario,
message, 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: except Exception as exc:
result = AgentResult(status="failed", error=str(exc), answer="") 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( return render(
request, request,
"chat/index.html", "chat/index.html",
{ {
"scenario": scenario, "conversation": conversation,
"conversations": Conversation.objects.all(),
"batch": batch,
"form": form, "form": form,
"documents": documents, "documents": documents,
"document_count": documents.count(), "document_count": documents.count(),
"result": result, "result": result,
"audit_log": audit_log, "audit_log": audit_log,
"task_modes": task_modes, "task_modes": task_modes,
"node_results": conversation.node_results,
"active_node": active_node,
}, },
) )

View File

@@ -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",
),
),
]

View File

@@ -1,6 +1,48 @@
from django.db import models 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): class UploadedDocument(models.Model):
""" """
保存用户上传文档的元数据和入库状态。 保存用户上传文档的元数据和入库状态。
@@ -13,11 +55,25 @@ class UploadedDocument(models.Model):
STATUS_INDEXED = "indexed" STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed" 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) scenario_id = models.CharField(max_length=100, db_index=True)
original_name = models.CharField(max_length=255) original_name = models.CharField(max_length=255)
file = models.FileField(upload_to="documents/%Y%m%d/") file = models.FileField(upload_to="documents/%Y%m%d/")
file_type = models.CharField(max_length=20) file_type = models.CharField(max_length=20)
size = models.PositiveIntegerField(default=0) 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) status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
error_message = models.TextField(blank=True) error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -4,11 +4,12 @@ import xml.etree.ElementTree as ET
from zipfile import BadZipFile, ZipFile from zipfile import BadZipFile, ZipFile
from agent_core.rag.ingest import ingest_document 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) extension = _detect_extension(uploaded_file.name)
return UploadedDocument.objects.create( return UploadedDocument.objects.create(
batch=batch,
scenario_id=scenario_id, scenario_id=scenario_id,
original_name=uploaded_file.name, original_name=uploaded_file.name,
file=uploaded_file, file=uploaded_file,
file_type=extension, file_type=extension,
size=uploaded_file.size, size=uploaded_file.size,
relative_path=uploaded_file.name,
status=UploadedDocument.STATUS_UPLOADED, 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: def extract_text(document: UploadedDocument) -> str:
""" """
根据文档类型选择合适的文本抽取策略。 根据文档类型选择合适的文本抽取策略。
@@ -83,6 +185,99 @@ def _detect_extension(file_name: str) -> str:
return Path(file_name).suffix.lower().lstrip(".") 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: def _read_text_file(path: Path) -> str:
"""优先按 UTF-8 读取;失败时回退到系统默认编码。""" """优先按 UTF-8 读取;失败时回退到系统默认编码。"""
try: try:

View File

@@ -5,18 +5,24 @@ from django.views.decorators.http import require_POST
from apps.scenarios.services import list_scenarios from apps.scenarios.services import list_scenarios
from .forms import DocumentUploadForm from .forms import DocumentUploadForm
from .models import UploadedDocument from .models import SubmissionBatch, UploadedDocument
from .services import create_uploaded_document, index_document from .services import import_submission_batch, index_document
def document_list(request): 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() documents = UploadedDocument.objects.all()
status_counts = { status_counts = {
"uploaded": documents.filter(status=UploadedDocument.STATUS_UPLOADED).count(), "pending": batches.filter(import_status=SubmissionBatch.STATUS_PENDING).count(),
"indexed": documents.filter(status=UploadedDocument.STATUS_INDEXED).count(), "completed": batches.filter(import_status=SubmissionBatch.STATUS_COMPLETED).count(),
"failed": documents.filter(status=UploadedDocument.STATUS_FAILED).count(), "review_required": batches.filter(
"total": documents.count(), import_status=SubmissionBatch.STATUS_REVIEW_REQUIRED
).count(),
"total": batches.count(),
} }
processing_pipeline = [ processing_pipeline = [
{"title": "原始文件接收", "detail": "校验格式、大小和场景归属后保存原件。"}, {"title": "原始文件接收", "detail": "校验格式、大小和场景归属后保存原件。"},
@@ -35,6 +41,8 @@ def document_list(request):
"documents/document_list.html", "documents/document_list.html",
{ {
"documents": documents, "documents": documents,
"batches": batches,
"keyword": keyword,
"status_counts": status_counts, "status_counts": status_counts,
"processing_pipeline": processing_pipeline, "processing_pipeline": processing_pipeline,
"exception_items": exception_items, "exception_items": exception_items,
@@ -43,12 +51,18 @@ def document_list(request):
def upload(request): def upload(request):
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发 # 上传成功后直接创建资料包并绑定主会话
if request.method == "POST": if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES) form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"]) result = import_submission_batch(
messages.success(request, "文件已上传,可继续执行入库。") form.cleaned_data["scenario_id"],
[form.cleaned_data["file"]],
)
messages.success(
request,
f"资料包已导入,已绑定会话 {result['conversation_id']}",
)
return redirect("documents:list") return redirect("documents:list")
else: else:
form = DocumentUploadForm() form = DocumentUploadForm()

View File

@@ -363,14 +363,10 @@
</div> </div>
</div> </div>
<nav class="topnav"> <nav class="topnav">
<a href="{% url 'scenarios:index' %}">总览</a> <a href="{% url 'chat:index' %}">审核智能体</a>
<a href="{% url 'documents:list' %}">文件中心</a> <a href="{% url 'documents:list' %}">资料包</a>
<a href="{% url 'chat:index' 'document_review' %}">审核工作台</a>
<a href="{% url 'platform_ui:knowledge-base' %}">知识库</a> <a href="{% url 'platform_ui:knowledge-base' %}">知识库</a>
<a href="{% url 'platform_ui:mcp-center' %}">MCP</a> <a href="{% url 'audit:list' %}">处理历史</a>
<a href="{% url 'platform_ui:skills' %}">Skills</a>
<a href="{% url 'platform_ui:command-center' %}">工作台</a>
<a href="{% url 'audit:list' %}">审计</a>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -1,31 +1,53 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ scenario.name|default:"Agent 审核工作台" }}{% endblock %} {% block title %}审核智能体{% endblock %}
{% block content %} {% block content %}
{% if error %} <section class="page-header">
<section class="notice notice-error">{{ error }}</section> <span class="eyebrow">Agent Workspace</span>
{% endif %} <h1 class="page-title">审核智能体</h1>
<p class="page-lead">以会话为中心组织资料包上传、节点式审核结果和动态任务信息卡。</p>
{% if scenario %} {% if conversation %}
<section class="page-header">
<span class="eyebrow">Workspace</span>
<h1 class="page-title">{{ scenario.name }}</h1>
<p class="page-lead">左侧输入问题和选择文档,右侧查看执行结果。</p>
<div class="badge-row"> <div class="badge-row">
<span class="pill pill-accent">已入库文档:{{ document_count }}</span> <span class="pill pill-accent">批次:{{ conversation.batch_id }}</span>
<span class="pill">输出{{ scenario.output.type }}</span> <span class="pill">产品{{ conversation.product_name|default:"未识别产品名称" }}</span>
<span class="pill">阶段:{{ conversation.task_status }}</span>
</div> </div>
</section> {% endif %}
</section>
<section class="workspace-grid"> <section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;">
<div class="stack"> <div class="stack">
<article class="panel"> <article class="panel">
<div class="section-heading"> <h2 class="section-title">会话历史</h2>
<div> <p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称。</p>
<h2 class="section-title">任务输入与资料范围</h2> <ul class="detail-list">
<p class="section-copy">左侧突出受控输入:先描述审核目标,再限定本轮使用的文档范围。</p> {% for item in conversations %}
</div> <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> </div>
<form method="post" class="stack"> <form method="post" class="stack">
{% csrf_token %} {% csrf_token %}
@@ -38,7 +60,6 @@
</div> </div>
<div> <div>
{{ form.document_ids.label_tag }} {{ form.document_ids.label_tag }}
<p class="help-text">不勾选时默认使用全部已入库文档。</p>
<div class="checkbox-list"> <div class="checkbox-list">
{% for checkbox in form.document_ids %} {% for checkbox in form.document_ids %}
<label class="checkbox-item"> <label class="checkbox-item">
@@ -46,136 +67,76 @@
<span>{{ checkbox.choice_label }}</span> <span>{{ checkbox.choice_label }}</span>
</label> </label>
{% empty %} {% empty %}
<div class="notice">当前场景还没有已入库文档,系统将仅依赖工具和模型能力生成结果</div> <div class="notice">当前资料包还没有可选文档</div>
{% endfor %} {% endfor %}
</div> </div>
{% if form.document_ids.errors %}
<p class="notice notice-error">{{ form.document_ids.errors|join:" " }}</p>
{% endif %}
</div> </div>
<div class="button-row"> <div class="button-row">
<button type="submit">提交问题并执行 Agent</button> <button type="submit">提交审核任务</button>
</div> </div>
</form> </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 %} {% 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;"> <div class="detail-item" style="margin-top: 16px;">
<strong>回答</strong> <strong>Agent 回答</strong>
<div>{{ result.answer|linebreaksbr }}</div> <div>{{ result.answer|linebreaksbr }}</div>
</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 %} {% endif %}
{% else %}
<div class="notice">暂无会话,请先导入资料包。</div>
{% endif %} {% endif %}
</div> </article>
<div class="stack"> <article class="panel">
<article class="panel"> <h2 class="section-title">节点式结果</h2>
<div class="section-heading"> {% if result and result.structured_output %}
<div> <table class="kv-table">
<h2 class="section-title">结构化审核结果</h2> <tbody>
<p class="section-copy">右侧结果舱用于展示缺失项、冲突项、字段池结果或风险清单。</p> {% for key, value in result.structured_output.items %}
</div> <tr>
</div> <th>{{ key }}</th>
{% if result %} <td><pre class="code-block">{{ value }}</pre></td>
<table class="kv-table"> </tr>
<caption style="text-align:left; padding-bottom:12px; color:var(--ink-soft);">结构化结果</caption> {% endfor %}
<tbody> </tbody>
{% for key, value in result.structured_output.items %} </table>
<tr> {% else %}
<th>{{ key }}</th> <div class="notice">执行任务后,这里会展示结构化节点结果。</div>
<td> {% endif %}
{% if key == "answer" or key == "summary" or key == "reply" %} </article>
{{ value|linebreaksbr }} </div>
{% else %}
<pre class="code-block">{{ value }}</pre>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="notice">执行任务后,这里会展示结构化审核结果和回填准备信息。</div>
{% endif %}
</article>
<article class="panel"> <div class="stack">
<h2 class="section-title">引用与审计</h2> <article class="panel">
<h2 class="section-title">上传区</h2>
<p class="section-copy">资料包导入入口在资料包页统一维护,当前会话只展示绑定关系。</p>
{% if batch %}
<ul class="detail-list"> <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> </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 %} {% if audit_log %}
<div class="button-row" style="margin-top: 16px;"> <li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
<a class="button" href="{% url 'audit:detail' audit_log.id %}">查看本次审计日志</a>
</div>
{% endif %} {% endif %}
</article> </ul>
</div> </article>
</section> </div>
{% endif %} </section>
{% endblock %} {% endblock %}

View File

@@ -1,36 +1,114 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}文件中心{% endblock %} {% block title %}资料包{% endblock %}
{% block content %} {% block content %}
<section class="page-header"> <section class="page-header">
<span class="eyebrow">Documents</span> <span class="eyebrow">Submission Batches</span>
<h1 class="page-title">文件中心</h1> <h1 class="page-title">资料包</h1>
<p class="page-lead">上传资料、查看状态、执行入库。页面只保留最常用操作</p> <p class="page-lead">按产品名称管理资料包,并查看会话绑定、目录概览和待复核状态</p>
<div class="button-row"> <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> </div>
</section> </section>
<section class="metric-grid"> <section class="metric-grid">
<article class="metric-card"> <article class="metric-card">
<div class="metric-label">文件总数</div> <div class="metric-label">资料包总数</div>
<div class="metric-value">{{ status_counts.total }}</div> <div class="metric-value">{{ status_counts.total }}</div>
</article> </article>
<article class="metric-card"> <article class="metric-card">
<div class="metric-label">已完成入库</div> <div class="metric-label">已完成</div>
<div class="metric-value">{{ status_counts.indexed }}</div> <div class="metric-value">{{ status_counts.completed }}</div>
</article> </article>
<article class="metric-card"> <article class="metric-card">
<div class="metric-label">入库</div> <div class="metric-label">复核</div>
<div class="metric-value">{{ status_counts.uploaded }}</div> <div class="metric-value">{{ status_counts.review_required }}</div>
</article> </article>
<article class="metric-card"> <article class="metric-card">
<div class="metric-label">失败</div> <div class="metric-label">待导入</div>
<div class="metric-value">{{ status_counts.failed }}</div> <div class="metric-value">{{ status_counts.pending }}</div>
</article> </article>
</section> </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"> <section class="panel">
<div class="section-heading"> <div class="section-heading">
<div> <div>
@@ -52,7 +130,7 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2 class="section-title">资料目录总览</h2> <h2 class="section-title">资料目录总览</h2>
<p class="section-copy">页面下方保留真实文件记录与手动入库动作,保证演示原型仍基于当前系统能力运行</p> <p class="section-copy">保留文件明细,便于说明目录识别、页数统计和异常定位</p>
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -60,7 +138,9 @@
<thead> <thead>
<tr> <tr>
<th>文件名</th> <th>文件名</th>
<th>批次</th>
<th>场景</th> <th>场景</th>
<th>章节点</th>
<th>类型</th> <th>类型</th>
<th>大小</th> <th>大小</th>
<th>状态</th> <th>状态</th>
@@ -71,7 +151,9 @@
{% for document in documents %} {% for document in documents %}
<tr> <tr>
<td>{{ document.original_name }}</td> <td>{{ document.original_name }}</td>
<td>{{ document.batch.batch_id|default:"-" }}</td>
<td>{{ document.scenario_id }}</td> <td>{{ document.scenario_id }}</td>
<td>{{ document.chapter_code|default:"待识别" }}</td>
<td>{{ document.file_type }}</td> <td>{{ document.file_type }}</td>
<td>{{ document.size }}</td> <td>{{ document.size }}</td>
<td> <td>
@@ -98,7 +180,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6">暂无文件,请先导入申报资料或法规原文。</td> <td colspan="8">暂无文件,请先导入申报资料或法规原文。</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}任务总览{% endblock %} {% block title %}平台总览{% endblock %}
{% block content %} {% block content %}
<section class="page-header"> <section class="page-header">
@@ -24,16 +24,16 @@
<p>查看规则树、知识源和切片策略。</p> <p>查看规则树、知识源和切片策略。</p>
</a> </a>
<a class="link-card" href="{% url 'documents:list' %}"> <a class="link-card" href="{% url 'documents:list' %}">
<h3>文件中心</h3> <h3>资料包</h3>
<p>上传资料、执行入库、查看状态</p> <p>导入资料包,按产品名称搜索并跳转关联会话</p>
</a> </a>
<a class="link-card" href="{% url 'chat:index' 'document_review' %}"> <a class="link-card" href="{% url 'chat:index' %}">
<h3>审核工作台</h3> <h3>审核智能体</h3>
<p>输入问题、选择文档、查看结果。</p> <p>进入会话工作台,查看节点式审核结果。</p>
</a> </a>
<a class="link-card" href="{% url 'audit:list' %}"> <a class="link-card" href="{% url 'audit:list' %}">
<h3>审计日志</h3> <h3>处理历史</h3>
<p>查看每次执行的输入、输出和引用</p> <p>查看每次执行的输入、输出和通知留痕</p>
</a> </a>
</section> </section>
@@ -71,7 +71,7 @@
{% endif %} {% endif %}
</p> </p>
<div class="button-row" style="margin-top: 16px;"> <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> </div>
</article> </article>
{% empty %} {% empty %}

View File

@@ -2,24 +2,67 @@ from django.urls import reverse
from agent_core.results import AgentResult from agent_core.results import AgentResult
from apps.audit.models import AgentAuditLog 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( response = client.post(
reverse("chat:index", args=["knowledge_qa"]), reverse("chat:detail", args=[conversation.conversation_id]),
{"message": "如何处理异常?"}, {"message": "如何处理异常?"},
) )
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode("utf-8") content = response.content.decode("utf-8")
assert "mock-model" in content assert "审核智能体" in content
assert "模拟回答" in content assert "模拟回答" in content
assert AgentAuditLog.objects.count() == 1 assert AgentAuditLog.objects.count() == 1
assert AgentAuditLog.objects.get().batch_id == batch.batch_id
def test_chat_rejects_empty_message(client, db): 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 response.status_code == 200
assert AgentAuditLog.objects.count() == 0 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): def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
batch, conversation = _create_conversation_with_batch()
selected = UploadedDocument.objects.create( selected = UploadedDocument.objects.create(
scenario_id="knowledge_qa", batch=batch,
scenario_id="document_review",
original_name="selected.md", original_name="selected.md",
file_type="md", file_type="md",
size=1, size=1,
status=UploadedDocument.STATUS_INDEXED, status=UploadedDocument.STATUS_INDEXED,
) )
other = UploadedDocument.objects.create( UploadedDocument.objects.create(
scenario_id="knowledge_qa", batch=batch,
scenario_id="document_review",
original_name="other.md", original_name="other.md",
file_type="md", file_type="md",
size=1, 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): def fake_run_agent(scenario_config, user_input, options=None):
captured["options"] = options or {} captured["options"] = options or {}
from agent_core.results import AgentResult
return AgentResult(answer="ok", status="success") return AgentResult(answer="ok", status="success")
monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent) monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent)
response = client.post( response = client.post(
reverse("chat:index", args=["knowledge_qa"]), reverse("chat:detail", args=[conversation.conversation_id]),
{"message": "只查选中文档", "document_ids": [str(selected.id)]}, {"message": "只查选中文档", "document_ids": [str(selected.id)]},
) )
assert response.status_code == 200 assert response.status_code == 200
assert captured["options"]["document_ids"] == [selected.id] 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 test_chat_renders_three_column_workspace_and_node_results(client, db):
def fake_run_agent(scenario_config, user_input, options=None): batch, conversation = _create_conversation_with_batch()
return AgentResult( UploadedDocument.objects.create(
answer="建议先隔离现场。", batch=batch,
structured_output={ scenario_id="document_review",
"output_type": "quality_report", original_name="说明书.md",
"summary": "发现异常批次需要立即处置。", file_type="md",
"risk_level": "high", size=1,
"suggested_actions": ["隔离现场", "通知负责人"], status=UploadedDocument.STATUS_INDEXED,
},
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 线异常"},
) )
response = client.get(reverse("chat:detail", args=[conversation.conversation_id]))
content = response.content.decode("utf-8") content = response.content.decode("utf-8")
assert response.status_code == 200 assert response.status_code == 200
assert "结构化结果" in content assert "会话历史" in content
assert "发现异常批次需要立即处置" in content assert "对话区与节点导航" in content
assert "引用片段" in content assert "上传区" in content
assert "sop.md" in content assert "资料包导入 / 已完成" in content
assert "工具调用" in content assert "目录汇总 / 处理中" in content
assert "query_demo_records" in content
assert "查看本次审计日志" in content

View File

@@ -2,8 +2,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from apps.documents.forms import DocumentUploadForm from apps.documents.forms import DocumentUploadForm
from apps.documents.models import UploadedDocument from apps.documents.models import SubmissionBatch, UploadedDocument
from apps.documents.services import extract_text, index_document 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): 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 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): 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 updated_document.status == UploadedDocument.STATUS_FAILED
assert "文档内容为空" in updated_document.error_message 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]