2627 lines
115 KiB
Python
2627 lines
115 KiB
Python
import json
|
||
import re
|
||
import io
|
||
import base64
|
||
import difflib
|
||
from html import escape
|
||
from typing import Any, Dict, List, 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 sqlalchemy.orm import Session
|
||
|
||
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_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.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 = "请补充"
|
||
DOCUMENT_TYPE_LEGAL_QUERY_TERMS = {
|
||
"procurement_goods": ["买卖合同", "货物采购", "交付", "风险转移", "验收", "质量", "发票", "付款", "质保", "违约", "民法典", "procurement", "breach"],
|
||
"technical_service": ["技术服务合同", "知识产权", "验收", "违约", "民法典"],
|
||
"software_development": ["软件开发合同", "知识产权", "交付", "验收", "民法典"],
|
||
"service_general": ["服务合同", "通用服务", "服务范围", "交付成果", "服务级别", "费用结算", "验收整改", "保密", "个人信息", "数据处理", "违约", "解除通知", "电子签署", "民法典"],
|
||
"labor_contract": ["劳动合同", "试用期", "社会保险", "竞业限制", "劳动合同法"],
|
||
"nda_commercial": ["保密协议", "商业秘密", "反不正当竞争法", "保密措施", "返还销毁", "违约责任", "电子签署", "民法典"],
|
||
"lease_house": ["房屋租赁合同", "租金", "押金", "解除", "民法典"],
|
||
"equity_transfer": ["股权转让", "公司法", "价款", "交割"],
|
||
"demand_letter": ["催告函", "履约催告", "违约", "送达", "证据留存", "民法典"],
|
||
"rescission_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"],
|
||
"contract_termination_notice": ["解除合同", "解除通知", "终止", "违约", "送达", "民法典"],
|
||
"agency_distribution_agreement": ["代理", "经销", "渠道", "返利", "区域", "合规"],
|
||
"franchise_agreement": ["加盟", "特许经营", "品牌授权", "两店一年", "信息披露", "备案", "商标许可", "消费者", "培训", "督导"],
|
||
"intercompany_loan_agreement": ["企业间借款", "借款合同", "借款协议", "关联交易", "利息", "担保", "还款", "资金用途", "公司法", "民间借贷"],
|
||
"investment_agreement": ["投资协议", "投融资", "估值", "交割", "治理权利", "退出", "陈述保证", "公司法", "反垄断", "外商投资"],
|
||
"valuation_adjustment_agreement": ["对赌协议", "估值调整", "业绩承诺", "补偿机制", "回购", "公司法", "投融资", "九民纪要"],
|
||
"shareholder_resolution": ["股东会决议", "股东大会决议", "公司法", "公司章程", "表决权", "召集通知", "工商变更", "登记", "备案"],
|
||
"power_of_attorney": ["授权委托书", "委托代理", "授权范围", "转委托", "签章", "电子签名", "诉讼代理", "登记备案"],
|
||
"legal_opinion": ["法律意见书", "律师事务所", "尽职调查", "核查验证", "法律分析", "结论意见", "前提假设", "使用限制"],
|
||
"property_service": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"],
|
||
"property_service_agreement": ["物业服务合同", "物业管理", "物业费", "专项维修资金", "公共收益", "业主委员会", "共用部位", "安全协助"],
|
||
}
|
||
|
||
TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES = {
|
||
"property_service": ["property_service", "property_service_agreement"],
|
||
"property_service_agreement": ["property_service", "property_service_agreement"],
|
||
"rescission_notice": ["rescission_notice", "contract_termination_notice"],
|
||
"contract_termination_notice": ["rescission_notice", "contract_termination_notice"],
|
||
"termination_notice": ["rescission_notice", "contract_termination_notice"],
|
||
}
|
||
|
||
CONTRACT_LIFECYCLE_STATUSES = {
|
||
"generated",
|
||
"internal_review",
|
||
"external_review",
|
||
"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):
|
||
self.db = db
|
||
self.repository = ContractTemplateRepository(db)
|
||
self.generated_contract_repository = GeneratedContractRepository(db)
|
||
self.generated_contract_version_repository = GeneratedContractVersionRepository(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)
|
||
|
||
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]:
|
||
"""获取可用的合同模板列表"""
|
||
tenant_id = current_user["tenant_id"]
|
||
normalized_document_type_code = None
|
||
if document_type_code is not None:
|
||
normalized_document_type_code = document_type_code.strip().lower().replace("-", "_")
|
||
|
||
document_type_candidates = [None]
|
||
if normalized_document_type_code is not None:
|
||
document_type_candidates = TEMPLATE_DOCUMENT_TYPE_CODE_ALIASES.get(
|
||
normalized_document_type_code,
|
||
[normalized_document_type_code],
|
||
)
|
||
|
||
templates_by_id: Dict[str, Any] = {}
|
||
for candidate in document_type_candidates:
|
||
matched_templates = self.repository.list_enabled(
|
||
tenant_id=tenant_id,
|
||
contract_type=contract_type,
|
||
template_category=template_category,
|
||
industry_category=industry_category,
|
||
document_type_code=candidate,
|
||
)
|
||
for template in matched_templates:
|
||
templates_by_id[template.id] = template
|
||
|
||
templates = sorted(
|
||
templates_by_id.values(),
|
||
key=lambda template: (template.updated_at, template.created_at),
|
||
reverse=True,
|
||
)
|
||
|
||
return [
|
||
{
|
||
"id": template.id,
|
||
"name": template.name,
|
||
"contract_type": template.contract_type,
|
||
"document_type_code": template.document_type_code,
|
||
"template_category": template.template_category,
|
||
"industry_category": template.industry_category,
|
||
"description": template.description,
|
||
"variables": self._parse_variables(template.variables_json),
|
||
"preview_excerpt": self._get_preview_excerpt(template.content),
|
||
}
|
||
for template in templates
|
||
]
|
||
|
||
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)
|
||
|
||
# 替换模板内容
|
||
generated_content = self._replace_variables(template.content, variable_values)
|
||
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
|
||
# 根据导出格式生成文件
|
||
file_content, file_name = self._export_contract(
|
||
generated_content,
|
||
template.name,
|
||
timestamp,
|
||
export_format,
|
||
options
|
||
)
|
||
|
||
file_item = self.file_service.create_file_record(
|
||
current_user=current_user,
|
||
file_name=file_name,
|
||
content=file_content,
|
||
file_type="contract",
|
||
mime_type=self._get_mime_type(export_format),
|
||
require_admin=False,
|
||
)
|
||
|
||
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=[
|
||
f"contract_generation_{resolved_document_type_code}" if resolved_document_type_code else None,
|
||
f"contract_generation_{template.contract_type}" if template.contract_type else None,
|
||
"contract_generation_default",
|
||
],
|
||
fallback_prompt_version="contract_generation.template_render.v1",
|
||
)
|
||
prompt_runtime_trace = self.prompt_runtime_service.to_trace(prompt_runtime)
|
||
persisted_options = {
|
||
**options,
|
||
"_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,
|
||
},
|
||
}
|
||
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
|
||
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._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,
|
||
},
|
||
)
|
||
auto_review_summary = self._create_auto_review_summary(current_user, created_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_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 HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="document_type_code is required")
|
||
if not isinstance(elements, dict):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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",
|
||
"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"]
|
||
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,
|
||
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,
|
||
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:
|
||
tenant_id = current_user["tenant_id"]
|
||
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问")
|
||
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.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
|
||
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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 HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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 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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
|
||
version = self.generated_contract_version_repository.get_by_id(tenant_id, contract_id, version_id)
|
||
if not version:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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 HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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_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._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.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
|
||
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
|
||
|
||
normalized_content = str(content or "").strip()
|
||
if not normalized_content:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="content is required")
|
||
if len(normalized_content) > 1_000_000:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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 HTTPException(status_code=status.HTTP_409_CONFLICT, detail="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.generated_contract_repository.get_by_id(contract_id, tenant_id)
|
||
if not generated_contract:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问")
|
||
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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问")
|
||
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 _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,
|
||
}
|
||
|
||
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_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,
|
||
}
|
||
|
||
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": f"/reviews/{review_id}",
|
||
"report_download_url": review_report.get("download_url") if review_report else f"/api/v1/reviews/{review_id}/report/download",
|
||
"report_file_name": review_report.get("file_name") if review_report else f"review-report-{review_id}.pdf",
|
||
"chat_url": f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}",
|
||
"prompt_runtime": prompt_runtime,
|
||
}
|
||
|
||
def _build_auto_review_failure_summary(self, generated_contract: GeneratedContract, failure_message: str) -> Dict[str, Any]:
|
||
safe_message = failure_message or "自动审查未能完成,请稍后重试。"
|
||
return {
|
||
"review_id": None,
|
||
"review_title": generated_contract.template_name,
|
||
"status": "failed",
|
||
"review_as": "party_a",
|
||
"contract_type": generated_contract.contract_type,
|
||
"risk_level": None,
|
||
"current_stage": "review",
|
||
"current_stage_label": "自动审查失败",
|
||
"status_detail": safe_message,
|
||
"failure_reason": safe_message,
|
||
"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_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 _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")
|
||
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",
|
||
"auto_review": auto_review,
|
||
"lifecycle": self._build_contract_lifecycle(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,
|
||
"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"],
|
||
"auto_review": item["auto_review"],
|
||
"lifecycle": item["lifecycle"],
|
||
"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"),
|
||
"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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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 HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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 HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
|
||
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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),
|
||
}
|
||
|
||
@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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在或无权访问")
|
||
if not template.enabled:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="模板已停用")
|
||
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 DOCUMENT_TYPE_LEGAL_QUERY_TERMS.get(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
|