feat: add contract delivery export diff
This commit is contained in:
@@ -24,6 +24,8 @@ ROUTE_PERMISSION_ACTIONS = {
|
||||
"versions": "contracts:versions:list",
|
||||
"version_detail": "contracts:versions:detail",
|
||||
"version_create": "contracts:versions:create",
|
||||
"version_diff": "contracts:versions:diff",
|
||||
"version_download": "contracts:versions:download",
|
||||
"lifecycle_update": "contracts:lifecycle:update",
|
||||
"lifecycle_tasks": "contracts:lifecycle-tasks:list",
|
||||
"lifecycle_task_sync": "contracts:lifecycle-tasks:sync",
|
||||
@@ -462,6 +464,80 @@ def list_generated_contract_versions(
|
||||
return success_response(contract_generation_service.list_generated_contract_versions(current_user, contract_id))
|
||||
|
||||
|
||||
@router.get("/{contract_id}/versions/diff")
|
||||
def diff_generated_contract_versions(
|
||||
contract_id: str,
|
||||
request: Request,
|
||||
from_version_id: Optional[str] = None,
|
||||
to_version_id: Optional[str] = None,
|
||||
contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
result = contract_generation_service.diff_generated_contract_versions(
|
||||
current_user=current_user,
|
||||
contract_id=contract_id,
|
||||
from_version_id=from_version_id,
|
||||
to_version_id=to_version_id,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="contract_version_diff",
|
||||
resource_type="contract",
|
||||
resource_id=contract_id,
|
||||
detail_json={
|
||||
"from_version": result.get("from_version", {}).get("version_label"),
|
||||
"to_version": result.get("to_version", {}).get("version_label"),
|
||||
"change_count": result.get("summary", {}).get("change_count"),
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/{contract_id}/versions/{version_id}/download")
|
||||
def download_generated_contract_version(
|
||||
contract_id: str,
|
||||
version_id: str,
|
||||
request: Request,
|
||||
export_format: str = "docx",
|
||||
contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
try:
|
||||
export_format_enum = ExportFormat(export_format.lower())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的导出格式: {export_format}")
|
||||
|
||||
result = contract_generation_service.export_generated_contract_version(
|
||||
current_user=current_user,
|
||||
contract_id=contract_id,
|
||||
version_id=version_id,
|
||||
export_format=export_format_enum,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="download_contract_version",
|
||||
resource_type="contract_version",
|
||||
resource_id=version_id,
|
||||
detail_json={
|
||||
"contract_id": contract_id,
|
||||
"version_label": result.get("version", {}).get("version_label"),
|
||||
"file_name": result["file_name"],
|
||||
"mime_type": result["mime_type"],
|
||||
"export_format": result.get("export_format"),
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(result['file_name'])}"}
|
||||
return Response(content=result["content"], media_type=result["mime_type"], headers=headers)
|
||||
|
||||
|
||||
@router.get("/{contract_id}/versions/{version_id}")
|
||||
def get_generated_contract_version(
|
||||
contract_id: str,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -659,6 +660,31 @@ class ContractGenerationService:
|
||||
"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)
|
||||
@@ -669,6 +695,51 @@ class ContractGenerationService:
|
||||
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],
|
||||
@@ -1176,6 +1247,182 @@ class ContractGenerationService:
|
||||
"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:
|
||||
@@ -1889,14 +2136,28 @@ class ContractGenerationService:
|
||||
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('合同文件', 0)
|
||||
title = doc.add_heading(document_title, 0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
# 添加生成信息
|
||||
info_para = doc.add_paragraph()
|
||||
info_para.add_run(f'生成时间: {timestamp}').italic = True
|
||||
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
|
||||
|
||||
# 添加分隔线
|
||||
@@ -1906,15 +2167,33 @@ class ContractGenerationService:
|
||||
# 按段落分割内容
|
||||
paragraphs = content.split('\n')
|
||||
for para_text in paragraphs:
|
||||
if para_text.strip():
|
||||
para = doc.add_paragraph(para_text.strip())
|
||||
para.style = doc.styles['Normal']
|
||||
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)
|
||||
|
||||
# 添加页脚
|
||||
section = doc.sections[0]
|
||||
footer = section.footer
|
||||
footer_para = footer.paragraphs[0]
|
||||
footer_para.text = f"AI合同平台生成 - {safe_name}"
|
||||
footer_para.text = f"AI合同平台导出 - {document_title}{f' - {version_label}' if version_label else ''}"
|
||||
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
# 保存到内存
|
||||
|
||||
@@ -339,6 +339,40 @@ class StubContractGenerationService:
|
||||
return item
|
||||
raise HTTPException(status_code=404, detail="contract version not found")
|
||||
|
||||
def diff_generated_contract_versions(self, current_user, contract_id, from_version_id=None, to_version_id=None):
|
||||
self.get_generated_contract(current_user, contract_id)
|
||||
return {
|
||||
"contract_id": contract_id,
|
||||
"from_version": {"id": "base", "version_number": 1, "version_label": "v1"},
|
||||
"to_version": {"id": to_version_id or "gcv-001", "version_number": 2, "version_label": "v2"},
|
||||
"summary": {
|
||||
"added": 1,
|
||||
"removed": 0,
|
||||
"modified": 1,
|
||||
"unchanged": 1,
|
||||
"change_count": 2,
|
||||
"from_paragraph_count": 2,
|
||||
"to_paragraph_count": 3,
|
||||
},
|
||||
"items": [
|
||||
{"change_type": "modified", "old_paragraph_index": 1, "new_paragraph_index": 1, "old_text": "Old", "new_text": "New"},
|
||||
{"change_type": "added", "old_paragraph_index": None, "new_paragraph_index": 3, "old_text": None, "new_text": "Payment deadline added."},
|
||||
],
|
||||
}
|
||||
|
||||
def export_generated_contract_version(self, current_user, contract_id, version_id=None, export_format=ExportFormat.DOCX):
|
||||
self.get_generated_contract(current_user, contract_id)
|
||||
return {
|
||||
"id": contract_id,
|
||||
"contract_id": contract_id,
|
||||
"version": {"id": version_id or "latest", "version_number": 2, "version_label": "v2"},
|
||||
"file_name": "contract-v2.docx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"content": b"docx-bytes",
|
||||
"file_size": 10,
|
||||
"export_format": export_format.value,
|
||||
}
|
||||
|
||||
def create_generated_contract_version(
|
||||
self,
|
||||
current_user,
|
||||
@@ -614,6 +648,24 @@ class ContractsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(detail.json()["data"]["id"], "gcv-001")
|
||||
|
||||
def test_user_can_diff_and_download_generated_contract_version(self):
|
||||
self._login()
|
||||
diff_response = self.client.get("/api/v1/contracts/contract-123/versions/diff")
|
||||
self.assertEqual(diff_response.status_code, 200)
|
||||
diff_body = diff_response.json()["data"]
|
||||
self.assertEqual(diff_body["summary"]["change_count"], 2)
|
||||
self.assertEqual(diff_body["items"][0]["change_type"], "modified")
|
||||
self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_diff")
|
||||
|
||||
download_response = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001/download")
|
||||
self.assertEqual(download_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
download_response.headers["content-type"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
self.assertEqual(download_response.content, b"docx-bytes")
|
||||
self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract_version")
|
||||
|
||||
def test_user_can_create_generated_contract_version_and_write_audit_log(self):
|
||||
self._login()
|
||||
response = self.client.post(
|
||||
@@ -725,6 +777,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
|
||||
from app.models.contract_template import ContractTemplate
|
||||
from app.models.file import File
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
from app.models.generated_contract_version import GeneratedContractVersion
|
||||
from app.models.legal_article import LegalArticle
|
||||
from app.models.legal_document_element_schema import LegalDocumentElementSchema
|
||||
from app.models.legal_document_type import LegalDocumentType
|
||||
@@ -738,6 +791,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
|
||||
self.ContractLifecycleTask = ContractLifecycleTask
|
||||
self.File = File
|
||||
self.GeneratedContract = GeneratedContract
|
||||
self.GeneratedContractVersion = GeneratedContractVersion
|
||||
self.LegalArticle = LegalArticle
|
||||
self.LegalDocumentType = LegalDocumentType
|
||||
self.LegalDocumentElementSchema = LegalDocumentElementSchema
|
||||
@@ -1082,6 +1136,112 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
|
||||
def test_diff_generated_contract_versions_returns_paragraph_changes(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
self.db.add(
|
||||
self.GeneratedContract(
|
||||
id="contract-diff-001",
|
||||
tenant_id="tenant-demo-001",
|
||||
template_id="tpl-diff",
|
||||
template_name="Diff Template",
|
||||
contract_type="procurement",
|
||||
document_type_code="procurement_goods",
|
||||
generated_by="user-admin",
|
||||
generated_by_name="Admin",
|
||||
export_format="docx",
|
||||
file_id=None,
|
||||
file_name="diff.docx",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file_size=10,
|
||||
generated_content="第一条 标的\n第二条 价款",
|
||||
variable_values_json={},
|
||||
export_options_json={},
|
||||
file_content_base64="",
|
||||
status="generated",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
self.GeneratedContractVersion(
|
||||
id="gcv-diff-002",
|
||||
tenant_id="tenant-demo-001",
|
||||
contract_id="contract-diff-001",
|
||||
version_number=2,
|
||||
version_label="v2",
|
||||
change_type="editor_save",
|
||||
change_summary="补充验收",
|
||||
content="第一条 标的\n第二条 价款\n第三条 验收",
|
||||
locate_status="applied",
|
||||
created_by="user-admin",
|
||||
created_at=now + timedelta(minutes=1),
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
result = self.service.diff_generated_contract_versions(
|
||||
current_user=self.current_user,
|
||||
contract_id="contract-diff-001",
|
||||
)
|
||||
|
||||
self.assertEqual(result["from_version"]["version_label"], "v1")
|
||||
self.assertEqual(result["to_version"]["version_label"], "v2")
|
||||
self.assertEqual(result["summary"]["added"], 1)
|
||||
self.assertEqual(result["items"][0]["change_type"], "added")
|
||||
self.assertIn("第三条 验收", result["items"][0]["new_text"])
|
||||
|
||||
def test_export_generated_contract_version_returns_docx_payload(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
self.db.add(
|
||||
self.GeneratedContract(
|
||||
id="contract-export-version-001",
|
||||
tenant_id="tenant-demo-001",
|
||||
template_id="tpl-export",
|
||||
template_name="Export Template",
|
||||
contract_type="procurement",
|
||||
document_type_code="procurement_goods",
|
||||
generated_by="user-admin",
|
||||
generated_by_name="Admin",
|
||||
export_format="docx",
|
||||
file_id=None,
|
||||
file_name="export.docx",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file_size=10,
|
||||
generated_content="第一条 标的\n第二条 价款",
|
||||
variable_values_json={},
|
||||
export_options_json={},
|
||||
file_content_base64="",
|
||||
status="generated",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
self.GeneratedContractVersion(
|
||||
id="gcv-export-002",
|
||||
tenant_id="tenant-demo-001",
|
||||
contract_id="contract-export-version-001",
|
||||
version_number=2,
|
||||
version_label="v2",
|
||||
change_type="editor_save",
|
||||
change_summary="补充交付",
|
||||
content="第一条 标的\n第二条 价款\n第三条 交付",
|
||||
locate_status="applied",
|
||||
created_by="user-admin",
|
||||
created_at=now + timedelta(minutes=1),
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
result = self.service.export_generated_contract_version(
|
||||
current_user=self.current_user,
|
||||
contract_id="contract-export-version-001",
|
||||
version_id="gcv-export-002",
|
||||
)
|
||||
|
||||
self.assertEqual(result["export_format"], "docx")
|
||||
self.assertEqual(result["version"]["version_label"], "v2")
|
||||
self.assertTrue(result["file_name"].endswith(".docx"))
|
||||
self.assertTrue(result["content"].startswith(b"PK"))
|
||||
|
||||
def test_update_generated_contract_lifecycle_tracks_expiry_and_owner(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
self.db.add(
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
BulbOutlined,
|
||||
CheckCircleOutlined,
|
||||
DiffOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
HistoryOutlined,
|
||||
LinkOutlined,
|
||||
@@ -27,9 +29,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
createGeneratedContractVersion,
|
||||
diffGeneratedContractVersions,
|
||||
downloadGeneratedContractVersion,
|
||||
getGeneratedContract,
|
||||
listGeneratedContractVersions,
|
||||
type GeneratedContract,
|
||||
type GeneratedContractVersionDiffResponse,
|
||||
type GeneratedContractVersionItem,
|
||||
} from "@/services/contract";
|
||||
import {
|
||||
@@ -67,6 +72,18 @@ const recommendationKindColorMap: Record<string, string> = {
|
||||
industry_practice: "purple",
|
||||
};
|
||||
|
||||
const diffChangeTypeLabelMap: Record<string, string> = {
|
||||
added: "新增",
|
||||
removed: "删除",
|
||||
modified: "修改",
|
||||
};
|
||||
|
||||
const diffChangeTypeColorMap: Record<string, string> = {
|
||||
added: "green",
|
||||
removed: "red",
|
||||
modified: "gold",
|
||||
};
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -148,6 +165,9 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
const [rewriteMode, setRewriteMode] = useState("professional");
|
||||
const [rewriteLoading, setRewriteLoading] = useState(false);
|
||||
const [rewriteResult, setRewriteResult] = useState<ClauseRewriteResponse | null>(null);
|
||||
const [versionDiff, setVersionDiff] = useState<GeneratedContractVersionDiffResponse | null>(null);
|
||||
const [diffLoading, setDiffLoading] = useState(false);
|
||||
const [exportingVersion, setExportingVersion] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const paragraphs = useMemo(() => splitParagraphs(editorContent), [editorContent]);
|
||||
@@ -166,6 +186,24 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
return versionsResponse.data;
|
||||
}, []);
|
||||
|
||||
const refreshVersionDiff = useCallback(async (id: string, toVersionId?: string | null) => {
|
||||
setDiffLoading(true);
|
||||
try {
|
||||
const diffResponse = await diffGeneratedContractVersions(id, { to_version_id: toVersionId ?? undefined });
|
||||
if (diffResponse.code !== 0) {
|
||||
messageApi.warning(diffResponse.message || "版本对比加载失败");
|
||||
return null;
|
||||
}
|
||||
setVersionDiff(diffResponse.data);
|
||||
return diffResponse.data;
|
||||
} catch (error) {
|
||||
messageApi.warning(getErrorMessage(error, "版本对比加载失败"));
|
||||
return null;
|
||||
} finally {
|
||||
setDiffLoading(false);
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
@@ -192,12 +230,13 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
currentVersionIdRef.current = latest?.id ?? null;
|
||||
setSelectedParagraphIndex(null);
|
||||
setAutoSaveStatus(latest ? `已加载 ${latest.version_label}` : "已加载 v1");
|
||||
void refreshVersionDiff(id, latest?.id);
|
||||
} catch (error) {
|
||||
setLoadError(getErrorMessage(error, "合同编辑器加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
}, [params, refreshVersionDiff]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
@@ -265,6 +304,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
currentVersionIdRef.current = response.data.id;
|
||||
setAutoSaveStatus(`已保存 ${response.data.version_label}`);
|
||||
await refreshVersions(contractId);
|
||||
await refreshVersionDiff(contractId, response.data.id);
|
||||
if (!options.quiet) {
|
||||
messageApi.success(`已生成新版本 ${response.data.version_label}`);
|
||||
}
|
||||
@@ -282,7 +322,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [contractId, messageApi, refreshVersions]);
|
||||
}, [contractId, messageApi, refreshVersionDiff, refreshVersions]);
|
||||
|
||||
const scheduleAutoSave = useCallback((nextContent: string, summary = "自动保存编辑内容") => {
|
||||
pendingChangeSummaryRef.current = summary;
|
||||
@@ -313,6 +353,43 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
setAutoSaveStatus(version ? `已切换 ${version.version_label}` : "已切换 v1");
|
||||
setSelectedParagraphIndex(null);
|
||||
setRewriteResult(null);
|
||||
if (contractId) {
|
||||
void refreshVersionDiff(contractId, version?.id ?? "base");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCurrentVersion = async () => {
|
||||
if (!contractId || !contract) {
|
||||
return;
|
||||
}
|
||||
let versionId = currentVersionIdRef.current ?? "base";
|
||||
let versionLabel = currentVersionIdRef.current
|
||||
? versions.find((item) => item.id === currentVersionIdRef.current)?.version_label ?? "current"
|
||||
: "v1";
|
||||
setExportingVersion(true);
|
||||
try {
|
||||
if (normalizeEditorText(editorContent) !== persistedContentRef.current) {
|
||||
const saved = await saveVersion(editorContent, {
|
||||
changeType: "editor_save",
|
||||
changeSummary: "导出前保存当前正文",
|
||||
quiet: true,
|
||||
});
|
||||
if (!saved && normalizeEditorText(editorContent) !== persistedContentRef.current) {
|
||||
messageApi.warning("当前正文尚未保存,已暂停导出。请先保存为新版本。");
|
||||
return;
|
||||
}
|
||||
if (saved) {
|
||||
versionId = saved.id;
|
||||
versionLabel = saved.version_label;
|
||||
}
|
||||
}
|
||||
await downloadGeneratedContractVersion(contractId, versionId, `${contract.template_name}_${versionLabel}.docx`);
|
||||
messageApi.success("已导出当前版本 Word 文件");
|
||||
} catch (error) {
|
||||
messageApi.error(getErrorMessage(error, "当前版本导出失败"));
|
||||
} finally {
|
||||
setExportingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecommend = async () => {
|
||||
@@ -471,6 +548,9 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
<Button icon={<MessageOutlined />} onClick={() => router.push(buildEditorChatHref(contract, editorContent))}>
|
||||
带当前正文去 Chat
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exportingVersion} onClick={() => void handleExportCurrentVersion()}>
|
||||
导出当前版本 Word
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -493,6 +573,59 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
</section>
|
||||
|
||||
<aside className="document-ai-sidebar">
|
||||
<section className="document-side-panel">
|
||||
<div className="document-side-panel-header">
|
||||
<DownloadOutlined />
|
||||
<strong>交付文件</strong>
|
||||
</div>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Button block type="primary" icon={<DownloadOutlined />} loading={exportingVersion} onClick={() => void handleExportCurrentVersion()}>
|
||||
导出当前版本 DOCX
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
icon={<DiffOutlined />}
|
||||
loading={diffLoading}
|
||||
onClick={() => {
|
||||
if (contractId) {
|
||||
void refreshVersionDiff(contractId, currentVersionIdRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
刷新版本对比
|
||||
</Button>
|
||||
</Space>
|
||||
<Text type="secondary">按当前版本正文重新生成 Word 文件,保留标题、条款段落、版本号和页脚信息。</Text>
|
||||
{versionDiff ? (
|
||||
<div className="document-version-diff-panel">
|
||||
<div className="document-version-diff-summary">
|
||||
<Tag>{versionDiff.from_version.version_label} → {versionDiff.to_version.version_label}</Tag>
|
||||
<Tag color="green">新增 {versionDiff.summary.added}</Tag>
|
||||
<Tag color="red">删除 {versionDiff.summary.removed}</Tag>
|
||||
<Tag color="gold">修改 {versionDiff.summary.modified}</Tag>
|
||||
</div>
|
||||
{versionDiff.items.length ? (
|
||||
<div className="document-version-diff-list">
|
||||
{versionDiff.items.slice(0, 5).map((item, index) => (
|
||||
<article className={`document-version-diff-item ${item.change_type}`} key={`${item.change_type}-${index}`}>
|
||||
<Tag color={diffChangeTypeColorMap[item.change_type] ?? "default"}>
|
||||
{diffChangeTypeLabelMap[item.change_type] ?? item.change_type}
|
||||
</Tag>
|
||||
{item.old_text ? <p className="document-version-diff-old">{item.old_text}</p> : null}
|
||||
{item.new_text ? <p className="document-version-diff-new">{item.new_text}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
{versionDiff.items.length > 5 ? <Text type="secondary">还有 {versionDiff.items.length - 5} 处变化,后续可继续展开成完整对比视图。</Text> : null}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">当前对比版本没有段落级变化。</Text>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">保存两个版本后,可在这里查看段落级/条款级变化。</Text>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="document-side-panel">
|
||||
<div className="document-side-panel-header">
|
||||
<BulbOutlined />
|
||||
|
||||
@@ -6672,6 +6672,53 @@ textarea.ant-input:focus,
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.document-version-diff-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.document-version-diff-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.document-version-diff-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-version-diff-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(61, 95, 129, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.document-version-diff-item p {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.document-version-diff-old {
|
||||
color: #7f1d1d;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.document-version-diff-new {
|
||||
color: #14532d;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.document-editor-recommend-card > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -255,6 +255,40 @@ export interface GeneratedContractVersionListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GeneratedContractVersionSnapshot {
|
||||
id?: string | null;
|
||||
version_number: number;
|
||||
version_label: string;
|
||||
source_version_id?: string | null;
|
||||
change_type?: string | null;
|
||||
change_summary?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedContractVersionDiffItem {
|
||||
change_type: "added" | "removed" | "modified" | string;
|
||||
old_paragraph_index?: number | null;
|
||||
new_paragraph_index?: number | null;
|
||||
old_text?: string | null;
|
||||
new_text?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedContractVersionDiffResponse {
|
||||
contract_id: string;
|
||||
from_version: GeneratedContractVersionSnapshot;
|
||||
to_version: GeneratedContractVersionSnapshot;
|
||||
summary: {
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
unchanged: number;
|
||||
change_count: number;
|
||||
from_paragraph_count: number;
|
||||
to_paragraph_count: number;
|
||||
};
|
||||
items: GeneratedContractVersionDiffItem[];
|
||||
}
|
||||
|
||||
export interface ContractPreview {
|
||||
preview_content: string;
|
||||
template_name: string;
|
||||
@@ -406,6 +440,24 @@ export async function listGeneratedContractVersions(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function diffGeneratedContractVersions(
|
||||
contract_id: string,
|
||||
params: { from_version_id?: string | null; to_version_id?: string | null } = {},
|
||||
): Promise<ApiResponse<GeneratedContractVersionDiffResponse>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.from_version_id) {
|
||||
searchParams.append("from_version_id", params.from_version_id);
|
||||
}
|
||||
if (params.to_version_id) {
|
||||
searchParams.append("to_version_id", params.to_version_id);
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
const response = await http.get<ApiResponse<GeneratedContractVersionDiffResponse>>(
|
||||
`/contracts/${contract_id}/versions/diff${queryString ? `?${queryString}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createGeneratedContractVersion(
|
||||
contract_id: string,
|
||||
payload: CreateGeneratedContractVersionRequest,
|
||||
@@ -548,3 +600,32 @@ export async function downloadContract(contract_id: string, file_name: string):
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadGeneratedContractVersion(
|
||||
contract_id: string,
|
||||
version_id: string,
|
||||
file_name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await http.get(`/contracts/${contract_id}/versions/${version_id}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const contentTypeHeader = response.headers["content-type"];
|
||||
const contentType = typeof contentTypeHeader === "string"
|
||||
? contentTypeHeader
|
||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = file_name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("导出合同版本失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4325,3 +4325,42 @@
|
||||
- 剩余边界:
|
||||
- 本轮未新增通用 `ai_quality_eval_runs/items` 表;这是未来跨 Prompt、跨业务对象统一质量平台的增强项。
|
||||
- 本轮未做全量法规向量化、真实微信或支付宝支付、电子签章、小程序等生产级增强。
|
||||
|
||||
## 2026-05-21 合同交付专业化增强:DOCX 导出与版本对比本地闭环
|
||||
|
||||
- 已按“现在不做完整 Word 级编辑器,先做合同交付够专业”的取舍推进本轮开发。
|
||||
- 后端新增能力:
|
||||
- `GET /api/v1/contracts/{contract_id}/versions/diff`
|
||||
- 默认对比上一版本与当前最新版本。
|
||||
- 支持 `from_version_id`、`to_version_id` 指定版本。
|
||||
- 返回新增、删除、修改、未变化、段落数和变化明细。
|
||||
- `GET /api/v1/contracts/{contract_id}/versions/{version_id}/download`
|
||||
- 支持导出 `base`、`latest` 或指定版本 ID。
|
||||
- 当前只开放 DOCX,避免扩大到完整 Word 级编辑器范围。
|
||||
- 导出的 DOCX 会包含合同标题、版本号、导出时间、经办人、条款段落和页脚。
|
||||
- 前端新增能力:
|
||||
- `frontend/src/app/documents/[id]/edit/page.tsx`
|
||||
- 工具栏新增“导出当前版本 Word”。
|
||||
- 右侧新增“交付文件”面板。
|
||||
- 面板支持“导出当前版本 DOCX”和“刷新版本对比”。
|
||||
- 对比结果展示版本范围、新增、删除、修改数量和前 5 条段落/条款级变化。
|
||||
- `frontend/src/services/contract.ts`
|
||||
- 新增版本对比类型与 `diffGeneratedContractVersions`。
|
||||
- 新增 `downloadGeneratedContractVersion`。
|
||||
- 实现边界:
|
||||
- 本轮没有新增数据库表,也没有新增 Alembic。
|
||||
- 本轮不是专业 Word 级编辑器,不做多人实时协同、不做 Word 高保真往返、不做逐字段修订模式。
|
||||
- 当前实现定位为“合同交付专业化增强”:让客户能拿到当前版本 Word 文件,并能在页面看清版本变化。
|
||||
- 本地验证:
|
||||
- `python -m compileall backend\app\api\contracts.py backend\app\services\contract_generation_service.py`
|
||||
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_contracts_api`
|
||||
- 结果:`Ran 25 tests in 1.869s`,`OK`。
|
||||
- `cd frontend; npx eslint "src/app/documents/[id]/edit/page.tsx" "src/services/contract.ts"`
|
||||
- `cd frontend; npm run build`
|
||||
- 构建结果:`Compiled successfully`,静态页面生成 `40/40`,路由清单包含 `/documents/[id]/edit`。
|
||||
- 当前闭环结论:
|
||||
- “DOCX 导出 + 版本对比”已形成本地开发闭环。
|
||||
- Phase15-D 从“编辑/保存/AI 侧栏”升级为“编辑/保存/AI 侧栏/版本对比/DOCX 交付文件”的轻量专业交付闭环。
|
||||
- 剩余边界:
|
||||
- 尚未提交推送远程 Git。
|
||||
- 尚未部署到 `192.168.3.90` 做真实库烟测。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
1. 本轮新增 `/quality-evals` 独立页面,不新增数据库迁移,继续复用 Prompt 评测运行记录作为质量评测真相源。
|
||||
2. 文档建议路径与代码仍保留一个等价实现差异:文档建议 `/clauses/apply`,当前落在合同编辑器版本链中;`/quality-evals` 已补成独立产品入口。
|
||||
3. 后续增强项:专业富文本库、字段级差异对比、通用 `ai_quality_eval_runs/items`、全量法规向量化、真实微信或支付宝支付、电子签章、小程序等不属于当前 Phase15 主线硬阻塞。
|
||||
3. 后续增强项:专业富文本库、真正字段级差异对比、多人协同编辑、通用 `ai_quality_eval_runs/items`、全量法规向量化、真实微信或支付宝支付、电子签章、小程序等不属于当前 Phase15 主线硬阻塞;合同交付专业化增强已先补 DOCX 当前版本导出和段落/条款级版本对比。
|
||||
|
||||
## 三、逐阶段对齐表
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| Phase15-C | 智能条款推荐 | `POST /api/v1/clauses/recommend` 支持缺失条款和行业惯例,`POST /api/v1/clauses/rewrite` 支持措辞优化 | 已闭环 |
|
||||
| LAW-CLAUSE-001 | 缺失条款推荐 | 推荐结果含插入位置、建议文本、理由、依据和行业惯例说明 | 已闭环 |
|
||||
| LAW-CLAUSE-002 | 措辞优化 | 支持专业化、简化、偏甲方、偏乙方、均衡等改写模式,返回法律边界和谈判建议 | 已闭环 |
|
||||
| Phase15-D | 富文本编辑器与 AI 侧边栏 | `/documents/[id]/edit` 支持正文编辑、自动保存、版本历史、条款推荐、措辞改写、相关法条和 Chat 入口 | 主线已闭环 |
|
||||
| Phase15-D | 富文本编辑器与 AI 侧边栏 | `/documents/[id]/edit` 支持正文编辑、自动保存、版本历史、条款推荐、措辞改写、相关法条、Chat 入口、当前版本 DOCX 导出和段落/条款级版本对比 | 主线已闭环 |
|
||||
| Phase15-E | Prompt 资产治理 | `/prompt-assets` 支持 Prompt 类型、版本、评测、发布、下线、回滚和运行证据 | 已闭环 |
|
||||
| Phase15-F | 黄金样本与质量评测 | 已有 Prompt 评测运行记录、逐条黄金样本报告、发布前门禁、`/prompt-assets` 治理入口和 `/quality-evals` 独立质量评测中心 | 已闭环 |
|
||||
| Phase15-G | 法规变更影响分析 | 已有法条变更入队、结构化和语义影响扫描、影响项、页面、后台 worker 和 Docker 服务 | 已闭环 |
|
||||
@@ -50,6 +50,8 @@
|
||||
- 文书类型:`backend/app/api/legal_document_types.py`
|
||||
- 文书草稿:`backend/app/api/legal_document_drafts.py`
|
||||
- 合同生成和版本:`backend/app/api/contracts.py`
|
||||
- `GET /api/v1/contracts/{contract_id}/versions/diff`
|
||||
- `GET /api/v1/contracts/{contract_id}/versions/{version_id}/download`
|
||||
- 审查建议动作:`backend/app/api/reviews.py`
|
||||
- 条款推荐和改写:`backend/app/api/clauses.py`
|
||||
- Prompt 治理:`backend/app/api/prompt_assets.py`
|
||||
@@ -150,14 +152,20 @@ Phase15-C、Phase15-F、Phase15-G 已完成提交、推送和 `192.168.3.90` 真
|
||||
|
||||
### 6.3 专业级富文本增强
|
||||
|
||||
当前 `/documents/[id]/edit` 已经支持编辑、自动保存、版本历史、推荐插入、改写替换和 AI 侧边栏。尚未做的增强包括:
|
||||
当前 `/documents/[id]/edit` 已经支持编辑、自动保存、版本历史、推荐插入、改写替换和 AI 侧边栏。本轮“合同交付专业化增强”又补齐:
|
||||
|
||||
- 当前版本 DOCX 重新导出:按版本正文生成 Word 文件,保留标题、条款段落、版本号、导出时间和页脚。
|
||||
- 段落/条款级版本对比:默认对比上一版本和当前版本,也支持指定版本对比。
|
||||
- 编辑页交付面板:提供“导出当前版本 DOCX”和“刷新版本对比”入口。
|
||||
|
||||
仍未做、且暂不建议近期作为主线强攻的增强包括:
|
||||
|
||||
- 专业富文本编辑器库。
|
||||
- 段落级或字段级差异对比。
|
||||
- Word 样式还原和导出增强。
|
||||
- 真正字段级差异对比。
|
||||
- Word 高保真样式还原。
|
||||
- 多人协同编辑。
|
||||
|
||||
这些是体验增强,不影响当前 Phase15-D 主线闭环。
|
||||
这些是体验增强,不影响当前 Phase15-D 主线闭环。当前路线是“不做完整 Word 级编辑器,先做合同交付够专业”。
|
||||
|
||||
### 6.4 全量法规向量化
|
||||
|
||||
|
||||
Reference in New Issue
Block a user