feat(regulatory): 展示法规核查工作流卡片
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="上一个工作流">‹</button>
|
<button type="button" class="workflow-batch-btn" data-workflow-action="prev" aria-label="上一个工作流">‹</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 %}"
|
||||||
|
|||||||
58
tests/test_regulatory_frontend.py
Normal file
58
tests/test_regulatory_frontend.py
Normal 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
|
||||||
Reference in New Issue
Block a user