Files
ai-contract-platform/backend/app/services/contract_generation_service.py
T
2026-06-19 11:23:32 +08:00

3466 lines
153 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
import io
import base64
import difflib
from html import escape
from typing import Any, Dict, List, Mapping, Optional, Tuple
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from enum import Enum
from urllib.parse import quote
from fastapi import HTTPException, status
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError
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
from app.models.contract_lifecycle_task import ContractLifecycleTask
from app.repositories.contract_approval_step_repository import ContractApprovalStepRepository
from app.repositories.contract_lifecycle_task_repository import ContractLifecycleTaskRepository
from app.repositories.contract_template_repository import ContractTemplateRepository
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.contract_template_service import ContractTemplateService
from app.services.contract_export_service import ContractExportService
from app.services.contract_lifecycle_service import ContractLifecycleService
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
from app.services.review_service import ReviewService
class ExportFormat(str, Enum):
"""合同导出格式"""
DOCX = "docx"
PDF = "pdf"
HTML = "html"
TXT = "txt"
MISSING_VALUE_PLACEHOLDER = "请补充"
CONTRACT_LIFECYCLE_STATUSES = {
"generated",
"internal_review",
"external_review",
"approved",
"signed",
"effective",
"expiring",
"expired",
"terminated",
"archived",
}
CONTRACT_LIFECYCLE_TASK_TYPES = {
"renewal_reminder",
"performance_milestone",
"termination_archive",
"custom",
}
CONTRACT_LIFECYCLE_TASK_STATUSES = {
"open",
"in_progress",
"completed",
"skipped",
"archived",
}
CONTRACT_LIFECYCLE_TASK_PRIORITIES = {
"low",
"medium",
"high",
"critical",
}
class ContractGenerationService:
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)
self.generated_contract_version_repository = GeneratedContractVersionRepository(db)
self.contract_approval_step_repository = ContractApprovalStepRepository(db)
self.contract_lifecycle_task_repository = ContractLifecycleTaskRepository(db)
self.file_service = FileService(db)
self.legal_article_service = LegalArticleService(db)
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
# 子服务
self.template_service = ContractTemplateService(db)
self.export_service = ContractExportService(db)
self.lifecycle_service = ContractLifecycleService(db)
def list_available_templates(
self,
current_user: Dict[str, Any],
contract_type: Optional[str] = None,
document_type_code: Optional[str] = None,
template_category: Optional[str] = None,
industry_category: Optional[str] = None,
) -> List[Dict]:
return self.template_service.list_available_templates(
current_user=current_user,
contract_type=contract_type,
document_type_code=document_type_code,
template_category=template_category,
industry_category=industry_category,
)
def generate_contract(
self,
current_user: Dict[str, Any],
template_id: str,
variable_values: Dict[str, str],
export_format: ExportFormat = ExportFormat.DOCX,
options: Optional[Dict[str, Any]] = None,
*,
document_type_code: Optional[str] = None,
elements_snapshot: Optional[Dict[str, Any]] = None,
element_schema_id: Optional[str] = None,
element_schema_version_label: Optional[str] = None,
missing_fields: Optional[List[Dict[str, Any]]] = None,
related_legal_articles: Optional[List[Dict[str, Any]]] = None,
ai_hints: Optional[List[Dict[str, Any]]] = None,
generation_source: Optional[Dict[str, Any]] = None,
legal_source_query: Optional[str] = None,
fill_missing_variables: bool = False,
) -> Dict:
"""根据模板和变量值生成合同(完整版)"""
tenant_id = current_user["tenant_id"]
options = options or {}
# 获取模板
template = self.repository.get_by_id(template_id, tenant_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问"
)
if not template.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板已停用"
)
# 解析模板变量
template_variables = self._parse_variables(template.variables_json)
if fill_missing_variables:
variable_values = self._complete_variable_values(template_variables, variable_values)
# 验证变量值
self._validate_variable_values(template_variables, variable_values)
# 替换模板内容
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(
current_user,
"contract_generation",
preferred_keys=self._build_generation_prompt_preferred_keys(
resolved_document_type_code,
template.contract_type,
),
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_service.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.export_service.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"],
}
ai_quality = self._evaluate_with_ai_quality(
current_user=current_user,
generated_content=generated_content,
template=template,
variable_values=variable_values,
document_type_code=resolved_document_type_code,
generation_mode=generation_mode,
)
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,
ai_quality=ai_quality,
contract_type=template.contract_type,
)
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,
"_template_version_label": template.current_version_label,
"_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:
persisted_options["_element_schema_id"] = element_schema_id
if element_schema_version_label is not None:
persisted_options["_element_schema_version_label"] = element_schema_version_label
if missing_fields is not None:
persisted_options["_missing_fields"] = missing_fields
if related_legal_articles is not None:
persisted_options["_related_legal_articles"] = related_legal_articles
if ai_hints is not None:
persisted_options["_ai_hints"] = ai_hints
if generation_source is not None:
persisted_options["_generation_source"] = {
**generation_source_payload,
**generation_source,
}
if legal_source_query is not None:
persisted_options["_legal_source_query"] = legal_source_query
generated_contract = GeneratedContract(
id=f"contract-{uuid4().hex[:12]}",
tenant_id=tenant_id,
template_id=template_id,
template_name=template.name,
contract_type=template.contract_type,
document_type_code=resolved_document_type_code,
generated_by=current_user["id"],
generated_by_name=current_user.get("display_name", current_user.get("username")),
export_format=export_format.value,
file_id=file_item["id"],
file_name=file_name,
mime_type=self.export_service.get_mime_type(export_format),
file_size=len(file_content),
generated_content=generated_content,
variable_values_json=variable_values,
elements_snapshot_json=elements_snapshot,
element_schema_id=element_schema_id,
element_schema_version_label=element_schema_version_label,
export_options_json=persisted_options,
file_content_base64=base64.b64encode(file_content).decode("utf-8"),
status="generated",
)
created_contract = self.generated_contract_repository.create(generated_contract)
self.prompt_runtime_service.record_run_link(
current_user,
prompt_runtime,
run_type="contract_generation",
run_id=created_contract.id,
resource_type="generated_contract",
resource_id=created_contract.id,
trace={
"template_id": template.id,
"template_name": template.name,
"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._queue_auto_review(generated_contract, generated_content)
created_contract = self._persist_auto_review_summary(created_contract, auto_review_summary)
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,
)
result = 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,
},
)
return {
**(result or {}),
"content": self._extract_enhancement_content(result),
}
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 _evaluate_with_ai_quality(
self,
current_user: Dict[str, Any],
*,
generated_content: str,
template: Any,
variable_values: Dict[str, str],
document_type_code: Optional[str],
generation_mode: str,
) -> Optional[Dict[str, Any]]:
"""调用 AI 对合同生成质量进行专业评估,失败时返回 None"""
try:
client = self._resolve_shared_ai_platform_client()
prompt_runtime = self.prompt_runtime_service.resolve_published_prompt(
current_user,
"contract_generation",
preferred_keys=[f"{document_type_code}_quality" if document_type_code else "contract_generation_quality_default"],
fallback_prompt_version="contract_generation.quality_evaluation.v1",
)
prompt = self._build_ai_quality_prompt(
generated_content=generated_content,
template=template,
variable_values=variable_values,
document_type_code=document_type_code,
)
result = client.enhance_contract_generation(
current_user,
prompt_runtime=prompt_runtime,
prompt=prompt,
draft_content=generated_content,
metadata={
"evaluation_type": "quality_scoring",
"contract_type": getattr(template, "contract_type", None),
"document_type_code": document_type_code,
"generation_mode": generation_mode,
},
)
ai_raw = (result or {}).get("raw_content") or (result or {}).get("content") or (result or {}).get("enhanced_content") or ""
return self._parse_ai_quality_result(ai_raw)
except Exception:
return None
@staticmethod
def _extract_enhancement_content(result: Optional[Dict[str, Any]]) -> str:
if not isinstance(result, Mapping):
return str(result or "").strip()
structured_output = result.get("structured_output")
if isinstance(structured_output, Mapping):
for key in ("html", "content", "text", "body", "markdown", "document"):
value = structured_output.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for key in ("content", "raw_content", "enhanced_content"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
@staticmethod
def _build_ai_quality_prompt(
*,
generated_content: str,
template: Any,
variable_values: Dict[str, str],
document_type_code: Optional[str],
) -> str:
return f"""请从专业法律角度评估以下合同的质量,从四个维度分别打分(每个维度 0-25 分,总分 0-100):
## 评估维度
1. **条款完整性**:合同是否覆盖了必要的关键条款(如付款、验收、违约、争议解决、保密、知识产权等,根据合同类型判断)
2. **措辞严谨度**:法律用语是否准确、权利义务表述是否清晰无歧义、责任边界是否明确
3. **风险覆盖度**:是否处理了常见法律风险(如免责限制、赔偿上限、数据合规、自动续约等)
4. **结构清晰度**:逻辑层次是否合理、条款顺序是否规范、可读性是否良好
## 合同信息
- 模板名称:{getattr(template, 'name', '')}
- 合同类型:{getattr(template, 'contract_type', '')}
- 文书类型:{document_type_code or ''}
## 输出格式
请严格按照以下 JSON 格式输出,不要包含其他内容:
{{
"total_score": <0-100 整数>,
"dimensions": {{
"clause_completeness": {{"score": <0-25>, "reason": "<简要说明>"}},
"wording_quality": {{"score": <0-25>, "reason": "<简要说明>"}},
"risk_coverage": {{"score": <0-25>, "reason": "<简要说明>"}},
"structure_clarity": {{"score": <0-25>, "reason": "<简要说明>"}}
}},
"strengths": ["<优点1>", "<优点2>"],
"weaknesses": ["<不足1>", "<不足2>"],
"overall_assessment": "<总体评价一句话>"
}}
## 合同正文
{generated_content}
"""
@staticmethod
def _parse_ai_quality_result(ai_raw: str) -> Optional[Dict[str, Any]]:
"""从 AI 返回的文本中解析结构化质量评分"""
if not ai_raw:
return None
import json
import re
# Try direct JSON parse first
cleaned = ai_raw.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
# Try to find JSON block
match = re.search(r"\{[^{}]*\"total_score\"[^{}]*\}", cleaned, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
except json.JSONDecodeError:
return None
else:
return None
dims = parsed.get("dimensions") or {}
total = parsed.get("total_score")
if total is None:
total = sum(
dims.get(d, {}).get("score", 0)
for d in ["clause_completeness", "wording_quality", "risk_coverage", "structure_clarity"]
)
return {
"ai_total_score": max(0, min(100, int(total))),
"ai_dimensions": {
"clause_completeness": dims.get("clause_completeness", {}).get("score", 0),
"wording_quality": dims.get("wording_quality", {}).get("score", 0),
"risk_coverage": dims.get("risk_coverage", {}).get("score", 0),
"structure_clarity": dims.get("structure_clarity", {}).get("score", 0),
},
"ai_strengths": parsed.get("strengths", []),
"ai_weaknesses": parsed.get("weaknesses", []),
"ai_assessment": parsed.get("overall_assessment", ""),
}
def _build_generation_prompt_preferred_keys(
self,
document_type_code: Optional[str],
contract_type: Optional[str],
) -> List[str]:
key_parts: List[str] = []
for value in (document_type_code, contract_type):
for key_part in self._generation_prompt_key_candidates(value):
if key_part and key_part not in key_parts:
key_parts.append(key_part)
preferred_keys: List[str] = []
for key_part in key_parts:
preferred_keys.append(f"{key_part}_default")
for key_part in key_parts:
specific_key = f"contract_generation_{key_part}"
if specific_key not in preferred_keys:
preferred_keys.append(specific_key)
if "contract_generation_default" not in preferred_keys:
preferred_keys.append("contract_generation_default")
return preferred_keys
@classmethod
def _generation_prompt_key_candidates(cls, value: Optional[str]) -> List[str]:
normalized_value = cls._normalize_generation_prompt_key_part(value)
if not normalized_value:
return []
return generation_prompt_key_candidates(value)
@staticmethod
def _normalize_generation_prompt_key_part(value: Optional[str]) -> str:
return str(value or "").strip().lower().replace("-", "_")
def generate_contract_from_elements(
self,
current_user: Dict[str, Any],
document_type_code: str,
elements: Dict[str, Any],
export_format: ExportFormat = ExportFormat.DOCX,
options: Optional[Dict[str, Any]] = None,
*,
template_id: Optional[str] = None,
variable_values: Optional[Dict[str, Any]] = None,
) -> Dict:
"""根据文书类型和结构化要素生成合同,支持自动选模板和来源追溯。"""
if not document_type_code or not str(document_type_code).strip():
raise ValidationError("document_type_code is required")
if not isinstance(elements, dict):
raise ValidationError("elements must be a JSON object")
tenant_id = current_user["tenant_id"]
template = self._resolve_generation_template(
tenant_id=tenant_id,
document_type_code=str(document_type_code).strip(),
template_id=template_id,
)
validation_result = self.element_schema_service.validate_element_values(
current_user,
document_type_code,
elements,
)
resolved_document_type_code = validation_result.get("document_type_code") or document_type_code
template_variables = self._parse_variables(template.variables_json)
mapped_values = self._map_elements_to_template_variables(elements, template_variables)
for key, value in (variable_values or {}).items():
if value is not None:
mapped_values[str(key)] = str(value)
missing_fields = self._build_missing_fields(template_variables, mapped_values, validation_result)
legal_source_query = self._build_generation_legal_source_query(
document_type_code=resolved_document_type_code,
elements=elements,
template_name=template.name,
)
related_legal_articles = self._search_generation_legal_articles(
current_user,
legal_source_query,
)
ai_hints = self._build_generation_ai_hints(
missing_fields=missing_fields,
related_legal_articles=related_legal_articles,
validation_result=validation_result,
)
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,
"template_name": template.name,
"element_schema_id": validation_result.get("schema_id"),
"element_schema_version_label": validation_result.get("version_label"),
}
return self.generate_contract(
current_user=current_user,
template_id=template.id,
variable_values=mapped_values,
export_format=export_format,
options=options or {},
document_type_code=resolved_document_type_code,
elements_snapshot=elements,
element_schema_id=validation_result.get("schema_id"),
element_schema_version_label=validation_result.get("version_label"),
missing_fields=missing_fields,
related_legal_articles=related_legal_articles,
ai_hints=ai_hints,
generation_source=generation_source,
legal_source_query=legal_source_query,
fill_missing_variables=True,
)
def list_generated_contracts(
self,
current_user: Dict[str, Any],
page: int = 1,
page_size: int = 10,
contract_type: Optional[str] = None,
document_type_code: Optional[str] = None,
lifecycle_status: Optional[str] = None,
keyword: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "")
normalized_lifecycle_status = self._normalize_optional_lifecycle_status(lifecycle_status)
items = self.generated_contract_repository.list_paginated(
tenant_id=tenant_id,
page=page,
page_size=page_size,
generated_by=generated_by,
contract_type=contract_type,
document_type_code=document_type_code,
lifecycle_status=normalized_lifecycle_status,
keyword=keyword,
)
total = self.generated_contract_repository.count(
tenant_id=tenant_id,
generated_by=generated_by,
contract_type=contract_type,
document_type_code=document_type_code,
lifecycle_status=normalized_lifecycle_status,
keyword=keyword,
)
return {
"items": [self._to_generated_contract_list_item(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
}
def get_generated_contract(self, current_user: Dict[str, Any], contract_id: str) -> Dict:
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise NotFoundError("合同不存在或无权访问")
return self._to_generated_contract_item(generated_contract)
def update_generated_contract_lifecycle(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
lifecycle_status: str,
counterparty_name: Optional[str] = None,
owner_name: Optional[str] = None,
effective_at: Optional[datetime] = None,
expires_at: Optional[datetime] = None,
lifecycle_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise NotFoundError("contract not found")
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
raise NotFoundError("contract not found")
normalized_status = self._normalize_lifecycle_status(lifecycle_status)
normalized_effective_at = self._normalize_optional_datetime(effective_at)
normalized_expires_at = self._normalize_optional_datetime(expires_at)
if normalized_effective_at and normalized_expires_at and normalized_expires_at < normalized_effective_at:
raise ValidationError("expires_at cannot be earlier than effective_at")
generated_contract.lifecycle_status = normalized_status
generated_contract.counterparty_name = self._optional_lifecycle_text(counterparty_name, max_length=120)
generated_contract.owner_name = self._optional_lifecycle_text(owner_name, max_length=100)
generated_contract.effective_at = normalized_effective_at
generated_contract.expires_at = normalized_expires_at
generated_contract.lifecycle_note = self._optional_lifecycle_text(lifecycle_note, max_length=1000)
generated_contract.lifecycle_updated_by = current_user.get("id")
generated_contract.lifecycle_updated_at = datetime.now(timezone.utc)
updated_contract = self.generated_contract_repository.update(generated_contract)
return self._to_generated_contract_item(updated_contract)
def list_contract_lifecycle_tasks(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
status_value: Optional[str] = None,
task_type: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_status = self._normalize_optional_lifecycle_task_status(status_value)
normalized_task_type = self._normalize_optional_lifecycle_task_type(task_type)
tasks = self.contract_lifecycle_task_repository.list_by_contract(
tenant_id=tenant_id,
contract_id=contract_id,
status_value=normalized_status,
task_type=normalized_task_type,
)
items = [self._to_contract_lifecycle_task_item(task) for task in tasks]
return {
"contract_id": contract_id,
"items": items,
"total": len(items),
"summary": self._build_lifecycle_task_summary(items),
}
def list_contract_lifecycle_task_workbench(
self,
current_user: Dict[str, Any],
*,
page: int = 1,
page_size: int = 50,
status_value: Optional[str] = None,
task_type: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
normalized_page = max(int(page or 1), 1)
normalized_page_size = max(min(int(page_size or 50), 100), 1)
normalized_status = self._normalize_optional_lifecycle_task_status(status_value)
normalized_task_type = self._normalize_optional_lifecycle_task_type(task_type)
generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "")
tasks = self.contract_lifecycle_task_repository.list_paginated(
tenant_id=tenant_id,
page=normalized_page,
page_size=normalized_page_size,
status_value=normalized_status,
task_type=normalized_task_type,
generated_by=generated_by,
)
total = self.contract_lifecycle_task_repository.count(
tenant_id=tenant_id,
status_value=normalized_status,
task_type=normalized_task_type,
generated_by=generated_by,
)
items = []
for task in tasks:
contract = self.generated_contract_repository.get_by_id(task.contract_id, tenant_id)
if contract is None:
continue
item = self._to_contract_lifecycle_task_item(task)
item["contract"] = self._to_generated_contract_list_item(contract)
items.append(item)
summary = self._build_lifecycle_task_summary(items)
return {
"items": items,
"total": total,
"page": normalized_page,
"page_size": normalized_page_size,
"summary": summary,
}
def sync_contract_lifecycle_tasks(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated"
expires_at = self._normalize_optional_datetime(generated_contract.expires_at)
task_key = f"renewal:{contract_id}"
existing = self.contract_lifecycle_task_repository.get_by_task_key(tenant_id, task_key)
if expires_at is None:
if existing and existing.status in {"open", "in_progress"}:
existing.status = "archived"
existing.metadata_json = {
**(existing.metadata_json or {}),
"sync_reason": "expires_at_not_set",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": False, "reason": "expires_at_not_set"}
if lifecycle_status in {"terminated", "archived"}:
if existing and existing.status in {"open", "in_progress"}:
existing.status = "archived"
existing.metadata_json = {
**(existing.metadata_json or {}),
"sync_reason": "contract_closed",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": False, "reason": "contract_closed"}
now = datetime.now(timezone.utc)
days_to_expiry = (expires_at.date() - now.date()).days
due_at = expires_at - timedelta(days=30)
priority = "critical" if days_to_expiry < 0 else "high" if days_to_expiry <= 30 else "medium"
title = f"续约提醒:{generated_contract.template_name}"
description = (
f"合同将在 {expires_at.date().isoformat()} 到期,请确认是否续约、变更或终止归档。"
if days_to_expiry >= 0
else f"合同已于 {expires_at.date().isoformat()} 到期,请立即确认续约、补签或终止归档。"
)
metadata = {
"sync_type": "renewal_by_expires_at",
"contract_lifecycle_status": lifecycle_status,
"days_to_expiry": days_to_expiry,
"source_expires_at": expires_at.isoformat(),
"synced_at": now.isoformat(),
}
if existing is None:
task = ContractLifecycleTask(
id=f"clt-{uuid4().hex[:12]}",
tenant_id=tenant_id,
contract_id=contract_id,
task_key=task_key,
task_type="renewal_reminder",
title=title,
description=description,
priority=priority,
status="open",
due_at=due_at,
metadata_json=metadata,
created_by=current_user.get("id"),
created_at=now,
updated_at=now,
)
self.contract_lifecycle_task_repository.create(task)
else:
existing.title = title
existing.description = description
existing.priority = priority
if existing.status in {"archived", "skipped"}:
existing.status = "open"
existing.due_at = due_at
existing.metadata_json = {**(existing.metadata_json or {}), **metadata}
self.contract_lifecycle_task_repository.update(existing)
result = self.list_contract_lifecycle_tasks(current_user, contract_id)
return {**result, "synced": True, "reason": "renewal_reminder_synced"}
def create_contract_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
task_type: str,
title: str,
description: Optional[str] = None,
priority: str = "medium",
status_value: str = "open",
due_at: Optional[datetime] = None,
evidence_file_id: Optional[str] = None,
evidence_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_task_type = self._normalize_lifecycle_task_type(task_type)
normalized_priority = self._normalize_lifecycle_task_priority(priority)
normalized_status = self._normalize_lifecycle_task_status(status_value)
normalized_due_at = self._normalize_optional_datetime(due_at)
title_text = self._required_task_text(title, field_name="title", max_length=200)
evidence_file = self._resolve_contract_lifecycle_evidence_file(tenant_id, evidence_file_id)
now = datetime.now(timezone.utc)
task = ContractLifecycleTask(
id=f"clt-{uuid4().hex[:12]}",
tenant_id=tenant_id,
contract_id=contract_id,
task_key=f"manual:{uuid4().hex}",
task_type=normalized_task_type,
title=title_text,
description=self._optional_task_text(description, max_length=4000),
priority=normalized_priority,
status=normalized_status,
due_at=normalized_due_at,
completed_by=current_user.get("id") if normalized_status == "completed" else None,
completed_at=now if normalized_status == "completed" else None,
evidence_file_id=evidence_file.id if evidence_file is not None else None,
evidence_note=self._optional_task_text(evidence_note, max_length=2000),
metadata_json={"source": "manual"},
created_by=current_user.get("id"),
created_at=now,
updated_at=now,
)
created_task = self.contract_lifecycle_task_repository.create(task)
return self._to_contract_lifecycle_task_item(created_task)
def update_contract_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
task_id: str,
*,
task_type: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
priority: Optional[str] = None,
status_value: Optional[str] = None,
due_at: Optional[datetime] = None,
evidence_file_id: Optional[str] = None,
evidence_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
task = self.contract_lifecycle_task_repository.get_by_id(tenant_id, contract_id, task_id)
if not task:
raise NotFoundError("lifecycle task not found")
if task_type is not None:
task.task_type = self._normalize_lifecycle_task_type(task_type)
if title is not None:
task.title = self._required_task_text(title, field_name="title", max_length=200)
if description is not None:
task.description = self._optional_task_text(description, max_length=4000)
if priority is not None:
task.priority = self._normalize_lifecycle_task_priority(priority)
if status_value is not None:
normalized_status = self._normalize_lifecycle_task_status(status_value)
task.status = normalized_status
if normalized_status == "completed" and task.completed_at is None:
task.completed_by = current_user.get("id")
task.completed_at = datetime.now(timezone.utc)
elif normalized_status != "completed":
task.completed_by = None
task.completed_at = None
if due_at is not None:
task.due_at = self._normalize_optional_datetime(due_at)
if evidence_file_id is not None:
evidence_file = self._resolve_contract_lifecycle_evidence_file(tenant_id, evidence_file_id)
task.evidence_file_id = evidence_file.id if evidence_file is not None else None
if evidence_note is not None:
task.evidence_note = self._optional_task_text(evidence_note, max_length=2000)
updated_task = self.contract_lifecycle_task_repository.update(task)
return self._to_contract_lifecycle_task_item(updated_task)
def list_generated_contract_versions(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise NotFoundError("contract not found")
versions = self.generated_contract_version_repository.list_by_contract(tenant_id, contract_id)
return {
"contract_id": contract_id,
"base_version": {
"version_number": 1,
"version_label": "v1",
"content": generated_contract.generated_content,
"created_at": generated_contract.created_at.isoformat() if generated_contract.created_at else None,
},
"items": [self._to_generated_contract_version_item(item) for item in versions],
"total": len(versions),
}
def diff_generated_contract_versions(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
from_version_id: Optional[str] = None,
to_version_id: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
from_snapshot, to_snapshot = self._resolve_diff_version_pair(
tenant_id=tenant_id,
generated_contract=generated_contract,
from_version_id=from_version_id,
to_version_id=to_version_id,
)
diff_result = self._build_contract_version_diff(from_snapshot["content"], to_snapshot["content"])
return {
"contract_id": contract_id,
"from_version": self._public_version_snapshot(from_snapshot),
"to_version": self._public_version_snapshot(to_snapshot),
"summary": diff_result["summary"],
"items": diff_result["items"],
}
def get_generated_contract_version(self, current_user: Dict[str, Any], contract_id: str, version_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise NotFoundError("contract not found")
version = self.generated_contract_version_repository.get_by_id(tenant_id, contract_id, version_id)
if not version:
raise NotFoundError("contract version not found")
return self._to_generated_contract_version_item(version)
def export_generated_contract_version(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
version_id: Optional[str] = None,
export_format: ExportFormat = ExportFormat.DOCX,
) -> Dict[str, Any]:
if export_format != ExportFormat.DOCX:
raise ValidationError("only docx export is supported for version delivery")
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
snapshot = self._resolve_contract_version_snapshot(
tenant_id=tenant_id,
generated_contract=generated_contract,
version_id=version_id or "latest",
)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
options = dict(generated_contract.export_options_json or {})
options.update(
{
"document_title": generated_contract.template_name,
"version_label": snapshot["version_label"],
"export_kind": "editable_contract_version",
"generated_by_name": generated_contract.generated_by_name,
}
)
content, file_name = self.export_service.export_contract(
snapshot["content"],
f"{generated_contract.template_name}_{snapshot['version_label']}",
timestamp,
export_format,
options,
)
return {
"id": generated_contract.id,
"contract_id": generated_contract.id,
"version": self._public_version_snapshot(snapshot),
"file_name": file_name,
"mime_type": self.export_service.get_mime_type(export_format),
"content": content,
"file_size": len(content),
"export_format": export_format.value,
}
def create_generated_contract_version(
self,
current_user: Dict[str, Any],
contract_id: str,
*,
content: str,
change_type: str = "editor_save",
change_summary: Optional[str] = None,
source_version_id: Optional[str] = None,
original_text: Optional[str] = None,
replacement_text: Optional[str] = None,
locate_status: str = "applied",
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_content = str(content or "").strip()
if not normalized_content:
raise ValidationError("content is required")
if len(normalized_content) > 1_000_000:
raise ValidationError("content is too long")
latest_version = self.generated_contract_version_repository.get_latest_by_contract(tenant_id, contract_id)
source_content = latest_version.content if latest_version is not None else generated_contract.generated_content
if normalized_content == str(source_content or "").strip():
raise ConflictError("content is unchanged")
resolved_source_version_id = source_version_id or (latest_version.id if latest_version is not None else None)
next_version_number = self.generated_contract_version_repository.next_version_number(tenant_id, contract_id)
version = self.generated_contract_version_repository.create(
GeneratedContractVersion(
id=f"gcv-{uuid4().hex[:12]}",
tenant_id=tenant_id,
contract_id=contract_id,
review_id=None,
review_risk_action_id=None,
version_number=next_version_number,
version_label=f"v{next_version_number}",
source_version_id=resolved_source_version_id,
change_type=self._normalize_version_change_type(change_type),
change_summary=self._optional_summary(change_summary) or "编辑器保存合同正文",
original_text=self._optional_summary(original_text),
replacement_text=self._optional_summary(replacement_text),
content=normalized_content,
locate_status=self._normalize_locate_status(locate_status),
created_by=current_user.get("id"),
)
)
return self._to_generated_contract_version_item(version)
def get_generated_contract_preview(self, current_user: Dict[str, Any], contract_id: str) -> Dict:
tenant_id = current_user["tenant_id"]
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise NotFoundError("合同不存在或无权访问")
return {
"id": generated_contract.id,
"template_name": generated_contract.template_name,
"contract_type": generated_contract.contract_type,
"preview_content": generated_contract.generated_content,
"export_format": generated_contract.export_format,
"file_name": generated_contract.file_name,
"generated_at": generated_contract.created_at.isoformat(),
"preview_url": f"/api/v1/contracts/{generated_contract.id}/preview",
"download_url": f"/api/v1/contracts/{generated_contract.id}/download",
}
def get_generated_contract_download(self, current_user: Dict[str, Any], contract_id: str) -> Dict:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not generated_contract:
raise NotFoundError("合同不存在或无权访问")
if generated_contract.file_id:
file_payload = self.file_service.get_download_payload(current_user, generated_contract.file_id, allow_owner_access=True)
return {
"id": generated_contract.id,
"file_id": generated_contract.file_id,
"file_name": file_payload["file_name"],
"mime_type": file_payload["mime_type"],
"content": file_payload["content"],
}
return {
"id": generated_contract.id,
"file_name": generated_contract.file_name,
"mime_type": generated_contract.mime_type,
"content": base64.b64decode(generated_contract.file_content_base64),
}
def _persist_auto_review_summary(self, generated_contract: GeneratedContract, auto_review_summary: Dict[str, Any]) -> GeneratedContract:
export_options = dict(generated_contract.export_options_json or {})
export_options["_auto_review"] = auto_review_summary
generated_contract.export_options_json = export_options
return self.generated_contract_repository.update(generated_contract)
def _queue_auto_review(self, generated_contract: GeneratedContract, generated_content: str) -> Dict[str, Any]:
"""立即返回 pending 状态的审查摘要,实际审查由 _execute_deferred_auto_review 异步执行"""
return {
"review_id": None,
"review_title": generated_contract.template_name,
"status": "pending",
"review_as": "party_a",
"contract_type": generated_contract.contract_type,
"risk_level": None,
"current_stage": "queued",
"current_stage_label": "审查排队中",
"status_detail": "合同生成完成,审查任务已排队等待处理。",
"failure_reason": None,
"next_action": "请稍后刷新页面查看审查结果。",
"can_retry": False,
"review_opinion": None,
"recommendation": None,
"source_file_id": generated_contract.file_id,
"source_file_name": generated_contract.file_name,
"source_text_length": len(generated_content or ""),
"processing_time_ms": None,
"detail_url": None,
"report_download_url": None,
"report_file_name": None,
"chat_url": None,
}
def _execute_deferred_auto_review(
self,
contract_id: str,
tenant_id: str,
review_user: Dict[str, Any],
) -> None:
"""后台执行审查任务,在 response 返回后由 BackgroundTasks 调用"""
try:
from app.db import SessionLocal
db = SessionLocal()
try:
contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not contract:
return
generated_content = contract.generated_content or ""
review_user_with_tenant = dict(review_user)
review_user_with_tenant["tenant_id"] = tenant_id
review_task = self.review_service.create_review_task(
tenant_id=tenant_id,
user_id=review_user.get("id", ""),
review_as="party_a",
contract_type=contract.contract_type,
text=generated_content,
file_name=contract.file_name,
extracted_text=generated_content,
extraction_metadata={
"document_kind": "generated_contract",
"extraction_mode": "async_review",
"source_file_id": contract.file_id,
"template_id": contract.template_id,
"template_name": contract.template_name,
},
source_file_id=contract.file_id,
)
review_id = review_task["id"]
review_detail = self.review_service.get_review_detail(review_user_with_tenant, review_id)
review_result = self.review_service.get_review_result(review_user_with_tenant, review_id)
review_report = self.review_service.get_review_report(review_user_with_tenant, review_id)
if not review_detail or not review_result:
raise RuntimeError("审查结果缺失")
summary = self._build_auto_review_summary(
generated_contract=contract,
review_id=review_id,
review_detail=review_detail,
review_result=review_result,
review_report=review_report,
)
self._persist_auto_review_summary(contract, summary)
finally:
db.close()
except Exception as exc:
try:
contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if contract:
failed = self._build_auto_review_failure_summary(contract, str(exc)[:300])
self._persist_auto_review_summary(contract, failed)
except Exception:
pass
def _create_auto_review_summary(
self,
current_user: Dict[str, Any],
generated_contract: GeneratedContract,
generated_content: str,
) -> Dict[str, Any]:
review_user = {
"id": generated_contract.generated_by,
"tenant_id": generated_contract.tenant_id,
"display_name": generated_contract.generated_by_name or generated_contract.generated_by,
"role": current_user.get("role", "admin"),
}
extraction_metadata = {
"document_kind": "generated_contract",
"extraction_mode": "template_render",
"ocr_gate_status": "PASS",
"source_file_id": generated_contract.file_id,
"source_file_name": generated_contract.file_name,
"template_id": generated_contract.template_id,
"template_name": generated_contract.template_name,
"document_type_code": generated_contract.document_type_code,
"element_schema_id": generated_contract.element_schema_id,
"element_schema_version_label": generated_contract.element_schema_version_label,
"missing_field_count": len((generated_contract.export_options_json or {}).get("_missing_fields") or []),
"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:
review_task = self.review_service.create_review_task(
tenant_id=generated_contract.tenant_id,
user_id=generated_contract.generated_by,
review_as="party_a",
contract_type=generated_contract.contract_type,
text=generated_content,
file_name=generated_contract.file_name,
extracted_text=generated_content,
extraction_metadata=extraction_metadata,
source_file_id=generated_contract.file_id,
)
review_id = review_task["id"]
review_detail = self.review_service.get_review_detail(review_user, review_id)
review_result = self.review_service.get_review_result(review_user, review_id)
review_report = self.review_service.get_review_report(review_user, review_id)
if not review_detail or not review_result:
raise RuntimeError("自动审查结果缺少详情或结果")
return self._build_auto_review_summary(
generated_contract=generated_contract,
review_id=review_id,
review_detail=review_detail,
review_result=review_result,
review_report=review_report,
)
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,
ai_quality: Optional[Dict[str, Any]] = None,
contract_type: Optional[str] = None,
) -> 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("缺少法条/知识来源辅助")
# 按合同类型的必备条款清单检查
clause_check = self._check_clause_completeness(generated_content, contract_type)
for missing in clause_check.get("missing_clauses", []):
if missing["required"]:
warning_issues.append(f"缺少{missing['name']}(必备条款)")
rule_score = 100
rule_score -= unresolved_variable_count * 20
rule_score -= missing_field_count * 6
rule_score -= placeholder_count * 4
rule_score -= sum(1 for value in section_coverage.values() if not value) * 5
if legal_source_count == 0:
rule_score -= 8
rule_score = max(0, min(100, rule_score))
# 合并 AI 评分(AI 占 60%,规则评分占 40%)
ai_total = ai_quality.get("ai_total_score") if ai_quality else None
if ai_total is not None:
score = int(round(ai_total * 0.6 + rule_score * 0.4))
else:
score = rule_score
if blocking_issues:
status_value = "blocked"
elif warning_issues:
status_value = "needs_review"
else:
status_value = "ready_for_review"
result: Dict[str, Any] = {
"schema_version": "generation_quality.v2",
"status": status_value,
"score": score,
"rule_score": rule_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,
}
if ai_quality:
result["ai_total_score"] = ai_quality.get("ai_total_score")
result["ai_dimensions"] = ai_quality.get("ai_dimensions")
result["ai_strengths"] = ai_quality.get("ai_strengths", [])
result["ai_weaknesses"] = ai_quality.get("ai_weaknesses", [])
result["ai_assessment"] = ai_quality.get("ai_assessment", "")
if ai_quality.get("ai_weaknesses"):
for w in ai_quality["ai_weaknesses"][:3]:
warning_issues.append(f"AI 建议:{w}")
result["warning_issues"] = warning_issues
return result
# ── 合同必备条款清单(按类型)──
# 来源:AI法律文书助手文档 第二部分第5章
CLAUSE_COMPLETENESS_CHECKLIST = {
"all": [
("parties", "当事人信息", ["甲方", "乙方", "委托方", "受托方"], True),
("subject_matter", "合同标的/服务范围", ["服务范围", "合同标的", "项目范围", "采购标的"], True),
("payment", "付款条款", ["付款", "价款", "费用", "服务费", "结算", "金额"], True),
("breach", "违约责任", ["违约", "赔偿", "违约金", "责任承担"], True),
("dispute", "争议解决", ["争议解决", "仲裁", "管辖", "法院", "诉讼"], True),
("signature", "签署栏", ["签字", "签章", "盖章", "签署"], True),
],
"procurement": [
("delivery", "交付条款", ["交付", "交货", "发货", "运输"], True),
("acceptance", "验收条款", ["验收", "检验", "验收标准"], True),
("quality", "质量保证", ["质量", "质保", "质量标准"], True),
("warranty", "质保期", ["质保期", "保修", "质保"], False),
("risk_transfer", "风险转移", ["风险转移", "风险承担", "毁损灭失"], False),
],
"service": [
("service_standard", "服务标准/SLA", ["服务标准", "服务水平", "SLA", "服务级别"], True),
("acceptance", "验收条款", ["验收", "验收标准", "验收流程"], True),
("personnel", "人员安排", ["人员", "项目团队", "负责人"], False),
("ip", "知识产权", ["知识产权", "著作权", "技术成果"], False),
],
"development": [
("deliverables", "交付成果", ["交付成果", "交付物", "技术成果", "源代码"], True),
("acceptance", "验收条款", ["验收", "验收标准", "测试"], True),
("ip", "知识产权归属", ["知识产权归属", "著作权归属", "技术成果归属"], True),
("confidentiality", "保密条款", ["保密", "商业秘密"], True),
],
"employment": [
("contract_period", "合同期限", ["合同期限", "劳动合同期限", "固定期限", "无固定期限"], True),
("work_content", "工作内容与地点", ["工作内容", "工作地点", "工作职责"], True),
("work_hours", "工作时间与休假", ["工作时间", "工时", "休假", "休息"], True),
("salary", "劳动报酬", ["劳动报酬", "工资", "薪酬", "薪资"], True),
("social_insurance", "社会保险", ["社会保险", "社保", "五险一金"], True),
("probation", "试用期", ["试用期"], False),
("non_compete", "竞业限制", ["竞业限制", "竞业"], False),
],
"nda": [
("confidential_info", "保密信息定义", ["保密信息", "商业秘密"], True),
("obligations", "保密义务", ["保密义务", "保密责任"], True),
("term", "保密期限", ["保密期限", "保密期"], True),
("exceptions", "例外条款", ["例外", "排除", "不属于保密信息"], True),
("return_destroy", "返还销毁", ["返还", "销毁", "归还"], True),
],
"lease": [
("property", "租赁物", ["租赁物", "房屋", "物业"], True),
("rent", "租金与押金", ["租金", "押金", "租赁费用"], True),
("term", "租赁期限", ["租赁期限", "租期"], True),
("maintenance", "维修责任", ["维修", "维护", "修缮"], True),
],
"investment": [
("representations", "陈述与保证", ["陈述与保证", "陈述保证"], True),
("price", "交易价格", ["价款", "转让价款", "投资金额", "增资额"], True),
("closing", "交割安排", ["交割", "交割条件", "交割日"], True),
("exit", "退出机制", ["退出", "回购", "优先清算", "出售权"], True),
],
}
@classmethod
def _get_clause_checklist(cls, contract_type: Optional[str]) -> list:
base = list(cls.CLAUSE_COMPLETENESS_CHECKLIST.get("all", []))
specific = cls.CLAUSE_COMPLETENESS_CHECKLIST.get(contract_type or "", [])
seen = set(code for code, _, _, _ in base)
for item in specific:
if item[0] not in seen:
base.append(item)
seen.add(item[0])
return base
@classmethod
def _check_clause_completeness(cls, content: str, contract_type: Optional[str]) -> Dict[str, Any]:
checklist = cls._get_clause_checklist(contract_type)
found: list = []
missing: list = []
for code, name, keywords, required in checklist:
found_keyword = any(kw in content for kw in keywords)
if found_keyword:
found.append(code)
elif required:
missing.append({"code": code, "name": name, "required": True})
else:
missing.append({"code": code, "name": name, "required": False})
coverage_rate = len(found) / max(len(checklist), 1) * 100
return {
"total_clauses": len(checklist),
"found_clauses": len(found),
"missing_clauses": missing,
"coverage_rate": round(coverage_rate, 1),
"found_codes": found,
}
@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,
review_id: str,
review_detail: Dict[str, Any],
review_result: Dict[str, Any],
review_report: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
workflow_state = review_detail.get("workflow_state") or {}
source_document = review_detail.get("source_document") or {}
ai_trace = review_detail.get("ai_trace") or {}
prompt_runtime = (generated_contract.export_options_json or {}).get("_prompt_runtime") or {}
if isinstance(ai_trace, dict) and prompt_runtime:
ai_trace = dict(ai_trace)
ai_trace_metadata = dict(ai_trace.get("metadata") or {})
ai_trace_metadata["prompt_runtime"] = prompt_runtime
ai_trace["metadata"] = ai_trace_metadata
overall_risk = (review_result.get("risk_summary") or {}).get("overall") or workflow_state.get("risk_level")
review_opinion = review_result.get("review_opinion")
recommendation = review_result.get("recommendation")
chat_context = {
"reviewId": review_id,
"prompt": self._build_auto_review_prompt(
generated_contract=generated_contract,
review_detail=review_detail,
review_result=review_result,
),
"metadata": {
"reviewTitle": generated_contract.template_name,
"reviewStatus": review_detail.get("status"),
"contractType": review_result.get("basic_info", {}).get("contract_type") or review_detail.get("contract_type"),
"current_stage": review_detail.get("current_stage"),
"current_stage_label": review_detail.get("current_stage_label"),
"status_detail": review_detail.get("status_detail"),
"failure_reason": review_detail.get("failure_reason"),
"next_action": review_detail.get("next_action"),
"overallRisk": overall_risk,
"reviewOpinion": review_opinion,
"focusLabel": generated_contract.template_name,
"actionLabel": "继续审查问答",
"focusType": "generated_contract",
"source_type": review_detail.get("source_type"),
"source_text_length": review_detail.get("source_text_length"),
"processing_time_ms": review_detail.get("processing_time_ms"),
"risk_level": workflow_state.get("risk_level"),
},
"workflowState": workflow_state,
"aiTrace": ai_trace,
"sourceDocument": source_document,
}
detail_url = f"/reviews/{review_id}" if review_detail else None
report_download_url = review_report.get("download_url") if review_report else None
report_file_name = review_report.get("file_name") if review_report else None
chat_url = f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}" if review_detail else None
return {
"review_id": review_id,
"review_title": generated_contract.template_name,
"status": review_detail.get("status"),
"review_as": review_detail.get("review_as"),
"contract_type": review_detail.get("contract_type"),
"risk_level": overall_risk,
"current_stage": review_detail.get("current_stage"),
"current_stage_label": review_detail.get("current_stage_label"),
"status_detail": review_detail.get("status_detail"),
"failure_reason": review_detail.get("failure_reason"),
"next_action": review_detail.get("next_action"),
"can_retry": review_detail.get("can_retry"),
"review_opinion": review_opinion,
"recommendation": recommendation,
"source_file_id": generated_contract.file_id,
"source_file_name": generated_contract.file_name,
"source_text_length": review_detail.get("source_text_length"),
"processing_time_ms": review_detail.get("processing_time_ms"),
"detail_url": detail_url,
"report_download_url": report_download_url,
"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]:
safe_message = failure_message or "自动审查未能完成,请稍后重试。"
local_risks: list = []
try:
local_risks = self.review_service._enrich_with_local_risk_rules(
text=generated_contract.generated_content or "",
contract_type=generated_contract.contract_type,
)
except Exception:
pass
return {
"review_id": None,
"review_title": generated_contract.template_name,
"status": "failed",
"review_as": "party_a",
"contract_type": generated_contract.contract_type,
"risk_level": "high" if any(r.get("level") == "high" for r in local_risks) else ("medium" if local_risks else None),
"current_stage": "review",
"current_stage_label": "自动审查失败(已触发本地规则扫描)",
"status_detail": safe_message,
"failure_reason": safe_message,
"next_action": "请稍后重新生成合同或手动发起审查。",
"can_retry": False,
"review_opinion": "AI 审查暂时不可用,已通过本地规则库扫描发现风险项。" if local_risks else None,
"recommendation": "manual_review",
"risk_items": local_risks[:10] if local_risks else None,
"source_file_id": generated_contract.file_id,
"source_file_name": generated_contract.file_name,
"source_text_length": len(generated_contract.generated_content or ""),
"processing_time_ms": None,
"detail_url": None,
"report_download_url": None,
"report_file_name": None,
"chat_url": None,
}
def _build_auto_review_prompt(
self,
generated_contract: GeneratedContract,
review_detail: Dict[str, Any],
review_result: Dict[str, Any],
) -> str:
source_document = review_detail.get("source_document") or {}
risk_summary = review_result.get("risk_summary") or {}
risk_items = review_result.get("risk_items") or []
prompt_parts = [
"请基于当前自动审查结果继续分析这份合同。",
f"合同名称:{generated_contract.template_name}",
f"审查视角:{review_detail.get('review_as') or 'party_a'}",
f"当前总体风险:{risk_summary.get('overall') or review_detail.get('status') or 'unknown'}",
f"审查意见:{review_result.get('review_opinion') or '暂无审查意见'}",
f"处理建议:{review_result.get('recommendation') or 'manual_review'}",
f"原始文本摘要:{(review_detail.get('source_text_preview') or '')[:500]}",
f"解析文件:{source_document.get('file_name') or generated_contract.file_name}",
f"文档类型:{source_document.get('source_kind') or source_document.get('source_type') or 'generated_contract'}",
f"文本解析:{source_document.get('parse_status') or 'completed'}",
f"抽取模式:{source_document.get('extraction_mode') or 'template_render'}",
f"OCR 门禁:{source_document.get('ocr_gate_status') or 'PASS'}",
]
risk_titles = [
item.get("title") or item.get("description") or "风险项"
for item in risk_items[:5]
if isinstance(item, dict)
]
if risk_titles:
prompt_parts.append(f"重点风险项:{';'.join(risk_titles)}")
return "\n".join(prompt_parts)
def _resolve_auto_review_summary(self, generated_contract: GeneratedContract) -> Optional[Dict[str, Any]]:
export_options = generated_contract.export_options_json or {}
stored_summary = export_options.get("_auto_review")
if isinstance(stored_summary, dict):
return stored_summary
if not generated_contract.file_id or self.review_service is None:
return None
repository = getattr(self.review_service, "repository", None)
if repository is None or not hasattr(repository, "get_latest_by_source_file_id"):
return None
review_task = repository.get_latest_by_source_file_id(
generated_contract.tenant_id,
generated_contract.generated_by,
generated_contract.file_id,
)
if review_task is None:
return None
review_user = {
"id": generated_contract.generated_by,
"tenant_id": generated_contract.tenant_id,
"role": "admin",
}
review_detail = self.review_service.get_review_detail(review_user, review_task.id)
review_result = self.review_service.get_review_result(review_user, review_task.id)
review_report = self.review_service.get_review_report(review_user, review_task.id)
if not review_detail or not review_result:
return None
return self._build_auto_review_summary(
generated_contract=generated_contract,
review_id=review_task.id,
review_detail=review_detail,
review_result=review_result,
review_report=review_report,
)
def preview_contract(
self,
current_user: Dict[str, Any],
template_id: str,
variable_values: Dict[str, str],
) -> Dict:
"""预览合同生成结果(不保存)"""
tenant_id = current_user["tenant_id"]
# 获取模板
template = self.repository.get_by_id(template_id, tenant_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问"
)
# 解析模板变量
template_variables = self._parse_variables(template.variables_json)
# 验证变量值
self._validate_variable_values(template_variables, variable_values, preview=True)
# 替换模板内容
generated_content = self._replace_variables(template.content, variable_values)
return {
"preview_content": generated_content,
"template_name": template.name,
"variable_summary": self._get_variable_summary(template_variables, variable_values),
}
def _get_mime_type(self, export_format: ExportFormat) -> str:
if export_format == ExportFormat.DOCX:
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
if export_format == ExportFormat.PDF:
return "application/pdf"
if export_format == ExportFormat.HTML:
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 {}
document_type_code = generated_contract.document_type_code or export_options.get("_document_type_code")
elements_snapshot = generated_contract.elements_snapshot_json or export_options.get("_elements_snapshot")
element_schema_id = generated_contract.element_schema_id or export_options.get("_element_schema_id")
element_schema_version_label = (
generated_contract.element_schema_version_label
or export_options.get("_element_schema_version_label")
)
missing_fields = export_options.get("_missing_fields") or []
related_legal_articles = export_options.get("_related_legal_articles") or []
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,
missing_fields=missing_fields,
related_legal_articles=related_legal_articles,
ai_hints=ai_hints,
)
return {
"id": generated_contract.id,
"template_id": generated_contract.template_id,
"template_name": generated_contract.template_name,
"contract_type": generated_contract.contract_type,
"document_type_code": document_type_code,
"template_category": export_options.get("_template_category"),
"industry_category": export_options.get("_industry_category"),
"file_id": generated_contract.file_id,
"generated_content": generated_contract.generated_content,
"generated_html": generated_html,
"variable_values": generated_contract.variable_values_json or {},
"elements_snapshot": elements_snapshot,
"missing_fields": missing_fields,
"ai_hints": ai_hints,
"related_legal_articles": related_legal_articles,
"generated_at": generated_contract.created_at.isoformat(),
"export_format": generated_contract.export_format,
"file_name": generated_contract.file_name,
"file_size": generated_contract.file_size,
"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),
"approval": self._build_contract_approval_summary(generated_contract),
"metadata": {
"generated_by": generated_contract.generated_by,
"generated_by_name": generated_contract.generated_by_name,
"tenant_id": generated_contract.tenant_id,
"original_template": generated_contract.template_name,
"template_category": export_options.get("_template_category"),
"industry_category": export_options.get("_industry_category"),
"document_type_code": document_type_code,
"template_version_label": export_options.get("_template_version_label"),
"elements_snapshot": elements_snapshot,
"element_schema_id": element_schema_id,
"element_schema_version_label": element_schema_version_label,
"missing_fields": missing_fields,
"missing_field_count": len(missing_fields),
"ai_hints": ai_hints,
"related_legal_articles": related_legal_articles,
"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
for key, value in export_options.items()
if not str(key).startswith("_")
},
"variables_used": len(generated_contract.variable_values_json or {}),
"asset_file_id": generated_contract.file_id,
}
}
def _to_generated_contract_list_item(self, generated_contract: GeneratedContract) -> Dict:
item = self._to_generated_contract_item(generated_contract)
return {
"id": item["id"],
"template_name": item["template_name"],
"contract_type": item["contract_type"],
"document_type_code": item["document_type_code"],
"template_category": item["template_category"],
"industry_category": item["industry_category"],
"file_id": item["file_id"],
"generated_at": item["generated_at"],
"export_format": item["export_format"],
"file_name": item["file_name"],
"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"],
"approval": item.get("approval"),
"missing_field_count": item["metadata"].get("missing_field_count", 0),
"related_legal_article_count": item["metadata"].get("related_legal_article_count", 0),
"metadata": {
"generated_by_name": item["metadata"]["generated_by_name"],
"asset_file_id": item["metadata"]["asset_file_id"],
"document_type_code": item["metadata"]["document_type_code"],
"template_category": item["metadata"]["template_category"],
"industry_category": item["metadata"]["industry_category"],
"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),
},
}
@staticmethod
def _to_generated_contract_version_item(version) -> Dict[str, Any]:
return {
"id": version.id,
"tenant_id": version.tenant_id,
"contract_id": version.contract_id,
"review_id": version.review_id,
"review_risk_action_id": version.review_risk_action_id,
"version_number": version.version_number,
"version_label": version.version_label,
"source_version_id": version.source_version_id,
"change_type": version.change_type,
"change_summary": version.change_summary,
"original_text": version.original_text,
"replacement_text": version.replacement_text,
"content": version.content,
"locate_status": version.locate_status,
"created_by": version.created_by,
"created_at": version.created_at.isoformat() if version.created_at else None,
"detail_url": f"/contracts/{version.contract_id}?version={version.id}",
}
def _base_contract_version_snapshot(self, generated_contract: GeneratedContract) -> Dict[str, Any]:
return {
"id": "base",
"version_number": 1,
"version_label": "v1",
"source_version_id": None,
"change_type": "generated",
"change_summary": "原始生成版本",
"content": generated_contract.generated_content,
"created_at": generated_contract.created_at,
}
@classmethod
def _version_model_snapshot(cls, version: GeneratedContractVersion) -> Dict[str, Any]:
return {
"id": version.id,
"version_number": version.version_number,
"version_label": version.version_label,
"source_version_id": version.source_version_id,
"change_type": version.change_type,
"change_summary": version.change_summary,
"content": version.content,
"created_at": version.created_at,
}
@staticmethod
def _public_version_snapshot(snapshot: Dict[str, Any]) -> Dict[str, Any]:
created_at = snapshot.get("created_at")
return {
"id": snapshot.get("id"),
"version_number": snapshot.get("version_number"),
"version_label": snapshot.get("version_label"),
"source_version_id": snapshot.get("source_version_id"),
"change_type": snapshot.get("change_type"),
"change_summary": snapshot.get("change_summary"),
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at,
}
def _resolve_contract_version_snapshot(
self,
*,
tenant_id: str,
generated_contract: GeneratedContract,
version_id: Optional[str],
) -> Dict[str, Any]:
normalized = str(version_id or "latest").strip()
if normalized.lower() in {"base", "v1", "original"}:
return self._base_contract_version_snapshot(generated_contract)
if normalized.lower() in {"latest", "current"}:
latest_version = self.generated_contract_version_repository.get_latest_by_contract(tenant_id, generated_contract.id)
return self._version_model_snapshot(latest_version) if latest_version is not None else self._base_contract_version_snapshot(generated_contract)
version = self.generated_contract_version_repository.get_by_id(tenant_id, generated_contract.id, normalized)
if not version:
raise NotFoundError("contract version not found")
return self._version_model_snapshot(version)
def _resolve_diff_version_pair(
self,
*,
tenant_id: str,
generated_contract: GeneratedContract,
from_version_id: Optional[str],
to_version_id: Optional[str],
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
if from_version_id:
from_snapshot = self._resolve_contract_version_snapshot(
tenant_id=tenant_id,
generated_contract=generated_contract,
version_id=from_version_id,
)
else:
from_snapshot = None
if to_version_id:
to_snapshot = self._resolve_contract_version_snapshot(
tenant_id=tenant_id,
generated_contract=generated_contract,
version_id=to_version_id,
)
else:
to_snapshot = None
if from_snapshot is not None and to_snapshot is not None:
return from_snapshot, to_snapshot
base_snapshot = self._base_contract_version_snapshot(generated_contract)
versions = [
self._version_model_snapshot(item)
for item in self.generated_contract_version_repository.list_by_contract(tenant_id, generated_contract.id)
]
versions_desc = sorted(versions, key=lambda item: (int(item.get("version_number") or 0), str(item.get("created_at") or "")), reverse=True)
if to_snapshot is None:
to_snapshot = versions_desc[0] if versions_desc else base_snapshot
if from_snapshot is None:
from_snapshot = self._find_previous_version_snapshot(base_snapshot, versions_desc, to_snapshot)
return from_snapshot, to_snapshot
@staticmethod
def _find_previous_version_snapshot(
base_snapshot: Dict[str, Any],
versions_desc: List[Dict[str, Any]],
to_snapshot: Dict[str, Any],
) -> Dict[str, Any]:
to_id = to_snapshot.get("id")
if to_id in {"base", None}:
return base_snapshot
for index, item in enumerate(versions_desc):
if item.get("id") == to_id:
return versions_desc[index + 1] if index + 1 < len(versions_desc) else base_snapshot
to_number = int(to_snapshot.get("version_number") or 1)
older = [item for item in versions_desc if int(item.get("version_number") or 0) < to_number]
return older[0] if older else base_snapshot
@classmethod
def _split_contract_diff_units(cls, content: str) -> List[str]:
normalized = str(content or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not normalized:
return []
paragraphs = [item.strip() for item in re.split(r"\n+", normalized) if item.strip()]
if len(paragraphs) > 1:
return paragraphs
clause_parts = [item.strip() for item in re.split(r"(?=第[一二三四五六七八九十百千万零〇0-9]+[章节条])", normalized) if item.strip()]
return clause_parts if len(clause_parts) > 1 else paragraphs
@classmethod
def _build_contract_version_diff(cls, old_content: str, new_content: str) -> Dict[str, Any]:
old_units = cls._split_contract_diff_units(old_content)
new_units = cls._split_contract_diff_units(new_content)
matcher = difflib.SequenceMatcher(a=old_units, b=new_units, autojunk=False)
items: List[Dict[str, Any]] = []
unchanged_count = 0
counters = {"added": 0, "removed": 0, "modified": 0}
for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes():
if tag == "equal":
unchanged_count += old_end - old_start
continue
old_slice = old_units[old_start:old_end]
new_slice = new_units[new_start:new_end]
pair_count = max(len(old_slice), len(new_slice))
for offset in range(pair_count):
old_text = old_slice[offset] if offset < len(old_slice) else None
new_text = new_slice[offset] if offset < len(new_slice) else None
if old_text is not None and new_text is not None:
change_type = "modified"
elif new_text is not None:
change_type = "added"
else:
change_type = "removed"
counters[change_type] += 1
items.append(
{
"change_type": change_type,
"old_paragraph_index": old_start + offset + 1 if old_text is not None else None,
"new_paragraph_index": new_start + offset + 1 if new_text is not None else None,
"old_text": old_text,
"new_text": new_text,
}
)
return {
"summary": {
"added": counters["added"],
"removed": counters["removed"],
"modified": counters["modified"],
"unchanged": unchanged_count,
"change_count": len(items),
"from_paragraph_count": len(old_units),
"to_paragraph_count": len(new_units),
},
"items": items,
}
@staticmethod
def _normalize_optional_datetime(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
@staticmethod
def _serialize_optional_datetime(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
@staticmethod
def _optional_lifecycle_text(value: Optional[str], *, max_length: int) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
return None
if len(normalized) > max_length:
raise ValidationError("lifecycle field is too long")
return normalized
@classmethod
def _normalize_lifecycle_status(cls, value: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_STATUSES:
raise ValidationError("invalid lifecycle_status")
return normalized
@classmethod
def _normalize_optional_lifecycle_status(cls, value: Optional[str]) -> Optional[str]:
if value is None or not str(value).strip():
return None
return cls._normalize_lifecycle_status(value)
def _get_generated_contract_for_lifecycle_task(
self,
current_user: Dict[str, Any],
contract_id: str,
) -> GeneratedContract:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not generated_contract:
raise NotFoundError("contract not found")
current_user_id = str(current_user.get("id") or "")
generated_by = str(generated_contract.generated_by or "")
if current_user.get("role") != "admin" and generated_by != current_user_id:
raise NotFoundError("contract not found")
return generated_contract
@classmethod
def _normalize_lifecycle_task_type(cls, value: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task type")
return normalized
@classmethod
def _normalize_optional_lifecycle_task_type(cls, value: Optional[str]) -> Optional[str]:
if value is None or not str(value).strip():
return None
return cls._normalize_lifecycle_task_type(value)
@classmethod
def _normalize_lifecycle_task_status(cls, value: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_STATUSES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task status")
return normalized
@classmethod
def _normalize_optional_lifecycle_task_status(cls, value: Optional[str]) -> Optional[str]:
if value is None or not str(value).strip():
return None
return cls._normalize_lifecycle_task_status(value)
@classmethod
def _normalize_lifecycle_task_priority(cls, value: str) -> str:
normalized = str(value or "medium").strip().lower().replace("-", "_")
if normalized not in CONTRACT_LIFECYCLE_TASK_PRIORITIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid lifecycle task priority")
return normalized
@staticmethod
def _required_task_text(value: Any, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required")
if len(normalized) > max_length:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is too long")
return normalized
@staticmethod
def _optional_task_text(value: Optional[Any], *, max_length: int) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
return None
if len(normalized) > max_length:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="task field is too long")
return normalized
def _resolve_contract_lifecycle_evidence_file(self, tenant_id: str, file_id: Optional[str]) -> Optional[File]:
if file_id is None or not str(file_id).strip():
return None
evidence_file = (
self.db.query(File)
.filter(
File.id == str(file_id).strip(),
File.tenant_id == tenant_id,
File.status == "active",
)
.first()
)
if evidence_file is None:
raise NotFoundError("evidence file not found")
return evidence_file
def _to_contract_lifecycle_task_item(self, task: ContractLifecycleTask) -> Dict[str, Any]:
evidence_file = None
if task.evidence_file_id:
evidence_file = (
self.db.query(File)
.filter(
File.id == task.evidence_file_id,
File.tenant_id == task.tenant_id,
File.status == "active",
)
.first()
)
return {
"id": task.id,
"tenant_id": task.tenant_id,
"contract_id": task.contract_id,
"task_key": task.task_key,
"task_type": task.task_type,
"title": task.title,
"description": task.description,
"priority": task.priority,
"status": task.status,
"due_at": self._serialize_optional_datetime(task.due_at),
"due_status": self._build_lifecycle_task_due_status(task),
"days_to_due": self._build_lifecycle_task_days_to_due(task),
"completed_by": task.completed_by,
"completed_at": self._serialize_optional_datetime(task.completed_at),
"evidence_file_id": task.evidence_file_id,
"evidence_file": self._to_lifecycle_task_evidence_file(evidence_file),
"evidence_note": task.evidence_note,
"metadata": task.metadata_json or {},
"created_by": task.created_by,
"created_at": self._serialize_optional_datetime(task.created_at),
"updated_at": self._serialize_optional_datetime(task.updated_at),
}
@staticmethod
def _to_lifecycle_task_evidence_file(evidence_file: Optional[File]) -> Optional[Dict[str, Any]]:
if evidence_file is None:
return None
return {
"id": evidence_file.id,
"file_name": evidence_file.file_name,
"file_type": evidence_file.file_type,
"mime_type": evidence_file.mime_type,
"file_size": evidence_file.file_size,
"download_url": f"/api/v1/files/{evidence_file.id}/download",
"created_at": evidence_file.created_at.isoformat() if evidence_file.created_at else None,
}
def _build_lifecycle_task_days_to_due(self, task: ContractLifecycleTask) -> Optional[int]:
if task.due_at is None:
return None
due_at = self._normalize_optional_datetime(task.due_at)
return (due_at.date() - datetime.now(timezone.utc).date()).days if due_at is not None else None
def _build_lifecycle_task_due_status(self, task: ContractLifecycleTask) -> str:
if task.status in {"completed", "skipped", "archived"}:
return "closed"
days_to_due = self._build_lifecycle_task_days_to_due(task)
if days_to_due is None:
return "not_set"
if days_to_due < 0:
return "overdue"
if days_to_due <= 7:
return "due_soon"
return "normal"
@staticmethod
def _build_lifecycle_task_summary(items: List[Dict[str, Any]]) -> Dict[str, int]:
summary = {
"open": 0,
"in_progress": 0,
"completed": 0,
"skipped": 0,
"archived": 0,
"overdue": 0,
"due_soon": 0,
"with_evidence": 0,
}
for item in items:
status_value = item.get("status")
if status_value in summary:
summary[status_value] += 1
if item.get("due_status") == "overdue":
summary["overdue"] += 1
if item.get("due_status") == "due_soon":
summary["due_soon"] += 1
if item.get("evidence_file_id"):
summary["with_evidence"] += 1
return summary
def _build_contract_lifecycle(self, generated_contract: GeneratedContract) -> Dict[str, Any]:
lifecycle_status = generated_contract.lifecycle_status or generated_contract.status or "generated"
expires_at = self._normalize_optional_datetime(generated_contract.expires_at)
now = datetime.now(timezone.utc)
days_to_expiry = None
due_status = "not_set"
if expires_at is not None:
days_to_expiry = (expires_at.date() - now.date()).days
if days_to_expiry < 0:
due_status = "overdue"
elif days_to_expiry <= 30:
due_status = "due_soon"
else:
due_status = "normal"
if lifecycle_status in {"terminated", "archived"}:
due_status = "closed"
return {
"status": lifecycle_status,
"counterparty_name": generated_contract.counterparty_name,
"owner_name": generated_contract.owner_name,
"effective_at": self._serialize_optional_datetime(generated_contract.effective_at),
"expires_at": self._serialize_optional_datetime(generated_contract.expires_at),
"days_to_expiry": days_to_expiry,
"due_status": due_status,
"note": generated_contract.lifecycle_note,
"updated_by": generated_contract.lifecycle_updated_by,
"updated_at": self._serialize_optional_datetime(generated_contract.lifecycle_updated_at),
}
def _build_contract_approval_summary(self, generated_contract: GeneratedContract) -> Dict[str, Any]:
steps = self.contract_approval_step_repository.list_by_contract(
generated_contract.tenant_id,
generated_contract.id,
)
latest = steps[0] if steps else None
pending = next((step for step in steps if step.status == "pending"), None)
return {
"status": pending.status if pending else latest.status if latest else "not_submitted",
"latest": self._to_contract_approval_step_summary(latest),
"pending": self._to_contract_approval_step_summary(pending),
"history": [self._to_contract_approval_step_summary(step) for step in steps],
"total": len(steps),
}
def _to_contract_approval_step_summary(self, step) -> Optional[Dict[str, Any]]:
if step is None:
return None
return {
"id": step.id,
"contract_id": step.contract_id,
"step_type": step.step_type,
"status": step.status,
"title": step.title,
"comment": step.comment,
"submitter_id": step.submitter_id,
"submitter_name": step.submitter_name,
"assignee_id": step.assignee_id,
"assignee_name": step.assignee_name,
"decided_by": step.decided_by,
"decided_by_name": step.decided_by_name,
"decided_at": self._serialize_optional_datetime(step.decided_at),
"created_at": self._serialize_optional_datetime(step.created_at),
"updated_at": self._serialize_optional_datetime(step.updated_at),
}
@staticmethod
def _normalize_version_change_type(value: str) -> str:
normalized = str(value or "editor_save").strip().lower().replace("-", "_")
if normalized not in {
"editor_save",
"editor_autosave",
"clause_recommendation",
"clause_rewrite",
"review_suggestion",
"manual_edit",
}:
return "editor_save"
return normalized
@staticmethod
def _normalize_locate_status(value: str) -> str:
normalized = str(value or "applied").strip().lower().replace("-", "_")
if normalized not in {"applied", "manual_required", "draft"}:
return "applied"
return normalized
@staticmethod
def _optional_summary(value: Any) -> Optional[str]:
if value is None or not str(value).strip():
return None
return str(value).strip()
def _resolve_generation_template(
self,
*,
tenant_id: str,
document_type_code: str,
template_id: Optional[str] = None,
):
if template_id:
template = self.repository.get_by_id(template_id, tenant_id)
if not template:
raise NotFoundError("模板不存在或无权访问")
if not template.enabled:
raise ValidationError("模板已停用")
return template
templates = self.repository.list_enabled(
tenant_id=tenant_id,
document_type_code=document_type_code,
)
if not templates:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前文书类型没有启用模板")
return templates[0]
@classmethod
def _map_elements_to_template_variables(cls, elements: Dict[str, Any], template_variables: List[Dict]) -> Dict[str, str]:
flattened = cls._flatten_values(elements)
aliases = cls._build_alias_values(flattened)
values: Dict[str, str] = {}
for item in template_variables:
name = str(item.get("name") or "")
if not name:
continue
value = None
if name in flattened:
value = flattened[name]
elif name in aliases:
value = aliases[name]
elif name.replace("_", ".") in flattened:
value = flattened[name.replace("_", ".")]
if value is not None and not cls._is_empty_value(value):
values[name] = str(value)
return values
@classmethod
def _build_missing_fields(
cls,
template_variables: List[Dict],
provided_values: Dict[str, Any],
validation_result: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
missing: List[Dict[str, Any]] = []
seen: set[str] = set()
for error in (validation_result or {}).get("errors") or []:
field_key = str(error.get("field_key") or "").strip()
if not field_key or field_key in seen:
continue
seen.add(field_key)
missing.append(
{
"source": "element_schema",
"field_key": field_key,
"label": field_key,
"message": error.get("message") or "required",
"placeholder": MISSING_VALUE_PLACEHOLDER,
}
)
for item in template_variables:
name = str(item.get("name") or "").strip()
if not name or name in seen or not item.get("required", True):
continue
if cls._is_empty_value(provided_values.get(name)):
seen.add(name)
missing.append(
{
"source": "template_variable",
"field_key": name,
"label": item.get("label") or name,
"message": "required",
"placeholder": MISSING_VALUE_PLACEHOLDER,
}
)
return missing
@classmethod
def _build_generation_legal_source_query(
cls,
*,
document_type_code: str,
elements: Dict[str, Any],
template_name: str,
) -> str:
terms: List[str] = []
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)
for key, value in flattened.items():
if isinstance(value, (dict, list)):
continue
if any(
marker in key
for marker in [
"clause",
"risk",
"dispute",
"payment",
"acceptance",
"goods",
"service",
"data",
"confidential",
"invoice",
"fee",
"liability",
"notice",
"rescission",
"sla",
"delivery",
"quality",
"warranty",
"price",
"packaging",
"交付",
"发票",
"质保",
"包装",
"验收",
"货物",
"付款",
"售后",
"风险",
"数据",
"保密",
"发票",
"费用",
"责任",
"通知",
"解除",
"服务级别",
"响应",
"交付成果",
]
):
cls._append_unique_text(terms, value)
return " ".join(terms[:18])
def _search_generation_legal_articles(self, current_user: Dict[str, Any], query: str) -> List[Dict[str, Any]]:
if not query.strip():
return []
try:
return self.legal_article_service.search_runtime_sources(current_user, query, limit=5)
except Exception:
return []
@staticmethod
def _build_generation_ai_hints(
*,
missing_fields: List[Dict[str, Any]],
related_legal_articles: List[Dict[str, Any]],
validation_result: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
hints: List[Dict[str, Any]] = []
if missing_fields:
hints.append(
{
"code": "missing_required_fields",
"level": "warning",
"message": f"仍有 {len(missing_fields)} 个必填要素未填写,已在初稿中使用“{MISSING_VALUE_PLACEHOLDER}”标识。",
"field_keys": [item.get("field_key") for item in missing_fields],
}
)
elif validation_result is not None:
hints.append(
{
"code": "element_validation_passed",
"level": "info",
"message": "结构化要素已通过当前文书 schema 校验。",
}
)
if related_legal_articles:
hints.append(
{
"code": "legal_sources_attached",
"level": "info",
"message": f"已关联 {len(related_legal_articles)} 条法条来源,生成结果可继续追溯法律依据。",
}
)
else:
hints.append(
{
"code": "legal_sources_not_found",
"level": "info",
"message": "本次生成未命中明确法条来源,建议后续审查阶段继续核对法律依据。",
}
)
return hints
@classmethod
def _build_generated_contract_html(
cls,
content: str,
*,
missing_fields: List[Dict[str, Any]],
related_legal_articles: List[Dict[str, Any]],
ai_hints: List[Dict[str, Any]],
) -> str:
body = "".join(f"<p>{escape(part.strip())}</p>" for part in str(content or "").splitlines() if part.strip())
if not body:
body = "<p></p>"
hint_items = "".join(f"<li>{escape(str(item.get('message') or ''))}</li>" for item in ai_hints)
missing_items = "".join(
f"<li>{escape(str(item.get('label') or item.get('field_key') or ''))}: {escape(str(item.get('placeholder') or MISSING_VALUE_PLACEHOLDER))}</li>"
for item in missing_fields
)
source_items = "".join(
f"<li>{escape(str(item.get('title') or ''))}</li>"
for item in related_legal_articles
)
return (
"<article class=\"generated-contract\">"
f"<section class=\"contract-body\">{body}</section>"
f"<section class=\"ai-hints\"><h2>AI提醒</h2><ul>{hint_items}</ul></section>"
f"<section class=\"missing-fields\"><h2>待补字段</h2><ul>{missing_items}</ul></section>"
f"<section class=\"legal-sources\"><h2>相关法条</h2><ul>{source_items}</ul></section>"
"</article>"
)
@classmethod
def _build_alias_values(cls, flattened: Dict[str, Any]) -> Dict[str, Any]:
party_a = cls._first(flattened, ["party_a.name", "buyer", "buyer_name", "employer.name", "landlord.name"])
party_b = cls._first(flattened, ["party_b.name", "supplier", "supplier_name", "employee.name", "tenant.name"])
service_name = cls._first(flattened, ["service.name", "service_name", "product_name", "project.name"])
service_scope = cls._first(flattened, ["service.scope", "service_scope", "service.content", "service_content"])
service_exclusions = cls._first(flattened, ["service.exclusions", "service_exclusions"])
deliverables = cls._first(flattened, ["deliverables", "service.deliverables", "service_outputs"])
service_period = cls._first(flattened, ["service.period", "service_period", "contract_period"])
service_level = cls._first(flattened, ["service.level", "service_level", "sla"])
service_response_time = cls._first(flattened, ["service.response_time", "service_response_time", "response_time"])
product_name = cls._first(flattened, ["goods.name", "product_name", "service.name", "service_name", "project.name"])
specification = cls._first(flattened, ["goods.specification", "specification"])
quantity = cls._first(flattened, ["goods.quantity", "quantity"])
unit = cls._first(flattened, ["goods.unit", "unit"])
unit_price = cls._first(flattened, ["price.unit", "unit_price"])
amount = cls._first(
flattened,
[
"price.total",
"amount",
"contract_amount",
"service_fee",
"fee.total",
"salary.monthly",
"rent.monthly",
"transfer.price",
],
)
delivery_location = cls._first(flattened, ["delivery.place", "delivery_location", "work_location", "property.address"])
delivery_deadline = cls._first(
flattened,
["delivery.deadline", "delivery.date", "delivery_deadline", "delivery_date", "acceptance.date", "closing.date", "effective_date"],
)
payment_terms = cls._first(flattened, ["payment.terms", "payment_terms"])
acceptance_standard = cls._first(flattened, ["acceptance.standard", "acceptance_standard"])
dispute_resolution = cls._first(flattened, ["dispute_resolution.method", "dispute_resolution", "dispute_forum"])
sign_date = cls._first(flattened, ["sign_date", "created_date"]) or datetime.now().date().isoformat()
quality_standard = cls._first(flattened, ["quality.standard", "quality_standard"])
packaging_transport = cls._first(flattened, ["packaging.transport", "packaging_transport"])
risk_transfer = cls._first(flattened, ["risk.transfer", "risk_transfer"])
delivery_documents = cls._first(flattened, ["delivery.documents", "delivery_documents"])
acceptance_period = cls._first(flattened, ["acceptance.period", "acceptance_period"])
invoice_type = cls._first(flattened, ["invoice.type", "invoice_type"])
payment_conditions = cls._first(flattened, ["payment.conditions", "payment_conditions"])
payee_account = cls._first(flattened, ["payee.account", "payee_account"])
warranty_period = cls._first(flattened, ["warranty.period", "warranty_period"])
after_sales_response = cls._first(flattened, ["after_sales.response", "after_sales_response"])
breach_liability = cls._first(flattened, ["breach_liability", "breach.liability"])
order_attachment_no = cls._first(flattened, ["order.attachment_no", "order_attachment_no"])
sample_or_technical_materials = cls._first(flattened, ["sample_or_technical_materials", "technical.materials"])
batch_delivery = cls._first(flattened, ["delivery.batch", "batch_delivery"])
liquidated_damages_formula = cls._first(flattened, ["liquidated_damages.formula", "liquidated_damages_formula"])
liability_cap = cls._first(flattened, ["liability.cap", "liability_cap"])
rescission_trigger = cls._first(flattened, ["rescission.trigger", "rescission_trigger"])
notice_address = cls._first(flattened, ["notice.address", "notice_address"])
e_signature_platform = cls._first(flattened, ["e_signature_platform", "signature.platform"])
data_scope = cls._first(flattened, ["data.scope", "data_scope", "data_access_scope"])
confidentiality_scope = cls._first(flattened, ["confidentiality.scope", "confidentiality_scope", "confidential_scope"])
regulatory_note = cls._first(flattened, ["regulatory_note", "regulatory.note"])
return {
"party_a": party_a,
"party_a_name": party_a,
"buyer": party_a,
"buyer_name": party_a,
"甲方名称": party_a,
"employer_name": party_a,
"party_b": party_b,
"party_b_name": party_b,
"supplier": party_b,
"supplier_name": party_b,
"乙方名称": party_b,
"employee_name": party_b,
"service_name": service_name,
"服务名称": service_name,
"service_scope": service_scope,
"service_content": service_scope,
"服务范围": service_scope,
"服务内容": service_scope,
"service_exclusions": service_exclusions,
"不包含事项": service_exclusions,
"deliverables": deliverables,
"交付成果": deliverables,
"service_period": service_period,
"服务期限": service_period,
"service_level": service_level,
"服务级别": service_level,
"service_response_time": service_response_time,
"响应时效": service_response_time,
"product_name": product_name,
"标的名称": product_name,
"specification": specification,
"规格型号": specification,
"quantity": quantity,
"采购数量": quantity,
"unit": unit,
"计量单位": unit,
"unit_price": unit_price,
"单价": unit_price,
"amount": amount,
"contract_amount": amount,
"service_fee": amount,
"合同价款": amount,
"quality_standard": quality_standard,
"质量标准": quality_standard,
"packaging_transport": packaging_transport,
"包装及运输要求": packaging_transport,
"delivery_location": delivery_location,
"交付地点": delivery_location,
"delivery_date": delivery_deadline,
"delivery_deadline": delivery_deadline,
"交付期限": delivery_deadline,
"risk_transfer": risk_transfer,
"风险转移节点": risk_transfer,
"delivery_documents": delivery_documents,
"随附文件清单": delivery_documents,
"payment_terms": payment_terms,
"付款安排": payment_terms,
"acceptance_standard": acceptance_standard,
"验收标准": acceptance_standard,
"acceptance_period": acceptance_period,
"验收期限": acceptance_period,
"invoice_type": invoice_type,
"发票类型": invoice_type,
"payment_conditions": payment_conditions,
"付款前置条件": payment_conditions,
"payee_account": payee_account,
"收款账户": payee_account,
"warranty_period": warranty_period,
"质量保证期": warranty_period,
"after_sales_response": after_sales_response,
"售后响应要求": after_sales_response,
"breach_liability": breach_liability,
"违约责任": breach_liability,
"dispute_resolution": dispute_resolution,
"dispute_forum": dispute_resolution,
"争议解决方式": dispute_resolution,
"sign_date": sign_date,
"签署日期": sign_date,
"order_attachment_no": order_attachment_no,
"订单/附件编号": order_attachment_no,
"sample_or_technical_materials": sample_or_technical_materials,
"样品或技术资料": sample_or_technical_materials,
"batch_delivery": batch_delivery,
"分批交付安排": batch_delivery,
"liquidated_damages_formula": liquidated_damages_formula,
"违约金计算口径": liquidated_damages_formula,
"liability_cap": liability_cap,
"责任上限": liability_cap,
"rescission_trigger": rescission_trigger,
"解除触发条件": rescission_trigger,
"notice_address": notice_address,
"通知方式及送达地址": notice_address,
"e_signature_platform": e_signature_platform,
"电子签署平台": e_signature_platform,
"data_scope": data_scope,
"数据接触范围": data_scope,
"confidentiality_scope": confidentiality_scope,
"保密范围": confidentiality_scope,
"regulatory_note": regulatory_note,
"特殊监管说明": regulatory_note,
}
@classmethod
def _flatten_values(cls, values: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
flattened: Dict[str, Any] = {}
for key, value in (values or {}).items():
path = f"{prefix}.{key}" if prefix else str(key)
flattened[path] = value
flattened[path.replace(".", "_")] = value
if isinstance(value, dict):
flattened.update(cls._flatten_values(value, path))
return flattened
@classmethod
def _first(cls, values: Dict[str, Any], keys: List[str]) -> Any:
for key in keys:
value = values.get(key)
if not cls._is_empty_value(value):
return value
return None
@staticmethod
def _append_unique_text(values: List[str], value: Any) -> None:
text = str(value or "").strip()
if text and text not in values:
values.append(text)
@staticmethod
def _is_empty_value(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str) and not value.strip():
return True
if isinstance(value, (list, dict)) and not value:
return True
return False
def _parse_variables(self, variables_json: Any) -> List[Dict]:
"""解析模板变量JSON"""
if not variables_json:
return []
if isinstance(variables_json, list):
variables = variables_json
else:
try:
variables = json.loads(variables_json)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(variables, list):
return []
parsed_variables = []
for var in variables:
if isinstance(var, dict) and "name" in var:
parsed_variables.append({
"name": var["name"],
"label": var.get("label", var["name"]),
"required": var.get("required", True),
"default_value": var.get("default_value", ""),
"description": var.get("description", ""),
})
return parsed_variables
def _validate_variable_values(
self,
template_variables: List[Dict],
variable_values: Dict[str, str],
preview: bool = False,
) -> None:
"""验证变量值"""
# 检查必填变量
for var in template_variables:
var_name = var["name"]
is_required = var["required"]
if is_required and var_name not in variable_values:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"必填变量 '{var['label']}' 未提供"
)
# 检查变量值是否为空(如果是必填)
if is_required and var_name in variable_values:
value = variable_values[var_name]
if not value or not value.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"变量 '{var['label']}' 不能为空"
)
# 检查是否有未知变量
template_var_names = {var["name"] for var in template_variables}
provided_var_names = set(variable_values.keys())
unknown_vars = provided_var_names - template_var_names
if unknown_vars and not preview:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"未知变量: {', '.join(unknown_vars)}"
)
@staticmethod
def _complete_variable_values(template_variables: List[Dict], variable_values: Dict[str, Any]) -> Dict[str, str]:
completed: Dict[str, str] = {}
for key, value in (variable_values or {}).items():
completed[str(key)] = "" if value is None else str(value)
for var in template_variables:
var_name = var["name"]
if var_name not in completed or not str(completed[var_name]).strip():
default_value = var.get("default_value")
completed[var_name] = str(default_value).strip() if default_value else MISSING_VALUE_PLACEHOLDER
return completed
def _replace_variables(self, template_content: str, variable_values: Dict[str, str]) -> str:
"""替换模板中的变量占位符"""
content = template_content
# 替换所有 {{variable_name}} 格式的占位符
for var_name, value in variable_values.items():
placeholder = f"{{{{{var_name}}}}}"
content = content.replace(placeholder, value)
# 清理未替换的占位符(用空字符串替换)
remaining_placeholders = re.findall(r'\{\{([^}]+)\}\}', content)
for placeholder in remaining_placeholders:
content = content.replace(f"{{{{{placeholder}}}}}", "")
return content
def _get_preview_excerpt(self, content: str, length: int = 100) -> str:
"""获取模板内容预览"""
# 移除变量占位符
preview = re.sub(r'\{\{[^}]+\}\}', '[变量]', content)
# 截取指定长度
if len(preview) > length:
preview = preview[:length] + "..."
return preview
def _get_variable_summary(
self,
template_variables: List[Dict],
variable_values: Dict[str, str],
) -> List[Dict]:
"""获取变量填写情况摘要"""
summary = []
for var in template_variables:
var_name = var["name"]
value = variable_values.get(var_name, "")
summary.append({
"name": var_name,
"label": var["label"],
"required": var["required"],
"provided": var_name in variable_values,
"value_preview": self._get_value_preview(value),
"is_empty": not value or not value.strip(),
})
return summary
def _get_value_preview(self, value: str, max_length: int = 50) -> str:
"""获取变量值预览"""
if not value:
return "(空)"
value = str(value).strip()
if len(value) > max_length:
return value[:max_length] + "..."
return value
def _export_contract(
self,
content: str,
template_name: str,
timestamp: str,
export_format: ExportFormat,
options: Dict[str, Any],
) -> Tuple[bytes, str]:
"""导出合同到不同格式"""
# 清理模板名称用于文件名
safe_name = re.sub(r'[^\w\s-]', '', template_name)
safe_name = re.sub(r'[-\s]+', '_', safe_name).strip('-_').lower()
if export_format == ExportFormat.DOCX:
return self._export_to_docx(content, safe_name, timestamp, options)
elif export_format == ExportFormat.PDF:
return self._export_to_pdf(content, safe_name, timestamp, options)
elif export_format == ExportFormat.HTML:
return self._export_to_html(content, safe_name, timestamp, options)
else: # TXT
return self._export_to_txt(content, safe_name, timestamp, options)
def _export_to_docx(
self,
content: str,
safe_name: str,
timestamp: str,
options: Dict[str, Any],
) -> Tuple[bytes, str]:
"""导出为DOCX格式"""
try:
# 尝试导入python-docx
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
# 创建新文档
doc = Document()
# 设置文档样式
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
section = doc.sections[0]
section.top_margin = Inches(0.85)
section.bottom_margin = Inches(0.85)
section.left_margin = Inches(0.85)
section.right_margin = Inches(0.85)
document_title = str(options.get("document_title") or "合同文件").strip() or "合同文件"
version_label = str(options.get("version_label") or "").strip()
generated_by_name = str(options.get("generated_by_name") or "").strip()
# 添加标题
title = doc.add_heading(document_title, 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
# 添加生成信息
info_para = doc.add_paragraph()
info_parts = [f"导出时间: {timestamp}"]
if version_label:
info_parts.insert(0, f"版本: {version_label}")
if generated_by_name:
info_parts.append(f"经办人: {generated_by_name}")
info_para.add_run(" | ".join(info_parts)).italic = True
info_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
# 添加分隔线
doc.add_paragraph('-' * 50)
# 添加合同内容
# 按段落分割内容
paragraphs = content.split('\n')
for para_text in paragraphs:
normalized_para = para_text.strip()
if not normalized_para:
continue
if re.match(r"^第[一二三四五六七八九十百千万零〇0-9]+[章节条]", normalized_para):
para = doc.add_paragraph()
run = para.add_run(normalized_para)
run.bold = True
run.font.size = Pt(12)
para.paragraph_format.space_before = Pt(8)
para.paragraph_format.space_after = Pt(4)
continue
if re.match(r"^[一二三四五六七八九十0-9]+[、..]", normalized_para):
para = doc.add_paragraph()
run = para.add_run(normalized_para)
run.bold = True
para.paragraph_format.space_before = Pt(6)
para.paragraph_format.space_after = Pt(3)
continue
para = doc.add_paragraph(normalized_para)
para.style = doc.styles['Normal']
para.paragraph_format.first_line_indent = Pt(24)
para.paragraph_format.space_after = Pt(4)
# 添加页脚
footer = section.footer
footer_para = footer.paragraphs[0]
footer_para.text = f"AI合同平台导出 - {document_title}{f' - {version_label}' if version_label else ''}"
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# 保存到内存
import io
output = io.BytesIO()
doc.save(output)
file_content = output.getvalue()
file_name = f"{safe_name}_{timestamp}.docx"
return file_content, file_name
except ImportError:
# 如果python-docx不可用,使用简单的文本格式
return self._export_to_txt(content, safe_name, timestamp, options)
def _export_to_pdf(
self,
content: str,
safe_name: str,
timestamp: str,
options: Dict[str, Any],
) -> Tuple[bytes, str]:
"""导出为PDF格式"""
try:
# 尝试导入reportlab
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.enums import TA_CENTER, TA_RIGHT
import io
# 创建内存中的PDF
buffer = io.BytesIO()
# 创建文档
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
# 准备内容
styles = getSampleStyleSheet()
story = []
# 标题
title_style = styles['Title']
title_style.alignment = TA_CENTER
story.append(Paragraph('合同文件', title_style))
story.append(Spacer(1, 0.5*cm))
# 生成信息
info_style = styles['Normal']
info_style.alignment = TA_RIGHT
story.append(Paragraph(f'生成时间: {timestamp}', info_style))
story.append(Spacer(1, 0.5*cm))
# 分隔线
story.append(Paragraph('-' * 50, styles['Normal']))
story.append(Spacer(1, 0.5*cm))
# 合同内容
paragraphs = content.split('\n')
for para_text in paragraphs:
if para_text.strip():
story.append(Paragraph(para_text.strip(), styles['Normal']))
story.append(Spacer(1, 0.2*cm))
# 页脚
footer_style = styles['Normal']
footer_style.alignment = TA_CENTER
story.append(Spacer(1, 1*cm))
story.append(Paragraph(f"AI合同平台生成 - {safe_name}", footer_style))
# 构建PDF
doc.build(story)
file_content = buffer.getvalue()
buffer.close()
file_name = f"{safe_name}_{timestamp}.pdf"
return file_content, file_name
except ImportError:
# 如果reportlab不可用,使用HTML格式
return self._export_to_html(content, safe_name, timestamp, options)
def _export_to_html(
self,
content: str,
safe_name: str,
timestamp: str,
options: Dict[str, Any],
) -> Tuple[bytes, str]:
"""导出为HTML格式"""
html_template = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>合同文件 - {safe_name}</title>
<style>
body {{
font-family: 'SimSun', '宋体', serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: #f8f9fa;
}}
.container {{
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 40px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
border-radius: 8px;
}}
.header {{
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #1890ff;
padding-bottom: 20px;
}}
.title {{
font-size: 28px;
color: #1890ff;
margin-bottom: 10px;
}}
.timestamp {{
text-align: right;
color: #666;
font-style: italic;
margin-bottom: 20px;
}}
.content {{
font-size: 14px;
white-space: pre-wrap;
margin-bottom: 30px;
}}
.footer {{
text-align: center;
color: #999;
font-size: 12px;
border-top: 1px solid #eee;
padding-top: 20px;
margin-top: 30px;
}}
.separator {{
border-top: 1px dashed #ddd;
margin: 20px 0;
}}
@media print {{
body {{
background-color: white;
padding: 0;
}}
.container {{
box-shadow: none;
padding: 0;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">合同文件</h1>
</div>
<div class="timestamp">
生成时间: {timestamp}
</div>
<div class="separator"></div>
<div class="content">
{content}
</div>
<div class="separator"></div>
<div class="footer">
AI合同平台生成 - {safe_name}
</div>
</div>
</body>
</html>"""
file_content = html_template.encode('utf-8')
file_name = f"{safe_name}_{timestamp}.html"
return file_content, file_name
def _export_to_txt(
self,
content: str,
safe_name: str,
timestamp: str,
options: Dict[str, Any],
) -> Tuple[bytes, str]:
"""导出为TXT格式"""
txt_content = f"""合同文件
{'=' * 50}
生成时间: {timestamp}
{'=' * 50}
{content}
{'=' * 50}
AI合同平台生成 - {safe_name}
"""
file_content = txt_content.encode('utf-8')
file_name = f"{safe_name}_{timestamp}.txt"
return file_content, file_name