3466 lines
153 KiB
Python
3466 lines
153 KiB
Python
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
|