diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 9f1be52f..7daed773 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -11,6 +11,7 @@ from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai from app.db import get_db_session from app.schemas.chat import ChatStreamRequest from app.services.legal_article_service import LegalArticleService +from app.services.legal_domain_registry import BUSINESS_AUXILIARY_PROMPT_KEYS from app.services.prompt_runtime_service import PromptRuntimeService from app.services.review_service import ReviewService from app.services.tenant_service import TenantService @@ -21,14 +22,6 @@ ROUTE_PERMISSION_ACTIONS = { "stream": "chat:stream", } -BUSINESS_AUXILIARY_PROMPT_KEYS = { - "obligation_extraction": "obligation_extraction_default", - "filing_materials_checklist": "filing_materials_checklist_default", - "negotiation_strategy": "negotiation_strategy_default", - "legal_opinion_boundary_fallback": "legal_opinion_boundary_fallback_default", -} - - def get_chat_review_service(db: Session = Depends(get_db_session)) -> ReviewService: return ReviewService(db) @@ -138,23 +131,47 @@ def _detect_business_auxiliary_prompt_key( message: str, review_context: Optional[Dict[str, Any]], ) -> Optional[str]: - text = _build_prompt_intent_text(message, review_context) - if _contains_any(text, ["法律意见书强边界", "法律意见书兜底", "法律意见书边界", "legal opinion boundary", "legal opinion fallback"]): + context_text = _build_review_context_action_text(review_context) + if context_text: + routed_key = _detect_business_auxiliary_prompt_key_from_text(context_text) + if routed_key: + return routed_key + + return _detect_business_auxiliary_prompt_key_from_text(_build_prompt_intent_text(message, review_context)) + + +def _detect_business_auxiliary_prompt_key_from_text(text: str) -> Optional[str]: + if _contains_any(text, ["法律意见书强边界", "法律意见书兜底", "法律意见书边界", "使用限制", "前提假设", "不能出具", "正式法律意见", "legal opinion boundary", "legal opinion fallback"]): return BUSINESS_AUXILIARY_PROMPT_KEYS["legal_opinion_boundary_fallback"] - if _contains_any(text, ["履约义务", "履行义务", "义务提取", "履约清单", "obligation extraction", "performance obligation"]): + if _contains_any(text, ["履约义务", "履行义务", "义务提取", "履约清单", "义务清单", "交付义务", "配合义务", "双方要做什么", "对方需要做什么", "obligation extraction", "performance obligation", "performance checklist"]): return BUSINESS_AUXILIARY_PROMPT_KEYS["obligation_extraction"] - if _contains_any(text, ["工商", "备案材料", "变更登记", "材料清单", "filing materials", "registration checklist", "filing checklist"]): + if _contains_any(text, ["工商", "备案材料", "变更登记", "登记材料", "材料清单", "提交材料", "申报材料", "登记机关", "filing materials", "registration materials", "registration checklist", "filing checklist"]): return BUSINESS_AUXILIARY_PROMPT_KEYS["filing_materials_checklist"] - if _contains_any(text, ["谈判策略", "谈判建议", "怎么谈", "议价", "让步方案", "negotiation strategy", "negotiate"]): + if _contains_any(text, ["谈判策略", "谈判建议", "怎么谈", "如何谈", "议价", "让步方案", "让步空间", "底线", "沟通话术", "对方不接受", "替代方案", "negotiate", "negotiation strategy", "negotiation plan", "fallback position"]): return BUSINESS_AUXILIARY_PROMPT_KEYS["negotiation_strategy"] if _contains_any(text, ["法律意见书", "legal opinion"]) and _contains_any( text, - ["边界", "兜底", "免责声明", "不能替代", "人工复核", "boundary", "fallback", "disclaimer", "human review"], + ["边界", "兜底", "免责声明", "不能替代", "人工复核", "使用限制", "前提假设", "boundary", "fallback", "disclaimer", "human review", "assumption", "limitation"], ): return BUSINESS_AUXILIARY_PROMPT_KEYS["legal_opinion_boundary_fallback"] return None +def _build_review_context_action_text(review_context: Optional[Dict[str, Any]]) -> str: + if not isinstance(review_context, dict): + return "" + metadata = review_context.get("metadata") if isinstance(review_context.get("metadata"), Mapping) else {} + parts = [ + metadata.get("actionLabel"), + metadata.get("focusLabel"), + metadata.get("reviewOpinion"), + review_context.get("actionLabel"), + review_context.get("focusLabel"), + review_context.get("prompt"), + ] + return "\n".join(str(item) for item in parts if item).lower() + + def _build_prompt_intent_text(message: str, review_context: Optional[Dict[str, Any]]) -> str: parts = [message or ""] if isinstance(review_context, dict): diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index d2acc001..520ff225 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -72,6 +72,15 @@ class SharedAIPlatformClient(Protocol): content: Optional[bytes] = None, ) -> dict: ... + def enhance_contract_generation( + self, + current_user: Dict[str, Any], + *, + prompt_runtime: Optional[Dict[str, Any]], + prompt: str, + draft_content: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: ... class RemoteSharedAIPlatformClient: @@ -282,12 +291,59 @@ class RemoteSharedAIPlatformClient: ) return self._normalize_remote_knowledge_document_item(payload) - def _context(self, current_user: Dict[str, Any]) -> SharedAIRequestContext: - return SharedAIRequestContext( - tenant_id=str(current_user.get("tenant_id") or ""), - user_id=str(current_user.get("id") or ""), - user_role=str(current_user.get("role") or "admin"), + + def enhance_contract_generation( + self, + current_user: Dict[str, Any], + *, + prompt_runtime: Optional[Dict[str, Any]], + prompt: str, + draft_content: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + template_key = str((prompt_runtime or {}).get("template_key") or "").strip() or None + payload = dict(metadata or {}) + payload["draft_content"] = draft_content + payload["prompt_version"] = (prompt_runtime or {}).get("prompt_version") + payload["prompt_key"] = (prompt_runtime or {}).get("prompt_key") + payload["prompt_type"] = (prompt_runtime or {}).get("prompt_type") + stream = self.stream_answer( + current_user, + None, + prompt, + review_context=payload, + template_key=template_key, ) + text_parts: list[str] = [] + for chunk in stream: + if not isinstance(chunk, str): + continue + for line in chunk.splitlines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + try: + event = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(event, Mapping): + continue + if event.get("type") == "text": + text_parts.append(str(event.get("content") or "")) + elif event.get("type") == "error": + raise RuntimeError(str(event.get("message") or "shared ai enhancement failed")) + content = "".join(text_parts).strip() + if not content: + raise RuntimeError("shared ai enhancement returned empty content") + return { + "content": content, + "provider": "shared-ai-platform", + "model": (prompt_runtime or {}).get("model"), + "prompt_key": (prompt_runtime or {}).get("prompt_key"), + "prompt_version": (prompt_runtime or {}).get("prompt_version"), + } def _format_event(self, payload: Dict[str, object]) -> str: return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 030ca70b..c95f9ff2 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -13,6 +13,7 @@ from urllib.parse import quote from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.generated_contract_version import GeneratedContractVersion @@ -22,6 +23,7 @@ from app.repositories.contract_template_repository import ContractTemplateReposi from app.repositories.generated_contract_repository import GeneratedContractRepository from app.repositories.generated_contract_version_repository import GeneratedContractVersionRepository from app.services.file_service import FileService +from app.services.legal_domain_registry import RISK_CLAUSE_SUGGESTION_RULES, document_type_candidates, generation_prompt_key_candidates, legal_query_terms from app.services.legal_article_service import LegalArticleService from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService from app.services.prompt_runtime_service import PromptRuntimeService @@ -37,45 +39,6 @@ class ExportFormat(str, Enum): MISSING_VALUE_PLACEHOLDER = "请补充" -DOCUMENT_TYPE_LEGAL_QUERY_TERMS = { - "procurement_goods": ["买卖合同", "货物采购", "交付", "风险转移", "验收", "质量", "发票", "付款", "质保", "违约", "民法典", "procurement", "breach"], - "technical_service": ["技术服务合同", "知识产权", "验收", "违约", "民法典"], - "software_development": ["软件开发合同", "知识产权", "交付", "验收", "民法典"], - "service_general": ["服务合同", "通用服务", "服务范围", "交付成果", "服务级别", "费用结算", "验收整改", "保密", "个人信息", "数据处理", "违约", "解除通知", "电子签署", "民法典"], - "labor_contract": ["劳动合同", "试用期", "社会保险", "竞业限制", "劳动合同法"], - "nda_commercial": ["保密协议", "商业秘密", "反不正当竞争法", "保密措施", "返还销毁", "违约责任", "电子签署", "民法典"], - "lease_house": ["房屋租赁合同", "租金", "押金", "解除", "民法典"], - "equity_transfer": ["股权转让", "公司法", "价款", "交割"], - "demand_letter": ["催告函", "履约催告", "违约", "送达", "证据留存", "民法典"], - "rescission_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"], - "contract_termination_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"], - "agency_distribution_agreement": ["代理", "经销", "渠道", "返利", "区域", "合规"], - "franchise_agreement": ["加盟", "特许经营", "品牌授权", "两店一年", "信息披露", "备案", "商标许可", "消费者", "培训", "督导"], - "intercompany_loan_agreement": ["企业间借款", "借款合同", "借款协议", "关联交易", "利息", "担保", "还款", "资金用途", "公司法", "民间借贷"], - "investment_agreement": ["投资协议", "投融资", "估值", "交割", "治理权利", "退出", "陈述保证", "公司法", "反垄断", "外商投资"], - "valuation_adjustment_agreement": ["对赌协议", "估值调整", "业绩承诺", "补偿机制", "回购", "公司法", "投融资", "九民纪要"], - "shareholder_resolution": ["股东会决议", "股东大会决议", "公司法", "公司章程", "表决权", "召集通知", "工商变更", "登记", "备案"], - "power_of_attorney": ["授权委托书", "委托代理", "授权范围", "转委托", "签章", "电子签名", "诉讼代理", "登记备案"], - "legal_opinion": ["法律意见书", "律师事务所", "尽职调查", "核查验证", "法律分析", "结论意见", "前提假设", "使用限制"], - "property_service": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"], - "property_service_agreement": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"], -} - -TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES = { - "property_service": ["property_service", "property_service_agreement"], - "property_service_agreement": ["property_service", "property_service_agreement"], - "rescission_notice": ["rescission_notice", "contract_termination_notice"], - "contract_termination_notice": ["rescission_notice", "contract_termination_notice"], - "termination_notice": ["rescission_notice", "contract_termination_notice"], -} - -GENERATION_PROMPT_KEY_ALIASES = { - "agency_distribution_agreement": ["agency_distribution"], - "franchise_agreement": ["franchise"], - "labor_service_agreement": ["labor_service"], - "sales_goods": ["sales"], -} - CONTRACT_LIFECYCLE_STATUSES = { "generated", "internal_review", @@ -112,7 +75,7 @@ CONTRACT_LIFECYCLE_TASK_PRIORITIES = { class ContractGenerationService: - def __init__(self, db: Session, review_service: Optional[ReviewService] = None): + def __init__(self, db: Session, review_service: Optional[ReviewService] = None, shared_ai_platform_client: Optional[SharedAIPlatformClient] = None): self.db = db self.repository = ContractTemplateRepository(db) self.generated_contract_repository = GeneratedContractRepository(db) @@ -123,6 +86,7 @@ class ContractGenerationService: self.element_schema_service = LegalDocumentElementSchemaService(db) self.prompt_runtime_service = PromptRuntimeService(db) self.review_service = review_service or ReviewService(db) + self.shared_ai_platform_client = shared_ai_platform_client def list_available_templates( self, @@ -138,15 +102,12 @@ class ContractGenerationService: if document_type_code is not None: normalized_document_type_code = document_type_code.strip().lower().replace("-", "_") - document_type_candidates = [None] + document_type_candidates_to_try = [None] if normalized_document_type_code is not None: - document_type_candidates = TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES.get( - normalized_document_type_code, - [normalized_document_type_code], - ) + document_type_candidates_to_try = document_type_candidates(normalized_document_type_code) templates_by_id: Dict[str, Any] = {} - for candidate in document_type_candidates: + for candidate in document_type_candidates_to_try: matched_templates = self.repository.list_enabled( tenant_id=tenant_id, contract_type=contract_type, @@ -224,27 +185,7 @@ class ContractGenerationService: self._validate_variable_values(template_variables, variable_values) # 替换模板内容 - generated_content = self._replace_variables(template.content, variable_values) - - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - - # 根据导出格式生成文件 - file_content, file_name = self._export_contract( - generated_content, - template.name, - timestamp, - export_format, - options - ) - - file_item = self.file_service.create_file_record( - current_user=current_user, - file_name=file_name, - content=file_content, - file_type="contract", - mime_type=self._get_mime_type(export_format), - require_admin=False, - ) + template_rendered_content = self._replace_variables(template.content, variable_values) resolved_document_type_code = document_type_code or template.document_type_code prompt_runtime = self.prompt_runtime_service.resolve_published_prompt( @@ -257,8 +198,101 @@ class ContractGenerationService: fallback_prompt_version="contract_generation.template_render.v1", ) prompt_runtime_trace = self.prompt_runtime_service.to_trace(prompt_runtime) + generated_content = template_rendered_content + generation_mode = "template_render" + enhancement_trace = { + "ai_enhancement_applied": False, + "fallback_used": False, + "fallback_reason": None, + "provider": None, + "model": None, + "prompt_key": prompt_runtime_trace.get("prompt_key"), + "prompt_version": prompt_runtime_trace.get("prompt_version"), + } + if options.get("ai_enhanced_generation"): + generation_mode = "ai_enhanced_generation" + try: + enhancement_result = self._generate_contract_with_ai_enhancement( + current_user=current_user, + prompt_runtime=prompt_runtime, + template=template, + variable_values=variable_values, + draft_content=template_rendered_content, + document_type_code=resolved_document_type_code, + elements_snapshot=elements_snapshot, + missing_fields=missing_fields, + related_legal_articles=related_legal_articles, + legal_source_query=legal_source_query, + ) + generated_content = enhancement_result["content"] + enhancement_trace = { + "ai_enhancement_applied": True, + "fallback_used": False, + "fallback_reason": None, + "provider": enhancement_result.get("provider"), + "model": enhancement_result.get("model"), + "prompt_key": enhancement_result.get("prompt_key") or prompt_runtime_trace.get("prompt_key"), + "prompt_version": enhancement_result.get("prompt_version") or prompt_runtime_trace.get("prompt_version"), + } + except Exception as exc: + generated_content = template_rendered_content + generation_mode = "template_render" + enhancement_trace = { + "ai_enhancement_applied": False, + "fallback_used": True, + "fallback_reason": str(exc)[:300], + "provider": "shared-ai-platform", + "model": prompt_runtime_trace.get("model"), + "prompt_key": prompt_runtime_trace.get("prompt_key"), + "prompt_version": prompt_runtime_trace.get("prompt_version"), + } + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + file_content, file_name = self._export_contract( + generated_content, + template.name, + timestamp, + export_format, + options + ) + + + file_item = self.file_service.create_file_record( + current_user=current_user, + file_name=file_name, + content=file_content, + file_type="contract", + mime_type=self._get_mime_type(export_format), + require_admin=False, + ) + generation_source_payload = { + **(generation_source or {}), + "generation_mode": generation_mode, + "template_rendered": True, + "ai_enhancement_applied": enhancement_trace["ai_enhancement_applied"], + "fallback_used": enhancement_trace["fallback_used"], + "fallback_reason": enhancement_trace["fallback_reason"], + "ai_provider": enhancement_trace["provider"], + "ai_model": enhancement_trace["model"], + "prompt_key": enhancement_trace["prompt_key"], + "prompt_version": enhancement_trace["prompt_version"], + } + generation_quality = self._build_generation_quality_summary( + generated_content=generated_content, + template_variables=template_variables, + missing_fields=missing_fields, + related_legal_articles=related_legal_articles, + generation_mode=generation_mode, + ) + if enhancement_trace["fallback_used"]: + generation_quality.setdefault("warning_issues", []) + generation_quality["warning_issues"].append("AI 增强生成未生效,已回退到模板渲染") persisted_options = { **options, + "_generation_mode": generation_mode, + "_generation_quality": generation_quality, + "_template_rendered": True, + "_ai_enhanced": enhancement_trace["ai_enhancement_applied"], "_template_category": template.template_category, "_industry_category": template.industry_category, "_document_type_code": resolved_document_type_code, @@ -266,7 +300,9 @@ class ContractGenerationService: "_prompt_runtime": { "contract_generation": prompt_runtime_trace, }, + "_generation_source": generation_source_payload, } + if elements_snapshot is not None: persisted_options["_elements_snapshot"] = elements_snapshot if element_schema_id is not None: @@ -280,7 +316,10 @@ class ContractGenerationService: if ai_hints is not None: persisted_options["_ai_hints"] = ai_hints if generation_source is not None: - persisted_options["_generation_source"] = generation_source + persisted_options["_generation_source"] = { + **generation_source_payload, + **generation_source, + } if legal_source_query is not None: persisted_options["_legal_source_query"] = legal_source_query @@ -321,6 +360,11 @@ class ContractGenerationService: "contract_type": template.contract_type, "document_type_code": resolved_document_type_code, "export_format": export_format.value, + "generation_mode": generation_mode, + "template_rendered": True, + "ai_enhanced": generation_mode == "ai_enhanced_generation", + "generation_quality_status": generation_quality.get("status"), + "generation_quality_score": generation_quality.get("score"), }, ) auto_review_summary = self._create_auto_review_summary(current_user, created_contract, generated_content) @@ -328,6 +372,83 @@ class ContractGenerationService: return self._to_generated_contract_item(created_contract) + + def _generate_contract_with_ai_enhancement( + self, + *, + current_user: Dict[str, Any], + prompt_runtime: Dict[str, Any], + template: Any, + variable_values: Dict[str, str], + draft_content: str, + document_type_code: Optional[str], + elements_snapshot: Optional[Dict[str, Any]], + missing_fields: Optional[List[Dict[str, Any]]], + related_legal_articles: Optional[List[Dict[str, Any]]], + legal_source_query: Optional[str], + ) -> Dict[str, Any]: + client = self._resolve_shared_ai_platform_client() + prompt = self._build_ai_enhanced_generation_prompt( + prompt_runtime=prompt_runtime, + template=template, + variable_values=variable_values, + draft_content=draft_content, + document_type_code=document_type_code, + elements_snapshot=elements_snapshot, + missing_fields=missing_fields, + related_legal_articles=related_legal_articles, + legal_source_query=legal_source_query, + ) + return client.enhance_contract_generation( + current_user, + prompt_runtime=prompt_runtime, + prompt=prompt, + draft_content=draft_content, + metadata={ + "contract_type": getattr(template, "contract_type", None), + "document_type_code": document_type_code, + "variable_values": variable_values, + "elements_snapshot": elements_snapshot, + "missing_fields": missing_fields or [], + "related_legal_articles": related_legal_articles or [], + "legal_source_query": legal_source_query, + }, + ) + + def _build_ai_enhanced_generation_prompt( + self, + *, + prompt_runtime: Dict[str, Any], + template: Any, + variable_values: Dict[str, str], + draft_content: str, + document_type_code: Optional[str], + elements_snapshot: Optional[Dict[str, Any]], + missing_fields: Optional[List[Dict[str, Any]]], + related_legal_articles: Optional[List[Dict[str, Any]]], + legal_source_query: Optional[str], + ) -> str: + prompt_parts = [ + str(prompt_runtime.get("system_prompt") or "你是合同生成增强助手,请在不编造业务事实的前提下润色和补强合同正文。"), + str(prompt_runtime.get("prompt_content") or "请基于草稿生成更完整、更专业的合同文本,保持法律措辞稳健。"), + f"模板名称:{getattr(template, 'name', '')}", + f"合同类型:{getattr(template, 'contract_type', '')}", + f"文书类型编码:{document_type_code or ''}", + f"变量值:{json.dumps(variable_values, ensure_ascii=False)}", + f"结构化要素:{json.dumps(elements_snapshot or {}, ensure_ascii=False)}", + f"缺失字段:{json.dumps(missing_fields or [], ensure_ascii=False)}", + f"法条来源:{json.dumps(related_legal_articles or [], ensure_ascii=False)}", + f"法条检索提示:{legal_source_query or ''}", + "要求:保留已知业务事实,不要编造未提供的金额、日期、主体信息;可优化结构、补强法律措辞、减少占位内容。", + f"合同草稿:\n{draft_content}", + ] + return "\n\n".join(part for part in prompt_parts if part) + + def _resolve_shared_ai_platform_client(self) -> SharedAIPlatformClient: + if self.shared_ai_platform_client is not None: + return self.shared_ai_platform_client + return build_shared_ai_platform_client(self.db) + def _build_generation_prompt_preferred_keys( self, document_type_code: Optional[str], @@ -355,8 +476,7 @@ class ContractGenerationService: normalized_value = cls._normalize_generation_prompt_key_part(value) if not normalized_value: return [] - aliases = GENERATION_PROMPT_KEY_ALIASES.get(normalized_value, []) - return [*aliases, normalized_value] + return generation_prompt_key_candidates(value) @staticmethod def _normalize_generation_prompt_key_part(value: Optional[str]) -> str: @@ -414,6 +534,7 @@ class ContractGenerationService: ) generation_source = { "mode": "structured_elements", + "generation_mode": "template_render", "template_selection": "explicit" if template_id else "auto", "document_type_code": resolved_document_type_code, "template_id": template.id, @@ -943,6 +1064,7 @@ class ContractGenerationService: "related_legal_article_count": len((generated_contract.export_options_json or {}).get("_related_legal_articles") or []), "export_format": generated_contract.export_format, "generated_by": generated_contract.generated_by, + "generation_quality": (generated_contract.export_options_json or {}).get("_generation_quality"), } try: @@ -973,6 +1095,82 @@ class ContractGenerationService: except Exception as exc: return self._build_auto_review_failure_summary(generated_contract, str(exc)) + def _build_generation_quality_summary( + self, + *, + generated_content: str, + template_variables: List[Dict[str, Any]], + missing_fields: Optional[List[Dict[str, Any]]], + related_legal_articles: Optional[List[Dict[str, Any]]], + generation_mode: str, + ) -> Dict[str, Any]: + placeholder_count = generated_content.count(MISSING_VALUE_PLACEHOLDER) + unresolved_variable_count = len(re.findall(r"\{\{\s*[^{}]+\s*\}\}", generated_content)) + missing_field_count = len(missing_fields or []) + required_variable_count = sum(1 for item in template_variables if item.get("required", True)) + legal_source_count = len(related_legal_articles or []) + section_coverage = self._build_generated_contract_section_coverage(generated_content) + blocking_issues: List[str] = [] + warning_issues: List[str] = [] + + if unresolved_variable_count: + blocking_issues.append("存在未替换模板变量") + if missing_field_count: + warning_issues.append("存在缺失要素占位") + if placeholder_count: + warning_issues.append("正文包含请补充占位") + if not section_coverage["has_payment"]: + warning_issues.append("缺少付款/价款安排") + if not section_coverage["has_breach"]: + warning_issues.append("缺少违约责任安排") + if not section_coverage["has_dispute_resolution"]: + warning_issues.append("缺少争议解决条款") + if legal_source_count == 0: + warning_issues.append("缺少法条/知识来源辅助") + + score = 100 + score -= unresolved_variable_count * 20 + score -= missing_field_count * 6 + score -= placeholder_count * 4 + score -= sum(1 for value in section_coverage.values() if not value) * 5 + if legal_source_count == 0: + score -= 8 + score = max(0, min(100, score)) + + if blocking_issues: + status_value = "blocked" + elif warning_issues: + status_value = "needs_review" + else: + status_value = "ready_for_review" + + return { + "schema_version": "generation_quality.v1", + "status": status_value, + "score": score, + "generation_mode": generation_mode, + "required_variable_count": required_variable_count, + "missing_field_count": missing_field_count, + "placeholder_count": placeholder_count, + "unresolved_variable_count": unresolved_variable_count, + "legal_source_count": legal_source_count, + "section_coverage": section_coverage, + "blocking_issues": blocking_issues, + "warning_issues": warning_issues, + } + + @staticmethod + def _build_generated_contract_section_coverage(content: str) -> Dict[str, bool]: + return { + "has_parties": bool(re.search(r"甲方|乙方|委托方|受托方|采购方|供应方|出租方|承租方", content)), + "has_subject_matter": bool(re.search(r"服务范围|合同标的|采购标的|项目范围|合作事项|授权范围|租赁物", content)), + "has_payment": bool(re.search(r"付款|价款|费用|服务费|租金|发票|结算|金额", content)), + "has_acceptance_or_delivery": bool(re.search(r"验收|交付|交货|交割|履行|整改", content)), + "has_breach": bool(re.search(r"违约|赔偿|责任承担|违约金", content)), + "has_dispute_resolution": bool(re.search(r"争议解决|仲裁|管辖|法院|诉讼", content)), + "has_signature": bool(re.search(r"签署日期|签约日期|签字|签章|盖章", content)), + } + def _build_auto_review_summary( self, generated_contract: GeneratedContract, @@ -1052,6 +1250,7 @@ class ContractGenerationService: "report_file_name": report_file_name, "chat_url": chat_url, "prompt_runtime": prompt_runtime, + "generation_quality": (generated_contract.export_options_json or {}).get("_generation_quality"), } def _build_auto_review_failure_summary(self, generated_contract: GeneratedContract, failure_message: str) -> Dict[str, Any]: @@ -1194,6 +1393,152 @@ class ContractGenerationService: return "text/html; charset=utf-8" return "text/plain; charset=utf-8" + def _build_clause_completion_suggestions( + self, + generation_quality: Optional[Dict[str, Any]], + auto_review: Optional[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + suggestions = self._build_review_risk_clause_suggestions(auto_review) + suggestions.extend(self._build_quality_clause_suggestions(generation_quality)) + return self._dedupe_clause_completion_suggestions(suggestions)[:6] + + def _build_review_risk_clause_suggestions(self, auto_review: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not isinstance(auto_review, dict): + return [] + if str(auto_review.get("status") or "").lower() != "completed": + return [] + + risk_texts = self._extract_auto_review_risk_texts(auto_review) + overall_risk = str(auto_review.get("risk_level") or "").strip().lower() + suggestions: List[Dict[str, Any]] = [] + seen_codes: set[str] = set() + for rule in RISK_CLAUSE_SUGGESTION_RULES: + if not any(keyword.lower() in text for keyword in rule.get("match_keywords", []) for text in risk_texts): + continue + suggestion = { + "code": str(rule["code"]), + "title": str(rule["title"]), + "priority": str(rule["priority"]), + "recommendation": str(rule["recommendation"]), + "source": "risk_review", + "reason": str(rule.get("reason") or "自动审查发现相关高风险事项。"), + "risk_level": overall_risk or "high", + } + if suggestion["code"] not in seen_codes: + suggestions.append(suggestion) + seen_codes.add(suggestion["code"]) + + if overall_risk == "high" and not suggestions: + suggestions.append( + { + "code": "high_risk_manual_clause_review", + "title": "高风险条款人工补强", + "priority": "high", + "recommendation": "结合自动审查识别出的高风险问题,逐条补充责任边界、付款条件、解除机制和争议解决安排。", + "source": "risk_review", + "reason": "自动审查结论为高风险,建议优先补强核心条款。", + "risk_level": overall_risk, + } + ) + return suggestions + + def _extract_auto_review_risk_texts(self, auto_review: Optional[Dict[str, Any]]) -> List[str]: + if not isinstance(auto_review, dict): + return [] + texts = [ + str(auto_review.get("review_opinion") or ""), + str(auto_review.get("recommendation") or ""), + str(auto_review.get("status_detail") or ""), + str(auto_review.get("failure_reason") or ""), + str(auto_review.get("next_action") or ""), + ] + risk_items = auto_review.get("risk_items") + if isinstance(risk_items, list): + for item in risk_items: + if not isinstance(item, dict): + continue + texts.extend( + [ + str(item.get("title") or ""), + str(item.get("description") or ""), + str(item.get("suggestion") or ""), + str(item.get("risk_summary") or ""), + ] + ) + return [text.lower() for text in texts if text] + + def _build_quality_clause_suggestions(self, generation_quality: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + suggestions: List[Dict[str, Any]] = [] + quality = generation_quality or {} + coverage = quality.get("section_coverage") or {} + warning_issues = list(quality.get("warning_issues") or []) + + missing_sections = [] + if not coverage.get("has_payment", True): + missing_sections.append(("payment", "付款与结算条款", "补充付款节点、发票条件、逾期责任和付款前置条件。")) + if not coverage.get("has_acceptance_or_delivery", True): + missing_sections.append(("acceptance_delivery", "交付与验收条款", "补充交付成果、验收标准、验收期限和异议处理流程。")) + if not coverage.get("has_breach", True): + missing_sections.append(("breach", "违约责任条款", "补充违约触发条件、违约金/赔偿计算口径、责任上限及例外。")) + if not coverage.get("has_dispute_resolution", True): + missing_sections.append(("dispute_resolution", "争议解决条款", "补充管辖法院或仲裁机构、适用法律和争议处理流程。")) + + for code, title, recommendation in missing_sections: + suggestions.append( + { + "code": code, + "title": title, + "priority": "high", + "recommendation": recommendation, + "source": "quality", + "reason": "生成质量检查发现合同结构缺失关键条款。", + "risk_level": None, + } + ) + + if quality.get("placeholder_count"): + suggestions.append( + { + "code": "placeholder_cleanup", + "title": "清理占位内容", + "priority": "high", + "recommendation": "将正文中的“请补充”替换为明确的业务事实或约定,避免对外发送半成品。", + "source": "quality", + "reason": "生成正文仍包含占位内容。", + "risk_level": None, + } + ) + if warning_issues and not suggestions: + suggestions.append( + { + "code": "manual_quality_review", + "title": "人工质量复核", + "priority": "medium", + "recommendation": "结合生成质量提示逐项复核合同完整性,并确认审查报告中的风险项是否已处理。", + "source": "quality", + "reason": "生成质量检查发现警告项,需要人工复核。", + "risk_level": None, + } + ) + return suggestions + + def _dedupe_clause_completion_suggestions(self, suggestions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + priority_order = {"high": 0, "medium": 1, "low": 2} + deduped: Dict[str, Dict[str, Any]] = {} + for item in suggestions: + code = str(item.get("code") or "").strip() + if not code: + continue + existing = deduped.get(code) + if existing is None: + deduped[code] = item + continue + existing_rank = priority_order.get(str(existing.get("priority") or "low"), 99) + current_rank = priority_order.get(str(item.get("priority") or "low"), 99) + if current_rank < existing_rank: + deduped[code] = item + return sorted(deduped.values(), key=lambda item: (priority_order.get(str(item.get("priority") or "low"), 99), str(item.get("title") or ""))) + def _to_generated_contract_item(self, generated_contract: GeneratedContract) -> Dict: auto_review = self._resolve_auto_review_summary(generated_contract) export_options = generated_contract.export_options_json or {} @@ -1209,6 +1554,8 @@ class ContractGenerationService: ai_hints = export_options.get("_ai_hints") or [] generation_source = export_options.get("_generation_source") legal_source_query = export_options.get("_legal_source_query") + generation_quality = export_options.get("_generation_quality") or {} + clause_suggestions = self._build_clause_completion_suggestions(generation_quality, auto_review) prompt_runtime = export_options.get("_prompt_runtime") or {} generated_html = self._build_generated_contract_html( generated_contract.generated_content, @@ -1239,6 +1586,8 @@ class ContractGenerationService: "file_content_base64": generated_contract.file_content_base64, "preview_url": f"/api/v1/contracts/{generated_contract.id}/preview", "download_url": f"/api/v1/contracts/{generated_contract.id}/download", + "generation_quality": generation_quality, + "clause_completion_suggestions": clause_suggestions, "auto_review": auto_review, "lifecycle": self._build_contract_lifecycle(generated_contract), "metadata": { @@ -1260,6 +1609,8 @@ class ContractGenerationService: "related_legal_article_count": len(related_legal_articles), "generation_source": generation_source, "legal_source_query": legal_source_query, + "generation_quality": generation_quality, + "clause_completion_suggestions": clause_suggestions, "prompt_runtime": prompt_runtime, "export_options": { key: value @@ -1287,6 +1638,8 @@ class ContractGenerationService: "file_size": item["file_size"], "preview_url": item["preview_url"], "download_url": item["download_url"], + "generation_quality": item.get("generation_quality"), + "clause_completion_suggestions": item.get("clause_completion_suggestions"), "auto_review": item["auto_review"], "lifecycle": item["lifecycle"], "missing_field_count": item["metadata"].get("missing_field_count", 0), @@ -1300,6 +1653,8 @@ class ContractGenerationService: "element_schema_id": item["metadata"].get("element_schema_id"), "element_schema_version_label": item["metadata"].get("element_schema_version_label"), "generation_source": item["metadata"].get("generation_source"), + "generation_quality": item["metadata"].get("generation_quality"), + "clause_completion_suggestions": item["metadata"].get("clause_completion_suggestions"), "prompt_runtime": item["metadata"].get("prompt_runtime"), "missing_field_count": item["metadata"].get("missing_field_count", 0), "related_legal_article_count": item["metadata"].get("related_legal_article_count", 0), @@ -1869,7 +2224,7 @@ class ContractGenerationService: template_name: str, ) -> str: terms: List[str] = [] - for term in DOCUMENT_TYPE_LEGAL_QUERY_TERMS.get(document_type_code, []): + for term in legal_query_terms(document_type_code): cls._append_unique_text(terms, term) cls._append_unique_text(terms, template_name) flattened = cls._flatten_values(elements) diff --git a/backend/app/services/legal_domain_registry.py b/backend/app/services/legal_domain_registry.py new file mode 100644 index 00000000..6d80d7c3 --- /dev/null +++ b/backend/app/services/legal_domain_registry.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from typing import Optional + +DOCUMENT_TYPE_LEGAL_QUERY_TERMS = { + "procurement_goods": ["买卖合同", "货物采购", "交付", "风险转移", "验收", "质量", "发票", "付款", "质保", "违约", "民法典", "procurement", "breach"], + "technical_service": ["技术服务合同", "知识产权", "验收", "违约", "民法典"], + "software_development": ["软件开发合同", "知识产权", "交付", "验收", "民法典"], + "service_general": ["服务合同", "通用服务", "服务范围", "交付成果", "服务级别", "费用结算", "验收整改", "保密", "个人信息", "数据处理", "违约", "解除通知", "电子签署", "民法典"], + "labor_contract": ["劳动合同", "试用期", "社会保险", "竞业限制", "劳动合同法"], + "nda_commercial": ["保密协议", "商业秘密", "反不正当竞争法", "保密措施", "返还销毁", "违约责任", "电子签署", "民法典"], + "lease_house": ["房屋租赁合同", "租金", "押金", "解除", "民法典"], + "equity_transfer": ["股权转让", "公司法", "价款", "交割"], + "demand_letter": ["催告函", "履约催告", "违约", "送达", "证据留存", "民法典"], + "rescission_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"], + "contract_termination_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"], + "agency_distribution_agreement": ["代理", "经销", "渠道", "返利", "区域", "合规"], + "franchise_agreement": ["加盟", "特许经营", "品牌授权", "两店一年", "信息披露", "备案", "商标许可", "消费者", "培训", "督导"], + "intercompany_loan_agreement": ["企业间借款", "借款合同", "借款协议", "关联交易", "利息", "担保", "还款", "资金用途", "公司法", "民间借贷"], + "investment_agreement": ["投资协议", "投融资", "估值", "交割", "治理权利", "退出", "陈述保证", "公司法", "反垄断", "外商投资"], + "valuation_adjustment_agreement": ["对赌协议", "估值调整", "业绩承诺", "补偿机制", "回购", "公司法", "投融资", "九民纪要"], + "shareholder_resolution": ["股东会决议", "股东大会决议", "公司法", "公司章程", "表决权", "召集通知", "工商变更", "登记", "备案"], + "power_of_attorney": ["授权委托书", "委托代理", "授权范围", "转委托", "签章", "电子签名", "诉讼代理", "登记备案"], + "legal_opinion": ["法律意见书", "律师事务所", "尽职调查", "核查验证", "法律分析", "结论意见", "前提假设", "使用限制"], + "property_service": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"], + "property_service_agreement": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"], +} + +TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES = { + "property_service": ["property_service", "property_service_agreement"], + "property_service_agreement": ["property_service", "property_service_agreement"], + "rescission_notice": ["rescission_notice", "contract_termination_notice"], + "contract_termination_notice": ["rescission_notice", "contract_termination_notice"], + "termination_notice": ["rescission_notice", "contract_termination_notice"], +} + +GENERATION_PROMPT_KEY_ALIASES = { + "agency_distribution_agreement": ["agency_distribution"], + "franchise_agreement": ["franchise"], + "labor_service_agreement": ["labor_service"], + "sales_goods": ["sales"], +} + +REVIEW_PROMPT_DOMAIN_KEY_BY_CONTRACT_TYPE = { + "investment": "contract_review_investment_finance", + "investment_agreement": "contract_review_investment_finance", + "equity_transfer": "contract_review_investment_finance", + "equity_transfer_agreement": "contract_review_investment_finance", + "intercompany_loan": "contract_review_investment_finance", + "intercompany_loan_agreement": "contract_review_investment_finance", + "valuation_adjustment": "contract_review_investment_finance", + "valuation_adjustment_agreement": "contract_review_investment_finance", + "shareholder_resolution": "contract_review_corporate_governance", + "power_of_attorney": "contract_review_corporate_governance", + "legal_opinion": "contract_review_corporate_governance", + "labor": "contract_review_employment_hr", + "employment": "contract_review_employment_hr", + "labor_contract": "contract_review_employment_hr", + "labor_service": "contract_review_employment_hr", + "labor_service_agreement": "contract_review_employment_hr", + "lease": "contract_review_real_estate_property", + "lease_house": "contract_review_real_estate_property", + "property_service": "contract_review_real_estate_property", + "property_service_agreement": "contract_review_real_estate_property", + "cooperation_framework": "contract_review_channel_franchise", + "agency_distribution": "contract_review_channel_franchise", + "agency_distribution_agreement": "contract_review_channel_franchise", + "franchise": "contract_review_channel_franchise", + "franchise_agreement": "contract_review_channel_franchise", + "cooperation": "contract_review_channel_franchise", + "framework": "contract_review_channel_franchise", + "sales": "contract_review_channel_franchise", + "technical_service": "contract_review_tech_saas_data_compliance", + "software_development": "contract_review_tech_saas_data_compliance", + "technology_development": "contract_review_tech_saas_data_compliance", + "software_saas": "contract_review_tech_saas_data_compliance", + "nda": "contract_review_tech_saas_data_compliance", + "nda_commercial": "contract_review_tech_saas_data_compliance", + "license": "contract_review_tech_saas_data_compliance", +} + +BUSINESS_AUXILIARY_PROMPT_KEYS = { + "obligation_extraction": "obligation_extraction_default", + "filing_materials_checklist": "filing_materials_checklist_default", + "negotiation_strategy": "negotiation_strategy_default", + "legal_opinion_boundary_fallback": "legal_opinion_boundary_fallback_default", +} + +RISK_CLAUSE_SUGGESTION_RULES = [ + { + "code": "renewal_notice_guardrail", + "title": "续约与退出机制条款", + "priority": "high", + "recommendation": "补充自动续约通知期、明确不续约通知方式,并增加到期退出和交接安排。", + "reason": "自动续约或续期风险需要通过通知期限和退出机制控制。", + "match_keywords": ["自动续约", "续约", "续期", "evergreen", "renewal"], + }, + { + "code": "liability_carve_out", + "title": "责任限制例外条款", + "priority": "high", + "recommendation": "补充责任上限的 carve-out,至少覆盖故意、重大过失、保密违约和知识产权侵权。", + "reason": "免责或责任限制过宽时,应明确无上限或例外场景。", + "match_keywords": ["免责", "责任限制", "赔偿上限", "liability", "carve-out", "重大过失"], + }, + { + "code": "payment_acceptance_alignment", + "title": "付款与验收联动条款", + "priority": "high", + "recommendation": "补充付款节点、验收标准、发票条件和逾期责任,避免先付款后验收的失衡安排。", + "reason": "付款条件不清或与验收脱节,容易形成履约和回款争议。", + "match_keywords": ["付款", "价款", "发票", "验收", "结算", "payment", "invoice"], + }, + { + "code": "delivery_acceptance_detail", + "title": "交付成果与验收标准条款", + "priority": "high", + "recommendation": "补充交付物清单、交付时间、验收标准、异议期和整改闭环。", + "reason": "交付或验收义务不清,会直接影响履约责任认定。", + "match_keywords": ["交付", "交货", "验收", "成果", "deliver", "acceptance"], + }, + { + "code": "confidentiality_scope", + "title": "保密范围与例外条款", + "priority": "medium", + "recommendation": "补充保密信息范围、保密期限、法定披露例外、返还销毁义务及违约责任。", + "reason": "保密条款过于笼统时,执行和举证都容易失焦。", + "match_keywords": ["保密", "商业秘密", "confidential", "nda"], + }, + { + "code": "ip_ownership_and_license", + "title": "知识产权归属与许可条款", + "priority": "high", + "recommendation": "明确交付成果、源代码、文档等知识产权归属,以及许可范围、使用限制和侵权责任。", + "reason": "知识产权归属不清时,后续使用、交付和维权都会受影响。", + "match_keywords": ["知识产权", "源代码", "软件著作权", "专利", "商标", "ip", "copyright"], + }, + { + "code": "termination_trigger_and_cure", + "title": "解除触发与补救期条款", + "priority": "medium", + "recommendation": "补充解除条件、补救期限、通知送达方式和解除后的结算/返还安排。", + "reason": "解除或终止机制不明确,会放大违约处理争议。", + "match_keywords": ["解除", "终止", "termination", "terminate", "补救期"], + }, + { + "code": "dispute_resolution_forum", + "title": "争议解决与适用法律条款", + "priority": "medium", + "recommendation": "明确适用法律、管辖法院或仲裁机构,并约定证据保留和送达方式。", + "reason": "争议解决路径不明会增加后续维权和执行成本。", + "match_keywords": ["争议解决", "仲裁", "管辖", "法院", "jurisdiction", "arbitration"], + }, +] +TEMPLATE_CATEGORY_LABELS = { + "general-business": "通用商务", + "employment-hr": "人事用工", + "procurement-supply": "采购供应链", + "sales-commercial": "销售与商业合作", + "commercial-cooperation": "商业合作", + "service-delivery": "服务交付", + "commercial-compliance": "合规与保密", + "investment-finance": "投融资与金融", + "corporate-governance": "公司治理", + "real-estate": "不动产与物业", + "notices-letters": "通知函件", +} + + +def normalize_domain_key(value: Optional[str]) -> str: + return str(value or "").strip().lower().replace("-", "_") + + +def document_type_candidates(document_type_code: Optional[str]) -> list[str]: + normalized = normalize_domain_key(document_type_code) + if not normalized: + return [] + return TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES.get(normalized, [normalized]) + + +def generation_prompt_key_candidates(value: Optional[str]) -> list[str]: + normalized = normalize_domain_key(value) + if not normalized: + return [] + return [*GENERATION_PROMPT_KEY_ALIASES.get(normalized, []), normalized] + + +def review_prompt_domain_key(contract_type: Optional[str]) -> Optional[str]: + return REVIEW_PROMPT_DOMAIN_KEY_BY_CONTRACT_TYPE.get(normalize_domain_key(contract_type)) + + +def legal_query_terms(document_type_code: Optional[str]) -> list[str]: + return DOCUMENT_TYPE_LEGAL_QUERY_TERMS.get(normalize_domain_key(document_type_code), []) diff --git a/backend/app/services/prompt_asset_service.py b/backend/app/services/prompt_asset_service.py index cb3d4fbe..8007120c 100644 --- a/backend/app/services/prompt_asset_service.py +++ b/backend/app/services/prompt_asset_service.py @@ -37,7 +37,7 @@ class PromptAssetService: "clause_recommendation": {"suite_key": "phase13_clause_generation", "sample_count": 6}, "wording_rewrite": {"suite_key": "phase15_wording_rewrite", "sample_count": 3}, "regulation_impact": {"suite_key": "future_regulation_impact", "sample_count": 0}, - "business_auxiliary": {"suite_key": "future_business_auxiliary", "sample_count": 0}, + "business_auxiliary": {"suite_key": "phase15_business_auxiliary", "sample_count": 8}, } def __init__(self, db: Session): diff --git a/backend/app/services/prompt_golden_sample_evaluator.py b/backend/app/services/prompt_golden_sample_evaluator.py index c6be468a..0837906e 100644 --- a/backend/app/services/prompt_golden_sample_evaluator.py +++ b/backend/app/services/prompt_golden_sample_evaluator.py @@ -48,9 +48,9 @@ class PromptGoldenSampleEvaluator: "min_samples": 0, }, "business_auxiliary": { - "suite_key": "future_business_auxiliary", - "sample_family": "future_business_auxiliary", - "min_samples": 0, + "suite_key": "phase15_business_auxiliary", + "sample_family": "phase15_business_auxiliary", + "min_samples": 8, }, } @@ -62,7 +62,9 @@ class PromptGoldenSampleEvaluator: 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] + resolved_root = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[3] + manifest_path = resolved_root / "project-docs" / "phase15-prompt-golden-samples" / "prompt-golden-suite-manifest.json" + self.repo_root = resolved_root if manifest_path.exists() 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 = ( @@ -205,7 +207,7 @@ class PromptGoldenSampleEvaluator: if prompt_type == "wording_rewrite": return self._wording_rewrite_samples() if prompt_type == "business_auxiliary": - return [] + return self._business_auxiliary_samples() return [] def _evaluate_sample_with_llm( @@ -517,6 +519,31 @@ class PromptGoldenSampleEvaluator: ) return results + def _business_auxiliary_samples(self) -> list[dict[str, Any]]: + path = self.repo_root / "project-docs" / "phase15-prompt-golden-samples" / "business-auxiliary-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": "business_auxiliary", + "sample_family": "phase15_business_auxiliary", + "intent": item.get("intent"), + "input": input_payload, + "expected": expected_payload, + "expected_citation_required": False, + "input_summary": str(input_payload.get("message") 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 {} diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index 9bebabad..d9e9ced1 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -33,6 +33,7 @@ from app.models.review_task import ReviewTask from app.schemas.ai_contract import build_ai_run_trace, build_parsed_document, build_review_workflow_state from app.repositories.legal_article_repository import LegalArticleRepository from app.repositories.review_result_repository import ReviewResultRepository +from app.services.legal_domain_registry import review_prompt_domain_key from app.services.file_service import FileService from app.services.legal_article_service import LegalArticleService from app.services.legal_review_execution_gateway import build_legal_review_execution_gateway @@ -218,6 +219,7 @@ STRUCTURE_MISSING_SECTION_RULES = [ {"type": "acceptance", "message": "验收标准或验收流程"}, {"type": "confidentiality", "message": "保密范围与期限"}, {"type": "breach", "message": "违约责任安排"}, + {"type": "termination", "message": "解除或终止条款"}, {"type": "dispute_resolution", "message": "争议解决条款"}, ] PARTY_NAME_PATTERNS = { @@ -261,32 +263,9 @@ PAYMENT_STAGE_GO_LIVE = "go_live" PAYMENT_STAGE_FINAL = "final" PAYMENT_STAGE_WARRANTY = "warranty_retention" PAYMENT_STAGE_MILESTONE = "milestone" -PAYMENT_LABEL_HINTS = [ - ("棰勪粯娆?", PAYMENT_STAGE_ADVANCE), - ("棣栨湡娆?", PAYMENT_STAGE_ADVANCE), - ("瀹氶噾", PAYMENT_STAGE_ADVANCE), - ("杩涘害娆?", PAYMENT_STAGE_MILESTONE), - ("瀹炴柦娆?", PAYMENT_STAGE_MILESTONE), - ("灏炬", PAYMENT_STAGE_FINAL), - ("璐ㄤ繚閲?", PAYMENT_STAGE_WARRANTY), -] -LIABILITY_CARVE_OUT_MAP = [ - ("鏁呮剰", "intentional_misconduct"), - ("閲嶅ぇ杩囧け", "gross_negligence"), - ("淇濆瘑", "confidentiality"), - ("鍟嗕笟绉樺瘑", "confidentiality"), - ("鐭ヨ瘑浜ф潈", "intellectual_property"), - ("钁椾綔鏉?", "intellectual_property"), - ("婧愪唬鐮?", "intellectual_property"), - ("杞欢钁椾綔鏉?", "intellectual_property"), - ("杩濈害", "breach"), -] -IP_OWNERSHIP_PATTERNS = [ - (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(褰掔敳鏂规墍鏈夊畨褰掑睘浜庣敳鏂圅鐢叉柟鎵€鏈?)", re.IGNORECASE), "party_a"), - (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(褰掍箼鏂规墍鏈夊畨褰掑睘浜庝箼鏂圅涔欐柟鎵€鏈?)", re.IGNORECASE), "party_b"), - (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(鍏卞悓鎵€鏈夊弻鏂瑰叡鏈夊弻鏂瑰叡鍚屼韩鏈?)", re.IGNORECASE), "shared"), - (re.compile(r"(鐭ヨ瘑浜ф潈|钁椾綔鏉?|婧愪唬鐮?|杞欢钁椾綔鏉?)[^\n銆傦紱;]*(璁稿彲鐢叉柟浣跨敤鎺堟潈鐢叉柟浣跨敤)", re.IGNORECASE), "licensed_to_party_a"), -] +PAYMENT_LABEL_HINTS = [] +LIABILITY_CARVE_OUT_MAP = [] +IP_OWNERSHIP_PATTERNS = [] _REAL_CHINESE_CONTRACT_HINTS = { "service": ["\u670d\u52a1\u5408\u540c", "\u670d\u52a1\u534f\u8bae", "\u6280\u672f\u670d\u52a1", "\u8fd0\u7ef4\u670d\u52a1", "\u54a8\u8be2\u670d\u52a1"], @@ -314,44 +293,6 @@ for _contract_type, _hints in _REAL_CHINESE_CONTRACT_HINTS.items(): if _hint not in existing: existing.append(_hint) -REVIEW_PROMPT_DOMAIN_KEY_BY_CONTRACT_TYPE = { - "investment": "contract_review_investment_finance", - "investment_agreement": "contract_review_investment_finance", - "equity_transfer": "contract_review_investment_finance", - "equity_transfer_agreement": "contract_review_investment_finance", - "intercompany_loan": "contract_review_investment_finance", - "intercompany_loan_agreement": "contract_review_investment_finance", - "valuation_adjustment": "contract_review_investment_finance", - "valuation_adjustment_agreement": "contract_review_investment_finance", - "shareholder_resolution": "contract_review_corporate_governance", - "power_of_attorney": "contract_review_corporate_governance", - "legal_opinion": "contract_review_corporate_governance", - "labor": "contract_review_employment_hr", - "employment": "contract_review_employment_hr", - "labor_contract": "contract_review_employment_hr", - "labor_service": "contract_review_employment_hr", - "labor_service_agreement": "contract_review_employment_hr", - "lease": "contract_review_real_estate_property", - "lease_house": "contract_review_real_estate_property", - "property_service": "contract_review_real_estate_property", - "property_service_agreement": "contract_review_real_estate_property", - "cooperation_framework": "contract_review_channel_franchise", - "agency_distribution": "contract_review_channel_franchise", - "agency_distribution_agreement": "contract_review_channel_franchise", - "franchise": "contract_review_channel_franchise", - "franchise_agreement": "contract_review_channel_franchise", - "cooperation": "contract_review_channel_franchise", - "framework": "contract_review_channel_franchise", - "sales": "contract_review_channel_franchise", - "technical_service": "contract_review_tech_saas_data_compliance", - "software_development": "contract_review_tech_saas_data_compliance", - "technology_development": "contract_review_tech_saas_data_compliance", - "software_saas": "contract_review_tech_saas_data_compliance", - "nda": "contract_review_tech_saas_data_compliance", - "nda_commercial": "contract_review_tech_saas_data_compliance", - "license": "contract_review_tech_saas_data_compliance", -} - _REAL_CHINESE_CLAUSE_KEYWORDS = { "payment": ["\u4ed8\u6b3e", "\u4ef7\u6b3e", "\u91d1\u989d", "\u53d1\u7968", "\u8d39\u7528", "\u5c3e\u6b3e", "\u8d28\u4fdd\u91d1", "\u9884\u4ed8\u6b3e"], "acceptance": ["\u9a8c\u6536", "\u9a8c\u6536\u6807\u51c6", "\u4e66\u9762\u9a8c\u6536", "\u6d4b\u8bd5", "\u8bd5\u8fd0\u884c"], @@ -1020,6 +961,8 @@ class ReviewService: "relationship_count": len(contract_structure.get("clause_relationships") or []), "high_confidence_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_HIGH), "weak_hint_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_WEAK), + "quality_score": self._build_review_quality_score(contract_structure, risk_items), + "quality_warnings": self._build_review_quality_warnings(contract_structure, risk_items), } enriched_basic_info = { "contract_type": base_basic_info.get("contract_type") or review.contract_type or "未分类合同", @@ -1041,6 +984,36 @@ class ReviewService: "rendered_report": result.get("rendered_report"), } + def _build_review_quality_score(self, contract_structure: Dict[str, Any], risk_items: List[Dict[str, Any]]) -> int: + missing_sections = contract_structure.get("missing_sections") or [] + clause_count = int(contract_structure.get("clause_count") or 0) + evidence_count = sum(1 for item in risk_items if item.get("evidence", {}).get("quote")) + relationship_count = len(contract_structure.get("clause_relationships") or []) + score = 100 + score -= min(len(missing_sections), 8) * 7 + if clause_count < 5: + score -= 12 + if risk_items and evidence_count < len(risk_items): + score -= min(len(risk_items) - evidence_count, 5) * 5 + if not relationship_count: + score -= 6 + return max(0, min(100, score)) + + def _build_review_quality_warnings(self, contract_structure: Dict[str, Any], risk_items: List[Dict[str, Any]]) -> List[str]: + warnings: List[str] = [] + missing_sections = contract_structure.get("missing_sections") or [] + if missing_sections: + warnings.append(f"缺少关键条款:{'、'.join(str(item) for item in missing_sections[:5])}") + if int(contract_structure.get("clause_count") or 0) < 5: + warnings.append("识别到的条款数量偏少,建议补充完整合同正文后复核") + if risk_items: + evidence_count = sum(1 for item in risk_items if item.get("evidence", {}).get("quote")) + if evidence_count < len(risk_items): + warnings.append("部分风险项缺少原文证据定位") + if not contract_structure.get("clause_relationships"): + warnings.append("关键条款之间的付款、验收、责任联动关系不足") + return warnings + def _merge_contract_structure( self, derived_contract_structure: Dict[str, Any], @@ -1436,10 +1409,14 @@ class ReviewService: missing.append(item["message"]) if clause_type_counts.get("payment") and not clause_type_counts.get("acceptance") and "验收标准或验收流程" not in missing: missing.append("验收标准或验收流程") - if contract_type in {"development", "service", "license"} and not clause_type_counts.get("intellectual_property"): + if contract_type in {"development", "service", "license", "technical_service", "software_development", "software_saas", "nda"} and not clause_type_counts.get("intellectual_property"): missing.append("知识产权归属或许可安排") - if contract_type in {"development", "service", "license"} and clause_type_counts.get("intellectual_property") and not intellectual_property_owner: + if contract_type in {"development", "service", "license", "technical_service", "software_development", "software_saas", "nda"} and clause_type_counts.get("intellectual_property") and not intellectual_property_owner: missing.append("知识产权归属口径") + if contract_type in {"property_service", "property_service_agreement", "lease", "lease_house"} and not clause_type_counts.get("liability"): + missing.append("安全协助、维修或责任边界") + if contract_type in {"investment", "investment_agreement", "equity_transfer", "intercompany_loan", "valuation_adjustment"} and not clause_type_counts.get("liability"): + missing.append("陈述保证、违约或责任承担安排") return missing def _extract_payment_milestones(self, clauses: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -2215,7 +2192,7 @@ class ReviewService: def _build_review_prompt_preferred_keys(self, contract_type: Optional[str]) -> List[str]: normalized_contract_type = self._normalize_review_prompt_contract_type(contract_type) preferred_keys: List[str] = [] - domain_key = REVIEW_PROMPT_DOMAIN_KEY_BY_CONTRACT_TYPE.get(normalized_contract_type) + domain_key = review_prompt_domain_key(normalized_contract_type) if domain_key: preferred_keys.append(domain_key) if normalized_contract_type: diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 2fb84d6d..902bcf5e 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -31,6 +31,8 @@ class StubTenantService: class StubContractGenerationService: + def __init__(self): + self.last_generate_options = None def _auto_review_summary(self, review_id="review-auto-123", status="completed"): return { "review_id": review_id, @@ -463,6 +465,7 @@ class StubContractGenerationService: element_schema_version_label=None, fill_missing_variables=False, ): + self.last_generate_options = options if template_id == "tpl-missing": raise HTTPException(status_code=404, detail="template not found") if "甲方名称" in variable_values: @@ -475,6 +478,16 @@ class StubContractGenerationService: contract_type = "procurement" document_type_code = document_type_code or "procurement_goods" template_name = "Procurement Template" + metadata_generation_source = { + "generation_mode": "ai_enhanced_generation" if options.get("ai_enhanced_generation") else "template_render", + "ai_enhancement_applied": bool(options.get("ai_enhanced_generation") and not options.get("simulate_ai_fallback")), + "fallback_used": bool(options.get("simulate_ai_fallback")), + "fallback_reason": "simulated shared ai failure" if options.get("simulate_ai_fallback") else None, + "ai_provider": "shared-ai-platform" if options.get("ai_enhanced_generation") else None, + "ai_model": "mock-model" if options.get("ai_enhanced_generation") else None, + "prompt_key": "contract_generation_default" if options.get("ai_enhanced_generation") else None, + "prompt_version": "contract_generation_default.v1.0" if options.get("ai_enhanced_generation") else None, + } return { "id": "contract-123", "template_id": template_id, @@ -521,6 +534,7 @@ class StubContractGenerationService: "variables_used": len(variable_values), "missing_field_count": 0, "related_legal_article_count": 0, + "generation_source": metadata_generation_source, }, } @@ -991,6 +1005,101 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertIn("contract_generation_default", preferred_keys) self.assertEqual(len(preferred_keys), len(set(preferred_keys))) + def test_ai_enhanced_generation_uses_shared_ai_content(self): + now = datetime.now(timezone.utc) + template = self.ContractTemplate( + id="tpl-ai-success", + tenant_id="tenant-demo-001", + name="AI Template", + contract_type="procurement", + description="AI enhanced generation", + content="Contract with {{supplier_name}}", + variables_json=[{"name": "supplier_name", "label": "Supplier", "required": True}], + enabled=True, + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + self.db.add(template) + self.db.commit() + + class FakeSharedAIClient: + def __init__(self): + self.calls = [] + + def enhance_contract_generation(self, current_user, *, prompt_runtime, prompt, draft_content, metadata=None): + self.calls.append({ + "current_user": current_user, + "prompt_runtime": prompt_runtime, + "prompt": prompt, + "draft_content": draft_content, + "metadata": metadata, + }) + return { + "content": "AI enhanced contract for Supplier A", + "provider": "shared-ai-platform", + "model": "test-model", + "prompt_key": "contract_generation_default", + "prompt_version": "contract_generation_default.v1.0", + } + + fake_client = FakeSharedAIClient() + self.service.shared_ai_platform_client = fake_client + + result = self.service.generate_contract( + current_user=self.current_user, + template_id="tpl-ai-success", + variable_values={"supplier_name": "Supplier A"}, + options={"ai_enhanced_generation": True}, + ) + + generation_source = result["metadata"]["generation_source"] + self.assertEqual(result["generated_content"], "AI enhanced contract for Supplier A") + self.assertEqual(len(fake_client.calls), 1) + self.assertTrue(generation_source["ai_enhancement_applied"]) + self.assertFalse(generation_source["fallback_used"]) + self.assertEqual(generation_source["ai_provider"], "shared-ai-platform") + + def test_ai_enhanced_generation_falls_back_to_template_content(self): + now = datetime.now(timezone.utc) + template = self.ContractTemplate( + id="tpl-ai-fallback", + tenant_id="tenant-demo-001", + name="AI Fallback Template", + contract_type="procurement", + description="AI enhanced generation fallback", + content="Contract with {{supplier_name}}", + variables_json=[{"name": "supplier_name", "label": "Supplier", "required": True}], + enabled=True, + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + self.db.add(template) + self.db.commit() + + class FailingSharedAIClient: + def enhance_contract_generation(self, current_user, *, prompt_runtime, prompt, draft_content, metadata=None): + raise RuntimeError("shared ai unavailable") + + self.service.shared_ai_platform_client = FailingSharedAIClient() + + result = self.service.generate_contract( + current_user=self.current_user, + template_id="tpl-ai-fallback", + variable_values={"supplier_name": "Supplier A"}, + options={"ai_enhanced_generation": True}, + ) + + generation_source = result["metadata"]["generation_source"] + self.assertEqual(result["generated_content"], "Contract with Supplier A") + self.assertFalse(generation_source["ai_enhancement_applied"]) + self.assertTrue(generation_source["fallback_used"]) + self.assertIn("shared ai unavailable", generation_source["fallback_reason"]) + self.assertIn("AI 增强生成未生效", result["generation_quality"]["warning_issues"][-1]) + def _seed_law_doc_004_data(self): now = datetime.now(timezone.utc) self.db.add( @@ -1852,5 +1961,57 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(ctx.exception.status_code, 400) -if __name__ == "__main__": - unittest.main() +class ClauseSuggestionRuleTests(unittest.TestCase): + def setUp(self): + from app.services.contract_generation_service import ContractGenerationService + + self.service = ContractGenerationService(db=None) + + def test_build_clause_suggestions_prefers_review_risk_rules(self): + generation_quality = { + "section_coverage": { + "has_payment": True, + "has_acceptance_or_delivery": True, + "has_breach": True, + "has_dispute_resolution": True, + }, + "warning_issues": [], + "placeholder_count": 0, + } + auto_review = { + "status": "completed", + "risk_level": "high", + "review_opinion": "合同存在自动续约和免责条款风险。", + "recommendation": "建议补充续约通知期,并限制免责范围。", + "risk_items": [ + {"title": "自动续约风险", "description": "自动续约条款对我方不利", "suggestion": "补充退出机制"}, + {"title": "免责条款风险", "description": "免责范围过宽", "suggestion": "增加 carve-out"}, + ], + } + + suggestions = self.service._build_clause_completion_suggestions(generation_quality, auto_review) + + self.assertTrue(any(item["code"] == "renewal_notice_guardrail" for item in suggestions)) + self.assertTrue(any(item["code"] == "liability_carve_out" for item in suggestions)) + self.assertTrue(all(item.get("source") == "risk_review" for item in suggestions[:2])) + + def test_build_clause_suggestions_falls_back_to_quality_rules(self): + generation_quality = { + "section_coverage": { + "has_payment": False, + "has_acceptance_or_delivery": True, + "has_breach": False, + "has_dispute_resolution": True, + }, + "warning_issues": ["缺少付款安排"], + "placeholder_count": 1, + } + auto_review = {"status": "failed", "risk_level": None} + + suggestions = self.service._build_clause_completion_suggestions(generation_quality, auto_review) + + codes = {item["code"] for item in suggestions} + self.assertIn("payment", codes) + self.assertIn("breach", codes) + self.assertIn("placeholder_cleanup", codes) + self.assertTrue(all(item.get("source") == "quality" for item in suggestions)) diff --git a/backend/tests/test_prompt_asset_service.py b/backend/tests/test_prompt_asset_service.py index d15aec1b..2c1f3efc 100644 --- a/backend/tests/test_prompt_asset_service.py +++ b/backend/tests/test_prompt_asset_service.py @@ -174,6 +174,7 @@ class PromptAssetServiceTestCase(unittest.TestCase): self.service.run_link_repository = StubPromptAssetRunLinkRepository() self.service.evaluation_run_repository = StubPromptAssetEvaluationRunRepository() self.service.quality_eval_service = StubQualityEvalService() + self.service.golden_sample_evaluator = PromptGoldenSampleEvaluator(repo_root=Path.cwd().parent, evaluation_mode="local") 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"} @@ -298,7 +299,7 @@ class PromptAssetServiceTestCase(unittest.TestCase): 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(), + repo_root=Path.cwd().parent, llm_client=fake_llm_client, evaluation_mode="llm", ) @@ -318,7 +319,7 @@ class PromptAssetServiceTestCase(unittest.TestCase): self.assertGreaterEqual(evaluation["evaluation_run"]["sample_count"], 3) self.assertEqual(len(fake_llm_client.calls), evaluation["evaluation_run"]["sample_count"]) - def test_business_auxiliary_prompt_evaluation_uses_future_suite(self): + def test_business_auxiliary_prompt_evaluation_uses_phase15_suite(self): created = self._create_asset( evaluation=False, prompt_key="obligation_extraction_default", @@ -330,8 +331,8 @@ class PromptAssetServiceTestCase(unittest.TestCase): 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"], "future_business_auxiliary") - self.assertEqual(evaluation["evaluation_report"]["sample_count"], 0) + self.assertEqual(evaluation["evaluation_report"]["suite_key"], "phase15_business_auxiliary") + self.assertEqual(evaluation["evaluation_report"]["sample_count"], 8) self.assertEqual(evaluation["evaluation_report"]["evaluation_mode"], "local_fallback") def test_publish_with_report_and_create_next_version(self): diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index 96f9852c..8cd33c51 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useMemo, useEffect, useState } from "react"; -import { Button, Card, Descriptions, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography, Upload, message } from "antd"; +import { Alert, Button, Card, Descriptions, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography, Upload, message } from "antd"; import type { UploadFile } from "antd/es/upload/interface"; import { useRouter } from "next/navigation"; @@ -49,6 +49,49 @@ type LifecycleTaskFormValues = { evidence_note?: string; }; +function getGenerationQualityColor(value?: string | null) { + if (value === "ready_for_review") return "green"; + if (value === "blocked") return "red"; + if (value === "needs_review") return "gold"; + return "default"; +} + +function formatGenerationQualityStatus(value?: string | null) { + if (value === "ready_for_review") return "质量良好"; + if (value === "blocked") return "需先修复"; + if (value === "needs_review") return "需复核"; + return "未评分"; +} + +function getGenerationQualityWarnings(detail?: GeneratedContract | null) { + const quality = detail?.generation_quality; + return [ + ...(quality?.blocking_issues ?? []), + ...(quality?.warning_issues ?? []), + ...(detail?.clause_completion_suggestions ?? []).map((item) => `${item.title}:${item.recommendation}`), + ]; +} + +function groupClauseSuggestions(detail?: GeneratedContract | null) { + const items = detail?.clause_completion_suggestions ?? []; + return { + riskReview: items.filter((item) => item.source === "risk_review"), + quality: items.filter((item) => item.source !== "risk_review"), + }; +} + + +function getGenerationModeMeta(detail?: GeneratedContract | null) { + const source = detail?.metadata.generation_source; + if (source?.fallback_used) { + return { label: "AI回退", color: "volcano", warning: source.fallback_reason ?? "AI 增强生成失败,已回退模板生成。" }; + } + if (source?.ai_enhancement_applied || source?.generation_mode === "ai_enhanced_generation") { + return { label: "AI增强", color: "purple", warning: null }; + } + return { label: "模板生成", color: "default", warning: null }; +} + function formatDateTime(value: string | null | undefined) { if (!value) { return "-"; @@ -296,6 +339,15 @@ function buildContractReadabilityItems( { label: "下一步动作", value: autoReview?.next_action ?? autoReview?.status_detail ?? "可进入详情继续审查或问答复核" }, ]; } +function shouldWarnBeforeDownload(detail?: GeneratedContract | null) { + const quality = detail?.generation_quality; + return Boolean( + quality?.status === "blocked" || + quality?.status === "needs_review" || + (typeof quality?.score === "number" && quality.score < 80) || + getGenerationQualityWarnings(detail).length > 0, + ); +} function buildContractChatHref( detail: GeneratedContract | null, @@ -381,7 +433,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis }; void loadDetail(); - }, [messageApi, params]); + }, [params]); const variableRows = useMemo(() => { if (!detail) { @@ -413,12 +465,43 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis () => buildContractReadabilityItems(detail, autoReview), [detail, autoReview], ); + const generationModeMeta = useMemo(() => getGenerationModeMeta(detail), [detail]); + const groupedClauseSuggestions = useMemo(() => groupClauseSuggestions(detail), [detail]); const handleDownload = async () => { if (!detail) { return; } + if (shouldWarnBeforeDownload(detail)) { + const warnings = getGenerationQualityWarnings(detail); + Modal.confirm({ + title: "当前合同质量需复核,确认下载?", + content: ( + + 质量状态:{formatGenerationQualityStatus(detail.generation_quality?.status)},分数:{detail.generation_quality?.score ?? "-"} + {warnings.slice(0, 5).map((item) => ( + • {item} + ))} + + ), + okText: "仍然下载", + cancelText: "先去复核", + onOk: async () => { + setDownloading(true); + try { + await downloadContract(detail.id, detail.file_name); + messageApi.success("文件下载开始"); + } catch { + messageApi.error("文件下载失败,请稍后重试"); + } finally { + setDownloading(false); + } + }, + }); + return; + } + setDownloading(true); try { await downloadContract(detail.id, detail.file_name); @@ -787,6 +870,85 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis + {detail?.generation_quality ? ( + + + + + {formatGenerationQualityStatus(detail.generation_quality.status)} + + 质量分 {detail.generation_quality.score ?? "-"} + {generationModeMeta.label} + 模式 {detail.generation_quality.generation_mode ?? "template_render"} + 法条来源 {detail.generation_quality.legal_source_count ?? 0} + + {generationModeMeta.warning ? ( + + ) : null} + {getGenerationQualityWarnings(detail).length ? ( + + {getGenerationQualityWarnings(detail).slice(0, 8).map((item) => ( + {item} + ))} + + } + /> + ) : null} + {groupedClauseSuggestions.riskReview.length ? ( + + 高风险补条款建议 +
+ {groupedClauseSuggestions.riskReview.map((item) => ( +
+
+ + {item.priority} + 自动审查 + {item.risk_level ? {item.risk_level} : null} + + {item.title} +
+

{item.recommendation}

+ {item.reason ? {item.reason} : null} +
+ ))} +
+
+ ) : null} + {groupedClauseSuggestions.quality.length ? ( + + 质量修复建议 +
+ {groupedClauseSuggestions.quality.map((item) => ( +
+
+ + {item.priority} + 质量检查 + + {item.title} +
+

{item.recommendation}

+ {item.reason ? {item.reason} : null} +
+ ))} +
+
+ ) : null} + +
+ ) : null} + {detail ? ( diff --git a/frontend/src/app/contracts/page.tsx b/frontend/src/app/contracts/page.tsx index e29685fd..d108b8d7 100644 --- a/frontend/src/app/contracts/page.tsx +++ b/frontend/src/app/contracts/page.tsx @@ -146,6 +146,44 @@ function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: }); } +function hasRiskReviewSuggestions(record?: Pick | Pick | null) { + return Boolean(record?.clause_completion_suggestions?.some((item) => item.source === "risk_review")); +} + +function getGenerationModeMeta(record?: Pick | Pick | null) { + const source = record?.metadata?.generation_source; + const fallbackUsed = Boolean(source?.fallback_used); + const aiApplied = Boolean(source?.ai_enhancement_applied); + if (fallbackUsed) { + return { label: "AI回退", color: "volcano" }; + } + if (aiApplied || source?.generation_mode === "ai_enhanced_generation") { + return { label: "AI增强", color: "purple" }; + } + return { label: "模板生成", color: "default" }; +} + +function getGenerationQualityColor(value?: string | null) { + if (value === "ready_for_review") return "green"; + if (value === "blocked") return "red"; + if (value === "needs_review") return "gold"; + return "default"; +} + +function formatGenerationQualityStatus(value?: string | null) { + if (value === "ready_for_review") return "质量良好"; + if (value === "blocked") return "需先修复"; + if (value === "needs_review") return "需复核"; + return "未评分"; +} + +function getAutoReviewRiskTagColor(value: string | null | undefined) { + if (value === "high") return "red"; + if (value === "medium") return "gold"; + if (value === "low") return "blue"; + return "default"; +} + function getRequiredVariableValidationMessage(template: ContractTemplate, variableValues: Record | undefined) { const missing = template.variables .filter((variable) => variable.required) @@ -856,6 +894,24 @@ export default function ContractsPage() { width: 180, render: (value: string) => new Date(value).toLocaleString("zh-CN"), }, + { + title: "生成质量", + dataIndex: "generation_quality", + key: "generation_quality", + width: 180, + render: (_, record) => { + const modeMeta = getGenerationModeMeta(record); + return ( + + {formatGenerationQualityStatus(record.generation_quality?.status)} + 分数 {record.generation_quality?.score ?? "-"} + {modeMeta.label} + {hasRiskReviewSuggestions(record) ? 高风险补条款 : null} + {record.clause_completion_suggestions?.length ? 需补条款 {record.clause_completion_suggestions.length} : null} + + ); + }, + }, { title: "自动审查", dataIndex: "auto_review", @@ -866,13 +922,20 @@ export default function ContractsPage() { return 未触发; } + const highRisk = autoReview.risk_level === "high"; return ( - {formatAutoReviewStatus(autoReview.status)} - + + {formatAutoReviewStatus(autoReview.status)} + {autoReview.risk_level ? {autoReview.risk_level === "high" ? "高风险" : autoReview.risk_level === "medium" ? "中风险" : autoReview.risk_level === "low" ? "低风险" : autoReview.risk_level} : null} + + {autoReview.current_stage_label ?? autoReview.next_action ?? "-"} - diff --git a/frontend/src/app/prompt-assets/page.tsx b/frontend/src/app/prompt-assets/page.tsx index 5ea51e3a..e2d9183a 100644 --- a/frontend/src/app/prompt-assets/page.tsx +++ b/frontend/src/app/prompt-assets/page.tsx @@ -109,9 +109,17 @@ function getEvaluationTag(value?: string | null) { return 未评测; } +function getEvaluationModeText(report?: Record | null) { + const mode = typeof report?.evaluation_mode === "string" ? report.evaluation_mode : null; + if (mode === "llm") return "LLM 逐条评测"; + if (mode === "local_fallback") return "本地静态兜底"; + if (mode === "llm_required_not_configured") return "要求 LLM 但未配置"; + return "未知评测模式"; +} + function getEvaluationModeTag(report?: Record | null) { const mode = typeof report?.evaluation_mode === "string" ? report.evaluation_mode : null; - if (mode === "llm") return 逐条大模型; + if (mode === "llm") return LLM 评测; if (mode === "local_fallback") return 本地兜底; if (mode === "llm_required_not_configured") return 模型未配置; return 未知; @@ -317,8 +325,9 @@ export default function PromptAssetsPage() { return; } const statusText = result.data.version.evaluation_status === "passed" ? "通过" : "未通过"; - const modeText = result.data.evaluation_report.evaluation_mode === "llm" ? "逐条大模型" : "本地兜底"; - messageApi.success(`Prompt 评测已完成:${statusText},${modeText}`); + const modeText = getEvaluationModeText(result.data.evaluation_report); + const sampleText = `${result.data.evaluation_run.passed_samples}/${result.data.evaluation_run.sample_count} 条样本`; + messageApi.success(`Prompt 评测已完成:${statusText},${modeText},${sampleText}`); await refreshAfterMutation(item.id); } catch (error) { messageApi.error(error instanceof Error ? error.message : "Prompt 评测失败"); @@ -455,10 +464,13 @@ export default function PromptAssetsPage() { key: "evaluation", width: 170, render: (_, item) => ( - - {getEvaluationTag(item.latest_evaluation_status)} - {item.latest_evaluation_score == null ? "-" : `分数 ${item.latest_evaluation_score}`} - + + {getEvaluationTag(item.latest_evaluation_status)} + {getEvaluationModeTag(item.latest_evaluation_report)} + + {item.latest_evaluation_score == null ? "-" : `分数 ${item.latest_evaluation_score}`} · 样本 {getEvaluationSampleResultCount(item.latest_evaluation_report)} + + ), }, { @@ -661,6 +673,12 @@ export default function PromptAssetsPage() { +