feat: add legal article semantic search

This commit is contained in:
2026-05-24 01:39:49 +08:00
parent 95680516bb
commit 26fdac2c4d
7 changed files with 227 additions and 2 deletions
+25
View File
@@ -18,6 +18,7 @@ ROUTE_PERMISSION_ACTIONS = {
"bulk_upsert": "legal_articles:bulk_upsert",
"index_status": "legal_articles:index_status:update",
"vectorize": "legal_articles:vectorize",
"semantic_search": "legal_articles:semantic_search",
}
@@ -93,6 +94,30 @@ def list_legal_articles(
article_no,
index_status,
published_status,
)
return success_response(result)
@router.get("/semantic-search")
def semantic_search_legal_articles(
q: str,
limit: int = 10,
legal_article_service: LegalArticleService = Depends(get_legal_article_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = legal_article_service.semantic_search_articles(current_user, q, limit=limit)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="semantic_search_legal_articles",
resource_type="legal_article",
resource_id="semantic-search",
detail_json={
"query": result["query"],
"limit": result["limit"],
"result_count": len(result["items"]),
},
)
return success_response(result)
@@ -145,6 +145,23 @@ class LegalArticleService:
"summary": summary,
}
def semantic_search_articles(self, current_user: dict, query: str, limit: int = 10) -> dict:
self._require_admin(current_user)
normalized_query = (query or "").strip()
if not normalized_query:
raise HTTPException(status_code=400, detail="query is required")
normalized_limit = max(1, min(limit, 20))
items = self.search_runtime_sources(current_user, normalized_query, normalized_limit)
return {
"query": normalized_query,
"limit": normalized_limit,
"items": items,
"vectorization_summary": self.repository.get_vectorization_summary(
current_user["tenant_id"],
LEGAL_ARTICLE_SEMANTIC_VECTOR_VERSION,
),
}
def bulk_upsert_articles(
self,
current_user: dict,
@@ -114,6 +114,17 @@ class LegalArticleServiceTestCase(unittest.TestCase):
self.assertEqual(result["summary"]["vectorized_articles"], 1)
self.assertEqual(result["summary"]["coverage_percent"], 100.0)
def test_semantic_search_articles_returns_ranked_results(self):
self.service.bulk_upsert_articles(self.current_user, [self.payload], enqueue_impact_scan=False)
result = self.service.semantic_search_articles(self.current_user, "违约金", limit=5)
self.assertEqual(result["query"], "违约金")
self.assertEqual(result["vectorization_summary"]["coverage_percent"], 100.0)
self.assertGreaterEqual(len(result["items"]), 1)
self.assertEqual(result["items"][0]["source_type"], "legal_article")
self.assertIn("语义向量", str(result["items"][0]["metadata"]["match_reason"]))
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -132,6 +132,39 @@ class StubLegalArticleService:
"latest_article_title": "中华人民共和国民法典 第四百六十四条",
},
}
def semantic_search_articles(self, current_user, query, limit=10):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"query": query,
"limit": limit,
"items": [
{
"title": "中华人民共和国民法典 第四百六十四条",
"content": "合同是民事主体之间设立、变更、终止民事法律关系的协议。",
"score": 0.913,
"source_type": "legal_article",
"source_id": "lart-001",
"detail_path": "/legal-articles?article_id=lart-001",
"article_no": "第四百六十四条",
"authority": "全国人民代表大会",
"knowledge_base_id": "kb-001",
"knowledge_document_id": "doc-001",
"metadata": {
"match_reason": "语义向量:0.913",
},
}
],
"vectorization_summary": {
"total_articles": 1,
"vectorized_articles": 1,
"pending_articles": 0,
"coverage_percent": 100.0,
"latest_vectorized_at": "2026-05-24T00:00:00+00:00",
"latest_article_id": "lart-001",
"latest_article_title": "中华人民共和国民法典 第四百六十四条",
},
}
class LegalArticlesApiTestCase(unittest.TestCase):
@@ -244,6 +277,21 @@ class LegalArticlesApiTestCase(unittest.TestCase):
self.assertEqual(response.json()["data"]["vectorized_count"], 1)
self.assertEqual(self.audit_log_service.events[0]["action"], "vectorize_legal_articles")
def test_admin_can_semantic_search_legal_articles(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.get("/api/v1/legal-articles/semantic-search", params={"q": "违约责任", "limit": 5})
self.assertEqual(response.status_code, 200)
body = response.json()["data"]
self.assertEqual(body["items"][0]["source_id"], "lart-001")
self.assertEqual(body["vectorization_summary"]["coverage_percent"], 100.0)
self.assertEqual(self.audit_log_service.events[0]["action"], "semantic_search_legal_articles")
def test_member_cannot_access_legal_articles(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-member",
+108 -2
View File
@@ -6,9 +6,9 @@ import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getLegalArticle, listLegalArticles, vectorizeLegalArticles } from "@/services/legal-article";
import { getLegalArticle, listLegalArticles, semanticSearchLegalArticles, vectorizeLegalArticles } from "@/services/legal-article";
import { useAuthStore } from "@/stores/auth.store";
import type { LegalArticleItem } from "@/types/api";
import type { ChatSourceItem, LegalArticleItem } from "@/types/api";
const { Paragraph, Text, Title } = Typography;
@@ -97,6 +97,11 @@ function normalizeFilters(values: LegalArticleFilterValues): LegalArticleFilterV
};
}
function getSemanticSearchReason(item: ChatSourceItem) {
const reason = item.metadata?.match_reason;
return typeof reason === "string" && reason.trim() ? reason : "混合语义命中";
}
export default function LegalArticlesPage() {
const [messageApi, contextHolder] = message.useMessage();
const currentUser = useAuthStore((state) => state.currentUser);
@@ -111,6 +116,9 @@ export default function LegalArticlesPage() {
const [selectedArticle, setSelectedArticle] = useState<LegalArticleItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [vectorizing, setVectorizing] = useState(false);
const [semanticQuery, setSemanticQuery] = useState("");
const [semanticSearching, setSemanticSearching] = useState(false);
const [semanticResults, setSemanticResults] = useState<ChatSourceItem[]>([]);
const [vectorizationSummary, setVectorizationSummary] = useState({
total_articles: 0,
vectorized_articles: 0,
@@ -228,6 +236,29 @@ export default function LegalArticlesPage() {
}
};
const handleSemanticSearch = async (value?: string) => {
const normalizedQuery = (value ?? semanticQuery).trim();
if (!normalizedQuery) {
messageApi.warning("请输入语义检索问题或关键词");
return;
}
setSemanticQuery(normalizedQuery);
setSemanticSearching(true);
try {
const result = await semanticSearchLegalArticles(normalizedQuery, 8);
if (result.code !== 0) {
messageApi.error(result.message || "法条语义检索失败");
return;
}
setSemanticResults(result.data.items);
setVectorizationSummary(result.data.vectorization_summary);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : "法条语义检索失败,请稍后重试");
} finally {
setSemanticSearching(false);
}
};
const handleTableChange = (pagination: TablePaginationConfig) => {
const nextPage = pagination.current ?? 1;
const nextPageSize = pagination.pageSize ?? pageSize;
@@ -235,6 +266,53 @@ export default function LegalArticlesPage() {
void loadArticles(nextPage, nextPageSize, filters);
};
const semanticColumns: ColumnsType<ChatSourceItem> = [
{
title: "命中法条",
key: "title",
width: 260,
render: (_, item) => (
<Space direction="vertical" size={2}>
<Text strong>{item.title}</Text>
<Text type="secondary">{item.authority || "-"}</Text>
</Space>
),
},
{
title: "相关度",
dataIndex: "score",
key: "score",
width: 100,
render: (value?: number) => (typeof value === "number" ? value.toFixed(3) : "-"),
},
{
title: "命中原因",
key: "reason",
width: 220,
render: (_, item) => <Text type="secondary">{getSemanticSearchReason(item)}</Text>,
},
{
title: "摘要",
dataIndex: "content",
key: "content",
render: (value: string) => (
<Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{value}
</Paragraph>
),
},
{
title: "操作",
key: "actions",
width: 120,
render: (_, item) => (
<Button disabled={!item.source_id} icon={<EyeOutlined />} size="small" onClick={() => (item.source_id ? void openArticleDetail(item.source_id) : undefined)}>
查看法条
</Button>
),
},
];
const columns: ColumnsType<LegalArticleItem> = [
{
title: "法源",
@@ -393,6 +471,34 @@ export default function LegalArticlesPage() {
</Card>
</Space>
<Card className="review-surface-card" title="语义检索">
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
allowClear
enterButton="检索"
loading={semanticSearching}
onChange={(event) => setSemanticQuery(event.target.value)}
onSearch={(value) => void handleSemanticSearch(value)}
placeholder="输入法条语义问题,例如:违约责任、付款条件、电子签章证据"
value={semanticQuery}
/>
<Alert
message="语义检索会优先结合已向量化的法条库返回结果,再叠加关键词与条号命中。"
type="info"
showIcon
/>
<Table
rowKey={(item) => `${item.source_id}-${item.detail_path}`}
columns={semanticColumns}
dataSource={semanticResults}
loading={semanticSearching}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="输入一个问题后即可看到语义命中的法条。" /> }}
pagination={{ pageSize: 5, showSizeChanger: false }}
scroll={{ x: 1200 }}
/>
</Space>
</Card>
<Card className="review-surface-card" title="检索条件">
<Form form={form} layout="vertical" initialValues={defaultFilters} onFinish={handleSearch}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 16 }}>
+11
View File
@@ -4,6 +4,7 @@ import type {
LegalArticleItem,
LegalArticleListParams,
LegalArticleListResponse,
LegalArticleSemanticSearchResponse,
LegalArticleVectorizeResponse,
} from "@/types/api";
@@ -32,3 +33,13 @@ export async function vectorizeLegalArticles(batchSize = 200, force = false) {
});
return response.data;
}
export async function semanticSearchLegalArticles(query: string, limit = 10) {
const response = await http.get<ApiResponse<LegalArticleSemanticSearchResponse>>("/legal-articles/semantic-search", {
params: {
q: query,
limit,
},
});
return response.data;
}
+7
View File
@@ -379,6 +379,13 @@ export type LegalArticleVectorizeResponse = {
summary: LegalArticleVectorizationSummary;
};
export type LegalArticleSemanticSearchResponse = {
query: string;
limit: number;
items: ChatSourceItem[];
vectorization_summary: LegalArticleVectorizationSummary;
};
export type LegalChangeImpactLevel = "immediate_update" | "next_renewal_update" | "reference_only" | string;
export type LegalChangeImpactStatus = "open" | "in_progress" | "resolved" | "ignored" | string;