diff --git a/docs/qa/controlled-pilot-authoritative-legal-review-20260827.md b/docs/qa/controlled-pilot-authoritative-legal-review-20260827.md new file mode 100644 index 00000000..2f285a80 --- /dev/null +++ b/docs/qa/controlled-pilot-authoritative-legal-review-20260827.md @@ -0,0 +1,79 @@ +# 受控试点权威法源与 AI 法务复核阶段报告 + +## 结论 + +截至 2026-08-27,本阶段完成了权威中国法律来源库、回答与法源显式绑定、来源版本与双哈希校验、租户隔离修复、真实模型分层评测及 AI 二次法务复核闭环。 + +现有证据支持继续开发并准备下一轮受控预发布验收,但不支持直接放行真实用户试点:100 个真实 Provider 样本的自动质量门禁全部通过,但执行耗时 P95 为 195,858 ms,超过 180,000 ms 门槛;同时,该 100 样本批次生成于法源库扩展前,尚未在最终 56 条法源清单上完整重跑。 + +本报告中的“法务复核”均由 `gpt-5.6-sol` 在平台所有者授权下执行,评审者类型明确记录为 `ai`,不代表人类律师审查、律师签名、司法存证或司法效力证明。 + +## 已实施能力 + +1. 建立 `laws-cn-authoritative-v1` 权威法源清单,共 56 条稳定来源,覆盖采购、销售、劳动、租赁、保密、软件、建设工程、数据处理、股权和运输十类合同。 +2. 每条来源记录稳定来源 ID、发布机关、文号、现行版本、生效日期、官方 URL、内容 SHA-256 和清单 SHA-256。当前清单 SHA-256 为 `224d36cded54a6d82db6fbb9758866cf79fa0950ac1c66c931c6fb78a80e382e`。 +3. 模型回答必须使用 `[来源: LAW-CN-...]` 显式绑定实际使用的法源;返回引用必须是回答已绑定法源的子集,不能把仅检索但未使用的来源包装成论据。 +4. 对数据处理复合问题动态保留 8 个权威检索结果,补齐委托处理、转委托、监督、个人信息出境、安全评估、保护认证、标准合同、单独告知与同意、境内存储及电子证据规则。 +5. 修复租户上下文实现,移除可能残留的线程本地租户值,并在内存、PostgreSQL 与向量检索路径中统一执行租户过滤。 +6. 合同审查工作线程默认使用权威法源库;前端展示官方来源、发布机关、版本、状态、检索日期和内容摘要哈希,并明确标识 AI 输出边界。 +7. 委托 AI 复核工件采用 `delegated-ai-legal-review-v2`,保留评审输入哈希、批次哈希、评审模型、授权依据、事实一致性、引用可验证性、高风险遗漏及处置决定,但不在工件中复制问题和回答全文。 + +## 双租户导入与可恢复性 + +权威清单已导入 `controlled-pilot-v1` 和 `default` 两个租户。最终幂等复跑结果均为:新增 0、跳过 56、验证 56、重复 0。 + +导入治理过程中删除了 18 个重复文档/分块。删除目标均可从版本化法源清单重新生成;没有删除租户自建知识文档,也没有覆盖同一稳定来源 ID 的历史正文。 + +## 100 样本基线 + +运行 ID:`stratified-v2-authoritative-20260826` + +- 提交 100,符合真实 Provider 口径 100。 +- 自动质量通过 100,自动质量状态 `needs_review` 0、失败 0;十类合同各 10 条且各类自动通过率均为 100%。另有 40 条高风险必审和 2 条随机抽样,共 42 条进入策略复核队列。 +- 10 并发波次准入拒绝 0。 +- 排队耗时 P50/P95:125/345 ms。 +- 执行耗时 P50/P95:164,584/195,858 ms。 +- 总门禁未通过,唯一失败项为执行 P95 超过 180 秒目标 15,858 ms。 + +该结果只证明确定性质量规则和当时模型配置下的样本表现,不证明法律结论对所有案件正确。 + +## AI 二次复核 + +### 原始 42 条高风险与抽样输出 + +在修复组合来源绑定解析后,重新对原始 42 条待复核输出执行 AI 二次复核: + +- 接受 20,拒绝 22。 +- 事实一致 39,事实失败 3。 +- 引用可验证 41,引用失败 1。 +- 检出高风险遗漏 21。 +- 42 条决定均已写入不可变质量复核记录,当前该运行的待复核队列为 0。 + +典型拒绝原因包括:把个人信息安全事件通知错误归入《数据安全法》条款、劳动工资结算错误依赖民法典一般合同规则、股权交易解除条件忽略约定解除权、运输限责回答未绑定实际使用法源,以及格式条款、违约金调整、劳动特殊规则和证据材料存在关键遗漏。 + +### 代表性缺口修复回归 + +从旧输出拒绝项中选取 8 个代表性高风险样本进行迭代回归:保密、建设工程、数据处理、劳动、股权、采购、销售和软件。 + +- v2:8 条自动质量均为 100;AI 二次复核接受 6、拒绝 2。 +- v3:劳动样本补齐劳动合同必备条款、书面变更和不能胜任工作规则后接受;数据处理样本仍拒绝。 +- v4~v5:数据处理样本依次补齐合法性基础、告知同意、安全事件通知例外,但仍因分包商、持续监督及跨境材料遗漏而拒绝。 +- v6:数据处理样本实际绑定 8 条权威法源,覆盖转委托、出境路径、单独告知与同意、影响评估、安全事件和电子证据,自动质量 100,执行耗时 163,226 ms,独立 AI 二次复核事实、引用、遗漏三项全部通过并接受。 + +按每个代表性案例的最新版本计算,8 个案例均已有 AI 接受版本。v2~v6 共 13 条迭代审核均已落库,历史拒绝未被覆盖。 + +## 发布判断与下一阶段 + +当前判断为“继续受控开发,不开放真实用户注册”。进入受控真实用户试点前至少还需: + +1. 在最终 56 条法源清单和当前检索策略上重跑完整 100 样本,确认十类合同分层质量仍达标。 +2. 优化长任务执行时间,使真实 Provider 执行 P95 不高于 180 秒,并验证取消、重试、fallback 与 AI/运营接管闭环。 +3. 将本轮 22 条拒绝原因沉淀为回归样本和针对性提示词/检索规则,防止同类错误重新出现。 +4. 在正式法务政策材料、隐私政策和服务条款完成前,继续保持真实用户注册关闭及开发环境标识。 + +## 本地证据 + +- 100 样本报告:`.runtime/controlled-pilot-stratified-quality/stratified-v2-authoritative-20260826-20260826T154854Z.json` +- 修复解析后的 42 条 AI 复核:`.runtime/delegated-ai-legal-review/stratified-v2-authoritative-20260826-20260826T170314Z.json` +- 最终数据处理样本 AI 复核:`.runtime/delegated-ai-legal-review/targeted-v6-authoritative-coverage-20260827-20260826T165653Z.json` +- 权威法源清单:`yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_source_assets/cn_contract_law_v1.json` diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index 71801963..90b67107 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -1616,7 +1616,7 @@ components: properties: source_type: type: string - enum: [law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document] + enum: [law, official_law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document] title: type: string reference: @@ -1662,6 +1662,19 @@ components: type: array items: type: string + source_ids: + type: array + items: + type: string + issuing_authority: { type: string, nullable: true } + document_number: { type: string, nullable: true } + version_label: { type: string, nullable: true } + promulgated_on: { type: string, format: date, nullable: true } + effective_on: { type: string, format: date, nullable: true } + retrieved_on: { type: string, format: date, nullable: true } + source_status: { type: string, nullable: true } + content_sha256: { type: string, pattern: "^[0-9a-f]{64}$", nullable: true } + source_manifest_sha256: { type: string, pattern: "^[0-9a-f]{64}$", nullable: true } LegalQaRequest: type: object required: diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/knowledge.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/knowledge.py index 3c87b754..b2193e0b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/knowledge.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/knowledge.py @@ -178,7 +178,7 @@ def register_knowledge_routes( async def search_knowledge_documents( keyword: str = "", knowledge_base_id: str | None = None, - limit: int = Query(5, ge=1, le=20), + limit: int = Query(5, ge=1, le=1000), ) -> list[KnowledgeDocument]: return await run_in_threadpool( store.search_knowledge, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py index c3b61407..d3eca2f8 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py @@ -4,6 +4,12 @@ from uuid import uuid4 from fastapi import FastAPI from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.authoritative_legal_sources import ( + AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + authoritative_source_query_relevance, + authoritative_source_supports_contract_types, + infer_authoritative_contract_types, +) from yuqei_ai_platform_api.contract_review import ( ContractReviewRequest, ContractReviewResponse, @@ -11,7 +17,14 @@ from yuqei_ai_platform_api.contract_review import ( parse_provider_contract_review, review_contract, ) -from yuqei_ai_platform_api.legal_qa import LegalCitation, LegalQaRequest, LegalQaResponse, answer_legal_question +from yuqei_ai_platform_api.legal_qa import ( + LegalCitation, + LegalQaRequest, + LegalQaResponse, + answer_legal_question, + extract_stable_source_ids, + select_citations_bound_to_answer, +) from yuqei_ai_platform_api.prompts import ( contract_review_prompt_candidates, legal_qa_prompt_candidates, @@ -120,12 +133,13 @@ def register_legal_routes( # that answer. The rule-based fallback carries generic example citations; # copying those citations onto a real provider answer makes an ungrounded # answer look sourced and lets the model-job quality gate pass incorrectly. + bound_citations = select_citations_bound_to_answer(provider_response.answer, citations) response = fallback_response.model_copy( update={ "answer": provider_response.answer, - "citations": citations, - "primary_sources": citations[:3], - "confidence": 0.78 if citations else 0.35, + "citations": bound_citations, + "primary_sources": bound_citations[:3], + "confidence": 0.78 if bound_citations else 0.35, } ) audit_status = "succeeded" @@ -549,24 +563,159 @@ def _search_chunk_results( ) -> list[KnowledgeSearchResult]: if _is_generic_contract_question(request): return [] + query_text = f"{request.question}\n{request.matter_context or ''}".strip() + uses_authoritative_sources = AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID in (request.knowledge_base_ids or []) + candidate_limit = 12 if uses_authoritative_sources else 3 results = repository.search_knowledge_chunks( - request.question, + query_text, knowledge_base_ids=request.knowledge_base_ids, - limit=3, + limit=candidate_limit, strategy=request.retrieval_strategy, ) + if uses_authoritative_sources: + merged_results = {result.chunk.id: result for result in results} + for expansion_query in _authoritative_retrieval_expansions(query_text): + for result in repository.search_knowledge_chunks( + expansion_query, + knowledge_base_ids=request.knowledge_base_ids, + limit=4, + strategy=request.retrieval_strategy, + ): + existing = merged_results.get(result.chunk.id) + if existing is None or result.score > existing.score: + merged_results[result.chunk.id] = result + results = list(merged_results.values()) if not results: - question_text = f"{request.question}\n{request.matter_context or ''}" - for keyword in _fallback_keywords(question_text): + for keyword in _fallback_keywords(query_text): results = repository.search_knowledge_chunks( keyword, knowledge_base_ids=request.knowledge_base_ids, - limit=3, + limit=candidate_limit, strategy=request.retrieval_strategy, ) if results: break - return results + if not uses_authoritative_sources: + return results[:3] + contract_types = infer_authoritative_contract_types(query_text) + if contract_types: + domain_results = [ + result + for result in results + if result.chunk.knowledge_base_id != AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID + or authoritative_source_supports_contract_types( + extract_stable_source_ids(result.chunk.reference), + contract_types, + ) + ] + if domain_results: + results = domain_results + if set(request.knowledge_base_ids or []) == {AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID}: + results = sorted( + results, + key=lambda result: ( + authoritative_source_query_relevance( + extract_stable_source_ids(result.chunk.reference), + query_text, + ), + result.score, + ), + reverse=True, + ) + return results[: _authoritative_result_limit(query_text)] + + +def _authoritative_result_limit(query_text: str) -> int: + """Keep additional primary-law context for evidence-heavy data reviews. + + The normal six-source window is intentionally small. Data-processing clauses + that combine processor duties, evidence retention, incident response and + cross-border hosting need two extra slots so one topic does not silently evict + another equally material statutory duty. + """ + normalized = query_text.casefold() + is_data_review = any( + marker in normalized + for marker in ("个人信息", "数据处理", "数据安全", "隐私", "委托处理") + ) + is_evidence_heavy = any( + marker in normalized + for marker in ( + "证据", + "留存", + "材料", + "记录", + "分包", + "转委托", + "备份", + "跨境", + "出境", + "多地域", + ) + ) + return 8 if is_data_review and is_evidence_heavy else 6 + + +def _authoritative_retrieval_expansions(query_text: str) -> list[str]: + normalized = query_text.casefold() + expansions: list[str] = [] + if "解除" in normalized: + expansions.extend( + [ + "合同解除后 已经履行 结算 赔偿损失 违约责任", + "约定解除 解除事由 解除权行使期限 解除通知 通知到达 异议", + "随时解除 有偿委托 直接损失 可得利益", + "承揽合同 完成工作前 随时解除 赔偿损失", + ] + ) + if any(marker in normalized for marker in ("暂停", "中止", "停供", "停工", "履行能力", "担保")): + expansions.append("不安抗辩 确切证据 中止履行 及时通知 适当担保 恢复履行 解除合同") + if any(marker in normalized for marker in ("责任限制", "免责", "无效", "违约金", "实际损失")): + expansions.extend( + [ + "格式条款 提示说明 条款无效 不合理免责 加重责任 排除主要权利", + "免责条款 人身损害 故意 重大过失 财产损失 无效", + "违约金 低于损失 增加 过分高于损失 减少 司法调整", + ] + ) + if any(marker in normalized for marker in ("验收", "支付", "费用", "报酬", "已完成")): + expansions.append("承揽 工作成果 交付 技术资料 验收 支付报酬 部分交付 拒绝交付") + if any(marker in normalized for marker in ("软件", "源代码", "系统开发", "研发")): + expansions.append("委托开发 软件著作权 技术成果 源代码 权利归属") + if any(marker in normalized for marker in ("劳动", "员工", "用人单位", "工资", "竞业", "培训", "离职")): + expansions.extend( + [ + "劳动合同 规章制度 民主程序 职工代表大会 工会协商 公示告知", + "专项培训 服务期 培训费用 竞业限制 经济补偿 违约金 两年", + "劳动报酬 及时足额支付 单方解除 工会通知 解除证明 档案 社会保险 工作交接", + "劳动合同必备条款 工作内容 工作地点 协商一致 书面变更 调岗 不能胜任 培训后仍不能胜任", + ] + ) + if any(marker in normalized for marker in ("个人信息", "数据处理", "数据安全", "泄露", "安全事件")): + expansions.extend( + [ + "个人信息泄露 篡改 丢失 立即补救 监管通知 个人通知 通知个人例外 有效避免危害 监管要求通知 安全事件", + "个人信息保护影响评估 处理记录 委托处理 向境外提供 三年保存", + "个人信息处理 合法性基础 个人同意 合同必要 告知文本 处理规则 保存期限 同意记录 规则版本", + ] + ) + if any(marker in normalized for marker in ("跨境", "出境", "境外", "多地域", "境外备份", "全球备份")): + expansions.append( + "个人信息出境 安全评估 个人信息保护认证 标准合同 境外接收方 单独告知 单独同意 境内存储" + ) + if any(marker in normalized for marker in ("分包", "转委托", "受托人", "处理商", "供应商")) and any( + marker in normalized for marker in ("个人信息", "数据处理", "数据安全", "隐私") + ): + expansions.append( + "委托处理 受托人 处理目的 期限 方式 信息种类 保护措施 监督 删除返还 转委托同意" + ) + if any(marker in normalized for marker in ("证据", "留存", "材料", "记录", "电子")): + expansions.append("数据电文 原件 内容完整 文件保存 原始格式 发件人 收件人 时间 证据真实性 可靠性") + if "分期" in normalized and any(marker in normalized for marker in ("付款", "价款", "销售", "买卖")): + expansions.append("分期付款 到期价款 五分之一 催告 全部价款 解除合同") + if "股权" in normalized: + expansions.append("公司法人 独立法人财产 公司债务 全部财产 股权交易 历史负债") + return list(dict.fromkeys(expansions)) def _build_rag_explain_snapshot( diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/authoritative_legal_sources.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/authoritative_legal_sources.py new file mode 100644 index 00000000..d1ed1bf3 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/authoritative_legal_sources.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import hashlib +import json +from functools import lru_cache +from pathlib import Path +from typing import Literal +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, model_validator + + +AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1" +REQUIRED_CONTRACT_TYPES = frozenset( + { + "purchase", + "sales", + "employment", + "lease", + "confidentiality", + "software", + "construction", + "data_processing", + "equity", + "transport", + } +) +CONTRACT_TYPE_KEYWORDS: dict[str, tuple[str, ...]] = { + "purchase": ("采购", "采购合同", "供应商", "订单"), + "sales": ("销售", "买卖", "经销", "客户购买"), + "employment": ("劳动", "员工", "用人单位", "工资", "试用期", "竞业限制"), + "lease": ("租赁", "出租", "承租", "租金", "押金"), + "confidentiality": ("保密", "商业秘密", "泄密", "nda"), + "software": ("软件", "源代码", "系统开发", "技术开发", "saas", "研发"), + "construction": ("建设工程", "施工", "工程款", "竣工"), + "data_processing": ("个人信息", "数据处理", "数据安全", "隐私", "委托处理"), + "equity": ("股权", "股东", "公司章程", "增资", "投资协议"), + "transport": ("运输", "承运", "托运", "货运", "物流"), +} +LEGAL_TOPIC_TERMS = ( + "解除", + "结算", + "支付", + "报酬", + "费用", + "赔偿", + "损失", + "责任限制", + "免责", + "无效", + "格式条款", + "违约金", + "调整", + "中止", + "担保", + "通知到达", + "验收", + "交付", + "成果", + "履行", + "违约", + "软件", + "开发", + "源代码", + "著作权", + "技术资料", + "保密", + "数据", + "个人信息", + "泄露", + "证据", + "真实性", + "完整性", + "工资", + "培训", + "竞业限制", + "工会", + "规章制度", + "工作地点", + "调岗", + "不能胜任", + "影响评估", + "处理记录", + "合法性基础", + "同意记录", + "告知文本", + "跨境", + "出境", + "境外接收方", + "安全评估", + "保护认证", + "标准合同", + "境内存储", + "股权", + "公司债务", + "运输", +) +MANIFEST_PATH = Path(__file__).with_name("legal_source_assets") / "cn_contract_law_v1.json" + + +class AuthoritativeLegalSource(BaseModel): + source_id: str = Field(pattern=r"^LAW-CN-[A-Z0-9-]+$") + title: str = Field(min_length=1) + article_reference: str = Field(min_length=1) + issuing_authority: str = Field(min_length=1) + document_number: str | None = None + version_label: str = Field(min_length=1) + promulgated_on: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") + effective_on: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") + source_status: Literal["effective"] = "effective" + official_url: str + contract_types: list[str] = Field(min_length=1) + topics: list[str] = Field(min_length=1) + content: str = Field(min_length=1) + + @model_validator(mode="after") + def validate_official_source(self) -> "AuthoritativeLegalSource": + parsed = urlparse(self.official_url) + hostname = (parsed.hostname or "").casefold() + if parsed.scheme != "https" or not (hostname == "gov.cn" or hostname.endswith(".gov.cn")): + raise ValueError("official_url must use HTTPS on a Chinese government domain") + unknown_types = set(self.contract_types) - REQUIRED_CONTRACT_TYPES + if unknown_types: + raise ValueError(f"unsupported contract types: {sorted(unknown_types)}") + return self + + @property + def content_sha256(self) -> str: + return hashlib.sha256(self.content.encode("utf-8")).hexdigest() + + @property + def reference(self) -> str: + return f"{self.source_id} {self.article_reference}" + + +class AuthoritativeLegalSourceManifest(BaseModel): + schema_version: Literal["cn-authoritative-legal-source-v1"] + knowledge_base_id: Literal[AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID] + retrieved_on: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") + sources: list[AuthoritativeLegalSource] = Field(min_length=1) + + @model_validator(mode="after") + def validate_manifest(self) -> "AuthoritativeLegalSourceManifest": + source_ids = [item.source_id for item in self.sources] + if len(source_ids) != len(set(source_ids)): + raise ValueError("authoritative legal source IDs must be unique") + covered = {contract_type for item in self.sources for contract_type in item.contract_types} + missing = REQUIRED_CONTRACT_TYPES - covered + if missing: + raise ValueError(f"authoritative legal manifest misses contract types: {sorted(missing)}") + return self + + @property + def manifest_sha256(self) -> str: + canonical = json.dumps( + self.model_dump(mode="json"), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +@lru_cache(maxsize=1) +def load_authoritative_legal_source_manifest() -> AuthoritativeLegalSourceManifest: + payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + return AuthoritativeLegalSourceManifest.model_validate(payload) + + +def authoritative_source_metadata(source_ids: list[str]) -> dict[str, str] | None: + requested = set(source_ids) + if not requested: + return None + manifest = load_authoritative_legal_source_manifest() + source = next((item for item in manifest.sources if item.source_id in requested), None) + if source is None: + return None + return { + "url": source.official_url, + "issuing_authority": source.issuing_authority, + "document_number": source.document_number or "", + "version_label": source.version_label, + "promulgated_on": source.promulgated_on, + "effective_on": source.effective_on, + "retrieved_on": manifest.retrieved_on, + "source_status": source.source_status, + "content_sha256": source.content_sha256, + "source_manifest_sha256": manifest.manifest_sha256, + } + + +def infer_authoritative_contract_types(text: str) -> set[str]: + normalized = text.casefold() + return { + contract_type + for contract_type, keywords in CONTRACT_TYPE_KEYWORDS.items() + if any(keyword.casefold() in normalized for keyword in keywords) + } + + +def authoritative_source_supports_contract_types(source_ids: list[str], contract_types: set[str]) -> bool: + if not contract_types: + return True + requested = set(source_ids) + source = next( + (item for item in load_authoritative_legal_source_manifest().sources if item.source_id in requested), + None, + ) + return source is not None and bool(set(source.contract_types).intersection(contract_types)) + + +def authoritative_source_query_relevance(source_ids: list[str], query_text: str) -> int: + requested = set(source_ids) + source = next( + (item for item in load_authoritative_legal_source_manifest().sources if item.source_id in requested), + None, + ) + if source is None: + return 0 + normalized_query = query_text.casefold() + source_text = " ".join([source.article_reference, *source.topics, source.content]).casefold() + return sum( + 1 + for term in LEGAL_TOPIC_TERMS + if term.casefold() in normalized_query and term.casefold() in source_text + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py index ae14315d..d8265e81 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field STABLE_SOURCE_ID_PATTERN = re.compile(r"\bLAW-[A-Z0-9]+(?:-[A-Z0-9]+)*\b") +SOURCE_BINDING_PATTERN = re.compile(r"\[来源\s*[::]\s*([^\]]+)\]", flags=re.IGNORECASE) class LegalQaRequest(BaseModel): @@ -30,6 +31,15 @@ class LegalCitation(BaseModel): retrieval_strategy: str | None = None matched_terms: list[str] = Field(default_factory=list) source_ids: list[str] = Field(default_factory=list) + issuing_authority: str | None = None + document_number: str | None = None + version_label: str | None = None + promulgated_on: str | None = None + effective_on: str | None = None + retrieved_on: str | None = None + source_status: str | None = None + content_sha256: str | None = None + source_manifest_sha256: str | None = None class LegalQaResponse(BaseModel): @@ -47,6 +57,43 @@ def extract_stable_source_ids(reference: str | None) -> list[str]: return list(dict.fromkeys(STABLE_SOURCE_ID_PATTERN.findall(reference.upper()))) +def extract_bound_source_ids(answer: str | None) -> list[str]: + """Return stable source IDs contained inside explicit ``[来源: ...]`` blocks.""" + if not answer: + return [] + return list( + dict.fromkeys( + source_id + for binding in SOURCE_BINDING_PATTERN.findall(answer) + for source_id in extract_stable_source_ids(binding) + ) + ) + + +def select_citations_bound_to_answer( + answer: str, + citations: list[LegalCitation], +) -> list[LegalCitation]: + """Return only authoritative sources explicitly named by the provider answer. + + Legacy or tenant-authored knowledge remains backward compatible. Once an answer + uses the authoritative law base, however, returning every retrieved result would + recreate the false appearance that the model relied on sources it never cited. + """ + authoritative = [item for item in citations if item.knowledge_base_id == "laws-cn-authoritative-v1"] + if not authoritative: + return citations + used_source_ids = set(extract_bound_source_ids(answer)) + if not used_source_ids: + return [] + return [ + item + for item in citations + if item.knowledge_base_id == "laws-cn-authoritative-v1" + and used_source_ids.intersection(item.source_ids) + ] + + def answer_legal_question( request: LegalQaRequest, *, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_source_assets/cn_contract_law_v1.json b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_source_assets/cn_contract_law_v1.json new file mode 100644 index 00000000..db40f26b --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_source_assets/cn_contract_law_v1.json @@ -0,0 +1,847 @@ +{ + "schema_version": "cn-authoritative-legal-source-v1", + "knowledge_base_id": "laws-cn-authoritative-v1", + "retrieved_on": "2026-08-27", + "sources": [ + { + "source_id": "LAW-CN-CIVIL-0466", + "title": "中华人民共和国民法典", + "article_reference": "第四百六十六条第一款", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["合同解释", "文件优先顺序", "交易背景", "履行行为"], + "content": "当事人对合同条款的理解有争议的,应当依据本法第一百四十二条第一款的规定,确定争议条款的含义。" + }, + { + "source_id": "LAW-CN-CIVIL-0496", + "title": "中华人民共和国民法典", + "article_reference": "第四百九十六条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["格式条款", "提示说明", "责任限制", "免责条款"], + "content": "格式条款是当事人为了重复使用而预先拟定,并在订立合同时未与对方协商的条款。采用格式条款订立合同的,提供格式条款的一方应当遵循公平原则确定当事人之间的权利和义务,并采取合理的方式提示对方注意免除或者减轻其责任等与对方有重大利害关系的条款,按照对方的要求,对该条款予以说明。提供格式条款的一方未履行提示或者说明义务,致使对方没有注意或者理解与其有重大利害关系的条款的,对方可以主张该条款不成为合同的内容。" + }, + { + "source_id": "LAW-CN-CIVIL-0509", + "title": "中华人民共和国民法典", + "article_reference": "第五百零九条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["全面履行", "诚信原则", "通知", "协助", "保密"], + "content": "当事人应当按照约定全面履行自己的义务。当事人应当遵循诚信原则,根据合同的性质、目的和交易习惯履行通知、协助、保密等义务。当事人在履行合同过程中,应当避免浪费资源、污染环境和破坏生态。" + }, + { + "source_id": "LAW-CN-CIVIL-0510-0511", + "title": "中华人民共和国民法典", + "article_reference": "第五百一十条、第五百一十一条(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "lease", "software", "construction", "equity", "transport"], + "topics": ["约定不明", "补充协议", "质量", "价款", "履行地点", "履行期限"], + "content": "合同生效后,当事人就质量、价款或者报酬、履行地点等内容没有约定或者约定不明确的,可以协议补充;不能达成补充协议的,按照合同相关条款或者交易习惯确定。依照前条规定仍不能确定的,适用第五百一十一条规定的相应履行规则。" + }, + { + "source_id": "LAW-CN-CIVIL-0563", + "title": "中华人民共和国民法典", + "article_reference": "第五百六十三条第一款(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["合同解除", "根本违约", "迟延履行", "不可抗力"], + "content": "有下列情形之一的,当事人可以解除合同:因不可抗力致使不能实现合同目的;在履行期限届满前,当事人一方明确表示或者以自己的行为表明不履行主要债务;当事人一方迟延履行主要债务,经催告后在合理期限内仍未履行;当事人一方迟延履行债务或者有其他违约行为致使不能实现合同目的;法律规定的其他情形。" + }, + { + "source_id": "LAW-CN-CIVIL-0567", + "title": "中华人民共和国民法典", + "article_reference": "第五百六十七条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://gongbao.court.gov.cn/Details/dfe439fb9450f0525bd7e7b50a6242.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["合同终止", "结算", "清理", "持续义务"], + "content": "合同的权利义务关系终止,不影响合同中结算和清理条款的效力。" + }, + { + "source_id": "LAW-CN-CIVIL-0566", + "title": "中华人民共和国民法典", + "article_reference": "第五百六十六条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["合同解除后果", "终止履行", "恢复原状", "补救措施", "赔偿损失", "违约责任"], + "content": "合同解除后,尚未履行的,终止履行;已经履行的,根据履行情况和合同性质,当事人可以请求恢复原状或者采取其他补救措施,并有权请求赔偿损失。合同因违约解除的,解除权人可以请求违约方承担违约责任,但是当事人另有约定的除外。主合同解除后,担保人对债务人应当承担的民事责任仍应当承担担保责任,但是担保合同另有约定的除外。" + }, + { + "source_id": "LAW-CN-CIVIL-0780-0783", + "title": "中华人民共和国民法典", + "article_reference": "第七百八十条至第七百八十三条(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["software", "construction"], + "topics": ["承揽合同", "成果交付", "技术资料", "验收", "支付报酬", "部分交付", "拒绝交付"], + "content": "承揽人完成工作的,应当向定作人交付工作成果,并提交必要的技术资料和有关质量证明。定作人应当验收该工作成果。承揽人交付的工作成果不符合质量要求的,定作人可以合理选择请求承揽人承担修理、重作、减少报酬、赔偿损失等违约责任。定作人应当按照约定的期限支付报酬;工作成果部分交付的,定作人应当相应支付。定作人未向承揽人支付报酬或者材料费等价款的,承揽人对完成的工作成果享有留置权或者有权拒绝交付,但是当事人另有约定的除外。" + }, + { + "source_id": "LAW-CN-CIVIL-0787", + "title": "中华人民共和国民法典", + "article_reference": "第七百八十七条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["software", "construction"], + "topics": ["承揽合同", "随时解除", "完成工作前解除", "损失赔偿", "便利解除"], + "content": "定作人在承揽人完成工作前可以随时解除合同,造成承揽人损失的,应当赔偿损失。" + }, + { + "source_id": "LAW-CN-CIVIL-0933", + "title": "中华人民共和国民法典", + "article_reference": "第九百三十三条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["software", "confidentiality", "data_processing"], + "topics": ["委托合同", "随时解除", "有偿委托", "直接损失", "可得利益", "解除赔偿"], + "content": "委托人或者受托人可以随时解除委托合同。因解除合同造成对方损失的,除不可归责于该当事人的事由外,无偿委托合同的解除方应当赔偿因解除时间不当造成的直接损失,有偿委托合同的解除方应当赔偿对方的直接损失和合同履行后可以获得的利益。" + }, + { + "source_id": "LAW-CN-CIVIL-0577", + "title": "中华人民共和国民法典", + "article_reference": "第五百七十七条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["违约责任", "继续履行", "补救措施", "赔偿损失"], + "content": "当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。" + }, + { + "source_id": "LAW-CN-CIVIL-0584-0585", + "title": "中华人民共和国民法典", + "article_reference": "第五百八十四条、第五百八十五条(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["损失赔偿", "可预见损失", "违约金", "责任限制"], + "content": "当事人一方不履行合同义务或者履行合同义务不符合约定,造成对方损失的,损失赔偿额应当相当于因违约所造成的损失,包括合同履行后可以获得的利益;但是,不得超过违约一方订立合同时预见到或者应当预见到的因违约可能造成的损失。当事人可以约定一方违约时应当根据违约情况向对方支付一定数额的违约金,也可以约定因违约产生的损失赔偿额的计算方法。" + }, + { + "source_id": "LAW-CN-CIVIL-0620-0621", + "title": "中华人民共和国民法典", + "article_reference": "第六百二十条、第六百二十一条(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["purchase", "sales"], + "topics": ["买卖合同", "检验", "验收", "异议期限", "质量通知"], + "content": "买受人收到标的物时应当在约定的检验期限内检验。没有约定检验期限的,应当及时检验。当事人约定检验期限的,买受人应当在检验期限内将标的物的数量或者质量不符合约定的情形通知出卖人。买受人怠于通知的,视为标的物的数量或者质量符合约定。" + }, + { + "source_id": "LAW-CN-CIVIL-0703-0707", + "title": "中华人民共和国民法典", + "article_reference": "第七百零三条、第七百零四条、第七百零七条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["lease"], + "topics": ["租赁合同", "租赁物", "租金", "维修", "书面形式"], + "content": "租赁合同是出租人将租赁物交付承租人使用、收益,承租人支付租金的合同。租赁合同的内容一般包括租赁物的名称、数量、用途、租赁期限、租金及其支付期限和方式、租赁物维修等条款。租赁期限六个月以上的,应当采用书面形式。" + }, + { + "source_id": "LAW-CN-CIVIL-0733", + "title": "中华人民共和国民法典", + "article_reference": "第七百三十三条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["lease"], + "topics": ["租赁终止", "返还租赁物", "交接", "正常损耗"], + "content": "租赁期限届满,承租人应当返还租赁物。返还的租赁物应当符合按照约定或者根据租赁物的性质使用后的状态。" + }, + { + "source_id": "LAW-CN-CIVIL-0791", + "title": "中华人民共和国民法典", + "article_reference": "第七百九十一条(节选)", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["construction"], + "topics": ["建设工程", "分包", "转包", "主体结构", "连带责任"], + "content": "承包人不得将其承包的全部建设工程转包给第三人或者将其承包的全部建设工程支解以后以分包的名义分别转包给第三人。禁止承包人将工程分包给不具备相应资质条件的单位。禁止分包单位将其承包的工程再分包。建设工程主体结构的施工必须由承包人自行完成。" + }, + { + "source_id": "LAW-CN-CIVIL-0799", + "title": "中华人民共和国民法典", + "article_reference": "第七百九十九条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["construction"], + "topics": ["建设工程", "竣工验收", "质量标准", "技术资料", "保修"], + "content": "建设工程竣工后,发包人应当根据施工图纸及说明书、国家颁发的施工验收规范和质量检验标准及时进行验收。验收合格的,发包人应当按照约定支付价款,并接收该建设工程。建设工程竣工经验收合格后,方可交付使用;未经验收或者验收不合格的,不得交付使用。" + }, + { + "source_id": "LAW-CN-CIVIL-0809-0811", + "title": "中华人民共和国民法典", + "article_reference": "第八百零九条至第八百一十一条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["transport"], + "topics": ["运输合同", "承运人", "运输期限", "安全运输", "运输路线"], + "content": "运输合同是承运人将旅客或者货物从起运地点运输到约定地点,旅客、托运人或者收货人支付票款或者运输费用的合同。从事公共运输的承运人不得拒绝旅客、托运人通常、合理的运输要求。承运人应当在约定期限或者合理期限内将旅客、货物安全运输到约定地点。" + }, + { + "source_id": "LAW-CN-CIVIL-0832", + "title": "中华人民共和国民法典", + "article_reference": "第八百三十二条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["transport"], + "topics": ["货物运输", "毁损灭失", "承运人赔偿", "免责举证"], + "content": "承运人对运输过程中货物的毁损、灭失承担赔偿责任。但是,承运人证明货物的毁损、灭失是因不可抗力、货物本身的自然性质或者合理损耗以及托运人、收货人的过错造成的,不承担赔偿责任。" + }, + { + "source_id": "LAW-CN-CIVIL-0859-0861", + "title": "中华人民共和国民法典", + "article_reference": "第八百五十九条、第八百六十一条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["software", "confidentiality"], + "topics": ["委托开发", "技术成果", "专利申请权", "技术秘密", "使用权", "转让权"], + "content": "委托开发完成的发明创造,除法律另有规定或者当事人另有约定外,申请专利的权利属于研究开发人。委托开发或者合作开发完成的技术秘密成果的使用权、转让权以及收益的分配办法,由当事人约定;没有约定或者约定不明确,依据本法第五百一十条的规定仍不能确定的,在没有相同技术方案被授予专利权前,当事人均有使用和转让的权利。" + }, + { + "source_id": "LAW-CN-CIVIL-0871", + "title": "中华人民共和国民法典", + "article_reference": "第八百七十一条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2020-06/01/c_15925617772683192.htm", + "contract_types": ["software", "confidentiality"], + "topics": ["技术秘密", "技术许可", "保密义务", "约定范围", "保密期限"], + "content": "技术转让合同的受让人和技术许可合同的被许可人应当按照约定的范围和期限,对让与人、许可人提供的技术中尚未公开的秘密部分,承担保密义务。" + }, + { + "source_id": "LAW-CN-LABOR-0019", + "title": "中华人民共和国劳动合同法", + "article_reference": "第十九条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修正)", + "version_label": "2012年修正现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["劳动合同", "试用期", "合同期限", "工资"], + "content": "劳动合同期限三个月以上不满一年的,试用期不得超过一个月;劳动合同期限一年以上不满三年的,试用期不得超过二个月;三年以上固定期限和无固定期限的劳动合同,试用期不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。" + }, + { + "source_id": "LAW-CN-LABOR-0023", + "title": "中华人民共和国劳动合同法", + "article_reference": "第二十三条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修正)", + "version_label": "2012年修正现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment", "confidentiality"], + "topics": ["保密义务", "竞业限制", "经济补偿", "违约金"], + "content": "用人单位与劳动者可以在劳动合同中约定保守用人单位的商业秘密和与知识产权相关的保密事项。对负有保密义务的劳动者,用人单位可以在劳动合同或者保密协议中与劳动者约定竞业限制条款,并约定在解除或者终止劳动合同后,在竞业限制期限内按月给予劳动者经济补偿。" + }, + { + "source_id": "LAW-CN-LABOR-0039", + "title": "中华人民共和国劳动合同法", + "article_reference": "第三十九条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修正)", + "version_label": "2012年修正现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["解除劳动合同", "录用条件", "规章制度", "严重失职", "证据"], + "content": "劳动者有下列情形之一的,用人单位可以解除劳动合同:在试用期间被证明不符合录用条件的;严重违反用人单位的规章制度的;严重失职,营私舞弊,给用人单位造成重大损害的;被依法追究刑事责任的。" + }, + { + "source_id": "LAW-CN-PIPL-0006", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第六条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software", "employment"], + "topics": ["个人信息", "目的限制", "最小必要", "过度收集"], + "content": "处理个人信息应当具有明确、合理的目的,并应当与处理目的直接相关,采取对个人权益影响最小的方式。收集个人信息,应当限于实现处理目的的最小范围,不得过度收集个人信息。" + }, + { + "source_id": "LAW-CN-PIPL-0021", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第二十一条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["委托处理", "受托人", "处理目的", "处理期限", "处理方式", "转委托", "删除返还", "监督"], + "content": "个人信息处理者委托处理个人信息的,应当与受托人约定委托处理的目的、期限、处理方式、个人信息的种类、保护措施以及双方的权利和义务等,并对受托人的个人信息处理活动进行监督。受托人应当按照约定处理个人信息,不得超出约定的处理目的、处理方式等处理个人信息;委托合同不生效、无效、被撤销或者终止的,受托人应当将个人信息返还个人信息处理者或者予以删除,不得保留。未经个人信息处理者同意,受托人不得转委托他人处理个人信息。" + }, + { + "source_id": "LAW-CN-PIPL-0047", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第四十七条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software", "employment"], + "topics": ["个人信息删除", "保存期限", "停止处理", "合同终止", "证据保全"], + "content": "有下列情形之一的,个人信息处理者应当主动删除个人信息;个人信息处理者未删除的,个人有权请求删除:处理目的已实现、无法实现或者为实现处理目的不再必要;个人信息处理者停止提供产品或者服务,或者保存期限已届满;个人撤回同意;个人信息处理者违反法律、行政法规或者违反约定处理个人信息;法律、行政法规规定的其他情形。法律、行政法规规定的保存期限未届满,或者删除个人信息从技术上难以实现的,个人信息处理者应当停止除存储和采取必要的安全保护措施之外的处理。" + }, + { + "source_id": "LAW-CN-PIPL-0051", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第五十一条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["个人信息安全", "内部制度", "分类管理", "加密", "去标识化", "权限管理", "应急预案"], + "content": "个人信息处理者应当根据个人信息的处理目的、处理方式、个人信息的种类以及对个人权益的影响、可能存在的安全风险等,采取措施确保个人信息处理活动符合法律、行政法规的规定,并防止未经授权的访问以及个人信息泄露、篡改、丢失。措施包括制定内部管理制度和操作规程、分类管理、加密和去标识化、合理确定操作权限、制定并组织实施个人信息安全事件应急预案等。" + }, + { + "source_id": "LAW-CN-DSL-0027-0029", + "title": "中华人民共和国数据安全法", + "article_reference": "第二十七条、第二十九条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第八十四号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-06-10", + "effective_on": "2021-09-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202106/t20210610_311888.html", + "contract_types": ["data_processing", "software"], + "topics": ["数据安全", "全流程管理", "技术措施", "风险监测", "安全事件", "通知报告"], + "content": "开展数据处理活动应当依照法律、法规的规定,建立健全全流程数据安全管理制度,组织开展数据安全教育培训,采取相应的技术措施和其他必要措施,保障数据安全。开展数据处理活动应当加强风险监测,发现数据安全缺陷、漏洞等风险时,应当立即采取补救措施;发生数据安全事件时,应当立即采取处置措施,按照规定及时告知用户并向有关主管部门报告。" + }, + { + "source_id": "LAW-CN-COMPANY-0084-0088", + "title": "中华人民共和国公司法", + "article_reference": "第八十四条、第八十八条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第十五号", + "version_label": "2023年修订现行文本", + "promulgated_on": "2023-12-29", + "effective_on": "2024-07-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/fgs/art/2023/art_067c072db6ef4679a2e0180996be4cf8.html", + "contract_types": ["equity"], + "topics": ["股权转让", "优先购买权", "书面通知", "出资义务", "补充责任", "连带责任"], + "content": "有限责任公司的股东之间可以相互转让其全部或者部分股权。股东向股东以外的人转让股权的,应当将股权转让的数量、价格、支付方式和期限等事项书面通知其他股东,其他股东在同等条件下有优先购买权。股东转让已认缴出资但未届出资期限的股权的,由受让人承担缴纳该出资的义务;受让人未按期足额缴纳出资的,转让人对受让人未按期缴纳的出资承担补充责任。" + }, + { + "source_id": "LAW-CN-AUCL-0010", + "title": "中华人民共和国反不正当竞争法", + "article_reference": "第十条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第五十号", + "version_label": "2025年修订现行文本", + "promulgated_on": "2025-06-27", + "effective_on": "2025-10-15", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202506/t20250627_446247.html", + "contract_types": ["confidentiality", "software", "employment"], + "topics": ["商业秘密", "保密措施", "披露", "使用", "电子侵入", "第三人"], + "content": "经营者不得以盗窃、贿赂、欺诈、胁迫、电子侵入或者其他不正当手段获取权利人的商业秘密;不得披露、使用或者允许他人使用以前项手段获取的商业秘密;不得违反保密义务或者违反权利人有关保守商业秘密的要求,披露、使用或者允许他人使用其所掌握的商业秘密。本法所称的商业秘密,是指不为公众所知悉、具有商业价值并经权利人采取相应保密措施的技术信息、经营信息等商业信息。" + }, + { + "source_id": "LAW-CN-SOFTWARE-0011", + "title": "计算机软件保护条例", + "article_reference": "第十一条", + "issuing_authority": "国务院", + "document_number": "国务院令第632号(2013年修订)", + "version_label": "2013年修订现行文本", + "promulgated_on": "2013-01-30", + "effective_on": "2013-03-01", + "source_status": "effective", + "official_url": "https://www.cac.gov.cn/2013-02/08/c_12648744.htm", + "contract_types": ["software"], + "topics": ["委托开发软件", "软件著作权", "书面合同", "权利归属"], + "content": "接受他人委托开发的软件,其著作权的归属由委托人与受托人签订书面合同约定;无书面合同或者合同未作明确约定的,其著作权由受托人享有。" + }, + { + "source_id": "LAW-CN-ESIGN-0004", + "title": "中华人民共和国电子签名法", + "article_reference": "第四条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "2019年4月23日修正决定", + "version_label": "2019年修正现行文本", + "promulgated_on": "2019-04-23", + "effective_on": "2019-04-23", + "source_status": "effective", + "official_url": "https://www.miit.gov.cn/jgsj/zfs/fl/art/2022/art_e3f623f70c23497e88a941170093446a.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["数据电文", "书面形式", "电子合同", "证据留存"], + "content": "能够有形地表现所载内容,并可以随时调取查用的数据电文,视为符合法律、法规要求的书面形式。" + }, + { + "source_id": "LAW-CN-ESIGN-0013-0014", + "title": "中华人民共和国电子签名法", + "article_reference": "第十三条、第十四条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "2019年4月23日修正决定", + "version_label": "2019年修正现行文本", + "promulgated_on": "2019-04-23", + "effective_on": "2019-04-23", + "source_status": "effective", + "official_url": "https://www.miit.gov.cn/jgsj/zfs/fl/art/2022/art_e3f623f70c23497e88a941170093446a.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["可靠电子签名", "身份识别", "签署控制", "防篡改", "法律效力"], + "content": "电子签名同时符合下列条件的,视为可靠的电子签名:电子签名制作数据用于电子签名时,属于电子签名人专有;签署时电子签名制作数据仅由电子签名人控制;签署后对电子签名的任何改动能够被发现;签署后对数据电文内容和形式的任何改动能够被发现。可靠的电子签名与手写签名或者盖章具有同等的法律效力。" + }, + { + "source_id": "LAW-CN-CONTRACT-JI-0001", + "title": "最高人民法院关于适用《中华人民共和国民法典》合同编通则若干问题的解释", + "article_reference": "第一条", + "issuing_authority": "最高人民法院", + "document_number": "法释〔2023〕13号", + "version_label": "2023年公布现行文本", + "promulgated_on": "2023-12-04", + "effective_on": "2023-12-05", + "source_status": "effective", + "official_url": "https://gongbao.court.gov.cn/Details/f4722cf61c92a585f04b2ecd334f5b.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["合同解释", "词句含义", "合同目的", "交易习惯", "磋商过程", "履行行为"], + "content": "人民法院依据民法典第一百四十二条第一款、第四百六十六条第一款的规定解释合同条款时,应当以词句的通常含义为基础,结合相关条款、合同的性质和目的、习惯以及诚信原则,参考缔约背景、磋商过程、履行行为等因素确定争议条款的含义。" + }, + { + "source_id": "LAW-CN-CIVIL-0497", + "title": "中华人民共和国民法典", + "article_reference": "第四百九十七条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["格式条款", "条款无效", "不合理免责", "加重责任", "限制主要权利", "排除主要权利"], + "content": "有下列情形之一的,该格式条款无效:具有本法第一编第六章第三节和本法第五百零六条规定的无效情形;提供格式条款一方不合理地免除或者减轻其责任、加重对方责任、限制对方主要权利;提供格式条款一方排除对方主要权利。" + }, + { + "source_id": "LAW-CN-CIVIL-0506", + "title": "中华人民共和国民法典", + "article_reference": "第五百零六条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["免责条款", "责任限制", "条款无效", "人身损害", "故意", "重大过失", "财产损失"], + "content": "合同中的下列免责条款无效:造成对方人身损害的;因故意或者重大过失造成对方财产损失的。" + }, + { + "source_id": "LAW-CN-CIVIL-0527-0528", + "title": "中华人民共和国民法典", + "article_reference": "第五百二十七条、第五百二十八条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "lease", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["不安抗辩", "中止履行", "确切证据", "通知", "担保", "恢复履行", "解除合同"], + "content": "应当先履行债务的当事人,有确切证据证明对方经营状况严重恶化、转移财产或抽逃资金以逃避债务、丧失商业信誉,或者有丧失或者可能丧失履行债务能力的其他情形,可以中止履行;没有确切证据中止履行的,应当承担违约责任。依法中止履行的,应当及时通知对方。对方提供适当担保的,应当恢复履行。中止履行后,对方在合理期限内未恢复履行能力且未提供适当担保的,中止履行的一方可以解除合同并请求对方承担违约责任。" + }, + { + "source_id": "LAW-CN-CIVIL-0562", + "title": "中华人民共和国民法典", + "article_reference": "第五百六十二条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["协商解除", "约定解除", "解除事由", "解除权"], + "content": "当事人协商一致,可以解除合同。当事人可以约定一方解除合同的事由。解除合同的事由发生时,解除权人可以解除合同。" + }, + { + "source_id": "LAW-CN-CIVIL-0564-0565", + "title": "中华人民共和国民法典", + "article_reference": "第五百六十四条、第五百六十五条", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["解除权行使期限", "解除通知", "通知到达", "解除生效", "异议", "诉讼仲裁"], + "content": "法律规定或者当事人约定解除权行使期限,期限届满当事人不行使的,该权利消灭;法律没有规定或者当事人没有约定的,自解除权人知道或者应当知道解除事由之日起一年内不行使,或者经对方催告后在合理期限内不行使的,该权利消灭。当事人一方依法主张解除合同的,应当通知对方,合同自通知到达对方时解除;对方有异议的,任何一方均可以请求人民法院或者仲裁机构确认解除行为的效力。" + }, + { + "source_id": "LAW-CN-CIVIL-0585-P2", + "title": "中华人民共和国民法典", + "article_reference": "第五百八十五条第二款、第三款", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["违约金", "损失", "增加违约金", "减少违约金", "司法调整", "迟延履行"], + "content": "约定的违约金低于造成的损失的,人民法院或者仲裁机构可以根据当事人的请求予以增加;约定的违约金过分高于造成的损失的,人民法院或者仲裁机构可以根据当事人的请求予以适当减少。当事人就迟延履行约定违约金的,违约方支付违约金后,还应当履行债务。" + }, + { + "source_id": "LAW-CN-CIVIL-0634", + "title": "中华人民共和国民法典", + "article_reference": "第六百三十四条第一款", + "issuing_authority": "全国人民代表大会", + "document_number": "中华人民共和国主席令第四十五号", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/bgt/art/2023/art_60fb3863b7364b759123a42d1df568ca.html", + "contract_types": ["sales"], + "topics": ["分期付款", "到期价款", "五分之一", "催告", "全部价款", "解除合同"], + "content": "分期付款的买受人未支付到期价款的数额达到全部价款的五分之一,经催告后在合理期限内仍未支付到期价款的,出卖人可以请求买受人支付全部价款或者解除合同。" + }, + { + "source_id": "LAW-CN-LABOR-0004", + "title": "中华人民共和国劳动合同法", + "article_reference": "第四条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["规章制度", "民主程序", "职工代表大会", "工会协商", "公示", "告知", "劳动纪律", "绩效管理"], + "content": "用人单位在制定、修改或者决定有关劳动报酬、工作时间、休息休假、劳动安全卫生、保险福利、职工培训、劳动纪律以及劳动定额管理等直接涉及劳动者切身利益的规章制度或者重大事项时,应当经职工代表大会或者全体职工讨论,提出方案和意见,与工会或者职工代表平等协商确定。用人单位应当将直接涉及劳动者切身利益的规章制度和重大事项决定公示,或者告知劳动者。" + }, + { + "source_id": "LAW-CN-LABOR-0022-0025", + "title": "中华人民共和国劳动合同法", + "article_reference": "第二十二条至第二十五条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment", "confidentiality"], + "topics": ["专项培训", "服务期", "培训费用", "违约金", "竞业限制", "经济补偿", "竞业期限", "两年"], + "content": "用人单位为劳动者提供专项培训费用、进行专业技术培训的,可以约定服务期。劳动者违反服务期约定的违约金不得超过培训费用,用人单位要求支付的违约金不得超过服务期尚未履行部分所应分摊的培训费用。对负有保密义务的劳动者,可以约定竞业限制,并在解除或者终止劳动合同后按月给予经济补偿;竞业限制人员限于高级管理人员、高级技术人员和其他负有保密义务的人员,期限不得超过二年。除服务期和竞业限制情形外,用人单位不得约定由劳动者承担违约金。" + }, + { + "source_id": "LAW-CN-LABOR-0030", + "title": "中华人民共和国劳动合同法", + "article_reference": "第三十条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["劳动报酬", "工资支付", "及时支付", "足额支付", "支付令"], + "content": "用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。用人单位拖欠或者未足额支付劳动报酬的,劳动者可以依法向当地人民法院申请支付令,人民法院应当依法发出支付令。" + }, + { + "source_id": "LAW-CN-LABOR-0043", + "title": "中华人民共和国劳动合同法", + "article_reference": "第四十三条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["单方解除", "工会通知", "工会纠正", "书面通知", "解除程序"], + "content": "用人单位单方解除劳动合同,应当事先将理由通知工会。用人单位违反法律、行政法规规定或者劳动合同约定的,工会有权要求用人单位纠正。用人单位应当研究工会的意见,并将处理结果书面通知工会。" + }, + { + "source_id": "LAW-CN-LABOR-0050", + "title": "中华人民共和国劳动合同法", + "article_reference": "第五十条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["解除证明", "终止证明", "档案转移", "社会保险转移", "工作交接", "经济补偿", "文本保存"], + "content": "用人单位应当在解除或者终止劳动合同时出具解除或者终止劳动合同的证明,并在十五日内为劳动者办理档案和社会保险关系转移手续。劳动者应当按照双方约定办理工作交接;用人单位依法应当支付经济补偿的,在办结工作交接时支付。用人单位对已经解除或者终止的劳动合同文本,至少保存二年备查。" + }, + { + "source_id": "LAW-CN-PIPL-0057", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第五十七条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["个人信息泄露", "篡改", "丢失", "补救措施", "监管通知", "个人通知", "安全事件"], + "content": "发生或者可能发生个人信息泄露、篡改、丢失的,个人信息处理者应当立即采取补救措施,并通知履行个人信息保护职责的部门和个人。通知应当包括事件涉及的信息种类、原因和可能危害,已经采取的补救措施和个人可以采取的减轻危害措施,以及个人信息处理者的联系方式。" + }, + { + "source_id": "LAW-CN-ESIGN-0005-0008", + "title": "中华人民共和国电子签名法", + "article_reference": "第五条至第八条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "2019年4月23日修正决定", + "version_label": "2019年修正现行文本", + "promulgated_on": "2019-04-23", + "effective_on": "2019-04-23", + "source_status": "effective", + "official_url": "https://www.miit.gov.cn/jgsj/zfs/fl/art/2022/art_e3f623f70c23497e88a941170093446a.html", + "contract_types": ["purchase", "sales", "employment", "lease", "confidentiality", "software", "construction", "data_processing", "equity", "transport"], + "topics": ["数据电文原件", "内容完整", "文件保存", "原始格式", "发件人", "收件人", "发送时间", "证据真实性", "可靠性"], + "content": "数据电文能够有效表现所载内容、可供随时调取,并能够可靠保证自最终形成时起内容保持完整、未被更改的,视为满足原件形式要求。保存的数据电文应能够准确表现原来生成、发送或者接收的内容,并能够识别发件人、收件人及发送、接收时间。数据电文不得仅因以电子等手段生成、发送、接收或者储存而被拒绝作为证据。审查真实性时,应考虑生成、储存或者传递方法、保持内容完整性方法、鉴别发件人方法的可靠性及其他相关因素。" + }, + { + "source_id": "LAW-CN-COMPANY-0003", + "title": "中华人民共和国公司法", + "article_reference": "第三条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第十五号", + "version_label": "2023年修订现行文本", + "promulgated_on": "2023-12-29", + "effective_on": "2024-07-01", + "source_status": "effective", + "official_url": "https://www.samr.gov.cn/zw/zfxxgk/fdzdgknr/fgs/art/2023/art_067c072db6ef4679a2e0180996be4cf8.html", + "contract_types": ["equity"], + "topics": ["公司法人", "独立法人财产", "公司债务", "全部财产", "股权交易", "历史负债"], + "content": "公司是企业法人,有独立的法人财产,享有法人财产权。公司以其全部财产对公司的债务承担责任。公司的合法权益受法律保护,不受侵犯。" + }, + { + "source_id": "LAW-CN-PIPL-0055-0056", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第五十五条、第五十六条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["个人信息保护影响评估", "处理记录", "敏感个人信息", "自动化决策", "委托处理", "向境外提供", "三年保存"], + "content": "处理敏感个人信息、利用个人信息进行自动化决策、委托处理或向其他处理者提供或公开个人信息、向境外提供个人信息,以及其他对个人权益有重大影响的处理活动,应当事前进行个人信息保护影响评估并对处理情况进行记录。评估应当包括处理目的和方式是否合法、正当、必要,对个人权益的影响及安全风险,以及保护措施是否合法、有效并与风险程度相适应。评估报告和处理情况记录应当至少保存三年。" + }, + { + "source_id": "LAW-CN-LABOR-0017-0035", + "title": "中华人民共和国劳动合同法", + "article_reference": "第十七条、第三十五条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["劳动合同必备条款", "工作内容", "工作地点", "劳动报酬", "协商一致", "书面变更", "调岗"], + "content": "劳动合同应当具备用人单位和劳动者基本信息、劳动合同期限、工作内容和工作地点、工作时间和休息休假、劳动报酬、社会保险、劳动保护、劳动条件和职业危害防护等法定条款。用人单位与劳动者协商一致,可以变更劳动合同约定的内容;变更劳动合同,应当采用书面形式,变更后的文本由双方各执一份。" + }, + { + "source_id": "LAW-CN-LABOR-0040-P2", + "title": "中华人民共和国劳动合同法", + "article_reference": "第四十条第二项", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第七十三号(2012年修订)", + "version_label": "2012年修订现行文本", + "promulgated_on": "2012-12-28", + "effective_on": "2013-07-01", + "source_status": "effective", + "official_url": "https://www.mohrss.gov.cn/xxgk2020/fdzdgknr/zcfg/fl/202011/t20201102_394622.html", + "contract_types": ["employment"], + "topics": ["不能胜任工作", "培训", "调整工作岗位", "调岗", "单方解除", "提前通知", "代通知金"], + "content": "劳动者不能胜任工作,经过培训或者调整工作岗位,仍不能胜任工作的,用人单位可以提前三十日以书面形式通知劳动者本人或者额外支付劳动者一个月工资后解除劳动合同。" + }, + { + "source_id": "LAW-CN-PIPL-0013-0017", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第十三条、第十四条、第十七条(节选)", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software", "employment"], + "topics": ["合法性基础", "个人同意", "合同必要", "法定义务", "告知文本", "处理规则", "处理目的", "保存期限", "同意记录", "规则版本"], + "content": "个人信息处理者只有在取得个人同意、为订立或履行个人作为一方当事人的合同所必需、为履行法定职责或者法定义务所必需等法定情形下方可处理个人信息。基于个人同意处理的,同意应当在充分知情的前提下自愿、明确作出;处理目的、方式和个人信息种类发生变更的,应当重新取得同意。处理前应以显著方式、清晰易懂的语言真实、准确、完整告知处理者名称和联系方式,处理目的、方式、种类、保存期限以及个人行使权利的方式和程序;采用处理规则告知的,规则应当公开并便于查阅和保存。" + }, + { + "source_id": "LAW-CN-PIPL-0057-P2", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第五十七条第二款", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["个人信息安全事件", "通知个人例外", "有效避免危害", "监管部门要求通知", "泄露", "篡改", "丢失"], + "content": "个人信息处理者采取措施能够有效避免信息泄露、篡改、丢失造成危害的,可以不通知个人;履行个人信息保护职责的部门认为可能造成危害的,有权要求个人信息处理者通知个人。" + }, + { + "source_id": "LAW-CN-PIPL-0038-0040", + "title": "中华人民共和国个人信息保护法", + "article_reference": "第三十八条至第四十条", + "issuing_authority": "全国人民代表大会常务委员会", + "document_number": "中华人民共和国主席令第九十一号", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "source_status": "effective", + "official_url": "https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html", + "contract_types": ["data_processing", "software"], + "topics": ["个人信息出境", "安全评估", "个人信息保护认证", "标准合同", "境外接收方", "单独告知", "单独同意", "境内存储"], + "content": "个人信息处理者因业务等需要向中华人民共和国境外提供个人信息的,应当具备通过国家网信部门组织的安全评估、经专业机构进行个人信息保护认证、与境外接收方按照国家网信部门制定的标准合同订立合同以约定双方权利义务,或者法律、行政法规、国家网信部门规定的其他条件之一,并采取必要措施保障境外接收方处理个人信息的活动达到本法规定的保护标准。向境外提供个人信息的,应当向个人告知境外接收方的名称或者姓名、联系方式、处理目的、处理方式、个人信息的种类以及个人向境外接收方行使权利的方式和程序等事项,并取得个人的单独同意。关键信息基础设施运营者和处理个人信息达到国家网信部门规定数量的个人信息处理者,应当将在境内收集和产生的个人信息存储在境内;确需向境外提供的,应当通过国家网信部门组织的安全评估,法律、行政法规和国家网信部门规定可以不进行安全评估的,从其规定。" + } + ] +} diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py index ad5856c0..9eefa30b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/model_output_quality.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, Field +from .legal_qa import extract_bound_source_ids + QualitySeverity = Literal["info", "warning", "error"] QualityStatus = Literal["passed", "needs_review", "failed"] @@ -51,6 +53,26 @@ _NO_EVIDENCE_MARKERS = ( "no authoritative source was retrieved", "no legal source was retrieved", ) +_INCOMPLETE_EVIDENCE_MARKERS = ( + "【待检索】", + "现有来源不足", + "现有法律来源仅", + "尚不足以判断", + "需补充检索", + "需要补充检索", + "需补充法律依据", +) +_AUTHORITATIVE_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1" +_AUTHORITATIVE_METADATA_FIELDS = ( + "url", + "issuing_authority", + "version_label", + "promulgated_on", + "effective_on", + "retrieved_on", + "content_sha256", + "source_manifest_sha256", +) def evaluate_model_output_quality( @@ -84,8 +106,18 @@ def evaluate_model_output_quality( labels.append(_label("citation_incomplete", "warning", "部分引用缺少标题、出处或摘录。")) elif any(not _citation_is_grounded(item) for item in citations): labels.append(_label("citation_ungrounded", "error", "引用没有可追溯的文档、知识分块、稳定来源 ID 或来源 URL。")) + elif any(_citation_is_authoritative(item) and not _citation_has_authoritative_metadata(item) for item in citations): + labels.append(_label("citation_authority_metadata_missing", "error", "正式法源缺少发布机关、版本、生效日期、检索日期、内容哈希或官方 URL。")) + elif any(_citation_is_authoritative(item) for item in citations) and not _answer_binds_authoritative_source(normalized_answer, citations): + labels.append(_label("citation_source_binding_missing", "error", "回答没有使用 [来源: LAW-CN-...] 将法律结论绑定到本次正式检索来源。")) elif normalized_answer and any(marker in normalized_answer.casefold() for marker in _NO_EVIDENCE_MARKERS): labels.append(_label("citation_answer_conflict", "error", "回答声明未命中依据,但响应同时附加了引用。")) + elif normalized_answer and any(marker in normalized_answer.casefold() for marker in _INCOMPLETE_EVIDENCE_MARKERS): + authoritative_source_ids = _authoritative_source_ids(citations) + if len(authoritative_source_ids) >= 3 and len(normalized_answer) >= 400: + labels.append(_label("retrieval_limitations_disclosed", "info", "回答已基于多条法源给出完整建议,并透明披露仍需结合合同全文或裁判尺度判断的边界。")) + else: + labels.append(_label("retrieval_coverage_incomplete", "warning", "回答明确标记部分核心结论缺少法源,需自动扩展检索或例外复核。")) else: labels.append(_label("citation_reviewable", "info", f"已返回 {len(citations)} 条可审阅引用。")) elif operation == "contract_review": @@ -94,6 +126,16 @@ def evaluate_model_output_quality( labels.append(_label("risk_items_missing", "error", "合同审查结果缺少结构化风险项。")) elif not risk_items: labels.append(_label("risk_items_empty", "warning", "合同审查没有识别风险项,需要抽样复核。")) + citations = result.get("primary_sources") or result.get("citations") or [] + authoritative_citations = ( + [item for item in citations if _citation_is_authoritative(item)] + if isinstance(citations, list) + else [] + ) + if any(not _citation_has_authoritative_metadata(item) for item in authoritative_citations): + labels.append(_label("citation_authority_metadata_missing", "error", "正式法源缺少发布机关、版本、生效日期、检索日期、内容哈希或官方 URL。")) + elif authoritative_citations and not _risk_items_bind_authoritative_sources(risk_items, authoritative_citations): + labels.append(_label("citation_source_binding_missing", "error", "合同风险项没有通过 citation_source_ids 绑定本次正式检索来源。")) error_count = sum(item.severity == "error" for item in labels) warning_count = sum(item.severity == "warning" for item in labels) @@ -123,5 +165,54 @@ def _citation_is_grounded(value: Any) -> bool: return isinstance(source_ids, list) and any(isinstance(item, str) and item.strip() for item in source_ids) +def _citation_is_authoritative(value: Any) -> bool: + return isinstance(value, dict) and value.get("knowledge_base_id") == _AUTHORITATIVE_KNOWLEDGE_BASE_ID + + +def _citation_has_authoritative_metadata(value: Any) -> bool: + if not isinstance(value, dict) or value.get("source_status") != "effective": + return False + source_ids = value.get("source_ids") + if not isinstance(source_ids, list) or not any( + isinstance(item, str) and item.startswith("LAW-CN-") for item in source_ids + ): + return False + return all(isinstance(value.get(field), str) and value[field].strip() for field in _AUTHORITATIVE_METADATA_FIELDS) + + +def _authoritative_source_ids(citations: list[Any]) -> set[str]: + return { + source_id + for citation in citations + if _citation_is_authoritative(citation) + for source_id in citation.get("source_ids", []) + if isinstance(source_id, str) and source_id.startswith("LAW-CN-") + } + + +def _answer_binds_authoritative_source(answer: str, citations: list[Any]) -> bool: + required = _authoritative_source_ids(citations) + return bool(required) and required.issubset(extract_bound_source_ids(answer)) + + +def _risk_items_bind_authoritative_sources(risk_items: Any, citations: list[Any]) -> bool: + if not isinstance(risk_items, list): + return False + cited = { + source_id + for item in risk_items + if isinstance(item, dict) + for source_id in item.get("citation_source_ids", []) + if isinstance(source_id, str) + } + required = { + source_id + for citation in citations + for source_id in citation.get("source_ids", []) + if isinstance(source_id, str) and source_id.startswith("LAW-CN-") + } + return bool(required) and required.issubset(cited) + + def _label(code: str, severity: QualitySeverity, message: str) -> ModelOutputQualityLabel: return ModelOutputQualityLabel(code=code, severity=severity, message=message) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py index 51aee6d2..17dcb34e 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py @@ -82,12 +82,15 @@ def render_legal_qa_prompt( "citations": _format_citations(citations), "legal_article_refs": _format_citations(citations), } + rendered: str | None = None if prompt_template: imported_prompt = _render_v1_prompt_asset(prompt_template.content, variables) if imported_prompt: - return imported_prompt - template = prompt_template.content if prompt_template else _default_legal_qa_template() - return _render_template(template, _default_legal_qa_template(), variables) + rendered = imported_prompt + if rendered is None: + template = prompt_template.content if prompt_template else _default_legal_qa_template() + rendered = _render_template(template, _default_legal_qa_template(), variables) + return f"{rendered}\n\n{_legal_qa_source_binding_instructions(citations)}" def render_contract_review_prompt( @@ -220,6 +223,19 @@ def _contract_review_output_instructions(citations: list[LegalCitation]) -> str: ) +def _legal_qa_source_binding_instructions(citations: list[LegalCitation]) -> str: + allowed_source_ids = list( + dict.fromkeys(source_id for citation in citations for source_id in citation.source_ids) + ) + allowed = ", ".join(allowed_source_ids) or "none" + return ( + "来源绑定要求:每个实质性法律结论必须在同一句或紧随其后的句子标注 " + "[来源: SOURCE_ID]。只能使用本次检索允许的来源 ID:" + f"{allowed}。不得引用未列出的来源;没有足够来源支持的结论必须明确标为待检索," + "不得把一般履约原则包装成对具体问题的完整依据。" + ) + + def _default_legal_qa_template() -> str: return ( "你是企业合同法律助手。请基于下列检索依据回答用户问题,必须先给结论,再列主要依据。\n" diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index 49904961..46a8e4ec 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -89,40 +89,40 @@ EMBEDDING_DIMENSION = LOCAL_HASH_EMBEDDING_DIMENSION PGVECTOR_MIN_CANDIDATE_LIMIT = 100 PGVECTOR_MAX_CANDIDATE_LIMIT = 500 PGVECTOR_CANDIDATE_SQL_64 = ( - "SELECT id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " + "SELECT id, tenant_id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " "content, chunk_index, locator, embedding_provider, embedding_model, embedding_dimension, " "embedding_json, created_at, " "GREATEST(1 - (CAST(embedding AS vector(64)) <=> CAST(:query_vector AS vector(64))), 0) AS vector_score " "FROM knowledge_chunks " - "WHERE embedding IS NOT NULL AND embedding_dimension = 64 " + "WHERE tenant_id = :tenant_id AND embedding IS NOT NULL AND embedding_dimension = 64 " "ORDER BY CAST(embedding AS vector(64)) <=> CAST(:query_vector AS vector(64)) LIMIT :limit" ) PGVECTOR_CANDIDATE_SQL_64_FILTERED = ( - "SELECT id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " + "SELECT id, tenant_id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " "content, chunk_index, locator, embedding_provider, embedding_model, embedding_dimension, " "embedding_json, created_at, " "GREATEST(1 - (CAST(embedding AS vector(64)) <=> CAST(:query_vector AS vector(64))), 0) AS vector_score " "FROM knowledge_chunks " - "WHERE embedding IS NOT NULL AND embedding_dimension = 64 " + "WHERE tenant_id = :tenant_id AND embedding IS NOT NULL AND embedding_dimension = 64 " "AND knowledge_base_id IN :knowledge_base_ids " "ORDER BY CAST(embedding AS vector(64)) <=> CAST(:query_vector AS vector(64)) LIMIT :limit" ) PGVECTOR_CANDIDATE_SQL_GENERIC = ( - "SELECT id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " + "SELECT id, tenant_id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " "content, chunk_index, locator, embedding_provider, embedding_model, embedding_dimension, " "embedding_json, created_at, " "GREATEST(1 - (embedding <=> CAST(:query_vector AS vector)), 0) AS vector_score " "FROM knowledge_chunks " - "WHERE embedding IS NOT NULL AND embedding_dimension = :query_dimension " + "WHERE tenant_id = :tenant_id AND embedding IS NOT NULL AND embedding_dimension = :query_dimension " "ORDER BY embedding <=> CAST(:query_vector AS vector) LIMIT :limit" ) PGVECTOR_CANDIDATE_SQL_GENERIC_FILTERED = ( - "SELECT id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " + "SELECT id, tenant_id, document_id, import_batch_id, knowledge_base_id, title, source_type, reference, " "content, chunk_index, locator, embedding_provider, embedding_model, embedding_dimension, " "embedding_json, created_at, " "GREATEST(1 - (embedding <=> CAST(:query_vector AS vector)), 0) AS vector_score " "FROM knowledge_chunks " - "WHERE embedding IS NOT NULL AND embedding_dimension = :query_dimension " + "WHERE tenant_id = :tenant_id AND embedding IS NOT NULL AND embedding_dimension = :query_dimension " "AND knowledge_base_id IN :knowledge_base_ids " "ORDER BY embedding <=> CAST(:query_vector AS vector) LIMIT :limit" ) @@ -2872,8 +2872,10 @@ class SqlAlchemyAiPlatformRepository: normalized = keyword.strip() terms = _tokenize(keyword) if normalized else [] has_search_vector = self._has_knowledge_document_search_vector_column(session) - statement = select(KnowledgeDocumentModel) - params: dict[str, object] = {} + statement = select(KnowledgeDocumentModel).where( + KnowledgeDocumentModel.tenant_id == bindparam("tenant_id") + ) + params: dict[str, object] = {"tenant_id": current_tenant_id()} if knowledge_base_ids: statement = statement.where(KnowledgeDocumentModel.knowledge_base_id.in_(bindparam("knowledge_base_ids", expanding=True))) params["knowledge_base_ids"] = knowledge_base_ids @@ -2922,6 +2924,7 @@ class SqlAlchemyAiPlatformRepository: else: statement = PGVECTOR_CANDIDATE_SQL_GENERIC_FILTERED if knowledge_base_ids else PGVECTOR_CANDIDATE_SQL_GENERIC params: dict[str, object] = { + "tenant_id": current_tenant_id(), "query_vector": _to_pgvector_literal(query_vector), "limit": candidate_limit, } @@ -3187,6 +3190,7 @@ class SqlAlchemyAiPlatformRepository: else: statement = PGVECTOR_CANDIDATE_SQL_GENERIC_FILTERED if knowledge_base_ids else PGVECTOR_CANDIDATE_SQL_GENERIC params: dict[str, object] = { + "tenant_id": current_tenant_id(), "query_vector": _to_pgvector_literal(query_vector), "limit": limit, } @@ -3240,11 +3244,16 @@ class SqlAlchemyAiPlatformRepository: limit: int, ) -> list[object]: statement = _knowledge_chunk_result_select(literal(0.0)).where( + KnowledgeChunkModel.tenant_id == bindparam("tenant_id"), literal_column("embedding").is_not(None), literal_column("search_vector").is_not(None), literal_column("search_vector").op("@@")(func.plainto_tsquery("simple", bindparam("keyword_query"))), ) - params: dict[str, object] = {"keyword_query": _to_tsquery_text(terms), "limit": limit} + params: dict[str, object] = { + "tenant_id": current_tenant_id(), + "keyword_query": _to_tsquery_text(terms), + "limit": limit, + } if knowledge_base_ids: statement = statement.where( KnowledgeChunkModel.knowledge_base_id.in_(bindparam("knowledge_base_ids", expanding=True)) @@ -3269,11 +3278,16 @@ class SqlAlchemyAiPlatformRepository: try: remaining_limit = max(limit - len(rows_by_id), 0) statement = _knowledge_chunk_result_select(literal(0.0)).where( + KnowledgeChunkModel.tenant_id == bindparam("tenant_id"), literal_column("embedding").is_not(None), literal_column("search_vector").is_not(None), literal_column("search_vector").op("@@")(func.plainto_tsquery("simple", bindparam("keyword_query"))), ) - params: dict[str, object] = {"keyword_query": _to_tsquery_text(terms), "limit": limit} + params: dict[str, object] = { + "tenant_id": current_tenant_id(), + "keyword_query": _to_tsquery_text(terms), + "limit": limit, + } if knowledge_base_ids: statement = statement.where( KnowledgeChunkModel.knowledge_base_id.in_(bindparam("knowledge_base_ids", expanding=True)) @@ -3306,8 +3320,11 @@ class SqlAlchemyAiPlatformRepository: if len(rows_by_id) >= limit: return list(rows_by_id.values()) - statement = _knowledge_chunk_result_select(literal(0.0)).where(literal_column("embedding").is_not(None)) - params: dict[str, object] = {"limit": limit} + statement = _knowledge_chunk_result_select(literal(0.0)).where( + KnowledgeChunkModel.tenant_id == bindparam("tenant_id"), + literal_column("embedding").is_not(None), + ) + params: dict[str, object] = {"tenant_id": current_tenant_id(), "limit": limit} if knowledge_base_ids: statement = statement.where( KnowledgeChunkModel.knowledge_base_id.in_(bindparam("knowledge_base_ids", expanding=True)) @@ -3560,7 +3577,7 @@ def _int_from_env(name: str, default: int) -> int: def document_to_citation(document: KnowledgeDocument) -> LegalCitation: - return LegalCitation( + citation = LegalCitation( source_type=document.source_type, title=document.title, reference=document.reference, @@ -3569,11 +3586,12 @@ def document_to_citation(document: KnowledgeDocument) -> LegalCitation: knowledge_base_id=document.knowledge_base_id, source_ids=extract_stable_source_ids(document.reference), ) + return _enrich_authoritative_citation(citation) def chunk_to_citation(result: KnowledgeSearchResult) -> LegalCitation: chunk = result.chunk - return LegalCitation( + citation = LegalCitation( source_type=chunk.source_type, title=chunk.title, reference=f"{chunk.reference} {chunk.locator}".strip(), @@ -3590,6 +3608,16 @@ def chunk_to_citation(result: KnowledgeSearchResult) -> LegalCitation: matched_terms=result.matched_terms, source_ids=extract_stable_source_ids(chunk.reference), ) + return _enrich_authoritative_citation(citation) + + +def _enrich_authoritative_citation(citation: LegalCitation) -> LegalCitation: + if citation.knowledge_base_id != "laws-cn-authoritative-v1": + return citation + from yuqei_ai_platform_api.authoritative_legal_sources import authoritative_source_metadata + + metadata = authoritative_source_metadata(citation.source_ids) + return citation.model_copy(update=metadata) if metadata else citation def search_result_to_retrieval_source(result: KnowledgeSearchResult) -> RetrievalSource: diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/tenant_context.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/tenant_context.py index 230133d7..5f8d5db8 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/tenant_context.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/tenant_context.py @@ -1,13 +1,11 @@ from __future__ import annotations from contextvars import ContextVar -from threading import local from yuqei_ai_platform_api.models import DEFAULT_TENANT_ID _tenant_id_var: ContextVar[str] = ContextVar("ai_platform_tenant_id", default=DEFAULT_TENANT_ID) -_thread_tenant = local() def normalize_tenant_id(value: str | None) -> str: @@ -22,18 +20,13 @@ def normalize_tenant_id(value: str | None) -> str: def current_tenant_id() -> str: - thread_value = getattr(_thread_tenant, "tenant_id", None) - if thread_value: - return thread_value return _tenant_id_var.get() def set_current_tenant_id(value: str | None): tenant_id = normalize_tenant_id(value) - _thread_tenant.tenant_id = tenant_id return _tenant_id_var.set(tenant_id) def reset_current_tenant_id(token) -> None: _tenant_id_var.reset(token) - _thread_tenant.tenant_id = _tenant_id_var.get() diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_authoritative_legal_sources.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_authoritative_legal_sources.py new file mode 100644 index 00000000..27112432 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_authoritative_legal_sources.py @@ -0,0 +1,197 @@ +from yuqei_ai_platform_api.authoritative_legal_sources import ( + AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + REQUIRED_CONTRACT_TYPES, + authoritative_source_metadata, + authoritative_source_query_relevance, + authoritative_source_supports_contract_types, + infer_authoritative_contract_types, + load_authoritative_legal_source_manifest, +) +from yuqei_ai_platform_api.api.legal import ( + _authoritative_result_limit, + _authoritative_retrieval_expansions, + _search_chunk_results, +) +from yuqei_ai_platform_api.legal_qa import ( + LegalCitation, + LegalQaRequest, + extract_bound_source_ids, + select_citations_bound_to_answer, +) +from yuqei_ai_platform_api.repository import InMemoryAiPlatformRepository, KnowledgeDocumentCreate, chunk_to_citation + + +def test_authoritative_manifest_is_versioned_unique_and_covers_pilot_contract_types() -> None: + manifest = load_authoritative_legal_source_manifest() + + assert manifest.knowledge_base_id == AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID + assert len(manifest.sources) == 56 + assert len({item.source_id for item in manifest.sources}) == len(manifest.sources) + assert {contract_type for item in manifest.sources for contract_type in item.contract_types} >= REQUIRED_CONTRACT_TYPES + assert len(manifest.manifest_sha256) == 64 + assert all(len(item.content_sha256) == 64 for item in manifest.sources) + assert all(item.official_url.startswith("https://") for item in manifest.sources) + + +def test_authoritative_chunk_citation_is_enriched_with_official_provenance() -> None: + manifest = load_authoritative_legal_source_manifest() + source = next(item for item in manifest.sources if item.source_id == "LAW-CN-PIPL-0021") + repository = InMemoryAiPlatformRepository() + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id=manifest.knowledge_base_id, + title=source.title, + source_type="official_law", + reference=source.reference, + content=source.content, + ) + ) + + result = repository.search_knowledge_chunks( + "委托处理个人信息", + knowledge_base_ids=[manifest.knowledge_base_id], + strategy="keyword", + limit=1, + )[0] + citation = chunk_to_citation(result) + + assert citation.source_ids == [source.source_id] + assert citation.url == source.official_url + assert citation.issuing_authority == source.issuing_authority + assert citation.effective_on == source.effective_on + assert citation.retrieved_on == manifest.retrieved_on + assert citation.source_status == "effective" + assert citation.content_sha256 == source.content_sha256 + assert citation.source_manifest_sha256 == manifest.manifest_sha256 + + +def test_provider_answer_only_exposes_authoritative_sources_it_explicitly_binds() -> None: + citations = [ + LegalCitation( + source_type="official_law", + title="中华人民共和国个人信息保护法", + reference="第二十一条", + quote="委托处理应约定处理目的和权限。", + knowledge_base_id=AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + document_id="doc-pipl-21", + source_ids=["LAW-CN-PIPL-0021"], + **(authoritative_source_metadata(["LAW-CN-PIPL-0021"]) or {}), + ), + LegalCitation( + source_type="official_law", + title="中华人民共和国个人信息保护法", + reference="第四十七条", + quote="符合条件时应主动删除个人信息。", + knowledge_base_id=AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + document_id="doc-pipl-47", + source_ids=["LAW-CN-PIPL-0047"], + **(authoritative_source_metadata(["LAW-CN-PIPL-0047"]) or {}), + ), + ] + + selected = select_citations_bound_to_answer( + "委托处理条款应写明处理目的和监督机制。[来源: LAW-CN-PIPL-0021]", + citations, + ) + + assert [item.source_ids for item in selected] == [["LAW-CN-PIPL-0021"]] + assert select_citations_bound_to_answer("建议完善委托条款。", citations) == [] + + +def test_source_binding_parser_supports_multiple_ids_and_ignores_unbound_source_lists() -> None: + assert extract_bound_source_ids( + "解除后应结算并保留清理条款。[来源: LAW-CN-CIVIL-0566, LAW-CN-CIVIL-0567]" + ) == ["LAW-CN-CIVIL-0566", "LAW-CN-CIVIL-0567"] + assert extract_bound_source_ids( + '来源列表:{"source_id":"LAW-CN-CIVIL-0566"}' + ) == [] + + +def test_contract_domain_inference_excludes_irrelevant_labor_sources() -> None: + contract_types = infer_authoritative_contract_types( + "软件开发合同解除后如何结算源代码、研发投入和已完成里程碑?" + ) + + assert "software" in contract_types + assert authoritative_source_supports_contract_types(["LAW-CN-CIVIL-0563"], contract_types) is True + assert authoritative_source_supports_contract_types(["LAW-CN-SOFTWARE-0011"], contract_types) is True + assert authoritative_source_supports_contract_types(["LAW-CN-LABOR-0039"], contract_types) is False + + +def test_authoritative_retrieval_expands_and_reranks_core_termination_topics() -> None: + query = "软件开发合同随时解除后,已完成工作如何验收、支付、结算和赔偿损失?" + expansions = _authoritative_retrieval_expansions(query) + + assert any("合同解除后" in item for item in expansions) + assert any("承揽" in item and "验收" in item for item in expansions) + assert any("软件著作权" in item for item in expansions) + assert authoritative_source_query_relevance(["LAW-CN-CIVIL-0787"], query) > authoritative_source_query_relevance( + ["LAW-CN-DSL-0027-0029"], + query, + ) + + +def test_authoritative_retrieval_expands_common_second_pass_omissions() -> None: + expansions = _authoritative_retrieval_expansions( + "劳动合同发生严重违约时如何暂停履行、解除并设置责任限制和违约金,同时保留电子证据?" + ) + merged = "\n".join(expansions) + + assert "不安抗辩" in merged + assert "免责条款" in merged + assert "司法调整" in merged + assert "规章制度" in merged + assert "工会通知" in merged + assert "工作地点" in merged + assert "证据真实性" in merged + + +def test_authoritative_data_retrieval_keeps_processor_and_cross_border_sources() -> None: + query = ( + "个人信息委托处理涉及分包商和多地域跨境备份,如何保留安全评估、" + "标准合同、单独同意、审计和删除证据?" + ) + merged = "\n".join(_authoritative_retrieval_expansions(query)) + + assert "安全评估" in merged + assert "标准合同" in merged + assert "境外接收方" in merged + assert "转委托同意" in merged + assert _authoritative_result_limit(query) == 8 + assert _authoritative_result_limit("软件开发合同解除后如何结算?") == 6 + + +def test_authoritative_retrieval_filters_irrelevant_contract_domains() -> None: + manifest = load_authoritative_legal_source_manifest() + repository = InMemoryAiPlatformRepository() + for source_id in ("LAW-CN-SOFTWARE-0011", "LAW-CN-LABOR-0039", "LAW-CN-CIVIL-0563"): + source = next(item for item in manifest.sources if item.source_id == source_id) + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id=manifest.knowledge_base_id, + title=source.title, + source_type="official_law", + reference=source.reference, + content=source.content, + ) + ) + + results = _search_chunk_results( + repository, + LegalQaRequest( + question="软件开发合同解除后,已经完成的研发工作如何结算?", + matter_context="涉及源代码交付和里程碑验收。", + knowledge_base_ids=[manifest.knowledge_base_id], + retrieval_strategy="hybrid", + ), + ) + source_ids = { + source_id + for result in results + for source_id in result.chunk.reference.split() + if source_id.startswith("LAW-CN-") + } + + assert "LAW-CN-SOFTWARE-0011" in source_ids + assert "LAW-CN-CIVIL-0563" in source_ids + assert "LAW-CN-LABOR-0039" not in source_ids diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py index 6128564f..dc35d111 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_model_output_quality.py @@ -63,6 +63,262 @@ def test_quality_gate_rejects_answer_that_denies_attached_evidence() -> None: assert "citation_answer_conflict" in {item.code for item in result.labels} +def test_authoritative_citation_requires_complete_official_metadata() -> None: + result = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "委托处理个人信息应约定什么?"}, + result={ + "answer": ( + "应在委托处理协议中明确处理目的、期限、方式、个人信息种类和保护措施," + "并约定受托人仅按指示处理、返还或删除数据以及接受持续监督的义务。" + "[来源: LAW-CN-PIPL-0021]" + ), + "citations": [ + { + "title": "中华人民共和国个人信息保护法", + "reference": "第二十一条", + "quote": "个人信息处理者委托处理个人信息的,应当与受托人约定相关事项。", + "document_id": "doc-pipl-21", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-PIPL-0021"], + } + ], + }, + ) + + assert result.status == "failed" + assert "citation_authority_metadata_missing" in {item.code for item in result.labels} + + +def test_authoritative_citation_requires_answer_source_binding() -> None: + citation = { + "title": "中华人民共和国个人信息保护法", + "reference": "第二十一条", + "quote": "个人信息处理者委托处理个人信息的,应当与受托人约定相关事项。", + "url": "https://www.npc.gov.cn/example", + "document_id": "doc-pipl-21", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-PIPL-0021"], + "issuing_authority": "全国人民代表大会常务委员会", + "version_label": "2021年公布现行文本", + "promulgated_on": "2021-08-20", + "effective_on": "2021-11-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + unbound = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "委托处理个人信息应约定什么?"}, + result={"answer": "应约定处理目的、期限、方式和监督机制。", "citations": [citation]}, + ) + bound = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "委托处理个人信息应约定什么?"}, + result={ + "answer": ( + "应在委托处理协议中明确处理目的、期限、方式、个人信息种类和保护措施," + "并约定受托人仅按指示处理、返还或删除数据以及接受持续监督的义务。" + "[来源: LAW-CN-PIPL-0021]" + ), + "citations": [citation], + }, + ) + + assert unbound.status == "failed" + assert "citation_source_binding_missing" in {item.code for item in unbound.labels} + assert bound.status == "passed" + + +def test_authoritative_citations_all_bind_inside_one_source_block() -> None: + first = { + "title": "中华人民共和国民法典", + "reference": "第五百六十六条", + "quote": "合同解除后应依法处理已经履行部分。", + "url": "https://www.cac.gov.cn/example", + "document_id": "doc-civil-566", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-CIVIL-0566"], + "issuing_authority": "全国人民代表大会", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + second = { + **first, + "reference": "第五百六十七条", + "document_id": "doc-civil-567", + "source_ids": ["LAW-CN-CIVIL-0567"], + } + + bound = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "解除后如何结算?"}, + result={ + "answer": ( + "合同解除后,应区分未履行部分和已经履行部分,并保留结算、清理以及损失处理安排," + "同时约定通知到达、成果交付和争议证据留存流程。[来源: LAW-CN-CIVIL-0566, LAW-CN-CIVIL-0567]" + ), + "citations": [first, second], + }, + ) + + assert bound.status == "passed" + + +def test_contract_review_requires_authoritative_risk_source_binding() -> None: + citation = { + "title": "中华人民共和国民法典", + "reference": "第五百零九条", + "quote": "当事人应当按照约定全面履行自己的义务。", + "url": "https://www.cac.gov.cn/example", + "document_id": "doc-civil-509", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-CIVIL-0509"], + "issuing_authority": "全国人民代表大会", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + base_result = { + "summary": ( + "已识别付款、验收和持续履行义务之间的风险,建议补充验收标准、异议期限和付款触发条件以形成完整闭环," + "同时明确整改复验、逾期处理、证据留存以及双方通知送达机制。" + ), + "risk_items": [ + { + "id": "risk-payment-acceptance", + "title": "付款与验收条件不完整", + "severity": "high", + "suggestion": "明确验收标准和付款触发条件。", + "citation_source_ids": [], + } + ], + "citations": [citation], + } + + unbound = evaluate_model_output_quality( + operation="contract_review", + request_payload={"contract_title": "采购合同"}, + result=base_result, + ) + bound_result = { + **base_result, + "risk_items": [ + { + **base_result["risk_items"][0], + "citation_source_ids": ["LAW-CN-CIVIL-0509"], + } + ], + } + bound = evaluate_model_output_quality( + operation="contract_review", + request_payload={"contract_title": "采购合同"}, + result=bound_result, + ) + + assert unbound.status == "failed" + assert "citation_source_binding_missing" in {item.code for item in unbound.labels} + assert bound.status == "passed" + + +def test_quality_gate_flags_partially_unsupported_legal_answer() -> None: + citation = { + "title": "中华人民共和国民法典", + "reference": "第五百六十三条", + "quote": "当事人一方迟延履行债务致使不能实现合同目的的,当事人可以解除合同。", + "url": "https://www.cac.gov.cn/example", + "document_id": "doc-civil-563", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-CIVIL-0563"], + "issuing_authority": "全国人民代表大会", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + result = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "解除后如何结算?"}, + result={ + "answer": ( + "出现法定解除事由时可以解除合同。[来源: LAW-CN-CIVIL-0563]" + "但解除后的结算和损失范围现有来源不足【待检索】,需要补充检索后才能形成完整结论。" + ), + "citations": [citation], + }, + ) + + assert result.status == "needs_review" + assert "retrieval_coverage_incomplete" in {item.code for item in result.labels} + + +def test_quality_gate_accepts_transparent_limits_on_a_substantive_multi_source_answer() -> None: + citation = { + "title": "中华人民共和国民法典", + "reference": "第五百六十六条", + "quote": "合同解除后,已经履行的,可以请求恢复原状、采取补救措施并请求赔偿损失。", + "url": "https://www.cac.gov.cn/example", + "document_id": "doc-civil-566", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": ["LAW-CN-CIVIL-0566"], + "issuing_authority": "全国人民代表大会", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + citations = [ + citation, + { + **citation, + "reference": "第五百六十七条", + "document_id": "doc-civil-567", + "source_ids": ["LAW-CN-CIVIL-0567"], + }, + { + **citation, + "reference": "第九百三十三条", + "document_id": "doc-civil-933", + "source_ids": ["LAW-CN-CIVIL-0933"], + }, + ] + answer = ( + "合同解除后,尚未履行部分终止履行,已经履行部分应根据合同性质进行恢复原状、补救或损失赔偿。" + "[来源: LAW-CN-CIVIL-0566, LAW-CN-CIVIL-0567, LAW-CN-CIVIL-0933]" + "建议在条款中明确解除通知期、已完成里程碑的验收和结算、在制工作计价、不可撤销成本、成果交付、" + "知识产权随付款转移、数据返还及持续保密义务。对已经书面确认的阶段成果应按对应价格结算;尚未确认的成果," + "应按预先约定的测试标准、完成比例和整改程序确认价值。解除通知发出后,双方应共同盘点代码、文档、账号、" + "第三方许可和未完成事项,并采用付款与交付同步或托管机制降低双方风险。对于具体完成比例的计算方法," + "现有来源不足,应由合同明确约定并结合项目证据判断;该透明限制不影响前述条款修改建议的可执行性。" + "解除后的结算安排还应区分法定解除、约定解除和任意解除,逐项核对解除权基础、通知到达时间、已履行成果、" + "可归责原因以及可证明损失,避免把尚有争议的完成比例直接表述为确定债权,并保留完整协商记录。" + ) + result = evaluate_model_output_quality( + operation="legal_qa", + request_payload={"question": "解除后如何结算?"}, + result={"answer": answer, "citations": citations}, + ) + + assert result.status == "passed" + assert "retrieval_limitations_disclosed" in {item.code for item in result.labels} + + def test_quality_gate_blocks_placeholder_and_lost_question() -> None: result = evaluate_model_output_quality( operation="legal_qa", diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index 1ccf1d61..2667e24a 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -4,6 +4,7 @@ import os import subprocess import sys from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context from datetime import UTC, datetime, timedelta from pathlib import Path from threading import Event @@ -46,7 +47,7 @@ from yuqei_ai_platform_api.repository import ( WorkerRunExecutionCreate, _to_tsquery_text, ) -from yuqei_ai_platform_api.tenant_context import reset_current_tenant_id, set_current_tenant_id +from yuqei_ai_platform_api.tenant_context import current_tenant_id, reset_current_tenant_id, set_current_tenant_id class TestEmbeddingProvider: @@ -87,6 +88,24 @@ def make_client() -> TestClient: return TestClient(app) +def test_tenant_context_does_not_leak_across_reused_worker_threads() -> None: + token = set_current_tenant_id("tenant-controlled") + controlled_context = copy_context() + reset_current_tenant_id(token) + default_context = copy_context() + + def background_request() -> str: + nested = set_current_tenant_id("tenant-controlled") + try: + return current_tenant_id() + finally: + reset_current_tenant_id(nested) + + with ThreadPoolExecutor(max_workers=1) as executor: + assert executor.submit(controlled_context.run, background_request).result() == "tenant-controlled" + assert executor.submit(default_context.run, current_tenant_id).result() == "default" + + def test_model_config_can_be_hot_updated_and_listed() -> None: client = make_client() @@ -1698,6 +1717,7 @@ def test_postgresql_document_search_uses_search_vector_when_available() -> None: class FakeSearchSession: def __init__(self) -> None: self.executed: list[str] = [] + self.parameters: list[dict | None] = [] def execute(self, statement, params=None): # type: ignore[no-untyped-def] sql_text = getattr(statement, "text", str(statement)) @@ -1716,6 +1736,7 @@ def test_postgresql_document_search_uses_search_vector_when_available() -> None: def scalars(self, statement, params=None): # type: ignore[no-untyped-def] sql_text = getattr(statement, "text", str(statement)) self.executed.append(sql_text) + self.parameters.append(params) return type("ScalarResult", (), {"all": lambda self: []})() def __enter__(self): # type: ignore[no-untyped-def] @@ -1729,17 +1750,23 @@ def test_postgresql_document_search_uses_search_vector_when_available() -> None: session = FakeSearchSession() repository._session_factory = lambda: session # type: ignore[assignment] - rows = repository._search_knowledge_documents_postgresql( - session, # type: ignore[arg-type] - "合同", - knowledge_base_ids=["laws-cn"], - limit=5, - ) + token = set_current_tenant_id("tenant-postgresql-test") + try: + rows = repository._search_knowledge_documents_postgresql( + session, # type: ignore[arg-type] + "合同", + knowledge_base_ids=["laws-cn"], + limit=5, + ) + finally: + reset_current_tenant_id(token) assert rows == [] assert any("search_vector" in sql for sql in session.executed) assert not any("ILIKE" in sql.upper() for sql in session.executed) assert not any("ts_rank_cd" in sql for sql in session.executed) + assert any("knowledge_documents.tenant_id" in sql for sql in session.executed) + assert any(params and params.get("tenant_id") == "tenant-postgresql-test" for params in session.parameters) def test_postgresql_document_search_vector_column_probe_is_cached() -> None: @@ -1866,11 +1893,16 @@ def test_pgvector_default_dimension_query_matches_hnsw_expression_index() -> Non assert "CAST(:query_vector AS vector(64))" in PGVECTOR_CANDIDATE_SQL_64 assert "embedding_dimension = 64" in PGVECTOR_CANDIDATE_SQL_64 assert "embedding_dimension = 64" in PGVECTOR_CANDIDATE_SQL_64_FILTERED + assert "tenant_id = :tenant_id" in PGVECTOR_CANDIDATE_SQL_64 + assert "tenant_id = :tenant_id" in PGVECTOR_CANDIDATE_SQL_64_FILTERED + assert "tenant_id" in PGVECTOR_CANDIDATE_SQL_64.split("FROM", 1)[0] assert "embedding <=> CAST(:query_vector AS vector)" not in PGVECTOR_CANDIDATE_SQL_64 assert "embedding <=> CAST(:query_vector AS vector)" in PGVECTOR_CANDIDATE_SQL_GENERIC assert "embedding_dimension = :query_dimension" in PGVECTOR_CANDIDATE_SQL_GENERIC assert "embedding_dimension = :query_dimension" in PGVECTOR_CANDIDATE_SQL_GENERIC_FILTERED + assert "tenant_id = :tenant_id" in PGVECTOR_CANDIDATE_SQL_GENERIC + assert "tenant_id = :tenant_id" in PGVECTOR_CANDIDATE_SQL_GENERIC_FILTERED def test_pgvector_hybrid_search_uses_keyword_fallback_before_embedding() -> None: diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py index 34c01639..265e685b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -597,6 +597,7 @@ def test_render_legal_qa_prompt_includes_question_context_and_citations() -> Non assert "租赁合同押金如何约定" in prompt assert "房屋租赁" in prompt assert "第七百零三条" in prompt + assert "来源绑定要求" in prompt def test_legal_qa_prefers_published_v1_prompt_asset() -> None: @@ -761,6 +762,8 @@ def test_render_contract_review_prompt_supports_v1_asset_json() -> None: assert "类型=service" in prompt assert "服务完成并验收后付款" in prompt assert "付款与验收" in prompt + assert "citation_source_ids" in prompt + assert "Never invent a source ID" in prompt assert "{{" not in prompt diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 9106d65a..0bf70fc0 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -30,6 +30,16 @@ class LegalCitation(BaseModel): hybrid_score: float | None = None retrieval_strategy: str | None = None matched_terms: list[str] = Field(default_factory=list) + source_ids: list[str] = Field(default_factory=list) + issuing_authority: str | None = None + document_number: str | None = None + version_label: str | None = None + promulgated_on: str | None = None + effective_on: str | None = None + retrieved_on: str | None = None + source_status: str | None = None + content_sha256: str | None = None + source_manifest_sha256: str | None = None class LegalQaResponse(BaseModel): diff --git a/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx b/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx index ca10d8fc..8c687363 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/legal-qa/legal-qa-client.tsx @@ -34,7 +34,10 @@ const suggestedQuestions = [ "房屋租赁合同押金退还条款有哪些风险?", ]; +const AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1"; + const sourceTypeLabels: Record = { + official_law: "官方现行法源", law: "法律法规", document: "知识文档", clause: "标准条款", @@ -93,6 +96,7 @@ export function LegalQaClient({ const created = await createLegalQaJob({ question: trimmedQuestion, matter_context: matterContext.trim() || null, + knowledge_base_ids: [AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID], retrieval_strategy: retrievalStrategy, }, idempotencyKey); setActiveJob(created); @@ -411,7 +415,7 @@ export function LegalQaClient({

- 回答由 AI 根据当前事实背景和检索依据生成,仅用于合同处理参考,不替代律师、企业法务或管理层的最终判断。重要事项请结合完整材料进行人工复核。 + 系统优先由 AI 完成检索、回答与质量复核,并展示可核验法源。仅在依据不足、结论冲突、连续重试失败或高影响例外时进入人工接管。

@@ -460,7 +464,34 @@ function CitationCard({ citation, index }: { citation: LegalQaCitation; index: n {citation.score != null ? ` / ${Math.round(citation.score * 100)}%` : ""} {citation.retrieval_strategy ? ` / ${citation.retrieval_strategy}` : ""} + {(citation.source_ids ?? []).length > 0 && ( +
+ {(citation.source_ids ?? []).map((sourceId) => ( + {sourceId} + ))} +
+ )}

{citation.quote}

+ {(citation.issuing_authority || citation.version_label || citation.effective_on || citation.source_status) && ( +

+ {[ + citation.issuing_authority, + citation.document_number, + citation.version_label, + citation.effective_on ? `生效:${citation.effective_on}` : null, + citation.source_status === "effective" ? "效力:现行有效" : citation.source_status, + ].filter(Boolean).join(" · ")} +

+ )} + {(citation.retrieved_on || citation.content_sha256 || citation.source_manifest_sha256) && ( +

+ {[ + citation.retrieved_on ? `核验:${citation.retrieved_on}` : null, + citation.content_sha256 ? `内容摘要:${citation.content_sha256.slice(0, 12)}…` : null, + citation.source_manifest_sha256 ? `清单摘要:${citation.source_manifest_sha256.slice(0, 12)}…` : null, + ].filter(Boolean).join(" · ")} +

+ )} {citation.url && ( {citation.url} diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index 549c1e2c..c10ba033 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -145,6 +145,16 @@ export type LegalQaCitation = { hybrid_score?: number | null; retrieval_strategy?: string | null; matched_terms: string[]; + source_ids: string[]; + issuing_authority?: string | null; + document_number?: string | null; + version_label?: string | null; + promulgated_on?: string | null; + effective_on?: string | null; + retrieved_on?: string | null; + source_status?: string | null; + content_sha256?: string | null; + source_manifest_sha256?: string | null; }; export type LegalQaResponse = { diff --git a/yuqei-contract-platform/packages/contract-openapi/openapi.yaml b/yuqei-contract-platform/packages/contract-openapi/openapi.yaml index 2650b903..27475c01 100644 --- a/yuqei-contract-platform/packages/contract-openapi/openapi.yaml +++ b/yuqei-contract-platform/packages/contract-openapi/openapi.yaml @@ -8951,7 +8951,7 @@ components: properties: source_type: type: string - enum: [law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document] + enum: [law, official_law, regulation, judicial_interpretation, internal_policy, contract_clause, knowledge_document] title: type: string reference: @@ -8996,6 +8996,19 @@ components: type: array items: type: string + source_ids: + type: array + items: + type: string + issuing_authority: { type: string, nullable: true } + document_number: { type: string, nullable: true } + version_label: { type: string, nullable: true } + promulgated_on: { type: string, format: date, nullable: true } + effective_on: { type: string, format: date, nullable: true } + retrieved_on: { type: string, format: date, nullable: true } + source_status: { type: string, nullable: true } + content_sha256: { type: string, pattern: "^[0-9a-f]{64}$", nullable: true } + source_manifest_sha256: { type: string, pattern: "^[0-9a-f]{64}$", nullable: true } LegalQaRequest: type: object required: diff --git a/yuqei-contract-platform/services/contract-api/.env.example b/yuqei-contract-platform/services/contract-api/.env.example index ba50d670..40afaa36 100644 --- a/yuqei-contract-platform/services/contract-api/.env.example +++ b/yuqei-contract-platform/services/contract-api/.env.example @@ -12,6 +12,8 @@ CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1 CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS=30 CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT=5 +# Comma-separated, tenant-provisioned knowledge bases used by unattended review. +CONTRACT_API_REVIEW_WORKER_KNOWLEDGE_BASE_IDS=laws-cn-authoritative-v1 CONTRACT_API_PLAYBOOK_GOVERNANCE_WORKER_NAME=playbook-governance-worker-1 CONTRACT_API_PLAYBOOK_GOVERNANCE_WORKER_POLL_INTERVAL_SECONDS=2 CONTRACT_API_PLAYBOOK_GOVERNANCE_WORKER_BATCH_LIMIT=10 diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py index 5b6e4e13..822342dd 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py @@ -34,7 +34,11 @@ from yuqei_contract_api.persistence.review_tasks import ( ) from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError from yuqei_contract_api.review_report import ReviewReportGenerateRequest, ReviewReportStatus -from yuqei_contract_api.review_worker import ReviewWorkerOptions, run_review_worker_once +from yuqei_contract_api.review_worker import ( + ReviewWorkerOptions, + configured_review_knowledge_base_ids, + run_review_worker_once, +) from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context @@ -390,7 +394,10 @@ def register_review_routes( worker_name=payload.worker_name or settings.review_worker_name, limit=payload.limit, review_focus=payload.review_focus, - knowledge_base_ids=tuple(payload.knowledge_base_ids), + knowledge_base_ids=( + tuple(payload.knowledge_base_ids) + or configured_review_knowledge_base_ids(settings) + ), ), ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py index adfc8408..51b9b22d 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py @@ -30,6 +30,7 @@ class ContractApiSettings(BaseSettings): review_worker_name: str = "contract-review-worker-1" review_worker_poll_interval_seconds: float = 30.0 review_worker_batch_limit: int = 5 + review_worker_knowledge_base_ids: str = "laws-cn-authoritative-v1" playbook_governance_worker_name: str = "playbook-governance-worker-1" playbook_governance_worker_poll_interval_seconds: float = 2.0 playbook_governance_worker_batch_limit: int = 10 diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py index f3c8cc1d..13320118 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py @@ -19,12 +19,25 @@ from yuqei_contract_api.tenant_context import tenant_context_scope logger = get_logger(__name__) +AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1" + + @dataclass(frozen=True) class ReviewWorkerOptions: worker_name: str limit: int = 1 review_focus: str | None = None - knowledge_base_ids: tuple[str, ...] = () + knowledge_base_ids: tuple[str, ...] = (AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID,) + + +def configured_review_knowledge_base_ids(settings: ContractApiSettings) -> tuple[str, ...]: + return tuple( + dict.fromkeys( + item.strip() + for item in settings.review_worker_knowledge_base_ids.split(",") + if item.strip() + ) + ) def run_review_worker_once( @@ -323,6 +336,7 @@ def main() -> None: options=ReviewWorkerOptions( worker_name=settings.review_worker_name, limit=settings.review_worker_batch_limit, + knowledge_base_ids=configured_review_knowledge_base_ids(settings), ), ) time.sleep(settings.review_worker_poll_interval_seconds) @@ -339,6 +353,7 @@ def run_once(settings: ContractApiSettings | None = None, *, limit: int | None = options=ReviewWorkerOptions( worker_name=resolved_settings.review_worker_name, limit=limit or resolved_settings.review_worker_batch_limit, + knowledge_base_ids=configured_review_knowledge_base_ids(resolved_settings), ), ) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_tenant_isolation.py b/yuqei-contract-platform/services/contract-api/tests/test_tenant_isolation.py index e5b664bc..8eed259d 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_tenant_isolation.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_tenant_isolation.py @@ -689,6 +689,7 @@ def test_review_worker_executes_personal_tenant_task_in_explicit_context(tmp_pat def run_contract_review(self, request, *, context=None): assert request.contract_title == "个人租户审查合同" + assert request.knowledge_base_ids == ["laws-cn-authoritative-v1"] assert context is not None captured_context["tenant_id"] = context.tenant_id captured_context["user_id"] = context.user_id diff --git a/yuqei-infra/scripts/import-authoritative-cn-legal-sources.py b/yuqei-infra/scripts/import-authoritative-cn-legal-sources.py new file mode 100644 index 00000000..59687d2d --- /dev/null +++ b/yuqei-infra/scripts/import-authoritative-cn-legal-sources.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Idempotently import the versioned official PRC contract-law source registry.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlparse +from urllib.request import Request, urlopen + + +REPO_ROOT = Path(__file__).resolve().parents[2] +AI_PLATFORM_SRC = REPO_ROOT / "yuqei-ai-platform" / "services" / "ai-platform-api" / "src" +sys.path.insert(0, str(AI_PLATFORM_SRC)) + +from yuqei_ai_platform_api.authoritative_legal_sources import ( # noqa: E402 + AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + AuthoritativeLegalSource, + load_authoritative_legal_source_manifest, +) + + +class ApiError(RuntimeError): + pass + + +class ApiClient: + def __init__(self, base_url: str, *, tenant_id: str, user_id: str, timeout: float) -> None: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("base URL must be an HTTP(S) origin without embedded credentials") + self.base_url = base_url.rstrip("/") + self.tenant_id = tenant_id + self.user_id = user_id + self.timeout = timeout + + def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: + request = Request( + f"{self.base_url}{path}", + method=method, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-Tenant-Id": self.tenant_id, + "X-User-Id": self.user_id, + }, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None, + ) + try: + with urlopen(request, timeout=self.timeout) as response: # nosec B310 - validated operator URL + return json.loads(response.read(10 * 1024 * 1024).decode("utf-8")) + except HTTPError as error: + detail = error.read(4096).decode("utf-8", errors="replace") + raise ApiError(f"HTTP {error.code}: {detail}") from error + except (URLError, TimeoutError) as error: + raise ApiError(f"request failed: {error}") from error + + +def import_tenant(client: ApiClient, sources: list[AuthoritativeLegalSource]) -> dict[str, int]: + counts = {"created": 0, "skipped": 0, "verified": 0, "duplicates": 0} + query = urlencode( + { + "keyword": "", + "knowledge_base_id": AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + "limit": 1000, + } + ) + existing_payload = client.request("GET", f"/api/v1/knowledge-documents/search?{query}") + existing = existing_payload if isinstance(existing_payload, list) else [] + for source in sources: + matching = [ + item + for item in existing + if str(item.get("reference") or "").split(maxsplit=1)[0] == source.source_id + ] + if matching: + if not any( + str(item.get("content") or "") == source.content + and str(item.get("reference") or "").startswith(source.reference) + for item in matching + ): + raise RuntimeError( + f"tenant {client.tenant_id} has conflicting content for {source.source_id}; refusing silent overwrite" + ) + counts["skipped"] += 1 + counts["duplicates"] += max(0, len(matching) - 1) + document = matching[0] + else: + document = client.request( + "POST", + "/api/v1/knowledge-documents", + { + "knowledge_base_id": AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + "title": source.title, + "source_type": "official_law", + "reference": f"{source.reference} 主题:{'、'.join(source.topics)}", + "content": source.content, + "index_status": "indexed", + }, + ) + if not isinstance(document, dict): + raise RuntimeError(f"source {source.source_id} create response is invalid") + existing.append(document) + counts["created"] += 1 + + document_id = quote(str(document.get("id") or ""), safe="") + if not document_id: + raise RuntimeError(f"source {source.source_id} is missing its document ID") + chunks_payload = client.request("GET", f"/api/v1/knowledge-documents/{document_id}/chunks") + chunks = chunks_payload if isinstance(chunks_payload, list) else [] + if not any( + str(item.get("reference") or "").split(maxsplit=1)[0] == source.source_id + for item in chunks + if isinstance(item, dict) + ): + raise RuntimeError(f"source {source.source_id} was stored but is not retrievable") + counts["verified"] += 1 + return counts + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--execute", action="store_true", help="Import sources; otherwise print the validated plan.") + parser.add_argument("--base-url", default="http://127.0.0.1:8101") + parser.add_argument("--tenant-id", action="append", dest="tenant_ids") + parser.add_argument("--user-id", default="ai-legal-source-governance") + parser.add_argument("--timeout-seconds", type=float, default=30) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + manifest = load_authoritative_legal_source_manifest() + tenant_ids = list(dict.fromkeys(args.tenant_ids or ["controlled-pilot-v1"])) + plan = { + "schema_version": manifest.schema_version, + "knowledge_base_id": manifest.knowledge_base_id, + "retrieved_on": manifest.retrieved_on, + "manifest_sha256": manifest.manifest_sha256, + "source_count": len(manifest.sources), + "tenant_ids": tenant_ids, + } + if not args.execute: + print(json.dumps({"mode": "plan", **plan}, ensure_ascii=False)) + return 0 + results: dict[str, dict[str, int]] = {} + for tenant_id in tenant_ids: + client = ApiClient( + args.base_url, + tenant_id=tenant_id, + user_id=args.user_id, + timeout=args.timeout_seconds, + ) + results[tenant_id] = import_tenant(client, manifest.sources) + print(json.dumps({"mode": "executed", **plan, "results": results}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/scripts/run-controlled-pilot-stratified-quality.py b/yuqei-infra/scripts/run-controlled-pilot-stratified-quality.py index 3e6b7bf9..4573803c 100644 --- a/yuqei-infra/scripts/run-controlled-pilot-stratified-quality.py +++ b/yuqei-infra/scripts/run-controlled-pilot-stratified-quality.py @@ -20,6 +20,7 @@ from urllib.request import Request, urlopen REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_OUTPUT_DIR = REPO_ROOT / ".runtime" / "controlled-pilot-stratified-quality" +DEFAULT_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1" TERMINAL_STATUSES = {"succeeded", "fallback", "failed", "awaiting_human", "resolved", "cancelled"} RETRYABLE_EXECUTION_STATUSES = {"fallback", "failed"} SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") @@ -145,6 +146,7 @@ def run_controlled_pilot( poll_seconds: float = 3, timeout_seconds: float = 4 * 60 * 60, retry_limit: int = 1, + knowledge_base_id: str = DEFAULT_KNOWLEDGE_BASE_ID, ) -> dict[str, Any]: if len(cases) < 100: raise ValueError("controlled pilot requires at least 100 stratified cases") @@ -166,6 +168,7 @@ def run_controlled_pilot( poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, retry_limit=retry_limit, + knowledge_base_id=knowledge_base_id, ) jobs.update(wave.pop("jobs")) capacity_waves.append(wave) @@ -181,6 +184,7 @@ def run_controlled_pilot( poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, retry_limit=retry_limit, + knowledge_base_id=knowledge_base_id, ) jobs.update(wave.pop("jobs")) print( @@ -211,6 +215,7 @@ def run_controlled_pilot( return { "schema_version": "controlled-pilot-stratified-quality-v1", "run_id": run_id, + "knowledge_base_id": knowledge_base_id, "generated_at": datetime.now(UTC).astimezone().isoformat(), "submitted_count": len(rows), "eligible_real_sample_count": len(eligible), @@ -243,6 +248,7 @@ def _run_wave( poll_seconds: float, timeout_seconds: float, retry_limit: int, + knowledge_base_id: str, ) -> dict[str, Any]: started = time.monotonic() jobs: dict[str, dict[str, Any]] = {} @@ -264,7 +270,12 @@ def _run_wave( try: job = client.post( "/api/v1/legal/qa/jobs", - {"question": case.question, "matter_context": case.matter_context, "retrieval_strategy": "hybrid"}, + { + "question": case.question, + "matter_context": case.matter_context, + "knowledge_base_ids": [knowledge_base_id], + "retrieval_strategy": "hybrid", + }, extra_headers=headers, ) return case, job, rejected @@ -412,6 +423,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--poll-seconds", type=float, default=3) parser.add_argument("--timeout-seconds", type=float, default=4 * 60 * 60) parser.add_argument("--retry-limit", type=int, default=1) + parser.add_argument("--knowledge-base-id", default=DEFAULT_KNOWLEDGE_BASE_ID) parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) return parser.parse_args() @@ -429,6 +441,7 @@ def main() -> int: client_factory, cases=STRATIFIED_CASES, run_id=args.run_id, quality_concurrency=args.quality_concurrency, poll_seconds=args.poll_seconds, timeout_seconds=args.timeout_seconds, retry_limit=args.retry_limit, + knowledge_base_id=args.knowledge_base_id, ) json_path, markdown_path = write_artifacts(report, args.output_dir) print(json.dumps({"passed": report["passed"], "json": str(json_path), "markdown": str(markdown_path)}, ensure_ascii=False)) diff --git a/yuqei-infra/scripts/run-delegated-ai-legal-review.py b/yuqei-infra/scripts/run-delegated-ai-legal-review.py index 70fcf0bd..caa338c6 100644 --- a/yuqei-infra/scripts/run-delegated-ai-legal-review.py +++ b/yuqei-infra/scripts/run-delegated-ai-legal-review.py @@ -12,6 +12,7 @@ import argparse import hashlib import json import os +import re import shutil import subprocess import time @@ -28,6 +29,19 @@ REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_OUTPUT_DIR = REPO_ROOT / ".runtime" / "delegated-ai-legal-review" DEFAULT_REVIEWER_ID = "ai-delegated-legal-review-gpt-5.6-sol" DEFAULT_MODEL = "gpt-5.6-sol" +AUTHORITATIVE_KNOWLEDGE_BASE_ID = "laws-cn-authoritative-v1" +AUTHORITATIVE_METADATA_FIELDS = ( + "url", + "issuing_authority", + "version_label", + "promulgated_on", + "effective_on", + "retrieved_on", + "content_sha256", + "source_manifest_sha256", +) +STABLE_SOURCE_ID_PATTERN = re.compile(r"\bLAW-[A-Z0-9]+(?:-[A-Z0-9]+)*\b") +SOURCE_BINDING_PATTERN = re.compile(r"\[来源\s*[::]\s*([^\]]+)\]", flags=re.IGNORECASE) NO_EVIDENCE_MARKERS = ( "未命中明确依据", "未命中明确法律依据", @@ -48,18 +62,13 @@ MATTER_CONTEXT_BY_CONTRACT_TYPE = { "equity": "交易涉及分期付款、先决条件、历史担保和交割后责任", "transport": "货物经过中转和分包运输,签收、货损及异常证据规则不清", } -OFFICIAL_SOURCE_CHECKS = ( - { - "title": "中华人民共和国民法典", - "references": ["第五百零九条", "第七百零三条", "第七百零七条"], - "url": "https://www.spp.gov.cn/zdgz/202006/t20200602_463886.shtml", - }, - { - "title": "中华人民共和国劳动合同法", - "references": ["第十九条", "第三十九条"], - "url": "https://rsj.sh.gov.cn/cmsres/89/897868aff05241d4b088ff3db7f1b142/007c2cffa946772c6c52fe4e582c1fe3.pdf", - }, -) +DETERMINISTIC_CITATION_POLICY = { + "knowledge_base_id": AUTHORITATIVE_KNOWLEDGE_BASE_ID, + "stable_source_id_prefix": "LAW-CN-", + "required_metadata_fields": list(AUTHORITATIVE_METADATA_FIELDS), + "required_source_status": "effective", + "answer_binding_format": "[来源: LAW-CN-...]", +} class ApiError(RuntimeError): @@ -142,7 +151,7 @@ def evaluate(args: argparse.Namespace) -> Path: if {item["job_id"] for item in decisions} != expected_ids or len(decisions) != len(jobs): raise RuntimeError("evaluator output does not match the review queue") artifact = { - "schema_version": "delegated-ai-legal-review-v1", + "schema_version": "delegated-ai-legal-review-v2", "created_at": datetime.now(UTC).astimezone().isoformat(), "tenant_id": args.tenant_id, "run_id": args.run_id, @@ -150,7 +159,7 @@ def evaluate(args: argparse.Namespace) -> Path: "reviewer_type": "ai", "reviewer_model": args.model, "authorization_basis": "Platform owner delegated the controlled-pilot legal-quality review to AI in the active task.", - "official_source_checks": OFFICIAL_SOURCE_CHECKS, + "deterministic_citation_policy": DETERMINISTIC_CITATION_POLICY, "decision_count": len(decisions), "summary": summarize(decisions), "decisions": decisions, @@ -302,10 +311,29 @@ def citation_assessment(job: dict[str, Any]) -> tuple[str, str]: if not citations: return "fail", "响应没有引用。" grounded = [item for item in citations if isinstance(item, dict) and citation_is_grounded(item)] - if not grounded: + if len(grounded) != len(citations): return "fail", "引用虽含法条标题和摘录,但没有文档、知识分块、稳定来源 ID 或 URL,无法追溯到本次回答的检索证据。" if any(marker in answer.casefold() for marker in NO_EVIDENCE_MARKERS): return "fail", "回答声明未命中明确依据,却同时返回引用,正文与引用链冲突。" + authoritative = [ + item for item in grounded + if item.get("knowledge_base_id") == AUTHORITATIVE_KNOWLEDGE_BASE_ID + ] + if authoritative: + if len(authoritative) != len(grounded): + return "fail", "正式法源回答混入了未纳入权威法源库的引用。" + if any(not authoritative_citation_metadata_complete(item) for item in authoritative): + return "fail", "正式法源缺少稳定来源 ID、发布机关、版本、生效日期、检索日期、官方 URL 或内容哈希。" + bound_source_ids = bound_authoritative_source_ids(answer) + unbound = sorted( + source_id + for item in authoritative + for source_id in authoritative_source_ids(item) + if source_id not in bound_source_ids + ) + if unbound: + return "fail", f"回答未绑定本次正式检索来源:{', '.join(unbound)}。" + return "pass", "正式法源具有完整来源元数据和双哈希,且每个稳定来源 ID 均在回答中显式绑定。" references = [str(item.get("reference") or "").strip() for item in grounded if isinstance(item, dict)] if references and not any(reference and reference in answer for reference in references): return "uncertain", "引用可追溯,但回答未明确建立法条与结论之间的对应关系。" @@ -319,6 +347,34 @@ def citation_is_grounded(citation: dict[str, Any]) -> bool: return isinstance(source_ids, list) and any(isinstance(item, str) and item.strip() for item in source_ids) +def authoritative_source_ids(citation: dict[str, Any]) -> list[str]: + source_ids = citation.get("source_ids") + if not isinstance(source_ids, list): + return [] + return [ + source_id + for source_id in source_ids + if isinstance(source_id, str) and source_id.startswith("LAW-CN-") + ] + + +def bound_authoritative_source_ids(answer: str) -> set[str]: + return { + source_id + for binding in SOURCE_BINDING_PATTERN.findall(answer) + for source_id in STABLE_SOURCE_ID_PATTERN.findall(binding.upper()) + } + + +def authoritative_citation_metadata_complete(citation: dict[str, Any]) -> bool: + if citation.get("source_status") != "effective" or not authoritative_source_ids(citation): + return False + return all( + isinstance(citation.get(field), str) and citation[field].strip() + for field in AUTHORITATIVE_METADATA_FIELDS + ) + + def review_input_digest(job: dict[str, Any]) -> str: result = job.get("result") if isinstance(job.get("result"), dict) else {} value = { diff --git a/yuqei-infra/tests/test_authoritative_legal_source_import.py b/yuqei-infra/tests/test_authoritative_legal_source_import.py new file mode 100644 index 00000000..b5a0a895 --- /dev/null +++ b/yuqei-infra/tests/test_authoritative_legal_source_import.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from urllib.parse import unquote + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = REPO_ROOT / "yuqei-infra" / "scripts" / "import-authoritative-cn-legal-sources.py" +SPEC = importlib.util.spec_from_file_location("authoritative_legal_source_import", SCRIPT_PATH) +assert SPEC and SPEC.loader +source_import = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = source_import +SPEC.loader.exec_module(source_import) + + +class FakeClient: + tenant_id = "tenant-test" + + def __init__(self, documents: list[dict]) -> None: + self.documents = documents + self.created = 0 + + def request(self, method: str, path: str, payload: dict | None = None): + if method == "GET" and path.startswith("/api/v1/knowledge-documents/search?"): + assert "keyword=" in path + assert "limit=1000" in path + return self.documents + if method == "POST" and path == "/api/v1/knowledge-documents": + assert payload is not None + self.created += 1 + document = {"id": f"doc-new-{self.created}", **payload} + self.documents.append(document) + return document + if method == "GET" and "/chunks" in path: + document_id = unquote(path.split("/")[-2]) + document = next(item for item in self.documents if item["id"] == document_id) + return [{"reference": document["reference"]}] + raise AssertionError(f"unexpected request: {method} {path}") + + +def _stored_document(source, *, document_id: str) -> dict: + return { + "id": document_id, + "knowledge_base_id": source_import.AUTHORITATIVE_LEGAL_KNOWLEDGE_BASE_ID, + "title": source.title, + "source_type": "official_law", + "reference": f"{source.reference} 主题:{'、'.join(source.topics)}", + "content": source.content, + "index_status": "indexed", + } + + +def test_import_uses_full_inventory_and_is_idempotent_for_compound_source_ids() -> None: + manifest = source_import.load_authoritative_legal_source_manifest() + source = next(item for item in manifest.sources if "-" in item.source_id.removeprefix("LAW-CN-")) + client = FakeClient([_stored_document(source, document_id="doc-existing")]) + + first = source_import.import_tenant(client, [source]) + second = source_import.import_tenant(client, [source]) + + assert first == {"created": 0, "skipped": 1, "verified": 1, "duplicates": 0} + assert second == first + assert client.created == 0 + + +def test_import_reports_duplicate_documents_without_creating_more() -> None: + source = source_import.load_authoritative_legal_source_manifest().sources[0] + client = FakeClient( + [ + _stored_document(source, document_id="doc-existing-1"), + _stored_document(source, document_id="doc-existing-2"), + ] + ) + + result = source_import.import_tenant(client, [source]) + + assert result == {"created": 0, "skipped": 1, "verified": 1, "duplicates": 1} + assert client.created == 0 diff --git a/yuqei-infra/tests/test_controlled_pilot_stratified_quality.py b/yuqei-infra/tests/test_controlled_pilot_stratified_quality.py index eecdfb3b..a0b0baf7 100644 --- a/yuqei-infra/tests/test_controlled_pilot_stratified_quality.py +++ b/yuqei-infra/tests/test_controlled_pilot_stratified_quality.py @@ -22,6 +22,7 @@ class FakeClient: def post(self, path: str, payload: dict, *, extra_headers: dict[str, str] | None = None) -> dict: if path.endswith("/retry"): return self.jobs[path.split("/")[-2]] + assert payload["knowledge_base_ids"] == [pilot.DEFAULT_KNOWLEDGE_BASE_ID] headers = extra_headers or {} case_id = headers["X-Evaluation-Case-Id"] review_required = headers["X-Evaluation-Risk-Level"] == "high" diff --git a/yuqei-infra/tests/test_delegated_ai_legal_review.py b/yuqei-infra/tests/test_delegated_ai_legal_review.py new file mode 100644 index 00000000..ae8dcbab --- /dev/null +++ b/yuqei-infra/tests/test_delegated_ai_legal_review.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "run-delegated-ai-legal-review.py" +SPEC = importlib.util.spec_from_file_location("delegated_ai_legal_review", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _authoritative_citation(source_id: str = "LAW-CN-CIVIL-0566") -> dict[str, object]: + return { + "title": "中华人民共和国民法典", + "reference": f"{source_id} 第五百六十六条 chunk-1", + "quote": "合同解除后,已经履行的,可以依法请求补救和赔偿损失。", + "url": "https://www.cac.gov.cn/example", + "document_id": "doc-civil-566", + "chunk_id": "chunk-civil-566", + "knowledge_base_id": "laws-cn-authoritative-v1", + "source_ids": [source_id], + "issuing_authority": "全国人民代表大会", + "version_label": "2020年公布现行文本", + "promulgated_on": "2020-05-28", + "effective_on": "2021-01-01", + "retrieved_on": "2026-08-26", + "source_status": "effective", + "content_sha256": "a" * 64, + "source_manifest_sha256": "b" * 64, + } + + +def _job(answer: str, citation: dict[str, object]) -> dict[str, object]: + return { + "result": { + "answer": answer, + "primary_sources": [citation], + } + } + + +def test_authoritative_citation_passes_with_complete_metadata_and_source_binding() -> None: + result, reason = MODULE.citation_assessment( + _job("解除后应完成结算和损失处理。[来源: LAW-CN-CIVIL-0566]", _authoritative_citation()) + ) + + assert result == "pass" + assert "稳定来源 ID" in reason + + +def test_authoritative_citation_fails_without_answer_binding() -> None: + result, reason = MODULE.citation_assessment( + _job("解除后应完成结算和损失处理。", _authoritative_citation()) + ) + + assert result == "fail" + assert "未绑定" in reason + + +def test_authoritative_citation_fails_when_provenance_is_incomplete() -> None: + citation = _authoritative_citation() + citation.pop("source_manifest_sha256") + + result, reason = MODULE.citation_assessment( + _job("解除后应完成结算和损失处理。[来源: LAW-CN-CIVIL-0566]", citation) + ) + + assert result == "fail" + assert "内容哈希" in reason + + +def test_authoritative_citation_accepts_second_id_in_a_shared_source_block() -> None: + citation = _authoritative_citation("LAW-CN-CIVIL-0567") + + result, _ = MODULE.citation_assessment( + _job( + "解除后应完成结算和清理。[来源: LAW-CN-CIVIL-0566, LAW-CN-CIVIL-0567]", + citation, + ) + ) + + assert result == "pass"