552 lines
21 KiB
Python
552 lines
21 KiB
Python
from django.contrib.auth.decorators import login_required
|
|
from django.conf import settings
|
|
from django.db.models import Count, Q, Sum
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
|
|
from django.shortcuts import redirect, render
|
|
from django.utils.http import urlencode
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from .services import (
|
|
create_conversation,
|
|
get_conversation_for_user,
|
|
list_conversations,
|
|
send_message,
|
|
stream_message,
|
|
)
|
|
from .models import (
|
|
ApplicationFormFillBatch,
|
|
Conversation,
|
|
FileAttachment,
|
|
FileSummaryBatch,
|
|
RegulatoryInfoPackageBatch,
|
|
RegulatoryReviewBatch,
|
|
WorkflowNodeRun,
|
|
)
|
|
from .knowledge_base import build_knowledge_base_context, search_knowledge_base
|
|
from .knowledge_base import (
|
|
build_knowledge_base_context_for_user,
|
|
create_document_from_upload,
|
|
delete_document,
|
|
index_managed_document,
|
|
list_documents_for_user,
|
|
serialize_document,
|
|
update_document,
|
|
)
|
|
from .models import KnowledgeBaseDocument
|
|
from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates
|
|
from .regulatory_review.services.rag_embedding import get_embedding_provider
|
|
from .regulatory_review.services.rag_index import build_chroma_index
|
|
from .regulatory_review.services.rule_loader import load_rule_file
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET"])
|
|
def home_dashboard(request: HttpRequest) -> HttpResponse:
|
|
"""Renders the data-first home dashboard for the current user."""
|
|
|
|
if request.GET.get("conversation"):
|
|
query = {"conversation": request.GET["conversation"]}
|
|
search = (request.GET.get("q") or "").strip()
|
|
if search:
|
|
query["q"] = search
|
|
return redirect(f"/chat/?{urlencode(query)}")
|
|
|
|
context = build_home_dashboard_context(request.user)
|
|
return render(
|
|
request,
|
|
"workbench.html",
|
|
{
|
|
"page_title": "首页",
|
|
"dashboard": context,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def workspace(request: HttpRequest) -> HttpResponse:
|
|
"""Renders the review-agent workspace and handles conversation actions."""
|
|
|
|
if request.method == "POST":
|
|
action = request.POST.get("action")
|
|
conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id"))
|
|
|
|
if action == "new_conversation":
|
|
conversation = create_conversation(request.user)
|
|
return redirect(f"/chat/?conversation={conversation.pk}")
|
|
|
|
if action == "send_message":
|
|
content = (request.POST.get("prompt") or "").strip()
|
|
if not conversation:
|
|
conversation = create_conversation(request.user)
|
|
if content:
|
|
send_message(conversation, content)
|
|
return redirect(f"/chat/?conversation={conversation.pk}")
|
|
|
|
search = (request.GET.get("q") or "").strip()
|
|
conversations = list_conversations(request.user, search)
|
|
current = get_conversation_for_user(request.user, request.GET.get("conversation"))
|
|
|
|
if current is None and conversations.exists():
|
|
current = conversations.first()
|
|
|
|
workflow_cards = build_workflow_cards(current) if current else []
|
|
condition_confirmation = build_condition_confirmation(workflow_cards)
|
|
|
|
return render(
|
|
request,
|
|
"home.html",
|
|
{
|
|
"page_title": "审核智能体",
|
|
"search_query": search,
|
|
"conversations": conversations,
|
|
"current_conversation": current,
|
|
"messages": current.messages.all() if current else [],
|
|
"attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [],
|
|
"workflow_cards": workflow_cards,
|
|
"condition_confirmation": condition_confirmation,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET"])
|
|
def attachment_manager(request: HttpRequest) -> HttpResponse:
|
|
conversations = (
|
|
Conversation.objects.filter(user=request.user)
|
|
.annotate(
|
|
attachment_count=Count(
|
|
"file_attachments",
|
|
filter=~Q(file_attachments__upload_status=FileAttachment.UploadStatus.DELETED),
|
|
)
|
|
)
|
|
.order_by("-updated_at", "-id")
|
|
)
|
|
selected = get_conversation_for_user(request.user, request.GET.get("conversation"))
|
|
attachments = (
|
|
FileAttachment.objects.filter(conversation=selected)
|
|
.order_by("original_name", "-version_no")
|
|
if selected
|
|
else []
|
|
)
|
|
return render(
|
|
request,
|
|
"attachment_manager.html",
|
|
{
|
|
"page_title": "附件管理",
|
|
"conversations": conversations,
|
|
"selected_conversation": selected,
|
|
"attachments": attachments,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET"])
|
|
def knowledge_base_manager(request: HttpRequest) -> HttpResponse:
|
|
context = build_knowledge_base_context_for_user(request.user)
|
|
return render(
|
|
request,
|
|
"knowledge_base.html",
|
|
{
|
|
"page_title": "知识库管理",
|
|
"knowledge_base": context,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET"])
|
|
def knowledge_base_status(request: HttpRequest) -> JsonResponse:
|
|
return JsonResponse(build_knowledge_base_context_for_user(request.user))
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def knowledge_base_rebuild_index(request: HttpRequest) -> JsonResponse:
|
|
payload = rebuild_knowledge_base_index()
|
|
return JsonResponse({"knowledge_base": build_knowledge_base_context_for_user(request.user), **payload})
|
|
|
|
|
|
def rebuild_knowledge_base_index() -> dict[str, object]:
|
|
rule_set = load_rule_file()
|
|
source_dir = Path(settings.BASE_DIR) / rule_set["source_material_dir"]
|
|
chunk_count = build_chroma_index(
|
|
source_dir=source_dir,
|
|
embedding_provider=get_embedding_provider(),
|
|
reset=True,
|
|
)
|
|
return {"chunk_count": chunk_count}
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def knowledge_base_search(request: HttpRequest) -> JsonResponse:
|
|
if request.content_type == "application/json":
|
|
try:
|
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
query = payload.get("query", "")
|
|
else:
|
|
query = request.POST.get("query", "")
|
|
return JsonResponse(search_knowledge_base(str(query)))
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def knowledge_base_documents(request: HttpRequest) -> JsonResponse:
|
|
if request.method == "GET":
|
|
return JsonResponse({"documents": list_documents_for_user(request.user)})
|
|
uploaded_file = request.FILES.get("file")
|
|
if uploaded_file is None:
|
|
return JsonResponse({"error": "请上传知识库材料。"}, status=400)
|
|
is_active = str(request.POST.get("is_active", "true")).lower() not in {"0", "false", "off"}
|
|
document = create_document_from_upload(
|
|
user=request.user,
|
|
uploaded_file=uploaded_file,
|
|
display_name=request.POST.get("display_name", ""),
|
|
description=request.POST.get("description", ""),
|
|
is_active=is_active,
|
|
)
|
|
return JsonResponse({"document": serialize_document(document)})
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "PATCH", "DELETE"])
|
|
def knowledge_base_document_detail(request: HttpRequest, document_id: int) -> JsonResponse:
|
|
try:
|
|
document = KnowledgeBaseDocument.objects.get(
|
|
pk=document_id,
|
|
user=request.user,
|
|
)
|
|
except KnowledgeBaseDocument.DoesNotExist:
|
|
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
|
if document.status == KnowledgeBaseDocument.Status.DELETED:
|
|
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
|
if request.method == "GET":
|
|
return JsonResponse({"document": serialize_document(document)})
|
|
if request.method == "DELETE":
|
|
delete_document(document)
|
|
return JsonResponse({"document": serialize_document(document)})
|
|
try:
|
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
update_document(document, payload)
|
|
return JsonResponse({"document": serialize_document(document)})
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def knowledge_base_document_index(request: HttpRequest, document_id: int) -> JsonResponse:
|
|
try:
|
|
document = KnowledgeBaseDocument.objects.get(
|
|
pk=document_id,
|
|
user=request.user,
|
|
)
|
|
except KnowledgeBaseDocument.DoesNotExist:
|
|
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
|
if document.status == KnowledgeBaseDocument.Status.DELETED:
|
|
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
|
chunk_count = index_managed_document(document)
|
|
document.refresh_from_db()
|
|
return JsonResponse({"document": serialize_document(document), "chunk_count": chunk_count})
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def stream_chat(request: HttpRequest) -> HttpResponse:
|
|
"""Streams one assistant reply so the UI can render incremental output."""
|
|
|
|
content = (request.POST.get("prompt") or "").strip()
|
|
if not content:
|
|
return JsonResponse({"error": "消息内容不能为空。"}, status=400)
|
|
|
|
conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id"))
|
|
if not conversation:
|
|
conversation = create_conversation(request.user)
|
|
|
|
response = StreamingHttpResponse(
|
|
streaming_content=stream_message(conversation, content),
|
|
content_type="text/event-stream",
|
|
)
|
|
response["Cache-Control"] = "no-cache"
|
|
response["X-Accel-Buffering"] = "no"
|
|
return response
|
|
|
|
|
|
def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]:
|
|
cards: list[dict[str, object]] = []
|
|
for batch in FileSummaryBatch.objects.filter(conversation=conversation).prefetch_related("node_runs"):
|
|
cards.append(
|
|
{
|
|
"id": batch.pk,
|
|
"workflow_type": "file_summary",
|
|
"batch_no": batch.batch_no,
|
|
"status": batch.status,
|
|
"error_message": batch.error_message,
|
|
"risk_label": "",
|
|
"created_at": batch.created_at,
|
|
"nodes": list(batch.node_runs.order_by("id")),
|
|
}
|
|
)
|
|
regulatory_batches = RegulatoryReviewBatch.objects.filter(conversation=conversation)
|
|
for batch in regulatory_batches:
|
|
condition_candidates = ensure_regulatory_condition_candidates(batch)
|
|
cards.append(
|
|
{
|
|
"id": batch.pk,
|
|
"workflow_type": "regulatory_review",
|
|
"batch_no": batch.batch_no,
|
|
"status": batch.status,
|
|
"error_message": batch.error_message,
|
|
"risk_label": _format_risk_label(batch.risk_summary or {}),
|
|
"condition_json": batch.condition_json or {},
|
|
"condition_candidates": condition_candidates,
|
|
"notification_count": batch.notifications.count(),
|
|
"review_record_count": batch.artifacts.filter(metadata__artifact="review_record").count(),
|
|
"created_at": batch.created_at,
|
|
"nodes": list(
|
|
WorkflowNodeRun.objects.filter(
|
|
workflow_type="regulatory_review",
|
|
workflow_batch_id=batch.pk,
|
|
).order_by("id")
|
|
),
|
|
}
|
|
)
|
|
form_fill_batches = ApplicationFormFillBatch.objects.filter(conversation=conversation, is_deleted=False)
|
|
for batch in form_fill_batches:
|
|
cards.append(
|
|
{
|
|
"id": batch.pk,
|
|
"workflow_type": "application_form_fill",
|
|
"batch_no": batch.batch_no,
|
|
"status": batch.status,
|
|
"error_message": batch.error_message,
|
|
"risk_label": _format_form_fill_label(batch),
|
|
"created_at": batch.created_at,
|
|
"nodes": list(
|
|
WorkflowNodeRun.objects.filter(
|
|
workflow_type="application_form_fill",
|
|
workflow_batch_id=batch.pk,
|
|
).order_by("id")
|
|
),
|
|
}
|
|
)
|
|
rip_batches = RegulatoryInfoPackageBatch.objects.filter(conversation=conversation, is_deleted=False)
|
|
for batch in rip_batches:
|
|
cards.append(
|
|
{
|
|
"id": batch.pk,
|
|
"workflow_type": "regulatory_info_package",
|
|
"batch_no": batch.batch_no,
|
|
"status": batch.status,
|
|
"error_message": batch.error_message,
|
|
"risk_label": _format_regulatory_info_package_label(batch),
|
|
"created_at": batch.created_at,
|
|
"nodes": list(
|
|
WorkflowNodeRun.objects.filter(
|
|
workflow_type="regulatory_info_package",
|
|
workflow_batch_id=batch.pk,
|
|
).order_by("id")
|
|
),
|
|
}
|
|
)
|
|
return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5]
|
|
|
|
|
|
def build_condition_confirmation(workflow_cards: list[dict[str, object]]) -> dict[str, object] | None:
|
|
for card in workflow_cards:
|
|
if (
|
|
card.get("workflow_type") == "regulatory_review"
|
|
and card.get("status") == RegulatoryReviewBatch.Status.WAITING_USER
|
|
and card.get("condition_candidates")
|
|
):
|
|
return {
|
|
"id": card["id"],
|
|
"batch_no": card["batch_no"],
|
|
"candidates": card["condition_candidates"],
|
|
}
|
|
return None
|
|
|
|
|
|
def _format_risk_label(risk_summary: dict) -> str:
|
|
parts = []
|
|
labels = [
|
|
("blocking", "阻断项"),
|
|
("high", "高风险"),
|
|
("medium", "中风险"),
|
|
("low", "低风险"),
|
|
("info", "提示"),
|
|
]
|
|
for key, label in labels:
|
|
count = int(risk_summary.get(key) or 0)
|
|
if count:
|
|
parts.append(f"{label} {count}")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str:
|
|
parts = []
|
|
if batch.selected_templates:
|
|
parts.append("模板 " + "、".join(batch.selected_templates))
|
|
if batch.conflict_summary:
|
|
parts.append(f"冲突字段 {len(batch.conflict_summary)}")
|
|
if batch.risk_notes:
|
|
parts.append(f"提示 {len(batch.risk_notes)}")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def _format_regulatory_info_package_label(batch: RegulatoryInfoPackageBatch) -> str:
|
|
parts = []
|
|
if batch.product_name:
|
|
parts.append(batch.product_name)
|
|
if batch.generated_files:
|
|
success_count = sum(1 for item in batch.generated_files if item.get("status") in {"success", "fallback_success"})
|
|
parts.append(f"生成 {success_count}/7")
|
|
if batch.missing_fields:
|
|
parts.append(f"缺失 {len(batch.missing_fields)}")
|
|
if batch.conflict_fields:
|
|
parts.append(f"冲突 {len(batch.conflict_fields)}")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def build_home_dashboard_context(user) -> dict[str, object]:
|
|
conversations = Conversation.objects.filter(user=user)
|
|
active_attachments = FileAttachment.objects.filter(user=user).exclude(
|
|
upload_status=FileAttachment.UploadStatus.DELETED
|
|
)
|
|
active_knowledge_documents = KnowledgeBaseDocument.objects.filter(user=user).exclude(
|
|
status=KnowledgeBaseDocument.Status.DELETED
|
|
)
|
|
knowledge_context = build_knowledge_base_context_for_user(user)
|
|
builtin_source_count = int(knowledge_context.get("source_count") or 0)
|
|
collection_chunk_count = int((knowledge_context.get("collection") or {}).get("count") or 0)
|
|
managed_document_count = active_knowledge_documents.count()
|
|
file_batches = FileSummaryBatch.objects.filter(user=user).select_related("conversation")
|
|
regulatory_batches = RegulatoryReviewBatch.objects.filter(user=user).select_related("conversation")
|
|
form_fill_batches = ApplicationFormFillBatch.objects.filter(user=user, is_deleted=False).select_related("conversation")
|
|
|
|
batch_status_counts = _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches)
|
|
total_batches = file_batches.count() + regulatory_batches.count() + form_fill_batches.count()
|
|
successful_batches = batch_status_counts["success"]
|
|
handled_batches = successful_batches + batch_status_counts["failed"]
|
|
recent_records = _build_recent_dashboard_records(
|
|
conversations.order_by("-updated_at", "-id")[:8],
|
|
file_batches.order_by("-created_at", "-id")[:8],
|
|
regulatory_batches.order_by("-created_at", "-id")[:8],
|
|
form_fill_batches.order_by("-created_at", "-id")[:8],
|
|
)
|
|
|
|
return {
|
|
"metrics": {
|
|
"conversation_count": conversations.count(),
|
|
"recent_conversation_count": conversations.filter(messages__isnull=False).distinct().count(),
|
|
"attachment_count": active_attachments.count(),
|
|
"active_attachment_count": active_attachments.filter(is_active=True).count(),
|
|
"knowledge_document_count": managed_document_count + builtin_source_count,
|
|
"running_batch_count": batch_status_counts["running"],
|
|
"handled_batch_count": handled_batches,
|
|
"success_batch_count": successful_batches,
|
|
"waiting_batch_count": batch_status_counts["waiting"],
|
|
"failed_batch_count": batch_status_counts["failed"],
|
|
"total_batch_count": total_batches,
|
|
},
|
|
"knowledge": {
|
|
"document_count": managed_document_count,
|
|
"builtin_source_count": builtin_source_count,
|
|
"total_material_count": managed_document_count + builtin_source_count,
|
|
"active_document_count": active_knowledge_documents.filter(is_active=True).count(),
|
|
"indexed_document_count": active_knowledge_documents.filter(indexed_chunk_count__gt=0).count(),
|
|
"managed_chunk_count": active_knowledge_documents.aggregate(total=Sum("indexed_chunk_count"))["total"] or 0,
|
|
"chunk_count": collection_chunk_count,
|
|
},
|
|
"attachments": {
|
|
"attachment_count": active_attachments.count(),
|
|
"active_attachment_count": active_attachments.filter(is_active=True).count(),
|
|
"recent_attachment_count": active_attachments.order_by("-created_at", "-id")[:5].count(),
|
|
"conversation_count": active_attachments.values("conversation_id").distinct().count(),
|
|
},
|
|
"workflow": {
|
|
"file_summary_count": file_batches.count(),
|
|
"regulatory_review_count": regulatory_batches.count(),
|
|
"application_form_fill_count": form_fill_batches.count(),
|
|
**batch_status_counts,
|
|
},
|
|
"recent_records": recent_records,
|
|
}
|
|
|
|
|
|
def _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches) -> dict[str, int]:
|
|
running_statuses = {
|
|
FileSummaryBatch.Status.PENDING,
|
|
FileSummaryBatch.Status.RUNNING,
|
|
ApplicationFormFillBatch.Status.PENDING,
|
|
ApplicationFormFillBatch.Status.RUNNING,
|
|
RegulatoryReviewBatch.Status.PENDING,
|
|
RegulatoryReviewBatch.Status.RUNNING,
|
|
}
|
|
waiting_statuses = {
|
|
ApplicationFormFillBatch.Status.WAITING_USER,
|
|
RegulatoryReviewBatch.Status.WAITING_USER,
|
|
}
|
|
success_statuses = {
|
|
FileSummaryBatch.Status.SUCCESS,
|
|
RegulatoryReviewBatch.Status.SUCCESS,
|
|
ApplicationFormFillBatch.Status.SUCCESS,
|
|
ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
|
|
}
|
|
failed_statuses = {
|
|
FileSummaryBatch.Status.FAILED,
|
|
RegulatoryReviewBatch.Status.FAILED,
|
|
ApplicationFormFillBatch.Status.FAILED,
|
|
}
|
|
statuses = [
|
|
*file_batches.values_list("status", flat=True),
|
|
*regulatory_batches.values_list("status", flat=True),
|
|
*form_fill_batches.values_list("status", flat=True),
|
|
]
|
|
return {
|
|
"running": sum(1 for status in statuses if status in running_statuses),
|
|
"waiting": sum(1 for status in statuses if status in waiting_statuses),
|
|
"success": sum(1 for status in statuses if status in success_statuses),
|
|
"failed": sum(1 for status in statuses if status in failed_statuses),
|
|
}
|
|
|
|
|
|
def _build_recent_dashboard_records(conversations, file_batches, regulatory_batches, form_fill_batches) -> list[dict[str, object]]:
|
|
records = []
|
|
for conversation in conversations:
|
|
records.append(
|
|
{
|
|
"type": "对话",
|
|
"title": conversation.title or "新对话",
|
|
"status": "已更新",
|
|
"updated_at": conversation.updated_at,
|
|
"url": f"/chat/?conversation={conversation.pk}",
|
|
}
|
|
)
|
|
for batch in file_batches:
|
|
records.append(_batch_record(batch, "文件汇总"))
|
|
for batch in regulatory_batches:
|
|
status = batch.status
|
|
risk_label = _format_risk_label(batch.risk_summary or {})
|
|
records.append(_batch_record(batch, "法规核查", status_label=risk_label or status))
|
|
for batch in form_fill_batches:
|
|
records.append(_batch_record(batch, "申报填表"))
|
|
return sorted(records, key=lambda item: item["updated_at"], reverse=True)[:8]
|
|
|
|
|
|
def _batch_record(batch, record_type: str, status_label: str | None = None) -> dict[str, object]:
|
|
return {
|
|
"type": record_type,
|
|
"title": batch.batch_no,
|
|
"status": status_label or batch.status,
|
|
"updated_at": batch.created_at,
|
|
"url": f"/chat/?conversation={batch.conversation_id}",
|
|
}
|