feat: 支持会话内补传资料并保持绑定
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apps.documents.forms import MultipleFileField, SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
class ChatForm(forms.Form):
|
class ChatForm(forms.Form):
|
||||||
@@ -38,3 +41,23 @@ class ChatForm(forms.Form):
|
|||||||
def clean_document_ids(self):
|
def clean_document_ids(self):
|
||||||
# View 与 Agent Core 都使用整型文档 ID,统一在表单层完成转换。
|
# View 与 Agent Core 都使用整型文档 ID,统一在表单层完成转换。
|
||||||
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])]
|
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])]
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationUploadForm(forms.Form):
|
||||||
|
# 会话右侧上传区只负责继续补传资料,不修改会话绑定关系。
|
||||||
|
files = MultipleFileField(label="补充文件或资料包", required=False)
|
||||||
|
file = forms.FileField(label="兼容单文件上传", required=False)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
files = list(cleaned_data.get("files") or [])
|
||||||
|
file = cleaned_data.get("file")
|
||||||
|
if file:
|
||||||
|
files.append(file)
|
||||||
|
if not files:
|
||||||
|
raise forms.ValidationError("请至少上传一个文件或资料包。")
|
||||||
|
for uploaded_file in files:
|
||||||
|
if Path(uploaded_file.name).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||||
|
raise forms.ValidationError("仅支持 .txt、.md、.pdf、.docx、.zip 和 .7z 文件")
|
||||||
|
cleaned_data["uploaded_files"] = files
|
||||||
|
return cleaned_data
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ app_name = "chat"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="index"),
|
path("", views.index, name="index"),
|
||||||
path("<str:conversation_id>/", views.detail, name="detail"),
|
path("<str:conversation_id>/", views.detail, name="detail"),
|
||||||
|
path("<str:conversation_id>/upload/", views.upload_documents, name="upload-documents"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
from django.contrib import messages
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
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, create_notification_record
|
from apps.audit.services import create_audit_log, create_notification_record
|
||||||
from apps.documents.models import SubmissionBatch, UploadedDocument
|
from apps.documents.models import SubmissionBatch, UploadedDocument
|
||||||
|
from apps.documents.services import append_documents_to_batch
|
||||||
from apps.scenarios.services import get_scenario
|
from apps.scenarios.services import get_scenario
|
||||||
|
|
||||||
from .forms import ChatForm
|
from .forms import ChatForm, ConversationUploadForm
|
||||||
from .models import Conversation
|
from .models import Conversation
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ def detail(request, conversation_id: str):
|
|||||||
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
|
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
|
||||||
documents = UploadedDocument.objects.filter(batch=batch)
|
documents = UploadedDocument.objects.filter(batch=batch)
|
||||||
form = ChatForm(request.POST or None, documents=documents)
|
form = ChatForm(request.POST or None, documents=documents)
|
||||||
|
upload_form = ConversationUploadForm()
|
||||||
result = None
|
result = None
|
||||||
audit_log = None
|
audit_log = None
|
||||||
active_node = None
|
active_node = None
|
||||||
@@ -97,10 +101,35 @@ def detail(request, conversation_id: str):
|
|||||||
"node_results": conversation.node_results,
|
"node_results": conversation.node_results,
|
||||||
"active_node": active_node,
|
"active_node": active_node,
|
||||||
"workspace_summary": workspace_summary,
|
"workspace_summary": workspace_summary,
|
||||||
|
"upload_form": upload_form,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def upload_documents(request, conversation_id: str):
|
||||||
|
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
|
||||||
|
batch = get_object_or_404(SubmissionBatch, batch_id=conversation.batch_id)
|
||||||
|
upload_form = ConversationUploadForm(request.POST, request.FILES)
|
||||||
|
if upload_form.is_valid():
|
||||||
|
result = append_documents_to_batch(
|
||||||
|
"document_review",
|
||||||
|
batch,
|
||||||
|
upload_form.cleaned_data["uploaded_files"],
|
||||||
|
)
|
||||||
|
warning_count = len(result["registration_overview_report"]["warnings"])
|
||||||
|
message = "资料已补充到当前资料包。"
|
||||||
|
if warning_count:
|
||||||
|
message += f" 当前有 {warning_count} 条待复核提示。"
|
||||||
|
messages.success(request, message)
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"补充资料失败:" + " ".join(upload_form.non_field_errors()) if upload_form.non_field_errors() else "补充资料失败。",
|
||||||
|
)
|
||||||
|
return redirect("chat:detail", conversation_id=conversation.conversation_id)
|
||||||
|
|
||||||
|
|
||||||
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
|
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
|
||||||
payload = result.notification_payload or {}
|
payload = result.notification_payload or {}
|
||||||
owners = payload.get("owners") or []
|
owners = payload.get("owners") or []
|
||||||
|
|||||||
@@ -53,51 +53,14 @@ def import_submission_batch(scenario_id: str, uploaded_files: list) -> dict:
|
|||||||
workflow_type="registration",
|
workflow_type="registration",
|
||||||
import_status=SubmissionBatch.STATUS_PROCESSING,
|
import_status=SubmissionBatch.STATUS_PROCESSING,
|
||||||
)
|
)
|
||||||
documents = []
|
ingest_result = _ingest_files_into_batch(
|
||||||
candidates = []
|
|
||||||
chapter_summary = {}
|
|
||||||
total_pages = 0
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
expanded_result = _expand_uploaded_files(uploaded_files)
|
|
||||||
expanded_files = expanded_result["files"]
|
|
||||||
warnings.extend(expanded_result["warnings"])
|
|
||||||
for uploaded_item in expanded_files:
|
|
||||||
uploaded_file = uploaded_item["uploaded_file"]
|
|
||||||
relative_path = uploaded_item["relative_path"]
|
|
||||||
document = create_uploaded_document(
|
|
||||||
scenario_id,
|
|
||||||
uploaded_file,
|
|
||||||
batch=batch,
|
batch=batch,
|
||||||
relative_path=relative_path,
|
scenario_id=scenario_id,
|
||||||
|
uploaded_files=uploaded_files,
|
||||||
)
|
)
|
||||||
text = extract_text(document)
|
documents = ingest_result["documents"]
|
||||||
page_count = _estimate_page_count(text)
|
warnings = ingest_result["warnings"]
|
||||||
document.page_count = page_count
|
product_name = ingest_result["product_name"]
|
||||||
document.page_count_confidence = "estimated"
|
|
||||||
document.document_role = _detect_document_role(document.relative_path)
|
|
||||||
document.chapter_code = _detect_chapter_code(document.relative_path, 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.relative_path, text))
|
|
||||||
|
|
||||||
product_name, product_warnings = _select_product_name(candidates)
|
|
||||||
warnings.extend(product_warnings)
|
|
||||||
conversation = create_conversation_for_batch(batch.batch_id, product_name)
|
conversation = create_conversation_for_batch(batch.batch_id, product_name)
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
@@ -106,11 +69,8 @@ def import_submission_batch(scenario_id: str, uploaded_files: list) -> dict:
|
|||||||
batch.product_name = product_name
|
batch.product_name = product_name
|
||||||
batch.conversation_id = conversation.conversation_id
|
batch.conversation_id = conversation.conversation_id
|
||||||
batch.file_count = len(documents)
|
batch.file_count = len(documents)
|
||||||
batch.page_count = total_pages
|
batch.page_count = ingest_result["page_count"]
|
||||||
batch.chapter_summary = [
|
batch.chapter_summary = ingest_result["chapter_summary"]
|
||||||
{"chapter_code": chapter_code, "document_count": count}
|
|
||||||
for chapter_code, count in sorted(chapter_summary.items())
|
|
||||||
]
|
|
||||||
batch.exception_count = len(warnings)
|
batch.exception_count = len(warnings)
|
||||||
if not documents:
|
if not documents:
|
||||||
batch.import_status = SubmissionBatch.STATUS_FAILED
|
batch.import_status = SubmissionBatch.STATUS_FAILED
|
||||||
@@ -155,6 +115,89 @@ def import_submission_batch(scenario_id: str, uploaded_files: list) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def append_documents_to_batch(
|
||||||
|
scenario_id: str,
|
||||||
|
batch: SubmissionBatch,
|
||||||
|
uploaded_files: list,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
在既有资料包下继续补传文件,并保持会话绑定不变。
|
||||||
|
|
||||||
|
该服务只负责 Documents 侧的数据更新:
|
||||||
|
- 新文件继续归属原 batch
|
||||||
|
- conversation_id 不变
|
||||||
|
- 如原产品名为空,可用新增文件补齐
|
||||||
|
- 如新增文件产品名与原产品名冲突,则转为待复核
|
||||||
|
"""
|
||||||
|
ingest_result = _ingest_files_into_batch(
|
||||||
|
batch=batch,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
uploaded_files=uploaded_files,
|
||||||
|
keep_existing_product_name=True,
|
||||||
|
)
|
||||||
|
warnings = list(ingest_result["warnings"])
|
||||||
|
all_documents = list(batch.documents.order_by("id"))
|
||||||
|
|
||||||
|
if not all_documents:
|
||||||
|
warnings.append("未发现可导入的支持文件,请检查资料包格式或补充 PDF/DOCX/MD/TXT 文件。")
|
||||||
|
batch.import_status = SubmissionBatch.STATUS_FAILED
|
||||||
|
elif warnings:
|
||||||
|
batch.import_status = SubmissionBatch.STATUS_REVIEW_REQUIRED
|
||||||
|
else:
|
||||||
|
batch.import_status = SubmissionBatch.STATUS_COMPLETED
|
||||||
|
|
||||||
|
batch.product_name = ingest_result["product_name"]
|
||||||
|
batch.file_count = len(all_documents)
|
||||||
|
batch.page_count = ingest_result["page_count"]
|
||||||
|
batch.chapter_summary = ingest_result["chapter_summary"]
|
||||||
|
batch.exception_count = len(warnings)
|
||||||
|
batch.save(
|
||||||
|
update_fields=[
|
||||||
|
"product_name",
|
||||||
|
"file_count",
|
||||||
|
"page_count",
|
||||||
|
"chapter_summary",
|
||||||
|
"exception_count",
|
||||||
|
"import_status",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if batch.conversation_id:
|
||||||
|
from apps.chat.models import Conversation
|
||||||
|
|
||||||
|
conversation = Conversation.objects.filter(conversation_id=batch.conversation_id).first()
|
||||||
|
if conversation:
|
||||||
|
conversation.product_name = batch.product_name
|
||||||
|
if batch.product_name:
|
||||||
|
conversation.title = batch.product_name
|
||||||
|
conversation.save(update_fields=["product_name", "title", "updated_at"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"batch_id": batch.batch_id,
|
||||||
|
"conversation_id": batch.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 all_documents
|
||||||
|
],
|
||||||
|
"warnings": warnings,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_text(document: UploadedDocument) -> str:
|
def extract_text(document: UploadedDocument) -> str:
|
||||||
"""
|
"""
|
||||||
根据文档类型选择合适的文本抽取策略。
|
根据文档类型选择合适的文本抽取策略。
|
||||||
@@ -248,6 +291,87 @@ def _expand_uploaded_files(uploaded_files: list) -> list[dict]:
|
|||||||
return {"files": expanded_files, "warnings": warnings}
|
return {"files": expanded_files, "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_files_into_batch(
|
||||||
|
*,
|
||||||
|
batch: SubmissionBatch,
|
||||||
|
scenario_id: str,
|
||||||
|
uploaded_files: list,
|
||||||
|
keep_existing_product_name: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
expanded_result = _expand_uploaded_files(uploaded_files)
|
||||||
|
expanded_files = expanded_result["files"]
|
||||||
|
warnings = list(expanded_result["warnings"])
|
||||||
|
new_documents = []
|
||||||
|
new_candidates = []
|
||||||
|
|
||||||
|
for uploaded_item in expanded_files:
|
||||||
|
uploaded_file = uploaded_item["uploaded_file"]
|
||||||
|
relative_path = uploaded_item["relative_path"]
|
||||||
|
document = create_uploaded_document(
|
||||||
|
scenario_id,
|
||||||
|
uploaded_file,
|
||||||
|
batch=batch,
|
||||||
|
relative_path=relative_path,
|
||||||
|
)
|
||||||
|
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.relative_path)
|
||||||
|
document.chapter_code = _detect_chapter_code(document.relative_path, 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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
new_documents.append(document)
|
||||||
|
new_candidates.extend(_extract_product_candidates(document.relative_path, text))
|
||||||
|
|
||||||
|
all_documents = list(batch.documents.order_by("id"))
|
||||||
|
chapter_summary = {}
|
||||||
|
total_pages = 0
|
||||||
|
for document in all_documents:
|
||||||
|
total_pages += document.page_count
|
||||||
|
chapter_key = document.chapter_code or "UNCLASSIFIED"
|
||||||
|
chapter_summary[chapter_key] = chapter_summary.get(chapter_key, 0) + 1
|
||||||
|
|
||||||
|
product_name = batch.product_name
|
||||||
|
if keep_existing_product_name and batch.product_name:
|
||||||
|
conflict_names = {
|
||||||
|
item["product_name"] for item in new_candidates if item["product_name"] != batch.product_name
|
||||||
|
}
|
||||||
|
if conflict_names:
|
||||||
|
warnings.append(
|
||||||
|
"新增文件与当前资料包产品名称不一致:"
|
||||||
|
+ " / ".join([batch.product_name, *sorted(conflict_names)])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
product_name, product_warnings = _select_product_name(new_candidates)
|
||||||
|
warnings.extend(product_warnings)
|
||||||
|
if keep_existing_product_name and not product_name:
|
||||||
|
product_name = batch.product_name
|
||||||
|
|
||||||
|
return {
|
||||||
|
"documents": all_documents if keep_existing_product_name else new_documents,
|
||||||
|
"new_documents": new_documents,
|
||||||
|
"warnings": warnings,
|
||||||
|
"product_name": product_name,
|
||||||
|
"page_count": total_pages if keep_existing_product_name else total_pages,
|
||||||
|
"chapter_summary": [
|
||||||
|
{"chapter_code": chapter_code, "document_count": count}
|
||||||
|
for chapter_code, count in sorted(chapter_summary.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_zip_entries(uploaded_file) -> dict:
|
def _extract_zip_entries(uploaded_file) -> dict:
|
||||||
archive_bytes = uploaded_file.read()
|
archive_bytes = uploaded_file.read()
|
||||||
uploaded_file.seek(0)
|
uploaded_file.seek(0)
|
||||||
|
|||||||
@@ -119,10 +119,20 @@
|
|||||||
<div>导入状态:{{ batch.get_import_status_display_text }}</div>
|
<div>导入状态:{{ batch.get_import_status_display_text }}</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="button-row" style="margin-top: 16px;">
|
<form method="post" action="{% url 'chat:upload-documents' conversation.conversation_id %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
|
||||||
<a class="button button-primary" href="{% url 'documents:upload' %}">继续上传资料</a>
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
{{ upload_form.files.label_tag }}
|
||||||
|
{{ upload_form.files }}
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit">继续上传资料</button>
|
||||||
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
|
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="button-row" style="margin-top: 16px;">
|
||||||
|
<a class="button" href="{% url 'documents:upload' %}">导入新资料包</a>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="notice">暂无绑定资料包。</div>
|
<div class="notice">暂无绑定资料包。</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
from agent_core.results import AgentResult
|
from agent_core.results import AgentResult
|
||||||
from apps.audit.models import AgentAuditLog
|
from apps.audit.models import AgentAuditLog
|
||||||
@@ -325,3 +326,40 @@ def test_chat_page_shows_upload_entry_and_dynamic_context_cards(client, db):
|
|||||||
assert "是否允许正式导出" in content
|
assert "是否允许正式导出" in content
|
||||||
assert "通知状态" in content
|
assert "通知状态" in content
|
||||||
assert "飞书通知 / 待处理" in content
|
assert "飞书通知 / 待处理" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_upload_keeps_existing_conversation_binding_and_adds_documents(client, db):
|
||||||
|
batch, conversation = _create_conversation_with_batch()
|
||||||
|
existing_document = UploadedDocument.objects.create(
|
||||||
|
batch=batch,
|
||||||
|
scenario_id="document_review",
|
||||||
|
original_name="原说明书.md",
|
||||||
|
file_type="md",
|
||||||
|
size=1,
|
||||||
|
status=UploadedDocument.STATUS_INDEXED,
|
||||||
|
)
|
||||||
|
existing_count = UploadedDocument.objects.filter(batch=batch).count()
|
||||||
|
upload_file = SimpleUploadedFile(
|
||||||
|
"新增补充资料.txt",
|
||||||
|
"产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒".encode("utf-8"),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("chat:upload-documents", args=[conversation.conversation_id]),
|
||||||
|
{"files": [upload_file]},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
batch.refresh_from_db()
|
||||||
|
conversation.refresh_from_db()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert SubmissionBatch.objects.count() == 1
|
||||||
|
assert Conversation.objects.count() == 1
|
||||||
|
assert conversation.conversation_id == "conv-001"
|
||||||
|
assert batch.conversation_id == conversation.conversation_id
|
||||||
|
assert UploadedDocument.objects.filter(batch=batch).count() == existing_count + 1
|
||||||
|
assert UploadedDocument.objects.filter(batch=batch, original_name="新增补充资料.txt").exists()
|
||||||
|
assert "新增补充资料.txt" in content
|
||||||
|
assert "已补充到当前资料包" in content
|
||||||
|
|||||||
Reference in New Issue
Block a user