feat(regulatory): 展示法规核查工作流卡片

This commit is contained in:
2026-06-07 00:43:18 +08:00
parent 4c28466fe4
commit bd805203f1
6 changed files with 187 additions and 21 deletions

View File

@@ -229,6 +229,7 @@ def batch_status(request, batch_id: int):
{ {
"batch": { "batch": {
"id": batch.pk, "id": batch.pk,
"workflow_type": "file_summary",
"batch_no": batch.batch_no, "batch_no": batch.batch_no,
"status": batch.status, "status": batch.status,
"product_name": batch.product_name, "product_name": batch.product_name,

View File

@@ -26,6 +26,7 @@ def batch_status(request, batch_id: int):
"status": batch.status, "status": batch.status,
"source_summary_batch_id": batch.source_summary_batch_id, "source_summary_batch_id": batch.source_summary_batch_id,
"risk_summary": batch.risk_summary, "risk_summary": batch.risk_summary,
"risk_summary_text": _format_risk_summary(batch.risk_summary or {}),
"error_message": batch.error_message, "error_message": batch.error_message,
}, },
"nodes": [ "nodes": [
@@ -40,3 +41,18 @@ def batch_status(request, batch_id: int):
], ],
} }
) )
def _format_risk_summary(risk_summary: dict) -> str:
labels = [
("blocking", "阻断项"),
("high", "高风险"),
("medium", "中风险"),
("low", "低风险"),
("info", "提示"),
]
return " · ".join(
f"{label} {int(risk_summary.get(key) or 0)}"
for key, label in labels
if int(risk_summary.get(key) or 0)
)

View File

@@ -11,7 +11,7 @@ from .services import (
send_message, send_message,
stream_message, stream_message,
) )
from .models import Conversation, FileAttachment, FileSummaryBatch from .models import Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
@login_required @login_required
@@ -42,6 +42,8 @@ def workspace(request: HttpRequest) -> HttpResponse:
if current is None and conversations.exists(): if current is None and conversations.exists():
current = conversations.first() current = conversations.first()
workflow_cards = build_workflow_cards(current) if current else []
return render( return render(
request, request,
"home.html", "home.html",
@@ -52,7 +54,7 @@ def workspace(request: HttpRequest) -> HttpResponse:
"current_conversation": current, "current_conversation": current,
"messages": current.messages.all() if current else [], "messages": current.messages.all() if current else [],
"attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [], "attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [],
"summary_batches": FileSummaryBatch.objects.filter(conversation=current).prefetch_related("node_runs").order_by("-created_at")[:5] if current else [], "workflow_cards": workflow_cards,
}, },
) )
@@ -109,3 +111,56 @@ def stream_chat(request: HttpRequest) -> HttpResponse:
response["Cache-Control"] = "no-cache" response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" response["X-Accel-Buffering"] = "no"
return response 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:
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 {}),
"created_at": batch.created_at,
"nodes": list(
WorkflowNodeRun.objects.filter(
workflow_type="regulatory_review",
workflow_batch_id=batch.pk,
).order_by("id")
),
}
)
return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5]
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)

View File

@@ -455,6 +455,12 @@
return summaryPanel.getAttribute(attributeName).replace(token, value); return summaryPanel.getAttribute(attributeName).replace(token, value);
} }
function statusUrlForWorkflow(workflow_type, batchId) {
var attributeName =
workflow_type === "regulatory_review" ? "data-regulatory-status-url-template" : "data-status-url-template";
return templateUrl(attributeName, "__batch_id__", batchId);
}
function renderAttachments(attachments) { function renderAttachments(attachments) {
if (!attachmentList) { if (!attachmentList) {
return; return;
@@ -542,13 +548,17 @@
if (empty) { if (empty) {
empty.remove(); empty.remove();
} }
var card = workflowCardList.querySelector('[data-batch-id="' + batch.batch_id + '"]'); var workflow_type = batch.workflow_type || "file_summary";
var card = workflowCardList.querySelector(
'[data-batch-id="' + batch.batch_id + '"][data-workflow-type="' + workflow_type + '"]'
);
if (card) { if (card) {
return card; return card;
} }
card = document.createElement("article"); card = document.createElement("article");
card.className = "workflow-card"; card.className = "workflow-card";
card.setAttribute("data-batch-id", batch.batch_id); card.setAttribute("data-batch-id", batch.batch_id);
card.setAttribute("data-workflow-type", workflow_type);
card.innerHTML = card.innerHTML =
"<header><strong>" + "<header><strong>" +
escapeHtml(batch.batch_no || "文件汇总") + escapeHtml(batch.batch_no || "文件汇总") +
@@ -634,13 +644,13 @@
selectWorkflowBatchIndex(activeIndex); selectWorkflowBatchIndex(activeIndex);
} }
async function refreshWorkflowCard(batchId) { async function refreshWorkflowCard(batchId, workflow_type) {
if (!summaryPanel || !batchId) { if (!summaryPanel || !batchId) {
return ""; return "";
} }
var response; var response;
try { try {
response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId), { response = await fetch(statusUrlForWorkflow(workflow_type || "file_summary", batchId), {
cache: "no-store", cache: "no-store",
}); });
} catch (error) { } catch (error) {
@@ -655,6 +665,7 @@
var card = ensureWorkflowCard({ var card = ensureWorkflowCard({
batch_id: payload.batch.id, batch_id: payload.batch.id,
batch_no: payload.batch.batch_no, batch_no: payload.batch.batch_no,
workflow_type: payload.batch.workflow_type || workflow_type || "file_summary",
}); });
if (!card) { if (!card) {
return payload.batch.status || ""; return payload.batch.status || "";
@@ -673,6 +684,17 @@
} else if (batchError) { } else if (batchError) {
batchError.remove(); batchError.remove();
} }
var riskSummary = card.querySelector(".workflow-risk-summary");
if (payload.batch.risk_summary_text) {
if (!riskSummary) {
riskSummary = document.createElement("p");
riskSummary.className = "workflow-risk-summary";
card.insertBefore(riskSummary, card.querySelector("ol"));
}
riskSummary.textContent = payload.batch.risk_summary_text;
} else if (riskSummary) {
riskSummary.remove();
}
var list = card.querySelector("ol"); var list = card.querySelector("ol");
list.innerHTML = ""; list.innerHTML = "";
(payload.nodes || []).forEach(function (node) { (payload.nodes || []).forEach(function (node) {
@@ -724,29 +746,37 @@
return status === "success" || status === "failed"; return status === "success" || status === "failed";
} }
function stopWorkflowPolling(batchId) { function workflowTimerKey(batchId, workflow_type) {
if (!workflowPollingTimers[batchId]) { return (workflow_type || "file_summary") + ":" + batchId;
}
function stopWorkflowPolling(batchId, workflow_type) {
var key = workflowTimerKey(batchId, workflow_type);
if (!workflowPollingTimers[key]) {
return; return;
} }
window.clearInterval(workflowPollingTimers[batchId]); window.clearInterval(workflowPollingTimers[key]);
delete workflowPollingTimers[batchId]; delete workflowPollingTimers[key];
} }
function startWorkflowPolling(batchId) { function startWorkflowPolling(batchId) {
if (!batchId || workflowPollingTimers[batchId]) { var card = workflowCardList ? workflowCardList.querySelector('[data-batch-id="' + batchId + '"]') : null;
var workflow_type = card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary";
var key = workflowTimerKey(batchId, workflow_type);
if (!batchId || workflowPollingTimers[key]) {
return; return;
} }
workflowPollingTimers[batchId] = window.setInterval(async function () { workflowPollingTimers[key] = window.setInterval(async function () {
var status = await refreshWorkflowCard(batchId); var status = await refreshWorkflowCard(batchId, workflow_type);
if (isWorkflowTerminalStatus(status)) { if (isWorkflowTerminalStatus(status)) {
refreshConversationMessages(); refreshConversationMessages();
stopWorkflowPolling(batchId); stopWorkflowPolling(batchId, workflow_type);
} }
}, WORKFLOW_POLL_INTERVAL_MS); }, WORKFLOW_POLL_INTERVAL_MS);
refreshWorkflowCard(batchId).then(function (status) { refreshWorkflowCard(batchId, workflow_type).then(function (status) {
if (isWorkflowTerminalStatus(status)) { if (isWorkflowTerminalStatus(status)) {
refreshConversationMessages(); refreshConversationMessages();
stopWorkflowPolling(batchId); stopWorkflowPolling(batchId, workflow_type);
} }
}); });
} }
@@ -757,10 +787,11 @@
} }
workflowCardList.querySelectorAll(".workflow-card").forEach(function (card) { workflowCardList.querySelectorAll(".workflow-card").forEach(function (card) {
var batchId = card.getAttribute("data-batch-id"); var batchId = card.getAttribute("data-batch-id");
var workflow_type = card.getAttribute("data-workflow-type") || "file_summary";
var status = card.querySelector(".workflow-status"); var status = card.querySelector(".workflow-status");
var statusText = status ? status.textContent.trim() : ""; var statusText = status ? status.textContent.trim() : "";
if (!isWorkflowTerminalStatus(statusText)) { if (!isWorkflowTerminalStatus(statusText)) {
startWorkflowPolling(batchId); startWorkflowPolling(batchId, workflow_type);
} }
}); });
} }

View File

@@ -177,6 +177,7 @@
data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/" data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/"
data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/" data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/"
data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/"
data-regulatory-status-url-template="/api/review-agent/regulatory-review/__batch_id__/status/"
data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/"
> >
<section class="summary-section upload-section"> <section class="summary-section upload-section">
@@ -221,10 +222,11 @@
<h3>工作流</h3> <h3>工作流</h3>
</div> </div>
<div class="workflow-card-list workflow-batch-carousel" id="workflowCardList" data-active-index="0"> <div class="workflow-card-list workflow-batch-carousel" id="workflowCardList" data-active-index="0">
{% for batch in summary_batches %} {% for batch in workflow_cards %}
<article <article
class="workflow-card{% if forloop.first %} active{% endif %}" class="workflow-card{% if forloop.first %} active{% endif %}"
data-batch-id="{{ batch.pk }}" data-batch-id="{{ batch.id }}"
data-workflow-type="{{ batch.workflow_type }}"
data-workflow-index="{{ forloop.counter0 }}" data-workflow-index="{{ forloop.counter0 }}"
aria-hidden="{% if forloop.first %}false{% else %}true{% endif %}" aria-hidden="{% if forloop.first %}false{% else %}true{% endif %}"
> >
@@ -232,11 +234,14 @@
<strong>{{ batch.batch_no }}</strong> <strong>{{ batch.batch_no }}</strong>
<span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span> <span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span>
</header> </header>
{% if batch.risk_label %}
<p class="workflow-risk-summary">{{ batch.risk_label }}</p>
{% endif %}
{% if batch.error_message %} {% if batch.error_message %}
<p class="workflow-error">{{ batch.error_message }}</p> <p class="workflow-error">{{ batch.error_message }}</p>
{% endif %} {% endif %}
<ol> <ol>
{% for node in batch.node_runs.all %} {% for node in batch.nodes %}
<li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}"> <li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}">
<div> <div>
<span>{{ node.node_name }}</span> <span>{{ node.node_name }}</span>
@@ -250,11 +255,11 @@
{% empty %} {% empty %}
<div class="panel-empty">暂无工作流</div> <div class="panel-empty">暂无工作流</div>
{% endfor %} {% endfor %}
{% if summary_batches %} {% if workflow_cards %}
<div class="workflow-batch-controls"> <div class="workflow-batch-controls">
<button type="button" class="workflow-batch-btn" data-workflow-action="prev" aria-label="上一个工作流">&lsaquo;</button> <button type="button" class="workflow-batch-btn" data-workflow-action="prev" aria-label="上一个工作流">&lsaquo;</button>
<div class="workflow-batch-dots" aria-label="工作流批次"> <div class="workflow-batch-dots" aria-label="工作流批次">
{% for batch in summary_batches %} {% for batch in workflow_cards %}
<button <button
type="button" type="button"
class="workflow-batch-dot{% if forloop.first %} active{% endif %}" class="workflow-batch-dot{% if forloop.first %} active{% endif %}"

View File

@@ -0,0 +1,58 @@
import pytest
from django.urls import reverse
from review_agent.models import (
Conversation,
FileSummaryBatch,
RegulatoryReviewBatch,
WorkflowNodeRun,
)
pytestmark = pytest.mark.django_db
def test_workspace_renders_regulatory_workflow_card(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-OK",
status=FileSummaryBatch.Status.SUCCESS,
)
regulatory = RegulatoryReviewBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="RR-CARD",
status=RegulatoryReviewBatch.Status.SUCCESS,
risk_summary={"blocking": 1, "high": 1},
)
WorkflowNodeRun.objects.create(
workflow_type="regulatory_review",
workflow_batch_id=regulatory.pk,
node_group="regulatory_review",
node_code="risk_assess",
node_name="风险评估",
status=WorkflowNodeRun.Status.SUCCESS,
progress=100,
)
client.force_login(user)
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
content = response.content.decode("utf-8")
assert "RR-CARD" in content
assert 'data-workflow-type="regulatory_review"' in content
assert "阻断项 1" in content
assert "风险评估" in content
assert "data-regulatory-status-url-template" in content
def test_frontend_selects_status_url_by_workflow_type():
script = open("static/js/app.js", encoding="utf-8").read()
assert "workflow_type" in script
assert "data-regulatory-status-url-template" in script
assert "statusUrlForWorkflow" in script