feat: add legal article semantic search
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user