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