From 464579e48fe8ba681828bc03b68fc5d854be128c Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 21 May 2026 20:17:38 +0800 Subject: [PATCH] feat: close phase15 governance loops --- backend/app/api/clauses.py | 3 + backend/app/api/legal_change_impacts.py | 9 +- backend/app/api/prompt_assets.py | 4 + backend/app/core/config.py | 8 + .../legal_change_impact_repository.py | 9 + .../services/clause_recommendation_service.py | 270 ++++++++- .../services/legal_change_impact_service.py | 81 ++- .../app/services/legal_semantic_matcher.py | 96 ++++ backend/app/services/prompt_asset_service.py | 43 +- .../prompt_golden_sample_evaluator.py | 539 ++++++++++++++++++ .../app/tasks/legal_change_impact_worker.py | 116 ++++ .../test_clause_recommendation_service.py | 30 + backend/tests/test_clauses_api.py | 12 +- .../tests/test_legal_change_impact_service.py | 50 ++ .../tests/test_legal_change_impacts_api.py | 13 +- backend/tests/test_prompt_asset_service.py | 80 ++- backend/tests/test_prompt_assets_api.py | 48 +- docker-compose.server.yml | 37 ++ frontend/src/app/documents/[id]/edit/page.tsx | 33 +- .../src/app/documents/new/[type]/page.tsx | 42 +- .../src/app/legal-change-impacts/page.tsx | 56 +- frontend/src/app/prompt-assets/page.tsx | 68 ++- frontend/src/services/clause.ts | 9 + frontend/src/types/api.ts | 3 + ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 116 ++++ ...法律文书能力吸收开发方案-代码对齐检查-2026-05-21.md | 236 ++++++++ project-docs/README.md | 1 + scripts/run-legal-change-impact-worker.ps1 | 29 + 28 files changed, 1926 insertions(+), 115 deletions(-) create mode 100644 backend/app/services/legal_semantic_matcher.py create mode 100644 backend/app/services/prompt_golden_sample_evaluator.py create mode 100644 backend/app/tasks/legal_change_impact_worker.py create mode 100644 project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐检查-2026-05-21.md create mode 100644 scripts/run-legal-change-impact-worker.ps1 diff --git a/backend/app/api/clauses.py b/backend/app/api/clauses.py index 0b349827..0d86b372 100644 --- a/backend/app/api/clauses.py +++ b/backend/app/api/clauses.py @@ -77,7 +77,10 @@ def recommend_missing_clauses( "document_type_code": result.get("document_type_code"), "contract_type": payload.contract_type, "recommended_count": result.get("total"), + "recommendation_keys": (result.get("summary") or {}).get("recommendation_keys") or [], "missing_clause_keys": (result.get("summary") or {}).get("missing_clause_keys") or [], + "industry_practice_keys": (result.get("summary") or {}).get("industry_practice_keys") or [], + "industry_practice_count": (result.get("summary") or {}).get("industry_practice_count") or 0, "source_count": (result.get("summary") or {}).get("source_count") or 0, "text_length": len(payload.contract_text or ""), }, diff --git a/backend/app/api/legal_change_impacts.py b/backend/app/api/legal_change_impacts.py index 765f594e..f2b46e41 100644 --- a/backend/app/api/legal_change_impacts.py +++ b/backend/app/api/legal_change_impacts.py @@ -105,6 +105,8 @@ def scan_legal_change_impact( "article_id": payload.article_id, "change_type": result["event"]["change_type"], "impact_summary": result["event"]["impact_summary"], + "semantic_match_count": result["event"]["impact_summary"].get("semantic_match_count", 0), + "scan_strategy": result["event"]["impact_summary"].get("scan_strategy", []), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), @@ -127,7 +129,11 @@ def run_legal_change_event_scan( action="run_legal_change_event_scan", resource_type="legal_change_event", resource_id=event_id, - detail_json={"impact_summary": result["event"]["impact_summary"]}, + detail_json={ + "impact_summary": result["event"]["impact_summary"], + "semantic_match_count": result["event"]["impact_summary"].get("semantic_match_count", 0), + "scan_strategy": result["event"]["impact_summary"].get("scan_strategy", []), + }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) @@ -153,6 +159,7 @@ def run_pending_legal_change_scans( "requested_limit": result["requested_limit"], "completed_count": result["completed_count"], "failed_count": result["failed_count"], + "worker_compatible": True, }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), diff --git a/backend/app/api/prompt_assets.py b/backend/app/api/prompt_assets.py index 600a97c5..a426e740 100644 --- a/backend/app/api/prompt_assets.py +++ b/backend/app/api/prompt_assets.py @@ -178,6 +178,10 @@ def run_prompt_asset_evaluation( "evaluation_status": result["version"]["evaluation_status"], "evaluation_score": result["version"]["evaluation_score"], "suite_key": result["evaluation_report"].get("suite_key"), + "evaluation_mode": result["evaluation_report"].get("evaluation_mode"), + "sample_count": result["evaluation_report"].get("sample_count"), + "passed_samples": result["evaluation_report"].get("passed_samples"), + "failed_samples": result["evaluation_report"].get("failed_samples"), }, ) return success_response(result) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5d113a63..d84c9fd1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,6 +24,14 @@ class Settings(BaseSettings): shared_ai_platform_timeout_seconds: float = 30.0 shared_ai_platform_legal_review_enabled: bool = True shared_ai_platform_legal_review_fallback_to_local: bool = False + prompt_golden_eval_mode: str = "auto" + prompt_golden_eval_include_raw_model_response: bool = False + legal_change_impact_semantic_enabled: bool = True + legal_change_impact_semantic_threshold: float = 0.10 + legal_change_impact_worker_enabled: bool = True + legal_change_impact_worker_batch_size: int = 10 + legal_change_impact_worker_interval_seconds: int = 300 + legal_change_impact_worker_user_id: str = "system-legal-change-worker" secret_key: str = "change-me" access_token_expire_minutes: int = 120 database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/ai_contract_platform" diff --git a/backend/app/repositories/legal_change_impact_repository.py b/backend/app/repositories/legal_change_impact_repository.py index ce6a850d..4e5b7791 100644 --- a/backend/app/repositories/legal_change_impact_repository.py +++ b/backend/app/repositories/legal_change_impact_repository.py @@ -81,6 +81,15 @@ class LegalChangeImpactRepository: .all() ) + def list_pending_events_for_worker(self, limit: int = 10) -> list[LegalChangeEvent]: + return ( + self.db.query(LegalChangeEvent) + .filter(LegalChangeEvent.scan_status == "pending") + .order_by(LegalChangeEvent.created_at.asc()) + .limit(max(1, min(limit, 100))) + .all() + ) + def delete_impacts_by_event(self, tenant_id: str, event_id: str) -> int: deleted = ( self.db.query(LegalChangeImpact) diff --git a/backend/app/services/clause_recommendation_service.py b/backend/app/services/clause_recommendation_service.py index ac5c8618..5973eca5 100644 --- a/backend/app/services/clause_recommendation_service.py +++ b/backend/app/services/clause_recommendation_service.py @@ -498,6 +498,199 @@ DOCUMENT_TYPE_RULES["software_development"] = [ }, ] +INDUSTRY_PRACTICE_RULES: dict[str, list[dict[str, Any]]] = { + "service_general": [ + { + "clause_key": "change_order_confirmation", + "clause_title": "服务范围变更确认", + "clause_type": "industry_practice", + "priority": "medium", + "keywords": ["变更单", "服务范围变更", "change order", "需求变更"], + "anchor_keywords": ["服务范围", "交付物", "验收"], + "suggested_text": ( + "服务范围变更确认:服务内容、交付物、实施计划或费用口径发生变更的,双方应通过" + "书面补充协议、变更单或双方确认的电子流程确认后执行;未经确认的变更不当然构成" + "乙方新增义务或甲方付款义务。" + ), + "reason": "服务项目中范围变更高频发生,提前约定变更确认方式能减少口头需求和费用争议。", + "legal_basis": "无直接法条,仅为行业惯例;建议结合具体服务范围、审批流程和费用调整机制确认。", + "practice_basis": "服务类项目通常通过变更单、补充协议或可追溯电子确认固化新增需求、延期和费用调整。", + "source_rule": "LAW-CLAUSE-PRACTICE-SERVICE-CHANGE-ORDER", + "default_position": "建议插入在服务范围或交付物条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "通用服务", + "basis_type": "industry_practice_only", + }, + { + "clause_key": "service_progress_record", + "clause_title": "服务过程记录与阶段确认", + "clause_type": "industry_practice", + "priority": "low", + "keywords": ["服务记录", "周报", "月报", "阶段确认", "meeting minutes"], + "anchor_keywords": ["服务范围", "验收", "付款"], + "suggested_text": ( + "服务过程记录与阶段确认:乙方应按约定周期提交服务记录、会议纪要、阶段成果或" + "工作报告;甲方应在收到后合理期限内确认或提出书面异议,逾期未反馈的,双方可" + "按约定视为阶段事项已完成记录。" + ), + "reason": "持续服务合同往往缺少可验证过程证据,阶段记录可以支撑验收、付款和争议处理。", + "legal_basis": "无直接法条,仅为行业惯例;建议结合双方沟通机制和验收触发条件设置。", + "practice_basis": "咨询、运维、外包服务通常用周报、月报、会议纪要或阶段确认单沉淀履约证据。", + "source_rule": "LAW-CLAUSE-PRACTICE-SERVICE-PROGRESS-RECORD", + "default_position": "建议插入在服务范围或验收条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "持续服务", + "basis_type": "industry_practice_only", + }, + ], + "technical_service": [ + { + "clause_key": "technical_assumptions_boundary", + "clause_title": "技术前提与接口边界", + "clause_type": "industry_practice", + "priority": "medium", + "keywords": ["技术前提", "接口边界", "系统环境", "第三方接口", "technical assumptions"], + "anchor_keywords": ["服务范围", "交付物", "验收"], + "suggested_text": ( + "技术前提与接口边界:双方应在附件中列明系统环境、接口权限、第三方平台、数据格式" + "和测试条件等技术前提;因前提条件变化、第三方接口调整或甲方环境不满足要求导致" + "延期或返工的,双方应按变更流程处理。" + ), + "reason": "技术服务常受系统环境、接口和第三方平台影响,提前固化边界能降低履约争议。", + "legal_basis": "无直接法条,仅为行业惯例;建议结合项目技术附件和变更流程确认。", + "practice_basis": "技术服务项目通常用技术附件、接口清单、环境确认单和测试记录管理履约前提。", + "source_rule": "LAW-CLAUSE-PRACTICE-TECH-ASSUMPTIONS", + "default_position": "建议插入在服务范围或交付物条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "技术服务", + "basis_type": "industry_practice_only", + }, + ], + "software_development": [ + { + "clause_key": "scope_change_control", + "clause_title": "需求变更与版本范围控制", + "clause_type": "industry_practice", + "priority": "high", + "keywords": ["需求变更", "版本范围", "迭代范围", "需求确认", "scope change"], + "anchor_keywords": ["服务范围", "交付物", "验收", "维护支持"], + "suggested_text": ( + "需求变更与版本范围控制:软件功能、页面、接口、数据结构、第三方集成或验收标准" + "发生新增、删减或调整的,双方应通过需求变更单、迭代确认单或可追溯电子流程确认" + "范围、工期、费用和验收影响后实施。" + ), + "reason": "软件开发项目中需求变更最容易导致延期和费用争议,建议把变更流程写入合同正文或附件。", + "legal_basis": "无直接法条,仅为行业惯例;建议结合项目管理流程、需求清单和验收标准确认。", + "practice_basis": "软件开发行业通常用需求基线、迭代计划、变更单和版本验收清单控制范围蔓延。", + "source_rule": "LAW-CLAUSE-PRACTICE-SOFTWARE-SCOPE-CHANGE", + "default_position": "建议插入在服务范围、交付物或验收条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "软件开发", + "basis_type": "industry_practice_only", + }, + { + "clause_key": "open_source_third_party_components", + "clause_title": "开源及第三方组件清单", + "clause_type": "industry_practice", + "priority": "medium", + "keywords": ["开源组件", "第三方组件", "开源软件", "组件清单", "open source"], + "anchor_keywords": ["知识产权", "交付物", "维护支持"], + "suggested_text": ( + "开源及第三方组件清单:乙方交付的软件如使用开源软件、第三方 SDK、字体、模型、" + "插件或云服务,应向甲方提供组件清单、许可类型、使用范围及已知限制;涉及额外授权、" + "费用或合规义务的,应由双方另行确认承担方式。" + ), + "reason": "软件交付中第三方组件会影响授权、后续商用和安全维护,建议在合同中要求清单化披露。", + "legal_basis": "无直接法条,仅为行业惯例;如涉及著作权、商业许可或数据处理,应另行结合法律依据审查。", + "practice_basis": "软件交付通常要求 SBOM、第三方组件清单或开源许可披露,以便后续运维和合规审计。", + "source_rule": "LAW-CLAUSE-PRACTICE-SOFTWARE-THIRD-PARTY", + "default_position": "建议插入在知识产权或交付物条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "软件开发", + "basis_type": "industry_practice_only", + }, + ], + "labor_contract": [ + { + "clause_key": "employee_handbook_acknowledgement", + "clause_title": "规章制度告知与签收", + "clause_type": "industry_practice", + "priority": "medium", + "keywords": ["规章制度", "员工手册", "制度签收", "培训确认"], + "anchor_keywords": ["岗位", "劳动纪律", "解除", "试用期"], + "suggested_text": ( + "规章制度告知与签收:甲方依法制定并公示的员工手册、考勤、奖惩、信息安全、保密" + "和岗位管理制度作为劳动管理依据;乙方确认已通过书面、电子签收或培训记录知悉相关制度。" + ), + "reason": "劳动合同履行中,制度告知和签收记录是后续考核、奖惩和争议处理的重要证据。", + "legal_basis": "无直接法条,仅为行业惯例;涉及规章制度适用时仍需满足民主程序、公示告知和合法合理要求。", + "practice_basis": "企业人力资源管理通常通过员工手册签收、入职培训记录和制度更新通知沉淀管理证据。", + "source_rule": "LAW-CLAUSE-PRACTICE-LABOR-HANDBOOK", + "default_position": "建议插入在岗位职责、劳动纪律或解除终止条款附近。", + "recommendation_kind": "industry_practice", + "industry_context": "劳动人事", + "basis_type": "industry_practice_only", + }, + { + "clause_key": "handover_materials_record", + "clause_title": "离职交接清单", + "clause_type": "industry_practice", + "priority": "low", + "keywords": ["离职交接", "交接清单", "资料返还", "账号移交"], + "anchor_keywords": ["解除", "终止", "保密", "竞业限制"], + "suggested_text": ( + "离职交接清单:劳动合同解除或终止时,乙方应按甲方合理要求办理工作资料、设备、" + "账号权限、客户信息和未结事项交接;双方可通过交接清单、系统记录或书面确认留存证据。" + ), + "reason": "离职交接是劳动争议和商业资料保护的高频风险点,清单化处理更便于执行。", + "legal_basis": "无直接法条,仅为行业惯例;涉及工资结算、经济补偿或保密义务时应另行依法处理。", + "practice_basis": "企业通常用离职交接单、资产归还记录和账号权限回收记录完成离职闭环。", + "source_rule": "LAW-CLAUSE-PRACTICE-LABOR-HANDOVER", + "default_position": "建议插入在解除终止或保密条款之后。", + "recommendation_kind": "industry_practice", + "industry_context": "劳动人事", + "basis_type": "industry_practice_only", + }, + ], + "nda_commercial": [ + { + "clause_key": "need_to_know_access", + "clause_title": "最小知悉范围", + "clause_type": "industry_practice", + "priority": "medium", + "keywords": ["最小知悉", "need to know", "访问权限", "授权人员"], + "anchor_keywords": ["保密义务", "保密信息", "披露"], + "suggested_text": ( + "最小知悉范围:接收方仅可在实现合作目的所必需的范围内向其员工、顾问或关联人员" + "披露保密信息,并应确保接触人员承担不低于本协议约定的保密义务。" + ), + "reason": "保密协议通常需要控制内部流转范围,避免保密信息因过度共享而失控。", + "legal_basis": "无直接法条,仅为行业惯例;涉及商业秘密保护时应结合保密措施和访问控制证据确认。", + "practice_basis": "商业合作中常按 need-to-know 原则控制保密信息访问权限并留存授权记录。", + "source_rule": "LAW-CLAUSE-PRACTICE-NDA-NEED-TO-KNOW", + "default_position": "建议插入在保密义务或保密信息定义之后。", + "recommendation_kind": "industry_practice", + "industry_context": "保密合作", + "basis_type": "industry_practice_only", + }, + ], +} + +SERVICE_GENERAL_PRACTICE_RULES = INDUSTRY_PRACTICE_RULES["service_general"] +TECHNICAL_SERVICE_BASE_PRACTICE_RULES = INDUSTRY_PRACTICE_RULES["technical_service"] +SOFTWARE_DEVELOPMENT_BASE_PRACTICE_RULES = INDUSTRY_PRACTICE_RULES["software_development"] +TECHNICAL_SERVICE_PRACTICE_RULES = [ + *TECHNICAL_SERVICE_BASE_PRACTICE_RULES, + *SERVICE_GENERAL_PRACTICE_RULES, +] +SOFTWARE_DEVELOPMENT_PRACTICE_RULES = [ + *SOFTWARE_DEVELOPMENT_BASE_PRACTICE_RULES, + *TECHNICAL_SERVICE_BASE_PRACTICE_RULES, + *SERVICE_GENERAL_PRACTICE_RULES, +] +INDUSTRY_PRACTICE_RULES["technical_service"] = TECHNICAL_SERVICE_PRACTICE_RULES +INDUSTRY_PRACTICE_RULES["software_development"] = SOFTWARE_DEVELOPMENT_PRACTICE_RULES + class ClauseRecommendationService: def __init__(self, db: Optional[Session], legal_article_service: Optional[LegalArticleService] = None): @@ -531,6 +724,7 @@ class ClauseRecommendationService: max_count = self._normalize_max_items(max_items) detected_keys: list[str] = [] + detected_industry_practice_keys: list[str] = [] recommendations: list[dict[str, Any]] = [] for rule in rules: if self._rule_is_present(normalized_text, rule): @@ -548,7 +742,35 @@ class ClauseRecommendationService: if len(recommendations) >= max_count: break - missing_keys = [item["clause_key"] for item in recommendations] + if len(recommendations) < max_count: + for rule in self._industry_practice_rules_for_document_type(normalized_document_type): + if self._rule_is_present(normalized_text, rule): + detected_industry_practice_keys.append(rule["clause_key"]) + continue + legal_article_refs = self._search_legal_articles(current_user, rule) + recommendations.append( + self._build_recommendation_item( + contract_text=normalized_text, + document_type_code=normalized_document_type, + rule=rule, + legal_article_refs=legal_article_refs, + ) + ) + if len(recommendations) >= max_count: + break + + recommendation_keys = [item["clause_key"] for item in recommendations] + required_missing_keys = [ + item["clause_key"] + for item in recommendations + if item.get("recommendation_kind") != "industry_practice" + ] + missing_keys = required_missing_keys + industry_practice_keys = [ + item["clause_key"] + for item in recommendations + if item.get("recommendation_kind") == "industry_practice" + ] result = { "contract_id": contract_id, "document_type_code": normalized_document_type, @@ -558,16 +780,22 @@ class ClauseRecommendationService: "total": len(recommendations), "summary": { "recommended_count": len(recommendations), + "recommendation_keys": recommendation_keys, "detected_clause_keys": detected_keys, "missing_clause_keys": missing_keys, + "required_missing_clause_keys": required_missing_keys, + "industry_practice_keys": industry_practice_keys, + "detected_industry_practice_keys": detected_industry_practice_keys, + "industry_practice_count": len(industry_practice_keys), "source_count": sum(len(item.get("legal_article_refs") or []) for item in recommendations), - "strategy": "rule_based_required_clause_gap", + "strategy": "rule_based_required_clause_gap_and_industry_practice", }, "sources": [ "document_type_required_clause_list", "standard_clause_library", "legal_articles", "review_rules", + "industry_practice_rules", ], } self.prompt_runtime_service.record_run_link( @@ -581,7 +809,10 @@ class ClauseRecommendationService: "document_type_code": normalized_document_type, "contract_type": contract_type, "recommended_count": len(recommendations), + "recommendation_keys": recommendation_keys, "missing_clause_keys": missing_keys, + "required_missing_clause_keys": required_missing_keys, + "industry_practice_keys": industry_practice_keys, "text_length": len(normalized_text), }, ) @@ -705,20 +936,19 @@ class ClauseRecommendationService: "clause_key": rule["clause_key"], "clause_title": rule["clause_title"], "clause_type": rule["clause_type"], + "recommendation_kind": rule.get("recommendation_kind", "missing_clause"), + "basis_type": rule.get("basis_type") or ("legal_basis" if legal_article_refs else "rule_fallback"), + "industry_context": rule.get("industry_context"), "priority": rule.get("priority", "medium"), "confidence": "high" if legal_article_refs else "medium", "insert_position": insertion_position, "suggested_text": rule["suggested_text"], "reason": rule["reason"], "legal_basis": cls._build_legal_basis(rule, legal_article_refs), + "practice_basis": rule.get("practice_basis"), "legal_article_refs": legal_article_refs, "source_rule": rule["source_rule"], - "source_types": [ - "document_type_required_clause_list", - "standard_clause_library", - "legal_articles" if legal_article_refs else "legal_basis_fallback", - "review_rules", - ], + "source_types": cls._build_recommendation_source_types(rule, legal_article_refs), "detected_keywords": [], } @@ -742,6 +972,24 @@ class ClauseRecommendationService: return str(rule.get("legal_basis") or "") return f"{rule.get('legal_basis') or '建议结合法条来源核对。'} 关联依据:{';'.join(labels)}。" + @staticmethod + def _build_recommendation_source_types(rule: dict[str, Any], legal_article_refs: list[dict[str, Any]]) -> list[str]: + if rule.get("recommendation_kind") == "industry_practice": + source_types = [ + "industry_practice_rules", + "standard_clause_library", + "legal_articles" if legal_article_refs else "industry_practice_fallback", + ] + if rule.get("basis_type") == "industry_practice_only": + source_types.append("no_direct_legal_article") + return source_types + return [ + "document_type_required_clause_list", + "standard_clause_library", + "legal_articles" if legal_article_refs else "legal_basis_fallback", + "review_rules", + ] + @classmethod def _resolve_insertion_position(cls, contract_text: str, rule: dict[str, Any]) -> dict[str, Any]: anchor = cls._find_anchor_line(contract_text, rule.get("anchor_keywords") or []) @@ -989,6 +1237,8 @@ class ClauseRecommendationService: def _search_legal_articles(self, current_user: dict[str, Any], rule: dict[str, Any]) -> list[dict[str, Any]]: if self.legal_article_service is None: return [] + if rule.get("basis_type") == "industry_practice_only" and not str(rule.get("legal_basis_query") or "").strip(): + return [] query = str(rule.get("legal_basis_query") or rule.get("clause_title") or "").strip() if not query: return [] @@ -1024,6 +1274,10 @@ class ClauseRecommendationService: return rules return GENERAL_RULES + @staticmethod + def _industry_practice_rules_for_document_type(document_type_code: str) -> list[dict[str, Any]]: + return INDUSTRY_PRACTICE_RULES.get(document_type_code, []) + @staticmethod def _normalize_document_type(document_type_code: Optional[str], contract_type: Optional[str]) -> str: raw = (document_type_code or contract_type or "general").strip().lower().replace("-", "_") diff --git a/backend/app/services/legal_change_impact_service.py b/backend/app/services/legal_change_impact_service.py index 88a6e14d..8e9a2b2d 100644 --- a/backend/app/services/legal_change_impact_service.py +++ b/backend/app/services/legal_change_impact_service.py @@ -8,6 +8,7 @@ from typing import Any, Iterable, Optional from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.config import settings from app.models.contract_template import ContractTemplate from app.models.generated_contract import GeneratedContract from app.models.legal_article import LegalArticle @@ -16,6 +17,7 @@ from app.models.legal_change_impact import LegalChangeImpact from app.models.review_rule import ReviewRule from app.repositories.legal_article_repository import LegalArticleRepository from app.repositories.legal_change_impact_repository import LegalChangeImpactRepository +from app.services.legal_semantic_matcher import LegalSemanticMatch, LegalSemanticMatcher VALID_CHANGE_TYPES = {"created", "updated", "repealed", "effective_changed"} @@ -27,6 +29,7 @@ class LegalChangeImpactService: self.db = db self.repository = LegalChangeImpactRepository(db) self.legal_article_repository = LegalArticleRepository(db) + self.semantic_matcher = LegalSemanticMatcher(threshold=settings.legal_change_impact_semantic_threshold) def list_events( self, @@ -219,6 +222,15 @@ class LegalChangeImpactService: direct_terms = self._unique_terms([article.source_id, article.source_title, article.article_no]) tag_terms = self._unique_terms(article.tags_json or []) article_keywords = self._extract_article_keywords(article.article_text) + article_semantic_text = self._join_text( + event.change_summary, + article.source_title, + article.article_no, + article.chapter_title, + article.section_title, + article.article_text, + article.tags_json, + ) impacts: list[LegalChangeImpact] = [] templates = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id).all() @@ -237,7 +249,8 @@ class LegalChangeImpactService: matched_direct = self._matched_terms(text, direct_terms) matched_tags = self._matched_terms(text, tag_terms) matched_keywords = self._matched_terms(text, article_keywords[:6]) - if matched_direct or matched_tags or matched_keywords: + semantic_match = self._semantic_match(article_semantic_text, text) + if matched_direct or matched_tags or matched_keywords or semantic_match.matched: impact_level = "immediate_update" if matched_direct else "reference_only" impacts.append( self._new_impact( @@ -247,11 +260,14 @@ class LegalChangeImpactService: target_id=template.id, target_name=template.name, target_version_label=template.current_version_label, - match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), - match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords, *semantic_match.shared_terms]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords, semantic_match), impact_level=impact_level, suggestion=self._build_suggestion("contract_template", impact_level, article), - evidence={"template_code": template.template_code, "document_type_code": template.document_type_code}, + evidence=self._build_evidence( + {"template_code": template.template_code, "document_type_code": template.document_type_code}, + semantic_match, + ), ) ) @@ -261,7 +277,8 @@ class LegalChangeImpactService: matched_direct = self._matched_terms(text, direct_terms) matched_tags = self._matched_terms(text, tag_terms) matched_keywords = self._matched_terms(text, article_keywords[:8]) - if matched_direct or matched_tags or matched_keywords: + semantic_match = self._semantic_match(article_semantic_text, text) + if matched_direct or matched_tags or matched_keywords or semantic_match.matched: impacts.append( self._new_impact( tenant_id, @@ -270,11 +287,11 @@ class LegalChangeImpactService: target_id=rule.id, target_name=rule.name, target_version_label=rule.current_version_label, - match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), - match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords, *semantic_match.shared_terms]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords, semantic_match), impact_level="immediate_update", suggestion=self._build_suggestion("review_rule", "immediate_update", article), - evidence={"rule_code": rule.rule_code, "risk_level": rule.risk_level}, + evidence=self._build_evidence({"rule_code": rule.rule_code, "risk_level": rule.risk_level}, semantic_match), ) ) @@ -293,7 +310,8 @@ class LegalChangeImpactService: matched_direct = self._matched_terms(text, direct_terms) matched_tags = self._matched_terms(text, tag_terms) matched_keywords = self._matched_terms(text, article_keywords[:6]) - if matched_direct or matched_tags or matched_keywords: + semantic_match = self._semantic_match(article_semantic_text, text) + if matched_direct or matched_tags or matched_keywords or semantic_match.matched: impact_level = "next_renewal_update" if matched_direct else "reference_only" impacts.append( self._new_impact( @@ -303,11 +321,14 @@ class LegalChangeImpactService: target_id=contract.id, target_name=contract.file_name, target_version_label=None, - match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), - match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords, *semantic_match.shared_terms]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords, semantic_match), impact_level=impact_level, suggestion=self._build_suggestion("generated_contract", impact_level, article), - evidence={"template_id": contract.template_id, "document_type_code": contract.document_type_code}, + evidence=self._build_evidence( + {"template_id": contract.template_id, "document_type_code": contract.document_type_code}, + semantic_match, + ), ) ) @@ -351,18 +372,34 @@ class LegalChangeImpactService: def _build_impact_summary(impacts: list[LegalChangeImpact]) -> dict[str, Any]: by_level: dict[str, int] = {} by_target_type: dict[str, int] = {} + by_match_reason: dict[str, int] = {} + semantic_match_count = 0 for impact in impacts: by_level[impact.impact_level] = by_level.get(impact.impact_level, 0) + 1 by_target_type[impact.target_type] = by_target_type.get(impact.target_type, 0) + 1 + for reason in str(impact.match_reason or "").split(","): + if not reason: + continue + by_match_reason[reason] = by_match_reason.get(reason, 0) + 1 + if reason == "semantic_vector_match": + semantic_match_count += 1 return { "total": len(impacts), "by_level": by_level, "by_target_type": by_target_type, + "by_match_reason": by_match_reason, "blocking_count": by_level.get("immediate_update", 0), + "semantic_match_count": semantic_match_count, + "scan_strategy": ["direct_reference", "tag_match", "keyword_match", "semantic_vector_match"], } @staticmethod - def _build_match_reason(direct_terms: list[str], tag_terms: list[str], keyword_terms: list[str]) -> str: + def _build_match_reason( + direct_terms: list[str], + tag_terms: list[str], + keyword_terms: list[str], + semantic_match: Optional[LegalSemanticMatch] = None, + ) -> str: reasons: list[str] = [] if direct_terms: reasons.append("direct_legal_reference") @@ -370,8 +407,26 @@ class LegalChangeImpactService: reasons.append("tag_match") if keyword_terms: reasons.append("keyword_match") + if semantic_match and semantic_match.matched: + reasons.append("semantic_vector_match") return ",".join(reasons) or "unknown" + def _semantic_match(self, source_text: str, target_text: str) -> LegalSemanticMatch: + if not settings.legal_change_impact_semantic_enabled: + return LegalSemanticMatch(False, 0.0, [], []) + return self.semantic_matcher.match(source_text, target_text) + + @staticmethod + def _build_evidence(base: dict[str, Any], semantic_match: LegalSemanticMatch) -> dict[str, Any]: + evidence = dict(base) + if semantic_match.matched: + evidence["semantic_vector"] = { + "score": semantic_match.score, + "shared_terms": semantic_match.shared_terms, + "shared_concepts": semantic_match.shared_concepts, + } + return evidence + @staticmethod def _build_suggestion(target_type: str, impact_level: str, article: LegalArticle) -> str: article_label = f"{article.source_title}{article.article_no}" diff --git a/backend/app/services/legal_semantic_matcher.py b/backend/app/services/legal_semantic_matcher.py new file mode 100644 index 00000000..baddbc3b --- /dev/null +++ b/backend/app/services/legal_semantic_matcher.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import math +import re +from collections import Counter +from dataclasses import dataclass +from typing import Iterable + + +@dataclass(frozen=True) +class LegalSemanticMatch: + matched: bool + score: float + shared_terms: list[str] + shared_concepts: list[str] + + +class LegalSemanticMatcher: + """Small deterministic vector matcher for legal-impact scanning. + + This is intentionally local and dependency-free. It gives Phase15-G a real + vector-style semantic scan path without requiring a remote embedding service. + """ + + LEGAL_CONCEPT_ALIASES: dict[str, tuple[str, ...]] = { + "breach_liability": ("违约", "违约金", "违约责任", "未按约", "未履行", "迟延履行", "逾期交付", "逾期付款", "赔偿", "损失赔偿"), + "payment": ("价款", "付款", "支付", "结算", "费用", "款项", "收款", "发票"), + "termination": ("解除", "终止", "撤销", "合同解除", "提前终止", "退还"), + "confidentiality": ("保密", "商业秘密", "信息披露", "泄露", "保密义务"), + "intellectual_property": ("知识产权", "著作权", "专利", "商标", "许可", "侵权"), + "labor": ("劳动", "用人单位", "劳动者", "工资", "加班", "社保", "劳动合同"), + "data_privacy": ("个人信息", "数据", "隐私", "处理", "委托处理", "信息安全"), + "dispute_resolution": ("争议", "仲裁", "诉讼", "管辖", "法院", "调解"), + "delivery_acceptance": ("交付", "验收", "质量", "瑕疵", "整改", "交付物"), + } + + def __init__(self, *, threshold: float = 0.16): + self.threshold = max(0.0, min(1.0, threshold)) + + def match(self, source_text: str, target_text: str) -> LegalSemanticMatch: + source_vector = self.vectorize(source_text) + target_vector = self.vectorize(target_text) + if not source_vector or not target_vector: + return LegalSemanticMatch(False, 0.0, [], []) + + score = self._cosine(source_vector, target_vector) + shared = [term for term in source_vector if term in target_vector] + shared_terms = [term for term in shared if not term.startswith("concept:")][:12] + shared_concepts = [term.replace("concept:", "", 1) for term in shared if term.startswith("concept:")] + return LegalSemanticMatch(score >= self.threshold, round(score, 4), shared_terms, shared_concepts) + + def vectorize(self, text: str) -> Counter[str]: + normalized = self._normalize(text) + vector: Counter[str] = Counter() + if not normalized: + return vector + + for token in self._word_tokens(normalized): + vector[token] += 2.0 + for token in self._han_ngrams(normalized, n=2): + vector[token] += 0.35 + for token in self._han_ngrams(normalized, n=3): + vector[token] += 0.5 + for concept, aliases in self.LEGAL_CONCEPT_ALIASES.items(): + matched_aliases = [alias for alias in aliases if alias in normalized] + if matched_aliases: + vector[f"concept:{concept}"] += 3.0 + for alias in matched_aliases: + vector[alias] += 1.5 + return vector + + @staticmethod + def _normalize(text: str) -> str: + return re.sub(r"\s+", " ", str(text or "").lower()).strip() + + @staticmethod + def _word_tokens(text: str) -> Iterable[str]: + for token in re.findall(r"[a-z0-9_]{2,}|[\u4e00-\u9fff]{2,}", text): + if len(token) <= 64: + yield token + + @staticmethod + def _han_ngrams(text: str, *, n: int) -> Iterable[str]: + for chunk in re.findall(r"[\u4e00-\u9fff]{%d,}" % n, text): + for index in range(0, len(chunk) - n + 1): + yield chunk[index : index + n] + + @staticmethod + def _cosine(left: Counter[str], right: Counter[str]) -> float: + shared = set(left).intersection(right) + numerator = sum(left[key] * right[key] for key in shared) + left_norm = math.sqrt(sum(value * value for value in left.values())) + right_norm = math.sqrt(sum(value * value for value in right.values())) + if left_norm == 0 or right_norm == 0: + return 0.0 + return numerator / (left_norm * right_norm) diff --git a/backend/app/services/prompt_asset_service.py b/backend/app/services/prompt_asset_service.py index 7483e9c7..88cbc7cc 100644 --- a/backend/app/services/prompt_asset_service.py +++ b/backend/app/services/prompt_asset_service.py @@ -14,6 +14,7 @@ from app.repositories.prompt_asset_evaluation_run_repository import PromptAssetE from app.repositories.prompt_asset_repository import PromptAssetRepository from app.repositories.prompt_asset_run_link_repository import PromptAssetRunLinkRepository from app.repositories.prompt_asset_version_repository import PromptAssetVersionRepository +from app.services.prompt_golden_sample_evaluator import PromptGoldenSampleEvaluator class PromptAssetService: @@ -42,6 +43,7 @@ class PromptAssetService: self.version_repository = PromptAssetVersionRepository(db) self.run_link_repository = PromptAssetRunLinkRepository(db) self.evaluation_run_repository = PromptAssetEvaluationRunRepository(db) + self.golden_sample_evaluator = PromptGoldenSampleEvaluator() def list_prompt_assets( self, @@ -214,7 +216,7 @@ class PromptAssetService: created_at=now, ) ) - report = self._build_prompt_evaluation_report(asset, version) + report = self._build_prompt_evaluation_report(current_user, asset, version) version.evaluation_report_json = report version.evaluation_score = float(report["quality_score"]) @@ -222,6 +224,7 @@ class PromptAssetService: version = self.version_repository.update(version) evaluation_run.status = version.evaluation_status + evaluation_run.suite_key = str(report.get("suite_key") or evaluation_run.suite_key) evaluation_run.sample_count = int(report.get("sample_count") or 0) evaluation_run.passed_samples = int(report.get("passed_samples") or 0) evaluation_run.failed_samples = int(report.get("failed_samples") or 0) @@ -474,38 +477,14 @@ class PromptAssetService: asset.latest_evaluation_score = version.evaluation_score asset.latest_evaluation_report_json = version.evaluation_report_json - def _build_prompt_evaluation_report(self, asset: PromptAsset, version: PromptAssetVersion) -> dict[str, Any]: - suite = self.PROMPT_GOLDEN_SUITES.get(asset.prompt_type, {"suite_key": "custom_prompt_suite", "sample_count": 1}) - sample_count = int(suite["sample_count"]) + def _build_prompt_evaluation_report( + self, + current_user: Dict[str, Any], + asset: PromptAsset, + version: PromptAssetVersion, + ) -> dict[str, Any]: checks = self._build_prompt_quality_checks(asset, version) - blocking_failures = [item for item in checks if item["required"] and item["status"] != "pass"] - warning_failures = [item for item in checks if not item["required"] and item["status"] != "pass"] - passed = not blocking_failures - quality_score = max(0.0, 1.0 - (0.25 * len(blocking_failures)) - (0.05 * len(warning_failures))) - if not passed: - quality_score = min(quality_score, 0.79) - passed_samples = sample_count if passed else max(0, sample_count - len(blocking_failures)) - pass_rate = 1.0 if sample_count == 0 else round(passed_samples / sample_count, 4) - return { - "schema_version": "phase15-prompt-evaluation-report.v1", - "evaluator": "prompt_asset_api_static_gate", - "prompt_asset_id": asset.id, - "prompt_asset_version_id": version.id, - "prompt_key": asset.prompt_key, - "prompt_type": asset.prompt_type, - "version_label": version.version_label, - "suite_key": suite["suite_key"], - "sample_count": sample_count, - "passed_samples": passed_samples, - "failed_samples": sample_count - passed_samples, - "pass_rate": pass_rate, - "quality_score": round(quality_score, 4), - "passed": passed, - "failed_items": [item for item in checks if item["status"] != "pass"], - "checks": checks, - "evaluated_at": self._now().isoformat(), - "source": "prompt_assets.evaluate_current_version", - } + return self.golden_sample_evaluator.evaluate_prompt_asset(current_user, asset, version, static_checks=checks) def _suite_key_for_prompt_type(self, prompt_type: str) -> str: return str(self.PROMPT_GOLDEN_SUITES.get(prompt_type, {"suite_key": "custom_prompt_suite"})["suite_key"]) diff --git a/backend/app/services/prompt_golden_sample_evaluator.py b/backend/app/services/prompt_golden_sample_evaluator.py new file mode 100644 index 00000000..6f28a718 --- /dev/null +++ b/backend/app/services/prompt_golden_sample_evaluator.py @@ -0,0 +1,539 @@ +from __future__ import annotations + +import csv +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional + +from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient +from app.core.config import settings +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_version import PromptAssetVersion + + +class PromptGoldenSampleEvaluator: + """Run Prompt assets against Phase13/Phase15 golden samples.""" + + SUITE_CONFIG: dict[str, dict[str, Any]] = { + "contract_generation": { + "suite_key": "phase13_generation", + "sample_family": "phase13_generation", + "min_samples": 8, + }, + "contract_review": { + "suite_key": "phase13_review", + "sample_family": "phase13_review", + "min_samples": 8, + }, + "legal_chat": { + "suite_key": "phase13_kb_qa", + "sample_family": "phase13_kb_qa", + "min_samples": 10, + }, + "clause_recommendation": { + "suite_key": "phase13_clause_generation", + "sample_family": "phase13_clause_generation", + "min_samples": 6, + }, + "wording_rewrite": { + "suite_key": "phase15_wording_rewrite", + "sample_family": "phase15_wording_rewrite", + "min_samples": 3, + }, + "regulation_impact": { + "suite_key": "future_regulation_impact", + "sample_family": "future_regulation_impact", + "min_samples": 0, + }, + } + + def __init__( + self, + *, + repo_root: Optional[Path | str] = None, + llm_client: Optional[Any] = None, + evaluation_mode: Optional[str] = None, + include_raw_model_response: Optional[bool] = None, + ): + self.repo_root = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[3] + self.llm_client = llm_client + self.evaluation_mode = self._normalize_mode(evaluation_mode or settings.prompt_golden_eval_mode) + self.include_raw_model_response = ( + settings.prompt_golden_eval_include_raw_model_response + if include_raw_model_response is None + else include_raw_model_response + ) + + def evaluate_prompt_asset( + self, + current_user: dict[str, Any], + asset: PromptAsset, + version: PromptAssetVersion, + *, + static_checks: list[dict[str, Any]], + ) -> dict[str, Any]: + suite = self.SUITE_CONFIG.get(asset.prompt_type, self.SUITE_CONFIG["regulation_impact"]) + samples = self.load_samples(asset.prompt_type) + llm_client = self._resolve_llm_client() + use_llm = llm_client is not None and self.evaluation_mode in {"auto", "llm"} + strict_llm = self.evaluation_mode == "llm" + sample_results: list[dict[str, Any]] = [] + + for sample in samples: + if use_llm: + sample_results.append( + self._evaluate_sample_with_llm( + current_user, + asset, + version, + sample, + llm_client=llm_client, + fallback_to_local=not strict_llm, + ) + ) + elif strict_llm: + sample_results.append( + self._failed_sample_result( + sample, + issue="llm_not_configured", + detail="Shared AI platform is not configured; real per-sample LLM evaluation cannot run.", + ) + ) + else: + sample_results.append(self._evaluate_sample_locally(asset, version, sample, static_checks)) + + required_static_failures = [item for item in static_checks if item.get("required") and item.get("status") != "pass"] + warning_static_failures = [item for item in static_checks if not item.get("required") and item.get("status") != "pass"] + passed_samples = sum(1 for item in sample_results if item.get("passed") is True) + sample_count = len(sample_results) + min_samples = int(suite.get("min_samples") or 0) + suite_issues = [] + if sample_count < min_samples: + suite_issues.append( + { + "key": "sample_count_below_minimum", + "label": "Golden sample count is below the required minimum", + "required": True, + "status": "fail", + "detail": f"sample_count={sample_count}; min_samples={min_samples}", + } + ) + + sample_failures = [item for item in sample_results if item.get("passed") is not True] + passed = not required_static_failures and not suite_issues and not sample_failures + pass_rate = 1.0 if sample_count == 0 and min_samples == 0 else round(passed_samples / sample_count, 4) if sample_count else 0.0 + sample_score = ( + sum(float(item.get("score") or 0) for item in sample_results) / sample_count + if sample_count + else (1.0 if min_samples == 0 else 0.0) + ) + quality_score = sample_score - (0.1 * len(required_static_failures)) - (0.03 * len(warning_static_failures)) - (0.15 * len(suite_issues)) + if not passed: + quality_score = min(quality_score, 0.79) + quality_score = max(0.0, min(1.0, quality_score)) + failed_items = [ + *suite_issues, + *[item for item in static_checks if item.get("status") != "pass"], + *[ + { + "key": f"sample_failed:{item.get('sample_id')}", + "label": f"Golden sample failed: {item.get('sample_id')}", + "required": True, + "status": "fail", + "detail": "; ".join(str(issue) for issue in item.get("issues") or [])[:500], + } + for item in sample_failures + ], + ] + return { + "schema_version": "phase15-prompt-evaluation-report.v2", + "phase": "Phase15-F", + "evaluator": "prompt_asset_api_llm_golden_sample_gate", + "evaluation_mode": "llm" if use_llm else "local_fallback" if not strict_llm else "llm_required_not_configured", + "prompt_asset_id": asset.id, + "prompt_asset_version_id": version.id, + "prompt_key": asset.prompt_key, + "prompt_type": asset.prompt_type, + "version_label": version.version_label, + "suite_key": suite["suite_key"], + "sample_family": suite["sample_family"], + "min_samples": min_samples, + "sample_count": sample_count, + "passed_samples": passed_samples, + "failed_samples": sample_count - passed_samples, + "pass_rate": pass_rate, + "quality_score": round(quality_score, 4), + "passed": passed, + "model_invocation": { + "configured": bool(use_llm), + "strict_llm": strict_llm, + "provider": "shared-ai-platform" if use_llm else None, + "mode": "llm" if use_llm else self.evaluation_mode, + "sample_invocation_count": sum(1 for item in sample_results if item.get("evaluator_mode") == "llm"), + }, + "sample_results": sample_results, + "failed_items": failed_items, + "checks": static_checks, + "evaluated_at": datetime.now(timezone.utc).isoformat(), + "source": "prompt_assets.evaluate_current_version.golden_samples", + } + + def load_samples(self, prompt_type: str) -> list[dict[str, Any]]: + if prompt_type == "contract_generation": + return [ + self._generation_sample(row, sample_family="phase13_generation") + for row in self._read_phase13_csv("generation-golden-sample-inventory.csv") + if str(row.get("sample_id") or "").startswith("GEN-SAMPLE-TPL-") + ] + if prompt_type == "clause_recommendation": + return [ + self._generation_sample(row, sample_family="phase13_clause_generation") + for row in self._read_phase13_csv("generation-golden-sample-inventory.csv") + if str(row.get("sample_id") or "").startswith("GEN-SAMPLE-CLAUSE-") + ] + if prompt_type == "contract_review": + return [self._review_sample(row) for row in self._read_phase13_csv("review-golden-sample-inventory.csv")] + if prompt_type == "legal_chat": + return [self._kb_qa_sample(row) for row in self._read_phase13_csv("kb-qa-golden-sample-inventory.csv")] + if prompt_type == "wording_rewrite": + return self._wording_rewrite_samples() + return [] + + def _evaluate_sample_with_llm( + self, + current_user: dict[str, Any], + asset: PromptAsset, + version: PromptAssetVersion, + sample: dict[str, Any], + *, + llm_client: Any, + fallback_to_local: bool, + ) -> dict[str, Any]: + message = self._build_llm_evaluation_message(asset, version, sample) + try: + raw_text = self._collect_llm_text( + llm_client.stream_answer( + current_user, + None, + message, + review_context={ + "task": "phase15_prompt_golden_sample_evaluation", + "prompt_type": asset.prompt_type, + "sample_id": sample.get("sample_id"), + }, + template_key=None, + ) + ) + verdict = self._parse_llm_verdict(raw_text) + result = { + "sample_id": sample["sample_id"], + "prompt_type": sample["prompt_type"], + "sample_family": sample["sample_family"], + "status": "pass" if verdict["passed"] else "fail", + "passed": verdict["passed"], + "score": verdict["score"], + "evaluator_mode": "llm", + "input_summary": sample.get("input_summary"), + "expected_summary": sample.get("expected_summary"), + "observed_output_summary": verdict.get("observed_output_summary"), + "issues": verdict.get("issues") or [], + "matched_expectations": verdict.get("matched_expectations") or [], + "model_trace": verdict.get("model_trace") or {}, + } + if self.include_raw_model_response: + result["raw_model_response"] = raw_text[:4000] + return result + except Exception as exc: + if fallback_to_local: + result = self._evaluate_sample_locally(asset, version, sample, []) + result["evaluator_mode"] = "local_fallback_after_llm_error" + result["issues"] = [*result.get("issues", []), f"llm_error:{str(exc)[:160]}"] + return result + return self._failed_sample_result(sample, issue="llm_call_failed", detail=str(exc)[:300]) + + def _evaluate_sample_locally( + self, + asset: PromptAsset, + version: PromptAssetVersion, + sample: dict[str, Any], + static_checks: list[dict[str, Any]], + ) -> dict[str, Any]: + content = f"{version.system_prompt or ''}\n{version.prompt_content or ''}".lower() + required_failures = [item for item in static_checks if item.get("required") and item.get("status") != "pass"] + issues: list[str] = [] + checks: list[dict[str, Any]] = [] + if required_failures: + issues.append("required_static_prompt_checks_failed") + if not sample.get("expected"): + issues.append("sample_expected_payload_missing") + if sample.get("expected_citation_required") and not self._contains_any(content, ["law", "legal", "basis", "citation", "source", "article"]): + checks.append({"key": "citation_instruction", "status": "warn", "detail": "Prompt does not clearly require legal source citations."}) + if asset.prompt_type == "wording_rewrite" and not self._contains_any(content, ["rewrite", "wording", "optimize", "revise"]): + issues.append("wording_rewrite_instruction_missing") + if asset.prompt_type == "contract_review" and not self._contains_any(content, ["review", "risk", "issue", "finding"]): + issues.append("contract_review_instruction_missing") + if asset.prompt_type == "legal_chat" and not self._contains_any(content, ["chat", "answer", "question", "reply"]): + checks.append({"key": "chat_answer_instruction", "status": "warn", "detail": "Prompt does not clearly ask the model to answer the user's question."}) + + passed = not issues + score = 0.94 if passed else 0.55 + if checks: + score = min(score, 0.88) + return { + "sample_id": sample["sample_id"], + "prompt_type": sample["prompt_type"], + "sample_family": sample["sample_family"], + "status": "pass" if passed else "fail", + "passed": passed, + "score": round(score, 4), + "evaluator_mode": "local_fallback", + "input_summary": sample.get("input_summary"), + "expected_summary": sample.get("expected_summary"), + "observed_output_summary": "Local fallback evaluation: checked prompt/sample/expected/source-boundary alignment.", + "issues": issues, + "matched_expectations": [item for item in ["sample_loaded", "expected_loaded", "prompt_static_gate"] if item], + "checks": checks, + } + + def _failed_sample_result(self, sample: dict[str, Any], *, issue: str, detail: str) -> dict[str, Any]: + return { + "sample_id": sample["sample_id"], + "prompt_type": sample["prompt_type"], + "sample_family": sample["sample_family"], + "status": "fail", + "passed": False, + "score": 0.0, + "evaluator_mode": "llm", + "input_summary": sample.get("input_summary"), + "expected_summary": sample.get("expected_summary"), + "observed_output_summary": detail, + "issues": [issue], + "matched_expectations": [], + } + + def _build_llm_evaluation_message(self, asset: PromptAsset, version: PromptAssetVersion, sample: dict[str, Any]) -> str: + payload = { + "prompt_asset": { + "prompt_key": asset.prompt_key, + "prompt_type": asset.prompt_type, + "version_label": version.version_label, + "system_prompt": version.system_prompt, + "prompt_content": version.prompt_content, + "variables": version.variables_json or [], + "output_schema": version.output_schema_json, + "model_config": version.model_config_json, + }, + "golden_sample": sample, + } + return ( + "You are the Phase15-F prompt golden-sample evaluator for the AI contract platform. " + "Treat prompt_asset as the prompt under test, reason through the golden_sample input, " + "and compare the expected boundaries. Return JSON only, no Markdown.\n" + "JSON schema: {\"passed\":true|false,\"score\":0..1,\"observed_output_summary\":\"...\"," + "\"issues\":[\"...\"],\"matched_expectations\":[\"...\"],\"model_trace\":{\"reason\":\"...\"}}.\n" + "Check sample expectations, legal-source grounding, and avoid over-confident legal conclusions.\n" + f"Evaluation input: {json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + def _parse_llm_verdict(self, raw_text: str) -> dict[str, Any]: + payload = self._extract_json_object(raw_text) + passed = bool(payload.get("passed")) + score = payload.get("score") + try: + numeric_score = float(score) + except (TypeError, ValueError): + numeric_score = 1.0 if passed else 0.0 + issues = payload.get("issues") if isinstance(payload.get("issues"), list) else [] + matched = payload.get("matched_expectations") if isinstance(payload.get("matched_expectations"), list) else [] + return { + "passed": passed, + "score": max(0.0, min(1.0, numeric_score)), + "observed_output_summary": str(payload.get("observed_output_summary") or "")[:1000], + "issues": [str(item)[:300] for item in issues], + "matched_expectations": [str(item)[:200] for item in matched], + "model_trace": payload.get("model_trace") if isinstance(payload.get("model_trace"), dict) else {}, + } + + def _extract_json_object(self, raw_text: str) -> dict[str, Any]: + text = str(raw_text or "").strip() + try: + payload = json.loads(text) + return payload if isinstance(payload, dict) else {} + except json.JSONDecodeError: + pass + match = re.search(r"\{.*\}", text, flags=re.S) + if not match: + return {"passed": False, "score": 0, "issues": ["model_response_not_json"], "observed_output_summary": text[:500]} + try: + payload = json.loads(match.group(0)) + except json.JSONDecodeError: + return {"passed": False, "score": 0, "issues": ["model_response_json_parse_failed"], "observed_output_summary": text[:500]} + return payload if isinstance(payload, dict) else {} + + def _collect_llm_text(self, chunks: Iterable[str]) -> str: + output: list[str] = [] + for chunk in chunks: + text = str(chunk or "") + parsed = self._extract_sse_text(text) + output.append(parsed if parsed is not None else text) + return "".join(output).strip() + + def _extract_sse_text(self, value: str) -> Optional[str]: + data_parts: list[str] = [] + for line in value.splitlines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError: + data_parts.append(raw) + continue + if isinstance(payload, Mapping): + if payload.get("type") in {"text", "delta"}: + data_parts.append(str(payload.get("content") or "")) + elif payload.get("content"): + data_parts.append(str(payload.get("content"))) + if not data_parts: + return None + return "".join(data_parts) + + def _resolve_llm_client(self) -> Optional[Any]: + if self.evaluation_mode == "local": + return None + if self.llm_client is not None: + return self.llm_client + if not settings.shared_ai_platform_base_url.strip() or not settings.shared_ai_platform_api_key.strip(): + return None + return RemoteSharedAIPlatformClient( + base_url=settings.shared_ai_platform_base_url, + app_id=settings.shared_ai_platform_app_id, + api_key=settings.shared_ai_platform_api_key, + timeout_seconds=settings.shared_ai_platform_timeout_seconds, + ) + + def _read_phase13_csv(self, file_name: str) -> list[dict[str, str]]: + path = self.repo_root / "project-docs" / "phase13-legal-knowledge-template-library" / "inventories" / file_name + return self._read_csv(path) + + def _read_csv(self, path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8-sig", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + def _generation_sample(self, row: Mapping[str, str], *, sample_family: str) -> dict[str, Any]: + base = self.repo_root / "project-docs" / "phase13-legal-knowledge-template-library" + input_payload = self._read_json_file(base / str(row.get("variable_payload_path") or "")) + expected_text = self._read_text_file(base / str(row.get("expected_document_path") or "")) + return { + "sample_id": row.get("sample_id"), + "prompt_type": "clause_recommendation" if sample_family == "phase13_clause_generation" else "contract_generation", + "sample_family": sample_family, + "input": input_payload, + "expected": { + "expected_document_excerpt": expected_text[:2000], + "expected_review_rule_ids": self._split_semicolon(row.get("expected_review_rule_ids")), + "expected_source_citations": self._split_semicolon(row.get("expected_source_citations")), + "contract_type": row.get("contract_type"), + "asset_under_test": row.get("template_id"), + }, + "expected_citation_required": bool(row.get("expected_source_citations")), + "input_summary": f"{row.get('sample_id')} variables={len(input_payload) if isinstance(input_payload, Mapping) else 'n/a'}", + "expected_summary": f"rules={row.get('expected_review_rule_ids')}; sources={row.get('expected_source_citations')}", + } + + def _review_sample(self, row: Mapping[str, str]) -> dict[str, Any]: + base = self.repo_root / "project-docs" / "phase13-legal-knowledge-template-library" + input_text = self._read_text_file(base / str(row.get("input_sample_path") or "")) + expected_text = self._read_text_file(base / str(row.get("expected_findings_path") or "")) + return { + "sample_id": row.get("sample_id"), + "prompt_type": "contract_review", + "sample_family": "phase13_review", + "input": {"contract_text": input_text, "contract_type": row.get("contract_type")}, + "expected": { + "expected_findings_excerpt": expected_text[:2000], + "risk_categories": self._split_semicolon(row.get("risk_categories")), + "expected_severity": row.get("expected_severity"), + "expected_source_citations": self._split_semicolon(row.get("expected_source_citations")), + }, + "expected_citation_required": bool(row.get("expected_source_citations")), + "input_summary": f"{row.get('sample_id')} contract_chars={len(input_text)}", + "expected_summary": f"severity={row.get('expected_severity')}; categories={row.get('risk_categories')}", + } + + def _kb_qa_sample(self, row: Mapping[str, str]) -> dict[str, Any]: + return { + "sample_id": row.get("sample_id"), + "prompt_type": "legal_chat", + "sample_family": "phase13_kb_qa", + "input": {"question": row.get("question")}, + "expected": { + "expected_answer_boundary": row.get("expected_answer_boundary"), + "expected_source_ids": self._split_semicolon(row.get("expected_source_ids")), + "expected_citation_required": str(row.get("expected_citation_required")).lower() == "true", + "expected_refusal_required": str(row.get("expected_refusal_required")).lower() == "true", + }, + "expected_citation_required": str(row.get("expected_citation_required")).lower() == "true", + "input_summary": str(row.get("question") or "")[:200], + "expected_summary": str(row.get("expected_answer_boundary") or "")[:300], + } + + def _wording_rewrite_samples(self) -> list[dict[str, Any]]: + path = self.repo_root / "project-docs" / "phase15-prompt-golden-samples" / "wording-rewrite-golden-samples.json" + payload = self._read_json_file(path) + samples = payload.get("samples") if isinstance(payload, Mapping) else [] + results: list[dict[str, Any]] = [] + for item in samples if isinstance(samples, list) else []: + if not isinstance(item, Mapping): + continue + input_payload = item.get("input") if isinstance(item.get("input"), Mapping) else {} + expected_payload = item.get("expected") if isinstance(item.get("expected"), Mapping) else {} + results.append( + { + "sample_id": item.get("sample_id"), + "prompt_type": "wording_rewrite", + "sample_family": "phase15_wording_rewrite", + "rewrite_type": item.get("rewrite_type"), + "document_type_code": item.get("document_type_code"), + "input": input_payload, + "expected": expected_payload, + "expected_citation_required": False, + "input_summary": str(input_payload.get("clause_text") or "")[:200], + "expected_summary": json.dumps(expected_payload, ensure_ascii=False)[:300], + } + ) + return results + + def _read_json_file(self, path: Path) -> Any: + if not path.exists() or not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError: + return {} + + def _read_text_file(self, path: Path) -> str: + if not path.exists() or not path.is_file(): + return "" + return path.read_text(encoding="utf-8-sig") + + @staticmethod + def _split_semicolon(value: Optional[str]) -> list[str]: + return [item.strip() for item in str(value or "").split(";") if item.strip()] + + @staticmethod + def _normalize_mode(value: Optional[str]) -> str: + normalized = str(value or "auto").strip().lower().replace("-", "_") + if normalized not in {"auto", "llm", "local"}: + return "auto" + return normalized + + @staticmethod + def _contains_any(value: str, candidates: list[str]) -> bool: + return any(candidate.lower() in value for candidate in candidates) diff --git a/backend/app/tasks/legal_change_impact_worker.py b/backend/app/tasks/legal_change_impact_worker.py new file mode 100644 index 00000000..466a1a8a --- /dev/null +++ b/backend/app/tasks/legal_change_impact_worker.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db import SessionLocal +from app.repositories.legal_change_impact_repository import LegalChangeImpactRepository +from app.services.legal_change_impact_service import LegalChangeImpactService + + +class LegalChangeImpactWorker: + """Consume pending legal-change events in a standalone worker process.""" + + def __init__(self, session_factory: Callable[[], Session] = SessionLocal): + self.session_factory = session_factory + + def run_once(self, *, limit: Optional[int] = None) -> dict[str, Any]: + if not settings.legal_change_impact_worker_enabled: + return { + "worker": "legal_change_impact_worker", + "status": "disabled", + "completed_count": 0, + "failed_count": 0, + "completed": [], + "failed": [], + } + + batch_size = max(1, min(limit or settings.legal_change_impact_worker_batch_size, 100)) + db = self.session_factory() + try: + repository = LegalChangeImpactRepository(db) + events = repository.list_pending_events_for_worker(limit=batch_size) + completed: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + for event in events: + current_user = { + "id": settings.legal_change_impact_worker_user_id, + "tenant_id": event.tenant_id, + "role": "admin", + } + try: + result = LegalChangeImpactService(db).run_event_scan(current_user, event.id) + completed.append( + { + "event_id": event.id, + "tenant_id": event.tenant_id, + "impact_summary": result["event"].get("impact_summary", {}), + } + ) + except HTTPException as exc: + failed.append({"event_id": event.id, "tenant_id": event.tenant_id, "status_code": exc.status_code, "message": exc.detail}) + except Exception as exc: # pragma: no cover - defensive worker safety net + self._mark_event_failed(db, event.tenant_id, event.id, str(exc)) + failed.append({"event_id": event.id, "tenant_id": event.tenant_id, "status_code": 500, "message": str(exc)[:300]}) + + return { + "worker": "legal_change_impact_worker", + "status": "completed", + "requested_limit": batch_size, + "completed_count": len(completed), + "failed_count": len(failed), + "completed": completed, + "failed": failed, + "ran_at": datetime.now(timezone.utc).isoformat(), + } + finally: + db.close() + + def run_forever(self, *, limit: Optional[int] = None, interval_seconds: Optional[int] = None, max_loops: Optional[int] = None) -> None: + interval = max(5, interval_seconds or settings.legal_change_impact_worker_interval_seconds) + loops = 0 + while True: + result = self.run_once(limit=limit) + print(json.dumps(result, ensure_ascii=False), flush=True) + loops += 1 + if max_loops is not None and loops >= max_loops: + return + time.sleep(interval) + + @staticmethod + def _mark_event_failed(db: Session, tenant_id: str, event_id: str, message: str) -> None: + repository = LegalChangeImpactRepository(db) + event = repository.get_event_by_id(tenant_id, event_id) + if event is None: + return + event.scan_status = "failed" + event.impact_summary_json = {"error": message[:500], "total": 0, "blocking_count": 0} + event.updated_at = datetime.now(timezone.utc) + repository.update_event(event) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Run the legal-change impact background worker.") + parser.add_argument("--once", action="store_true", help="Run one batch and exit.") + parser.add_argument("--limit", type=int, default=None, help="Maximum pending events to consume per batch.") + parser.add_argument("--interval-seconds", type=int, default=None, help="Polling interval for daemon mode.") + parser.add_argument("--max-loops", type=int, default=None, help="Optional loop cap for smoke tests.") + args = parser.parse_args(argv) + + worker = LegalChangeImpactWorker() + if args.once: + print(json.dumps(worker.run_once(limit=args.limit), ensure_ascii=False), flush=True) + return 0 + worker.run_forever(limit=args.limit, interval_seconds=args.interval_seconds, max_loops=args.max_loops) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_clause_recommendation_service.py b/backend/tests/test_clause_recommendation_service.py index e6bafe87..3e2315e2 100644 --- a/backend/tests/test_clause_recommendation_service.py +++ b/backend/tests/test_clause_recommendation_service.py @@ -96,6 +96,36 @@ class ClauseRecommendationServiceTestCase(unittest.TestCase): self.assertEqual(result["document_type_code"], "service_general") self.assertEqual([item["clause_key"] for item in result["items"]], ["service_scope", "deliverables"]) + def test_software_contract_returns_industry_practice_recommendations(self): + result = self.service.recommend_missing_clauses( + self.current_user, + document_type_code="software_development", + contract_text=( + "软件开发合同\n" + "一、服务范围:乙方为甲方开发客户管理系统。\n" + "二、交付物:乙方提交源代码、部署包和操作文档。\n" + "三、验收:甲方在收到交付物后五个工作日内完成验收。\n" + "四、知识产权:定制成果权利归甲方所有。\n" + "五、保密:双方对合作资料承担保密义务。\n" + "六、甲方配合义务:甲方提供接口账号和测试环境。\n" + "七、维护支持:乙方在验收后提供必要缺陷修复。" + ), + max_items=3, + ) + + self.assertEqual(result["document_type_code"], "software_development") + self.assertGreaterEqual(result["summary"]["industry_practice_count"], 1) + self.assertEqual(result["summary"]["missing_clause_keys"], []) + first = result["items"][0] + self.assertEqual(first["clause_key"], "scope_change_control") + self.assertEqual(first["recommendation_kind"], "industry_practice") + self.assertEqual(first["basis_type"], "industry_practice_only") + self.assertIn("无直接法条", first["legal_basis"]) + self.assertIn("industry_practice_rules", first["source_types"]) + self.assertIn("no_direct_legal_article", first["source_types"]) + self.assertEqual(first["legal_article_refs"], []) + self.assertIn(first["clause_key"], result["summary"]["industry_practice_keys"]) + def test_professional_rewrite_expands_ambiguous_delivery_deadline(self): result = self.service.rewrite_clause_wording( self.current_user, diff --git a/backend/tests/test_clauses_api.py b/backend/tests/test_clauses_api.py index a3421851..788d6f0c 100644 --- a/backend/tests/test_clauses_api.py +++ b/backend/tests/test_clauses_api.py @@ -32,6 +32,9 @@ class StubClauseRecommendationService: "clause_key": "acceptance", "clause_title": "验收标准与流程", "clause_type": "acceptance", + "recommendation_kind": "missing_clause", + "basis_type": "legal_basis", + "industry_context": None, "priority": "high", "confidence": "high", "insert_position": { @@ -42,6 +45,7 @@ class StubClauseRecommendationService: "suggested_text": "验收标准与流程:甲方应在收到货物后完成验收。", "reason": "买卖合同需要把交付和验收衔接起来。", "legal_basis": "《民法典》合同履行规则。", + "practice_basis": None, "legal_article_refs": [], "source_rule": "LAW-CLAUSE-PROCUREMENT-ACCEPTANCE", "source_types": ["document_type_required_clause_list", "standard_clause_library"], @@ -51,10 +55,15 @@ class StubClauseRecommendationService: "total": 1, "summary": { "recommended_count": 1, + "recommendation_keys": ["acceptance"], "detected_clause_keys": [], "missing_clause_keys": ["acceptance"], + "required_missing_clause_keys": ["acceptance"], + "industry_practice_keys": [], + "detected_industry_practice_keys": [], + "industry_practice_count": 0, "source_count": 0, - "strategy": "rule_based_required_clause_gap", + "strategy": "rule_based_required_clause_gap_and_industry_practice", }, "sources": ["document_type_required_clause_list"], } @@ -128,6 +137,7 @@ class ClausesApiTestCase(unittest.TestCase): self.assertEqual(self.clause_service.calls[0][1]["contract_id"], "contract-001") self.assertEqual(self.audit_log_service.events[0]["action"], "recommend_missing_clauses") self.assertEqual(self.audit_log_service.events[0]["detail_json"]["missing_clause_keys"], ["acceptance"]) + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["industry_practice_count"], 0) def test_rewrite_clause_wording_returns_result_and_records_audit(self): response = self.client.post( diff --git a/backend/tests/test_legal_change_impact_service.py b/backend/tests/test_legal_change_impact_service.py index 5ac78a8b..bc333dc6 100644 --- a/backend/tests/test_legal_change_impact_service.py +++ b/backend/tests/test_legal_change_impact_service.py @@ -11,6 +11,7 @@ from app.models.contract_template import ContractTemplate from app.models.generated_contract import GeneratedContract from app.models.legal_article import LegalArticle from app.models.review_rule import ReviewRule +from app.tasks.legal_change_impact_worker import LegalChangeImpactWorker from app.services.legal_change_impact_service import LegalChangeImpactService @@ -19,6 +20,7 @@ class LegalChangeImpactServiceTestCase(unittest.TestCase): self.engine = create_engine("sqlite:///:memory:", future=True) Base.metadata.create_all(bind=self.engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine, future=True) + self.SessionLocal = SessionLocal self.db = SessionLocal() self.current_user = {"id": "user-admin", "tenant_id": "tenant-001", "role": "admin"} self.service = LegalChangeImpactService(self.db) @@ -134,6 +136,54 @@ class LegalChangeImpactServiceTestCase(unittest.TestCase): self.assertEqual(result["completed"][0]["event"]["scan_status"], "completed") self.assertEqual(len(result["completed"][0]["impacts"]), 3) + def test_semantic_vector_scan_finds_related_contract_without_direct_reference(self): + semantic_contract = GeneratedContract( + id="contract-semantic-001", + tenant_id="tenant-001", + template_id="tpl-semantic", + template_name="通用服务协议", + contract_type="service", + document_type_code="service_general", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_name="服务协议.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=456, + generated_content="若任一方未按约履行交付义务,应承担迟延履行责任并补偿对方实际损失。", + file_content_base64="SGVsbG8=", + status="generated", + ) + self.db.add(semantic_contract) + self.db.commit() + + result = self.service.scan_article_change(self.current_user, article_id="lart-civil-585") + impact = next(item for item in result["impacts"] if item["target_id"] == "contract-semantic-001") + + self.assertIn("semantic_vector_match", impact["match_reason"]) + self.assertEqual(impact["impact_level"], "reference_only") + self.assertGreaterEqual(impact["evidence"]["semantic_vector"]["score"], 0.10) + self.assertIn("breach_liability", impact["evidence"]["semantic_vector"]["shared_concepts"]) + self.assertGreaterEqual(result["event"]["impact_summary"]["semantic_match_count"], 1) + + def test_background_worker_consumes_pending_events(self): + queued = self.service.enqueue_article_change( + self.current_user, + article_id="lart-civil-585", + change_type="updated", + change_summary="后台 worker 消费", + ) + + worker = LegalChangeImpactWorker(session_factory=self.SessionLocal) + worker_result = worker.run_once(limit=1) + self.db.expire_all() + detail = self.service.get_event_detail(self.current_user, queued["event"]["id"]) + + self.assertEqual(worker_result["completed_count"], 1) + self.assertEqual(worker_result["failed_count"], 0) + self.assertEqual(detail["event"]["scan_status"], "completed") + self.assertEqual(detail["event"]["impact_summary"]["total"], 3) + def test_update_impact_status_records_handling_note(self): result = self.service.scan_article_change(self.current_user, article_id="lart-civil-585") impact_id = result["impacts"][0]["id"] diff --git a/backend/tests/test_legal_change_impacts_api.py b/backend/tests/test_legal_change_impacts_api.py index d0523562..0bea61df 100644 --- a/backend/tests/test_legal_change_impacts_api.py +++ b/backend/tests/test_legal_change_impacts_api.py @@ -38,7 +38,14 @@ class StubLegalChangeImpactService: "effective_date": "2021-01-01", "tags": ["违约金"], "scan_status": "completed", - "impact_summary": {"total": 1, "blocking_count": 1, "by_level": {"immediate_update": 1}}, + "impact_summary": { + "total": 1, + "blocking_count": 1, + "by_level": {"immediate_update": 1}, + "by_match_reason": {"semantic_vector_match": 1}, + "semantic_match_count": 1, + "scan_strategy": ["direct_reference", "tag_match", "keyword_match", "semantic_vector_match"], + }, "created_by": "user-admin", "created_at": "2026-05-21T00:00:00+00:00", "scanned_at": "2026-05-21T00:01:00+00:00", @@ -61,7 +68,7 @@ class StubLegalChangeImpactService: "impact_level": "immediate_update", "suggestion": "请立即检查模板。", "impact_status": "open", - "evidence": {}, + "evidence": {"semantic_vector": {"score": 0.42, "shared_concepts": ["breach_liability"]}}, "handled_by": None, "handled_at": None, "created_at": "2026-05-21T00:01:00+00:00", @@ -151,6 +158,8 @@ class LegalChangeImpactsApiTestCase(unittest.TestCase): "run_pending_legal_change_scans", "update_legal_change_impact_status", ]) + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["semantic_match_count"], 1) + self.assertTrue(self.audit_log_service.events[2]["detail_json"]["worker_compatible"]) def test_member_cannot_access_legal_change_impacts(self): app.dependency_overrides[require_current_user] = lambda: { diff --git a/backend/tests/test_prompt_asset_service.py b/backend/tests/test_prompt_asset_service.py index 2da5a67f..b6a8f601 100644 --- a/backend/tests/test_prompt_asset_service.py +++ b/backend/tests/test_prompt_asset_service.py @@ -1,6 +1,7 @@ import json import unittest from datetime import datetime, timezone +from pathlib import Path from fastapi import HTTPException @@ -8,6 +9,7 @@ from app.models.prompt_asset import PromptAsset from app.models.prompt_asset_evaluation_run import PromptAssetEvaluationRun from app.models.prompt_asset_run_link import PromptAssetRunLink from app.models.prompt_asset_version import PromptAssetVersion +from app.services.prompt_golden_sample_evaluator import PromptGoldenSampleEvaluator from app.services.prompt_asset_service import PromptAssetService @@ -117,6 +119,32 @@ class StubPromptAssetEvaluationRunRepository: return [item for item in self.items.values() if item.tenant_id == tenant_id and item.prompt_asset_id == asset_id][:limit] +class FakeGoldenSampleLLMClient: + def __init__(self): + self.calls = [] + + def stream_answer(self, current_user, conversation_id, message, review_context=None, template_key=None): + self.calls.append( + { + "current_user": current_user, + "conversation_id": conversation_id, + "message": message, + "review_context": review_context, + "template_key": template_key, + } + ) + yield json.dumps( + { + "passed": True, + "score": 0.97, + "observed_output_summary": "Sample expectations are satisfied.", + "issues": [], + "matched_expectations": ["expected_boundary", "legal_safety"], + "model_trace": {"reason": "fake evaluator accepted this sample"}, + } + ) + + class PromptAssetServiceTestCase(unittest.TestCase): def setUp(self): self.service = PromptAssetService(db=None) @@ -127,13 +155,21 @@ class PromptAssetServiceTestCase(unittest.TestCase): self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin", "display_name": "Admin"} self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} - def _create_asset(self, evaluation=True): + def _create_asset( + self, + evaluation=True, + *, + prompt_key="contract_generation_default", + prompt_type="contract_generation", + name="Contract generation default", + prompt_content="Generate a contract from structured elements.", + ): return self.service.create_prompt_asset( self.admin_user, - prompt_key="contract_generation_default", - prompt_type="contract_generation", - name="Contract generation default", - prompt_content="Generate a contract from structured elements.", + prompt_key=prompt_key, + prompt_type=prompt_type, + name=name, + prompt_content=prompt_content, variables_json=json.dumps([{"name": "party_a", "required": True}]), output_schema_json=json.dumps({"type": "object"}), model_config_json=json.dumps({"model": "gpt-5.4"}), @@ -168,10 +204,42 @@ class PromptAssetServiceTestCase(unittest.TestCase): self.assertEqual(evaluation["asset"]["latest_evaluation_status"], "passed") self.assertEqual(evaluation["version"]["evaluation_status"], "passed") self.assertEqual(evaluation["evaluation_report"]["suite_key"], "phase13_generation") + self.assertEqual(evaluation["evaluation_report"]["schema_version"], "phase15-prompt-evaluation-report.v2") + self.assertEqual(evaluation["evaluation_report"]["evaluation_mode"], "local_fallback") + self.assertGreaterEqual(evaluation["evaluation_run"]["sample_count"], 8) + self.assertEqual(evaluation["evaluation_run"]["sample_count"], len(evaluation["evaluation_report"]["sample_results"])) self.assertEqual(evaluation["evaluation_run"]["status"], "passed") - self.assertEqual(evaluation["evaluation_run"]["sample_count"], 8) self.assertEqual(published["status"], "published") + def test_run_evaluation_invokes_llm_once_per_golden_sample(self): + fake_llm_client = FakeGoldenSampleLLMClient() + self.service.golden_sample_evaluator = PromptGoldenSampleEvaluator( + repo_root=Path.cwd(), + llm_client=fake_llm_client, + evaluation_mode="llm", + ) + created = self._create_asset( + evaluation=False, + prompt_key="wording_rewrite_default", + prompt_type="wording_rewrite", + name="Wording rewrite default", + prompt_content="Rewrite and optimize clause wording, explain risk boundaries, and preserve legal safety.", + ) + + evaluation = self.service.evaluate_current_prompt_asset(self.admin_user, created["id"]) + + self.assertEqual(evaluation["version"]["evaluation_status"], "passed") + self.assertEqual(evaluation["evaluation_report"]["suite_key"], "phase15_wording_rewrite") + self.assertEqual(evaluation["evaluation_report"]["evaluation_mode"], "llm") + self.assertGreaterEqual(evaluation["evaluation_run"]["sample_count"], 3) + self.assertEqual(len(fake_llm_client.calls), evaluation["evaluation_run"]["sample_count"]) + self.assertEqual( + evaluation["evaluation_report"]["model_invocation"]["sample_invocation_count"], + evaluation["evaluation_run"]["sample_count"], + ) + self.assertTrue(all(item["evaluator_mode"] == "llm" for item in evaluation["evaluation_report"]["sample_results"])) + self.assertTrue(all(call["current_user"]["id"] == self.admin_user["id"] for call in fake_llm_client.calls)) + def test_publish_with_report_and_create_next_version(self): created = self._create_asset(evaluation=False) published = self.service.publish_prompt_asset( diff --git a/backend/tests/test_prompt_assets_api.py b/backend/tests/test_prompt_assets_api.py index 209edf44..ff6d7629 100644 --- a/backend/tests/test_prompt_assets_api.py +++ b/backend/tests/test_prompt_assets_api.py @@ -107,7 +107,16 @@ class StubPromptAssetService: "failed_samples": 0, "pass_rate": 1.0, "quality_score": 0.95, - "report": {"suite_key": "phase13_generation", "passed": True}, + "report": { + "schema_version": "phase15-prompt-evaluation-report.v2", + "suite_key": "phase13_generation", + "evaluation_mode": "llm", + "sample_count": 8, + "passed_samples": 8, + "failed_samples": 0, + "sample_results": [{"sample_id": "GEN-SAMPLE-TPL-001", "passed": True}], + "passed": True, + }, "failure_reason": None, "triggered_by": "user-admin", "started_at": "2026-05-21T00:00:00+00:00", @@ -143,7 +152,16 @@ class StubPromptAssetService: "model_config": None, "evaluation_status": "passed", "evaluation_score": 0.95, - "evaluation_report": {"suite_key": "phase13_generation", "passed": True}, + "evaluation_report": { + "schema_version": "phase15-prompt-evaluation-report.v2", + "suite_key": "phase13_generation", + "evaluation_mode": "llm", + "sample_count": 8, + "passed_samples": 8, + "failed_samples": 0, + "sample_results": [{"sample_id": "GEN-SAMPLE-TPL-001", "passed": True}], + "passed": True, + }, "change_summary": "Initial prompt version", "rollback_from_version_id": None, "is_current": True, @@ -154,7 +172,17 @@ class StubPromptAssetService: "published_at": None, "published_by": None, }, - "evaluation_report": {"suite_key": "phase13_generation", "passed": True, "quality_score": 0.95}, + "evaluation_report": { + "schema_version": "phase15-prompt-evaluation-report.v2", + "suite_key": "phase13_generation", + "evaluation_mode": "llm", + "sample_count": 8, + "passed_samples": 8, + "failed_samples": 0, + "sample_results": [{"sample_id": "GEN-SAMPLE-TPL-001", "passed": True}], + "passed": True, + "quality_score": 0.95, + }, "evaluation_run": { "id": "prompteval-123", "tenant_id": "tenant-demo-001", @@ -169,7 +197,16 @@ class StubPromptAssetService: "failed_samples": 0, "pass_rate": 1.0, "quality_score": 0.95, - "report": {"suite_key": "phase13_generation", "passed": True}, + "report": { + "schema_version": "phase15-prompt-evaluation-report.v2", + "suite_key": "phase13_generation", + "evaluation_mode": "llm", + "sample_count": 8, + "passed_samples": 8, + "failed_samples": 0, + "sample_results": [{"sample_id": "GEN-SAMPLE-TPL-001", "passed": True}], + "passed": True, + }, "failure_reason": None, "triggered_by": current_user["id"], "started_at": "2026-05-21T00:00:00+00:00", @@ -264,6 +301,7 @@ class PromptAssetsApiTestCase(unittest.TestCase): self.assertEqual(version_response.json()["data"]["current_version_label"], "v1.1") self.assertEqual(evaluation_response.json()["data"]["version"]["evaluation_status"], "passed") self.assertEqual(evaluation_response.json()["data"]["evaluation_run"]["status"], "passed") + self.assertEqual(evaluation_response.json()["data"]["evaluation_report"]["evaluation_mode"], "llm") self.assertEqual(publish_response.json()["data"]["status"], "published") self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1.2") self.assertEqual(run_link_response.json()["data"]["prompt_asset_version_id"], "promptver-123") @@ -275,6 +313,8 @@ class PromptAssetsApiTestCase(unittest.TestCase): "rollback_prompt_asset", "record_prompt_asset_run_link", ]) + self.assertEqual(self.audit_log_service.events[2]["detail_json"]["evaluation_mode"], "llm") + self.assertEqual(self.audit_log_service.events[2]["detail_json"]["sample_count"], 8) def test_member_cannot_list_prompt_assets(self): app.dependency_overrides[require_current_user] = lambda: { diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 7508a11b..9725a566 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -55,6 +55,43 @@ services: ports: - "8001:8000" + legal-change-impact-worker: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + backend: + condition: service_started + environment: + APP_NAME: AI Contract Platform + APP_ENV: production + SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting production} + DATABASE_URL: postgresql+psycopg://postgres:postgres@postgres:5432/ai_contract_platform + REDIS_URL: redis://redis:6379/0 + MINIO_ENDPOINT: 192.168.3.90:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + MINIO_BUCKET: contracts + SHARED_AI_PLATFORM_BASE_URL: http://192.168.3.90:18011 + SHARED_AI_PLATFORM_API_PREFIX: /api/v0.1 + SHARED_AI_PLATFORM_APP_ID: ai-contract-platform + SHARED_AI_PLATFORM_API_KEY: "shared-ai-platform-admin-dev-token" + SHARED_AI_PLATFORM_TIMEOUT_SECONDS: 180 + SHARED_AI_PLATFORM_LEGAL_REVIEW_ENABLED: "true" + SHARED_AI_PLATFORM_LEGAL_REVIEW_FALLBACK_TO_LOCAL: "false" + LEGAL_CHANGE_IMPACT_WORKER_BATCH_SIZE: 10 + LEGAL_CHANGE_IMPACT_WORKER_INTERVAL_SECONDS: 300 + LEGAL_CHANGE_IMPACT_SEMANTIC_ENABLED: "true" + command: > + python -m app.tasks.legal_change_impact_worker + --limit 10 + --interval-seconds 300 + volumes: + - backend_storage:/app/storage + frontend: build: context: ./frontend diff --git a/frontend/src/app/documents/[id]/edit/page.tsx b/frontend/src/app/documents/[id]/edit/page.tsx index 324610b6..f9af2c32 100644 --- a/frontend/src/app/documents/[id]/edit/page.tsx +++ b/frontend/src/app/documents/[id]/edit/page.tsx @@ -57,6 +57,16 @@ const clausePriorityColorMap: Record = { low: "blue", }; +const recommendationKindLabelMap: Record = { + missing_clause: "缺失条款", + industry_practice: "行业惯例", +}; + +const recommendationKindColorMap: Record = { + missing_clause: "orange", + industry_practice: "purple", +}; + function formatDateTime(value?: string | null) { if (!value) { return "-"; @@ -111,6 +121,10 @@ function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } +function readableRecommendationKind(kind?: string | null) { + return recommendationKindLabelMap[kind ?? ""] ?? "条款建议"; +} + export default function GeneratedContractEditorPage({ params }: { params: Promise<{ id: string }> }) { const [messageApi, contextHolder] = message.useMessage(); const router = useRouter(); @@ -474,7 +488,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis showIcon type="info" message="编辑器说明" - description="左侧正文可直接编辑;右侧可选择段落、检查缺失条款、改写条款并把结果插入正文。保存后会生成可追溯的新版本。" + description="左侧正文可直接编辑;右侧可选择段落、检查缺失条款和行业惯例、改写条款并把结果插入正文。保存后会生成可追溯的新版本。" /> @@ -486,7 +500,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis @@ -570,9 +593,9 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis ))} ) : recommendations ? ( - 当前未发现首批必备条款缺失。 + 当前未发现必备条款缺失或行业惯例建议。 ) : ( - 点击“检查缺失条款”后展示推荐。 + 点击“检查条款与惯例”后展示推荐。 )} diff --git a/frontend/src/app/documents/new/[type]/page.tsx b/frontend/src/app/documents/new/[type]/page.tsx index 7f375c8f..4fdd6077 100644 --- a/frontend/src/app/documents/new/[type]/page.tsx +++ b/frontend/src/app/documents/new/[type]/page.tsx @@ -134,6 +134,16 @@ const clausePriorityLabelMap: Record = { low: "低优先级", }; +const recommendationKindLabelMap: Record = { + missing_clause: "缺失条款", + industry_practice: "行业惯例", +}; + +const recommendationKindColorMap: Record = { + missing_clause: "orange", + industry_practice: "purple", +}; + const clauseRewriteModeOptions = [ { label: "专业化", value: "professional" }, { label: "简化解释", value: "simplify" }, @@ -224,6 +234,10 @@ function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } +function readableRecommendationKind(kind?: string | null) { + return recommendationKindLabelMap[kind ?? ""] ?? "条款建议"; +} + function renderField(field: LegalDocumentElementField) { if (field.type === "select") { return