feat: link review risks to legal articles
This commit is contained in:
@@ -49,6 +49,17 @@ class LegalArticleRepository:
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_reference_candidates(self, tenant_id: str, limit: int = 500) -> list[LegalArticle]:
|
||||
return (
|
||||
self.db.query(LegalArticle)
|
||||
.filter(LegalArticle.tenant_id == tenant_id)
|
||||
.filter(LegalArticle.published_status == "published")
|
||||
.filter(LegalArticle.index_status == "indexed")
|
||||
.order_by(LegalArticle.source_id.asc(), LegalArticle.article_no.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def create(self, article: LegalArticle) -> LegalArticle:
|
||||
self.db.add(article)
|
||||
self.db.commit()
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.core.config import settings
|
||||
from app.models.review_result import ReviewResult
|
||||
from app.models.review_task import ReviewTask
|
||||
from app.schemas.ai_contract import build_ai_run_trace, build_parsed_document, build_review_workflow_state
|
||||
from app.repositories.legal_article_repository import LegalArticleRepository
|
||||
from app.repositories.review_result_repository import ReviewResultRepository
|
||||
from app.services.file_service import FileService
|
||||
from app.services.legal_review_execution_gateway import build_legal_review_execution_gateway
|
||||
@@ -372,6 +373,64 @@ RELATION_STATUS_INFO = "info"
|
||||
RELATION_STATUS_ATTENTION = "attention"
|
||||
RELATION_CONFIDENCE_HIGH = "high"
|
||||
RELATION_CONFIDENCE_WEAK = "weak"
|
||||
LEGAL_ARTICLE_REFERENCE_RULES = [
|
||||
{
|
||||
"triggers": ["违约金"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第五百八十五条"), ("LAW-CIVIL-CODE-2020", "第五百七十七条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["违约责任", "赔偿", "补救措施"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第五百七十七条"), ("LAW-CIVIL-CODE-2020", "第五百八十五条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["免责", "责任限制", "格式条款", "无效条款", "提示说明"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第四百九十六条"), ("LAW-CIVIL-CODE-2020", "第四百九十七条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["自动续约", "续约", "续期"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第四百七十条"), ("LAW-CIVIL-CODE-2020", "第五百零九条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["付款", "价款", "发票", "履行顺序", "结算"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第五百零九条"), ("LAW-CIVIL-CODE-2020", "第四百七十条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["合同条款", "必备条款", "合同内容"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第四百七十条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["书面形式", "数据电文", "电子合同"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第四百六十九条"), ("LAW-E-SIGNATURE-2019", "第四条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["电子签名", "签名", "盖章"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第四百九十条"), ("LAW-E-SIGNATURE-2019", "第十三条"), ("LAW-E-SIGNATURE-2019", "第十四条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["个人信息", "隐私", "敏感个人信息", "告知同意"],
|
||||
"targets": [("LAW-PIPL-2021", "第十三条"), ("LAW-PIPL-2021", "第十四条"), ("LAW-PIPL-2021", "第十七条"), ("LAW-PIPL-2021", "第二十八条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["数据安全", "重要数据", "出境安全"],
|
||||
"targets": [("LAW-DATA-SECURITY-2021", "第二十七条"), ("LAW-DATA-SECURITY-2021", "第二十九条"), ("LAW-DATA-SECURITY-2021", "第三十一条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["保密", "商业秘密"],
|
||||
"targets": [("LAW-CIVIL-CODE-2020", "第五百零九条"), ("LAW-CIVIL-CODE-2020", "第五百七十七条")],
|
||||
},
|
||||
{
|
||||
"triggers": ["竞业限制", "劳动合同", "服务期"],
|
||||
"targets": [("LAW-LABOR-CONTRACT-2012", "第二十三条"), ("LAW-LABOR-CONTRACT-2012", "第二十四条"), ("LAW-LABOR-CONTRACT-2012", "第二十二条")],
|
||||
},
|
||||
]
|
||||
LEGAL_SOURCE_ALIAS_MAP = {
|
||||
"LAW-CIVIL-CODE-2020": ["民法典", "中华人民共和国民法典"],
|
||||
"LAW-E-SIGNATURE-2019": ["电子签名法", "中华人民共和国电子签名法"],
|
||||
"LAW-PIPL-2021": ["个人信息保护法", "中华人民共和国个人信息保护法"],
|
||||
"LAW-LABOR-CONTRACT-2012": ["劳动合同法", "中华人民共和国劳动合同法"],
|
||||
"LAW-DATA-SECURITY-2021": ["数据安全法", "中华人民共和国数据安全法"],
|
||||
"LAW-COMPANY-2023": ["公司法", "中华人民共和国公司法"],
|
||||
}
|
||||
|
||||
pdfmetrics.registerFont(UnicodeCIDFont(PDF_FONT_NAME))
|
||||
|
||||
@@ -382,6 +441,7 @@ class ReviewService:
|
||||
self.repository = ReviewTaskRepository(db)
|
||||
self.review_rule_repository = ReviewRuleRepository(db)
|
||||
self.review_result_repository = ReviewResultRepository(db)
|
||||
self.legal_article_repository = LegalArticleRepository(db)
|
||||
self.file_service = FileService(db)
|
||||
|
||||
def _get_legal_review_execution_gateway(self):
|
||||
@@ -617,7 +677,7 @@ class ReviewService:
|
||||
assert result is not None
|
||||
|
||||
if result.get("rendered_report"):
|
||||
return result["rendered_report"]
|
||||
return self._append_legal_article_refs_to_rendered_report(result["rendered_report"], result.get("risk_items") or [])
|
||||
|
||||
lines = [
|
||||
f"审查任务:{review.title}",
|
||||
@@ -672,6 +732,13 @@ class ReviewService:
|
||||
lines.append(f" 判定说明:{evidence['reason']}")
|
||||
if item.get("legal_basis"):
|
||||
lines.append(f" 法条依据:{item['legal_basis']}")
|
||||
legal_article_refs = item.get("legal_article_refs") or []
|
||||
if legal_article_refs:
|
||||
refs_text = ";".join(
|
||||
f"{ref.get('source_title')} {ref.get('article_no')}({ref.get('detail_path')})"
|
||||
for ref in legal_article_refs
|
||||
)
|
||||
lines.append(f" 关联法条:{refs_text}")
|
||||
if item.get("suggestion"):
|
||||
lines.append(f" 修订建议:{item['suggestion']}")
|
||||
else:
|
||||
@@ -706,6 +773,23 @@ class ReviewService:
|
||||
lines.extend(["", "原始文本摘要:", (review.input_text or "未提供合同文本,仅记录了任务元数据。")[:500]])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _append_legal_article_refs_to_rendered_report(self, rendered_report: str, risk_items: List[Dict[str, Any]]) -> str:
|
||||
refs: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in risk_items:
|
||||
for ref in item.get("legal_article_refs") or []:
|
||||
ref_id = ref.get("id")
|
||||
if not ref_id or ref_id in seen:
|
||||
continue
|
||||
seen.add(ref_id)
|
||||
refs.append(ref)
|
||||
if not refs or "关联法条全文:" in rendered_report:
|
||||
return rendered_report
|
||||
lines = ["", "关联法条全文:"]
|
||||
for index, ref in enumerate(refs, start=1):
|
||||
lines.append(f"{index}. {ref.get('source_title')} {ref.get('article_no')}:{ref.get('detail_path')}")
|
||||
return f"{rendered_report.rstrip()}\n" + "\n".join(lines)
|
||||
|
||||
def build_report_pdf(self, current_user: Dict[str, Any], review_id: str) -> Optional[bytes]:
|
||||
review = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if review is None:
|
||||
@@ -1544,6 +1628,124 @@ class ReviewService:
|
||||
)
|
||||
return enriched
|
||||
|
||||
def _attach_legal_article_refs(self, risk_items: List[Dict[str, Any]], tenant_id: str) -> List[Dict[str, Any]]:
|
||||
candidates = self._load_legal_article_reference_candidates(tenant_id)
|
||||
if not candidates:
|
||||
return risk_items
|
||||
|
||||
by_source_article = {(article.source_id, article.article_no): article for article in candidates}
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for item in risk_items:
|
||||
if item.get("legal_article_refs"):
|
||||
enriched.append(item)
|
||||
continue
|
||||
|
||||
refs = self._resolve_legal_article_refs_for_risk(item, candidates, by_source_article)
|
||||
enriched.append({**item, "legal_article_refs": refs})
|
||||
return enriched
|
||||
|
||||
def _load_legal_article_reference_candidates(self, tenant_id: str):
|
||||
try:
|
||||
return self.legal_article_repository.list_reference_candidates(tenant_id)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _resolve_legal_article_refs_for_risk(self, item: Dict[str, Any], candidates: List[Any], by_source_article: Dict[tuple, Any]) -> List[Dict[str, Any]]:
|
||||
risk_text = self._build_legal_reference_search_text(item)
|
||||
selected: List[tuple[Any, str]] = []
|
||||
|
||||
for article in candidates:
|
||||
if self._is_direct_legal_article_match(risk_text, article):
|
||||
selected.append((article, "法律依据文本直接命中"))
|
||||
|
||||
for rule in LEGAL_ARTICLE_REFERENCE_RULES:
|
||||
matched_trigger = next((trigger for trigger in rule["triggers"] if trigger in risk_text), None)
|
||||
if not matched_trigger:
|
||||
continue
|
||||
for target in rule["targets"]:
|
||||
article = by_source_article.get(target)
|
||||
if article is not None:
|
||||
selected.append((article, f"命中风险关键词:{matched_trigger}"))
|
||||
|
||||
if not selected:
|
||||
selected = self._score_legal_article_refs_by_tags(risk_text, candidates)
|
||||
|
||||
refs: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for article, match_reason in selected:
|
||||
if article.id in seen:
|
||||
continue
|
||||
seen.add(article.id)
|
||||
refs.append(self._format_legal_article_ref(article, match_reason))
|
||||
if len(refs) >= 3:
|
||||
break
|
||||
return refs
|
||||
|
||||
def _build_legal_reference_search_text(self, item: Dict[str, Any]) -> str:
|
||||
evidence = item.get("evidence") or {}
|
||||
parts = [
|
||||
item.get("title"),
|
||||
item.get("description"),
|
||||
item.get("suggestion"),
|
||||
item.get("legal_basis"),
|
||||
evidence.get("quote"),
|
||||
evidence.get("reason"),
|
||||
evidence.get("clause_title"),
|
||||
" ".join(evidence.get("matched_keywords") or []),
|
||||
]
|
||||
return "\n".join(str(part) for part in parts if part).lower()
|
||||
|
||||
def _is_direct_legal_article_match(self, risk_text: str, article: Any) -> bool:
|
||||
if not article.article_no or article.article_no not in risk_text:
|
||||
return False
|
||||
aliases = LEGAL_SOURCE_ALIAS_MAP.get(article.source_id, [])
|
||||
aliases = [*aliases, article.source_id, article.source_title]
|
||||
return any(alias and alias.lower() in risk_text for alias in aliases)
|
||||
|
||||
def _score_legal_article_refs_by_tags(self, risk_text: str, candidates: List[Any]) -> List[tuple[Any, str]]:
|
||||
scored: List[tuple[int, Any, str]] = []
|
||||
for article in candidates:
|
||||
score = 0
|
||||
matched_tags: List[str] = []
|
||||
for tag in article.tags_json or []:
|
||||
tag_text = str(tag).strip()
|
||||
if tag_text and tag_text.lower() in risk_text:
|
||||
score += 3
|
||||
matched_tags.append(tag_text)
|
||||
for alias in LEGAL_SOURCE_ALIAS_MAP.get(article.source_id, []):
|
||||
if alias.lower() in risk_text:
|
||||
score += 2
|
||||
if article.article_no and article.article_no in risk_text:
|
||||
score += 4
|
||||
if score >= 4:
|
||||
reason = f"命中法条标签:{'、'.join(matched_tags[:2])}" if matched_tags else "命中法律依据文本"
|
||||
scored.append((score, article, reason))
|
||||
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
return [(article, reason) for _, article, reason in scored[:3]]
|
||||
|
||||
def _format_legal_article_ref(self, article: Any, match_reason: str) -> Dict[str, Any]:
|
||||
updated_at = article.updated_at.astimezone(timezone.utc).isoformat() if article.updated_at else None
|
||||
return {
|
||||
"id": article.id,
|
||||
"source_id": article.source_id,
|
||||
"source_title": article.source_title,
|
||||
"article_no": article.article_no,
|
||||
"chapter_title": article.chapter_title,
|
||||
"section_title": article.section_title,
|
||||
"authority": article.authority,
|
||||
"effective_date": article.effective_date,
|
||||
"validity_status": article.validity_status,
|
||||
"region": article.region,
|
||||
"tags": article.tags_json or [],
|
||||
"article_excerpt": self._summarize_clause_text(article.article_text),
|
||||
"knowledge_base_id": article.knowledge_base_id,
|
||||
"knowledge_document_id": article.knowledge_document_id,
|
||||
"detail_path": f"/legal-articles?article_id={article.id}",
|
||||
"match_reason": match_reason,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _build_legal_basis(self, risk_items: List[Dict[str, str]]) -> List[str]:
|
||||
basis: List[str] = []
|
||||
@@ -1568,9 +1770,16 @@ class ReviewService:
|
||||
|
||||
def _build_report_payload(self, review: ReviewTask, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enriched = self._enrich_review_result(review, result)
|
||||
risk_items = enriched["risk_items"]
|
||||
risk_items = self._attach_legal_article_refs(enriched["risk_items"], review.tenant_id)
|
||||
rendered_report = enriched.get("rendered_report")
|
||||
if rendered_report:
|
||||
rendered_report = self._append_legal_article_refs_to_rendered_report(rendered_report, risk_items)
|
||||
confidence_summary = enriched.get("confidence_summary")
|
||||
if rendered_report and confidence_summary is not None:
|
||||
confidence_summary = dict(confidence_summary)
|
||||
confidence_summary["rendered_report"] = rendered_report
|
||||
lifecycle = self._build_review_lifecycle(review)
|
||||
ai_contract_state = self._build_ai_contract_state(review, lifecycle, enriched.get("confidence_summary"))
|
||||
ai_contract_state = self._build_ai_contract_state(review, lifecycle, confidence_summary)
|
||||
return {
|
||||
"basic_info": enriched["basic_info"],
|
||||
"contract_structure": enriched["contract_structure"],
|
||||
@@ -1581,8 +1790,8 @@ class ReviewService:
|
||||
"legal_basis": self._build_legal_basis(risk_items),
|
||||
"review_opinion": enriched["review_opinion"],
|
||||
"recommendation": enriched["recommendation"],
|
||||
"confidence_summary": enriched.get("confidence_summary"),
|
||||
"rendered_report": enriched.get("rendered_report"),
|
||||
"confidence_summary": confidence_summary,
|
||||
"rendered_report": rendered_report,
|
||||
"source_excerpt": (review.input_text or "未提供合同文本,仅记录了任务元数据。")[:500],
|
||||
"workflow_state": ai_contract_state["workflow_state"],
|
||||
"ai_trace": ai_contract_state["ai_trace"],
|
||||
@@ -1646,7 +1855,8 @@ class ReviewService:
|
||||
|
||||
result = self._get_legal_review_execution_gateway().run_review(review)
|
||||
result = self._enrich_review_result(review, result, getattr(review, "_extraction_metadata", None))
|
||||
risk_items = result["risk_items"]
|
||||
risk_items = self._attach_legal_article_refs(result["risk_items"], review.tenant_id)
|
||||
result["risk_items"] = risk_items
|
||||
risk_summary = result["risk_summary"]
|
||||
confidence_summary = result.get("confidence_summary")
|
||||
if confidence_summary and result.get("rendered_report"):
|
||||
|
||||
@@ -94,6 +94,49 @@ class StubFileService:
|
||||
return self.items[file_id]
|
||||
|
||||
|
||||
class StubLegalArticle:
|
||||
def __init__(self, article_id, source_id, source_title, article_no, tags, article_text):
|
||||
self.id = article_id
|
||||
self.source_id = source_id
|
||||
self.source_title = source_title
|
||||
self.source_type = "法律"
|
||||
self.authority = "全国人民代表大会"
|
||||
self.effective_date = "2021-01-01"
|
||||
self.validity_status = "现行有效"
|
||||
self.region = "CN"
|
||||
self.official_url = "https://flk.npc.gov.cn/"
|
||||
self.article_no = article_no
|
||||
self.chapter_title = "第三编 合同"
|
||||
self.section_title = "第八章 违约责任"
|
||||
self.article_text = article_text
|
||||
self.tags_json = tags
|
||||
self.source_checksum = "source-checksum"
|
||||
self.text_checksum = "text-checksum"
|
||||
self.knowledge_base_id = "kb-phase14"
|
||||
self.knowledge_document_id = f"doc-{article_id}"
|
||||
self.index_status = "indexed"
|
||||
self.published_status = "published"
|
||||
self.published_at = datetime.now(timezone.utc)
|
||||
self.published_by = "codex"
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class StubLegalArticleRepository:
|
||||
def __init__(self):
|
||||
self.items = [
|
||||
StubLegalArticle("lart-civil-470", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第四百七十条", ["合同条款", "合同内容"], "合同的内容由当事人约定。"),
|
||||
StubLegalArticle("lart-civil-496", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第四百九十六条", ["格式条款", "提示说明"], "格式条款是当事人为了重复使用而预先拟定的条款。"),
|
||||
StubLegalArticle("lart-civil-497", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第四百九十七条", ["格式条款", "无效条款"], "有法定情形的,格式条款无效。"),
|
||||
StubLegalArticle("lart-civil-509", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第五百零九条", ["全面履行", "诚信原则"], "当事人应当按照约定全面履行自己的义务。"),
|
||||
StubLegalArticle("lart-civil-577", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第五百七十七条", ["违约责任", "赔偿损失"], "当事人一方不履行合同义务的,应当承担违约责任。"),
|
||||
StubLegalArticle("lart-civil-585", "LAW-CIVIL-CODE-2020", "中华人民共和国民法典", "第五百八十五条", ["违约金", "损失赔偿"], "当事人可以约定一方违约时应当根据违约情况向对方支付一定数额的违约金。"),
|
||||
]
|
||||
|
||||
def list_reference_candidates(self, tenant_id, limit=500):
|
||||
return self.items[:limit]
|
||||
|
||||
|
||||
class StubLegalReviewGateway:
|
||||
def run_review(self, review):
|
||||
return {
|
||||
@@ -198,6 +241,7 @@ class ReviewServiceTestCase(unittest.TestCase):
|
||||
self.service.repository = StubRepository()
|
||||
self.service.review_rule_repository = StubRuleRepository()
|
||||
self.service.review_result_repository = StubReviewResultRepository()
|
||||
self.service.legal_article_repository = StubLegalArticleRepository()
|
||||
self.service.file_service = StubFileService()
|
||||
self.service._get_legal_review_execution_gateway = lambda: StubLegalReviewGateway()
|
||||
self.current_user = {"id": "user-1", "tenant_id": "tenant-demo-001", "role": "admin"}
|
||||
@@ -257,6 +301,11 @@ class ReviewServiceTestCase(unittest.TestCase):
|
||||
self.assertIn("summary", result["clauses"][0])
|
||||
self.assertEqual(result["risk_items"][0]["certainty"], "confirmed")
|
||||
self.assertTrue(result["risk_items"][0]["evidence"]["quote"])
|
||||
breach_item = next(item for item in result["risk_items"] if item["title"] == "违约责任条款需重点核查")
|
||||
self.assertEqual(breach_item["legal_article_refs"][0]["id"], "lart-civil-585")
|
||||
self.assertEqual(breach_item["legal_article_refs"][0]["detail_path"], "/legal-articles?article_id=lart-civil-585")
|
||||
liability_item = next(item for item in result["risk_items"] if item["title"] == "免责或责任限制条款需要限制范围")
|
||||
self.assertEqual(liability_item["legal_article_refs"][0]["article_no"], "第四百九十六条")
|
||||
self.assertIn("structured_analysis", result["confidence_summary"])
|
||||
task = self.service.repository.get_by_id(created["id"])
|
||||
self.assertIsNotNone(task)
|
||||
|
||||
@@ -145,6 +145,29 @@ export default function LegalArticlesPage() {
|
||||
[currentUser?.role, filters, messageApi, page, pageSize],
|
||||
);
|
||||
|
||||
const openArticleDetail = useCallback(
|
||||
async (articleId: string, fallback?: LegalArticleItem) => {
|
||||
if (fallback) {
|
||||
setSelectedArticle(fallback);
|
||||
}
|
||||
setDrawerOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const result = await getLegalArticle(articleId);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "读取法条详情失败");
|
||||
return;
|
||||
}
|
||||
setSelectedArticle(result.data);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "读取法条详情失败,请稍后重试");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[messageApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadArticles();
|
||||
@@ -153,6 +176,16 @@ export default function LegalArticlesPage() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadArticles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role !== "admin" || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const articleId = new URLSearchParams(window.location.search).get("article_id");
|
||||
if (articleId) {
|
||||
void openArticleDetail(articleId);
|
||||
}
|
||||
}, [currentUser?.role, openArticleDetail]);
|
||||
|
||||
const handleSearch = (values: LegalArticleFilterValues) => {
|
||||
const nextFilters = normalizeFilters(values);
|
||||
setFilters(nextFilters);
|
||||
@@ -174,24 +207,6 @@ export default function LegalArticlesPage() {
|
||||
void loadArticles(nextPage, nextPageSize, filters);
|
||||
};
|
||||
|
||||
const handleOpenDetail = async (item: LegalArticleItem) => {
|
||||
setSelectedArticle(item);
|
||||
setDrawerOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const result = await getLegalArticle(item.id);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "读取法条详情失败");
|
||||
return;
|
||||
}
|
||||
setSelectedArticle(result.data);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "读取法条详情失败,请稍后重试");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LegalArticleItem> = [
|
||||
{
|
||||
title: "法源",
|
||||
@@ -288,7 +303,7 @@ export default function LegalArticlesPage() {
|
||||
fixed: "right",
|
||||
width: 120,
|
||||
render: (_, item) => (
|
||||
<Button icon={<EyeOutlined />} size="small" onClick={() => void handleOpenDetail(item)}>
|
||||
<Button icon={<EyeOutlined />} size="small" onClick={() => void openArticleDetail(item.id, item)}>
|
||||
查看全文
|
||||
</Button>
|
||||
),
|
||||
|
||||
@@ -1360,6 +1360,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
<div className="review-detail-card-grid">
|
||||
{riskItems.map((item, index) => {
|
||||
const followups = buildRiskFollowups(item, detail, result);
|
||||
const legalArticleRefs = item.legal_article_refs ?? [];
|
||||
return (
|
||||
<article className="review-detail-item-card" data-testid="review-risk-item-readability" key={`${item.title ?? "risk-item"}-${index}`}>
|
||||
<div className="review-detail-item-header">
|
||||
@@ -1369,7 +1370,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
<Paragraph className="review-detail-item-description">
|
||||
{item.description ?? "暂无风险描述"}
|
||||
</Paragraph>
|
||||
{item.evidence?.quote || item.legal_basis || item.suggestion ? (
|
||||
{item.evidence?.quote || item.legal_basis || legalArticleRefs.length > 0 || item.suggestion ? (
|
||||
<section className="review-detail-evidence-card">
|
||||
<div className="review-detail-evidence-head">
|
||||
<span>风险证据卡</span>
|
||||
@@ -1397,6 +1398,19 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
|
||||
<strong>{item.legal_basis}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{legalArticleRefs.length > 0 ? (
|
||||
<div className="review-detail-evidence-meta">
|
||||
<span>法条全文</span>
|
||||
<Space direction="vertical" size={6}>
|
||||
{legalArticleRefs.map((ref) => (
|
||||
<Link href={ref.detail_path} key={ref.id}>
|
||||
{ref.source_title} {ref.article_no}
|
||||
{ref.match_reason ? ` · ${ref.match_reason}` : ""}
|
||||
</Link>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
{item.suggestion ? (
|
||||
<div className="review-detail-evidence-meta">
|
||||
<span>修订建议</span>
|
||||
|
||||
@@ -147,6 +147,25 @@ export type ReviewResult = {
|
||||
description?: string;
|
||||
suggestion?: string;
|
||||
legal_basis?: string | null;
|
||||
legal_article_refs?: Array<{
|
||||
id: string;
|
||||
source_id: string;
|
||||
source_title: string;
|
||||
article_no: string;
|
||||
chapter_title?: string | null;
|
||||
section_title?: string | null;
|
||||
authority?: string | null;
|
||||
effective_date?: string | null;
|
||||
validity_status?: string | null;
|
||||
region?: string | null;
|
||||
tags?: string[];
|
||||
article_excerpt?: string | null;
|
||||
knowledge_base_id?: string | null;
|
||||
knowledge_document_id?: string | null;
|
||||
detail_path: string;
|
||||
match_reason?: string | null;
|
||||
updated_at?: string | null;
|
||||
}>;
|
||||
certainty?: string;
|
||||
evidence?: {
|
||||
clause_title?: string | null;
|
||||
|
||||
@@ -2143,3 +2143,49 @@
|
||||
### 阻塞项
|
||||
- 本轮无 required 阻塞项。
|
||||
- 仍保留旧的非 required 项:外部告警 Webhook 未配置。
|
||||
|
||||
## 2026-05-16 Phase14 审查报告法条全文引用闭环
|
||||
### 本轮完成项
|
||||
- 已把审查风险项的“法条依据”补成可追溯的 `legal_article_refs`:
|
||||
- 后端从已发布、已索引的 `legal_articles` 中匹配具体条文。
|
||||
- 每个引用包含:法源、条号、章节、摘要、知识库编号、知识文档编号和 `detail_path`。
|
||||
- 典型链接格式:`/legal-articles?article_id=lart-...`
|
||||
- 已增强审查报告内容:
|
||||
- Web 审查详情页风险证据卡新增“法条全文”链接。
|
||||
- PDF/文本报告内容会追加“关联法条全文”路径,避免导出报告丢失追溯线索。
|
||||
- 已增强法条全文库页面:
|
||||
- 支持 `/legal-articles?article_id=...` 直接打开指定法条全文抽屉。
|
||||
|
||||
### 本轮验证
|
||||
- 后端编译:
|
||||
- `.venv\Scripts\python.exe -m py_compile app\services\review_service.py app\repositories\legal_article_repository.py`
|
||||
- 结果:通过
|
||||
- 后端单测:
|
||||
- `.venv\Scripts\python.exe -m unittest tests.test_review_service tests.test_review_service_rules tests.test_legal_articles_api`
|
||||
- 结果:`40 tests OK`
|
||||
- 前端校验:
|
||||
- `npm --prefix frontend run lint`
|
||||
- `npm --prefix frontend run build`
|
||||
- 结果:均通过
|
||||
- Playwright 前端冒烟:
|
||||
- 从 `/reviews/review-001` 点击“中华人民共和国民法典 第五百八十五条”
|
||||
- 跳转到 `/legal-articles?article_id=lart-civil-585`
|
||||
- 自动打开法条全文抽屉并显示正文
|
||||
- 结果:`PASS`
|
||||
- `git diff --check`
|
||||
- 结果:通过,仅有 Git CRLF 工作区提示
|
||||
|
||||
### 当前状态
|
||||
- Phase14 已形成“风险项 -> 法条依据 -> 具体法条全文 -> 知识库追溯”的第一版闭环。
|
||||
- 当前匹配方式以已入库首批 33 条高频条文为真相源,优先按风险关键词、法律名称、条号和标签匹配。
|
||||
|
||||
### 未完成项
|
||||
- 当前只覆盖首批 Phase14 已吸收的 6 个法源、33 条高频合同条文。
|
||||
- 后续增强项:
|
||||
- 扩展更多法律法规和司法解释条文。
|
||||
- 把 `legal_article_refs` 进一步带入 Chat 引用来源和报告下载文件元数据。
|
||||
- 增加后台规则配置中的“绑定具体法条”选择器,让规则和法条绑定更精确。
|
||||
|
||||
### 阻塞项
|
||||
- 本轮无 required 阻塞项。
|
||||
- 仍保留旧的非 required 项:外部告警 Webhook 未配置。
|
||||
|
||||
Reference in New Issue
Block a user