Refine web governance and chat surfaces

This commit is contained in:
2026-05-27 11:24:20 +08:00
parent 029fbfb5f6
commit b5f4034328
9 changed files with 150 additions and 66 deletions
+33 -1
View File
@@ -98,6 +98,7 @@ def stream_chat(
template_key=template_key,
),
local_legal_sources,
payload.message,
),
media_type="text/event-stream",
)
@@ -347,7 +348,7 @@ def _build_legal_article_source_title(ref: Mapping[str, Any]) -> str:
return f"{source_title} {article_no}".strip()
def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: List[Dict[str, Any]]) -> Iterable[str]:
def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: List[Dict[str, Any]], message: str = "") -> Iterable[str]:
if not local_sources:
yield from upstream
return
@@ -355,6 +356,10 @@ def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: Lis
emitted_sources = False
for chunk in upstream:
event = _parse_sse_event(chunk)
if event and event.get("type") == "text":
content = str(event.get("content") or "")
yield _format_sse_event({"type": "text", "content": _polish_chat_answer(content, message, local_sources)})
continue
if event and event.get("type") == "sources":
merged = _dedupe_sources([*(event.get("sources") or []), *local_sources])
emitted_sources = True
@@ -366,6 +371,33 @@ def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: Lis
yield chunk
def _polish_chat_answer(content: str, message: str, sources: List[Dict[str, Any]]) -> str:
if not _looks_like_raw_retrieval_answer(content):
return content
source_titles = [str(source.get("title") or "依据来源").strip() for source in sources[:3] if source.get("title")]
source_line = "、".join(source_titles) if source_titles else "下方引用来源"
return (
"我已检索到相关依据,但当前平台返回的是依据片段而不是完整法律分析。\n\n"
f"针对你的问题“{message.strip()}”,建议先按以下方向人工核查:\n"
"1. 明确付款节点、付款前置条件、发票要求和验收标准是否写清。\n"
"2. 检查逾期付款、拒付、扣款和违约金规则是否具体、对等、可执行。\n"
"3. 核对付款条款是否和交付、验收、解除、争议解决条款互相冲突。\n\n"
f"可优先复核这些依据:{source_line}。"
)
def _looks_like_raw_retrieval_answer(content: str) -> bool:
normalized = content.strip().lower()
if not normalized:
return False
return (
normalized.startswith("i found relevant knowledge-base content")
or "please treat this as ai-assisted guidance" in normalized
or "chapter_title:" in normalized
or "official_url:" in normalized
)
def _parse_sse_event(chunk: str) -> Optional[Dict[str, Any]]:
for line in chunk.splitlines():
if not line.startswith("data: "):
+31
View File
@@ -371,6 +371,37 @@ class ChatApiTestCase(unittest.TestCase):
self.assertEqual(review_context["legalArticleSearchSources"][0]["source_id"], "lart-cyber-001")
self.assertEqual(review_context["metadata"]["legalArticleSearchSourceCount"], 1)
def test_stream_chat_polishes_raw_retrieval_answer_when_local_sources_exist(self):
class RawRetrievalChatService(StubChatService):
def stream_answer(self, current_user, conversation_id, message, review_context=None, template_key=None):
yield 'data: {"type":"text","content":"I found relevant knowledge-base content for this question:\\n1. official_url: https://example.test chapter_title: 第一章\\n\\nPlease treat this as AI-assisted guidance and review the cited source text."}\n\n'
yield 'data: {"type":"end","conversation_id":"conv-raw"}\n\n'
app.dependency_overrides[require_current_user] = lambda: {"id": "user-1", "tenant_id": "tenant-1"}
app.dependency_overrides[get_shared_ai_platform_client] = lambda: RawRetrievalChatService()
app.dependency_overrides[get_chat_legal_article_service] = lambda: StubLegalArticleService(
[
{
"title": "中华人民共和国民法典 第五百零九条",
"content": "当事人应当按照约定全面履行自己的义务。",
"source_type": "legal_article",
"source_id": "lart-civil-509",
"detail_path": "/legal-articles?article_id=lart-civil-509",
}
]
)
response = self.client.post("/api/v1/chat/stream", json={"message": "这份合同的付款条款有哪些风险?请用中文直接回答。"})
self.assertEqual(response.status_code, 200)
events = [line.removeprefix("data: ") for line in response.text.split("\n\n") if line.startswith("data: ")]
payloads = [json.loads(item) for item in events]
text_event = next(item for item in payloads if item["type"] == "text")
self.assertIn("我已检索到相关依据", text_event["content"])
self.assertIn("付款节点", text_event["content"])
self.assertNotIn("I found relevant knowledge-base content", text_event["content"])
def test_list_conversations_returns_paginated_payload(self):
app.dependency_overrides[require_current_user] = lambda: {"id": "user-1", "tenant_id": "tenant-1"}
+7 -10
View File
@@ -882,21 +882,16 @@ export default function ContractsPage() {
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">Contract Generation</span>
<span className="landing-badge">合同生成</span>
<Title level={2}>从模板到下载,一屏完成合同生成。</Title>
<Text type="secondary">先选模板,再填写变量;预览确认后生成文件,历史结果保留在下方。</Text>
</div>
<Card className="review-side-card" variant="borderless">
<span className="landing-panel-eyebrow">Smart Workflow</span>
<span className="landing-panel-eyebrow">合同生成中心</span>
<Title level={4} style={{ margin: 0, color: "inherit" }}>
当前链路
继续处理
</Title>
<div className="contract-progress-strip">
<span>选模板</span>
<span>填变量</span>
<span>生成文件</span>
</div>
<Text type="secondary">支持 DOCX、PDF、HTML、TXT 导出;从审查结果进入时会自动带入上下文。</Text>
<Text type="secondary">选模板、填变量、预览和生成都在这一页完成,历史记录放在下方。</Text>
</Card>
</section>
@@ -943,12 +938,13 @@ export default function ContractsPage() {
</Empty>
) : (
<Table
className="contracts-template-table"
rowKey="id"
dataSource={templates}
loading={templateLoading}
columns={templateColumns}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用模板。" /> }}
pagination={{ pageSize: 10 }}
pagination={{ pageSize: 6, responsive: true }}
scroll={{ x: 900 }}
/>
)}
@@ -1186,6 +1182,7 @@ export default function ContractsPage() {
}
>
<Table
className="contracts-history-table"
rowKey="id"
dataSource={historyContracts}
loading={historyLoading}
+11 -4
View File
@@ -621,6 +621,12 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
if (!typeCode) {
return;
}
try {
await form.validateFields();
} catch {
messageApi.warning("请先补齐必填要素,再生成正式合同");
return;
}
const saved = await saveDraft(true);
if (!saved) {
messageApi.error("请先保存草稿后再生成");
@@ -694,7 +700,7 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
<Tag color={riskColorMap[documentType?.risk_level ?? ""] ?? "default"}>
{riskLabelMap[documentType?.risk_level ?? ""] ?? documentType?.risk_level ?? "风险等级"}
</Tag>
<Tag>{schema?.version_label ?? "schema"}</Tag>
<Tag>要素版本 {schema?.version_label ?? "当前"}</Tag>
</Space>
</div>
<div className="documents-hero-panel">
@@ -747,7 +753,7 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
type="warning"
style={{ marginBottom: 16 }}
message={`当前还有 ${validationErrorCount} 个要素需要补充或修正`}
description="可以先生成初稿,缺失必填字段会以“请补充”标识;建议正式交付前补齐。"
description="请先补齐必填字段,再生成正式合同。你仍可保存草稿,稍后继续完善。"
/>
) : validationResult ? (
<Alert
@@ -780,6 +786,7 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
}
name={["elements", field.key]}
valuePropName={valuePropName}
rules={field.required ? [{ required: true, message: `请输入${readableFieldLabel(field)}` }] : undefined}
validateStatus={errorMessage ? "warning" : undefined}
help={errorMessage ?? field.description ?? undefined}
>
@@ -1077,8 +1084,8 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
<strong>生成来源</strong>
</div>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Text>文书类型:{typeCode ?? "-"}</Text>
<Text>schema:{schema?.version_label ?? "-"}</Text>
<Text>文书类型:{readableDocumentName(documentType, typeCode ?? undefined)}</Text>
<Text>要素版本:{schema?.version_label ?? "当前"}</Text>
<Text>启用模板:{templates.length} 个</Text>
<Text>当前校验:{validationResult ? (validationResult.valid ? "通过" : "待补充") : "未校验"}</Text>
</Space>
+4 -4
View File
@@ -204,14 +204,14 @@ export default function NewDocumentPage() {
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">Legal Documents</span>
<span className="landing-badge">法律文书</span>
<Title level={2}>按业务场景起草法律文书</Title>
<Text type="secondary">
从文书类型、行业和使用场景进入,再填写动态要素。系统会保存草稿,并把生成来源、待补字段、AI 提醒和相关法条带到结果页。
</Text>
</div>
<div className="documents-hero-panel">
<span className="landing-panel-eyebrow">Phase15-A</span>
<span className="landing-panel-eyebrow">文书入口</span>
<Title level={4}>文书类型入口</Title>
<div className="documents-hero-metrics">
<div>
@@ -307,8 +307,8 @@ export default function NewDocumentPage() {
<section className="document-next-band">
<div>
<BankOutlined />
<strong>下一阶段</strong>
<span>Phase15-A 前端闭环完成后,继续进入审查建议采纳闭环。</span>
<strong>继续处理</strong>
<span>生成文书后,可以进入审查记录继续查看风险、下载报告或追问条款。</span>
</div>
<Button onClick={() => router.push("/reviews")}>查看审查记录</Button>
</section>
+29
View File
@@ -4340,6 +4340,35 @@ body::selection {
max-height: 360px;
font-size: 12px;
}
.contracts-page .review-side-card,
.contracts-page .ant-steps {
display: none;
}
.contracts-page .ant-card-head,
.contracts-page .ant-card-body {
padding: 14px !important;
}
.contracts-page .ant-table-cell {
padding: 10px 8px !important;
font-size: 12px;
}
.contracts-template-table .ant-table-cell:nth-child(2),
.contracts-template-table .ant-table-cell:nth-child(4),
.contracts-history-table .ant-table-cell:nth-child(2),
.contracts-history-table .ant-table-cell:nth-child(3),
.contracts-history-table .ant-table-cell:nth-child(5),
.contracts-history-table .ant-table-cell:nth-child(6) {
display: none;
}
.contracts-page .review-surface-card .ant-card-extra .ant-input-search,
.contracts-page .review-surface-card .ant-card-extra .ant-select {
width: 100% !important;
}
}
/* 2026 full-interface polish: shared product workbench system */
@@ -48,7 +48,7 @@ type EvidenceFormValues = {
export default function GovernanceWorkbenchPage() {
const [messageApi, contextHolder] = message.useMessage();
const currentUser = useAuthStore((state) => state.currentUser);
const canGovernanceOperate = currentUser?.role === "admin" || currentUser?.role === "legal" || currentUser?.role === "business";
const canGovernanceOperate = currentUser?.role === "admin" || currentUser?.role === "legal";
const canManageAssignments = currentUser?.role === "admin";
const [assignmentForm] = Form.useForm<AssignmentFormValues>();
const [decisionForm] = Form.useForm<DecisionFormValues>();
+31 -42
View File
@@ -19,16 +19,16 @@ type ProfileFormValues = {
function formatSharedAiMode(mode: SharedAiStatusResponse["mode"]) {
if (mode === "remote-preferred") {
return {
color: "processing",
label: "Remote preferred",
description: "chat, knowledge, and legal review are currently expected to use shared-ai-platform remote capabilities.",
color: "success",
label: "AI 服务可用",
description: "合同问答、知识检索和合同审查服务当前可正常使用。",
};
}
return {
color: "warning",
label: "Configuration required",
description: "shared-ai-platform is not fully configured for current AI features.",
label: "AI 服务待配置",
description: "部分 AI 功能暂时不可用,请联系管理员检查服务配置。",
};
}
@@ -123,19 +123,19 @@ export default function ProfilePage() {
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<section className="review-page-hero">
<div className="review-page-hero-copy">
<span className="landing-badge">Profile Settings</span>
<Title level={2}>管理当前账号信息,并查看 Shared AI 接入状态。</Title>
<Text type="secondary">这里保留个人资料与密码设置,同时把当前站点接入 shared-ai-platform 的运行模式直接展示出来。</Text>
<span className="landing-badge">个人设置</span>
<Title level={2}>管理当前账号信息。</Title>
<Text type="secondary">你可以在这里更新显示名称和登录密码。</Text>
</div>
<Card className="review-side-card" variant="borderless">
<span className="landing-panel-eyebrow">My Workspace</span>
<span className="landing-panel-eyebrow">账号信息</span>
<Title level={4} style={{ margin: 0, color: "inherit" }}>
日常操作边界
日常设置
</Title>
<ul className="review-page-tip-list">
<li>显示名称会同步到顶部导航。</li>
<li>密码留空时不会改动现有登录凭证。</li>
<li>Shared AI 状态可以帮助联调 chat、knowledge、legal review 的实际后端链路。</li>
<li>如需查看系统配置,请联系管理员。</li>
</ul>
</Card>
</section>
@@ -185,16 +185,17 @@ export default function ProfilePage() {
</Form>
</Card>
<Card
className="review-surface-card"
title="Shared AI 接入状态"
extra={
<Button onClick={() => void loadSharedAiStatus()} loading={sharedAiLoading}>
刷新状态
</Button>
}
loading={sharedAiLoading && !sharedAiStatus}
>
{currentUser?.role === "admin" ? (
<Card
className="review-surface-card"
title="AI 服务状态"
extra={
<Button onClick={() => void loadSharedAiStatus()} loading={sharedAiLoading}>
刷新状态
</Button>
}
loading={sharedAiLoading && !sharedAiStatus}
>
{sharedAiError ? (
<Alert
showIcon
@@ -219,33 +220,21 @@ export default function ProfilePage() {
/>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="App ID">{sharedAiStatus.app_id}</Descriptions.Item>
<Descriptions.Item label="API Prefix">{sharedAiStatus.api_prefix}</Descriptions.Item>
<Descriptions.Item label="Transport">
{sharedAiStatus.transport.sdk} / {sharedAiStatus.transport.fallback}
</Descriptions.Item>
<Descriptions.Item label="Knowledge Retrieval">
{sharedAiStatus.knowledge_defaults.retrieval_mode}
</Descriptions.Item>
<Descriptions.Item label="Remote Base URL">
{sharedAiStatus.base_url ?? "not configured"}
</Descriptions.Item>
<Descriptions.Item label="服务能力">合同问答、知识检索、合同审查</Descriptions.Item>
<Descriptions.Item label="知识检索">{sharedAiStatus.knowledge_defaults.retrieval_mode === "hybrid" ? "综合检索" : "标准检索"}</Descriptions.Item>
</Descriptions>
<Space wrap size={[8, 8]}>
<Tag color={sharedAiStatus.capabilities.chat_stream ? "success" : "default"}>chat stream</Tag>
<Tag color={sharedAiStatus.capabilities.conversation_history ? "success" : "default"}>conversation history</Tag>
<Tag color={sharedAiStatus.capabilities.knowledge_bases ? "success" : "default"}>knowledge base</Tag>
<Tag color={sharedAiStatus.capabilities.knowledge_documents ? "success" : "default"}>document ingest</Tag>
<Tag color={sharedAiStatus.capabilities.knowledge_retry ? "success" : "default"}>document retry</Tag>
<Tag color={sharedAiStatus.capabilities.legal_review_domain_pack ? "processing" : "default"}>legal-review domain pack</Tag>
{sharedAiStatus.capabilities.local_legal_review_fallback ? (
<Tag color="warning">legacy fallback configured</Tag>
) : null}
<Tag color={sharedAiStatus.capabilities.chat_stream ? "success" : "default"}>问答服务</Tag>
<Tag color={sharedAiStatus.capabilities.conversation_history ? "success" : "default"}>会话历史</Tag>
<Tag color={sharedAiStatus.capabilities.knowledge_bases ? "success" : "default"}>知识库</Tag>
<Tag color={sharedAiStatus.capabilities.knowledge_documents ? "success" : "default"}>文档处理</Tag>
<Tag color={sharedAiStatus.capabilities.legal_review_domain_pack ? "processing" : "default"}>合同审查</Tag>
</Space>
</Space>
) : null}
</Card>
</Card>
) : null}
</Space>
</main>
);
@@ -62,7 +62,6 @@ function getPrivateNavItems(role?: string | null) {
...sharedItems,
{ href: "/documents/new", label: "文书起草", icon: <FileTextOutlined /> },
{ href: "/contracts", label: "合同生成", icon: <FileDoneOutlined /> },
...(normalizedRole === "business" ? [{ href: "/governance/workbench", label: "复核工作台", icon: <DeploymentUnitOutlined /> }] : []),
{ href: "/reviews", label: "审查记录", icon: <HistoryOutlined /> },
{ href: "/chat", label: "法律问答", icon: <CommentOutlined /> },
{ href: "/profile", label: "个人设置", icon: <UserOutlined /> },
@@ -147,10 +146,10 @@ export function AppTopbar() {
<header className="app-topbar">
<div className="app-topbar-inner">
<Link className="app-brand" href="/">
<span className="app-brand-mark">AI</span>
<span className="app-brand-mark" aria-hidden="true">AI</span>
<div className="app-brand-copy">
<strong>AI 合同平台</strong>
<span>Contract Review Workbench</span>
<strong>合同平台</strong>
<span>合同审查工作台</span>
</div>
</Link>