diff --git a/.gitignore b/.gitignore index b5597f63..fa8763cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ backend/.venv/ frontend/.next/ +frontend/.next-dev-log/ frontend/node_modules/ *.pyc __pycache__/ diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index a3736b38..c58f3576 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -10,6 +10,7 @@ from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai from app.db import get_db_session from app.schemas.chat import ChatStreamRequest from app.services.legal_article_service import LegalArticleService +from app.services.prompt_runtime_service import PromptRuntimeService from app.services.review_service import ReviewService from app.services.tenant_service import TenantService @@ -28,6 +29,10 @@ def get_chat_legal_article_service(db: Session = Depends(get_db_session)) -> Leg return LegalArticleService(db) +def get_chat_prompt_runtime_service(db: Session = Depends(get_db_session)) -> PromptRuntimeService: + return PromptRuntimeService(db) + + def get_tenant_service(db: Session = Depends(get_db_session)) -> TenantService: return TenantService(db) @@ -40,6 +45,7 @@ def stream_chat( current_user: dict = Depends(require_current_user), review_service: Optional[ReviewService] = Depends(get_chat_review_service), legal_article_service: Optional[LegalArticleService] = Depends(get_chat_legal_article_service), + prompt_runtime_service: Optional[PromptRuntimeService] = Depends(get_chat_prompt_runtime_service), ): tenant_service.assert_billing_allows(current_user, "chat") review_context = ( @@ -54,16 +60,78 @@ def stream_chat( legal_article_service, ) review_context = _enrich_review_context_with_runtime_legal_sources(review_context, runtime_legal_sources) + prompt_runtime = _resolve_chat_prompt_runtime(current_user, review_context, prompt_runtime_service) + review_context = _enrich_review_context_with_prompt_runtime(review_context, prompt_runtime, prompt_runtime_service) + template_key = prompt_runtime.get("template_key") if prompt_runtime and prompt_runtime.get("available") else None + if prompt_runtime_service is not None: + prompt_runtime_service.record_run_link( + current_user, + prompt_runtime, + run_type="legal_chat", + run_id=payload.conversation_id or "new_conversation", + resource_type="conversation", + resource_id=payload.conversation_id, + trace={ + "message_length": len(payload.message or ""), + "has_review_context": bool(review_context), + "review_id": (review_context or {}).get("reviewId") or (review_context or {}).get("review_id"), + "template_key": template_key, + }, + ) local_legal_sources = _dedupe_sources([*_build_legal_article_sources(review_context), *runtime_legal_sources]) return StreamingResponse( _merge_local_sources_into_stream( - shared_ai_platform_client.stream_answer(current_user, payload.conversation_id, payload.message, review_context), + shared_ai_platform_client.stream_answer( + current_user, + payload.conversation_id, + payload.message, + review_context, + template_key=template_key, + ), local_legal_sources, ), media_type="text/event-stream", ) +def _resolve_chat_prompt_runtime( + current_user: Dict[str, Any], + review_context: Optional[Dict[str, Any]], + prompt_runtime_service: Optional[PromptRuntimeService], +) -> Dict[str, Any]: + if prompt_runtime_service is None: + return {"available": False, "prompt_type": "legal_chat", "prompt_version": "legal_chat.shared_ai_stream.v1", "source": "local_default"} + has_review_context = bool(review_context and ((review_context.get("reviewId") or review_context.get("review_id")))) + return prompt_runtime_service.resolve_published_prompt( + current_user, + "legal_chat", + preferred_keys=[ + "legal_chat_review" if has_review_context else None, + "legal_chat_default", + ], + fallback_prompt_version="legal_chat.shared_ai_stream.v1", + ) + + +def _enrich_review_context_with_prompt_runtime( + review_context: Optional[Dict[str, Any]], + prompt_runtime: Optional[Dict[str, Any]], + prompt_runtime_service: Optional[PromptRuntimeService], +) -> Optional[Dict[str, Any]]: + if prompt_runtime_service is None or not prompt_runtime: + return review_context + enriched = dict(review_context or {}) + prompt_trace = prompt_runtime_service.to_trace(prompt_runtime) + enriched["promptRuntime"] = prompt_trace + enriched["prompt_runtime"] = prompt_trace + metadata = dict(enriched.get("metadata") or {}) + metadata["promptRuntime"] = prompt_trace + metadata["promptVersion"] = prompt_trace.get("prompt_version") + metadata["promptKey"] = prompt_trace.get("prompt_key") + enriched["metadata"] = metadata + return enriched + + def _enrich_review_context_with_legal_articles( current_user: Dict[str, Any], review_context: Optional[Dict[str, Any]], diff --git a/backend/app/api/clauses.py b/backend/app/api/clauses.py new file mode 100644 index 00000000..0b349827 --- /dev/null +++ b/backend/app/api/clauses.py @@ -0,0 +1,127 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.clause_recommendation_service import ClauseRecommendationService + +router = APIRouter(prefix="/clauses", tags=["clauses"]) +ROUTE_PERMISSION_SCOPE = "clause_recommendation" +ROUTE_PERMISSION_ACTIONS = { + "recommend": "clauses:recommend", + "rewrite": "clauses:rewrite", +} + + +class ClauseRecommendRequest(BaseModel): + contract_text: str + document_type_code: Optional[str] = None + contract_type: Optional[str] = None + contract_id: Optional[str] = None + max_items: int = Field(default=8, ge=1, le=20) + + +class ClauseRewriteRequest(BaseModel): + clause_text: str + rewrite_type: str = Field(default="professional") + document_type_code: Optional[str] = None + contract_type: Optional[str] = None + contract_id: Optional[str] = None + clause_id: Optional[str] = None + context_text: Optional[str] = None + + +def get_clause_recommendation_service(db: Session = Depends(get_db_session)) -> ClauseRecommendationService: + return ClauseRecommendationService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.post("/recommend") +def recommend_missing_clauses( + payload: ClauseRecommendRequest, + request: Request, + clause_recommendation_service: ClauseRecommendationService = Depends(get_clause_recommendation_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = clause_recommendation_service.recommend_missing_clauses( + current_user, + contract_text=payload.contract_text, + document_type_code=payload.document_type_code, + contract_type=payload.contract_type, + contract_id=payload.contract_id, + max_items=payload.max_items, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="recommend_missing_clauses", + resource_type="clause_recommendation", + resource_id=payload.contract_id, + detail_json={ + "document_type_code": result.get("document_type_code"), + "contract_type": payload.contract_type, + "recommended_count": result.get("total"), + "missing_clause_keys": (result.get("summary") or {}).get("missing_clause_keys") or [], + "source_count": (result.get("summary") or {}).get("source_count") or 0, + "text_length": len(payload.contract_text or ""), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/rewrite") +def rewrite_clause_wording( + payload: ClauseRewriteRequest, + request: Request, + clause_recommendation_service: ClauseRecommendationService = Depends(get_clause_recommendation_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = clause_recommendation_service.rewrite_clause_wording( + current_user, + clause_text=payload.clause_text, + rewrite_type=payload.rewrite_type, + document_type_code=payload.document_type_code, + contract_type=payload.contract_type, + contract_id=payload.contract_id, + clause_id=payload.clause_id, + context_text=payload.context_text, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="rewrite_clause_wording", + resource_type="clause_rewrite", + resource_id=payload.clause_id or payload.contract_id, + detail_json={ + "contract_id": payload.contract_id, + "clause_id": payload.clause_id, + "document_type_code": result.get("document_type_code"), + "rewrite_type": result.get("rewrite_type"), + "change_count": (result.get("summary") or {}).get("change_count") or 0, + "risk_note_count": (result.get("summary") or {}).get("risk_note_count") or 0, + "source_count": (result.get("summary") or {}).get("source_count") or 0, + "text_length": len(payload.clause_text or ""), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index e140ad6b..3f4faa85 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -2,6 +2,7 @@ from typing import Optional from urllib.parse import quote from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, Response +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.response import success_response @@ -19,11 +20,24 @@ ROUTE_PERMISSION_ACTIONS = { "generate": "contracts:generate", "preview": "contracts:preview", "detail": "contracts:detail", + "versions": "contracts:versions:list", + "version_detail": "contracts:versions:detail", + "version_create": "contracts:versions:create", "preview_generated": "contracts:generated:preview", "download": "contracts:download", } +class GeneratedContractVersionCreateRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=1_000_000) + 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" + + def get_contract_generation_service(db: Session = Depends(get_db_session)) -> ContractGenerationService: return ContractGenerationService(db) @@ -48,6 +62,7 @@ def list_generated_contracts( page: int = 1, page_size: int = 10, contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, keyword: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), @@ -58,6 +73,7 @@ def list_generated_contracts( page=page, page_size=page_size, contract_type=contract_type, + document_type_code=document_type_code, keyword=keyword, ) ) @@ -66,6 +82,7 @@ def list_generated_contracts( @router.get("/templates") def list_available_templates( contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), @@ -75,6 +92,8 @@ def list_available_templates( "current_user": current_user, "contract_type": contract_type, } + if document_type_code is not None: + kwargs["document_type_code"] = document_type_code if template_category is not None: kwargs["template_category"] = template_category if industry_category is not None: @@ -85,10 +104,14 @@ def list_available_templates( @router.post("/generate") def generate_contract( request: Request, - template_id: str = Form(...), - variable_values_json: str = Form(...), + template_id: Optional[str] = Form(default=None), + variable_values_json: Optional[str] = Form(default=None), export_format: str = Form(default="docx"), options_json: Optional[str] = Form(default=None), + document_type_code: Optional[str] = Form(default=None), + elements_json: Optional[str] = Form(default=None), + element_schema_id: Optional[str] = Form(default=None), + element_schema_version_label: Optional[str] = Form(default=None), contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), tenant_service: TenantService = Depends(get_tenant_service), @@ -97,7 +120,7 @@ def generate_contract( import json try: - variable_values = json.loads(variable_values_json) + variable_values = json.loads(variable_values_json) if variable_values_json else {} if not isinstance(variable_values, dict): raise HTTPException(status_code=400, detail="变量值必须是JSON对象") @@ -107,6 +130,12 @@ def generate_contract( if not isinstance(options, dict): raise HTTPException(status_code=400, detail="选项必须是JSON对象") + elements_snapshot = None + if elements_json: + elements_snapshot = json.loads(elements_json) + if not isinstance(elements_snapshot, dict): + raise HTTPException(status_code=400, detail="elements_json must be a JSON object") + try: export_format_enum = ExportFormat(export_format.lower()) except ValueError: @@ -114,13 +143,34 @@ def generate_contract( tenant_service.assert_billing_allows(current_user, "generation") tenant_service.assert_billing_allows(current_user, "review") - result = contract_generation_service.generate_contract( - current_user=current_user, - template_id=template_id, - variable_values=variable_values, - export_format=export_format_enum, - options=options, - ) + if elements_snapshot is not None: + if not document_type_code: + raise HTTPException(status_code=400, detail="document_type_code is required when elements_json is provided") + result = contract_generation_service.generate_contract_from_elements( + current_user=current_user, + document_type_code=document_type_code, + elements=elements_snapshot, + export_format=export_format_enum, + options=options, + template_id=template_id, + variable_values=variable_values, + ) + else: + if not template_id: + raise HTTPException(status_code=400, detail="template_id is required when elements_json is not provided") + if not variable_values_json: + raise HTTPException(status_code=400, detail="variable_values_json is required when elements_json is not provided") + result = contract_generation_service.generate_contract( + current_user=current_user, + template_id=template_id, + variable_values=variable_values, + export_format=export_format_enum, + options=options, + document_type_code=document_type_code, + elements_snapshot=elements_snapshot, + element_schema_id=element_schema_id, + element_schema_version_label=element_schema_version_label, + ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), @@ -129,13 +179,18 @@ def generate_contract( resource_type="contract", resource_id=result["id"], detail_json={ - "template_id": template_id, + "template_id": result.get("template_id") or template_id, "template_name": result["template_name"], "contract_type": result["contract_type"], + "document_type_code": result.get("document_type_code"), + "element_schema_id": result.get("metadata", {}).get("element_schema_id"), + "element_schema_version_label": result.get("metadata", {}).get("element_schema_version_label"), "template_category": result.get("template_category"), "industry_category": result.get("industry_category"), "export_format": export_format, - "variables_count": len(variable_values), + "variables_count": len(result.get("variable_values") or variable_values), + "missing_field_count": len(result.get("missing_fields") or []), + "related_legal_article_count": len(result.get("related_legal_articles") or []), "file_name": result["file_name"], }, ip_address=request.client.host if request.client else None, @@ -191,6 +246,64 @@ def get_generated_contract( return success_response(contract_generation_service.get_generated_contract(current_user, contract_id)) +@router.get("/{contract_id}/versions") +def list_generated_contract_versions( + contract_id: str, + contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), + current_user: dict = Depends(require_current_user), +): + return success_response(contract_generation_service.list_generated_contract_versions(current_user, contract_id)) + + +@router.get("/{contract_id}/versions/{version_id}") +def get_generated_contract_version( + contract_id: str, + version_id: str, + contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), + current_user: dict = Depends(require_current_user), +): + return success_response(contract_generation_service.get_generated_contract_version(current_user, contract_id, version_id)) + + +@router.post("/{contract_id}/versions") +def create_generated_contract_version( + contract_id: str, + payload: GeneratedContractVersionCreateRequest, + request: Request, + 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.create_generated_contract_version( + current_user=current_user, + contract_id=contract_id, + content=payload.content, + change_type=payload.change_type, + change_summary=payload.change_summary, + source_version_id=payload.source_version_id, + original_text=payload.original_text, + replacement_text=payload.replacement_text, + locate_status=payload.locate_status, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="contract_version_create", + resource_type="contract_version", + resource_id=result.get("id"), + detail_json={ + "contract_id": contract_id, + "version_label": result.get("version_label"), + "change_type": result.get("change_type"), + "change_summary": result.get("change_summary"), + "content_length": len(payload.content or ""), + }, + 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}/preview") def get_contract_preview( contract_id: str, diff --git a/backend/app/api/legal_articles.py b/backend/app/api/legal_articles.py index 634542c5..a3312405 100644 --- a/backend/app/api/legal_articles.py +++ b/backend/app/api/legal_articles.py @@ -47,6 +47,8 @@ class LegalArticlePayload(BaseModel): class BulkUpsertLegalArticlesRequest(BaseModel): articles: list[LegalArticlePayload] + enqueue_impact_scan: bool = True + max_impact_scan_events: int = 50 class UpdateLegalArticleIndexStatusRequest(BaseModel): @@ -110,7 +112,12 @@ def bulk_upsert_legal_articles( current_user: dict = Depends(require_current_user), ): articles_payload: list[dict[str, Any]] = [article.model_dump(mode="json") for article in payload.articles] - result = legal_article_service.bulk_upsert_articles(current_user, articles_payload) + result = legal_article_service.bulk_upsert_articles( + current_user, + articles_payload, + enqueue_impact_scan=payload.enqueue_impact_scan, + max_impact_scan_events=payload.max_impact_scan_events, + ) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), @@ -122,6 +129,8 @@ def bulk_upsert_legal_articles( "updated_count": result["updated_count"], "total": result["total"], "source_ids": sorted({article["source_id"] for article in articles_payload}), + "impact_scan_events_enqueued": result.get("impact_scan_events_enqueued", 0), + "impact_scan_events_skipped": result.get("impact_scan_events_skipped", 0), }, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), diff --git a/backend/app/api/legal_change_impacts.py b/backend/app/api/legal_change_impacts.py new file mode 100644 index 00000000..765f594e --- /dev/null +++ b/backend/app/api/legal_change_impacts.py @@ -0,0 +1,188 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.reviews import require_current_user +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.legal_change_impact_service import LegalChangeImpactService + +router = APIRouter(prefix="/legal-change-impacts", tags=["legal-change-impacts"]) +ROUTE_PERMISSION_SCOPE = "legal_change_impact" +ROUTE_PERMISSION_ACTIONS = { + "list_events": "legal_change_impacts:events:list", + "detail": "legal_change_impacts:events:detail", + "scan": "legal_change_impacts:scan", + "run_event": "legal_change_impacts:events:run", + "run_pending": "legal_change_impacts:pending:run", + "update_status": "legal_change_impacts:status:update", +} + + +class LegalChangeScanRequest(BaseModel): + article_id: str + change_type: str = "updated" + change_summary: Optional[str] = None + previous_article_text: Optional[str] = None + previous_text_checksum: Optional[str] = None + + +class LegalChangeImpactStatusRequest(BaseModel): + impact_status: str + handling_note: Optional[str] = None + + +class LegalChangeRunPendingRequest(BaseModel): + limit: int = 10 + + +def get_legal_change_impact_service(db: Session = Depends(get_db_session)) -> LegalChangeImpactService: + return LegalChangeImpactService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +@router.get("/events") +def list_legal_change_events( + page: int = 1, + page_size: int = 20, + scan_status: Optional[str] = None, + source_id: Optional[str] = None, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + service.list_events( + current_user, + page=page, + page_size=page_size, + scan_status=scan_status, + source_id=source_id, + ) + ) + + +@router.get("/events/{event_id}") +def get_legal_change_event_detail( + event_id: str, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + current_user: dict = Depends(require_current_user), +): + result = service.get_event_detail(current_user, event_id) + if result is None: + raise HTTPException(status_code=404, detail="legal change event not found") + return success_response(result) + + +@router.post("/scan") +def scan_legal_change_impact( + payload: LegalChangeScanRequest, + request: Request, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.scan_article_change( + current_user, + article_id=payload.article_id, + change_type=payload.change_type, + change_summary=payload.change_summary, + previous_article_text=payload.previous_article_text, + previous_text_checksum=payload.previous_text_checksum, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="scan_legal_change_impact", + resource_type="legal_change_event", + resource_id=result["event"]["id"], + detail_json={ + "article_id": payload.article_id, + "change_type": result["event"]["change_type"], + "impact_summary": result["event"]["impact_summary"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/events/{event_id}/run") +def run_legal_change_event_scan( + event_id: str, + request: Request, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.run_event_scan(current_user, event_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="run_legal_change_event_scan", + resource_type="legal_change_event", + resource_id=event_id, + detail_json={"impact_summary": result["event"]["impact_summary"]}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/run-pending") +def run_pending_legal_change_scans( + payload: LegalChangeRunPendingRequest, + request: Request, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.run_pending_scans(current_user, limit=payload.limit) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="run_pending_legal_change_scans", + resource_type="legal_change_event", + resource_id="pending-queue", + detail_json={ + "requested_limit": result["requested_limit"], + "completed_count": result["completed_count"], + "failed_count": result["failed_count"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/impacts/{impact_id}/status") +def update_legal_change_impact_status( + impact_id: str, + payload: LegalChangeImpactStatusRequest, + request: Request, + service: LegalChangeImpactService = Depends(get_legal_change_impact_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.update_impact_status( + current_user, + impact_id, + impact_status=payload.impact_status, + handling_note=payload.handling_note, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_legal_change_impact_status", + resource_type="legal_change_impact", + resource_id=impact_id, + detail_json={"impact_status": result["impact_status"], "change_event_id": result["change_event_id"]}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) diff --git a/backend/app/api/legal_document_drafts.py b/backend/app/api/legal_document_drafts.py new file mode 100644 index 00000000..f10cba78 --- /dev/null +++ b/backend/app/api/legal_document_drafts.py @@ -0,0 +1,209 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.contract_generation_service import ExportFormat +from app.services.legal_document_draft_service import LegalDocumentDraftService + +router = APIRouter(prefix="/legal-document-drafts", tags=["legal-document-drafts"]) +ROUTE_PERMISSION_SCOPE = "legal_document_drafts" +ROUTE_PERMISSION_ACTIONS = { + "list": "legal_document_drafts:list", + "create": "legal_document_drafts:create", + "detail": "legal_document_drafts:detail", + "update": "legal_document_drafts:update", + "delete": "legal_document_drafts:delete", + "generate": "legal_document_drafts:generate", +} + + +class LegalDocumentDraftCreateRequest(BaseModel): + document_type_code: str + element_values: dict[str, Any] = Field(default_factory=dict) + title: Optional[str] = None + template_id: Optional[str] = None + + +class LegalDocumentDraftUpdateRequest(BaseModel): + element_values: Optional[dict[str, Any]] = None + title: Optional[str] = None + template_id: Optional[str] = None + + +class LegalDocumentDraftGenerateRequest(BaseModel): + export_format: str = "docx" + template_id: Optional[str] = None + options: dict[str, Any] = Field(default_factory=dict) + + +def get_legal_document_draft_service(db: Session = Depends(get_db_session)) -> LegalDocumentDraftService: + return LegalDocumentDraftService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("") +def list_legal_document_drafts( + document_type_code: Optional[str] = None, + status: Optional[str] = "draft", + limit: int = 20, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_draft_service.list_drafts( + current_user, + document_type_code=document_type_code, + status_value=status, + limit=limit, + ) + return success_response(result) + + +@router.post("") +def create_legal_document_draft( + payload: LegalDocumentDraftCreateRequest, + request: Request, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_draft_service.create_draft( + current_user, + document_type_code=payload.document_type_code, + element_values=payload.element_values, + title=payload.title, + template_id=payload.template_id, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="save_legal_document_draft", + resource_type="legal_document_draft", + resource_id=result["id"], + detail_json={ + "document_type_code": result["document_type_code"], + "template_id": result.get("template_id"), + "valid": (result.get("validation_result") or {}).get("valid"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.get("/{draft_id}") +def get_legal_document_draft( + draft_id: str, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + current_user: dict = Depends(require_current_user), +): + return success_response(legal_document_draft_service.get_draft(current_user, draft_id)) + + +@router.put("/{draft_id}") +def update_legal_document_draft( + draft_id: str, + payload: LegalDocumentDraftUpdateRequest, + request: Request, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_draft_service.update_draft( + current_user, + draft_id, + element_values=payload.element_values, + title=payload.title, + template_id=payload.template_id, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_legal_document_draft", + resource_type="legal_document_draft", + resource_id=result["id"], + detail_json={ + "document_type_code": result["document_type_code"], + "template_id": result.get("template_id"), + "valid": (result.get("validation_result") or {}).get("valid"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.delete("/{draft_id}") +def delete_legal_document_draft( + draft_id: str, + request: Request, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_draft_service.delete_draft(current_user, draft_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="delete_legal_document_draft", + resource_type="legal_document_draft", + resource_id=result["id"], + detail_json={"document_type_code": result["document_type_code"]}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{draft_id}/generate") +def generate_contract_from_legal_document_draft( + draft_id: str, + payload: LegalDocumentDraftGenerateRequest, + request: Request, + legal_document_draft_service: LegalDocumentDraftService = Depends(get_legal_document_draft_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + try: + export_format = ExportFormat(payload.export_format.lower()) + except ValueError: + raise HTTPException(status_code=400, detail=f"unsupported export format: {payload.export_format}") + result = legal_document_draft_service.generate_from_draft( + current_user, + draft_id, + export_format=export_format, + template_id=payload.template_id, + options=payload.options, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="generate_contract_from_legal_document_draft", + resource_type="legal_document_draft", + resource_id=result["draft"]["id"], + detail_json={ + "document_type_code": result["draft"]["document_type_code"], + "template_id": result["draft"].get("template_id"), + "generated_contract_id": result["draft"].get("generated_contract_id"), + "element_schema_id": result["draft"].get("element_schema_id"), + "element_schema_version_label": result["draft"].get("element_schema_version_label"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) diff --git a/backend/app/api/legal_document_types.py b/backend/app/api/legal_document_types.py new file mode 100644 index 00000000..c982080a --- /dev/null +++ b/backend/app/api/legal_document_types.py @@ -0,0 +1,397 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService +from app.services.legal_document_type_service import LegalDocumentTypeService + +router = APIRouter(prefix="/legal-document-types", tags=["legal-document-types"]) +ROUTE_PERMISSION_SCOPE = "legal_document_type_management" +ROUTE_PERMISSION_ACTIONS = { + "list": "legal_document_types:list", + "detail": "legal_document_types:detail", + "create": "legal_document_types:create", + "update": "legal_document_types:update", + "seed_defaults": "legal_document_types:seed_defaults", + "element_schema_detail": "legal_document_element_schemas:detail", + "element_schema_versions": "legal_document_element_schemas:versions", + "element_schema_create": "legal_document_element_schemas:create", + "element_schema_update": "legal_document_element_schemas:update", + "element_schema_publish": "legal_document_element_schemas:publish", + "element_schema_seed_defaults": "legal_document_element_schemas:seed_defaults", + "element_schema_validate": "legal_document_element_schemas:validate", +} + + +class LegalDocumentTypeCreateRequest(BaseModel): + code: str + name: str + category: str + industry: str + scenario: str + description: Optional[str] = None + risk_level: str = "medium" + is_active: bool = True + sort_order: int = 100 + + +class LegalDocumentTypeUpdateRequest(BaseModel): + name: Optional[str] = None + category: Optional[str] = None + industry: Optional[str] = None + scenario: Optional[str] = None + description: Optional[str] = None + risk_level: Optional[str] = None + is_active: Optional[bool] = None + sort_order: Optional[int] = None + + +class LegalDocumentElementSchemaCreateRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + element_schema: dict[str, Any] = Field(alias="schema_json") + version_label: Optional[str] = None + status: str = "draft" + rollback_from_schema_id: Optional[str] = None + + +class LegalDocumentElementSchemaUpdateRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + element_schema: Optional[dict[str, Any]] = Field(default=None, alias="schema_json") + version_label: Optional[str] = None + status: Optional[str] = None + + +class LegalDocumentElementSchemaValidateRequest(BaseModel): + values: dict[str, Any] + + +def get_legal_document_type_service(db: Session = Depends(get_db_session)) -> LegalDocumentTypeService: + return LegalDocumentTypeService(db) + + +def get_legal_document_element_schema_service(db: Session = Depends(get_db_session)) -> LegalDocumentElementSchemaService: + return LegalDocumentElementSchemaService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("") +def list_legal_document_types( + category: Optional[str] = None, + industry: Optional[str] = None, + scenario: Optional[str] = None, + active_only: bool = True, + query: Optional[str] = None, + legal_document_type_service: LegalDocumentTypeService = Depends(get_legal_document_type_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_type_service.list_document_types( + current_user, + category=category, + industry=industry, + scenario=scenario, + active_only=active_only, + query=query, + ) + return success_response(result) + + +@router.post("") +def create_legal_document_type( + payload: LegalDocumentTypeCreateRequest, + request: Request, + legal_document_type_service: LegalDocumentTypeService = Depends(get_legal_document_type_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_type_service.create_document_type(current_user, **payload.model_dump(mode="json")) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_legal_document_type", + resource_type="legal_document_type", + resource_id=result["code"], + detail_json={ + "code": result["code"], + "name": result["name"], + "category": result["category"], + "industry": result["industry"], + "scenario": result["scenario"], + "risk_level": result["risk_level"], + "is_active": result["is_active"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/seed-defaults") +def seed_default_legal_document_types( + request: Request, + legal_document_type_service: LegalDocumentTypeService = Depends(get_legal_document_type_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_type_service.seed_default_document_types(current_user) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="seed_default_legal_document_types", + resource_type="legal_document_type", + resource_id="default-legal-document-types", + detail_json={ + "created_count": result["created_count"], + "existing_count": result["existing_count"], + "total": result["total"], + "document_type_codes": result.get("document_type_codes", []), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/element-schemas/seed-defaults") +def seed_default_legal_document_element_schemas( + request: Request, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.seed_default_element_schemas(current_user) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="seed_default_legal_document_element_schemas", + resource_type="legal_document_element_schema", + resource_id="default-element-schemas", + detail_json={ + "created_count": result["created_count"], + "existing_count": result["existing_count"], + "skipped_count": result["skipped_count"], + "total": result["total"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.get("/{code}/element-schema") +def get_legal_document_element_schema( + code: str, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.get_current_element_schema(current_user, code) + return success_response(result) + + +@router.get("/{code}/element-schemas") +def list_legal_document_element_schema_versions( + code: str, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.list_element_schema_versions(current_user, code) + return success_response(result) + + +@router.post("/{code}/element-schema") +def create_legal_document_element_schema( + code: str, + payload: LegalDocumentElementSchemaCreateRequest, + request: Request, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.create_element_schema( + current_user, + code, + schema_json=payload.element_schema, + version_label=payload.version_label, + status_value=payload.status, + rollback_from_schema_id=payload.rollback_from_schema_id, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_legal_document_element_schema", + resource_type="legal_document_element_schema", + resource_id=result["id"], + detail_json={ + "document_type_code": result["document_type_code"], + "version_label": result["version_label"], + "status": result["status"], + "section_count": len(result["schema_json"].get("sections", [])), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{code}/element-schema/seed-default") +def seed_default_legal_document_element_schema( + code: str, + request: Request, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.seed_default_element_schemas(current_user, code) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="seed_default_legal_document_element_schema", + resource_type="legal_document_element_schema", + resource_id=code, + detail_json={ + "document_type_code": code, + "created_count": result["created_count"], + "existing_count": result["existing_count"], + "skipped_count": result["skipped_count"], + "total": result["total"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{code}/element-schema/validate") +def validate_legal_document_element_values( + code: str, + payload: LegalDocumentElementSchemaValidateRequest, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.validate_element_values(current_user, code, payload.values) + return success_response(result) + + +@router.put("/{code}/element-schema/{schema_id}") +def update_legal_document_element_schema( + code: str, + schema_id: str, + payload: LegalDocumentElementSchemaUpdateRequest, + request: Request, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + update_payload = payload.model_dump(mode="json", exclude_unset=True, by_alias=True) + result = legal_document_element_schema_service.update_element_schema( + current_user, + code, + schema_id, + schema_json=update_payload.get("schema_json"), + version_label=update_payload.get("version_label"), + status_value=update_payload.get("status"), + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_legal_document_element_schema", + resource_type="legal_document_element_schema", + resource_id=result["id"], + detail_json={ + "document_type_code": result["document_type_code"], + "version_label": result["version_label"], + "status": result["status"], + "updated_fields": sorted(update_payload.keys()), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{code}/element-schema/{schema_id}/publish") +def publish_legal_document_element_schema( + code: str, + schema_id: str, + request: Request, + legal_document_element_schema_service: LegalDocumentElementSchemaService = Depends(get_legal_document_element_schema_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_element_schema_service.publish_element_schema(current_user, code, schema_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="publish_legal_document_element_schema", + resource_type="legal_document_element_schema", + resource_id=result["id"], + detail_json={ + "document_type_code": result["document_type_code"], + "version_label": result["version_label"], + "published_at": result["published_at"], + "published_by": result["published_by"], + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.get("/{code}") +def get_legal_document_type( + code: str, + legal_document_type_service: LegalDocumentTypeService = Depends(get_legal_document_type_service), + current_user: dict = Depends(require_current_user), +): + result = legal_document_type_service.get_document_type(current_user, code) + return success_response(result) + + +@router.put("/{code}") +def update_legal_document_type( + code: str, + payload: LegalDocumentTypeUpdateRequest, + request: Request, + legal_document_type_service: LegalDocumentTypeService = Depends(get_legal_document_type_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + update_payload = payload.model_dump(mode="json", exclude_unset=True) + result = legal_document_type_service.update_document_type(current_user, code, **update_payload) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_legal_document_type", + resource_type="legal_document_type", + resource_id=result["code"], + detail_json={ + "code": result["code"], + "name": result["name"], + "category": result["category"], + "industry": result["industry"], + "scenario": result["scenario"], + "risk_level": result["risk_level"], + "is_active": result["is_active"], + "updated_fields": sorted(update_payload.keys()), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) diff --git a/backend/app/api/operation_kpis.py b/backend/app/api/operation_kpis.py new file mode 100644 index 00000000..c887ec2f --- /dev/null +++ b/backend/app/api/operation_kpis.py @@ -0,0 +1,34 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.auth_service import AuthService +from app.services.operation_kpi_service import OperationKpiService + +router = APIRouter(prefix="/operation-kpis", tags=["operation-kpis"]) +ROUTE_PERMISSION_SCOPE = "operation_kpis" +ROUTE_PERMISSION_ACTIONS = { + "dashboard": "operation_kpis:dashboard", +} + + +def get_operation_kpi_service(db: Session = Depends(get_db_session)) -> OperationKpiService: + return OperationKpiService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("/dashboard") +def get_operation_kpi_dashboard( + operation_kpi_service: OperationKpiService = Depends(get_operation_kpi_service), + current_user: dict = Depends(require_current_user), +): + return success_response(operation_kpi_service.get_dashboard(current_user)) diff --git a/backend/app/api/prompt_assets.py b/backend/app/api/prompt_assets.py new file mode 100644 index 00000000..5e1e1d8d --- /dev/null +++ b/backend/app/api/prompt_assets.py @@ -0,0 +1,280 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Form, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.prompt_asset_service import PromptAssetService + +router = APIRouter(prefix="/prompt-assets", tags=["prompt-assets"]) +ROUTE_PERMISSION_SCOPE = "prompt_asset_management" +ROUTE_PERMISSION_ACTIONS = { + "list": "prompt_assets:list", + "detail": "prompt_assets:detail", + "create": "prompt_assets:create", + "create_version": "prompt_assets:versions:create", + "publish": "prompt_assets:publish", + "unpublish": "prompt_assets:unpublish", + "rollback": "prompt_assets:rollback", + "record_run_link": "prompt_assets:run_links:create", +} + + +def get_prompt_asset_service(db: Session = Depends(get_db_session)) -> PromptAssetService: + return PromptAssetService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("") +def list_prompt_assets( + page: int = 1, + page_size: int = 20, + prompt_type: Optional[str] = None, + status_filter: Optional[str] = None, + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + current_user: dict = Depends(require_current_user), +): + return success_response(prompt_asset_service.list_prompt_assets(current_user, page, page_size, prompt_type, status_filter)) + + +@router.get("/{asset_id}") +def get_prompt_asset_detail( + asset_id: str, + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + current_user: dict = Depends(require_current_user), +): + return success_response(prompt_asset_service.get_prompt_asset_detail(current_user, asset_id)) + + +@router.post("") +def create_prompt_asset( + prompt_key: str = Form(...), + prompt_type: str = Form(...), + name: str = Form(...), + prompt_content: str = Form(...), + description: Optional[str] = Form(default=None), + target_scene: Optional[str] = Form(default=None), + system_prompt: Optional[str] = Form(default=None), + variables_json: Optional[str] = Form(default=None), + output_schema_json: Optional[str] = Form(default=None), + model_config_json: Optional[str] = Form(default=None), + evaluation_report_json: Optional[str] = Form(default=None), + evaluation_status: Optional[str] = Form(default=None), + evaluation_score: Optional[float] = Form(default=None), + change_summary: Optional[str] = Form(default=None), + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.create_prompt_asset( + current_user, + prompt_key, + prompt_type, + name, + prompt_content, + description=description, + target_scene=target_scene, + system_prompt=system_prompt, + variables_json=variables_json, + output_schema_json=output_schema_json, + model_config_json=model_config_json, + evaluation_report_json=evaluation_report_json, + evaluation_status=evaluation_status, + evaluation_score=evaluation_score, + change_summary=change_summary, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_prompt_asset", + resource_type="prompt_asset", + resource_id=result["id"], + detail_json={ + "prompt_key": result["prompt_key"], + "prompt_type": result["prompt_type"], + "current_version_label": result["current_version_label"], + "evaluation_status": result.get("latest_evaluation_status"), + }, + ) + return success_response(result) + + +@router.post("/{asset_id}/versions") +def create_prompt_asset_version( + asset_id: str, + prompt_content: str = Form(...), + system_prompt: Optional[str] = Form(default=None), + variables_json: Optional[str] = Form(default=None), + output_schema_json: Optional[str] = Form(default=None), + model_config_json: Optional[str] = Form(default=None), + evaluation_report_json: Optional[str] = Form(default=None), + evaluation_status: Optional[str] = Form(default=None), + evaluation_score: Optional[float] = Form(default=None), + change_summary: Optional[str] = Form(default=None), + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.create_prompt_asset_version( + current_user, + asset_id, + prompt_content, + system_prompt=system_prompt, + variables_json=variables_json, + output_schema_json=output_schema_json, + model_config_json=model_config_json, + evaluation_report_json=evaluation_report_json, + evaluation_status=evaluation_status, + evaluation_score=evaluation_score, + change_summary=change_summary, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_prompt_asset_version", + resource_type="prompt_asset", + resource_id=result["id"], + detail_json={ + "prompt_key": result["prompt_key"], + "current_version_label": result["current_version_label"], + "evaluation_status": result.get("latest_evaluation_status"), + }, + ) + return success_response(result) + + +@router.post("/{asset_id}/publish") +def publish_prompt_asset( + asset_id: str, + evaluation_report_json: Optional[str] = Form(default=None), + evaluation_status: Optional[str] = Form(default=None), + evaluation_score: Optional[float] = Form(default=None), + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.publish_prompt_asset( + current_user, + asset_id, + evaluation_report_json=evaluation_report_json, + evaluation_status=evaluation_status, + evaluation_score=evaluation_score, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="publish_prompt_asset", + resource_type="prompt_asset", + resource_id=result["id"], + detail_json={ + "prompt_key": result["prompt_key"], + "published_version_id": result["published_version_id"], + "published_at": result["published_at"], + "evaluation_status": result.get("latest_evaluation_status"), + "evaluation_score": result.get("latest_evaluation_score"), + }, + ) + return success_response(result) + + +@router.post("/{asset_id}/unpublish") +def unpublish_prompt_asset( + asset_id: str, + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.unpublish_prompt_asset(current_user, asset_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="unpublish_prompt_asset", + resource_type="prompt_asset", + resource_id=result["id"], + detail_json={ + "prompt_key": result["prompt_key"], + "status": result["status"], + }, + ) + return success_response(result) + + +@router.post("/{asset_id}/rollback") +def rollback_prompt_asset( + asset_id: str, + target_version_label: str = Form(...), + change_summary: Optional[str] = Form(default=None), + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.rollback_prompt_asset(current_user, asset_id, target_version_label, change_summary) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="rollback_prompt_asset", + resource_type="prompt_asset", + resource_id=result["id"], + detail_json={ + "prompt_key": result["prompt_key"], + "target_version_label": target_version_label, + "current_version_label": result["current_version_label"], + "status": result["status"], + }, + ) + return success_response(result) + + +@router.post("/{asset_id}/run-links") +def record_prompt_run_link( + asset_id: str, + run_type: str = Form(default="ai_run"), + run_id: Optional[str] = Form(default=None), + resource_type: Optional[str] = Form(default=None), + resource_id: Optional[str] = Form(default=None), + workflow_run_id: Optional[str] = Form(default=None), + task_run_id: Optional[str] = Form(default=None), + trace_json: Optional[str] = Form(default=None), + prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = prompt_asset_service.record_prompt_run_link( + current_user, + asset_id, + run_type, + run_id=run_id, + resource_type=resource_type, + resource_id=resource_id, + workflow_run_id=workflow_run_id, + task_run_id=task_run_id, + trace_json=trace_json, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="record_prompt_asset_run_link", + resource_type="prompt_asset_run_link", + resource_id=result["id"], + detail_json={ + "prompt_asset_id": result["prompt_asset_id"], + "prompt_asset_version_id": result["prompt_asset_version_id"], + "run_id": result["run_id"], + "resource_type": result["resource_type"], + "resource_id": result["resource_id"], + }, + ) + return success_response(result) diff --git a/backend/app/api/reviews.py b/backend/app/api/reviews.py index 5781a308..b5a38e24 100644 --- a/backend/app/api/reviews.py +++ b/backend/app/api/reviews.py @@ -2,12 +2,14 @@ from typing import Optional from urllib.parse import quote from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Request, Response, UploadFile +from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.response import success_response from app.db import get_db_session from app.services.audit_log_service import AuditLogService from app.services.auth_service import AuthService +from app.services.review_risk_action_service import ReviewRiskActionService from app.services.review_service import ReviewService from app.services.tenant_service import TenantService @@ -21,9 +23,27 @@ ROUTE_PERMISSION_ACTIONS = { "report": "reviews:report:get", "download_report": "reviews:report:download", "retry": "reviews:retry", + "risk_actions": "reviews:risk_actions:list", + "risk_action_update": "reviews:risk_actions:update", + "risk_action_apply": "reviews:risk_actions:apply", } +class ReviewRiskActionUpdateRequest(BaseModel): + action_status: Optional[str] = None + user_modified_text: Optional[str] = None + skip_reason: Optional[str] = None + applied_contract_version_id: Optional[str] = None + + +class ReviewRiskActionApplyRequest(BaseModel): + contract_id: Optional[str] = None + action_mode: str = "adopt" + replacement_text: Optional[str] = None + match_text: Optional[str] = None + change_summary: Optional[str] = None + + def get_review_service(db: Session = Depends(get_db_session)) -> ReviewService: return ReviewService(db) @@ -32,6 +52,10 @@ def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogServ return AuditLogService(db) +def get_review_risk_action_service(db: Session = Depends(get_db_session)) -> ReviewRiskActionService: + return ReviewRiskActionService(db) + + def get_tenant_service(db: Session = Depends(get_db_session)) -> TenantService: return TenantService(db) @@ -165,6 +189,97 @@ def retry_review( return success_response(result, message="审查任务已重新发起") +@router.get("/{review_id}/risk-actions") +def list_review_risk_actions( + review_id: str, + review_risk_action_service: ReviewRiskActionService = Depends(get_review_risk_action_service), + current_user: dict = Depends(require_current_user), +): + result = review_risk_action_service.list_actions(current_user, review_id) + return success_response(result) + + +@router.put("/{review_id}/risk-actions/{action_id}") +def update_review_risk_action( + review_id: str, + action_id: str, + payload: ReviewRiskActionUpdateRequest, + request: Request, + review_risk_action_service: ReviewRiskActionService = Depends(get_review_risk_action_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = review_risk_action_service.update_action( + current_user, + review_id, + action_id, + action_status=payload.action_status, + user_modified_text=payload.user_modified_text, + skip_reason=payload.skip_reason, + applied_contract_version_id=payload.applied_contract_version_id, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_review_risk_action", + resource_type="review_risk_action", + resource_id=result["id"], + detail_json={ + "review_id": review_id, + "risk_item_id": result["risk_item_id"], + "action_status": result["action_status"], + "has_user_modified_text": bool(result.get("user_modified_text")), + "has_skip_reason": bool(result.get("skip_reason")), + "applied_contract_version_id": result.get("applied_contract_version_id"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.post("/{review_id}/risk-actions/{action_id}/apply") +def apply_review_risk_action( + review_id: str, + action_id: str, + payload: ReviewRiskActionApplyRequest, + request: Request, + review_risk_action_service: ReviewRiskActionService = Depends(get_review_risk_action_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = review_risk_action_service.apply_action( + current_user, + review_id, + action_id, + contract_id=payload.contract_id, + action_mode=payload.action_mode, + replacement_text=payload.replacement_text, + match_text=payload.match_text, + change_summary=payload.change_summary, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="apply_review_risk_action", + resource_type="review_risk_action", + resource_id=result["action"]["id"], + detail_json={ + "review_id": review_id, + "risk_item_id": result["action"]["risk_item_id"], + "action_status": result["action"]["action_status"], + "contract_id": (result.get("version") or {}).get("contract_id") or payload.contract_id, + "contract_version_id": (result.get("version") or {}).get("id"), + "version_label": (result.get("version") or {}).get("version_label"), + "locate_status": result.get("locate_status"), + "manual_confirmation_required": result.get("manual_confirmation_required", False), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + @router.get("/{review_id}/result") def get_review_result( review_id: str, diff --git a/backend/app/api/sales_followups.py b/backend/app/api/sales_followups.py new file mode 100644 index 00000000..80ea1946 --- /dev/null +++ b/backend/app/api/sales_followups.py @@ -0,0 +1,153 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Form, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.sales_followup_service import SalesFollowupService + +router = APIRouter(prefix="/sales-followups", tags=["sales-followups"]) +ROUTE_PERMISSION_SCOPE = "sales_followups" +ROUTE_PERMISSION_ACTIONS = { + "list": "sales_followups:list", + "sync": "sales_followups:sync", + "create": "sales_followups:create", + "update": "sales_followups:update", +} + + +def get_sales_followup_service(db: Session = Depends(get_db_session)) -> SalesFollowupService: + return SalesFollowupService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("") +def list_sales_followup_events( + status_value: Optional[str] = None, + limit: int = 100, + service: SalesFollowupService = Depends(get_sales_followup_service), + current_user: dict = Depends(require_current_user), +): + return success_response(service.list_events(current_user, status_value=status_value, limit=limit)) + + +@router.post("/sync") +def sync_sales_followup_events( + limit: int = Form(default=100), + service: SalesFollowupService = Depends(get_sales_followup_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.sync_commercial_signals(current_user, limit=limit) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="sync_sales_followups", + resource_type="sales_followup_event", + resource_id="commercial_signals", + detail_json={ + "created_count": result["created_count"], + "updated_count": result["updated_count"], + "resolved_count": result["resolved_count"], + "active_signal_count": result["active_signal_count"], + }, + ) + return success_response(result) + + +@router.post("") +def create_sales_followup_event( + event_type: str = Form(default="manual"), + title: str = Form(...), + description: Optional[str] = Form(default=None), + priority: str = Form(default="medium"), + status_value: str = Form(default="open"), + resource_type: Optional[str] = Form(default=None), + resource_id: Optional[str] = Form(default=None), + resource_label: Optional[str] = Form(default=None), + contact_name: Optional[str] = Form(default=None), + contact_phone: Optional[str] = Form(default=None), + contact_email: Optional[str] = Form(default=None), + next_action: Optional[str] = Form(default=None), + note: Optional[str] = Form(default=None), + assigned_to: Optional[str] = Form(default=None), + due_at: Optional[datetime] = Form(default=None), + service: SalesFollowupService = Depends(get_sales_followup_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.create_manual_event( + current_user=current_user, + event_type=event_type, + title=title, + description=description, + priority=priority, + status_value=status_value, + resource_type=resource_type, + resource_id=resource_id, + resource_label=resource_label, + contact_name=contact_name, + contact_phone=contact_phone, + contact_email=contact_email, + next_action=next_action, + note=note, + assigned_to=assigned_to, + due_at=due_at, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_sales_followup", + resource_type="sales_followup_event", + resource_id=result["id"], + detail_json={"event_type": result["event_type"], "priority": result["priority"], "status": result["status"]}, + ) + return success_response(result) + + +@router.put("/{event_id}") +def update_sales_followup_event( + event_id: str, + status_value: Optional[str] = Form(default=None), + priority: Optional[str] = Form(default=None), + assigned_to: Optional[str] = Form(default=None), + due_at: Optional[datetime] = Form(default=None), + next_action: Optional[str] = Form(default=None), + note: Optional[str] = Form(default=None), + service: SalesFollowupService = Depends(get_sales_followup_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = service.update_event( + current_user=current_user, + event_id=event_id, + status_value=status_value, + priority=priority, + assigned_to=assigned_to, + due_at=due_at, + next_action=next_action, + note=note, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_sales_followup", + resource_type="sales_followup_event", + resource_id=result["id"], + detail_json={"priority": result["priority"], "status": result["status"], "assigned_to": result["assigned_to"]}, + ) + return success_response(result) diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index 6d61b4c2..c04dde65 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -43,6 +43,7 @@ def list_templates( page: int = 1, page_size: int = 20, contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, template_service: TemplateService = Depends(get_template_service), @@ -54,6 +55,8 @@ def list_templates( "page_size": page_size, "contract_type": contract_type, } + if document_type_code is not None: + kwargs["document_type_code"] = document_type_code if template_category is not None: kwargs["template_category"] = template_category if industry_category is not None: @@ -75,6 +78,7 @@ def create_template( name: str = Form(...), template_code: Optional[str] = Form(default=None), contract_type: Optional[str] = Form(default=None), + document_type_code: Optional[str] = Form(default=None), template_category: Optional[str] = Form(default=None), industry_category: Optional[str] = Form(default=None), description: Optional[str] = Form(default=None), @@ -97,6 +101,8 @@ def create_template( "change_summary": change_summary, "template_code": template_code, } + if document_type_code is not None: + kwargs["document_type_code"] = document_type_code if template_category is not None: kwargs["template_category"] = template_category if industry_category is not None: @@ -112,6 +118,7 @@ def create_template( "name": result["name"], "template_code": result.get("template_code"), "contract_type": result["contract_type"], + "document_type_code": result.get("document_type_code"), "template_category": result.get("template_category"), "industry_category": result.get("industry_category"), "enabled": result["enabled"], @@ -152,6 +159,7 @@ def update_template( name: str = Form(...), template_code: Optional[str] = Form(default=None), contract_type: Optional[str] = Form(default=None), + document_type_code: Optional[str] = Form(default=None), template_category: Optional[str] = Form(default=None), industry_category: Optional[str] = Form(default=None), description: Optional[str] = Form(default=None), @@ -175,6 +183,8 @@ def update_template( "change_summary": change_summary, "template_code": template_code, } + if document_type_code is not None: + kwargs["document_type_code"] = document_type_code if template_category is not None: kwargs["template_category"] = template_category if industry_category is not None: @@ -190,6 +200,7 @@ def update_template( "name": result["name"], "template_code": result.get("template_code"), "contract_type": result["contract_type"], + "document_type_code": result.get("document_type_code"), "template_category": result.get("template_category"), "industry_category": result.get("industry_category"), "enabled": result["enabled"], diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index 407aa3a4..8f92ec05 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -18,6 +18,7 @@ class SharedAIPlatformClient(Protocol): conversation_id: Optional[str], message: str, review_context: Optional[Any] = None, + template_key: Optional[str] = None, ) -> Iterable[str]: ... def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: ... @@ -87,6 +88,7 @@ class RemoteSharedAIPlatformClient: conversation_id: Optional[str], message: str, review_context: Optional[Any] = None, + template_key: Optional[str] = None, ) -> Iterable[str]: review_context_payload = self._normalize_review_context(review_context) @@ -97,6 +99,7 @@ class RemoteSharedAIPlatformClient: message=message, chat_context=review_context_payload or {}, conversation_id=conversation_id, + template_key=template_key, ): if event.event_type in {"delta", "text"}: yield self._format_event({"type": "text", "content": event.payload.get("content", "")}) diff --git a/backend/app/main.py b/backend/app/main.py index c6f31eee..3227c93f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,15 +5,22 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.audit_logs import router as audit_logs_router from app.api.auth import router as auth_router from app.api.chat import router as chat_router +from app.api.clauses import router as clauses_router from app.api.contracts import router as contracts_router from app.api.conversations import router as conversations_router from app.api.files import router as files_router from app.api.governance import router as governance_router from app.api.knowledge_bases import router as knowledge_bases_router from app.api.legal_articles import router as legal_articles_router +from app.api.legal_change_impacts import router as legal_change_impacts_router +from app.api.legal_document_drafts import router as legal_document_drafts_router +from app.api.legal_document_types import router as legal_document_types_router +from app.api.operation_kpis import router as operation_kpis_router from app.api.pilot_requests import router as pilot_requests_router +from app.api.prompt_assets import router as prompt_assets_router from app.api.review_rules import router as review_rules_router from app.api.reviews import router as reviews_router +from app.api.sales_followups import router as sales_followups_router from app.api.system import router as system_router from app.api.templates import router as templates_router from app.api.tenant_settings import router as tenant_settings_router @@ -34,15 +41,22 @@ app.add_middleware( app.include_router(auth_router, prefix=settings.api_v1_prefix) app.include_router(reviews_router, prefix=settings.api_v1_prefix) app.include_router(chat_router, prefix=settings.api_v1_prefix) +app.include_router(clauses_router, prefix=settings.api_v1_prefix) app.include_router(contracts_router, prefix=settings.api_v1_prefix) app.include_router(conversations_router, prefix=settings.api_v1_prefix) app.include_router(files_router, prefix=settings.api_v1_prefix) app.include_router(governance_router, prefix=settings.api_v1_prefix) app.include_router(knowledge_bases_router, prefix=settings.api_v1_prefix) app.include_router(legal_articles_router, prefix=settings.api_v1_prefix) +app.include_router(legal_change_impacts_router, prefix=settings.api_v1_prefix) +app.include_router(legal_document_drafts_router, prefix=settings.api_v1_prefix) +app.include_router(legal_document_types_router, prefix=settings.api_v1_prefix) +app.include_router(operation_kpis_router, prefix=settings.api_v1_prefix) app.include_router(pilot_requests_router, prefix=settings.api_v1_prefix) +app.include_router(prompt_assets_router, prefix=settings.api_v1_prefix) app.include_router(review_rules_router, prefix=settings.api_v1_prefix) app.include_router(templates_router, prefix=settings.api_v1_prefix) +app.include_router(sales_followups_router, prefix=settings.api_v1_prefix) app.include_router(tenant_settings_router, prefix=settings.api_v1_prefix) app.include_router(users_router, prefix=settings.api_v1_prefix) app.include_router(audit_logs_router, prefix=settings.api_v1_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1e4a26ad..a3bd6331 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.contract_template import ContractTemplate from app.models.contract_template_version import ContractTemplateVersion from app.models.file import File from app.models.generated_contract import GeneratedContract +from app.models.generated_contract_version import GeneratedContractVersion from app.models.governance_asset import GovernanceAsset from app.models.governance_evidence import GovernanceEvidence from app.models.governance_gate_run import GovernanceGateRun @@ -15,11 +16,21 @@ from app.models.governance_review_decision import GovernanceReviewDecision from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base_document import KnowledgeBaseDocument from app.models.legal_article import LegalArticle +from app.models.legal_change_event import LegalChangeEvent +from app.models.legal_change_impact import LegalChangeImpact +from app.models.legal_document_draft import LegalDocumentDraft +from app.models.legal_document_element_schema import LegalDocumentElementSchema +from app.models.legal_document_type import LegalDocumentType from app.models.message import Message from app.models.pilot_request import PilotRequest +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_run_link import PromptAssetRunLink +from app.models.prompt_asset_version import PromptAssetVersion from app.models.review_result import ReviewResult +from app.models.review_risk_action import ReviewRiskAction from app.models.review_rule import ReviewRule from app.models.review_rule_version import ReviewRuleVersion from app.models.review_task import ReviewTask +from app.models.sales_followup_event import SalesFollowupEvent from app.models.tenant import Tenant from app.models.user import User diff --git a/backend/app/models/contract_template.py b/backend/app/models/contract_template.py index 4339abfb..ad2b0836 100644 --- a/backend/app/models/contract_template.py +++ b/backend/app/models/contract_template.py @@ -14,6 +14,7 @@ class ContractTemplate(Base): tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + document_type_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) template_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) diff --git a/backend/app/models/contract_template_version.py b/backend/app/models/contract_template_version.py index 300598ef..e42dd5cf 100644 --- a/backend/app/models/contract_template_version.py +++ b/backend/app/models/contract_template_version.py @@ -17,6 +17,7 @@ class ContractTemplateVersion(Base): change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) name: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + document_type_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) diff --git a/backend/app/models/generated_contract.py b/backend/app/models/generated_contract.py index 6dbb6479..e2c12a77 100644 --- a/backend/app/models/generated_contract.py +++ b/backend/app/models/generated_contract.py @@ -15,6 +15,7 @@ class GeneratedContract(Base): template_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) template_name: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + document_type_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) generated_by: Mapped[str] = mapped_column(String(64), nullable=False, index=True) generated_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) export_format: Mapped[str] = mapped_column(String(16), nullable=False) @@ -24,6 +25,9 @@ class GeneratedContract(Base): file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) generated_content: Mapped[str] = mapped_column(Text, nullable=False) variable_values_json: Mapped[Optional[dict[str, str]]] = mapped_column(JSON, nullable=True) + elements_snapshot_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + element_schema_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + element_schema_version_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) export_options_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) file_content_base64: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(String(32), default="generated") diff --git a/backend/app/models/generated_contract_version.py b/backend/app/models/generated_contract_version.py new file mode 100644 index 00000000..6c590918 --- /dev/null +++ b/backend/app/models/generated_contract_version.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class GeneratedContractVersion(Base): + __tablename__ = "generated_contract_versions" + __table_args__ = ( + UniqueConstraint("tenant_id", "contract_id", "version_number", name="uq_generated_contract_versions_tenant_contract_number"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + contract_id: Mapped[str] = mapped_column(String(64), ForeignKey("generated_contracts.id"), nullable=False, index=True) + review_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("review_tasks.id"), nullable=True, index=True) + review_risk_action_id: Mapped[Optional[str]] = mapped_column( + String(64), + ForeignKey("review_risk_actions.id"), + nullable=True, + index=True, + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1, index=True) + version_label: Mapped[str] = mapped_column(String(32), nullable=False) + source_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + change_type: Mapped[str] = mapped_column(String(64), nullable=False, default="review_suggestion") + change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + original_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + replacement_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + locate_status: Mapped[str] = mapped_column(String(32), nullable=False, default="applied", index=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/legal_change_event.py b/backend/app/models/legal_change_event.py new file mode 100644 index 00000000..15455139 --- /dev/null +++ b/backend/app/models/legal_change_event.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class LegalChangeEvent(Base): + __tablename__ = "legal_change_events" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + article_id: Mapped[Optional[str]] = mapped_column(String(96), nullable=True, index=True) + source_id: Mapped[str] = mapped_column(String(96), nullable=False, index=True) + source_title: Mapped[str] = mapped_column(String(255), nullable=False) + article_no: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + change_type: Mapped[str] = mapped_column(String(32), nullable=False, default="updated", index=True) + change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + previous_text_checksum: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + current_text_checksum: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + previous_article_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + current_article_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + effective_date: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) + tags_json: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True) + scan_status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", index=True) + impact_summary_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + scanned_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/legal_change_impact.py b/backend/app/models/legal_change_impact.py new file mode 100644 index 00000000..008d1598 --- /dev/null +++ b/backend/app/models/legal_change_impact.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class LegalChangeImpact(Base): + __tablename__ = "legal_change_impacts" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + change_event_id: Mapped[str] = mapped_column(String(64), ForeignKey("legal_change_events.id"), nullable=False, index=True) + target_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + target_id: Mapped[str] = mapped_column(String(96), nullable=False, index=True) + target_name: Mapped[str] = mapped_column(String(255), nullable=False) + target_version_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + match_reason: Mapped[str] = mapped_column(String(255), nullable=False) + match_terms_json: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True) + impact_level: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + suggestion: Mapped[str] = mapped_column(Text, nullable=False) + impact_status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True) + evidence_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + handled_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + handled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/legal_document_draft.py b/backend/app/models/legal_document_draft.py new file mode 100644 index 00000000..35e1c70e --- /dev/null +++ b/backend/app/models/legal_document_draft.py @@ -0,0 +1,32 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import JSON, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class LegalDocumentDraft(Base): + __tablename__ = "legal_document_drafts" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + document_type_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", index=True) + element_values_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + validation_result_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + element_schema_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + element_schema_version_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + template_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + generated_contract_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/legal_document_element_schema.py b/backend/app/models/legal_document_element_schema.py new file mode 100644 index 00000000..4a0709ca --- /dev/null +++ b/backend/app/models/legal_document_element_schema.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import JSON, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class LegalDocumentElementSchema(Base): + __tablename__ = "legal_document_element_schemas" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + document_type_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + version_label: Mapped[str] = mapped_column(String(32), nullable=False, default="v1.0") + schema_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", index=True) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + published_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + rollback_from_schema_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/legal_document_type.py b/backend/app/models/legal_document_type.py new file mode 100644 index 00000000..a8db18b0 --- /dev/null +++ b/backend/app/models/legal_document_type.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class LegalDocumentType(Base): + __tablename__ = "legal_document_types" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + category: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + industry: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + scenario: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + risk_level: Mapped[str] = mapped_column(String(32), nullable=False, default="medium", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=100) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/prompt_asset.py b/backend/app/models/prompt_asset.py new file mode 100644 index 00000000..421b2c37 --- /dev/null +++ b/backend/app/models/prompt_asset.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, Float, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class PromptAsset(Base): + __tablename__ = "prompt_assets" + __table_args__ = ( + UniqueConstraint("tenant_id", "prompt_key", name="uq_prompt_assets_tenant_key"), + Index("ix_prompt_assets_tenant_type_status", "tenant_id", "prompt_type", "status"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + prompt_key: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + prompt_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(160), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + target_scene: Mapped[Optional[str]] = mapped_column(String(160), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + current_version_label: Mapped[str] = mapped_column(String(32), nullable=False, default="v1.0") + current_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + published_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + published_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + rollback_from_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + latest_change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + latest_evaluation_status: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + latest_evaluation_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + latest_evaluation_report_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + version_count: Mapped[int] = mapped_column(default=1) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/prompt_asset_run_link.py b/backend/app/models/prompt_asset_run_link.py new file mode 100644 index 00000000..8ef10b01 --- /dev/null +++ b/backend/app/models/prompt_asset_run_link.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class PromptAssetRunLink(Base): + __tablename__ = "prompt_asset_run_links" + __table_args__ = ( + Index("ix_prompt_asset_run_links_tenant_run", "tenant_id", "run_id"), + Index("ix_prompt_asset_run_links_tenant_resource", "tenant_id", "resource_type", "resource_id"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + prompt_asset_id: Mapped[str] = mapped_column(String(64), ForeignKey("prompt_assets.id"), nullable=False, index=True) + prompt_asset_version_id: Mapped[str] = mapped_column(String(64), ForeignKey("prompt_asset_versions.id"), nullable=False, index=True) + prompt_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + run_type: Mapped[str] = mapped_column(String(64), nullable=False, default="ai_run") + run_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + resource_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + resource_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + workflow_run_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + task_run_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + trace_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/prompt_asset_version.py b/backend/app/models/prompt_asset_version.py new file mode 100644 index 00000000..02bec6d6 --- /dev/null +++ b/backend/app/models/prompt_asset_version.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class PromptAssetVersion(Base): + __tablename__ = "prompt_asset_versions" + __table_args__ = ( + UniqueConstraint("tenant_id", "prompt_asset_id", "version_label", name="uq_prompt_asset_versions_label"), + Index("ix_prompt_asset_versions_tenant_asset", "tenant_id", "prompt_asset_id"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + prompt_asset_id: Mapped[str] = mapped_column(String(64), ForeignKey("prompt_assets.id"), nullable=False, index=True) + prompt_key: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + prompt_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + version_label: Mapped[str] = mapped_column(String(32), nullable=False) + prompt_content: Mapped[str] = mapped_column(Text, nullable=False) + system_prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + variables_json: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSON, nullable=True) + output_schema_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + model_config_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + evaluation_status: Mapped[str] = mapped_column(String(32), nullable=False, default="not_evaluated") + evaluation_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + evaluation_report_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + rollback_from_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + is_current: Mapped[bool] = mapped_column(Boolean, default=False) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + published_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) diff --git a/backend/app/models/review_risk_action.py b/backend/app/models/review_risk_action.py new file mode 100644 index 00000000..6b70a10e --- /dev/null +++ b/backend/app/models/review_risk_action.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class ReviewRiskAction(Base): + __tablename__ = "review_risk_actions" + __table_args__ = ( + UniqueConstraint("tenant_id", "review_id", "risk_item_id", name="uq_review_risk_actions_tenant_review_risk"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + review_id: Mapped[str] = mapped_column(String(64), ForeignKey("review_tasks.id"), nullable=False, index=True) + risk_item_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + risk_item_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + risk_item_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + risk_level: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + action_status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", index=True) + suggested_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + user_modified_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + skip_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + applied_contract_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + risk_snapshot_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/sales_followup_event.py b/backend/app/models/sales_followup_event.py new file mode 100644 index 00000000..24d76f0f --- /dev/null +++ b/backend/app/models/sales_followup_event.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import DateTime, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class SalesFollowupEvent(Base): + __tablename__ = "sales_followup_events" + __table_args__ = ( + UniqueConstraint("tenant_id", "event_key", name="uq_sales_followup_events_tenant_event_key"), + Index("ix_sales_followup_events_tenant_status", "tenant_id", "status"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + event_key: Mapped[str] = mapped_column(String(160), nullable=False) + event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + source: Mapped[str] = mapped_column(String(64), nullable=False, default="manual") + priority: Mapped[str] = mapped_column(String(32), nullable=False, default="medium", index=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + resource_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + resource_id: Mapped[Optional[str]] = mapped_column(String(96), nullable=True, index=True) + resource_label: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + contact_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + contact_email: Mapped[Optional[str]] = mapped_column(String(120), nullable=True) + next_action: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + assigned_to: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + resolved_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/repositories/contract_template_repository.py b/backend/app/repositories/contract_template_repository.py index d972e3ff..c74c77ff 100644 --- a/backend/app/repositories/contract_template_repository.py +++ b/backend/app/repositories/contract_template_repository.py @@ -49,6 +49,7 @@ class ContractTemplateRepository: contract_type: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, + document_type_code: Optional[str] = None, ) -> List[ContractTemplate]: query = self.db.query(ContractTemplate).filter( ContractTemplate.tenant_id == tenant_id, @@ -56,6 +57,8 @@ class ContractTemplateRepository: ) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if document_type_code: + query = query.filter(ContractTemplate.document_type_code == document_type_code) if template_category: query = query.filter(ContractTemplate.template_category == template_category) if industry_category: @@ -70,10 +73,13 @@ class ContractTemplateRepository: contract_type: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, + document_type_code: Optional[str] = None, ) -> List[ContractTemplate]: query = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if document_type_code: + query = query.filter(ContractTemplate.document_type_code == document_type_code) if template_category: query = query.filter(ContractTemplate.template_category == template_category) if industry_category: @@ -86,10 +92,13 @@ class ContractTemplateRepository: contract_type: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, + document_type_code: Optional[str] = None, ) -> int: query = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if document_type_code: + query = query.filter(ContractTemplate.document_type_code == document_type_code) if template_category: query = query.filter(ContractTemplate.template_category == template_category) if industry_category: diff --git a/backend/app/repositories/generated_contract_repository.py b/backend/app/repositories/generated_contract_repository.py index 6e2fce04..0edf5718 100644 --- a/backend/app/repositories/generated_contract_repository.py +++ b/backend/app/repositories/generated_contract_repository.py @@ -28,17 +28,26 @@ class GeneratedContractRepository: query = query.filter(GeneratedContract.tenant_id == tenant_id) return query.first() + def get_by_file_id(self, file_id: str, tenant_id: Optional[str] = None) -> Optional[GeneratedContract]: + query = self.db.query(GeneratedContract).filter(GeneratedContract.file_id == file_id) + if tenant_id is not None: + query = query.filter(GeneratedContract.tenant_id == tenant_id) + return query.order_by(GeneratedContract.created_at.desc()).first() + def list_paginated( self, tenant_id: str, page: int, page_size: int, contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, keyword: Optional[str] = None, ) -> List[GeneratedContract]: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) if contract_type: query = query.filter(GeneratedContract.contract_type == contract_type) + if document_type_code: + query = query.filter(GeneratedContract.document_type_code == document_type_code) if keyword: keyword_like = f"%{keyword.strip()}%" query = query.filter( @@ -50,10 +59,18 @@ class GeneratedContractRepository: ) return query.order_by(GeneratedContract.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - def count(self, tenant_id: str, contract_type: Optional[str] = None, keyword: Optional[str] = None) -> int: + def count( + self, + tenant_id: str, + contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, + keyword: Optional[str] = None, + ) -> int: query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) if contract_type: query = query.filter(GeneratedContract.contract_type == contract_type) + if document_type_code: + query = query.filter(GeneratedContract.document_type_code == document_type_code) if keyword: keyword_like = f"%{keyword.strip()}%" query = query.filter( diff --git a/backend/app/repositories/generated_contract_version_repository.py b/backend/app/repositories/generated_contract_version_repository.py new file mode 100644 index 00000000..3ac6e802 --- /dev/null +++ b/backend/app/repositories/generated_contract_version_repository.py @@ -0,0 +1,76 @@ +from typing import Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.generated_contract_version import GeneratedContractVersion + + +class GeneratedContractVersionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, version: GeneratedContractVersion) -> GeneratedContractVersion: + self.db.add(version) + self.db.commit() + self.db.refresh(version) + return version + + def get_by_id( + self, + tenant_id: str, + contract_id: str, + version_id: str, + ) -> Optional[GeneratedContractVersion]: + return ( + self.db.query(GeneratedContractVersion) + .filter( + GeneratedContractVersion.tenant_id == tenant_id, + GeneratedContractVersion.contract_id == contract_id, + GeneratedContractVersion.id == version_id, + ) + .first() + ) + + def get_by_version_id(self, tenant_id: str, version_id: str) -> Optional[GeneratedContractVersion]: + return ( + self.db.query(GeneratedContractVersion) + .filter( + GeneratedContractVersion.tenant_id == tenant_id, + GeneratedContractVersion.id == version_id, + ) + .first() + ) + + def list_by_contract(self, tenant_id: str, contract_id: str) -> list[GeneratedContractVersion]: + return ( + self.db.query(GeneratedContractVersion) + .filter( + GeneratedContractVersion.tenant_id == tenant_id, + GeneratedContractVersion.contract_id == contract_id, + ) + .order_by(GeneratedContractVersion.version_number.desc(), GeneratedContractVersion.created_at.desc()) + .all() + ) + + def get_latest_by_contract(self, tenant_id: str, contract_id: str) -> Optional[GeneratedContractVersion]: + return ( + self.db.query(GeneratedContractVersion) + .filter( + GeneratedContractVersion.tenant_id == tenant_id, + GeneratedContractVersion.contract_id == contract_id, + ) + .order_by(GeneratedContractVersion.version_number.desc(), GeneratedContractVersion.created_at.desc()) + .first() + ) + + def next_version_number(self, tenant_id: str, contract_id: str) -> int: + current_max = ( + self.db.query(func.max(GeneratedContractVersion.version_number)) + .filter( + GeneratedContractVersion.tenant_id == tenant_id, + GeneratedContractVersion.contract_id == contract_id, + ) + .scalar() + ) + return max(int(current_max or 1), 1) + 1 diff --git a/backend/app/repositories/legal_change_impact_repository.py b/backend/app/repositories/legal_change_impact_repository.py new file mode 100644 index 00000000..ce6a850d --- /dev/null +++ b/backend/app/repositories/legal_change_impact_repository.py @@ -0,0 +1,106 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.legal_change_event import LegalChangeEvent +from app.models.legal_change_impact import LegalChangeImpact + + +class LegalChangeImpactRepository: + def __init__(self, db: Session): + self.db = db + + def create_event(self, event: LegalChangeEvent) -> LegalChangeEvent: + self.db.add(event) + self.db.commit() + self.db.refresh(event) + return event + + def update_event(self, event: LegalChangeEvent) -> LegalChangeEvent: + self.db.add(event) + self.db.commit() + self.db.refresh(event) + return event + + def create_impacts(self, impacts: list[LegalChangeImpact]) -> list[LegalChangeImpact]: + if not impacts: + return [] + self.db.add_all(impacts) + self.db.commit() + for impact in impacts: + self.db.refresh(impact) + return impacts + + def list_events( + self, + tenant_id: str, + page: int, + page_size: int, + *, + scan_status: Optional[str] = None, + source_id: Optional[str] = None, + ) -> tuple[list[LegalChangeEvent], int]: + query = self.db.query(LegalChangeEvent).filter(LegalChangeEvent.tenant_id == tenant_id) + if scan_status: + query = query.filter(LegalChangeEvent.scan_status == scan_status) + if source_id: + query = query.filter(LegalChangeEvent.source_id == source_id) + total = query.count() + items = ( + query.order_by(LegalChangeEvent.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return items, total + + def get_event_by_id(self, tenant_id: str, event_id: str) -> Optional[LegalChangeEvent]: + return ( + self.db.query(LegalChangeEvent) + .filter(LegalChangeEvent.tenant_id == tenant_id) + .filter(LegalChangeEvent.id == event_id) + .first() + ) + + def list_impacts_by_event(self, tenant_id: str, event_id: str) -> list[LegalChangeImpact]: + return ( + self.db.query(LegalChangeImpact) + .filter(LegalChangeImpact.tenant_id == tenant_id) + .filter(LegalChangeImpact.change_event_id == event_id) + .order_by(LegalChangeImpact.impact_level.asc(), LegalChangeImpact.created_at.asc()) + .all() + ) + + def list_pending_events(self, tenant_id: str, limit: int = 10) -> list[LegalChangeEvent]: + return ( + self.db.query(LegalChangeEvent) + .filter(LegalChangeEvent.tenant_id == tenant_id) + .filter(LegalChangeEvent.scan_status == "pending") + .order_by(LegalChangeEvent.created_at.asc()) + .limit(max(1, min(limit, 50))) + .all() + ) + + def delete_impacts_by_event(self, tenant_id: str, event_id: str) -> int: + deleted = ( + self.db.query(LegalChangeImpact) + .filter(LegalChangeImpact.tenant_id == tenant_id) + .filter(LegalChangeImpact.change_event_id == event_id) + .delete(synchronize_session=False) + ) + self.db.commit() + return deleted + + def get_impact_by_id(self, tenant_id: str, impact_id: str) -> Optional[LegalChangeImpact]: + return ( + self.db.query(LegalChangeImpact) + .filter(LegalChangeImpact.tenant_id == tenant_id) + .filter(LegalChangeImpact.id == impact_id) + .first() + ) + + def update_impact(self, impact: LegalChangeImpact) -> LegalChangeImpact: + self.db.add(impact) + self.db.commit() + self.db.refresh(impact) + return impact diff --git a/backend/app/repositories/legal_document_draft_repository.py b/backend/app/repositories/legal_document_draft_repository.py new file mode 100644 index 00000000..18a5546a --- /dev/null +++ b/backend/app/repositories/legal_document_draft_repository.py @@ -0,0 +1,50 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.legal_document_draft import LegalDocumentDraft + + +class LegalDocumentDraftRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, draft: LegalDocumentDraft) -> LegalDocumentDraft: + self.db.add(draft) + self.db.commit() + self.db.refresh(draft) + return draft + + def update(self, draft: LegalDocumentDraft) -> LegalDocumentDraft: + self.db.add(draft) + self.db.commit() + self.db.refresh(draft) + return draft + + def get_by_id(self, draft_id: str, tenant_id: Optional[str] = None) -> Optional[LegalDocumentDraft]: + query = self.db.query(LegalDocumentDraft).filter(LegalDocumentDraft.id == draft_id) + if tenant_id is not None: + query = query.filter(LegalDocumentDraft.tenant_id == tenant_id) + return query.first() + + def list_recent( + self, + *, + tenant_id: str, + user_id: str, + document_type_code: Optional[str] = None, + status_value: Optional[str] = "draft", + include_deleted: bool = False, + limit: int = 20, + ) -> list[LegalDocumentDraft]: + query = self.db.query(LegalDocumentDraft).filter( + LegalDocumentDraft.tenant_id == tenant_id, + LegalDocumentDraft.user_id == user_id, + ) + if document_type_code: + query = query.filter(LegalDocumentDraft.document_type_code == document_type_code) + if status_value: + query = query.filter(LegalDocumentDraft.status == status_value) + if not include_deleted: + query = query.filter(LegalDocumentDraft.deleted_at.is_(None)) + return query.order_by(LegalDocumentDraft.updated_at.desc(), LegalDocumentDraft.created_at.desc()).limit(limit).all() diff --git a/backend/app/repositories/legal_document_element_schema_repository.py b/backend/app/repositories/legal_document_element_schema_repository.py new file mode 100644 index 00000000..5a9d1b36 --- /dev/null +++ b/backend/app/repositories/legal_document_element_schema_repository.py @@ -0,0 +1,72 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.legal_document_element_schema import LegalDocumentElementSchema + + +class LegalDocumentElementSchemaRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, element_schema: LegalDocumentElementSchema) -> LegalDocumentElementSchema: + self.db.add(element_schema) + self.db.commit() + self.db.refresh(element_schema) + return element_schema + + def update(self, element_schema: LegalDocumentElementSchema) -> LegalDocumentElementSchema: + self.db.add(element_schema) + self.db.commit() + self.db.refresh(element_schema) + return element_schema + + def get_by_id(self, schema_id: str) -> Optional[LegalDocumentElementSchema]: + return ( + self.db.query(LegalDocumentElementSchema) + .filter(LegalDocumentElementSchema.id == schema_id) + .first() + ) + + def get_by_document_type_version( + self, + document_type_code: str, + version_label: str, + ) -> Optional[LegalDocumentElementSchema]: + return ( + self.db.query(LegalDocumentElementSchema) + .filter(LegalDocumentElementSchema.document_type_code == document_type_code) + .filter(LegalDocumentElementSchema.version_label == version_label) + .first() + ) + + def get_current_by_document_type_code(self, document_type_code: str) -> Optional[LegalDocumentElementSchema]: + published = ( + self.db.query(LegalDocumentElementSchema) + .filter(LegalDocumentElementSchema.document_type_code == document_type_code) + .filter(LegalDocumentElementSchema.status == "published") + .order_by( + LegalDocumentElementSchema.published_at.desc().nullslast(), + LegalDocumentElementSchema.updated_at.desc(), + ) + .first() + ) + if published is not None: + return published + return self.get_latest_by_document_type_code(document_type_code) + + def get_latest_by_document_type_code(self, document_type_code: str) -> Optional[LegalDocumentElementSchema]: + return ( + self.db.query(LegalDocumentElementSchema) + .filter(LegalDocumentElementSchema.document_type_code == document_type_code) + .order_by(LegalDocumentElementSchema.updated_at.desc(), LegalDocumentElementSchema.created_at.desc()) + .first() + ) + + def list_by_document_type_code(self, document_type_code: str) -> list[LegalDocumentElementSchema]: + return ( + self.db.query(LegalDocumentElementSchema) + .filter(LegalDocumentElementSchema.document_type_code == document_type_code) + .order_by(LegalDocumentElementSchema.updated_at.desc(), LegalDocumentElementSchema.created_at.desc()) + .all() + ) diff --git a/backend/app/repositories/legal_document_type_repository.py b/backend/app/repositories/legal_document_type_repository.py new file mode 100644 index 00000000..0e82b65f --- /dev/null +++ b/backend/app/repositories/legal_document_type_repository.py @@ -0,0 +1,66 @@ +from typing import Optional + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.legal_document_type import LegalDocumentType + + +class LegalDocumentTypeRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, document_type: LegalDocumentType) -> LegalDocumentType: + self.db.add(document_type) + self.db.commit() + self.db.refresh(document_type) + return document_type + + def update(self, document_type: LegalDocumentType) -> LegalDocumentType: + self.db.add(document_type) + self.db.commit() + self.db.refresh(document_type) + return document_type + + def get_by_code(self, code: str) -> Optional[LegalDocumentType]: + return ( + self.db.query(LegalDocumentType) + .filter(LegalDocumentType.code == code) + .first() + ) + + def list_filtered( + self, + *, + category: Optional[str] = None, + industry: Optional[str] = None, + scenario: Optional[str] = None, + active_only: bool = True, + query: Optional[str] = None, + ) -> list[LegalDocumentType]: + db_query = self.db.query(LegalDocumentType) + if active_only: + db_query = db_query.filter(LegalDocumentType.is_active.is_(True)) + if category: + db_query = db_query.filter(LegalDocumentType.category == category) + if industry: + db_query = db_query.filter(LegalDocumentType.industry == industry) + if scenario: + db_query = db_query.filter(LegalDocumentType.scenario == scenario) + if query: + like = f"%{query}%" + db_query = db_query.filter( + or_( + LegalDocumentType.code.ilike(like), + LegalDocumentType.name.ilike(like), + LegalDocumentType.description.ilike(like), + ) + ) + return ( + db_query.order_by( + LegalDocumentType.sort_order.asc(), + LegalDocumentType.category.asc(), + LegalDocumentType.name.asc(), + ) + .all() + ) diff --git a/backend/app/repositories/prompt_asset_repository.py b/backend/app/repositories/prompt_asset_repository.py new file mode 100644 index 00000000..73ff6875 --- /dev/null +++ b/backend/app/repositories/prompt_asset_repository.py @@ -0,0 +1,96 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.prompt_asset import PromptAsset + + +class PromptAssetRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, prompt_asset: PromptAsset) -> PromptAsset: + self.db.add(prompt_asset) + self.db.commit() + self.db.refresh(prompt_asset) + return prompt_asset + + def update(self, prompt_asset: PromptAsset) -> PromptAsset: + self.db.add(prompt_asset) + self.db.commit() + self.db.refresh(prompt_asset) + return prompt_asset + + def get_by_id(self, asset_id: str) -> Optional[PromptAsset]: + return self.db.get(PromptAsset, asset_id) + + def get_by_key(self, tenant_id: str, prompt_key: str) -> Optional[PromptAsset]: + return ( + self.db.query(PromptAsset) + .filter( + PromptAsset.tenant_id == tenant_id, + PromptAsset.prompt_key == prompt_key, + ) + .first() + ) + + def get_published_by_key(self, tenant_id: str, prompt_key: str) -> Optional[PromptAsset]: + return ( + self.db.query(PromptAsset) + .filter( + PromptAsset.tenant_id == tenant_id, + PromptAsset.prompt_key == prompt_key, + PromptAsset.status == "published", + PromptAsset.published_version_id.isnot(None), + ) + .first() + ) + + def list_published_by_type(self, tenant_id: str, prompt_type: str, limit: int = 20) -> List[PromptAsset]: + return ( + self.db.query(PromptAsset) + .filter( + PromptAsset.tenant_id == tenant_id, + PromptAsset.prompt_type == prompt_type, + PromptAsset.status == "published", + PromptAsset.published_version_id.isnot(None), + ) + .order_by(PromptAsset.published_at.desc(), PromptAsset.updated_at.desc()) + .limit(limit) + .all() + ) + + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + *, + prompt_type: Optional[str] = None, + status_filter: Optional[str] = None, + ) -> List[PromptAsset]: + query = self.db.query(PromptAsset).filter(PromptAsset.tenant_id == tenant_id) + if prompt_type: + query = query.filter(PromptAsset.prompt_type == prompt_type) + if status_filter: + query = query.filter(PromptAsset.status == status_filter) + return ( + query.order_by(PromptAsset.updated_at.desc(), PromptAsset.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + def count( + self, + tenant_id: str, + *, + prompt_type: Optional[str] = None, + status_filter: Optional[str] = None, + ) -> int: + query = self.db.query(PromptAsset).filter(PromptAsset.tenant_id == tenant_id) + if prompt_type: + query = query.filter(PromptAsset.prompt_type == prompt_type) + if status_filter: + query = query.filter(PromptAsset.status == status_filter) + return query.count() diff --git a/backend/app/repositories/prompt_asset_run_link_repository.py b/backend/app/repositories/prompt_asset_run_link_repository.py new file mode 100644 index 00000000..83a30619 --- /dev/null +++ b/backend/app/repositories/prompt_asset_run_link_repository.py @@ -0,0 +1,28 @@ +from typing import List + +from sqlalchemy.orm import Session + +from app.models.prompt_asset_run_link import PromptAssetRunLink + + +class PromptAssetRunLinkRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, run_link: PromptAssetRunLink) -> PromptAssetRunLink: + self.db.add(run_link) + self.db.commit() + self.db.refresh(run_link) + return run_link + + def list_by_asset_id(self, tenant_id: str, asset_id: str, limit: int = 20) -> List[PromptAssetRunLink]: + return ( + self.db.query(PromptAssetRunLink) + .filter( + PromptAssetRunLink.tenant_id == tenant_id, + PromptAssetRunLink.prompt_asset_id == asset_id, + ) + .order_by(PromptAssetRunLink.created_at.desc()) + .limit(limit) + .all() + ) diff --git a/backend/app/repositories/prompt_asset_version_repository.py b/backend/app/repositories/prompt_asset_version_repository.py new file mode 100644 index 00000000..76523a4d --- /dev/null +++ b/backend/app/repositories/prompt_asset_version_repository.py @@ -0,0 +1,75 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.prompt_asset_version import PromptAssetVersion + + +class PromptAssetVersionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, version: PromptAssetVersion) -> PromptAssetVersion: + self.db.add(version) + self.db.commit() + self.db.refresh(version) + return version + + def update(self, version: PromptAssetVersion) -> PromptAssetVersion: + self.db.add(version) + self.db.commit() + self.db.refresh(version) + return version + + def get_by_id(self, version_id: str) -> Optional[PromptAssetVersion]: + return self.db.get(PromptAssetVersion, version_id) + + def get_by_version_label(self, tenant_id: str, asset_id: str, version_label: str) -> Optional[PromptAssetVersion]: + return ( + self.db.query(PromptAssetVersion) + .filter( + PromptAssetVersion.tenant_id == tenant_id, + PromptAssetVersion.prompt_asset_id == asset_id, + PromptAssetVersion.version_label == version_label, + ) + .first() + ) + + def list_by_asset_id(self, tenant_id: str, asset_id: str) -> List[PromptAssetVersion]: + return ( + self.db.query(PromptAssetVersion) + .filter( + PromptAssetVersion.tenant_id == tenant_id, + PromptAssetVersion.prompt_asset_id == asset_id, + ) + .order_by(PromptAssetVersion.created_at.desc()) + .all() + ) + + def clear_current(self, tenant_id: str, asset_id: str) -> None: + rows = ( + self.db.query(PromptAssetVersion) + .filter( + PromptAssetVersion.tenant_id == tenant_id, + PromptAssetVersion.prompt_asset_id == asset_id, + ) + .all() + ) + for item in rows: + item.is_current = False + self.db.commit() + + def clear_published(self, tenant_id: str, asset_id: str) -> None: + rows = ( + self.db.query(PromptAssetVersion) + .filter( + PromptAssetVersion.tenant_id == tenant_id, + PromptAssetVersion.prompt_asset_id == asset_id, + ) + .all() + ) + for item in rows: + item.is_published = False + item.published_at = None + item.published_by = None + self.db.commit() diff --git a/backend/app/repositories/review_risk_action_repository.py b/backend/app/repositories/review_risk_action_repository.py new file mode 100644 index 00000000..820e9a7d --- /dev/null +++ b/backend/app/repositories/review_risk_action_repository.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.review_risk_action import ReviewRiskAction + + +class ReviewRiskActionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, action: ReviewRiskAction) -> ReviewRiskAction: + self.db.add(action) + self.db.commit() + self.db.refresh(action) + return action + + def update(self, action: ReviewRiskAction) -> ReviewRiskAction: + self.db.add(action) + self.db.commit() + self.db.refresh(action) + return action + + def list_by_review_id(self, tenant_id: str, review_id: str) -> list[ReviewRiskAction]: + return ( + self.db.query(ReviewRiskAction) + .filter(ReviewRiskAction.tenant_id == tenant_id, ReviewRiskAction.review_id == review_id) + .order_by(ReviewRiskAction.risk_item_index.asc(), ReviewRiskAction.created_at.asc()) + .all() + ) + + def get_by_id(self, tenant_id: str, action_id: str) -> Optional[ReviewRiskAction]: + return ( + self.db.query(ReviewRiskAction) + .filter(ReviewRiskAction.tenant_id == tenant_id, ReviewRiskAction.id == action_id) + .first() + ) + + def get_by_risk_item(self, tenant_id: str, review_id: str, risk_item_id: str) -> Optional[ReviewRiskAction]: + return ( + self.db.query(ReviewRiskAction) + .filter( + ReviewRiskAction.tenant_id == tenant_id, + ReviewRiskAction.review_id == review_id, + ReviewRiskAction.risk_item_id == risk_item_id, + ) + .first() + ) diff --git a/backend/app/services/clause_recommendation_service.py b/backend/app/services/clause_recommendation_service.py new file mode 100644 index 00000000..ac5c8618 --- /dev/null +++ b/backend/app/services/clause_recommendation_service.py @@ -0,0 +1,1054 @@ +from __future__ import annotations + +import re +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.services.legal_article_service import LegalArticleService +from app.services.prompt_runtime_service import PromptRuntimeService + + +DOCUMENT_TYPE_ALIASES = { + "procurement": "procurement_goods", + "goods": "procurement_goods", + "purchase": "procurement_goods", + "sale": "procurement_goods", + "service": "service_general", + "technical": "technical_service", + "technology": "technical_service", + "development": "software_development", + "software": "software_development", + "labor": "labor_contract", + "employment": "labor_contract", + "nda": "nda_commercial", + "confidentiality": "nda_commercial", + "lease": "lease_house", +} + +REWRITE_TYPE_ALIASES = { + "professional": "professional", + "formal": "professional", + "legal": "professional", + "simplify": "simplify", + "plain": "simplify", + "simple": "simplify", + "party_a": "party_a", + "favor_party_a": "party_a", + "buyer": "party_a", + "client": "party_a", + "party_b": "party_b", + "favor_party_b": "party_b", + "supplier": "party_b", + "vendor": "party_b", + "balanced": "balanced", + "neutral": "balanced", +} + +REWRITE_TYPE_META = { + "professional": { + "label": "专业化", + "legal_boundary": "专业化改写只能增强确定性和可执行性,不能用模糊表述替代核心期限、金额、验收标准或责任边界。", + "negotiation_advice": "建议把“若干工作日”“合理期限”等变量在谈判中落到具体数字或附件口径。", + }, + "simplify": { + "label": "简化解释", + "legal_boundary": "简化解释用于帮助业务理解,不宜直接替代正式合同正文;正式文本仍应保留必要定义、条件和责任安排。", + "negotiation_advice": "可先用简化版对齐双方商业理解,再把确认后的期限、标准和证据要求写回正式条款。", + }, + "party_a": { + "label": "偏甲方", + "legal_boundary": "偏甲方表述不能免除甲方依法或依约应承担的协助、验收、付款和诚信履行义务。", + "negotiation_advice": "甲方可优先争取验收控制权、付款条件和违约救济,但应避免设置明显失衡或难以履行的单方权利。", + }, + "party_b": { + "label": "偏乙方", + "legal_boundary": "偏乙方表述不能排除乙方依法或依约应承担的质量、交付、保密和损害赔偿责任。", + "negotiation_advice": "乙方可优先争取甲方配合前置条件、宽限期和责任上限,同时保留必要的履约证据。", + }, + "balanced": { + "label": "平衡版", + "legal_boundary": "平衡版仍需结合交易金额、风险等级和行业监管要求补齐具体变量。", + "negotiation_advice": "建议双方围绕交付、验收、付款、违约责任和争议解决逐项确认,避免只在原则层面达成一致。", + }, +} + +REWRITE_PHRASE_REPLACEMENTS = [ + { + "from": "尽快交付", + "to": "在合同签订后若干工作日内完成交付,具体期限由双方在订单、附件或书面确认文件中明确", + "reason": "把不确定期限改成可填写的工作日口径。", + }, + { + "from": "尽快付款", + "to": "在收到合格发票、验收文件及付款申请后若干工作日内支付对应款项", + "reason": "把付款触发条件和期限拆开,便于执行和举证。", + }, + { + "from": "及时通知", + "to": "在知悉相关事项后若干工作日内以书面或双方确认的电子方式通知", + "reason": "明确通知起算点、期限和方式。", + }, + { + "from": "质量合格", + "to": "符合国家标准、行业标准及本合同约定的规格、性能和验收标准", + "reason": "把笼统质量要求改成可核验标准。", + }, + { + "from": "负责赔偿", + "to": "赔偿守约方因此遭受的实际损失,包括为维护权利支出的合理费用", + "reason": "补足损失范围和维权费用口径。", + }, + { + "from": "长期有效", + "to": "自合同生效之日起至双方权利义务履行完毕或另有书面约定时止", + "reason": "避免期限无限或无法判断。", + }, + { + "from": "双方协商解决", + "to": "双方应先友好协商;协商不成的,按本合同约定的争议解决方式处理", + "reason": "把协商和后续争议解决路径衔接起来。", + }, +] + + +GENERAL_RULES: list[dict[str, Any]] = [ + { + "clause_key": "breach_liability", + "clause_title": "违约责任", + "clause_type": "breach", + "priority": "high", + "keywords": ["违约", "违约金", "赔偿", "责任承担", "default", "breach"], + "anchor_keywords": ["付款", "交付", "服务费", "价款", "payment", "delivery"], + "suggested_text": ( + "违约责任:任何一方违反本合同约定,给守约方造成损失的,应承担继续履行、" + "采取补救措施、赔偿损失等违约责任;如合同另有违约金约定,违约金不足以" + "弥补损失的,违约方仍应补足差额。" + ), + "reason": "合同应明确违约后果,避免出现只有义务没有责任的执行风险。", + "legal_basis_query": "民法典 违约责任 继续履行 补救措施 赔偿损失", + "legal_basis": "《民法典》关于违约责任、继续履行、补救措施和损害赔偿的一般规则。", + "source_rule": "LAW-CLAUSE-GENERAL-BREACH", + "default_position": "建议插入在主要付款、交付或服务义务条款之后。", + }, + { + "clause_key": "dispute_resolution", + "clause_title": "争议解决", + "clause_type": "dispute_resolution", + "priority": "medium", + "keywords": ["争议解决", "管辖", "仲裁", "诉讼", "dispute", "arbitration", "jurisdiction"], + "anchor_keywords": ["违约", "不可抗力", "通知", "签署"], + "suggested_text": ( + "争议解决:因本合同产生或与本合同有关的任何争议,双方应先友好协商;" + "协商不成的,任一方可向有管辖权的人民法院提起诉讼。" + ), + "reason": "争议解决条款能够降低履约争议发生后的程序不确定性。", + "legal_basis_query": "民法典 合同 争议解决 诉讼 仲裁", + "legal_basis": "合同通常应明确争议解决方式和管辖安排。", + "source_rule": "LAW-CLAUSE-GENERAL-DISPUTE", + "default_position": "建议插入在合同尾部、签署页之前。", + }, +] + + +DOCUMENT_TYPE_RULES: dict[str, list[dict[str, Any]]] = { + "procurement_goods": [ + { + "clause_key": "acceptance", + "clause_title": "验收标准与流程", + "clause_type": "acceptance", + "priority": "high", + "keywords": ["验收", "验收标准", "验收流程", "试运行", "acceptance"], + "anchor_keywords": ["交付", "交货", "delivery"], + "suggested_text": ( + "验收标准与流程:乙方交付货物后,甲方应在收到货物后五个工作日内按照合同约定的" + "规格、数量、质量标准进行验收;验收不合格的,甲方有权要求乙方在合理期限内" + "更换、修理或补足,相关费用由乙方承担。" + ), + "reason": "买卖合同需要把交付和验收衔接起来,否则付款、质量异议和违约责任都难以落地。", + "legal_basis_query": "民法典 买卖合同 验收 质量 数量", + "legal_basis": "《民法典》买卖合同规则和合同全面履行原则要求交付、质量和验收口径清晰。", + "source_rule": "LAW-CLAUSE-PROCUREMENT-ACCEPTANCE", + "default_position": "建议插入在交付条款之后、付款条款之前。", + }, + { + "clause_key": "quality_warranty", + "clause_title": "质量保证", + "clause_type": "quality", + "priority": "high", + "keywords": ["质量保证", "质保", "保修", "质量标准", "quality", "warranty"], + "anchor_keywords": ["验收", "交付", "货物"], + "suggested_text": ( + "质量保证:乙方保证交付货物符合国家标准、行业标准及本合同约定的质量要求。" + "质量保证期内出现质量问题的,乙方应在收到甲方通知后三个工作日内响应,并承担" + "修理、更换、退货或赔偿损失等责任。" + ), + "reason": "质量保证条款能够覆盖验收后发现瑕疵的处理边界。", + "legal_basis_query": "民法典 买卖合同 质量 瑕疵担保", + "legal_basis": "买卖合同通常需要明确标的物质量要求和瑕疵处理责任。", + "source_rule": "LAW-CLAUSE-PROCUREMENT-QUALITY", + "default_position": "建议插入在验收条款之后。", + }, + { + "clause_key": "breach_liability", + "clause_title": "违约责任", + "clause_type": "breach", + "priority": "high", + "keywords": ["违约", "违约金", "赔偿", "逾期交付", "逾期付款", "breach"], + "anchor_keywords": ["付款", "验收", "交付"], + "suggested_text": ( + "违约责任:乙方逾期交付、交付货物不符合约定或甲方逾期付款的,违约方应承担" + "继续履行、采取补救措施、支付违约金并赔偿守约方损失等责任。" + ), + "reason": "买卖合同的付款、交付和质量义务需要对应的违约后果。", + "legal_basis_query": "民法典 买卖合同 违约责任 逾期交付 逾期付款", + "legal_basis": "《民法典》关于违约责任和买卖合同履行的规则。", + "source_rule": "LAW-CLAUSE-PROCUREMENT-BREACH", + "default_position": "建议插入在付款或验收条款之后。", + }, + { + "clause_key": "force_majeure", + "clause_title": "不可抗力", + "clause_type": "force_majeure", + "priority": "medium", + "keywords": ["不可抗力", "不可预见", "不可避免", "force majeure"], + "anchor_keywords": ["违约", "争议解决", "通知"], + "suggested_text": ( + "不可抗力:因不可预见、不可避免且不可克服的客观情况导致不能履行合同的," + "受影响方应及时通知对方并在合理期限内提供证明;双方可根据影响程度协商" + "部分或全部免除责任,但迟延履行后发生不可抗力的除外。" + ), + "reason": "不可抗力条款有助于区分真实免责事由和一般商业风险。", + "legal_basis_query": "民法典 不可抗力 合同 免责", + "legal_basis": "《民法典》关于不可抗力及免责边界的规则。", + "source_rule": "LAW-CLAUSE-PROCUREMENT-FORCE-MAJEURE", + "default_position": "建议插入在违约责任条款之后。", + }, + { + "clause_key": "notice_delivery", + "clause_title": "通知与送达", + "clause_type": "notice", + "priority": "medium", + "keywords": ["通知", "送达", "通讯地址", "电子邮件", "notice"], + "anchor_keywords": ["争议解决", "不可抗力", "签署"], + "suggested_text": ( + "通知与送达:双方确认本合同载明的联系人、通讯地址、电子邮箱为有效送达信息。" + "任何一方变更送达信息的,应提前书面通知对方;未通知导致无法送达的,相关" + "不利后果由变更方承担。" + ), + "reason": "通知送达条款能保障催告、解除、质量异议和争议处理的证据链。", + "legal_basis_query": "民法典 通知 送达 合同 催告", + "legal_basis": "合同履行中的通知、催告和协助义务需要明确送达规则。", + "source_rule": "LAW-CLAUSE-PROCUREMENT-NOTICE", + "default_position": "建议插入在合同尾部、签署页之前。", + }, + ], + "service_general": [ + { + "clause_key": "service_scope", + "clause_title": "服务范围", + "clause_type": "service_scope", + "priority": "high", + "keywords": ["服务范围", "服务内容", "工作范围", "scope of services"], + "anchor_keywords": ["项目", "费用", "交付"], + "suggested_text": "服务范围:乙方应按照本合同及附件约定提供服务,未经甲方书面确认,不得擅自减少服务内容或降低服务标准。", + "reason": "服务范围不清会直接影响验收、付款和违约判断。", + "legal_basis_query": "民法典 服务合同 履行 服务范围", + "legal_basis": "合同内容和履行义务应明确具体。", + "source_rule": "LAW-CLAUSE-SERVICE-SCOPE", + "default_position": "建议插入在合同标的或项目说明之后。", + }, + { + "clause_key": "deliverables", + "clause_title": "交付物", + "clause_type": "delivery", + "priority": "high", + "keywords": ["交付物", "成果", "报告", "deliverable", "delivery"], + "anchor_keywords": ["服务范围", "服务内容"], + "suggested_text": "交付物:乙方应按约定时间提交服务成果、报告或其他交付物,交付物应满足本合同约定的格式、内容和质量要求。", + "reason": "服务合同需要把服务过程转化为可验收的交付成果。", + "legal_basis_query": "民法典 合同 交付 成果 验收", + "legal_basis": "服务成果、交付标准和验收条件应保持一致。", + "source_rule": "LAW-CLAUSE-SERVICE-DELIVERABLES", + "default_position": "建议插入在服务范围条款之后。", + }, + { + "clause_key": "acceptance", + "clause_title": "验收", + "clause_type": "acceptance", + "priority": "high", + "keywords": ["验收", "验收标准", "验收流程", "acceptance"], + "anchor_keywords": ["交付物", "成果", "服务费"], + "suggested_text": "验收:甲方应在收到交付物后五个工作日内完成验收;验收不合格的,乙方应按甲方合理要求进行修改完善,直至符合约定标准。", + "reason": "验收条款是服务费支付和成果确认的关键依据。", + "legal_basis_query": "民法典 服务合同 验收 付款", + "legal_basis": "合同履行应明确成果确认和付款触发条件。", + "source_rule": "LAW-CLAUSE-SERVICE-ACCEPTANCE", + "default_position": "建议插入在交付物条款之后。", + }, + { + "clause_key": "intellectual_property", + "clause_title": "知识产权", + "clause_type": "intellectual_property", + "priority": "high", + "keywords": ["知识产权", "著作权", "专利", "源代码", "intellectual property", "ip ownership"], + "anchor_keywords": ["交付物", "成果", "验收"], + "suggested_text": "知识产权:除双方另有书面约定外,乙方为履行本合同形成并交付给甲方的定制成果,其可转让财产性权利在甲方付清相应费用后归甲方所有。", + "reason": "服务成果的权属不清容易引发后续使用和转授权争议。", + "legal_basis_query": "民法典 知识产权 合同 成果 权属", + "legal_basis": "涉及成果交付的服务合同应明确知识产权归属和使用范围。", + "source_rule": "LAW-CLAUSE-SERVICE-IP", + "default_position": "建议插入在验收条款之后。", + }, + { + "clause_key": "confidentiality", + "clause_title": "保密", + "clause_type": "confidentiality", + "priority": "medium", + "keywords": ["保密", "商业秘密", "confidential", "non-disclosure"], + "anchor_keywords": ["知识产权", "违约"], + "suggested_text": "保密:双方应对在合作中获悉的商业秘密、技术资料、客户信息及其他非公开信息承担保密义务,未经信息披露方书面同意不得向第三方披露。", + "reason": "服务履行通常会接触业务资料和客户信息,需要明确保密边界。", + "legal_basis_query": "民法典 保密义务 商业秘密 合同", + "legal_basis": "合同附随义务和商业秘密保护要求对保密事项作出约定。", + "source_rule": "LAW-CLAUSE-SERVICE-CONFIDENTIALITY", + "default_position": "建议插入在知识产权条款之后。", + }, + { + "clause_key": "cooperation_obligation", + "clause_title": "甲方配合义务", + "clause_type": "cooperation", + "priority": "medium", + "keywords": ["配合义务", "甲方配合", "资料提供", "协助", "cooperation"], + "anchor_keywords": ["服务范围", "交付物", "验收"], + "suggested_text": "甲方配合义务:甲方应按乙方合理要求及时提供项目所需资料、账号、场地或业务确认;因甲方未及时配合导致延期的,服务期限相应顺延。", + "reason": "服务合同中甲方配合不足常导致延期争议,需要提前约定责任边界。", + "legal_basis_query": "民法典 协助义务 合同履行", + "legal_basis": "合同履行中的协助义务需要双方配合完成。", + "source_rule": "LAW-CLAUSE-SERVICE-COOPERATION", + "default_position": "建议插入在服务范围或交付物条款之后。", + }, + ], + "technical_service": [], + "software_development": [], + "labor_contract": [ + { + "clause_key": "probation", + "clause_title": "试用期", + "clause_type": "probation", + "priority": "medium", + "keywords": ["试用期", "probation"], + "anchor_keywords": ["合同期限", "岗位"], + "suggested_text": "试用期:双方依法约定试用期;试用期工资、考核标准和转正条件应符合劳动法律法规及用人单位依法制定的规章制度。", + "reason": "劳动合同中试用期需要依法约定,避免期限和工资标准不合规。", + "legal_basis_query": "劳动合同法 试用期 工资", + "legal_basis": "《劳动合同法》对试用期长度、次数和工资标准有明确限制。", + "source_rule": "LAW-CLAUSE-LABOR-PROBATION", + "default_position": "建议插入在合同期限条款之后。", + }, + { + "clause_key": "social_security", + "clause_title": "社会保险", + "clause_type": "social_security", + "priority": "high", + "keywords": ["社会保险", "社保", "公积金", "五险", "social insurance"], + "anchor_keywords": ["劳动报酬", "工资"], + "suggested_text": "社会保险:甲方应依法为乙方办理社会保险并缴纳相应费用;乙方应按甲方要求提供办理社会保险所需资料。", + "reason": "社会保险属于劳动用工合规高频必备事项。", + "legal_basis_query": "劳动合同法 社会保险 用人单位", + "legal_basis": "劳动法律法规要求用人单位依法为劳动者缴纳社会保险。", + "source_rule": "LAW-CLAUSE-LABOR-SOCIAL-SECURITY", + "default_position": "建议插入在劳动报酬条款之后。", + }, + { + "clause_key": "working_time", + "clause_title": "工作时间和休息休假", + "clause_type": "working_time", + "priority": "medium", + "keywords": ["工作时间", "休息休假", "加班", "工时", "working time"], + "anchor_keywords": ["岗位", "工作地点"], + "suggested_text": "工作时间和休息休假:甲方依法执行工时制度和休息休假制度;因工作需要安排加班的,应依法履行审批并支付加班工资或安排补休。", + "reason": "工时、加班和休假直接影响劳动争议风险。", + "legal_basis_query": "劳动法 工作时间 休息休假 加班", + "legal_basis": "劳动法律法规对工作时间、休息休假和加班安排有强制性要求。", + "source_rule": "LAW-CLAUSE-LABOR-WORKING-TIME", + "default_position": "建议插入在岗位和工作地点条款之后。", + }, + { + "clause_key": "termination", + "clause_title": "解除和终止", + "clause_type": "termination", + "priority": "high", + "keywords": ["解除", "终止", "离职", "termination"], + "anchor_keywords": ["社会保险", "劳动报酬", "违约"], + "suggested_text": "解除和终止:劳动合同的解除、终止及经济补偿按照劳动法律法规执行;双方办理离职交接时应结清工资、社保及应返还资料。", + "reason": "解除终止条款能降低离职交接和经济补偿争议。", + "legal_basis_query": "劳动合同法 解除 终止 经济补偿", + "legal_basis": "《劳动合同法》对劳动合同解除、终止和经济补偿有专门规定。", + "source_rule": "LAW-CLAUSE-LABOR-TERMINATION", + "default_position": "建议插入在劳动报酬或社会保险条款之后。", + }, + { + "clause_key": "non_compete", + "clause_title": "竞业限制", + "clause_type": "non_compete", + "priority": "low", + "keywords": ["竞业限制", "竞业禁止", "non-compete", "non compete"], + "anchor_keywords": ["保密", "知识产权", "解除"], + "suggested_text": "竞业限制:如甲方确需对乙方设置竞业限制,应另行依法签署竞业限制协议,明确适用人员、范围、期限、补偿标准和违约责任。", + "reason": "竞业限制不是所有劳动合同都必须约定,但涉及核心岗位时需要依法单独明确。", + "legal_basis_query": "劳动合同法 竞业限制 补偿", + "legal_basis": "竞业限制适用人员、期限和补偿均受劳动法律法规约束。", + "source_rule": "LAW-CLAUSE-LABOR-NON-COMPETE", + "default_position": "建议插入在保密或解除终止条款之后。", + }, + ], + "nda_commercial": [ + { + "clause_key": "confidential_info_definition", + "clause_title": "保密信息定义", + "clause_type": "confidentiality", + "priority": "high", + "keywords": ["保密信息", "商业秘密", "confidential information"], + "anchor_keywords": ["合作事项", "披露"], + "suggested_text": "保密信息:指一方以书面、口头、电子或其他形式向对方披露的技术、商业、客户、财务、产品、方案及其他非公开信息。", + "reason": "保密协议首先需要界定保护对象,否则后续违约认定困难。", + "legal_basis_query": "民法典 保密义务 商业秘密 定义", + "legal_basis": "保密义务应围绕明确的保密信息范围展开。", + "source_rule": "LAW-CLAUSE-NDA-DEFINITION", + "default_position": "建议插入在合作背景或披露目的之后。", + }, + { + "clause_key": "exclusions", + "clause_title": "保密例外", + "clause_type": "confidentiality_exclusion", + "priority": "high", + "keywords": ["保密例外", "不属于保密信息", "已公开", "exclusion"], + "anchor_keywords": ["保密信息", "保密范围"], + "suggested_text": "保密例外:已为公众所知、接收方在披露前已合法知悉、非因接收方违约而公开、或接收方从有权第三方合法取得的信息,不属于保密信息。", + "reason": "保密例外能避免把公开信息或合法已知信息错误纳入违约范围。", + "legal_basis_query": "民法典 保密义务 商业秘密 公开信息", + "legal_basis": "保密范围应与可保护的非公开信息保持一致。", + "source_rule": "LAW-CLAUSE-NDA-EXCLUSIONS", + "default_position": "建议插入在保密信息定义之后。", + }, + { + "clause_key": "return_destroy", + "clause_title": "返还或销毁", + "clause_type": "return_destroy", + "priority": "medium", + "keywords": ["返还", "销毁", "删除", "return", "destroy"], + "anchor_keywords": ["保密期限", "终止"], + "suggested_text": "返还或销毁:合作终止或披露方要求时,接收方应返还、删除或销毁保密信息及其载体,并按披露方要求出具书面确认。", + "reason": "返还销毁条款能控制保密信息在合作结束后的继续流转。", + "legal_basis_query": "民法典 保密义务 合同终止 返还", + "legal_basis": "合同终止后的保密义务和资料返还应明确约定。", + "source_rule": "LAW-CLAUSE-NDA-RETURN-DESTROY", + "default_position": "建议插入在保密期限或终止条款之后。", + }, + { + "clause_key": "compelled_disclosure", + "clause_title": "强制披露例外", + "clause_type": "compelled_disclosure", + "priority": "medium", + "keywords": ["强制披露", "监管要求", "司法机关", "compelled disclosure"], + "anchor_keywords": ["保密例外", "保密义务"], + "suggested_text": "强制披露:接收方因法律法规、监管机关或司法机关要求必须披露保密信息的,应在法律允许范围内提前通知披露方,并尽合理努力限制披露范围。", + "reason": "强制披露例外能平衡法定披露义务和保密责任。", + "legal_basis_query": "民法典 保密义务 强制披露 监管 司法", + "legal_basis": "保密义务不能排除依法披露,但应限制披露范围并保留证据。", + "source_rule": "LAW-CLAUSE-NDA-COMPELLED-DISCLOSURE", + "default_position": "建议插入在保密例外条款之后。", + }, + { + "clause_key": "breach_liability", + "clause_title": "保密违约责任", + "clause_type": "breach", + "priority": "high", + "keywords": ["违约责任", "赔偿", "违约金", "泄密责任", "breach"], + "anchor_keywords": ["保密期限", "返还", "销毁"], + "suggested_text": "保密违约责任:接收方违反保密义务的,应立即停止违约行为、采取补救措施,并赔偿披露方因此遭受的全部损失。", + "reason": "保密义务需要对应可执行的违约后果。", + "legal_basis_query": "民法典 保密义务 违约责任 赔偿", + "legal_basis": "违反合同保密义务应承担违约责任和损害赔偿责任。", + "source_rule": "LAW-CLAUSE-NDA-BREACH", + "default_position": "建议插入在保密义务或保密期限之后。", + }, + ], +} + +DOCUMENT_TYPE_RULES["technical_service"] = DOCUMENT_TYPE_RULES["service_general"] +DOCUMENT_TYPE_RULES["software_development"] = [ + *DOCUMENT_TYPE_RULES["service_general"], + { + "clause_key": "maintenance_support", + "clause_title": "维护支持", + "clause_type": "maintenance", + "priority": "medium", + "keywords": ["维护", "运维", "售后支持", "maintenance", "support"], + "anchor_keywords": ["验收", "交付物", "知识产权"], + "suggested_text": "维护支持:软件验收后,乙方应在约定期间内提供缺陷修复、技术支持和必要的系统维护服务,响应时间和服务范围以附件约定为准。", + "reason": "软件开发成果上线后通常需要维护支持,缺失该条款会影响后续稳定运行。", + "legal_basis_query": "软件开发合同 维护 支持 验收", + "legal_basis": "软件开发类合同应明确验收后的维护支持和缺陷修复责任。", + "source_rule": "LAW-CLAUSE-SOFTWARE-MAINTENANCE", + "default_position": "建议插入在验收和知识产权条款之后。", + }, +] + + +class ClauseRecommendationService: + def __init__(self, db: Optional[Session], legal_article_service: Optional[LegalArticleService] = None): + self.db = db + self.legal_article_service = legal_article_service or (LegalArticleService(db) if db is not None else None) + self.prompt_runtime_service = PromptRuntimeService(db) + + def recommend_missing_clauses( + self, + current_user: dict[str, Any], + *, + contract_text: str, + document_type_code: Optional[str] = None, + contract_type: Optional[str] = None, + contract_id: Optional[str] = None, + max_items: int = 8, + ) -> dict[str, Any]: + self._require_authenticated(current_user) + normalized_text = self._required_contract_text(contract_text) + normalized_document_type = self._normalize_document_type(document_type_code, contract_type) + prompt_runtime = self._resolve_prompt_runtime( + current_user, + "clause_recommendation", + [ + f"clause_recommendation_{normalized_document_type}", + "clause_recommendation_default", + ], + "clause_recommendation.rule_based.v1", + ) + rules = self._rules_for_document_type(normalized_document_type) + max_count = self._normalize_max_items(max_items) + + detected_keys: list[str] = [] + recommendations: list[dict[str, Any]] = [] + for rule in rules: + if self._rule_is_present(normalized_text, rule): + detected_keys.append(rule["clause_key"]) + continue + legal_article_refs = self._search_legal_articles(current_user, rule) + recommendations.append( + self._build_recommendation_item( + contract_text=normalized_text, + document_type_code=normalized_document_type, + rule=rule, + legal_article_refs=legal_article_refs, + ) + ) + if len(recommendations) >= max_count: + break + + missing_keys = [item["clause_key"] for item in recommendations] + result = { + "contract_id": contract_id, + "document_type_code": normalized_document_type, + "contract_type": contract_type, + "prompt_runtime": self.prompt_runtime_service.to_trace(prompt_runtime), + "items": recommendations, + "total": len(recommendations), + "summary": { + "recommended_count": len(recommendations), + "detected_clause_keys": detected_keys, + "missing_clause_keys": missing_keys, + "source_count": sum(len(item.get("legal_article_refs") or []) for item in recommendations), + "strategy": "rule_based_required_clause_gap", + }, + "sources": [ + "document_type_required_clause_list", + "standard_clause_library", + "legal_articles", + "review_rules", + ], + } + self.prompt_runtime_service.record_run_link( + current_user, + prompt_runtime, + run_type="clause_recommendation", + run_id=contract_id or f"clause-rec-{uuid4().hex[:12]}", + resource_type="generated_contract" if contract_id else "contract_text", + resource_id=contract_id, + trace={ + "document_type_code": normalized_document_type, + "contract_type": contract_type, + "recommended_count": len(recommendations), + "missing_clause_keys": missing_keys, + "text_length": len(normalized_text), + }, + ) + return result + + def rewrite_clause_wording( + self, + current_user: dict[str, Any], + *, + clause_text: str, + rewrite_type: str = "professional", + document_type_code: Optional[str] = None, + contract_type: Optional[str] = None, + contract_id: Optional[str] = None, + clause_id: Optional[str] = None, + context_text: Optional[str] = None, + ) -> dict[str, Any]: + self._require_authenticated(current_user) + normalized_text = self._required_clause_text(clause_text) + normalized_type = self._normalize_rewrite_type(rewrite_type) + normalized_document_type = self._normalize_document_type(document_type_code, contract_type) + prompt_runtime = self._resolve_prompt_runtime( + current_user, + "wording_rewrite", + [ + f"wording_rewrite_{normalized_document_type}", + "wording_rewrite_default", + ], + "wording_rewrite.rule_based.v1", + ) + rewritten_text, changes = self._rewrite_text(normalized_text, normalized_type) + risk_notes = self._build_rewrite_risk_notes( + normalized_text, + rewritten_text, + normalized_type, + context_text=context_text, + ) + legal_article_refs = self._search_legal_articles( + current_user, + { + "clause_title": "措辞优化", + "legal_basis_query": self._build_rewrite_legal_query(normalized_text, rewritten_text), + "legal_basis": "合同措辞优化应遵循诚实信用、全面履行、违约责任和证据留存等基本规则。", + }, + ) + meta = REWRITE_TYPE_META[normalized_type] + result = { + "contract_id": contract_id, + "clause_id": clause_id, + "document_type_code": normalized_document_type, + "contract_type": contract_type, + "prompt_runtime": self.prompt_runtime_service.to_trace(prompt_runtime), + "rewrite_type": normalized_type, + "rewrite_type_label": meta["label"], + "original_text": normalized_text, + "rewritten_text": rewritten_text, + "changes": changes, + "legal_boundary": meta["legal_boundary"], + "negotiation_advice": meta["negotiation_advice"], + "risk_notes": risk_notes, + "legal_article_refs": legal_article_refs, + "confidence": "high" if changes else "medium", + "source_types": [ + "wording_rewrite_rules", + "legal_boundary_check", + "legal_articles" if legal_article_refs else "legal_basis_fallback", + ], + "summary": { + "changed": rewritten_text != normalized_text, + "change_count": len(changes), + "risk_note_count": len(risk_notes), + "source_count": len(legal_article_refs), + "strategy": "rule_based_clause_wording_rewrite", + }, + } + self.prompt_runtime_service.record_run_link( + current_user, + prompt_runtime, + run_type="wording_rewrite", + run_id=clause_id or contract_id or f"wording-rewrite-{uuid4().hex[:12]}", + resource_type="clause" if clause_id else "generated_contract" if contract_id else "contract_text", + resource_id=clause_id or contract_id, + trace={ + "document_type_code": normalized_document_type, + "contract_type": contract_type, + "rewrite_type": normalized_type, + "change_count": len(changes), + "risk_note_count": len(risk_notes), + "text_length": len(normalized_text), + }, + ) + return result + + def _resolve_prompt_runtime( + self, + current_user: dict[str, Any], + prompt_type: str, + preferred_keys: list[str], + fallback_prompt_version: str, + ) -> dict[str, Any]: + return self.prompt_runtime_service.resolve_published_prompt( + current_user, + prompt_type, + preferred_keys=preferred_keys, + fallback_prompt_version=fallback_prompt_version, + ) + + @classmethod + def _build_recommendation_item( + cls, + *, + contract_text: str, + document_type_code: str, + rule: dict[str, Any], + legal_article_refs: list[dict[str, Any]], + ) -> dict[str, Any]: + insertion_position = cls._resolve_insertion_position(contract_text, rule) + return { + "id": f"clause-{document_type_code}-{rule['clause_key']}", + "document_type_code": document_type_code, + "clause_key": rule["clause_key"], + "clause_title": rule["clause_title"], + "clause_type": rule["clause_type"], + "priority": rule.get("priority", "medium"), + "confidence": "high" if legal_article_refs else "medium", + "insert_position": insertion_position, + "suggested_text": rule["suggested_text"], + "reason": rule["reason"], + "legal_basis": cls._build_legal_basis(rule, legal_article_refs), + "legal_article_refs": legal_article_refs, + "source_rule": rule["source_rule"], + "source_types": [ + "document_type_required_clause_list", + "standard_clause_library", + "legal_articles" if legal_article_refs else "legal_basis_fallback", + "review_rules", + ], + "detected_keywords": [], + } + + @staticmethod + def _build_legal_basis(rule: dict[str, Any], legal_article_refs: list[dict[str, Any]]) -> str: + if not legal_article_refs: + return str(rule.get("legal_basis") or "") + labels: list[str] = [] + for ref in legal_article_refs[:3]: + label = " ".join( + part + for part in [ + str(ref.get("source_title") or "").strip(), + str(ref.get("article_no") or "").strip(), + ] + if part + ) + if label and label not in labels: + labels.append(label) + if not labels: + return str(rule.get("legal_basis") or "") + return f"{rule.get('legal_basis') or '建议结合法条来源核对。'} 关联依据:{';'.join(labels)}。" + + @classmethod + def _resolve_insertion_position(cls, contract_text: str, rule: dict[str, Any]) -> dict[str, Any]: + anchor = cls._find_anchor_line(contract_text, rule.get("anchor_keywords") or []) + if anchor: + return { + "strategy": "after_anchor_clause", + "anchor_text": anchor, + "description": f"建议插入在包含“{anchor[:32]}”的相关条款之后。", + } + signature_anchor = cls._find_anchor_line(contract_text, ["签署", "盖章", "甲方", "乙方"]) + if signature_anchor: + return { + "strategy": "before_signature", + "anchor_text": signature_anchor, + "description": "建议插入在签署页或双方盖章信息之前。", + } + return { + "strategy": "append_before_end", + "anchor_text": None, + "description": str(rule.get("default_position") or "建议插入在合同正文尾部、签署页之前。"), + } + + @staticmethod + def _find_anchor_line(contract_text: str, keywords: list[str]) -> Optional[str]: + lines = [line.strip() for line in str(contract_text or "").splitlines() if line.strip()] + normalized_keywords = [keyword.lower() for keyword in keywords if str(keyword or "").strip()] + for line in reversed(lines): + lowered = line.lower() + if any(keyword in lowered for keyword in normalized_keywords): + return re.sub(r"\s+", " ", line)[:120] + return None + + @classmethod + def _rewrite_text(cls, clause_text: str, rewrite_type: str) -> tuple[str, list[dict[str, str]]]: + normalized = cls._normalize_clause_text(clause_text) + replaced_text, changes = cls._apply_rewrite_replacements(normalized) + if cls._looks_like_delivery_clause(normalized): + return cls._rewrite_delivery_clause(replaced_text, rewrite_type, changes) + if cls._looks_like_payment_clause(normalized): + return cls._rewrite_payment_clause(replaced_text, rewrite_type, changes) + + if rewrite_type == "professional": + rewritten = ( + f"双方确认,{cls._strip_sentence_end(replaced_text)};本条涉及的履行条件、期限、" + "验收标准、通知方式和责任承担,应以本合同及双方书面确认文件为准。" + ) + elif rewrite_type == "simplify": + rewritten = ( + f"本条款可简化理解为:双方应按照约定完成相关义务,并保留书面或可追溯的" + f"电子确认记录。原核心内容为:{cls._strip_sentence_end(replaced_text)}。" + ) + elif rewrite_type == "party_a": + rewritten = ( + "乙方应严格按照本合同及甲方合理要求履行本条义务;乙方未按约履行或履行" + "不符合约定的,甲方有权要求限期整改、暂停相应付款、解除合同并要求乙方" + "赔偿因此遭受的损失。" + ) + elif rewrite_type == "party_b": + rewritten = ( + "乙方在甲方按约提供必要资料、确认意见、场地、账号或付款等配合条件后履行" + "本条义务;因甲方原因、第三方原因或不可抗力导致迟延的,履行期限相应顺延," + "乙方不承担由此产生的迟延责任。" + ) + else: + rewritten = ( + "双方应按照本合同约定履行本条义务;一方履行需要另一方提供资料、确认或协助的," + "另一方应在合理期限内配合。任何一方违反约定造成对方损失的,应依法或依约承担责任。" + ) + + if not changes: + changes = [ + { + "from": normalized, + "to": rewritten, + "reason": "按目标立场补足履行条件、期限、证据和责任边界。", + } + ] + return cls._normalize_clause_text(rewritten), changes + + @classmethod + def _rewrite_delivery_clause( + cls, + replaced_text: str, + rewrite_type: str, + changes: list[dict[str, str]], + ) -> tuple[str, list[dict[str, str]]]: + if rewrite_type == "professional": + rewritten = ( + "交付安排:乙方应在合同签订后若干工作日内完成交付,交付内容、地点、方式和验收资料" + "以本合同、订单或附件约定为准;乙方交付前应向甲方提供必要的交付清单和验收文件。" + ) + elif rewrite_type == "simplify": + rewritten = "简化说明:交付时间、交付地点、交付内容和验收资料都要写清楚,不能只写“尽快交付”。" + elif rewrite_type == "party_a": + rewritten = ( + "交付安排:乙方应在合同签订后若干工作日内一次性完成符合约定标准的交付;" + "乙方逾期交付、交付不完整或交付不符合约定的,甲方有权拒收、要求限期整改、" + "顺延付款并要求乙方承担违约责任。" + ) + elif rewrite_type == "party_b": + rewritten = ( + "交付安排:乙方应在甲方完成必要资料、场地、账号、接口、预付款或书面确认等配合条件后" + "若干工作日内完成交付;因甲方未及时配合、需求变更或第三方原因导致延期的,交付期限相应顺延。" + ) + else: + rewritten = ( + "交付安排:乙方应在合同签订且甲方完成必要配合后若干工作日内完成交付;" + "交付内容、地点、方式和验收资料以本合同或附件为准。因一方原因导致延期的," + "责任方应承担相应责任;双方确认变更交付期限的,应采用书面或可追溯电子方式。" + ) + return cls._rewrite_result(replaced_text, rewritten, changes, "围绕交付期限、配合条件、验收资料和违约后果重写。") + + @classmethod + def _rewrite_payment_clause( + cls, + replaced_text: str, + rewrite_type: str, + changes: list[dict[str, str]], + ) -> tuple[str, list[dict[str, str]]]: + if rewrite_type == "professional": + rewritten = ( + "付款安排:甲方应在收到乙方合格发票、付款申请及经确认的验收文件后若干工作日内支付" + "对应款项;付款金额、账户信息和税费承担以本合同及附件约定为准。" + ) + elif rewrite_type == "simplify": + rewritten = "简化说明:付款应写清楚触发条件、付款期限、发票要求、收款账户和逾期后果。" + elif rewrite_type == "party_a": + rewritten = ( + "付款安排:甲方在乙方完成符合约定的交付或服务、通过验收并提交合格发票及付款申请后" + "若干工作日内支付对应款项;乙方交付不合格或资料不完整的,甲方有权暂缓支付相应款项。" + ) + elif rewrite_type == "party_b": + rewritten = ( + "付款安排:甲方应在收到乙方付款申请及合格发票后若干工作日内支付到期款项;" + "甲方逾期付款的,应按逾期未付款金额承担违约责任,且乙方有权暂停后续履行。" + ) + else: + rewritten = ( + "付款安排:甲方应在乙方完成约定交付或服务、提交合格发票及付款申请后若干工作日内" + "支付对应款项;如对交付或服务存在合理异议,甲方应在验收期限内书面说明,双方应及时核对并处理。" + ) + return cls._rewrite_result(replaced_text, rewritten, changes, "围绕付款触发条件、发票、验收和逾期责任重写。") + + @staticmethod + def _rewrite_result( + original_after_replacement: str, + rewritten: str, + changes: list[dict[str, str]], + reason: str, + ) -> tuple[str, list[dict[str, str]]]: + normalized = ClauseRecommendationService._normalize_clause_text(rewritten) + if not changes or normalized != original_after_replacement: + changes = [ + *changes, + { + "from": original_after_replacement, + "to": normalized, + "reason": reason, + }, + ] + return normalized, changes + + @staticmethod + def _apply_rewrite_replacements(clause_text: str) -> tuple[str, list[dict[str, str]]]: + rewritten = clause_text + changes: list[dict[str, str]] = [] + for replacement in REWRITE_PHRASE_REPLACEMENTS: + source = replacement["from"] + if source in rewritten: + target = replacement["to"] + rewritten = rewritten.replace(source, target) + changes.append( + { + "from": source, + "to": target, + "reason": replacement["reason"], + } + ) + return rewritten, changes + + @staticmethod + def _build_rewrite_risk_notes( + original_text: str, + rewritten_text: str, + rewrite_type: str, + *, + context_text: Optional[str], + ) -> list[str]: + notes = ["请将“若干工作日”“合理期限”等占位表述替换为双方确认的具体数字或客观标准。"] + combined = f"{original_text}\n{rewritten_text}\n{context_text or ''}" + if any(keyword in combined for keyword in ["违约金", "赔偿", "责任"]): + notes.append("违约金、赔偿范围和责任上限需要与合同金额、可预见损失和交易风险相匹配。") + if any(keyword in combined for keyword in ["解除", "终止"]): + notes.append("解除或终止权应写清触发条件、通知方式、宽限期和终止后的结算交接。") + if rewrite_type == "party_a": + notes.append("偏甲方版本应避免无限扩大乙方责任,否则可能影响谈判接受度和条款公平性。") + if rewrite_type == "party_b": + notes.append("偏乙方版本应避免把核心履约责任全部转为甲方前置条件,否则可能被甲方拒绝。") + return notes + + @staticmethod + def _build_rewrite_legal_query(original_text: str, rewritten_text: str) -> str: + combined = f"{original_text} {rewritten_text}" + query_parts = ["民法典 合同履行 诚实信用 违约责任"] + if any(keyword in combined for keyword in ["交付", "交货", "验收"]): + query_parts.append("交付 验收") + if any(keyword in combined for keyword in ["付款", "支付", "发票"]): + query_parts.append("付款 发票") + if any(keyword in combined for keyword in ["保密", "商业秘密"]): + query_parts.append("保密 商业秘密") + if any(keyword in combined for keyword in ["解除", "终止"]): + query_parts.append("解除 终止") + return " ".join(query_parts) + + @staticmethod + def _looks_like_delivery_clause(value: str) -> bool: + return any(keyword in value for keyword in ["交付", "交货", "交付物", "delivery"]) + + @staticmethod + def _looks_like_payment_clause(value: str) -> bool: + return any(keyword in value for keyword in ["付款", "支付", "款项", "payment"]) + + @staticmethod + def _normalize_clause_text(value: str) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()) + + @staticmethod + def _strip_sentence_end(value: str) -> str: + return str(value or "").strip().rstrip("。;; ") + + @staticmethod + def _normalize_rewrite_type(value: str) -> str: + raw = str(value or "professional").strip().lower().replace("-", "_") + return REWRITE_TYPE_ALIASES.get(raw, "professional") + + @staticmethod + def _required_clause_text(value: str) -> str: + text = ClauseRecommendationService._normalize_clause_text(value) + if not text: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clause_text is required") + if len(text) > 5000: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clause_text is too long") + return text + + def _search_legal_articles(self, current_user: dict[str, Any], rule: dict[str, Any]) -> list[dict[str, Any]]: + if self.legal_article_service is None: + return [] + query = str(rule.get("legal_basis_query") or rule.get("clause_title") or "").strip() + if not query: + return [] + try: + raw_items = self.legal_article_service.search_runtime_sources(current_user, query, limit=3) + except Exception: + return [] + refs: list[dict[str, Any]] = [] + for item in raw_items or []: + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + refs.append( + { + "id": item.get("source_id"), + "source_title": metadata.get("source_title") or item.get("title"), + "article_no": item.get("article_no"), + "authority": item.get("authority"), + "article_excerpt": item.get("content"), + "detail_path": item.get("detail_path"), + "match_reason": metadata.get("match_reason"), + } + ) + return refs + + @staticmethod + def _rule_is_present(contract_text: str, rule: dict[str, Any]) -> bool: + lowered = contract_text.lower() + return any(str(keyword or "").lower() in lowered for keyword in rule.get("keywords") or []) + + @staticmethod + def _rules_for_document_type(document_type_code: str) -> list[dict[str, Any]]: + rules = DOCUMENT_TYPE_RULES.get(document_type_code) + if rules: + return rules + return GENERAL_RULES + + @staticmethod + def _normalize_document_type(document_type_code: Optional[str], contract_type: Optional[str]) -> str: + raw = (document_type_code or contract_type or "general").strip().lower().replace("-", "_") + if raw in DOCUMENT_TYPE_RULES: + return raw + return DOCUMENT_TYPE_ALIASES.get(raw, raw or "general") + + @staticmethod + def _normalize_max_items(value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = 8 + return max(1, min(parsed, 20)) + + @staticmethod + def _required_contract_text(value: str) -> str: + text = str(value or "").strip() + if not text: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="contract_text is required") + if len(text) < 20: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="contract_text is too short") + return text + + @staticmethod + def _require_authenticated(current_user: dict[str, Any]) -> None: + if not current_user or not current_user.get("id") or not current_user.get("tenant_id"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index e4e2317c..f6dce8b9 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -2,6 +2,7 @@ import json import re import io import base64 +from html import escape from typing import Any, Dict, List, Optional, Tuple from datetime import datetime from uuid import uuid4 @@ -12,9 +13,14 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from app.models.generated_contract import GeneratedContract +from app.models.generated_contract_version import GeneratedContractVersion 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 @@ -26,18 +32,36 @@ class ExportFormat(str, Enum): 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": ["股权转让", "公司法", "价款", "交割"], +} + + 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.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]: @@ -45,13 +69,20 @@ class ContractGenerationService: tenant_id = current_user["tenant_id"] # 获取启用的模板 - templates = self.repository.list_enabled(tenant_id, contract_type, template_category, industry_category) + templates = self.repository.list_enabled( + tenant_id=tenant_id, + contract_type=contract_type, + template_category=template_category, + industry_category=industry_category, + document_type_code=document_type_code, + ) 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, @@ -68,6 +99,17 @@ class ContractGenerationService: 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"] @@ -89,6 +131,8 @@ class ContractGenerationService: # 解析模板变量 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) @@ -116,12 +160,44 @@ class ContractGenerationService: 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]}", @@ -129,6 +205,7 @@ class ContractGenerationService: 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, @@ -138,23 +215,121 @@ class ContractGenerationService: 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, keyword: Optional[str] = None, ) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] @@ -163,11 +338,13 @@ class ContractGenerationService: page=page, page_size=page_size, contract_type=contract_type, + document_type_code=document_type_code, keyword=keyword, ) total = self.generated_contract_repository.count( tenant_id=tenant_id, contract_type=contract_type, + document_type_code=document_type_code, keyword=keyword, ) return { @@ -185,6 +362,88 @@ class ContractGenerationService: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问") return self._to_generated_contract_item(generated_contract) + 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 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 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) @@ -250,6 +509,11 @@ class ContractGenerationService: "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, } @@ -293,6 +557,12 @@ class ContractGenerationService: 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") @@ -350,6 +620,7 @@ class ContractGenerationService: "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]: @@ -494,16 +765,42 @@ class ContractGenerationService: 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, - "template_category": (generated_contract.export_options_json or {}).get("_template_category"), - "industry_category": (generated_contract.export_options_json or {}).get("_industry_category"), + "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, @@ -517,12 +814,24 @@ class ContractGenerationService: "generated_by_name": generated_contract.generated_by_name, "tenant_id": generated_contract.tenant_id, "original_template": generated_contract.template_name, - "template_category": (generated_contract.export_options_json or {}).get("_template_category"), - "industry_category": (generated_contract.export_options_json or {}).get("_industry_category"), - "template_version_label": (generated_contract.export_options_json or {}).get("_template_version_label"), + "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 (generated_contract.export_options_json or {}).items() + for key, value in export_options.items() if not str(key).startswith("_") }, "variables_used": len(generated_contract.variable_values_json or {}), @@ -536,6 +845,7 @@ class ContractGenerationService: "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"], @@ -546,14 +856,331 @@ class ContractGenerationService: "preview_url": item["preview_url"], "download_url": item["download_url"], "auto_review": item["auto_review"], + "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}", + } + + @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"]): + 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"

{escape(part.strip())}

" for part in str(content or "").splitlines() if part.strip()) + if not body: + body = "

" + hint_items = "".join(f"
  • {escape(str(item.get('message') or ''))}
  • " for item in ai_hints) + missing_items = "".join( + f"
  • {escape(str(item.get('label') or item.get('field_key') or ''))}: {escape(str(item.get('placeholder') or MISSING_VALUE_PLACEHOLDER))}
  • " + for item in missing_fields + ) + source_items = "".join( + f"
  • {escape(str(item.get('title') or ''))}
  • " + for item in related_legal_articles + ) + return ( + "
    " + f"
    {body}
    " + f"

    AI提醒

    " + f"

    待补字段

    " + f"

    相关法条

    " + "
    " + ) + + @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"]) + amount = cls._first( + flattened, + [ + "price.total", + "amount", + "contract_amount", + "service_fee", + "fee.total", + "salary.monthly", + "rent.monthly", + "transfer.price", + ], + ) + return { + "party_a": party_a, + "party_a_name": party_a, + "buyer": party_a, + "buyer_name": party_a, + "employer_name": party_a, + "party_b": party_b, + "party_b_name": party_b, + "supplier": party_b, + "supplier_name": party_b, + "employee_name": party_b, + "product_name": cls._first(flattened, ["goods.name", "product_name", "service.name", "project.name"]), + "quantity": cls._first(flattened, ["goods.quantity", "quantity"]), + "amount": amount, + "contract_amount": amount, + "service_fee": amount, + "delivery_location": cls._first(flattened, ["delivery.place", "delivery_location", "work_location", "property.address"]), + "delivery_date": cls._first(flattened, ["delivery.date", "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"]), + "sign_date": cls._first(flattened, ["sign_date", "created_date"]) or datetime.now().date().isoformat(), + } + + @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: @@ -620,6 +1247,18 @@ class ContractGenerationService: 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 diff --git a/backend/app/services/legal_article_service.py b/backend/app/services/legal_article_service.py index 99355010..6f16028b 100644 --- a/backend/app/services/legal_article_service.py +++ b/backend/app/services/legal_article_service.py @@ -100,12 +100,33 @@ class LegalArticleService: article = self.repository.get_by_id(article_id, current_user["tenant_id"]) return self._to_dict(article) if article is not None else None - def bulk_upsert_articles(self, current_user: dict, articles: list[dict[str, Any]]) -> dict: + def bulk_upsert_articles( + self, + current_user: dict, + articles: list[dict[str, Any]], + *, + enqueue_impact_scan: bool = True, + max_impact_scan_events: int = 50, + ) -> dict: self._require_admin(current_user) created: list[dict] = [] updated: list[dict] = [] + impact_scan_events: list[dict] = [] + impact_scan_events_skipped = 0 + normalized_max_events = max(0, min(max_impact_scan_events, 500)) for payload in articles: - result, was_created = self.upsert_article(current_user, payload) + should_enqueue = enqueue_impact_scan and len(impact_scan_events) < normalized_max_events + result, was_created = self.upsert_article( + current_user, + payload, + enqueue_impact_scan=should_enqueue, + ) + event = result.pop("_impact_scan_event", None) + skipped = bool(result.pop("_impact_scan_skipped", False)) + if event: + impact_scan_events.append(event) + if skipped: + impact_scan_events_skipped += 1 if was_created: created.append(result) else: @@ -116,9 +137,18 @@ class LegalArticleService: "total": len(created) + len(updated), "created": created, "updated": updated, + "impact_scan_events_enqueued": len(impact_scan_events), + "impact_scan_events_skipped": impact_scan_events_skipped, + "impact_scan_events": impact_scan_events, } - def upsert_article(self, current_user: dict, payload: dict[str, Any]) -> tuple[dict, bool]: + def upsert_article( + self, + current_user: dict, + payload: dict[str, Any], + *, + enqueue_impact_scan: bool = True, + ) -> tuple[dict, bool]: self._require_admin(current_user) tenant_id = current_user["tenant_id"] source_id = self._required_text(payload, "source_id") @@ -154,8 +184,25 @@ class LegalArticleService: published_by=self._optional_text(payload.get("published_by")) or current_user.get("id"), ) created = self.repository.create(article) - return self._to_dict(created), True + result = self._to_dict(created) + self._attach_impact_scan_event( + current_user, + result, + created, + change_type="created", + change_summary="新增法条后自动进入法规影响待扫描队列", + previous_article_text=None, + previous_text_checksum=None, + enqueue_impact_scan=enqueue_impact_scan, + ) + return result, True + previous_article_text = existing.article_text + previous_text_checksum = existing.text_checksum + previous_effective_date = existing.effective_date + previous_validity_status = existing.validity_status + previous_published_status = existing.published_status + previous_tags = list(existing.tags_json or []) existing.source_title = self._required_text(payload, "source_title") existing.source_type = self._text_or_default(payload.get("source_type"), existing.source_type) existing.authority = self._text_or_default(payload.get("authority"), existing.authority) @@ -176,7 +223,28 @@ class LegalArticleService: existing.published_at = self._parse_datetime(payload.get("published_at")) existing.published_by = self._optional_text(payload.get("published_by")) or existing.published_by or current_user.get("id") updated = self.repository.update(existing) - return self._to_dict(updated), False + result = self._to_dict(updated) + change_type = self._detect_change_type( + previous_article_text=previous_article_text, + previous_text_checksum=previous_text_checksum, + previous_effective_date=previous_effective_date, + previous_validity_status=previous_validity_status, + previous_published_status=previous_published_status, + previous_tags=previous_tags, + updated_article=updated, + ) + if change_type: + self._attach_impact_scan_event( + current_user, + result, + updated, + change_type=change_type, + change_summary=self._build_change_summary(change_type, updated), + previous_article_text=previous_article_text, + previous_text_checksum=previous_text_checksum, + enqueue_impact_scan=enqueue_impact_scan, + ) + return result, False def update_index_status( self, @@ -192,6 +260,7 @@ class LegalArticleService: article = self.repository.get_by_id(article_id, current_user["tenant_id"]) if article is None: return None + previous_published_status = article.published_status article.index_status = self._text_or_default(index_status, article.index_status) article.knowledge_base_id = self._optional_text(knowledge_base_id) or article.knowledge_base_id article.knowledge_document_id = self._optional_text(knowledge_document_id) or article.knowledge_document_id @@ -201,7 +270,21 @@ class LegalArticleService: article.published_at = datetime.now(timezone.utc) article.published_by = current_user.get("id") updated = self.repository.update(article) - return self._to_dict(updated) + result = self._to_dict(updated) + if previous_published_status != updated.published_status: + self._attach_impact_scan_event( + current_user, + result, + updated, + change_type="effective_changed", + change_summary=f"法条发布状态由 {previous_published_status} 变更为 {updated.published_status}", + previous_article_text=updated.article_text, + previous_text_checksum=updated.text_checksum, + enqueue_impact_scan=True, + ) + result.pop("_impact_scan_event", None) + result.pop("_impact_scan_skipped", None) + return result def search_runtime_sources(self, current_user: dict, query: str, limit: int = 5) -> list[dict]: tenant_id = current_user.get("tenant_id") @@ -255,6 +338,62 @@ class LegalArticleService: for score, article, reason in scored[:normalized_limit] ] + def _attach_impact_scan_event( + self, + current_user: dict, + result: dict, + article: LegalArticle, + *, + change_type: str, + change_summary: str, + previous_article_text: Optional[str], + previous_text_checksum: Optional[str], + enqueue_impact_scan: bool, + ) -> None: + if not enqueue_impact_scan: + result["_impact_scan_skipped"] = True + return + from app.services.legal_change_impact_service import LegalChangeImpactService + + event_result = LegalChangeImpactService(self.db).enqueue_article_change( + current_user, + article_id=article.id, + change_type=change_type, + change_summary=change_summary, + previous_article_text=previous_article_text, + previous_text_checksum=previous_text_checksum, + ) + result["_impact_scan_event"] = event_result["event"] + + @staticmethod + def _detect_change_type( + *, + previous_article_text: Optional[str], + previous_text_checksum: Optional[str], + previous_effective_date: Optional[str], + previous_validity_status: Optional[str], + previous_published_status: Optional[str], + previous_tags: list[str], + updated_article: LegalArticle, + ) -> Optional[str]: + if previous_text_checksum != updated_article.text_checksum or previous_article_text != updated_article.article_text: + return "updated" + if ( + previous_effective_date != updated_article.effective_date + or previous_validity_status != updated_article.validity_status + or previous_published_status != updated_article.published_status + ): + return "effective_changed" + if sorted(previous_tags) != sorted(updated_article.tags_json or []): + return "updated" + return None + + @staticmethod + def _build_change_summary(change_type: str, article: LegalArticle) -> str: + if change_type == "effective_changed": + return f"{article.source_title}{article.article_no} 生效/发布状态发生变化,已进入法规影响待扫描队列" + return f"{article.source_title}{article.article_no} 内容或标签发生变化,已进入法规影响待扫描队列" + @staticmethod def _require_admin(current_user: dict) -> None: if current_user.get("role") != "admin": diff --git a/backend/app/services/legal_change_impact_service.py b/backend/app/services/legal_change_impact_service.py new file mode 100644 index 00000000..88a6e14d --- /dev/null +++ b/backend/app/services/legal_change_impact_service.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Iterable, Optional + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.contract_template import ContractTemplate +from app.models.generated_contract import GeneratedContract +from app.models.legal_article import LegalArticle +from app.models.legal_change_event import LegalChangeEvent +from app.models.legal_change_impact import LegalChangeImpact +from app.models.review_rule import ReviewRule +from app.repositories.legal_article_repository import LegalArticleRepository +from app.repositories.legal_change_impact_repository import LegalChangeImpactRepository + + +VALID_CHANGE_TYPES = {"created", "updated", "repealed", "effective_changed"} +VALID_IMPACT_STATUS = {"open", "in_progress", "resolved", "ignored"} + + +class LegalChangeImpactService: + def __init__(self, db: Session): + self.db = db + self.repository = LegalChangeImpactRepository(db) + self.legal_article_repository = LegalArticleRepository(db) + + def list_events( + self, + current_user: dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + scan_status: Optional[str] = None, + source_id: Optional[str] = None, + ) -> dict[str, Any]: + self._require_admin(current_user) + safe_page = max(1, page) + safe_page_size = max(1, min(page_size, 100)) + events, total = self.repository.list_events( + current_user["tenant_id"], + safe_page, + safe_page_size, + scan_status=scan_status if scan_status and scan_status != "all" else None, + source_id=source_id.strip() if source_id else None, + ) + return { + "items": [self._event_to_dict(event) for event in events], + "page": safe_page, + "page_size": safe_page_size, + "total": total, + } + + def get_event_detail(self, current_user: dict[str, Any], event_id: str) -> Optional[dict[str, Any]]: + self._require_admin(current_user) + event = self.repository.get_event_by_id(current_user["tenant_id"], event_id) + if event is None: + return None + impacts = self.repository.list_impacts_by_event(current_user["tenant_id"], event.id) + return { + "event": self._event_to_dict(event), + "impacts": [self._impact_to_dict(impact) for impact in impacts], + } + + def scan_article_change( + self, + current_user: dict[str, Any], + *, + article_id: str, + change_type: str = "updated", + change_summary: Optional[str] = None, + previous_article_text: Optional[str] = None, + previous_text_checksum: Optional[str] = None, + ) -> dict[str, Any]: + queued = self.enqueue_article_change( + current_user, + article_id=article_id, + change_type=change_type, + change_summary=change_summary, + previous_article_text=previous_article_text, + previous_text_checksum=previous_text_checksum, + ) + return self.run_event_scan(current_user, queued["event"]["id"]) + + def enqueue_article_change( + self, + current_user: dict[str, Any], + *, + article_id: str, + change_type: str = "updated", + change_summary: Optional[str] = None, + previous_article_text: Optional[str] = None, + previous_text_checksum: Optional[str] = None, + ) -> dict[str, Any]: + self._require_admin(current_user) + normalized_change_type = (change_type or "updated").strip() + if normalized_change_type not in VALID_CHANGE_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid change_type") + + article = self.legal_article_repository.get_by_id(article_id, current_user["tenant_id"]) + if article is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal article not found") + + event = LegalChangeEvent( + id=self._new_id("lce"), + tenant_id=current_user["tenant_id"], + article_id=article.id, + source_id=article.source_id, + source_title=article.source_title, + article_no=article.article_no, + change_type=normalized_change_type, + change_summary=(change_summary or "").strip() or None, + previous_text_checksum=(previous_text_checksum or "").strip() or None, + current_text_checksum=article.text_checksum, + previous_article_text=(previous_article_text or "").strip() or None, + current_article_text=article.article_text, + effective_date=article.effective_date, + tags_json=article.tags_json or [], + scan_status="pending", + created_by=current_user.get("id"), + created_at=self._now(), + updated_at=self._now(), + ) + event = self.repository.create_event(event) + return { + "event": self._event_to_dict(event), + "impacts": [], + } + + def run_event_scan(self, current_user: dict[str, Any], event_id: str) -> dict[str, Any]: + self._require_admin(current_user) + event = self.repository.get_event_by_id(current_user["tenant_id"], event_id) + if event is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal change event not found") + if event.scan_status == "completed": + impacts = self.repository.list_impacts_by_event(current_user["tenant_id"], event.id) + return { + "event": self._event_to_dict(event), + "impacts": [self._impact_to_dict(impact) for impact in impacts], + } + article = self.legal_article_repository.get_by_id(event.article_id or "", current_user["tenant_id"]) + if article is None: + event.scan_status = "failed" + event.impact_summary_json = {"error": "legal article not found", "total": 0, "blocking_count": 0} + event.updated_at = self._now() + event = self.repository.update_event(event) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal article not found") + + event.scan_status = "running" + event.updated_at = self._now() + event = self.repository.update_event(event) + self.repository.delete_impacts_by_event(current_user["tenant_id"], event.id) + impacts = self._build_impacts_for_article(current_user["tenant_id"], event, article) + self.repository.create_impacts(impacts) + summary = self._build_impact_summary(impacts) + event.scan_status = "completed" + event.impact_summary_json = summary + event.scanned_at = self._now() + event.updated_at = self._now() + event = self.repository.update_event(event) + return { + "event": self._event_to_dict(event), + "impacts": [self._impact_to_dict(impact) for impact in impacts], + } + + def run_pending_scans(self, current_user: dict[str, Any], *, limit: int = 10) -> dict[str, Any]: + self._require_admin(current_user) + events = self.repository.list_pending_events(current_user["tenant_id"], limit=limit) + completed: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + for event in events: + try: + completed.append(self.run_event_scan(current_user, event.id)) + except HTTPException as exc: + failed.append({"event_id": event.id, "status_code": exc.status_code, "message": exc.detail}) + return { + "requested_limit": max(1, min(limit, 50)), + "completed_count": len(completed), + "failed_count": len(failed), + "completed": completed, + "failed": failed, + } + + def update_impact_status( + self, + current_user: dict[str, Any], + impact_id: str, + *, + impact_status: str, + handling_note: Optional[str] = None, + ) -> dict[str, Any]: + self._require_admin(current_user) + normalized_status = impact_status.strip() + if normalized_status not in VALID_IMPACT_STATUS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid impact_status") + impact = self.repository.get_impact_by_id(current_user["tenant_id"], impact_id) + if impact is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal change impact not found") + impact.impact_status = normalized_status + if normalized_status in {"resolved", "ignored"}: + impact.handled_by = current_user.get("id") + impact.handled_at = self._now() + evidence = dict(impact.evidence_json or {}) + if handling_note: + evidence["handling_note"] = handling_note.strip() + impact.evidence_json = evidence + impact.updated_at = self._now() + return self._impact_to_dict(self.repository.update_impact(impact)) + + def _build_impacts_for_article( + self, + tenant_id: str, + event: LegalChangeEvent, + article: LegalArticle, + ) -> list[LegalChangeImpact]: + direct_terms = self._unique_terms([article.source_id, article.source_title, article.article_no]) + tag_terms = self._unique_terms(article.tags_json or []) + article_keywords = self._extract_article_keywords(article.article_text) + impacts: list[LegalChangeImpact] = [] + + templates = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id).all() + for template in templates: + text = self._join_text( + template.name, + template.template_code, + template.contract_type, + template.document_type_code, + template.template_category, + template.industry_category, + template.description, + template.content, + template.variables_json, + ) + matched_direct = self._matched_terms(text, direct_terms) + matched_tags = self._matched_terms(text, tag_terms) + matched_keywords = self._matched_terms(text, article_keywords[:6]) + if matched_direct or matched_tags or matched_keywords: + impact_level = "immediate_update" if matched_direct else "reference_only" + impacts.append( + self._new_impact( + tenant_id, + event, + target_type="contract_template", + target_id=template.id, + target_name=template.name, + target_version_label=template.current_version_label, + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + impact_level=impact_level, + suggestion=self._build_suggestion("contract_template", impact_level, article), + evidence={"template_code": template.template_code, "document_type_code": template.document_type_code}, + ) + ) + + rules = self.db.query(ReviewRule).filter(ReviewRule.tenant_id == tenant_id).all() + for rule in rules: + text = self._join_text(rule.name, rule.rule_code, rule.keyword, rule.description, rule.suggestion, rule.legal_basis, rule.contract_type) + matched_direct = self._matched_terms(text, direct_terms) + matched_tags = self._matched_terms(text, tag_terms) + matched_keywords = self._matched_terms(text, article_keywords[:8]) + if matched_direct or matched_tags or matched_keywords: + impacts.append( + self._new_impact( + tenant_id, + event, + target_type="review_rule", + target_id=rule.id, + target_name=rule.name, + target_version_label=rule.current_version_label, + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + impact_level="immediate_update", + suggestion=self._build_suggestion("review_rule", "immediate_update", article), + evidence={"rule_code": rule.rule_code, "risk_level": rule.risk_level}, + ) + ) + + contracts = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id).all() + for contract in contracts: + text = self._join_text( + contract.template_name, + contract.file_name, + contract.contract_type, + contract.document_type_code, + contract.generated_content, + contract.variable_values_json, + contract.elements_snapshot_json, + contract.export_options_json, + ) + matched_direct = self._matched_terms(text, direct_terms) + matched_tags = self._matched_terms(text, tag_terms) + matched_keywords = self._matched_terms(text, article_keywords[:6]) + if matched_direct or matched_tags or matched_keywords: + impact_level = "next_renewal_update" if matched_direct else "reference_only" + impacts.append( + self._new_impact( + tenant_id, + event, + target_type="generated_contract", + target_id=contract.id, + target_name=contract.file_name, + target_version_label=None, + match_terms=self._unique_terms([*matched_direct, *matched_tags, *matched_keywords]), + match_reason=self._build_match_reason(matched_direct, matched_tags, matched_keywords), + impact_level=impact_level, + suggestion=self._build_suggestion("generated_contract", impact_level, article), + evidence={"template_id": contract.template_id, "document_type_code": contract.document_type_code}, + ) + ) + + return impacts + + def _new_impact( + self, + tenant_id: str, + event: LegalChangeEvent, + *, + target_type: str, + target_id: str, + target_name: str, + target_version_label: Optional[str], + match_terms: list[str], + match_reason: str, + impact_level: str, + suggestion: str, + evidence: dict[str, Any], + ) -> LegalChangeImpact: + now = self._now() + return LegalChangeImpact( + id=self._new_id("lci"), + tenant_id=tenant_id, + change_event_id=event.id, + target_type=target_type, + target_id=target_id, + target_name=target_name, + target_version_label=target_version_label, + match_reason=match_reason, + match_terms_json=match_terms, + impact_level=impact_level, + suggestion=suggestion, + impact_status="open", + evidence_json=evidence, + created_at=now, + updated_at=now, + ) + + @staticmethod + def _build_impact_summary(impacts: list[LegalChangeImpact]) -> dict[str, Any]: + by_level: dict[str, int] = {} + by_target_type: dict[str, int] = {} + for impact in impacts: + by_level[impact.impact_level] = by_level.get(impact.impact_level, 0) + 1 + by_target_type[impact.target_type] = by_target_type.get(impact.target_type, 0) + 1 + return { + "total": len(impacts), + "by_level": by_level, + "by_target_type": by_target_type, + "blocking_count": by_level.get("immediate_update", 0), + } + + @staticmethod + def _build_match_reason(direct_terms: list[str], tag_terms: list[str], keyword_terms: list[str]) -> str: + reasons: list[str] = [] + if direct_terms: + reasons.append("direct_legal_reference") + if tag_terms: + reasons.append("tag_match") + if keyword_terms: + reasons.append("keyword_match") + return ",".join(reasons) or "unknown" + + @staticmethod + def _build_suggestion(target_type: str, impact_level: str, article: LegalArticle) -> str: + article_label = f"{article.source_title}{article.article_no}" + if target_type == "review_rule": + return f"请立即复核该审查规则的法律依据、触发条件和整改建议是否仍符合 {article_label}。" + if target_type == "contract_template": + if impact_level == "immediate_update": + return f"请立即检查模板中引用或依赖 {article_label} 的条款,并发布新模板版本。" + return f"模板与 {article_label} 的标签或关键词相关,建议在下一次模板治理时复核。" + if impact_level == "next_renewal_update": + return f"历史合同疑似引用 {article_label},建议在续签、补充协议或重新起草时同步更新。" + return f"该历史合同与 {article_label} 的标签或关键词相关,保留为参考提示。" + + @staticmethod + def _extract_article_keywords(text: Optional[str]) -> list[str]: + if not text: + return [] + chunks = re.split(r"[\s,。;;、,.()()\[\]《》::]+", text) + stop_words = {"的", "和", "或者", "以及", "应当", "可以", "不得", "有关", "规定", "进行", "按照"} + keywords = [] + for chunk in chunks: + value = chunk.strip() + if len(value) < 2 or value in stop_words: + continue + if value not in keywords: + keywords.append(value) + if len(keywords) >= 12: + break + return keywords + + @staticmethod + def _matched_terms(text: str, terms: Iterable[str]) -> list[str]: + if not text: + return [] + matches: list[str] = [] + lowered_text = text.lower() + for term in terms: + normalized = str(term).strip() + if not normalized: + continue + if normalized.lower() in lowered_text and normalized not in matches: + matches.append(normalized) + return matches + + @staticmethod + def _join_text(*values: Any) -> str: + parts: list[str] = [] + for value in values: + if value is None: + continue + if isinstance(value, (dict, list, tuple)): + parts.append(str(value)) + else: + parts.append(str(value)) + return "\n".join(parts) + + @staticmethod + def _unique_terms(values: Iterable[Any]) -> list[str]: + terms: list[str] = [] + for value in values: + text = str(value).strip() + if not text or text in terms: + continue + terms.append(text) + return terms + + @staticmethod + def _require_admin(current_user: dict[str, Any]) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + @staticmethod + def _new_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:24]}" + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + @classmethod + def _event_to_dict(cls, event: LegalChangeEvent) -> dict[str, Any]: + return { + "id": event.id, + "tenant_id": event.tenant_id, + "article_id": event.article_id, + "source_id": event.source_id, + "source_title": event.source_title, + "article_no": event.article_no, + "change_type": event.change_type, + "change_summary": event.change_summary, + "previous_text_checksum": event.previous_text_checksum, + "current_text_checksum": event.current_text_checksum, + "previous_article_text": event.previous_article_text, + "current_article_text": event.current_article_text, + "effective_date": event.effective_date, + "tags": event.tags_json or [], + "scan_status": event.scan_status, + "impact_summary": event.impact_summary_json or {}, + "created_by": event.created_by, + "created_at": event.created_at.isoformat() if event.created_at else None, + "scanned_at": event.scanned_at.isoformat() if event.scanned_at else None, + "updated_at": event.updated_at.isoformat() if event.updated_at else None, + } + + @classmethod + def _impact_to_dict(cls, impact: LegalChangeImpact) -> dict[str, Any]: + return { + "id": impact.id, + "tenant_id": impact.tenant_id, + "change_event_id": impact.change_event_id, + "target_type": impact.target_type, + "target_id": impact.target_id, + "target_name": impact.target_name, + "target_version_label": impact.target_version_label, + "match_reason": impact.match_reason, + "match_terms": impact.match_terms_json or [], + "impact_level": impact.impact_level, + "suggestion": impact.suggestion, + "impact_status": impact.impact_status, + "evidence": impact.evidence_json or {}, + "handled_by": impact.handled_by, + "handled_at": impact.handled_at.isoformat() if impact.handled_at else None, + "created_at": impact.created_at.isoformat() if impact.created_at else None, + "updated_at": impact.updated_at.isoformat() if impact.updated_at else None, + } diff --git a/backend/app/services/legal_document_draft_service.py b/backend/app/services/legal_document_draft_service.py new file mode 100644 index 00000000..bcfc6588 --- /dev/null +++ b/backend/app/services/legal_document_draft_service.py @@ -0,0 +1,340 @@ +import json +from datetime import date, datetime, timezone +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.legal_document_draft import LegalDocumentDraft +from app.repositories.contract_template_repository import ContractTemplateRepository +from app.repositories.legal_document_draft_repository import LegalDocumentDraftRepository +from app.services.contract_generation_service import ContractGenerationService, ExportFormat +from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService + + +VALID_DRAFT_STATUS = {"draft", "generated", "deleted"} + + +class LegalDocumentDraftService: + def __init__(self, db: Session): + self.db = db + self.repository = LegalDocumentDraftRepository(db) if db is not None else None + self.contract_template_repository = ContractTemplateRepository(db) if db is not None else None + self.contract_generation_service = ContractGenerationService(db) + self.element_schema_service = LegalDocumentElementSchemaService(db) + + def list_drafts( + self, + current_user: dict, + *, + document_type_code: Optional[str] = None, + status_value: Optional[str] = "draft", + limit: int = 20, + ) -> dict[str, Any]: + self._require_authenticated(current_user) + normalized_status = self._normalize_optional_status(status_value) + items = self._require_repository().list_recent( + tenant_id=current_user["tenant_id"], + user_id=current_user["id"], + document_type_code=self._optional_text(document_type_code), + status_value=normalized_status, + include_deleted=False, + limit=max(1, min(int(limit or 20), 100)), + ) + return {"items": [self._to_item(item) for item in items], "total": len(items)} + + def get_draft(self, current_user: dict, draft_id: str) -> dict[str, Any]: + draft = self._require_accessible_draft(current_user, draft_id) + return self._to_item(draft) + + def create_draft( + self, + current_user: dict, + *, + document_type_code: str, + element_values: dict[str, Any], + title: Optional[str] = None, + template_id: Optional[str] = None, + ) -> dict[str, Any]: + self._require_authenticated(current_user) + if not isinstance(element_values, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="element_values must be an object") + validation_result = self.element_schema_service.validate_element_values( + current_user, + document_type_code, + element_values, + ) + now = self._now() + draft = LegalDocumentDraft( + id=f"ldd-{uuid4().hex[:12]}", + tenant_id=current_user["tenant_id"], + user_id=current_user["id"], + document_type_code=validation_result["document_type_code"], + title=self._optional_text(title), + status="draft", + element_values_json=element_values, + validation_result_json=validation_result, + element_schema_id=validation_result.get("schema_id"), + element_schema_version_label=validation_result.get("version_label"), + template_id=self._optional_text(template_id), + created_at=now, + updated_at=now, + ) + return self._to_item(self._require_repository().create(draft)) + + def update_draft( + self, + current_user: dict, + draft_id: str, + *, + element_values: Optional[dict[str, Any]] = None, + title: Optional[str] = None, + template_id: Optional[str] = None, + ) -> dict[str, Any]: + draft = self._require_accessible_draft(current_user, draft_id) + if draft.status == "deleted" or draft.deleted_at is not None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="draft not found") + if element_values is not None: + if not isinstance(element_values, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="element_values must be an object") + validation_result = self.element_schema_service.validate_element_values( + current_user, + draft.document_type_code, + element_values, + ) + draft.element_values_json = element_values + draft.validation_result_json = validation_result + draft.element_schema_id = validation_result.get("schema_id") + draft.element_schema_version_label = validation_result.get("version_label") + if title is not None: + draft.title = self._optional_text(title) + if template_id is not None: + draft.template_id = self._optional_text(template_id) + draft.status = "draft" + draft.updated_at = self._now() + return self._to_item(self._require_repository().update(draft)) + + def delete_draft(self, current_user: dict, draft_id: str) -> dict[str, Any]: + draft = self._require_accessible_draft(current_user, draft_id) + draft.status = "deleted" + draft.deleted_at = self._now() + draft.updated_at = self._now() + updated = self._require_repository().update(draft) + return self._to_item(updated) + + def generate_from_draft( + self, + current_user: dict, + draft_id: str, + *, + export_format: ExportFormat = ExportFormat.DOCX, + template_id: Optional[str] = None, + options: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + draft = self._require_accessible_draft(current_user, draft_id) + if draft.status == "deleted" or draft.deleted_at is not None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="draft not found") + template = self._select_template( + tenant_id=current_user["tenant_id"], + document_type_code=draft.document_type_code, + template_id=template_id or draft.template_id, + ) + generated_contract = self.contract_generation_service.generate_contract_from_elements( + current_user=current_user, + document_type_code=draft.document_type_code, + elements=draft.element_values_json or {}, + export_format=export_format, + options=options or {}, + template_id=template.id, + ) + draft.status = "generated" + draft.template_id = template.id + draft.generated_contract_id = generated_contract["id"] + draft.submitted_at = self._now() + draft.updated_at = self._now() + updated = self._require_repository().update(draft) + return { + "draft": self._to_item(updated), + "generated_contract": generated_contract, + } + + def _select_template(self, *, tenant_id: str, document_type_code: str, template_id: Optional[str]): + repository = self._require_template_repository() + if template_id: + template = repository.get_by_id(template_id, tenant_id) + if template is None or not template.enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="template not found") + return template + templates = 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="no enabled template for document type") + return templates[0] + + def _require_accessible_draft(self, current_user: dict, draft_id: str) -> LegalDocumentDraft: + self._require_authenticated(current_user) + draft = self._require_repository().get_by_id(draft_id, current_user["tenant_id"]) + if draft is None or draft.deleted_at is not None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="draft not found") + if draft.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="draft not found") + return draft + + @classmethod + def _map_elements_to_template_variables(cls, elements: dict[str, Any], variables_json: Any) -> dict[str, str]: + variable_names = cls._template_variable_names(variables_json) + flattened = cls._flatten_values(elements) + aliases = cls._build_alias_values(flattened) + values: dict[str, str] = {} + for name in variable_names: + 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): + values[name] = str(value) + return values + + @staticmethod + def _template_variable_names(variables_json: Any) -> list[str]: + if not variables_json: + return [] + variables = variables_json + if isinstance(variables_json, str): + try: + variables = json.loads(variables_json) + except (TypeError, json.JSONDecodeError): + return [] + if not isinstance(variables, list): + return [] + names: list[str] = [] + for item in variables: + if isinstance(item, dict) and item.get("name"): + names.append(str(item["name"])) + return names + + @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"]) + amount = cls._first( + flattened, + [ + "price.total", + "amount", + "contract_amount", + "service_fee", + "fee.total", + "salary.monthly", + "rent.monthly", + "transfer.price", + ], + ) + return { + "party_a": party_a, + "party_a_name": party_a, + "buyer": party_a, + "buyer_name": party_a, + "employer_name": party_a, + "party_b": party_b, + "party_b_name": party_b, + "supplier": party_b, + "supplier_name": party_b, + "employee_name": party_b, + "product_name": cls._first(flattened, ["goods.name", "product_name", "service.name", "project.name"]), + "quantity": cls._first(flattened, ["goods.quantity", "quantity"]), + "amount": amount, + "contract_amount": amount, + "service_fee": amount, + "delivery_location": cls._first(flattened, ["delivery.place", "delivery_location", "work_location", "property.address"]), + "delivery_date": cls._first(flattened, ["delivery.date", "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"]), + "sign_date": cls._first(flattened, ["sign_date", "created_date"]) or date.today().isoformat(), + } + + @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): + return value + return None + + @staticmethod + def _is_empty(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 _to_item(self, draft: LegalDocumentDraft) -> dict[str, Any]: + return { + "id": draft.id, + "tenant_id": draft.tenant_id, + "user_id": draft.user_id, + "document_type_code": draft.document_type_code, + "title": draft.title, + "status": draft.status, + "element_values": draft.element_values_json or {}, + "validation_result": draft.validation_result_json, + "element_schema_id": draft.element_schema_id, + "element_schema_version_label": draft.element_schema_version_label, + "template_id": draft.template_id, + "generated_contract_id": draft.generated_contract_id, + "submitted_at": draft.submitted_at.isoformat() if draft.submitted_at else None, + "deleted_at": draft.deleted_at.isoformat() if draft.deleted_at else None, + "created_at": draft.created_at.isoformat() if draft.created_at else None, + "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, + } + + def _require_repository(self) -> LegalDocumentDraftRepository: + if self.repository is None: + raise RuntimeError("legal document draft repository is not configured") + return self.repository + + def _require_template_repository(self) -> ContractTemplateRepository: + if self.contract_template_repository is None: + raise RuntimeError("contract template repository is not configured") + return self.contract_template_repository + + @staticmethod + def _require_authenticated(current_user: dict) -> None: + if not current_user or not current_user.get("id") or not current_user.get("tenant_id"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + @classmethod + def _normalize_optional_status(cls, value: Optional[str]) -> Optional[str]: + if value is None or not str(value).strip(): + return None + normalized = str(value).strip().lower() + if normalized not in VALID_DRAFT_STATUS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid draft status") + return normalized + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return str(value).strip() + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) diff --git a/backend/app/services/legal_document_element_schema_service.py b/backend/app/services/legal_document_element_schema_service.py new file mode 100644 index 00000000..783f8cda --- /dev/null +++ b/backend/app/services/legal_document_element_schema_service.py @@ -0,0 +1,639 @@ +import re +from datetime import date, datetime, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.legal_document_element_schema import LegalDocumentElementSchema +from app.repositories.legal_document_element_schema_repository import LegalDocumentElementSchemaRepository +from app.repositories.legal_document_type_repository import LegalDocumentTypeRepository + + +DOCUMENT_TYPE_CODE_PATTERN = re.compile(r"^[a-z0-9_]{2,64}$") +VALID_FIELD_TYPES = { + "text", + "textarea", + "number", + "money", + "date", + "select", + "radio", + "checkbox", + "party", + "clause_toggle", + "file_reference", + "percentage", +} +VALID_SCHEMA_STATUS = {"draft", "published", "archived"} + + +def _party_section() -> dict[str, Any]: + return { + "key": "parties", + "title": "当事人信息", + "fields": [ + {"key": "party_a.name", "label": "甲方名称", "type": "text", "required": True}, + {"key": "party_a.contact", "label": "甲方联系人", "type": "text", "required": False}, + {"key": "party_b.name", "label": "乙方名称", "type": "text", "required": True}, + {"key": "party_b.contact", "label": "乙方联系人", "type": "text", "required": False}, + ], + } + + +DEFAULT_ELEMENT_SCHEMAS: dict[str, dict[str, Any]] = { + "procurement_goods": { + "sections": [ + _party_section(), + { + "key": "transaction", + "title": "交易与交付", + "fields": [ + {"key": "goods.name", "label": "货物名称", "type": "text", "required": True}, + {"key": "goods.quantity", "label": "数量", "type": "number", "required": True}, + {"key": "price.total", "label": "合同总价", "type": "money", "required": True}, + {"key": "delivery.date", "label": "交付日期", "type": "date", "required": True}, + {"key": "delivery.place", "label": "交付地点", "type": "text", "required": True}, + {"key": "acceptance.standard", "label": "验收标准", "type": "textarea", "required": True}, + ], + }, + { + "key": "risk_controls", + "title": "风险控制", + "fields": [ + {"key": "payment.terms", "label": "付款安排", "type": "textarea", "required": True}, + {"key": "liquidated_damages.enabled", "label": "是否约定违约金", "type": "clause_toggle", "required": False}, + { + "key": "dispute_resolution.method", + "label": "争议解决方式", + "type": "select", + "required": True, + "options": ["诉讼", "仲裁", "协商后另行确定"], + }, + ], + }, + ] + }, + "technical_service": { + "sections": [ + _party_section(), + { + "key": "service_scope", + "title": "技术服务内容", + "fields": [ + {"key": "service.name", "label": "服务项目名称", "type": "text", "required": True}, + {"key": "service.scope", "label": "服务范围", "type": "textarea", "required": True}, + {"key": "deliverables", "label": "交付成果", "type": "textarea", "required": True}, + {"key": "service_fee", "label": "服务费用", "type": "money", "required": True}, + {"key": "acceptance.date", "label": "验收日期", "type": "date", "required": False}, + ], + }, + ] + }, + "software_development": { + "sections": [ + _party_section(), + { + "key": "project", + "title": "软件项目", + "fields": [ + {"key": "project.name", "label": "项目名称", "type": "text", "required": True}, + {"key": "project.modules", "label": "功能模块", "type": "textarea", "required": True}, + {"key": "milestones", "label": "里程碑", "type": "textarea", "required": True}, + {"key": "fee.total", "label": "开发费用", "type": "money", "required": True}, + { + "key": "ip_ownership", + "label": "知识产权归属", + "type": "select", + "required": True, + "options": ["甲方所有", "乙方所有", "双方共有", "另行约定"], + }, + ], + }, + ] + }, + "service_general": { + "sections": [ + _party_section(), + { + "key": "service", + "title": "服务交付", + "fields": [ + {"key": "service.scope", "label": "服务范围", "type": "textarea", "required": True}, + {"key": "service.period", "label": "服务期限", "type": "text", "required": True}, + {"key": "service_fee", "label": "服务费用", "type": "money", "required": True}, + {"key": "acceptance.standard", "label": "验收标准", "type": "textarea", "required": True}, + ], + }, + ] + }, + "labor_contract": { + "sections": [ + { + "key": "employment_parties", + "title": "用工双方", + "fields": [ + {"key": "employer.name", "label": "用人单位名称", "type": "text", "required": True}, + {"key": "employee.name", "label": "员工姓名", "type": "text", "required": True}, + {"key": "employee.id_no", "label": "身份证号", "type": "text", "required": True}, + ], + }, + { + "key": "employment_terms", + "title": "劳动条件", + "fields": [ + {"key": "start_date", "label": "合同开始日期", "type": "date", "required": True}, + {"key": "end_date", "label": "合同结束日期", "type": "date", "required": False}, + {"key": "job_title", "label": "岗位", "type": "text", "required": True}, + {"key": "work_location", "label": "工作地点", "type": "text", "required": True}, + {"key": "salary.monthly", "label": "月工资", "type": "money", "required": True}, + {"key": "probation.months", "label": "试用期(月)", "type": "number", "required": False}, + {"key": "social_security.enabled", "label": "是否缴纳社保", "type": "checkbox", "required": True}, + ], + }, + ] + }, + "nda_commercial": { + "sections": [ + _party_section(), + { + "key": "confidentiality", + "title": "保密事项", + "fields": [ + {"key": "cooperation_matter", "label": "合作事项", "type": "textarea", "required": True}, + {"key": "confidential_scope", "label": "保密信息范围", "type": "textarea", "required": True}, + {"key": "confidential_term", "label": "保密期限", "type": "text", "required": True}, + {"key": "breach_liability", "label": "违约责任", "type": "textarea", "required": True}, + ], + }, + ] + }, + "lease_house": { + "sections": [ + _party_section(), + { + "key": "lease", + "title": "租赁信息", + "fields": [ + {"key": "property.address", "label": "房屋地址", "type": "text", "required": True}, + {"key": "lease.start_date", "label": "起租日期", "type": "date", "required": True}, + {"key": "lease.end_date", "label": "退租日期", "type": "date", "required": True}, + {"key": "rent.monthly", "label": "月租金", "type": "money", "required": True}, + {"key": "deposit.amount", "label": "押金", "type": "money", "required": False}, + ], + }, + ] + }, + "equity_transfer": { + "sections": [ + _party_section(), + { + "key": "equity", + "title": "股权转让", + "fields": [ + {"key": "company.name", "label": "目标公司", "type": "text", "required": True}, + {"key": "equity.percent", "label": "转让股权比例", "type": "percentage", "required": True}, + {"key": "transfer.price", "label": "转让价款", "type": "money", "required": True}, + {"key": "closing.date", "label": "交割日期", "type": "date", "required": True}, + ], + }, + ] + }, + "cooperation_framework": { + "sections": [ + _party_section(), + { + "key": "cooperation", + "title": "合作安排", + "fields": [ + {"key": "cooperation.scope", "label": "合作范围", "type": "textarea", "required": True}, + {"key": "cooperation.term", "label": "合作期限", "type": "text", "required": True}, + {"key": "cost_sharing", "label": "费用承担", "type": "textarea", "required": False}, + ], + }, + ] + }, + "demand_letter": { + "sections": [ + _party_section(), + { + "key": "demand", + "title": "催告事项", + "fields": [ + {"key": "obligation", "label": "被催告义务", "type": "textarea", "required": True}, + {"key": "deadline", "label": "履行期限", "type": "date", "required": True}, + {"key": "amount", "label": "涉及金额", "type": "money", "required": False}, + ], + }, + ] + }, + "rescission_notice": { + "sections": [ + _party_section(), + { + "key": "rescission", + "title": "解除事项", + "fields": [ + {"key": "contract.name", "label": "原合同名称", "type": "text", "required": True}, + {"key": "rescission.reason", "label": "解除原因", "type": "textarea", "required": True}, + {"key": "effective_date", "label": "解除生效日期", "type": "date", "required": True}, + ], + }, + ] + }, +} + + +class LegalDocumentElementSchemaService: + def __init__(self, db: Session): + self.db = db + self.repository = LegalDocumentElementSchemaRepository(db) if db is not None else None + self.document_type_repository = LegalDocumentTypeRepository(db) if db is not None else None + + def get_current_element_schema(self, current_user: dict, document_type_code: str) -> dict: + self._require_authenticated(current_user) + normalized_code = self._normalize_code(document_type_code) + element_schema = self._require_current_schema(normalized_code) + return self._to_item(element_schema) + + def list_element_schema_versions(self, current_user: dict, document_type_code: str) -> dict: + self._require_admin(current_user) + normalized_code = self._normalize_code(document_type_code) + self._require_document_type(normalized_code) + items = self._require_repository().list_by_document_type_code(normalized_code) + return { + "document_type_code": normalized_code, + "items": [self._to_item(item) for item in items], + "total": len(items), + } + + def create_element_schema( + self, + current_user: dict, + document_type_code: str, + *, + schema_json: dict[str, Any], + version_label: Optional[str] = None, + status_value: str = "draft", + rollback_from_schema_id: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + normalized_code = self._normalize_code(document_type_code) + self._require_document_type(normalized_code) + normalized_version = self._normalize_version_label(version_label, normalized_code) + if repository.get_by_document_type_version(normalized_code, normalized_version) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="element schema version already exists") + normalized_status = self._normalize_status(status_value) + normalized_schema = self._validate_schema(schema_json) + now = self._now() + element_schema = LegalDocumentElementSchema( + id=f"ldes-{uuid4().hex[:12]}", + document_type_code=normalized_code, + version_label=normalized_version, + schema_json=normalized_schema, + status=normalized_status, + published_at=now if normalized_status == "published" else None, + published_by=current_user.get("id") if normalized_status == "published" else None, + rollback_from_schema_id=self._optional_text(rollback_from_schema_id), + created_by=current_user.get("id"), + updated_by=current_user.get("id"), + created_at=now, + updated_at=now, + ) + created = repository.create(element_schema) + if created.status == "published": + self._archive_other_published_schemas(created) + return self._to_item(created) + + def update_element_schema( + self, + current_user: dict, + document_type_code: str, + schema_id: str, + *, + schema_json: Optional[dict[str, Any]] = None, + version_label: Optional[str] = None, + status_value: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + normalized_code = self._normalize_code(document_type_code) + element_schema = self._require_schema(schema_id, normalized_code) + if schema_json is not None: + element_schema.schema_json = self._validate_schema(schema_json) + if version_label is not None: + normalized_version = self._normalize_version_label(version_label, normalized_code) + existing = repository.get_by_document_type_version(normalized_code, normalized_version) + if existing is not None and existing.id != element_schema.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="element schema version already exists") + element_schema.version_label = normalized_version + if status_value is not None: + element_schema.status = self._normalize_status(status_value) + element_schema.updated_by = current_user.get("id") + element_schema.updated_at = self._now() + updated = repository.update(element_schema) + if updated.status == "published": + if updated.published_at is None: + updated.published_at = self._now() + updated.published_by = current_user.get("id") + updated = repository.update(updated) + self._archive_other_published_schemas(updated) + return self._to_item(updated) + + def publish_element_schema(self, current_user: dict, document_type_code: str, schema_id: str) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + normalized_code = self._normalize_code(document_type_code) + element_schema = self._require_schema(schema_id, normalized_code) + element_schema.status = "published" + element_schema.published_at = self._now() + element_schema.published_by = current_user.get("id") + element_schema.updated_by = current_user.get("id") + element_schema.updated_at = self._now() + updated = repository.update(element_schema) + self._archive_other_published_schemas(updated) + return self._to_item(updated) + + def seed_default_element_schemas(self, current_user: dict, document_type_code: Optional[str] = None) -> dict: + self._require_admin(current_user) + target_codes = [self._normalize_code(document_type_code)] if document_type_code else sorted(DEFAULT_ELEMENT_SCHEMAS.keys()) + created: list[dict[str, Any]] = [] + existing: list[dict[str, Any]] = [] + skipped: list[str] = [] + for code in target_codes: + if code not in DEFAULT_ELEMENT_SCHEMAS: + skipped.append(code) + continue + self._require_document_type(code) + found = self._require_repository().get_by_document_type_version(code, "v1.0") + if found is not None: + existing.append(self._to_item(found)) + continue + created.append( + self.create_element_schema( + current_user, + code, + schema_json=DEFAULT_ELEMENT_SCHEMAS[code], + version_label="v1.0", + status_value="published", + ) + ) + return { + "created": created, + "existing": existing, + "skipped": skipped, + "created_count": len(created), + "existing_count": len(existing), + "skipped_count": len(skipped), + "total": len(target_codes), + } + + def validate_element_values( + self, + current_user: dict, + document_type_code: str, + values: dict[str, Any], + ) -> dict: + self._require_authenticated(current_user) + normalized_code = self._normalize_code(document_type_code) + element_schema = self._require_current_schema(normalized_code) + errors = self._validate_values_against_schema(element_schema.schema_json, values or {}) + return { + "document_type_code": normalized_code, + "schema_id": element_schema.id, + "version_label": element_schema.version_label, + "valid": not errors, + "errors": errors, + } + + def _require_current_schema(self, document_type_code: str) -> LegalDocumentElementSchema: + self._require_document_type(document_type_code) + element_schema = self._require_repository().get_current_by_document_type_code(document_type_code) + if element_schema is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="element schema not found") + return element_schema + + def _require_schema(self, schema_id: str, document_type_code: str) -> LegalDocumentElementSchema: + element_schema = self._require_repository().get_by_id(schema_id) + if element_schema is None or element_schema.document_type_code != document_type_code: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="element schema not found") + return element_schema + + def _require_document_type(self, document_type_code: str) -> None: + repository = self._require_document_type_repository() + if repository.get_by_code(document_type_code) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal document type not found") + + def _archive_other_published_schemas(self, published_schema: LegalDocumentElementSchema) -> None: + repository = self._require_repository() + for item in repository.list_by_document_type_code(published_schema.document_type_code): + if item.id == published_schema.id or item.status != "published": + continue + item.status = "archived" + item.updated_at = self._now() + repository.update(item) + + def _validate_schema(self, schema_json: dict[str, Any]) -> dict[str, Any]: + if not isinstance(schema_json, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="schema_json must be an object") + sections = schema_json.get("sections") + if not isinstance(sections, list) or not sections: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="schema_json.sections must be a non-empty array") + + section_keys: set[str] = set() + field_keys: set[str] = set() + for section in sections: + if not isinstance(section, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="schema section must be an object") + section_key = self._required_text(section.get("key"), "section.key") + self._required_text(section.get("title"), "section.title") + if section_key in section_keys: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"duplicate section key: {section_key}") + section_keys.add(section_key) + fields = section.get("fields") + if not isinstance(fields, list) or not fields: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"section {section_key} fields must be a non-empty array") + for field in fields: + if not isinstance(field, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="schema field must be an object") + field_key = self._required_text(field.get("key"), "field.key") + self._required_text(field.get("label"), "field.label") + field_type = self._required_text(field.get("type"), "field.type") + if field_type not in VALID_FIELD_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"unsupported field type: {field_type}") + if field_key in field_keys: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"duplicate field key: {field_key}") + field_keys.add(field_key) + if field_type in {"select", "radio"}: + self._validate_options(field_key, field.get("options")) + return schema_json + + def _validate_values_against_schema(self, schema_json: dict[str, Any], values: dict[str, Any]) -> list[dict[str, str]]: + errors: list[dict[str, str]] = [] + for section in schema_json.get("sections", []): + for field in section.get("fields", []): + key = field.get("key") + value = self._get_nested_value(values, key) + if field.get("required") is True and self._is_empty_value(value): + errors.append({"field_key": key, "message": "required"}) + continue + if self._is_empty_value(value): + continue + field_type = field.get("type") + if field_type in {"number", "money", "percentage"}: + number_error = self._validate_decimal_value(value, field_type) + if number_error: + errors.append({"field_key": key, "message": number_error}) + elif field_type == "date" and not self._is_valid_date(value): + errors.append({"field_key": key, "message": "date must use YYYY-MM-DD"}) + elif field_type in {"select", "radio"}: + allowed_values = self._option_values(field.get("options")) + if allowed_values and str(value) not in allowed_values: + errors.append({"field_key": key, "message": "value is not in options"}) + elif field_type == "checkbox" and not isinstance(value, (bool, list)): + errors.append({"field_key": key, "message": "checkbox value must be boolean or array"}) + elif field_type == "party" and not isinstance(value, dict): + errors.append({"field_key": key, "message": "party value must be an object"}) + elif field_type == "clause_toggle" and not isinstance(value, bool): + errors.append({"field_key": key, "message": "clause_toggle value must be boolean"}) + return errors + + @staticmethod + def _validate_options(field_key: str, options: Any) -> None: + if not isinstance(options, list) or not options: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_key} options must be a non-empty array") + for option in options: + if isinstance(option, str) and option.strip(): + continue + if isinstance(option, dict) and str(option.get("value") or "").strip() and str(option.get("label") or "").strip(): + continue + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_key} option must be a string or value/label object") + + @staticmethod + def _option_values(options: Any) -> set[str]: + values: set[str] = set() + for option in options or []: + if isinstance(option, str): + values.add(option) + elif isinstance(option, dict) and option.get("value") is not None: + values.add(str(option.get("value"))) + return values + + @staticmethod + def _validate_decimal_value(value: Any, field_type: str) -> Optional[str]: + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return f"{field_type} must be numeric" + if field_type == "percentage" and (number < 0 or number > 100): + return "percentage must be between 0 and 100" + return None + + @staticmethod + def _is_valid_date(value: Any) -> bool: + try: + date.fromisoformat(str(value)) + return True + except ValueError: + return False + + @staticmethod + def _get_nested_value(values: dict[str, Any], key: str) -> Any: + if key in values: + return values.get(key) + current: Any = values + for part in str(key).split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + @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 _require_repository(self) -> LegalDocumentElementSchemaRepository: + if self.repository is None: + raise RuntimeError("legal document element schema repository is not configured") + return self.repository + + def _require_document_type_repository(self) -> LegalDocumentTypeRepository: + if self.document_type_repository is None: + raise RuntimeError("legal document type repository is not configured") + return self.document_type_repository + + @staticmethod + def _require_authenticated(current_user: dict) -> None: + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + @staticmethod + def _require_admin(current_user: dict) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return str(value).strip() + + @classmethod + def _required_text(cls, value: Any, field_name: str) -> str: + normalized = cls._optional_text(value) + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required") + return normalized + + @classmethod + def _normalize_code(cls, value: str) -> str: + normalized = cls._required_text(value, "document_type_code").lower().replace("-", "_") + if not DOCUMENT_TYPE_CODE_PATTERN.fullmatch(normalized): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="document_type_code must use lowercase letters, numbers, and underscores") + return normalized + + @staticmethod + def _normalize_status(value: str) -> str: + normalized = str(value or "draft").strip().lower() + if normalized not in VALID_SCHEMA_STATUS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status must be draft, published, or archived") + return normalized + + @staticmethod + def _normalize_version_label(value: Optional[str], document_type_code: str) -> str: + normalized = str(value or "v1.0").strip() + if not normalized: + normalized = "v1.0" + if len(normalized) > 32: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="version_label is too long") + return normalized + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + @staticmethod + def _to_item(element_schema: LegalDocumentElementSchema) -> dict: + return { + "id": element_schema.id, + "document_type_code": element_schema.document_type_code, + "version_label": element_schema.version_label, + "schema_json": element_schema.schema_json, + "status": element_schema.status, + "published_at": element_schema.published_at.isoformat() if element_schema.published_at else None, + "published_by": element_schema.published_by, + "rollback_from_schema_id": element_schema.rollback_from_schema_id, + "created_by": element_schema.created_by, + "updated_by": element_schema.updated_by, + "created_at": element_schema.created_at.isoformat() if element_schema.created_at else None, + "updated_at": element_schema.updated_at.isoformat() if element_schema.updated_at else None, + } diff --git a/backend/app/services/legal_document_type_service.py b/backend/app/services/legal_document_type_service.py new file mode 100644 index 00000000..9c068220 --- /dev/null +++ b/backend/app/services/legal_document_type_service.py @@ -0,0 +1,364 @@ +import re +from datetime import datetime, timezone +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.legal_document_type import LegalDocumentType +from app.repositories.legal_document_type_repository import LegalDocumentTypeRepository + + +DOCUMENT_TYPE_CODE_PATTERN = re.compile(r"^[a-z0-9_]{2,64}$") +VALID_RISK_LEVELS = {"low", "medium", "high", "critical"} + +DEFAULT_LEGAL_DOCUMENT_TYPES: list[dict[str, Any]] = [ + { + "code": "procurement_goods", + "name": "货物买卖合同", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "procurement-supply", + "description": "适用于货物采购、销售、交付、验收和付款场景。", + "risk_level": "medium", + "sort_order": 10, + }, + { + "code": "technical_service", + "name": "技术服务合同", + "category": "company-commercial", + "industry": "technology", + "scenario": "technical-service", + "description": "适用于技术咨询、技术服务、成果交付和验收场景。", + "risk_level": "medium", + "sort_order": 20, + }, + { + "code": "software_development", + "name": "软件开发合同", + "category": "company-commercial", + "industry": "technology", + "scenario": "software-development", + "description": "适用于软件定制开发、交付、验收、知识产权和维护场景。", + "risk_level": "high", + "sort_order": 30, + }, + { + "code": "service_general", + "name": "通用服务合同", + "category": "company-commercial", + "industry": "professional-services", + "scenario": "service-delivery", + "description": "适用于咨询、运营、外包等通用服务交付场景。", + "risk_level": "medium", + "sort_order": 40, + }, + { + "code": "labor_contract", + "name": "劳动合同", + "category": "employment-hr", + "industry": "general-enterprise", + "scenario": "employment", + "description": "适用于企业与员工建立劳动关系的标准场景。", + "risk_level": "high", + "sort_order": 50, + }, + { + "code": "nda_commercial", + "name": "保密协议", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "confidentiality", + "description": "适用于商务合作、尽调、沟通前后的保密约定。", + "risk_level": "medium", + "sort_order": 60, + }, + { + "code": "lease_house", + "name": "房屋租赁合同", + "category": "real-estate", + "industry": "real-estate", + "scenario": "lease", + "description": "适用于办公、商业或居住房屋租赁场景。", + "risk_level": "medium", + "sort_order": 70, + }, + { + "code": "equity_transfer", + "name": "股权转让协议", + "category": "investment-finance", + "industry": "general-enterprise", + "scenario": "equity-transfer", + "description": "适用于有限责任公司股权转让、价款支付和工商变更场景。", + "risk_level": "high", + "sort_order": 80, + }, + { + "code": "cooperation_framework", + "name": "合作框架协议", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "cooperation", + "description": "适用于双方建立长期合作意向和原则性约定场景。", + "risk_level": "medium", + "sort_order": 90, + }, + { + "code": "demand_letter", + "name": "催告函", + "category": "notices-letters", + "industry": "general-enterprise", + "scenario": "demand-letter", + "description": "适用于付款、交付、整改等履约催告场景。", + "risk_level": "medium", + "sort_order": 100, + }, + { + "code": "rescission_notice", + "name": "解除合同通知书", + "category": "notices-letters", + "industry": "general-enterprise", + "scenario": "rescission-notice", + "description": "适用于依法或依约解除合同并保留证据的通知场景。", + "risk_level": "high", + "sort_order": 110, + }, +] + + +class LegalDocumentTypeService: + def __init__(self, db: Session): + self.db = db + self.repository = LegalDocumentTypeRepository(db) if db is not None else None + + def list_document_types( + self, + current_user: dict, + *, + category: Optional[str] = None, + industry: Optional[str] = None, + scenario: Optional[str] = None, + active_only: bool = True, + query: Optional[str] = None, + ) -> dict: + self._require_authenticated(current_user) + repository = self._require_repository() + normalized_category = self._optional_slug(category) + normalized_industry = self._optional_slug(industry) + normalized_scenario = self._optional_slug(scenario) + normalized_query = self._optional_text(query) + items = repository.list_filtered( + category=normalized_category, + industry=normalized_industry, + scenario=normalized_scenario, + active_only=active_only, + query=normalized_query, + ) + facet_items = repository.list_filtered(active_only=active_only) + return { + "items": [self._to_item(item) for item in items], + "total": len(items), + "facets": self._build_facets(facet_items), + "filters": { + "category": normalized_category, + "industry": normalized_industry, + "scenario": normalized_scenario, + "active_only": active_only, + "query": normalized_query, + }, + } + + def get_document_type(self, current_user: dict, code: str) -> dict: + self._require_authenticated(current_user) + document_type = self._require_document_type(code) + return self._to_item(document_type) + + def create_document_type( + self, + current_user: dict, + *, + code: str, + name: str, + category: str, + industry: str, + scenario: str, + description: Optional[str] = None, + risk_level: str = "medium", + is_active: bool = True, + sort_order: int = 100, + ) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + normalized_code = self._normalize_code(code) + if repository.get_by_code(normalized_code) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="legal document type code already exists") + document_type = LegalDocumentType( + id=f"ldt-{uuid4().hex[:12]}", + code=normalized_code, + name=self._required_text(name, "name"), + category=self._required_slug(category, "category"), + industry=self._required_slug(industry, "industry"), + scenario=self._required_slug(scenario, "scenario"), + description=self._optional_text(description), + risk_level=self._normalize_risk_level(risk_level), + is_active=bool(is_active), + sort_order=self._normalize_sort_order(sort_order), + created_at=self._now(), + updated_at=self._now(), + ) + return self._to_item(repository.create(document_type)) + + def update_document_type( + self, + current_user: dict, + code: str, + *, + name: Optional[str] = None, + category: Optional[str] = None, + industry: Optional[str] = None, + scenario: Optional[str] = None, + description: Optional[str] = None, + risk_level: Optional[str] = None, + is_active: Optional[bool] = None, + sort_order: Optional[int] = None, + ) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + document_type = self._require_document_type(code) + if name is not None: + document_type.name = self._required_text(name, "name") + if category is not None: + document_type.category = self._required_slug(category, "category") + if industry is not None: + document_type.industry = self._required_slug(industry, "industry") + if scenario is not None: + document_type.scenario = self._required_slug(scenario, "scenario") + if description is not None: + document_type.description = self._optional_text(description) + if risk_level is not None: + document_type.risk_level = self._normalize_risk_level(risk_level) + if is_active is not None: + document_type.is_active = bool(is_active) + if sort_order is not None: + document_type.sort_order = self._normalize_sort_order(sort_order) + document_type.updated_at = self._now() + return self._to_item(repository.update(document_type)) + + def seed_default_document_types(self, current_user: dict) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + created: list[dict[str, Any]] = [] + existing: list[dict[str, Any]] = [] + for item in DEFAULT_LEGAL_DOCUMENT_TYPES: + document_type = repository.get_by_code(item["code"]) + if document_type is not None: + existing.append(self._to_item(document_type)) + continue + created.append(self.create_document_type(current_user, **item)) + return { + "created": created, + "existing": existing, + "created_count": len(created), + "existing_count": len(existing), + "total": len(DEFAULT_LEGAL_DOCUMENT_TYPES), + "document_type_codes": [item["code"] for item in created], + } + + def _require_document_type(self, code: str) -> LegalDocumentType: + repository = self._require_repository() + normalized_code = self._normalize_code(code) + document_type = repository.get_by_code(normalized_code) + if document_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="legal document type not found") + return document_type + + def _require_repository(self) -> LegalDocumentTypeRepository: + if self.repository is None: + raise RuntimeError("legal document type repository is not configured") + return self.repository + + @staticmethod + def _require_authenticated(current_user: dict) -> None: + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + @staticmethod + def _require_admin(current_user: dict) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return str(value).strip() + + @classmethod + def _required_text(cls, value: Any, field_name: str) -> str: + normalized = cls._optional_text(value) + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required") + return normalized + + @classmethod + def _normalize_code(cls, value: str) -> str: + normalized = cls._required_text(value, "code").lower().replace("-", "_") + if not DOCUMENT_TYPE_CODE_PATTERN.fullmatch(normalized): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="code must use lowercase letters, numbers, and underscores") + return normalized + + @classmethod + def _required_slug(cls, value: Any, field_name: str) -> str: + normalized = cls._required_text(value, field_name).lower() + if len(normalized) > 64: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is too long") + return normalized + + @classmethod + def _optional_slug(cls, value: Any) -> Optional[str]: + normalized = cls._optional_text(value) + return normalized.lower() if normalized else None + + @staticmethod + def _normalize_risk_level(value: str) -> str: + normalized = str(value or "medium").strip().lower() + if normalized not in VALID_RISK_LEVELS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="risk_level must be low, medium, high, or critical") + return normalized + + @staticmethod + def _normalize_sort_order(value: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="sort_order must be an integer") + + @staticmethod + def _build_facets(items: list[LegalDocumentType]) -> dict: + return { + "categories": sorted({item.category for item in items if item.category}), + "industries": sorted({item.industry for item in items if item.industry}), + "scenarios": sorted({item.scenario for item in items if item.scenario}), + } + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + @staticmethod + def _to_item(document_type: LegalDocumentType) -> dict: + return { + "id": document_type.id, + "code": document_type.code, + "name": document_type.name, + "category": document_type.category, + "industry": document_type.industry, + "scenario": document_type.scenario, + "description": document_type.description, + "risk_level": document_type.risk_level, + "is_active": document_type.is_active, + "sort_order": document_type.sort_order, + "created_at": document_type.created_at.isoformat() if document_type.created_at else None, + "updated_at": document_type.updated_at.isoformat() if document_type.updated_at else None, + } diff --git a/backend/app/services/legal_review_execution_gateway.py b/backend/app/services/legal_review_execution_gateway.py index 7fd9fd63..a5f0f13c 100644 --- a/backend/app/services/legal_review_execution_gateway.py +++ b/backend/app/services/legal_review_execution_gateway.py @@ -18,6 +18,9 @@ class LegalReviewExecutionGateway: detail="shared-ai-platform legal review is required but is disabled", ) + prompt_runtime = getattr(review, "_prompt_runtime", None) or {} + provider = prompt_runtime.get("provider") if isinstance(prompt_runtime, dict) else None + return self.shared_ai_platform_client.run_legal_review( { "id": review.user_id, @@ -29,6 +32,7 @@ class LegalReviewExecutionGateway: "contract_type": review.contract_type, "review_as": review.review_as, "text": review.input_text or "", + "provider": provider, }, ) diff --git a/backend/app/services/operation_kpi_service.py b/backend/app/services/operation_kpi_service.py new file mode 100644 index 00000000..be5a9cbe --- /dev/null +++ b/backend/app/services/operation_kpi_service.py @@ -0,0 +1,426 @@ +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Optional + +from fastapi import HTTPException, status +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.audit_log import AuditLog +from app.models.commercial_order import CommercialOrder +from app.models.generated_contract import GeneratedContract +from app.models.legal_article import LegalArticle +from app.models.message import Message +from app.models.review_result import ReviewResult +from app.models.review_risk_action import ReviewRiskAction +from app.models.review_task import ReviewTask +from app.models.tenant import Tenant +from app.services.tenant_service import TenantService + + +class OperationKpiService: + def __init__(self, db: Session): + self.db = db + self.tenant_service = TenantService(db) + + def get_dashboard(self, current_user: dict[str, Any]) -> dict[str, Any]: + self._require_admin(current_user) + tenant_id = str(current_user["tenant_id"]) + now = datetime.now(timezone.utc) + period_specs = self._build_period_specs(now) + periods = [ + self._build_period_metrics(tenant_id, key, label, start_at, now) + for key, label, start_at in period_specs + ] + billing_summary = self.tenant_service.get_current_billing(current_user) + + return { + "scope": { + "tenant_id": tenant_id, + "usage_scope": "current_tenant", + "plan_distribution_scope": "platform_counts", + "timezone": "UTC", + }, + "generated_at": now.isoformat(), + "periods": periods, + "top_templates": self._build_top_templates(tenant_id), + "clause_action_summary": self._build_clause_action_summary(tenant_id, None), + "risk_action_top_items": self._build_risk_action_top_items(tenant_id), + "legal_citation_summary": self._build_legal_citation_summary(tenant_id, None), + "conversion_funnel": self._build_conversion_funnel(), + "overage_conversion": self._build_overage_conversion(current_user, billing_summary), + } + + def _build_period_specs(self, now: datetime) -> list[tuple[str, str, datetime]]: + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = day_start - timedelta(days=day_start.weekday()) + month_start = day_start.replace(day=1) + return [ + ("daily", "今日", day_start), + ("weekly", "本周", week_start), + ("monthly", "本月", month_start), + ] + + def _build_period_metrics( + self, + tenant_id: str, + key: str, + label: str, + start_at: datetime, + end_at: datetime, + ) -> dict[str, Any]: + review_count = self._count_since(ReviewTask, tenant_id, ReviewTask.created_at, start_at) + generation_count = self._count_since(GeneratedContract, tenant_id, GeneratedContract.created_at, start_at) + chat_count = self._count_since(Message, tenant_id, Message.created_at, start_at, Message.role == "user") + report_download_count = self._count_audit_actions(tenant_id, start_at, ["download_report"]) + clause_ai_count = self._count_audit_actions( + tenant_id, + start_at, + ["recommend_missing_clauses", "rewrite_clause_wording"], + ) + clause_actions = self._build_clause_action_summary(tenant_id, start_at) + legal_citations = self._build_legal_citation_summary(tenant_id, start_at) + + return { + "key": key, + "label": label, + "start_at": start_at.isoformat(), + "end_at": end_at.isoformat(), + "metrics": { + "ai_usage_count": review_count + generation_count + chat_count + clause_ai_count, + "review_count": review_count, + "generation_count": generation_count, + "chat_message_count": chat_count, + "report_download_count": report_download_count, + "clause_recommendation_count": clause_ai_count, + "clause_action_total": clause_actions["total"], + "clause_action_applied": clause_actions["applied_count"], + "clause_action_ignored": clause_actions["ignored_count"], + "clause_action_adoption_rate_percent": clause_actions["adoption_rate_percent"], + "legal_reference_count": legal_citations["total_reference_count"], + "legal_reference_verified_rate_percent": legal_citations["verified_reference_rate_percent"], + }, + } + + def _build_top_templates(self, tenant_id: str, limit: int = 10) -> list[dict[str, Any]]: + rows = ( + self.db.query( + GeneratedContract.template_id, + GeneratedContract.template_name, + func.count(GeneratedContract.id).label("usage_count"), + func.max(GeneratedContract.created_at).label("last_used_at"), + ) + .filter(GeneratedContract.tenant_id == tenant_id) + .group_by(GeneratedContract.template_id, GeneratedContract.template_name) + .order_by(func.count(GeneratedContract.id).desc(), func.max(GeneratedContract.created_at).desc()) + .limit(limit) + .all() + ) + return [ + { + "template_id": row.template_id, + "template_name": row.template_name, + "usage_count": int(row.usage_count or 0), + "last_used_at": self._iso(row.last_used_at), + } + for row in rows + ] + + def _build_clause_action_summary(self, tenant_id: str, start_at: Optional[datetime]) -> dict[str, Any]: + query = self.db.query(ReviewRiskAction.action_status, func.count(ReviewRiskAction.id)).filter( + ReviewRiskAction.tenant_id == tenant_id + ) + if start_at is not None: + query = query.filter(ReviewRiskAction.updated_at >= start_at) + rows = query.group_by(ReviewRiskAction.action_status).all() + by_status = {str(status_value or "pending"): int(count or 0) for status_value, count in rows} + adopted_count = by_status.get("adopted", 0) + modified_count = by_status.get("modified", 0) + ignored_count = by_status.get("ignored", 0) + pending_count = by_status.get("pending", 0) + manual_required_count = by_status.get("manual_required", 0) + total = sum(by_status.values()) + applied_count = adopted_count + modified_count + + return { + "total": total, + "by_status": by_status, + "adopted_count": adopted_count, + "modified_count": modified_count, + "applied_count": applied_count, + "ignored_count": ignored_count, + "pending_count": pending_count, + "manual_required_count": manual_required_count, + "adoption_rate_percent": self._percent(applied_count, total), + "ignored_rate_percent": self._percent(ignored_count, total), + } + + def _build_risk_action_top_items(self, tenant_id: str, limit: int = 10) -> list[dict[str, Any]]: + rows = ( + self.db.query( + ReviewRiskAction.risk_item_title, + ReviewRiskAction.risk_level, + ReviewRiskAction.action_status, + func.count(ReviewRiskAction.id).label("action_count"), + ) + .filter(ReviewRiskAction.tenant_id == tenant_id) + .group_by( + ReviewRiskAction.risk_item_title, + ReviewRiskAction.risk_level, + ReviewRiskAction.action_status, + ) + .order_by(func.count(ReviewRiskAction.id).desc()) + .limit(limit) + .all() + ) + return [ + { + "risk_item_title": row.risk_item_title or "未命名风险建议", + "risk_level": row.risk_level, + "action_status": row.action_status, + "action_count": int(row.action_count or 0), + } + for row in rows + ] + + def _build_legal_citation_summary(self, tenant_id: str, start_at: Optional[datetime]) -> dict[str, Any]: + review_results_query = ( + self.db.query(ReviewResult) + .join(ReviewTask, ReviewTask.id == ReviewResult.review_task_id) + .filter(ReviewTask.tenant_id == tenant_id) + ) + contracts_query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id) + messages_query = self.db.query(Message).filter(Message.tenant_id == tenant_id, Message.role == "assistant") + if start_at is not None: + review_results_query = review_results_query.filter(ReviewResult.created_at >= start_at) + contracts_query = contracts_query.filter(GeneratedContract.created_at >= start_at) + messages_query = messages_query.filter(Message.created_at >= start_at) + + review_results = review_results_query.all() + contracts = contracts_query.all() + messages = messages_query.all() + + review_refs: list[dict[str, Any]] = [] + review_reports_with_refs = 0 + for result in review_results: + refs = self._extract_review_legal_refs(result) + if refs: + review_reports_with_refs += 1 + review_refs.extend(refs) + + contract_refs: list[dict[str, Any]] = [] + generated_contracts_with_refs = 0 + for contract in contracts: + refs = self._extract_contract_legal_refs(contract) + if refs: + generated_contracts_with_refs += 1 + contract_refs.extend(refs) + + chat_refs: list[dict[str, Any]] = [] + chat_messages_with_refs = 0 + for message in messages: + refs = self._extract_message_legal_refs(message) + if refs: + chat_messages_with_refs += 1 + chat_refs.extend(refs) + + all_refs = [*review_refs, *contract_refs, *chat_refs] + ref_ids = sorted({str(ref.get("id") or ref.get("source_id") or "").strip() for ref in all_refs if str(ref.get("id") or ref.get("source_id") or "").strip()}) + article_rows = [] + if ref_ids: + article_rows = ( + self.db.query(LegalArticle) + .filter(LegalArticle.tenant_id == tenant_id, LegalArticle.id.in_(ref_ids)) + .all() + ) + article_by_id = {article.id: article for article in article_rows} + published_reference_count = sum( + 1 + for ref_id in ref_ids + if article_by_id.get(ref_id) is not None and article_by_id[ref_id].published_status == "published" + ) + indexed_reference_count = sum( + 1 + for ref_id in ref_ids + if article_by_id.get(ref_id) is not None and article_by_id[ref_id].index_status == "indexed" + ) + verified_reference_count = sum( + 1 + for ref_id in ref_ids + if article_by_id.get(ref_id) is not None + and article_by_id[ref_id].published_status == "published" + and article_by_id[ref_id].index_status == "indexed" + ) + + return { + "review_results_scanned": len(review_results), + "review_reports_with_legal_refs": review_reports_with_refs, + "generated_contracts_scanned": len(contracts), + "generated_contracts_with_legal_refs": generated_contracts_with_refs, + "chat_messages_scanned": len(messages), + "chat_messages_with_legal_refs": chat_messages_with_refs, + "total_reference_count": len(all_refs), + "unique_reference_count": len(ref_ids), + "published_reference_count": published_reference_count, + "indexed_reference_count": indexed_reference_count, + "verified_reference_count": verified_reference_count, + "verified_reference_rate_percent": self._percent(verified_reference_count, len(ref_ids)), + "accuracy_proxy_note": "当前统计为法条引用可验证率:引用 ID 能在本租户法条库中找到,且状态为已发布、已索引。", + } + + def _build_conversion_funnel(self) -> dict[str, Any]: + tenant_rows = self.db.query(Tenant).all() + order_rows = self.db.query(CommercialOrder).all() + plan_distribution: dict[tuple[str, str, str], int] = {} + for tenant in tenant_rows: + key = ( + tenant.plan_code or "trial", + tenant.plan_name or tenant.plan_code or "trial", + tenant.billing_status or "unknown", + ) + plan_distribution[key] = plan_distribution.get(key, 0) + 1 + + trial_tenants = [ + tenant + for tenant in tenant_rows + if (tenant.billing_status or "").lower() == "trialing" or (tenant.plan_code or "").lower() in {"trial", "free"} + ] + active_paid_tenants = [ + tenant + for tenant in tenant_rows + if (tenant.billing_status or "").lower() == "active" and (tenant.plan_code or "").lower() not in {"trial", "free"} + ] + expired_trial_tenants = [ + tenant + for tenant in tenant_rows + if (tenant.billing_status or "").lower() in {"expired", "past_due", "suspended"} + ] + activated_orders = [order for order in order_rows if order.status == "activated"] + pending_orders = [order for order in order_rows if order.status == "pending_payment"] + activated_amount_cents = sum(order.amount_cents or 0 for order in activated_orders) + + return { + "total_tenants": len(tenant_rows), + "trial_or_free_tenants": len(trial_tenants), + "active_paid_tenants": len(active_paid_tenants), + "expired_or_blocked_tenants": len(expired_trial_tenants), + "pending_order_count": len(pending_orders), + "activated_order_count": len(activated_orders), + "activated_amount_cents": activated_amount_cents, + "trial_to_paid_rate_percent": self._percent(len(active_paid_tenants), len(trial_tenants) + len(active_paid_tenants)), + "plan_distribution": [ + { + "plan_code": plan_code, + "plan_name": plan_name, + "billing_status": billing_status, + "tenant_count": count, + } + for (plan_code, plan_name, billing_status), count in sorted(plan_distribution.items()) + ], + "order_status_distribution": self._build_order_status_distribution(order_rows), + } + + def _build_order_status_distribution(self, order_rows: Iterable[CommercialOrder]) -> list[dict[str, Any]]: + distribution: dict[tuple[str, str, str], dict[str, Any]] = {} + for order in order_rows: + key = (order.plan_code, order.plan_name, order.status) + bucket = distribution.setdefault( + key, + { + "plan_code": order.plan_code, + "plan_name": order.plan_name, + "status": order.status, + "order_count": 0, + "amount_cents": 0, + }, + ) + bucket["order_count"] += 1 + bucket["amount_cents"] += int(order.amount_cents or 0) + return list(distribution.values()) + + def _build_overage_conversion(self, current_user: dict[str, Any], billing_summary: dict[str, Any]) -> dict[str, Any]: + tenant_id = str(current_user["tenant_id"]) + orders = self.db.query(CommercialOrder).filter(CommercialOrder.tenant_id == tenant_id).all() + overage_orders = [order for order in orders if order.order_type == "overage"] + upgrade_orders = [order for order in orders if order.order_type == "subscription"] + activated_overage_orders = [order for order in overage_orders if order.status == "activated"] + activated_upgrade_orders = [order for order in upgrade_orders if order.status == "activated"] + warning_count = int((billing_summary.get("summary") or {}).get("quota_warning_count") or 0) + exceeded_count = int((billing_summary.get("summary") or {}).get("exceeded_count") or 0) + total_prompted_signals = warning_count + exceeded_count + total_conversion_orders = len(activated_overage_orders) + len(activated_upgrade_orders) + + return { + "current_quota_warning_count": warning_count, + "current_exceeded_count": exceeded_count, + "pending_overage_order_count": len([order for order in overage_orders if order.status == "pending_payment"]), + "activated_overage_order_count": len(activated_overage_orders), + "pending_upgrade_order_count": len([order for order in upgrade_orders if order.status == "pending_payment"]), + "activated_upgrade_order_count": len(activated_upgrade_orders), + "conversion_order_count": total_conversion_orders, + "conversion_rate_percent": self._percent(total_conversion_orders, total_prompted_signals), + "note": "当前按额度预警/超额信号与订单生效结果做转化代理统计,后续可继续补点击、弹窗曝光和销售跟进事件。", + } + + def _count_since(self, model: type, tenant_id: str, created_at_column, start_at: datetime, *extra_filters) -> int: + query = self.db.query(func.count(model.id)).filter(model.tenant_id == tenant_id, created_at_column >= start_at) + for extra_filter in extra_filters: + query = query.filter(extra_filter) + return int(query.scalar() or 0) + + def _count_audit_actions(self, tenant_id: str, start_at: datetime, actions: list[str]) -> int: + return int( + self.db.query(func.count(AuditLog.id)) + .filter(AuditLog.tenant_id == tenant_id, AuditLog.created_at >= start_at, AuditLog.action.in_(actions)) + .scalar() + or 0 + ) + + def _extract_review_legal_refs(self, result: ReviewResult) -> list[dict[str, Any]]: + try: + risk_items = json.loads(result.risk_items_json or "[]") + except (TypeError, ValueError): + return [] + refs: list[dict[str, Any]] = [] + for item in risk_items if isinstance(risk_items, list) else []: + if not isinstance(item, dict): + continue + for ref in item.get("legal_article_refs") or []: + if isinstance(ref, dict): + refs.append(ref) + return refs + + def _extract_contract_legal_refs(self, contract: GeneratedContract) -> list[dict[str, Any]]: + options = contract.export_options_json if isinstance(contract.export_options_json, dict) else {} + refs = options.get("_related_legal_articles") or [] + return [ref for ref in refs if isinstance(ref, dict)] + + def _extract_message_legal_refs(self, message: Message) -> list[dict[str, Any]]: + refs: list[dict[str, Any]] = [] + sources = message.sources_json if isinstance(message.sources_json, list) else [] + for source in sources: + if not isinstance(source, dict): + continue + detail_path = str(source.get("detail_path") or "") + if source.get("source_type") == "legal_article" or detail_path.startswith("/legal-articles"): + refs.append(source) + return refs + + @staticmethod + def _percent(numerator: float, denominator: float) -> float: + if not denominator: + return 0.0 + return round(float(numerator) * 100 / float(denominator), 2) + + @staticmethod + def _iso(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + @staticmethod + def _require_admin(current_user: dict[str, Any]) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") diff --git a/backend/app/services/prompt_asset_service.py b/backend/app/services/prompt_asset_service.py new file mode 100644 index 00000000..411f563b --- /dev/null +++ b/backend/app/services/prompt_asset_service.py @@ -0,0 +1,582 @@ +import json +from datetime import datetime, timezone +from typing import Any, Dict, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_run_link import PromptAssetRunLink +from app.models.prompt_asset_version import PromptAssetVersion +from app.repositories.prompt_asset_repository import PromptAssetRepository +from app.repositories.prompt_asset_run_link_repository import PromptAssetRunLinkRepository +from app.repositories.prompt_asset_version_repository import PromptAssetVersionRepository + + +class PromptAssetService: + PROMPT_TYPES = { + "contract_generation", + "contract_review", + "clause_recommendation", + "legal_chat", + "wording_rewrite", + "regulation_impact", + } + EVALUATION_STATUSES = {"not_evaluated", "pending", "passed", "failed"} + PUBLISHABLE_EVALUATION_STATUSES = {"passed"} + + def __init__(self, db: Session): + self.db = db + self.repository = PromptAssetRepository(db) + self.version_repository = PromptAssetVersionRepository(db) + self.run_link_repository = PromptAssetRunLinkRepository(db) + + def list_prompt_assets( + self, + current_user: Dict[str, Any], + page: int = 1, + page_size: int = 20, + prompt_type: Optional[str] = None, + status_filter: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + normalized_page = max(page, 1) + normalized_page_size = max(min(page_size, 100), 1) + normalized_prompt_type = self._normalize_optional_prompt_type(prompt_type) + normalized_status = status_filter.strip() if status_filter else None + items = self.repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + prompt_type=normalized_prompt_type, + status_filter=normalized_status, + ) + return { + "items": [self._to_item(item) for item in items], + "page": normalized_page, + "page_size": normalized_page_size, + "total": self.repository.count(tenant_id, prompt_type=normalized_prompt_type, status_filter=normalized_status), + "prompt_types": sorted(self.PROMPT_TYPES), + } + + def get_prompt_asset_detail(self, current_user: Dict[str, Any], asset_id: str) -> dict: + asset = self._require_asset(current_user, asset_id) + versions = self.version_repository.list_by_asset_id(current_user["tenant_id"], asset.id) + run_links = self.run_link_repository.list_by_asset_id(current_user["tenant_id"], asset.id, limit=20) + return { + "asset": self._to_item(asset), + "versions": [self._to_version_item(item) for item in versions], + "run_links": [self._to_run_link_item(item) for item in run_links], + } + + def create_prompt_asset( + self, + current_user: Dict[str, Any], + prompt_key: str, + prompt_type: str, + name: str, + prompt_content: str, + description: Optional[str] = None, + target_scene: Optional[str] = None, + system_prompt: Optional[str] = None, + variables_json: Optional[str] = None, + output_schema_json: Optional[str] = None, + model_config_json: Optional[str] = None, + evaluation_report_json: Optional[str] = None, + evaluation_status: Optional[str] = None, + evaluation_score: Optional[float] = None, + change_summary: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + normalized_key = self._normalize_prompt_key(prompt_key) + if self.repository.get_by_key(tenant_id, normalized_key) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="prompt_key already exists") + + now = self._now() + asset = PromptAsset( + id=f"prompt-{uuid4().hex[:12]}", + tenant_id=tenant_id, + prompt_key=normalized_key, + prompt_type=self._normalize_prompt_type(prompt_type), + name=self._normalize_required_text(name, "name")[:160], + description=self._optional_text(description), + target_scene=self._optional_text(target_scene, max_length=160), + status="draft", + current_version_label="v1.0", + latest_change_summary=self._optional_text(change_summary, max_length=500) or "Initial prompt version", + version_count=1, + created_by=current_user.get("id"), + updated_by=current_user.get("id"), + created_at=now, + updated_at=now, + ) + created = self.repository.create(asset) + version = self._create_version_snapshot( + created, + current_user, + prompt_content=prompt_content, + system_prompt=system_prompt, + variables_json=variables_json, + output_schema_json=output_schema_json, + model_config_json=model_config_json, + evaluation_report_json=evaluation_report_json, + evaluation_status=evaluation_status, + evaluation_score=evaluation_score, + change_summary=created.latest_change_summary or "Initial prompt version", + version_label=created.current_version_label, + is_current=True, + ) + created.current_version_id = version.id + self._copy_evaluation_to_asset(created, version) + updated = self.repository.update(created) + return self._to_item(updated) + + def create_prompt_asset_version( + self, + current_user: Dict[str, Any], + asset_id: str, + prompt_content: str, + system_prompt: Optional[str] = None, + variables_json: Optional[str] = None, + output_schema_json: Optional[str] = None, + model_config_json: Optional[str] = None, + evaluation_report_json: Optional[str] = None, + evaluation_status: Optional[str] = None, + evaluation_score: Optional[float] = None, + change_summary: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, asset_id) + next_version_label = self._bump_version_label(asset.current_version_label) + asset.current_version_label = next_version_label + asset.latest_change_summary = self._optional_text(change_summary, max_length=500) or f"{next_version_label} prompt update" + asset.version_count = max(asset.version_count or 0, 1) + 1 + asset.status = "published" if asset.published_version_id else "draft" + asset.rollback_from_version_id = None + asset.updated_by = current_user.get("id") + asset.updated_at = self._now() + updated = self.repository.update(asset) + version = self._create_version_snapshot( + updated, + current_user, + prompt_content=prompt_content, + system_prompt=system_prompt, + variables_json=variables_json, + output_schema_json=output_schema_json, + model_config_json=model_config_json, + evaluation_report_json=evaluation_report_json, + evaluation_status=evaluation_status, + evaluation_score=evaluation_score, + change_summary=updated.latest_change_summary or f"{next_version_label} prompt update", + version_label=next_version_label, + is_current=True, + ) + updated.current_version_id = version.id + self._copy_evaluation_to_asset(updated, version) + return self._to_item(self.repository.update(updated)) + + def publish_prompt_asset( + self, + current_user: Dict[str, Any], + asset_id: str, + evaluation_report_json: Optional[str] = None, + evaluation_status: Optional[str] = None, + evaluation_score: Optional[float] = None, + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, asset_id) + version = self._require_current_version(current_user, asset) + if evaluation_report_json or evaluation_status or evaluation_score is not None: + self._apply_evaluation(version, evaluation_report_json, evaluation_status, evaluation_score) + version = self.version_repository.update(version) + + if version.evaluation_status not in self.PUBLISHABLE_EVALUATION_STATUSES or not version.evaluation_report_json: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="prompt evaluation report is required before publish") + + now = self._now() + self.version_repository.clear_published(asset.tenant_id, asset.id) + version.is_published = True + version.published_at = now + version.published_by = current_user.get("id") + self.version_repository.update(version) + + asset.status = "published" + asset.published_version_id = version.id + asset.published_at = now + asset.published_by = current_user.get("id") + asset.updated_by = current_user.get("id") + asset.updated_at = now + self._copy_evaluation_to_asset(asset, version) + return self._to_item(self.repository.update(asset)) + + def unpublish_prompt_asset(self, current_user: Dict[str, Any], asset_id: str) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, asset_id) + self.version_repository.clear_published(asset.tenant_id, asset.id) + asset.status = "inactive" + asset.published_version_id = None + asset.published_at = None + asset.published_by = None + asset.updated_by = current_user.get("id") + asset.updated_at = self._now() + return self._to_item(self.repository.update(asset)) + + def rollback_prompt_asset( + self, + current_user: Dict[str, Any], + asset_id: str, + target_version_label: str, + change_summary: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, asset_id) + target_version = self.version_repository.get_by_version_label(current_user["tenant_id"], asset.id, target_version_label) + if target_version is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="prompt version not found") + + next_version_label = self._bump_version_label(asset.current_version_label) + asset.current_version_label = next_version_label + asset.latest_change_summary = self._optional_text(change_summary, max_length=500) or f"Rollback to {target_version_label}" + asset.version_count = max(asset.version_count or 0, 1) + 1 + asset.rollback_from_version_id = target_version.id + asset.updated_by = current_user.get("id") + asset.updated_at = self._now() + updated = self.repository.update(asset) + version = self._create_version_snapshot( + updated, + current_user, + prompt_content=target_version.prompt_content, + system_prompt=target_version.system_prompt, + variables_json=self._dump_json(target_version.variables_json), + output_schema_json=self._dump_json(target_version.output_schema_json), + model_config_json=self._dump_json(target_version.model_config_json), + evaluation_report_json=self._dump_json(target_version.evaluation_report_json), + evaluation_status=target_version.evaluation_status, + evaluation_score=target_version.evaluation_score, + change_summary=updated.latest_change_summary or f"Rollback to {target_version_label}", + version_label=next_version_label, + is_current=True, + rollback_from_version_id=target_version.id, + ) + updated.current_version_id = version.id + if version.evaluation_status in self.PUBLISHABLE_EVALUATION_STATUSES and version.evaluation_report_json: + now = self._now() + self.version_repository.clear_published(updated.tenant_id, updated.id) + version.is_published = True + version.published_at = now + version.published_by = current_user.get("id") + self.version_repository.update(version) + updated.status = "published" + updated.published_version_id = version.id + updated.published_at = now + updated.published_by = current_user.get("id") + else: + updated.status = "draft" + updated.published_version_id = None + updated.published_at = None + updated.published_by = None + self._copy_evaluation_to_asset(updated, version) + return self._to_item(self.repository.update(updated)) + + def record_prompt_run_link( + self, + current_user: Dict[str, Any], + asset_id: str, + run_type: str, + run_id: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + workflow_run_id: Optional[str] = None, + task_run_id: Optional[str] = None, + trace_json: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, asset_id) + version_id = asset.published_version_id or asset.current_version_id + if not version_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="prompt asset has no version") + version = self.version_repository.get_by_id(version_id) + if version is None or version.tenant_id != current_user["tenant_id"]: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="prompt version not found") + run_link = PromptAssetRunLink( + id=f"promptrun-{uuid4().hex[:12]}", + tenant_id=asset.tenant_id, + prompt_asset_id=asset.id, + prompt_asset_version_id=version.id, + prompt_type=asset.prompt_type, + run_type=self._optional_text(run_type, max_length=64) or "ai_run", + run_id=self._optional_text(run_id, max_length=100), + resource_type=self._optional_text(resource_type, max_length=64), + resource_id=self._optional_text(resource_id, max_length=100), + workflow_run_id=self._optional_text(workflow_run_id, max_length=100), + task_run_id=self._optional_text(task_run_id, max_length=100), + trace_json=self._parse_optional_json_object(trace_json, "trace_json"), + created_by=current_user.get("id"), + created_at=self._now(), + ) + return self._to_run_link_item(self.run_link_repository.create(run_link)) + + def _require_asset(self, current_user: Dict[str, Any], asset_id: str) -> PromptAsset: + asset = self.repository.get_by_id(asset_id) + if asset is None or asset.tenant_id != current_user["tenant_id"]: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="prompt asset not found") + return asset + + def _require_current_version(self, current_user: Dict[str, Any], asset: PromptAsset) -> PromptAssetVersion: + version = self.version_repository.get_by_version_label(current_user["tenant_id"], asset.id, asset.current_version_label) + if version is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="current prompt version not found") + return version + + def _require_admin(self, current_user: Dict[str, Any]) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + def _create_version_snapshot( + self, + asset: PromptAsset, + current_user: Dict[str, Any], + *, + prompt_content: str, + system_prompt: Optional[str], + variables_json: Optional[str], + output_schema_json: Optional[str], + model_config_json: Optional[str], + evaluation_report_json: Optional[str], + evaluation_status: Optional[str], + evaluation_score: Optional[float], + change_summary: str, + version_label: str, + is_current: bool, + rollback_from_version_id: Optional[str] = None, + ) -> PromptAssetVersion: + if is_current: + self.version_repository.clear_current(asset.tenant_id, asset.id) + version = PromptAssetVersion( + id=f"promptver-{uuid4().hex[:12]}", + tenant_id=asset.tenant_id, + prompt_asset_id=asset.id, + prompt_key=asset.prompt_key, + prompt_type=asset.prompt_type, + version_label=version_label, + prompt_content=self._normalize_required_text(prompt_content, "prompt_content"), + system_prompt=self._optional_text(system_prompt), + variables_json=self._parse_optional_json_array(variables_json, "variables_json"), + output_schema_json=self._parse_optional_json_object(output_schema_json, "output_schema_json"), + model_config_json=self._parse_optional_json_object(model_config_json, "model_config_json"), + evaluation_status="not_evaluated", + evaluation_score=None, + evaluation_report_json=None, + change_summary=self._optional_text(change_summary, max_length=500), + rollback_from_version_id=rollback_from_version_id, + is_current=is_current, + is_published=False, + created_by=current_user.get("id"), + created_by_name=current_user.get("display_name"), + created_at=self._now(), + ) + self._apply_evaluation(version, evaluation_report_json, evaluation_status, evaluation_score) + return self.version_repository.create(version) + + def _apply_evaluation( + self, + version: PromptAssetVersion, + evaluation_report_json: Optional[str], + evaluation_status: Optional[str], + evaluation_score: Optional[float], + ) -> None: + report = self._parse_optional_json_object(evaluation_report_json, "evaluation_report_json") + if report is not None: + version.evaluation_report_json = report + if evaluation_score is not None: + version.evaluation_score = float(evaluation_score) + elif report is not None: + version.evaluation_score = self._read_score_from_report(report) + + normalized_status = self._normalize_evaluation_status(evaluation_status) + if normalized_status: + version.evaluation_status = normalized_status + elif report is not None: + version.evaluation_status = self._infer_evaluation_status(report) + elif version.evaluation_status not in self.EVALUATION_STATUSES: + version.evaluation_status = "not_evaluated" + + def _copy_evaluation_to_asset(self, asset: PromptAsset, version: PromptAssetVersion) -> None: + asset.latest_evaluation_status = version.evaluation_status + asset.latest_evaluation_score = version.evaluation_score + asset.latest_evaluation_report_json = version.evaluation_report_json + + def _normalize_prompt_key(self, value: str) -> str: + normalized = self._normalize_required_text(value, "prompt_key").lower() + normalized = "".join(char if char.isalnum() else "_" for char in normalized) + normalized = "_".join(part for part in normalized.split("_") if part) + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="prompt_key is required") + return normalized[:100] + + def _normalize_prompt_type(self, value: str) -> str: + normalized = self._normalize_required_text(value, "prompt_type") + if normalized not in self.PROMPT_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid prompt_type") + return normalized + + def _normalize_optional_prompt_type(self, value: Optional[str]) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return self._normalize_prompt_type(str(value)) + + def _normalize_evaluation_status(self, value: Optional[str]) -> Optional[str]: + if value is None or not str(value).strip(): + return None + normalized = str(value).strip() + if normalized not in self.EVALUATION_STATUSES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid evaluation_status") + return normalized + + def _normalize_required_text(self, value: Optional[str], field_name: str) -> str: + normalized = (value or "").strip() + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required") + return normalized + + def _optional_text(self, value: Optional[str], max_length: Optional[int] = None) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + return None + if max_length is not None: + return normalized[:max_length] + return normalized + + def _parse_optional_json_object(self, value: Optional[str], field_name: str) -> Optional[dict[str, Any]]: + if value is None or not str(value).strip(): + return None + try: + payload = json.loads(value) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a valid JSON object") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a valid JSON object") + return payload + + def _parse_optional_json_array(self, value: Optional[str], field_name: str) -> Optional[list[dict[str, Any]]]: + if value is None or not str(value).strip(): + return None + try: + payload = json.loads(value) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a valid JSON array") from exc + if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a JSON array of objects") + return payload + + def _dump_json(self, value: Any) -> Optional[str]: + if value is None: + return None + return json.dumps(value, ensure_ascii=False) + + def _read_score_from_report(self, report: dict[str, Any]) -> Optional[float]: + for key in ("quality_score", "score", "pass_rate"): + if key in report and report[key] is not None: + try: + return float(report[key]) + except (TypeError, ValueError): + return None + return None + + def _infer_evaluation_status(self, report: dict[str, Any]) -> str: + if report.get("passed") is True: + return "passed" + if report.get("passed") is False: + return "failed" + score = self._read_score_from_report(report) + if score is not None and score >= 0.8: + return "passed" + return "failed" + + def _bump_version_label(self, current_version_label: Optional[str]) -> str: + if not current_version_label: + return "v1.1" + major_minor = current_version_label.strip().lstrip("v").split(".") + if len(major_minor) != 2 or not all(part.isdigit() for part in major_minor): + return "v1.1" + return f"v{major_minor[0]}.{int(major_minor[1]) + 1}" + + def _now(self) -> datetime: + return datetime.now(timezone.utc) + + def _to_item(self, asset: PromptAsset) -> dict: + return { + "id": asset.id, + "tenant_id": asset.tenant_id, + "prompt_key": asset.prompt_key, + "prompt_type": asset.prompt_type, + "name": asset.name, + "description": asset.description, + "target_scene": asset.target_scene, + "status": asset.status, + "current_version_label": asset.current_version_label, + "current_version_id": asset.current_version_id, + "published_version_id": asset.published_version_id, + "published_at": asset.published_at.isoformat() if asset.published_at else None, + "published_by": asset.published_by, + "rollback_from_version_id": asset.rollback_from_version_id, + "latest_change_summary": asset.latest_change_summary, + "latest_evaluation_status": asset.latest_evaluation_status, + "latest_evaluation_score": asset.latest_evaluation_score, + "latest_evaluation_report": asset.latest_evaluation_report_json, + "version_count": max(asset.version_count or 0, 1), + "created_by": asset.created_by, + "updated_by": asset.updated_by, + "created_at": asset.created_at.isoformat(), + "updated_at": asset.updated_at.isoformat(), + } + + def _to_version_item(self, version: PromptAssetVersion) -> dict: + return { + "id": version.id, + "tenant_id": version.tenant_id, + "prompt_asset_id": version.prompt_asset_id, + "prompt_key": version.prompt_key, + "prompt_type": version.prompt_type, + "version_label": version.version_label, + "prompt_content": version.prompt_content, + "system_prompt": version.system_prompt, + "variables": version.variables_json or [], + "output_schema": version.output_schema_json, + "model_config": version.model_config_json, + "evaluation_status": version.evaluation_status, + "evaluation_score": version.evaluation_score, + "evaluation_report": version.evaluation_report_json, + "change_summary": version.change_summary, + "rollback_from_version_id": version.rollback_from_version_id, + "is_current": version.is_current, + "is_published": version.is_published, + "created_by": version.created_by, + "created_by_name": version.created_by_name, + "created_at": version.created_at.isoformat(), + "published_at": version.published_at.isoformat() if version.published_at else None, + "published_by": version.published_by, + } + + def _to_run_link_item(self, run_link: PromptAssetRunLink) -> dict: + return { + "id": run_link.id, + "tenant_id": run_link.tenant_id, + "prompt_asset_id": run_link.prompt_asset_id, + "prompt_asset_version_id": run_link.prompt_asset_version_id, + "prompt_type": run_link.prompt_type, + "run_type": run_link.run_type, + "run_id": run_link.run_id, + "resource_type": run_link.resource_type, + "resource_id": run_link.resource_id, + "workflow_run_id": run_link.workflow_run_id, + "task_run_id": run_link.task_run_id, + "trace": run_link.trace_json, + "created_by": run_link.created_by, + "created_at": run_link.created_at.isoformat(), + } diff --git a/backend/app/services/prompt_runtime_service.py b/backend/app/services/prompt_runtime_service.py new file mode 100644 index 00000000..49fd5809 --- /dev/null +++ b/backend/app/services/prompt_runtime_service.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, Optional +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_run_link import PromptAssetRunLink +from app.models.prompt_asset_version import PromptAssetVersion +from app.repositories.prompt_asset_repository import PromptAssetRepository +from app.repositories.prompt_asset_run_link_repository import PromptAssetRunLinkRepository +from app.repositories.prompt_asset_version_repository import PromptAssetVersionRepository + + +class PromptRuntimeService: + """Resolve published prompt assets for runtime AI calls.""" + + def __init__(self, db: Optional[Session]): + self.db = db + self.repository = PromptAssetRepository(db) if db is not None else None + self.version_repository = PromptAssetVersionRepository(db) if db is not None else None + self.run_link_repository = PromptAssetRunLinkRepository(db) if db is not None else None + + def resolve_published_prompt( + self, + current_user: Dict[str, Any], + prompt_type: str, + *, + preferred_keys: Optional[Iterable[Optional[str]]] = None, + fallback_prompt_version: Optional[str] = None, + ) -> Dict[str, Any]: + tenant_id = str(current_user.get("tenant_id") or "").strip() + normalized_type = self._normalize_token(prompt_type) + if not tenant_id or not normalized_type or self.repository is None or self.version_repository is None: + return self._fallback_runtime(normalized_type, fallback_prompt_version) + + try: + for prompt_key in self._normalize_keys(preferred_keys): + asset = self.repository.get_published_by_key(tenant_id, prompt_key) + runtime = self._runtime_from_asset(asset) + if runtime.get("available"): + runtime["match_strategy"] = "preferred_key" + return runtime + + for asset in self.repository.list_published_by_type(tenant_id, normalized_type, limit=20): + runtime = self._runtime_from_asset(asset) + if runtime.get("available"): + runtime["match_strategy"] = "prompt_type" + return runtime + except Exception: + return self._fallback_runtime(normalized_type, fallback_prompt_version) + + return self._fallback_runtime(normalized_type, fallback_prompt_version) + + def record_run_link( + self, + current_user: Dict[str, Any], + prompt_runtime: Optional[Dict[str, Any]], + *, + run_type: str, + run_id: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + workflow_run_id: Optional[str] = None, + task_run_id: Optional[str] = None, + trace: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + if self.run_link_repository is None or not prompt_runtime or not prompt_runtime.get("available"): + return None + try: + run_link = PromptAssetRunLink( + id=f"promptrun-{uuid4().hex[:12]}", + tenant_id=str(current_user.get("tenant_id") or prompt_runtime.get("tenant_id") or ""), + prompt_asset_id=str(prompt_runtime["prompt_asset_id"]), + prompt_asset_version_id=str(prompt_runtime["prompt_asset_version_id"]), + prompt_type=str(prompt_runtime.get("prompt_type") or ""), + run_type=self._clip(run_type, 64) or "ai_run", + run_id=self._clip(run_id, 100), + resource_type=self._clip(resource_type, 64), + resource_id=self._clip(resource_id, 100), + workflow_run_id=self._clip(workflow_run_id, 100), + task_run_id=self._clip(task_run_id, 100), + trace_json=self._build_trace(prompt_runtime, trace), + created_by=current_user.get("id"), + created_at=datetime.now(timezone.utc), + ) + created = self.run_link_repository.create(run_link) + return { + "id": created.id, + "prompt_asset_id": created.prompt_asset_id, + "prompt_asset_version_id": created.prompt_asset_version_id, + "prompt_type": created.prompt_type, + "run_type": created.run_type, + "run_id": created.run_id, + "resource_type": created.resource_type, + "resource_id": created.resource_id, + "workflow_run_id": created.workflow_run_id, + "task_run_id": created.task_run_id, + } + except Exception: + return None + + def to_trace(self, prompt_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not prompt_runtime: + return self._fallback_runtime("", None) + keys = { + "available", + "tenant_id", + "prompt_asset_id", + "prompt_asset_version_id", + "prompt_key", + "prompt_type", + "version_label", + "prompt_version", + "template_key", + "target_scene", + "published_at", + "published_by", + "evaluation_status", + "evaluation_score", + "match_strategy", + "source", + } + return {key: prompt_runtime.get(key) for key in keys if key in prompt_runtime} + + def _runtime_from_asset(self, asset: Optional[PromptAsset]) -> Dict[str, Any]: + if asset is None or self.version_repository is None or not asset.published_version_id: + return self._fallback_runtime("", None) + version = self.version_repository.get_by_id(asset.published_version_id) + if version is None or version.tenant_id != asset.tenant_id or not version.is_published: + return self._fallback_runtime(asset.prompt_type, None) + return self._runtime_from_version(asset, version) + + def _runtime_from_version(self, asset: PromptAsset, version: PromptAssetVersion) -> Dict[str, Any]: + prompt_version = f"{asset.prompt_key}.{version.version_label}" + model_config = version.model_config_json or {} + return { + "available": True, + "tenant_id": asset.tenant_id, + "prompt_asset_id": asset.id, + "prompt_asset_version_id": version.id, + "prompt_key": asset.prompt_key, + "prompt_type": asset.prompt_type, + "version_label": version.version_label, + "prompt_version": prompt_version, + "template_key": asset.prompt_key, + "target_scene": asset.target_scene, + "prompt_content": version.prompt_content, + "system_prompt": version.system_prompt, + "variables": version.variables_json or [], + "output_schema": version.output_schema_json, + "model_config": model_config, + "evaluation_status": version.evaluation_status, + "evaluation_score": version.evaluation_score, + "evaluation_report": version.evaluation_report_json, + "published_at": version.published_at.isoformat() if version.published_at else None, + "published_by": version.published_by, + "provider": model_config.get("provider"), + "model": model_config.get("model"), + "source": "published_prompt_asset", + } + + def _fallback_runtime(self, prompt_type: str, fallback_prompt_version: Optional[str]) -> Dict[str, Any]: + normalized_type = self._normalize_token(prompt_type) + return { + "available": False, + "prompt_type": normalized_type, + "prompt_version": fallback_prompt_version or (f"{normalized_type}.local_default" if normalized_type else "local_default"), + "template_key": None, + "source": "local_default", + } + + def _build_trace(self, prompt_runtime: Dict[str, Any], trace: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(trace or {}) + payload["prompt_runtime"] = self.to_trace(prompt_runtime) + return payload + + def _normalize_keys(self, values: Optional[Iterable[Optional[str]]]) -> list[str]: + keys: list[str] = [] + for value in values or []: + normalized = self._normalize_key(value) + if normalized and normalized not in keys: + keys.append(normalized) + return keys + + @staticmethod + def _normalize_key(value: Optional[str]) -> str: + normalized = PromptRuntimeService._normalize_token(value) + normalized = "".join(char if char.isalnum() else "_" for char in normalized) + return "_".join(part for part in normalized.split("_") if part)[:100] + + @staticmethod + def _normalize_token(value: Optional[str]) -> str: + return str(value or "").strip().lower() + + @staticmethod + def _clip(value: Optional[str], length: int) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + return normalized[:length] if normalized else None diff --git a/backend/app/services/review_risk_action_service.py b/backend/app/services/review_risk_action_service.py new file mode 100644 index 00000000..7d9ad98a --- /dev/null +++ b/backend/app/services/review_risk_action_service.py @@ -0,0 +1,420 @@ +from datetime import datetime, timezone +from hashlib import sha1 +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.generated_contract_version import GeneratedContractVersion +from app.models.review_risk_action import ReviewRiskAction +from app.models.review_task import ReviewTask +from app.repositories.generated_contract_repository import GeneratedContractRepository +from app.repositories.generated_contract_version_repository import GeneratedContractVersionRepository +from app.repositories.review_result_repository import ReviewResultRepository +from app.repositories.review_risk_action_repository import ReviewRiskActionRepository +from app.repositories.review_task_repository import ReviewTaskRepository + + +VALID_REVIEW_RISK_ACTION_STATUS = {"pending", "adopted", "modified", "ignored", "manual_required"} +VALID_REVIEW_RISK_ACTION_APPLY_MODE = {"adopt", "modify"} + + +class ReviewRiskActionService: + def __init__(self, db: Session): + self.db = db + self.repository = ReviewRiskActionRepository(db) if db is not None else None + self.review_task_repository = ReviewTaskRepository(db) if db is not None else None + self.review_result_repository = ReviewResultRepository(db) if db is not None else None + self.generated_contract_repository = GeneratedContractRepository(db) if db is not None else None + self.generated_contract_version_repository = GeneratedContractVersionRepository(db) if db is not None else None + + def list_actions(self, current_user: dict, review_id: str) -> dict[str, Any]: + review = self._require_accessible_review(current_user, review_id) + self._ensure_actions_for_review(current_user, review) + items = self._require_repository().list_by_review_id(review.tenant_id, review.id) + return { + "review_id": review.id, + "items": [self._to_item(item) for item in items], + "total": len(items), + "summary": self._build_summary(items), + } + + def update_action( + self, + current_user: dict, + review_id: str, + action_id: str, + *, + action_status: Optional[str] = None, + user_modified_text: Optional[str] = None, + skip_reason: Optional[str] = None, + applied_contract_version_id: Optional[str] = None, + ) -> dict[str, Any]: + review = self._require_accessible_review(current_user, review_id) + self._ensure_actions_for_review(current_user, review) + action = self._require_repository().get_by_id(review.tenant_id, action_id) + if action is None or action.review_id != review.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="review risk action not found") + if action_status is not None: + action.action_status = self._normalize_action_status(action_status) + if user_modified_text is not None: + action.user_modified_text = self._optional_text(user_modified_text) + if skip_reason is not None: + action.skip_reason = self._optional_text(skip_reason) + if applied_contract_version_id is not None: + action.applied_contract_version_id = self._optional_text(applied_contract_version_id) + action.updated_by = current_user.get("id") + action.updated_at = self._now() + updated = self._require_repository().update(action) + return self._to_item(updated) + + def apply_action( + self, + current_user: dict, + review_id: str, + action_id: str, + *, + contract_id: Optional[str] = None, + action_mode: str = "adopt", + replacement_text: Optional[str] = None, + match_text: Optional[str] = None, + change_summary: Optional[str] = None, + ) -> dict[str, Any]: + review = self._require_accessible_review(current_user, review_id) + self._ensure_actions_for_review(current_user, review) + action = self._require_repository().get_by_id(review.tenant_id, action_id) + if action is None or action.review_id != review.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="review risk action not found") + + contract = self._resolve_generated_contract_for_review(review, contract_id) + if contract is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="generated contract source is required") + if current_user.get("role") != "admin" and contract.generated_by != current_user.get("id"): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="generated contract not found") + + normalized_mode = self._normalize_apply_mode(action_mode) + replacement = self._resolve_replacement_text(action, normalized_mode, replacement_text) + if not replacement: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="replacement text is required") + + source_version = self._require_generated_contract_version_repository().get_latest_by_contract( + review.tenant_id, + contract.id, + ) + source_content = source_version.content if source_version is not None else contract.generated_content + + resolved_match_text = self._resolve_match_text(action, match_text) + if not resolved_match_text or resolved_match_text not in source_content: + updated_action = self._mark_action_manual_required( + current_user=current_user, + action=action, + normalized_mode=normalized_mode, + replacement=replacement, + ) + return { + "review_id": review.id, + "action": self._to_item(updated_action), + "version": None, + "locate_status": "manual_required", + "manual_confirmation_required": True, + "message": "无法自动定位原文,需要人工或代理确认后再应用。", + "match_text": resolved_match_text, + "replacement_text": replacement, + } + + next_version_number = self._require_generated_contract_version_repository().next_version_number( + review.tenant_id, + contract.id, + ) + next_content = source_content.replace(resolved_match_text, replacement, 1) + contract_version = self._require_generated_contract_version_repository().create( + GeneratedContractVersion( + id=f"gcv-{uuid4().hex[:12]}", + tenant_id=review.tenant_id, + contract_id=contract.id, + review_id=review.id, + review_risk_action_id=action.id, + version_number=next_version_number, + version_label=f"v{next_version_number}", + source_version_id=source_version.id if source_version is not None else None, + change_type="review_suggestion", + change_summary=self._optional_text(change_summary) + or self._default_change_summary(action, normalized_mode), + original_text=resolved_match_text, + replacement_text=replacement, + content=next_content, + locate_status="applied", + created_by=current_user.get("id"), + created_at=self._now(), + ) + ) + + action.action_status = "modified" if normalized_mode == "modify" else "adopted" + if normalized_mode == "modify": + action.user_modified_text = replacement + action.skip_reason = None + action.applied_contract_version_id = contract_version.id + action.updated_by = current_user.get("id") + action.updated_at = self._now() + updated_action = self._require_repository().update(action) + + return { + "review_id": review.id, + "action": self._to_item(updated_action), + "version": self._to_version_item(contract_version), + "locate_status": "applied", + "manual_confirmation_required": False, + "detail_url": f"/contracts/{contract.id}?version={contract_version.id}", + } + + def _ensure_actions_for_review(self, current_user: dict, review: ReviewTask) -> None: + repository = self._require_repository() + for index, risk_item in enumerate(self._risk_items_for_review(review.id), start=1): + risk_item_id = self._risk_item_id(risk_item, index) + if repository.get_by_risk_item(review.tenant_id, review.id, risk_item_id) is not None: + continue + repository.create( + ReviewRiskAction( + id=f"rra-{uuid4().hex[:12]}", + tenant_id=review.tenant_id, + review_id=review.id, + risk_item_id=risk_item_id, + risk_item_index=index, + risk_item_title=self._optional_text(risk_item.get("title") or risk_item.get("description")), + risk_level=self._optional_text(risk_item.get("level") or risk_item.get("risk_level")), + action_status="pending", + suggested_text=self._optional_text(risk_item.get("suggestion") or risk_item.get("description")), + risk_snapshot_json=risk_item, + created_by=current_user.get("id"), + updated_by=current_user.get("id"), + created_at=self._now(), + updated_at=self._now(), + ) + ) + + def _risk_items_for_review(self, review_id: str) -> list[dict[str, Any]]: + repository = self._require_review_result_repository() + review_result = repository.get_by_review_task_id(review_id) + if review_result is None: + return [] + risk_items = review_result.risk_items + return [item for item in risk_items if isinstance(item, dict)] if isinstance(risk_items, list) else [] + + def _require_accessible_review(self, current_user: dict, review_id: str) -> ReviewTask: + self._require_authenticated(current_user) + repository = self._require_review_task_repository() + review = repository.get_by_id( + review_id, + tenant_id=current_user["tenant_id"], + user_id=None if current_user.get("role") == "admin" else current_user["id"], + ) + if review is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="review not found") + return review + + def _require_repository(self) -> ReviewRiskActionRepository: + if self.repository is None: + raise RuntimeError("review risk action repository is not configured") + return self.repository + + def _require_review_task_repository(self) -> ReviewTaskRepository: + if self.review_task_repository is None: + raise RuntimeError("review task repository is not configured") + return self.review_task_repository + + def _require_review_result_repository(self) -> ReviewResultRepository: + if self.review_result_repository is None: + raise RuntimeError("review result repository is not configured") + return self.review_result_repository + + def _require_generated_contract_repository(self) -> GeneratedContractRepository: + if self.generated_contract_repository is None: + raise RuntimeError("generated contract repository is not configured") + return self.generated_contract_repository + + def _require_generated_contract_version_repository(self) -> GeneratedContractVersionRepository: + if self.generated_contract_version_repository is None: + raise RuntimeError("generated contract version repository is not configured") + return self.generated_contract_version_repository + + def _resolve_generated_contract_for_review(self, review: ReviewTask, contract_id: Optional[str]): + repository = self._require_generated_contract_repository() + normalized_contract_id = self._optional_text(contract_id) + if normalized_contract_id: + return repository.get_by_id(normalized_contract_id, review.tenant_id) + if review.source_file_id: + return repository.get_by_file_id(review.source_file_id, review.tenant_id) + return None + + @staticmethod + def _require_authenticated(current_user: dict) -> None: + if not current_user or not current_user.get("id") or not current_user.get("tenant_id"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + + @staticmethod + def _risk_item_id(risk_item: dict[str, Any], index: int) -> str: + for key in ("id", "risk_id", "risk_item_id", "clause_id"): + value = risk_item.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + title = str(risk_item.get("title") or "").strip() + if title: + return f"risk-{index}-{sha1(title.encode('utf-8')).hexdigest()[:8]}" + return f"risk-{index}" + + @staticmethod + def _normalize_action_status(value: str) -> str: + normalized = str(value or "").strip().lower() + if normalized not in VALID_REVIEW_RISK_ACTION_STATUS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid review risk action status") + return normalized + + @staticmethod + def _normalize_apply_mode(value: str) -> str: + normalized = str(value or "adopt").strip().lower() + if normalized not in VALID_REVIEW_RISK_ACTION_APPLY_MODE: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid review risk action apply mode") + return normalized + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None or not str(value).strip(): + return None + return str(value).strip() + + @staticmethod + def _build_summary(items: list[ReviewRiskAction]) -> dict[str, int]: + summary = {status_value: 0 for status_value in sorted(VALID_REVIEW_RISK_ACTION_STATUS)} + for item in items: + summary[item.action_status] = summary.get(item.action_status, 0) + 1 + summary["processed"] = sum(summary.get(status_value, 0) for status_value in ("adopted", "modified", "ignored")) + summary["pending"] = summary.get("pending", 0) + return summary + + def _mark_action_manual_required( + self, + *, + current_user: dict, + action: ReviewRiskAction, + normalized_mode: str, + replacement: str, + ) -> ReviewRiskAction: + action.action_status = "manual_required" + if normalized_mode == "modify": + action.user_modified_text = replacement + action.skip_reason = None + action.updated_by = current_user.get("id") + action.updated_at = self._now() + return self._require_repository().update(action) + + @classmethod + def _resolve_replacement_text( + cls, + action: ReviewRiskAction, + normalized_mode: str, + replacement_text: Optional[str], + ) -> Optional[str]: + if normalized_mode == "modify": + return cls._optional_text(replacement_text) or cls._optional_text(action.user_modified_text) + return cls._optional_text(action.suggested_text) + + @classmethod + def _resolve_match_text(cls, action: ReviewRiskAction, explicit_match_text: Optional[str]) -> Optional[str]: + explicit = cls._optional_text(explicit_match_text) + if explicit: + return explicit + snapshot = action.risk_snapshot_json or {} + for path in ( + ("evidence", "quote"), + ("evidence", "text"), + ("clause", "content"), + ("clause", "text"), + ): + candidate = cls._nested_text(snapshot, path) + if candidate: + return candidate + for key in ( + "original_text", + "clause_text", + "clause_content", + "matched_text", + "evidence_text", + "quote", + ): + candidate = cls._optional_text(snapshot.get(key)) + if candidate: + return candidate + return None + + @classmethod + def _nested_text(cls, payload: dict[str, Any], path: tuple[str, str]) -> Optional[str]: + current: Any = payload + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return cls._optional_text(current) + + @staticmethod + def _default_change_summary(action: ReviewRiskAction, normalized_mode: str) -> str: + verb = "修改" if normalized_mode == "modify" else "采纳" + title = action.risk_item_title or action.risk_item_id + return f"{verb}审查建议:{title}" + + @staticmethod + def _to_version_item(version: GeneratedContractVersion) -> 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}", + } + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + def _to_item(self, action: ReviewRiskAction) -> dict[str, Any]: + applied_version = None + if action.applied_contract_version_id and self.generated_contract_version_repository is not None: + applied_version = self.generated_contract_version_repository.get_by_version_id( + action.tenant_id, + action.applied_contract_version_id, + ) + return { + "id": action.id, + "tenant_id": action.tenant_id, + "review_id": action.review_id, + "risk_item_id": action.risk_item_id, + "risk_item_index": action.risk_item_index, + "risk_item_title": action.risk_item_title, + "risk_level": action.risk_level, + "action_status": action.action_status, + "suggested_text": action.suggested_text, + "user_modified_text": action.user_modified_text, + "skip_reason": action.skip_reason, + "applied_contract_version_id": action.applied_contract_version_id, + "applied_contract_version": self._to_version_item(applied_version) if applied_version else None, + "applied_contract_version_detail_url": ( + f"/contracts/{applied_version.contract_id}?version={applied_version.id}" if applied_version else None + ), + "risk_snapshot": action.risk_snapshot_json or {}, + "created_by": action.created_by, + "updated_by": action.updated_by, + "created_at": action.created_at.isoformat() if action.created_at else None, + "updated_at": action.updated_at.isoformat() if action.updated_at else None, + } diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index 64f25ead..f43e28de 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -36,6 +36,7 @@ from app.repositories.review_result_repository import ReviewResultRepository from app.services.file_service import FileService from app.services.legal_article_service import LegalArticleService from app.services.legal_review_execution_gateway import build_legal_review_execution_gateway +from app.services.prompt_runtime_service import PromptRuntimeService from app.repositories.review_rule_repository import ReviewRuleRepository from app.repositories.review_task_repository import ReviewTaskRepository @@ -444,6 +445,7 @@ class ReviewService: self.review_result_repository = ReviewResultRepository(db) self.legal_article_repository = LegalArticleRepository(db) self.file_service = FileService(db) + self.prompt_runtime_service = PromptRuntimeService(db) def _get_legal_review_execution_gateway(self): return build_legal_review_execution_gateway(self.db) @@ -2009,14 +2011,25 @@ class ReviewService: review.completed_at = datetime.now(timezone.utc) return self.repository.update(review) + runtime_user = {"id": review.user_id, "tenant_id": review.tenant_id, "role": "system"} + prompt_runtime = self.prompt_runtime_service.resolve_published_prompt( + runtime_user, + "contract_review", + preferred_keys=[ + f"contract_review_{review.contract_type}" if review.contract_type else None, + "contract_review_default", + ], + fallback_prompt_version="legal-review.v1", + ) + setattr(review, "_prompt_runtime", prompt_runtime) result = self._get_legal_review_execution_gateway().run_review(review) result = self._enrich_review_result(review, result, getattr(review, "_extraction_metadata", None)) risk_items = self._attach_legal_article_refs(result["risk_items"], review.tenant_id) result["risk_items"] = risk_items risk_summary = result["risk_summary"] confidence_summary = result.get("confidence_summary") + confidence_summary = self._merge_prompt_runtime_into_confidence(confidence_summary, prompt_runtime) if confidence_summary: - confidence_summary = dict(confidence_summary) legal_article_refs = self._collect_unique_legal_article_refs(risk_items) confidence_summary["report_download_metadata"] = self._build_report_download_metadata(legal_article_refs) if result.get("rendered_report"): @@ -2041,9 +2054,51 @@ class ReviewService: report_file_id=None, ) self.review_result_repository.create(review_result) + self.prompt_runtime_service.record_run_link( + runtime_user, + prompt_runtime, + run_type="contract_review", + run_id=review.id, + resource_type="review_task", + resource_id=review.id, + workflow_run_id=confidence_summary.get("workflow_run_id") if confidence_summary else None, + task_run_id=confidence_summary.get("task_run_id") if confidence_summary else None, + trace={ + "review_id": review.id, + "contract_type": review.contract_type, + "review_as": review.review_as, + "risk_level": review.risk_level, + "risk_item_count": len(risk_items), + }, + ) return self.repository.update(review) + def _merge_prompt_runtime_into_confidence( + self, + confidence_summary: Optional[Dict[str, Any]], + prompt_runtime: Optional[Dict[str, Any]], + ) -> Optional[Dict[str, Any]]: + if not confidence_summary and not prompt_runtime: + return None + merged = dict(confidence_summary or {}) + prompt_trace = self.prompt_runtime_service.to_trace(prompt_runtime) + merged["prompt_runtime"] = prompt_trace + if prompt_runtime and prompt_runtime.get("available"): + merged["prompt_asset_id"] = prompt_runtime.get("prompt_asset_id") + merged["prompt_asset_version_id"] = prompt_runtime.get("prompt_asset_version_id") + merged["prompt_key"] = prompt_runtime.get("prompt_key") + merged["prompt_version"] = prompt_runtime.get("prompt_version") + merged["template_source"] = "published_prompt_asset" + if prompt_runtime.get("provider") and not merged.get("provider"): + merged["provider"] = prompt_runtime.get("provider") + if prompt_runtime.get("model") and not merged.get("model"): + merged["model"] = prompt_runtime.get("model") + else: + merged.setdefault("prompt_version", prompt_trace.get("prompt_version") or "legal-review.v1") + merged.setdefault("template_source", "local_default") + return merged + def _merge_confidence_summary(self, provider_confidence: Optional[Dict[str, Any]], extraction_metadata: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: if not provider_confidence and not extraction_metadata: return None diff --git a/backend/app/services/sales_followup_service.py b/backend/app/services/sales_followup_service.py new file mode 100644 index 00000000..1523801a --- /dev/null +++ b/backend/app/services/sales_followup_service.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.commercial_invoice import CommercialInvoice +from app.models.commercial_order import CommercialOrder +from app.models.sales_followup_event import SalesFollowupEvent +from app.services.tenant_service import TenantService + +VALID_FOLLOWUP_PRIORITIES = {"low", "medium", "high", "critical"} +VALID_FOLLOWUP_STATUSES = {"open", "in_progress", "resolved", "ignored"} +AUTO_RESOLVABLE_EVENT_TYPES = {"billing_warning", "order_pending_payment", "invoice_requested"} + + +class SalesFollowupService: + def __init__(self, db: Session): + self.db = db + + def list_events(self, current_user: dict, status_value: Optional[str] = None, limit: int = 100) -> list[dict]: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + normalized_limit = min(max(int(limit or 100), 1), 200) + query = self.db.query(SalesFollowupEvent).filter(SalesFollowupEvent.tenant_id == tenant_id) + normalized_status = self._normalize_optional_status(status_value) + if normalized_status: + query = query.filter(SalesFollowupEvent.status == normalized_status) + events = ( + query.order_by( + SalesFollowupEvent.status.asc(), + SalesFollowupEvent.due_at.is_(None).asc(), + SalesFollowupEvent.due_at.asc(), + SalesFollowupEvent.created_at.desc(), + ) + .limit(normalized_limit) + .all() + ) + return [self._to_item(event) for event in events] + + def create_manual_event( + self, + current_user: dict, + event_type: str, + title: str, + description: Optional[str] = None, + priority: str = "medium", + status_value: str = "open", + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + resource_label: Optional[str] = None, + contact_name: Optional[str] = None, + contact_phone: Optional[str] = None, + contact_email: Optional[str] = None, + next_action: Optional[str] = None, + note: Optional[str] = None, + assigned_to: Optional[str] = None, + due_at: Optional[datetime] = None, + ) -> dict: + self._require_admin(current_user) + now = datetime.now(timezone.utc) + normalized_event_type = self._normalize_event_type(event_type or "manual") + event = SalesFollowupEvent( + id=f"sales-followup-{uuid4().hex}", + tenant_id=current_user["tenant_id"], + event_key=f"manual:{uuid4().hex}", + event_type=normalized_event_type, + source="manual", + priority=self._normalize_priority(priority), + status=self._normalize_status(status_value), + title=self._normalize_required_text(title, "title"), + description=self._normalize_text(description), + resource_type=self._normalize_text(resource_type), + resource_id=self._normalize_text(resource_id), + resource_label=self._normalize_text(resource_label), + contact_name=self._normalize_text(contact_name), + contact_phone=self._normalize_text(contact_phone), + contact_email=self._normalize_text(contact_email), + next_action=self._normalize_text(next_action), + note=self._normalize_text(note), + assigned_to=self._normalize_text(assigned_to), + due_at=self._normalize_datetime(due_at), + metadata_json={"manual": True}, + created_by=current_user.get("id"), + created_at=now, + updated_at=now, + ) + if event.status in {"resolved", "ignored"}: + event.resolved_by = current_user.get("id") + event.resolved_at = now + self.db.add(event) + self.db.commit() + self.db.refresh(event) + return self._to_item(event) + + def update_event( + self, + current_user: dict, + event_id: str, + status_value: Optional[str] = None, + priority: Optional[str] = None, + assigned_to: Optional[str] = None, + due_at: Optional[datetime] = None, + next_action: Optional[str] = None, + note: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + event = self._get_event(current_user["tenant_id"], event_id) + now = datetime.now(timezone.utc) + if status_value is not None: + event.status = self._normalize_status(status_value) + if event.status in {"resolved", "ignored"}: + event.resolved_by = current_user.get("id") + event.resolved_at = now + else: + event.resolved_by = None + event.resolved_at = None + if priority is not None: + event.priority = self._normalize_priority(priority) + if assigned_to is not None: + event.assigned_to = self._normalize_text(assigned_to) + if due_at is not None: + event.due_at = self._normalize_datetime(due_at) + if next_action is not None: + event.next_action = self._normalize_text(next_action) + if note is not None: + event.note = self._normalize_text(note) + event.updated_at = now + self.db.add(event) + self.db.commit() + self.db.refresh(event) + return self._to_item(event) + + def sync_commercial_signals(self, current_user: dict, limit: int = 100) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + now = datetime.now(timezone.utc) + max_items = min(max(int(limit or 100), 1), 200) + candidates = self._build_commercial_signal_candidates(current_user, now, max_items) + active_keys: set[str] = set() + created = 0 + updated = 0 + + for candidate in candidates: + active_keys.add(candidate["event_key"]) + event = ( + self.db.query(SalesFollowupEvent) + .filter(SalesFollowupEvent.tenant_id == tenant_id, SalesFollowupEvent.event_key == candidate["event_key"]) + .first() + ) + if event is None: + event = SalesFollowupEvent( + id=f"sales-followup-{uuid4().hex}", + tenant_id=tenant_id, + event_key=candidate["event_key"], + event_type=candidate["event_type"], + source=candidate["source"], + priority=candidate["priority"], + status="open", + title=candidate["title"], + description=candidate.get("description"), + resource_type=candidate.get("resource_type"), + resource_id=candidate.get("resource_id"), + resource_label=candidate.get("resource_label"), + contact_name=candidate.get("contact_name"), + contact_phone=candidate.get("contact_phone"), + contact_email=candidate.get("contact_email"), + next_action=candidate.get("next_action"), + due_at=candidate.get("due_at"), + metadata_json=candidate.get("metadata_json"), + created_by=current_user.get("id"), + created_at=now, + updated_at=now, + ) + self.db.add(event) + created += 1 + continue + + event.title = candidate["title"] + event.description = candidate.get("description") + event.priority = candidate["priority"] + event.resource_type = candidate.get("resource_type") + event.resource_id = candidate.get("resource_id") + event.resource_label = candidate.get("resource_label") + event.contact_name = candidate.get("contact_name") + event.contact_phone = candidate.get("contact_phone") + event.contact_email = candidate.get("contact_email") + event.next_action = candidate.get("next_action") + event.due_at = candidate.get("due_at") + event.metadata_json = candidate.get("metadata_json") + event.updated_at = now + updated += 1 + self.db.add(event) + + resolved = self._resolve_stale_auto_events(tenant_id, active_keys, current_user.get("id"), now) + self.db.commit() + events = self.list_events(current_user, limit=max_items) + return { + "created_count": created, + "updated_count": updated, + "resolved_count": resolved, + "active_signal_count": len(active_keys), + "events": events, + } + + def _build_commercial_signal_candidates(self, current_user: dict, now: datetime, limit: int) -> list[dict]: + tenant_service = TenantService(self.db) + summary = tenant_service.get_current_billing(current_user) + candidates: list[dict] = [] + + for warning in summary.get("warnings", []): + warning_code = str(warning.get("code") or "unknown") + is_error = warning.get("level") == "error" + candidates.append( + { + "event_key": f"billing-warning:{warning_code}", + "event_type": "billing_warning", + "source": "billing_summary", + "priority": "critical" if is_error else "high", + "title": str(warning.get("title") or warning_code), + "description": str(warning.get("message") or ""), + "resource_type": "tenant", + "resource_id": summary["tenant"]["id"], + "resource_label": summary["tenant"]["name"], + "next_action": "联系客户确认是否升级套餐、购买超额包或延长试用。", + "due_at": now + timedelta(days=1 if is_error else 3), + "metadata_json": {"warning": warning, "plan": summary.get("plan"), "summary": summary.get("summary")}, + } + ) + + pending_orders = ( + self.db.query(CommercialOrder) + .filter(CommercialOrder.tenant_id == current_user["tenant_id"], CommercialOrder.status == "pending_payment") + .order_by(CommercialOrder.created_at.desc()) + .limit(limit) + .all() + ) + for order in pending_orders: + candidates.append( + { + "event_key": f"order-pending-payment:{order.id}", + "event_type": "order_pending_payment", + "source": "commercial_order", + "priority": "high", + "title": f"订单待收款:{order.order_no}", + "description": f"{order.plan_name} {order.amount_cents / 100:.2f} {order.currency} 仍未确认收款。", + "resource_type": "commercial_order", + "resource_id": order.id, + "resource_label": order.order_no, + "contact_name": order.contact_name, + "contact_phone": order.contact_phone, + "contact_email": order.contact_email, + "next_action": "确认客户付款计划;如已付款,登记收款并激活套餐。", + "due_at": self._normalize_datetime(order.created_at) + timedelta(days=2) if order.created_at else now + timedelta(days=2), + "metadata_json": {"order_no": order.order_no, "plan_code": order.plan_code, "amount_cents": order.amount_cents}, + } + ) + + requested_invoices = ( + self.db.query(CommercialInvoice) + .filter(CommercialInvoice.tenant_id == current_user["tenant_id"], CommercialInvoice.status == "requested") + .order_by(CommercialInvoice.created_at.desc()) + .limit(limit) + .all() + ) + for invoice in requested_invoices: + candidates.append( + { + "event_key": f"invoice-requested:{invoice.id}", + "event_type": "invoice_requested", + "source": "commercial_invoice", + "priority": "medium", + "title": f"待开票:{invoice.invoice_title}", + "description": f"客户已提交税号 {invoice.tax_number},需要完成开票并上传发票文件。", + "resource_type": "commercial_invoice", + "resource_id": invoice.id, + "resource_label": invoice.invoice_title, + "contact_phone": invoice.receiver_phone, + "contact_email": invoice.receiver_email, + "next_action": "完成开票、上传发票文件,并通知客户查收。", + "due_at": self._normalize_datetime(invoice.created_at) + timedelta(days=2) if invoice.created_at else now + timedelta(days=2), + "metadata_json": {"invoice_type": invoice.invoice_type, "amount_cents": invoice.amount_cents, "order_id": invoice.order_id}, + } + ) + + issued_invoices = ( + self.db.query(CommercialInvoice) + .filter(CommercialInvoice.tenant_id == current_user["tenant_id"], CommercialInvoice.status == "issued") + .order_by(CommercialInvoice.issued_at.is_(None).asc(), CommercialInvoice.issued_at.desc(), CommercialInvoice.created_at.desc()) + .limit(limit) + .all() + ) + for invoice in issued_invoices: + candidates.append( + { + "event_key": f"invoice-issued:{invoice.id}", + "event_type": "invoice_issued", + "source": "commercial_invoice", + "priority": "low", + "title": f"确认发票签收:{invoice.invoice_title}", + "description": f"发票 {invoice.invoice_no or invoice.id} 已登记开票,建议确认客户收到并记录反馈。", + "resource_type": "commercial_invoice", + "resource_id": invoice.id, + "resource_label": invoice.invoice_title, + "contact_phone": invoice.receiver_phone, + "contact_email": invoice.receiver_email, + "next_action": "确认客户已收到发票;如客户满意,可继续跟进续费或增购。", + "due_at": (self._normalize_datetime(invoice.issued_at) or now) + timedelta(days=7), + "metadata_json": {"invoice_no": invoice.invoice_no, "invoice_file_id": invoice.invoice_file_id, "order_id": invoice.order_id}, + } + ) + + return candidates[:limit] + + def _resolve_stale_auto_events(self, tenant_id: str, active_keys: set[str], user_id: Optional[str], now: datetime) -> int: + stale_events = ( + self.db.query(SalesFollowupEvent) + .filter( + SalesFollowupEvent.tenant_id == tenant_id, + SalesFollowupEvent.event_type.in_(AUTO_RESOLVABLE_EVENT_TYPES), + SalesFollowupEvent.status.in_(["open", "in_progress"]), + ) + .all() + ) + resolved = 0 + for event in stale_events: + if event.event_key in active_keys: + continue + event.status = "resolved" + event.resolved_by = user_id or "system" + event.resolved_at = now + event.note = self._append_note(event.note, "系统同步:商业信号已消失,自动收口。") + event.updated_at = now + self.db.add(event) + resolved += 1 + return resolved + + def _get_event(self, tenant_id: str, event_id: str) -> SalesFollowupEvent: + event = ( + self.db.query(SalesFollowupEvent) + .filter(SalesFollowupEvent.id == event_id, SalesFollowupEvent.tenant_id == tenant_id) + .first() + ) + if event is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="sales followup event not found") + return event + + def _to_item(self, event: SalesFollowupEvent) -> dict: + return { + "id": event.id, + "tenant_id": event.tenant_id, + "event_key": event.event_key, + "event_type": event.event_type, + "source": event.source, + "priority": event.priority, + "status": event.status, + "title": event.title, + "description": event.description, + "resource_type": event.resource_type, + "resource_id": event.resource_id, + "resource_label": event.resource_label, + "contact_name": event.contact_name, + "contact_phone": event.contact_phone, + "contact_email": event.contact_email, + "next_action": event.next_action, + "note": event.note, + "assigned_to": event.assigned_to, + "due_at": event.due_at.isoformat() if event.due_at else None, + "resolved_by": event.resolved_by, + "resolved_at": event.resolved_at.isoformat() if event.resolved_at else None, + "metadata": event.metadata_json or {}, + "created_by": event.created_by, + "created_at": event.created_at.isoformat(), + "updated_at": event.updated_at.isoformat(), + } + + def _require_admin(self, current_user: dict) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + def _normalize_text(self, value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = value.strip() + return normalized or None + + def _normalize_required_text(self, value: str, field_name: str) -> str: + normalized = self._normalize_text(value) + if normalized is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required") + return normalized + + def _normalize_event_type(self, value: str) -> str: + normalized = self._normalize_required_text(value, "event_type").lower() + if len(normalized) > 64: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="event_type is too long") + return normalized + + def _normalize_priority(self, value: str) -> str: + normalized = self._normalize_required_text(value or "medium", "priority").lower() + if normalized not in VALID_FOLLOWUP_PRIORITIES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid priority") + return normalized + + def _normalize_status(self, value: str) -> str: + normalized = self._normalize_required_text(value or "open", "status").lower() + if normalized not in VALID_FOLLOWUP_STATUSES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid status") + return normalized + + def _normalize_optional_status(self, value: Optional[str]) -> Optional[str]: + if value is None: + return None + return self._normalize_status(value) + + def _normalize_datetime(self, value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + def _append_note(self, current_note: Optional[str], addition: str) -> str: + current = self._normalize_text(current_note) + return f"{current}\n{addition}" if current else addition diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index c96a1851..59eee205 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -18,6 +18,7 @@ DEFAULT_TEMPLATES: list[dict[str, Any]] = [ { "name": "劳动合同标准模板", "contract_type": "labor", + "document_type_code": "labor_contract", "template_category": "employment-hr", "industry_category": "general-enterprise", "description": "适用于企业与员工签署的标准劳动合同。", @@ -48,6 +49,7 @@ DEFAULT_TEMPLATES: list[dict[str, Any]] = [ { "name": "采购合同标准模板", "contract_type": "procurement", + "document_type_code": "procurement_goods", "template_category": "procurement-supply", "industry_category": "general-enterprise", "description": "适用于货物采购、交付与验收场景。", @@ -84,6 +86,7 @@ DEFAULT_TEMPLATES: list[dict[str, Any]] = [ { "name": "保密协议标准模板", "contract_type": "nda", + "document_type_code": "nda_commercial", "template_category": "commercial-compliance", "industry_category": "general-enterprise", "description": "适用于商务合作前的双向保密约定。", @@ -129,6 +132,7 @@ class TemplateService: page: int = 1, page_size: int = 20, contract_type: Optional[str] = None, + document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, ) -> dict: @@ -138,25 +142,28 @@ class TemplateService: normalized_page = max(page, 1) normalized_page_size = max(min(page_size, 100), 1) normalized_contract_type = contract_type.strip() if contract_type else None + normalized_document_type_code = self._normalize_document_type_code(document_type_code, None) if document_type_code else None normalized_template_category = template_category.strip() if template_category else None normalized_industry_category = industry_category.strip() if industry_category else None items = repository.list_paginated( - tenant_id, - normalized_page, - normalized_page_size, - normalized_contract_type, - normalized_template_category, - normalized_industry_category, + tenant_id=tenant_id, + page=normalized_page, + page_size=normalized_page_size, + contract_type=normalized_contract_type, + template_category=normalized_template_category, + industry_category=normalized_industry_category, + document_type_code=normalized_document_type_code, ) return { "items": [self._to_item(item) for item in items], "page": normalized_page, "page_size": normalized_page_size, "total": repository.count( - tenant_id, - normalized_contract_type, - normalized_template_category, - normalized_industry_category, + tenant_id=tenant_id, + contract_type=normalized_contract_type, + template_category=normalized_template_category, + industry_category=normalized_industry_category, + document_type_code=normalized_document_type_code, ), } @@ -186,6 +193,7 @@ class TemplateService: enabled: bool, change_summary: Optional[str] = None, template_code: Optional[str] = None, + document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, ) -> dict: @@ -199,6 +207,7 @@ class TemplateService: tenant_id=current_user["tenant_id"], name=name.strip(), contract_type=normalized_contract_type, + document_type_code=self._normalize_document_type_code(document_type_code, normalized_contract_type), template_code=self._normalize_template_code(template_code, normalized_contract_type, name), template_category=self._normalize_template_category(template_category, normalized_contract_type), industry_category=self._normalize_industry_category(industry_category, normalized_contract_type), @@ -229,6 +238,7 @@ class TemplateService: enabled: bool, change_summary: Optional[str] = None, template_code: Optional[str] = None, + document_type_code: Optional[str] = None, template_category: Optional[str] = None, industry_category: Optional[str] = None, ) -> dict: @@ -238,9 +248,17 @@ class TemplateService: next_version_label = self._bump_version_label(contract_template.current_version_label) normalized_change_summary = self._normalize_change_summary(change_summary, default=f"{next_version_label} template update") normalized_contract_type = contract_type.strip() if contract_type else None + previous_contract_type = contract_template.contract_type + previous_document_type_code = contract_template.document_type_code contract_template.name = name.strip() contract_template.contract_type = normalized_contract_type + if document_type_code is not None: + contract_template.document_type_code = self._normalize_document_type_code(document_type_code, normalized_contract_type) + elif previous_contract_type == normalized_contract_type and previous_document_type_code: + contract_template.document_type_code = previous_document_type_code + else: + contract_template.document_type_code = self._normalize_document_type_code(None, normalized_contract_type) contract_template.template_code = self._normalize_template_code(template_code or contract_template.template_code, normalized_contract_type, name) contract_template.template_category = self._normalize_template_category(template_category, normalized_contract_type) contract_template.industry_category = self._normalize_industry_category(industry_category, normalized_contract_type) @@ -285,6 +303,7 @@ class TemplateService: tenant_id=tenant_id, name=template_data["name"], contract_type=template_data["contract_type"], + document_type_code=template_data["document_type_code"], template_code=self._normalize_template_code(None, template_data["contract_type"], template_data["name"]), template_category=template_data["template_category"], industry_category=template_data["industry_category"], @@ -361,6 +380,7 @@ class TemplateService: normalized_change_summary = self._normalize_change_summary(change_summary, default=f"Rollback to {target_version_label}") contract_template.name = target_version.name contract_template.contract_type = target_version.contract_type + contract_template.document_type_code = target_version.document_type_code contract_template.template_category = target_version.template_category contract_template.industry_category = target_version.industry_category contract_template.description = target_version.description @@ -437,6 +457,7 @@ class TemplateService: change_summary=change_summary, name=contract_template.name, contract_type=contract_template.contract_type, + document_type_code=contract_template.document_type_code, template_category=contract_template.template_category, industry_category=contract_template.industry_category, description=contract_template.description, @@ -458,6 +479,7 @@ class TemplateService: change_summary=contract_template.latest_change_summary, name=contract_template.name, contract_type=contract_template.contract_type, + document_type_code=contract_template.document_type_code, template_category=contract_template.template_category, industry_category=contract_template.industry_category, description=contract_template.description, @@ -497,6 +519,22 @@ class TemplateService: "nda": "general-enterprise", }.get((contract_type or "").strip().lower(), "general-enterprise") + def _normalize_document_type_code(self, document_type_code: Optional[str], contract_type: Optional[str]) -> Optional[str]: + normalized = document_type_code.strip().lower().replace("-", "_") if document_type_code else "" + if normalized: + return normalized + return { + "labor": "labor_contract", + "procurement": "procurement_goods", + "purchase": "procurement_goods", + "sales": "procurement_goods", + "service": "service_general", + "nda": "nda_commercial", + "development": "software_development", + "software": "software_development", + "technical": "technical_service", + }.get((contract_type or "").strip().lower()) + def _normalize_template_code(self, template_code: Optional[str], contract_type: Optional[str], name: str) -> str: normalized = template_code.strip() if template_code else "" if normalized: @@ -563,6 +601,7 @@ class TemplateService: "id": contract_template.id, "name": contract_template.name, "contract_type": contract_template.contract_type, + "document_type_code": contract_template.document_type_code, "template_code": contract_template.template_code, "template_category": contract_template.template_category, "industry_category": contract_template.industry_category, @@ -595,6 +634,7 @@ class TemplateService: "change_summary": version.change_summary, "name": version.name, "contract_type": version.contract_type, + "document_type_code": version.document_type_code, "template_category": version.template_category, "industry_category": version.industry_category, "description": version.description, diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index 0329acfa..88d7ed7b 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -467,10 +467,14 @@ class TenantService: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is not activated") now = datetime.now(timezone.utc) + normalized_invoice_file_id = self._normalize_text(invoice_file_id) + if normalized_invoice_file_id: + self._get_active_file(tenant.id, normalized_invoice_file_id) + invoice.status = "issued" invoice.issued_by = current_user.get("id") invoice.invoice_no = self._normalize_required_text(invoice_no, "invoice_no") - invoice.invoice_file_id = self._normalize_text(invoice_file_id) + invoice.invoice_file_id = normalized_invoice_file_id if note is not None: invoice.note = self._normalize_text(note) invoice.issued_at = invoice.issued_at or now @@ -860,12 +864,43 @@ class TenantService: "currency": invoice.currency, "invoice_no": invoice.invoice_no, "invoice_file_id": invoice.invoice_file_id, + "invoice_file": self._to_invoice_file_item(invoice.tenant_id, invoice.invoice_file_id), "note": invoice.note, "issued_at": invoice.issued_at.isoformat() if invoice.issued_at else None, "created_at": invoice.created_at.isoformat(), "updated_at": invoice.updated_at.isoformat(), } + def _get_active_file(self, tenant_id: str, file_id: str) -> File: + file_record = ( + self.db.query(File) + .filter(File.id == file_id, File.tenant_id == tenant_id, File.status == "active") + .first() + ) + if file_record is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="invoice file not found") + return file_record + + def _to_invoice_file_item(self, tenant_id: str, file_id: Optional[str]) -> Optional[dict]: + if not file_id: + return None + file_record = ( + self.db.query(File) + .filter(File.id == file_id, File.tenant_id == tenant_id, File.status == "active") + .first() + ) + if file_record is None: + return None + return { + "id": file_record.id, + "file_name": file_record.file_name, + "mime_type": file_record.mime_type, + "file_size": file_record.file_size, + "file_type": file_record.file_type, + "download_url": f"/api/v1/files/{file_record.id}/download", + "created_at": file_record.created_at.isoformat(), + } + def _raise_billing_block(self, message: str): raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=message) diff --git a/backend/migrations/versions/20260520_0027_create_legal_document_types.py b/backend/migrations/versions/20260520_0027_create_legal_document_types.py new file mode 100644 index 00000000..594a6d6e --- /dev/null +++ b/backend/migrations/versions/20260520_0027_create_legal_document_types.py @@ -0,0 +1,313 @@ +"""create legal document types""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import sqlalchemy as sa +from alembic import op + + +revision = "20260520_0027" +down_revision = "20260518_0026" +branch_labels = None +depends_on = None + + +DEFAULT_LEGAL_DOCUMENT_TYPES = [ + { + "id": "ldt-procurement_goods", + "code": "procurement_goods", + "name": "货物买卖合同", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "procurement-supply", + "description": "适用于货物采购、销售、交付、验收和付款场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 10, + }, + { + "id": "ldt-technical_service", + "code": "technical_service", + "name": "技术服务合同", + "category": "company-commercial", + "industry": "technology", + "scenario": "technical-service", + "description": "适用于技术咨询、技术服务、成果交付和验收场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 20, + }, + { + "id": "ldt-software_development", + "code": "software_development", + "name": "软件开发合同", + "category": "company-commercial", + "industry": "technology", + "scenario": "software-development", + "description": "适用于软件定制开发、交付、验收、知识产权和维护场景。", + "risk_level": "high", + "is_active": True, + "sort_order": 30, + }, + { + "id": "ldt-service_general", + "code": "service_general", + "name": "通用服务合同", + "category": "company-commercial", + "industry": "professional-services", + "scenario": "service-delivery", + "description": "适用于咨询、运营、外包等通用服务交付场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 40, + }, + { + "id": "ldt-labor_contract", + "code": "labor_contract", + "name": "劳动合同", + "category": "employment-hr", + "industry": "general-enterprise", + "scenario": "employment", + "description": "适用于企业与员工建立劳动关系的标准场景。", + "risk_level": "high", + "is_active": True, + "sort_order": 50, + }, + { + "id": "ldt-nda_commercial", + "code": "nda_commercial", + "name": "保密协议", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "confidentiality", + "description": "适用于商务合作、尽调、沟通前后的保密约定。", + "risk_level": "medium", + "is_active": True, + "sort_order": 60, + }, + { + "id": "ldt-lease_house", + "code": "lease_house", + "name": "房屋租赁合同", + "category": "real-estate", + "industry": "real-estate", + "scenario": "lease", + "description": "适用于办公、商业或居住房屋租赁场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 70, + }, + { + "id": "ldt-equity_transfer", + "code": "equity_transfer", + "name": "股权转让协议", + "category": "investment-finance", + "industry": "general-enterprise", + "scenario": "equity-transfer", + "description": "适用于有限责任公司股权转让、价款支付和工商变更场景。", + "risk_level": "high", + "is_active": True, + "sort_order": 80, + }, + { + "id": "ldt-cooperation_framework", + "code": "cooperation_framework", + "name": "合作框架协议", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "cooperation", + "description": "适用于双方建立长期合作意向和原则性约定场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 90, + }, + { + "id": "ldt-demand_letter", + "code": "demand_letter", + "name": "催告函", + "category": "notices-letters", + "industry": "general-enterprise", + "scenario": "demand-letter", + "description": "适用于付款、交付、整改等履约催告场景。", + "risk_level": "medium", + "is_active": True, + "sort_order": 100, + }, + { + "id": "ldt-rescission_notice", + "code": "rescission_notice", + "name": "解除合同通知书", + "category": "notices-letters", + "industry": "general-enterprise", + "scenario": "rescission-notice", + "description": "适用于依法或依约解除合同并保留证据的通知场景。", + "risk_level": "high", + "is_active": True, + "sort_order": 110, + }, +] + +CONTRACT_TYPE_DOCUMENT_TYPE_MAP = { + "labor": "labor_contract", + "procurement": "procurement_goods", + "purchase": "procurement_goods", + "sales": "procurement_goods", + "service": "service_general", + "nda": "nda_commercial", + "development": "software_development", + "software": "software_development", + "technical": "technical_service", +} + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], + *, + unique: bool = False, +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "legal_document_types" not in tables: + op.create_table( + "legal_document_types", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("category", sa.String(length=64), nullable=False), + sa.Column("industry", sa.String(length=64), nullable=False), + sa.Column("scenario", sa.String(length=64), nullable=False), + sa.Column("description", sa.String(length=500), nullable=True), + sa.Column("risk_level", sa.String(length=32), nullable=False, server_default="medium"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_legal_document_types_code", "legal_document_types", ["code"], unique=True) + _create_index_if_missing(inspector, "ix_legal_document_types_category", "legal_document_types", ["category"]) + _create_index_if_missing(inspector, "ix_legal_document_types_industry", "legal_document_types", ["industry"]) + _create_index_if_missing(inspector, "ix_legal_document_types_scenario", "legal_document_types", ["scenario"]) + _create_index_if_missing(inspector, "ix_legal_document_types_risk_level", "legal_document_types", ["risk_level"]) + _create_index_if_missing(inspector, "ix_legal_document_types_is_active", "legal_document_types", ["is_active"]) + + _seed_default_document_types(bind) + _add_document_type_code_to_template_tables(bind) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + + for table_name in ("contract_template_versions", "contract_templates"): + if table_name not in tables: + continue + columns = _column_names(inspector, table_name) + if "document_type_code" not in columns: + continue + _drop_index_if_present(inspector, f"ix_{table_name}_document_type_code", table_name) + op.drop_column(table_name, "document_type_code") + + inspector = sa.inspect(bind) + if "legal_document_types" in _table_names(inspector): + _drop_index_if_present(inspector, "ix_legal_document_types_is_active", "legal_document_types") + _drop_index_if_present(inspector, "ix_legal_document_types_risk_level", "legal_document_types") + _drop_index_if_present(inspector, "ix_legal_document_types_scenario", "legal_document_types") + _drop_index_if_present(inspector, "ix_legal_document_types_industry", "legal_document_types") + _drop_index_if_present(inspector, "ix_legal_document_types_category", "legal_document_types") + _drop_index_if_present(inspector, "ix_legal_document_types_code", "legal_document_types") + op.drop_table("legal_document_types") + + +def _seed_default_document_types(bind) -> None: + existing_codes = { + row[0] + for row in bind.execute(sa.text("SELECT code FROM legal_document_types")).fetchall() + } + now = datetime.now(timezone.utc) + rows = [] + for item in DEFAULT_LEGAL_DOCUMENT_TYPES: + if item["code"] in existing_codes: + continue + rows.append({**item, "created_at": now, "updated_at": now}) + if not rows: + return + op.bulk_insert( + sa.table( + "legal_document_types", + sa.column("id", sa.String(length=64)), + sa.column("code", sa.String(length=64)), + sa.column("name", sa.String(length=120)), + sa.column("category", sa.String(length=64)), + sa.column("industry", sa.String(length=64)), + sa.column("scenario", sa.String(length=64)), + sa.column("description", sa.String(length=500)), + sa.column("risk_level", sa.String(length=32)), + sa.column("is_active", sa.Boolean()), + sa.column("sort_order", sa.Integer()), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ), + rows, + ) + + +def _add_document_type_code_to_template_tables(bind) -> None: + inspector = sa.inspect(bind) + tables = _table_names(inspector) + for table_name in ("contract_templates", "contract_template_versions"): + if table_name not in tables: + continue + columns = _column_names(inspector, table_name) + if "document_type_code" not in columns: + op.add_column(table_name, sa.Column("document_type_code", sa.String(length=64), nullable=True)) + inspector = sa.inspect(bind) + _create_index_if_missing(inspector, f"ix_{table_name}_document_type_code", table_name, ["document_type_code"]) + _backfill_template_document_type_codes(table_name) + + +def _backfill_template_document_type_codes(table_name: str) -> None: + for contract_type, document_type_code in CONTRACT_TYPE_DOCUMENT_TYPE_MAP.items(): + op.execute( + sa.text( + f""" + UPDATE {table_name} + SET document_type_code = :document_type_code + WHERE document_type_code IS NULL + AND lower(contract_type) = :contract_type + """ + ).bindparams( + document_type_code=document_type_code, + contract_type=contract_type, + ) + ) diff --git a/backend/migrations/versions/20260520_0028_create_legal_document_element_schemas.py b/backend/migrations/versions/20260520_0028_create_legal_document_element_schemas.py new file mode 100644 index 00000000..f83359f6 --- /dev/null +++ b/backend/migrations/versions/20260520_0028_create_legal_document_element_schemas.py @@ -0,0 +1,296 @@ +"""create legal document element schemas""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import sqlalchemy as sa +from alembic import op + + +revision = "20260520_0028" +down_revision = "20260520_0027" +branch_labels = None +depends_on = None + + +def _party_section() -> dict: + return { + "key": "parties", + "title": "当事人信息", + "fields": [ + {"key": "party_a.name", "label": "甲方名称", "type": "text", "required": True}, + {"key": "party_b.name", "label": "乙方名称", "type": "text", "required": True}, + ], + } + + +DEFAULT_ELEMENT_SCHEMAS = { + "procurement_goods": { + "sections": [ + _party_section(), + { + "key": "transaction", + "title": "交易与交付", + "fields": [ + {"key": "goods.name", "label": "货物名称", "type": "text", "required": True}, + {"key": "goods.quantity", "label": "数量", "type": "number", "required": True}, + {"key": "price.total", "label": "合同总价", "type": "money", "required": True}, + {"key": "delivery.date", "label": "交付日期", "type": "date", "required": True}, + ], + }, + ] + }, + "technical_service": { + "sections": [ + _party_section(), + { + "key": "service_scope", + "title": "技术服务内容", + "fields": [ + {"key": "service.scope", "label": "服务范围", "type": "textarea", "required": True}, + {"key": "service_fee", "label": "服务费用", "type": "money", "required": True}, + ], + }, + ] + }, + "software_development": { + "sections": [ + _party_section(), + { + "key": "project", + "title": "软件项目", + "fields": [ + {"key": "project.name", "label": "项目名称", "type": "text", "required": True}, + {"key": "project.modules", "label": "功能模块", "type": "textarea", "required": True}, + {"key": "fee.total", "label": "开发费用", "type": "money", "required": True}, + ], + }, + ] + }, + "service_general": { + "sections": [ + _party_section(), + { + "key": "service", + "title": "服务交付", + "fields": [ + {"key": "service.scope", "label": "服务范围", "type": "textarea", "required": True}, + {"key": "service_fee", "label": "服务费用", "type": "money", "required": True}, + ], + }, + ] + }, + "labor_contract": { + "sections": [ + { + "key": "employment_parties", + "title": "用工双方", + "fields": [ + {"key": "employer.name", "label": "用人单位名称", "type": "text", "required": True}, + {"key": "employee.name", "label": "员工姓名", "type": "text", "required": True}, + ], + }, + { + "key": "employment_terms", + "title": "劳动条件", + "fields": [ + {"key": "start_date", "label": "合同开始日期", "type": "date", "required": True}, + {"key": "salary.monthly", "label": "月工资", "type": "money", "required": True}, + ], + }, + ] + }, + "nda_commercial": { + "sections": [ + _party_section(), + { + "key": "confidentiality", + "title": "保密事项", + "fields": [ + {"key": "confidential_scope", "label": "保密信息范围", "type": "textarea", "required": True}, + {"key": "confidential_term", "label": "保密期限", "type": "text", "required": True}, + ], + }, + ] + }, + "lease_house": { + "sections": [ + _party_section(), + { + "key": "lease", + "title": "租赁信息", + "fields": [ + {"key": "property.address", "label": "房屋地址", "type": "text", "required": True}, + {"key": "rent.monthly", "label": "月租金", "type": "money", "required": True}, + ], + }, + ] + }, + "equity_transfer": { + "sections": [ + _party_section(), + { + "key": "equity", + "title": "股权转让", + "fields": [ + {"key": "company.name", "label": "目标公司", "type": "text", "required": True}, + {"key": "equity.percent", "label": "转让股权比例", "type": "percentage", "required": True}, + {"key": "transfer.price", "label": "转让价款", "type": "money", "required": True}, + ], + }, + ] + }, + "cooperation_framework": { + "sections": [ + _party_section(), + { + "key": "cooperation", + "title": "合作安排", + "fields": [ + {"key": "cooperation.scope", "label": "合作范围", "type": "textarea", "required": True}, + {"key": "cooperation.term", "label": "合作期限", "type": "text", "required": True}, + ], + }, + ] + }, + "demand_letter": { + "sections": [ + _party_section(), + { + "key": "demand", + "title": "催告事项", + "fields": [ + {"key": "obligation", "label": "被催告义务", "type": "textarea", "required": True}, + {"key": "deadline", "label": "履行期限", "type": "date", "required": True}, + ], + }, + ] + }, + "rescission_notice": { + "sections": [ + _party_section(), + { + "key": "rescission", + "title": "解除事项", + "fields": [ + {"key": "contract.name", "label": "原合同名称", "type": "text", "required": True}, + {"key": "rescission.reason", "label": "解除原因", "type": "textarea", "required": True}, + {"key": "effective_date", "label": "解除生效日期", "type": "date", "required": True}, + ], + }, + ] + }, +} + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing(inspector: sa.Inspector, index_name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "legal_document_element_schemas" not in _table_names(inspector): + op.create_table( + "legal_document_element_schemas", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("document_type_code", sa.String(length=64), nullable=False), + sa.Column("version_label", sa.String(length=32), nullable=False, server_default="v1.0"), + sa.Column("schema_json", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("published_by", sa.String(length=64), nullable=True), + sa.Column("rollback_from_schema_id", sa.String(length=64), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_legal_document_element_schemas_document_type_code", "legal_document_element_schemas", ["document_type_code"]) + _create_index_if_missing(inspector, "ix_legal_document_element_schemas_status", "legal_document_element_schemas", ["status"]) + _create_index_if_missing(inspector, "ix_legal_document_element_schemas_created_at", "legal_document_element_schemas", ["created_at"]) + _create_index_if_missing( + inspector, + "ix_legal_document_element_schemas_type_version", + "legal_document_element_schemas", + ["document_type_code", "version_label"], + unique=True, + ) + _seed_default_element_schemas(bind) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "legal_document_element_schemas" not in _table_names(inspector): + return + _drop_index_if_present(inspector, "ix_legal_document_element_schemas_type_version", "legal_document_element_schemas") + _drop_index_if_present(inspector, "ix_legal_document_element_schemas_created_at", "legal_document_element_schemas") + _drop_index_if_present(inspector, "ix_legal_document_element_schemas_status", "legal_document_element_schemas") + _drop_index_if_present(inspector, "ix_legal_document_element_schemas_document_type_code", "legal_document_element_schemas") + op.drop_table("legal_document_element_schemas") + + +def _seed_default_element_schemas(bind) -> None: + existing_keys = { + (row[0], row[1]) + for row in bind.execute(sa.text("SELECT document_type_code, version_label FROM legal_document_element_schemas")).fetchall() + } + now = datetime.now(timezone.utc) + rows = [] + for code, schema_json in DEFAULT_ELEMENT_SCHEMAS.items(): + if (code, "v1.0") in existing_keys: + continue + rows.append( + { + "id": f"ldes-{code}-v1", + "document_type_code": code, + "version_label": "v1.0", + "schema_json": schema_json, + "status": "published", + "published_at": now, + "published_by": "system", + "rollback_from_schema_id": None, + "created_by": "system", + "updated_by": "system", + "created_at": now, + "updated_at": now, + } + ) + if not rows: + return + op.bulk_insert( + sa.table( + "legal_document_element_schemas", + sa.column("id", sa.String(length=64)), + sa.column("document_type_code", sa.String(length=64)), + sa.column("version_label", sa.String(length=32)), + sa.column("schema_json", sa.JSON()), + sa.column("status", sa.String(length=32)), + sa.column("published_at", sa.DateTime(timezone=True)), + sa.column("published_by", sa.String(length=64)), + sa.column("rollback_from_schema_id", sa.String(length=64)), + sa.column("created_by", sa.String(length=64)), + sa.column("updated_by", sa.String(length=64)), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ), + rows, + ) diff --git a/backend/migrations/versions/20260520_0029_create_legal_document_drafts.py b/backend/migrations/versions/20260520_0029_create_legal_document_drafts.py new file mode 100644 index 00000000..c2f17e2e --- /dev/null +++ b/backend/migrations/versions/20260520_0029_create_legal_document_drafts.py @@ -0,0 +1,134 @@ +"""create legal document drafts""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260520_0029" +down_revision = "20260520_0028" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], + *, + unique: bool = False, +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def _add_column_if_missing(inspector: sa.Inspector, table_name: str, column: sa.Column) -> None: + if column.name not in _column_names(inspector, table_name): + op.add_column(table_name, column) + + +def _drop_column_if_present(inspector: sa.Inspector, table_name: str, column_name: str) -> None: + if column_name in _column_names(inspector, table_name): + op.drop_column(table_name, column_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "legal_document_drafts" not in tables: + op.create_table( + "legal_document_drafts", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=64), nullable=False), + sa.Column("document_type_code", sa.String(length=64), nullable=False), + sa.Column("title", sa.String(length=200), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"), + sa.Column("element_values_json", sa.JSON(), nullable=False), + sa.Column("validation_result_json", sa.JSON(), nullable=True), + sa.Column("element_schema_id", sa.String(length=64), nullable=True), + sa.Column("element_schema_version_label", sa.String(length=32), nullable=True), + sa.Column("template_id", sa.String(length=64), nullable=True), + sa.Column("generated_contract_id", sa.String(length=64), nullable=True), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_legal_document_drafts_tenant_id", "legal_document_drafts", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_user_id", "legal_document_drafts", ["user_id"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_document_type_code", "legal_document_drafts", ["document_type_code"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_status", "legal_document_drafts", ["status"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_template_id", "legal_document_drafts", ["template_id"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_generated_contract_id", "legal_document_drafts", ["generated_contract_id"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_element_schema_id", "legal_document_drafts", ["element_schema_id"]) + _create_index_if_missing(inspector, "ix_legal_document_drafts_updated_at", "legal_document_drafts", ["updated_at"]) + + inspector = sa.inspect(bind) + if "generated_contracts" in _table_names(inspector): + _add_column_if_missing(inspector, "generated_contracts", sa.Column("document_type_code", sa.String(length=64), nullable=True)) + _add_column_if_missing(inspector, "generated_contracts", sa.Column("elements_snapshot_json", sa.JSON(), nullable=True)) + _add_column_if_missing(inspector, "generated_contracts", sa.Column("element_schema_id", sa.String(length=64), nullable=True)) + _add_column_if_missing(inspector, "generated_contracts", sa.Column("element_schema_version_label", sa.String(length=32), nullable=True)) + inspector = sa.inspect(bind) + _create_index_if_missing(inspector, "ix_generated_contracts_document_type_code", "generated_contracts", ["document_type_code"]) + _create_index_if_missing(inspector, "ix_generated_contracts_element_schema_id", "generated_contracts", ["element_schema_id"]) + if bind.dialect.name == "postgresql": + op.execute( + sa.text( + """ + UPDATE generated_contracts + SET document_type_code = export_options_json ->> '_document_type_code' + WHERE document_type_code IS NULL + AND export_options_json IS NOT NULL + AND (export_options_json::jsonb ? '_document_type_code') + """ + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "generated_contracts" in _table_names(inspector): + _drop_index_if_present(inspector, "ix_generated_contracts_element_schema_id", "generated_contracts") + _drop_index_if_present(inspector, "ix_generated_contracts_document_type_code", "generated_contracts") + inspector = sa.inspect(bind) + _drop_column_if_present(inspector, "generated_contracts", "element_schema_version_label") + _drop_column_if_present(inspector, "generated_contracts", "element_schema_id") + _drop_column_if_present(inspector, "generated_contracts", "elements_snapshot_json") + _drop_column_if_present(inspector, "generated_contracts", "document_type_code") + inspector = sa.inspect(bind) + if "legal_document_drafts" not in _table_names(inspector): + return + _drop_index_if_present(inspector, "ix_legal_document_drafts_updated_at", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_element_schema_id", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_generated_contract_id", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_template_id", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_status", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_document_type_code", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_user_id", "legal_document_drafts") + _drop_index_if_present(inspector, "ix_legal_document_drafts_tenant_id", "legal_document_drafts") + op.drop_table("legal_document_drafts") diff --git a/backend/migrations/versions/20260520_0030_create_review_risk_actions.py b/backend/migrations/versions/20260520_0030_create_review_risk_actions.py new file mode 100644 index 00000000..7e595d36 --- /dev/null +++ b/backend/migrations/versions/20260520_0030_create_review_risk_actions.py @@ -0,0 +1,95 @@ +"""create review risk actions""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260520_0030" +down_revision = "20260520_0029" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], + *, + unique: bool = False, +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "review_risk_actions" not in _table_names(inspector): + op.create_table( + "review_risk_actions", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("review_id", sa.String(length=64), sa.ForeignKey("review_tasks.id"), nullable=False), + sa.Column("risk_item_id", sa.String(length=128), nullable=False), + sa.Column("risk_item_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("risk_item_title", sa.String(length=255), nullable=True), + sa.Column("risk_level", sa.String(length=32), nullable=True), + sa.Column("action_status", sa.String(length=32), nullable=False, server_default="pending"), + sa.Column("suggested_text", sa.Text(), nullable=True), + sa.Column("user_modified_text", sa.Text(), nullable=True), + sa.Column("skip_reason", sa.Text(), nullable=True), + sa.Column("applied_contract_version_id", sa.String(length=64), nullable=True), + sa.Column("risk_snapshot_json", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_review_risk_actions_tenant_id", "review_risk_actions", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_review_id", "review_risk_actions", ["review_id"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_risk_item_id", "review_risk_actions", ["risk_item_id"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_action_status", "review_risk_actions", ["action_status"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_applied_contract_version_id", "review_risk_actions", ["applied_contract_version_id"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_created_by", "review_risk_actions", ["created_by"]) + _create_index_if_missing(inspector, "ix_review_risk_actions_updated_by", "review_risk_actions", ["updated_by"]) + _create_index_if_missing( + inspector, + "uq_review_risk_actions_tenant_review_risk", + "review_risk_actions", + ["tenant_id", "review_id", "risk_item_id"], + unique=True, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "review_risk_actions" not in _table_names(inspector): + return + _drop_index_if_present(inspector, "uq_review_risk_actions_tenant_review_risk", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_updated_by", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_created_by", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_applied_contract_version_id", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_action_status", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_risk_item_id", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_review_id", "review_risk_actions") + _drop_index_if_present(inspector, "ix_review_risk_actions_tenant_id", "review_risk_actions") + op.drop_table("review_risk_actions") diff --git a/backend/migrations/versions/20260520_0031_create_generated_contract_versions.py b/backend/migrations/versions/20260520_0031_create_generated_contract_versions.py new file mode 100644 index 00000000..b8d9304e --- /dev/null +++ b/backend/migrations/versions/20260520_0031_create_generated_contract_versions.py @@ -0,0 +1,121 @@ +"""create generated contract versions""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260520_0031" +down_revision = "20260520_0030" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], + *, + unique: bool = False, +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "generated_contract_versions" not in _table_names(inspector): + op.create_table( + "generated_contract_versions", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("contract_id", sa.String(length=64), sa.ForeignKey("generated_contracts.id"), nullable=False), + sa.Column("review_id", sa.String(length=64), sa.ForeignKey("review_tasks.id"), nullable=True), + sa.Column("review_risk_action_id", sa.String(length=64), sa.ForeignKey("review_risk_actions.id"), nullable=True), + sa.Column("version_number", sa.Integer(), nullable=False, server_default="1"), + sa.Column("version_label", sa.String(length=32), nullable=False), + sa.Column("source_version_id", sa.String(length=64), nullable=True), + sa.Column("change_type", sa.String(length=64), nullable=False, server_default="review_suggestion"), + sa.Column("change_summary", sa.String(length=500), nullable=True), + sa.Column("original_text", sa.Text(), nullable=True), + sa.Column("replacement_text", sa.Text(), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("locate_status", sa.String(length=32), nullable=False, server_default="applied"), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_generated_contract_versions_tenant_id", "generated_contract_versions", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_generated_contract_versions_contract_id", "generated_contract_versions", ["contract_id"]) + _create_index_if_missing(inspector, "ix_generated_contract_versions_review_id", "generated_contract_versions", ["review_id"]) + _create_index_if_missing( + inspector, + "ix_generated_contract_versions_review_risk_action_id", + "generated_contract_versions", + ["review_risk_action_id"], + ) + _create_index_if_missing( + inspector, + "ix_generated_contract_versions_version_number", + "generated_contract_versions", + ["version_number"], + ) + _create_index_if_missing( + inspector, + "ix_generated_contract_versions_source_version_id", + "generated_contract_versions", + ["source_version_id"], + ) + _create_index_if_missing( + inspector, + "ix_generated_contract_versions_locate_status", + "generated_contract_versions", + ["locate_status"], + ) + _create_index_if_missing( + inspector, + "ix_generated_contract_versions_created_by", + "generated_contract_versions", + ["created_by"], + ) + _create_index_if_missing( + inspector, + "uq_generated_contract_versions_tenant_contract_number", + "generated_contract_versions", + ["tenant_id", "contract_id", "version_number"], + unique=True, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "generated_contract_versions" not in _table_names(inspector): + return + _drop_index_if_present(inspector, "uq_generated_contract_versions_tenant_contract_number", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_created_by", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_locate_status", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_source_version_id", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_version_number", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_review_risk_action_id", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_review_id", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_contract_id", "generated_contract_versions") + _drop_index_if_present(inspector, "ix_generated_contract_versions_tenant_id", "generated_contract_versions") + op.drop_table("generated_contract_versions") diff --git a/backend/migrations/versions/20260521_0032_create_prompt_assets.py b/backend/migrations/versions/20260521_0032_create_prompt_assets.py new file mode 100644 index 00000000..b204fe86 --- /dev/null +++ b/backend/migrations/versions/20260521_0032_create_prompt_assets.py @@ -0,0 +1,192 @@ +"""create prompt assets""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260521_0032" +down_revision = "20260520_0031" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], + *, + unique: bool = False, +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_assets" not in tables: + op.create_table( + "prompt_assets", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("prompt_key", sa.String(length=100), nullable=False), + sa.Column("prompt_type", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("target_scene", sa.String(length=160), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"), + sa.Column("current_version_label", sa.String(length=32), nullable=False, server_default="v1.0"), + sa.Column("current_version_id", sa.String(length=64), nullable=True), + sa.Column("published_version_id", sa.String(length=64), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("published_by", sa.String(length=64), nullable=True), + sa.Column("rollback_from_version_id", sa.String(length=64), nullable=True), + sa.Column("latest_change_summary", sa.String(length=500), nullable=True), + sa.Column("latest_evaluation_status", sa.String(length=32), nullable=True), + sa.Column("latest_evaluation_score", sa.Float(), nullable=True), + sa.Column("latest_evaluation_report_json", sa.JSON(), nullable=True), + sa.Column("version_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "prompt_key", name="uq_prompt_assets_tenant_key"), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_prompt_assets_tenant_id", "prompt_assets", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_prompt_assets_prompt_key", "prompt_assets", ["prompt_key"]) + _create_index_if_missing(inspector, "ix_prompt_assets_prompt_type", "prompt_assets", ["prompt_type"]) + _create_index_if_missing(inspector, "ix_prompt_assets_tenant_type_status", "prompt_assets", ["tenant_id", "prompt_type", "status"]) + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_asset_versions" not in tables: + op.create_table( + "prompt_asset_versions", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("prompt_asset_id", sa.String(length=64), sa.ForeignKey("prompt_assets.id"), nullable=False), + sa.Column("prompt_key", sa.String(length=100), nullable=False), + sa.Column("prompt_type", sa.String(length=64), nullable=False), + sa.Column("version_label", sa.String(length=32), nullable=False), + sa.Column("prompt_content", sa.Text(), nullable=False), + sa.Column("system_prompt", sa.Text(), nullable=True), + sa.Column("variables_json", sa.JSON(), nullable=True), + sa.Column("output_schema_json", sa.JSON(), nullable=True), + sa.Column("model_config_json", sa.JSON(), nullable=True), + sa.Column("evaluation_status", sa.String(length=32), nullable=False, server_default="not_evaluated"), + sa.Column("evaluation_score", sa.Float(), nullable=True), + sa.Column("evaluation_report_json", sa.JSON(), nullable=True), + sa.Column("change_summary", sa.String(length=500), nullable=True), + sa.Column("rollback_from_version_id", sa.String(length=64), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_published", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_by_name", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("published_by", sa.String(length=64), nullable=True), + sa.UniqueConstraint("tenant_id", "prompt_asset_id", "version_label", name="uq_prompt_asset_versions_label"), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_prompt_asset_versions_tenant_id", "prompt_asset_versions", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_prompt_asset_versions_prompt_asset_id", "prompt_asset_versions", ["prompt_asset_id"]) + _create_index_if_missing(inspector, "ix_prompt_asset_versions_prompt_key", "prompt_asset_versions", ["prompt_key"]) + _create_index_if_missing(inspector, "ix_prompt_asset_versions_prompt_type", "prompt_asset_versions", ["prompt_type"]) + _create_index_if_missing( + inspector, + "ix_prompt_asset_versions_tenant_asset", + "prompt_asset_versions", + ["tenant_id", "prompt_asset_id"], + ) + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_asset_run_links" not in tables: + op.create_table( + "prompt_asset_run_links", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("prompt_asset_id", sa.String(length=64), sa.ForeignKey("prompt_assets.id"), nullable=False), + sa.Column("prompt_asset_version_id", sa.String(length=64), sa.ForeignKey("prompt_asset_versions.id"), nullable=False), + sa.Column("prompt_type", sa.String(length=64), nullable=False), + sa.Column("run_type", sa.String(length=64), nullable=False, server_default="ai_run"), + sa.Column("run_id", sa.String(length=100), nullable=True), + sa.Column("resource_type", sa.String(length=64), nullable=True), + sa.Column("resource_id", sa.String(length=100), nullable=True), + sa.Column("workflow_run_id", sa.String(length=100), nullable=True), + sa.Column("task_run_id", sa.String(length=100), nullable=True), + sa.Column("trace_json", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_prompt_asset_run_links_tenant_id", "prompt_asset_run_links", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_prompt_asset_run_links_prompt_asset_id", "prompt_asset_run_links", ["prompt_asset_id"]) + _create_index_if_missing( + inspector, + "ix_prompt_asset_run_links_prompt_asset_version_id", + "prompt_asset_run_links", + ["prompt_asset_version_id"], + ) + _create_index_if_missing(inspector, "ix_prompt_asset_run_links_prompt_type", "prompt_asset_run_links", ["prompt_type"]) + _create_index_if_missing(inspector, "ix_prompt_asset_run_links_tenant_run", "prompt_asset_run_links", ["tenant_id", "run_id"]) + _create_index_if_missing( + inspector, + "ix_prompt_asset_run_links_tenant_resource", + "prompt_asset_run_links", + ["tenant_id", "resource_type", "resource_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_asset_run_links" in tables: + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_tenant_resource", "prompt_asset_run_links") + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_tenant_run", "prompt_asset_run_links") + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_prompt_type", "prompt_asset_run_links") + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_prompt_asset_version_id", "prompt_asset_run_links") + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_prompt_asset_id", "prompt_asset_run_links") + _drop_index_if_present(inspector, "ix_prompt_asset_run_links_tenant_id", "prompt_asset_run_links") + op.drop_table("prompt_asset_run_links") + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_asset_versions" in tables: + _drop_index_if_present(inspector, "ix_prompt_asset_versions_tenant_asset", "prompt_asset_versions") + _drop_index_if_present(inspector, "ix_prompt_asset_versions_prompt_type", "prompt_asset_versions") + _drop_index_if_present(inspector, "ix_prompt_asset_versions_prompt_key", "prompt_asset_versions") + _drop_index_if_present(inspector, "ix_prompt_asset_versions_prompt_asset_id", "prompt_asset_versions") + _drop_index_if_present(inspector, "ix_prompt_asset_versions_tenant_id", "prompt_asset_versions") + op.drop_table("prompt_asset_versions") + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "prompt_assets" in tables: + _drop_index_if_present(inspector, "ix_prompt_assets_tenant_type_status", "prompt_assets") + _drop_index_if_present(inspector, "ix_prompt_assets_prompt_type", "prompt_assets") + _drop_index_if_present(inspector, "ix_prompt_assets_prompt_key", "prompt_assets") + _drop_index_if_present(inspector, "ix_prompt_assets_tenant_id", "prompt_assets") + op.drop_table("prompt_assets") diff --git a/backend/migrations/versions/20260521_0033_create_legal_change_impacts.py b/backend/migrations/versions/20260521_0033_create_legal_change_impacts.py new file mode 100644 index 00000000..c97b76fb --- /dev/null +++ b/backend/migrations/versions/20260521_0033_create_legal_change_impacts.py @@ -0,0 +1,136 @@ +"""create legal change impact analysis tables""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260521_0033" +down_revision = "20260521_0032" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing( + inspector: sa.Inspector, + index_name: str, + table_name: str, + columns: list[str], +) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + + if "legal_change_events" not in tables: + op.create_table( + "legal_change_events", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("article_id", sa.String(length=96), nullable=True), + sa.Column("source_id", sa.String(length=96), nullable=False), + sa.Column("source_title", sa.String(length=255), nullable=False), + sa.Column("article_no", sa.String(length=64), nullable=False), + sa.Column("change_type", sa.String(length=32), nullable=False, server_default="updated"), + sa.Column("change_summary", sa.String(length=500), nullable=True), + sa.Column("previous_text_checksum", sa.String(length=64), nullable=True), + sa.Column("current_text_checksum", sa.String(length=64), nullable=True), + sa.Column("previous_article_text", sa.Text(), nullable=True), + sa.Column("current_article_text", sa.Text(), nullable=True), + sa.Column("effective_date", sa.String(length=128), nullable=True), + sa.Column("tags_json", sa.JSON(), nullable=True), + sa.Column("scan_status", sa.String(length=32), nullable=False, server_default="pending"), + sa.Column("impact_summary_json", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("scanned_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_legal_change_events_tenant_id", "legal_change_events", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_legal_change_events_article_id", "legal_change_events", ["article_id"]) + _create_index_if_missing(inspector, "ix_legal_change_events_source_id", "legal_change_events", ["source_id"]) + _create_index_if_missing(inspector, "ix_legal_change_events_article_no", "legal_change_events", ["article_no"]) + _create_index_if_missing(inspector, "ix_legal_change_events_change_type", "legal_change_events", ["change_type"]) + _create_index_if_missing(inspector, "ix_legal_change_events_scan_status", "legal_change_events", ["scan_status"]) + _create_index_if_missing(inspector, "ix_legal_change_events_created_at", "legal_change_events", ["created_at"]) + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "legal_change_impacts" not in tables: + op.create_table( + "legal_change_impacts", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("change_event_id", sa.String(length=64), sa.ForeignKey("legal_change_events.id"), nullable=False), + sa.Column("target_type", sa.String(length=64), nullable=False), + sa.Column("target_id", sa.String(length=96), nullable=False), + sa.Column("target_name", sa.String(length=255), nullable=False), + sa.Column("target_version_label", sa.String(length=32), nullable=True), + sa.Column("match_reason", sa.String(length=255), nullable=False), + sa.Column("match_terms_json", sa.JSON(), nullable=True), + sa.Column("impact_level", sa.String(length=32), nullable=False), + sa.Column("suggestion", sa.Text(), nullable=False), + sa.Column("impact_status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("evidence_json", sa.JSON(), nullable=True), + sa.Column("handled_by", sa.String(length=64), nullable=True), + sa.Column("handled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_legal_change_impacts_tenant_id", "legal_change_impacts", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_change_event_id", "legal_change_impacts", ["change_event_id"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_target_type", "legal_change_impacts", ["target_type"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_target_id", "legal_change_impacts", ["target_id"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_impact_level", "legal_change_impacts", ["impact_level"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_impact_status", "legal_change_impacts", ["impact_status"]) + _create_index_if_missing(inspector, "ix_legal_change_impacts_created_at", "legal_change_impacts", ["created_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = _table_names(inspector) + + if "legal_change_impacts" in tables: + _drop_index_if_present(inspector, "ix_legal_change_impacts_created_at", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_impact_status", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_impact_level", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_target_id", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_target_type", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_change_event_id", "legal_change_impacts") + _drop_index_if_present(inspector, "ix_legal_change_impacts_tenant_id", "legal_change_impacts") + op.drop_table("legal_change_impacts") + + inspector = sa.inspect(bind) + tables = _table_names(inspector) + if "legal_change_events" in tables: + _drop_index_if_present(inspector, "ix_legal_change_events_created_at", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_scan_status", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_change_type", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_article_no", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_source_id", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_article_id", "legal_change_events") + _drop_index_if_present(inspector, "ix_legal_change_events_tenant_id", "legal_change_events") + op.drop_table("legal_change_events") diff --git a/backend/migrations/versions/20260521_0034_create_sales_followup_events.py b/backend/migrations/versions/20260521_0034_create_sales_followup_events.py new file mode 100644 index 00000000..bad0d843 --- /dev/null +++ b/backend/migrations/versions/20260521_0034_create_sales_followup_events.py @@ -0,0 +1,97 @@ +"""create sales followup events""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260521_0034" +down_revision = "20260521_0033" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _create_index_if_missing(inspector: sa.Inspector, index_name: str, table_name: str, columns: list[str], unique: bool = False) -> None: + if index_name not in _index_names(inspector, table_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_present(inspector: sa.Inspector, index_name: str, table_name: str) -> None: + if index_name in _index_names(inspector, table_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "sales_followup_events" not in _table_names(inspector): + op.create_table( + "sales_followup_events", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("event_key", sa.String(length=160), nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("source", sa.String(length=64), nullable=False, server_default="manual"), + sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("resource_type", sa.String(length=64), nullable=True), + sa.Column("resource_id", sa.String(length=96), nullable=True), + sa.Column("resource_label", sa.String(length=200), nullable=True), + sa.Column("contact_name", sa.String(length=100), nullable=True), + sa.Column("contact_phone", sa.String(length=64), nullable=True), + sa.Column("contact_email", sa.String(length=120), nullable=True), + sa.Column("next_action", sa.Text(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("assigned_to", sa.String(length=64), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by", sa.String(length=64), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "event_key", name="uq_sales_followup_events_tenant_event_key"), + ) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_sales_followup_events_tenant_id", "sales_followup_events", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_event_type", "sales_followup_events", ["event_type"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_priority", "sales_followup_events", ["priority"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_status", "sales_followup_events", ["status"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_resource_type", "sales_followup_events", ["resource_type"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_resource_id", "sales_followup_events", ["resource_id"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_assigned_to", "sales_followup_events", ["assigned_to"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_due_at", "sales_followup_events", ["due_at"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_created_at", "sales_followup_events", ["created_at"]) + _create_index_if_missing(inspector, "ix_sales_followup_events_tenant_status", "sales_followup_events", ["tenant_id", "status"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "sales_followup_events" not in _table_names(inspector): + return + + _drop_index_if_present(inspector, "ix_sales_followup_events_tenant_status", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_created_at", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_due_at", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_assigned_to", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_resource_id", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_resource_type", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_status", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_priority", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_event_type", "sales_followup_events") + _drop_index_if_present(inspector, "ix_sales_followup_events_tenant_id", "sales_followup_events") + + op.drop_table("sales_followup_events") diff --git a/backend/tests/test_chat_api.py b/backend/tests/test_chat_api.py index 5c7ebd99..f42cbefb 100644 --- a/backend/tests/test_chat_api.py +++ b/backend/tests/test_chat_api.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from app.api.chat import ( get_chat_legal_article_service, + get_chat_prompt_runtime_service, get_chat_review_service, get_shared_ai_platform_client, get_tenant_service, @@ -19,8 +20,8 @@ class StubChatService: def __init__(self): self.stream_calls = [] - def stream_answer(self, current_user, conversation_id, message, review_context=None): - self.stream_calls.append((current_user, conversation_id, message, review_context)) + def stream_answer(self, current_user, conversation_id, message, review_context=None, template_key=None): + self.stream_calls.append((current_user, conversation_id, message, review_context, template_key)) if not message.strip(): yield 'data: {"type":"error","message":"message 不能为空"}\n\n' return @@ -117,6 +118,44 @@ class StubTenantService: raise HTTPException(status_code=402, detail="试用已到期,请升级套餐后继续使用 Chat 法律问答。") +class StubPromptRuntimeService: + def __init__(self): + self.record_calls = [] + + def resolve_published_prompt(self, current_user, prompt_type, *, preferred_keys=None, fallback_prompt_version=None): + return { + "available": True, + "prompt_asset_id": "prompt-001", + "prompt_asset_version_id": "promptver-001", + "prompt_key": "legal_chat_default", + "prompt_type": prompt_type, + "version_label": "v1.0", + "prompt_version": "legal_chat_default.v1.0", + "template_key": "legal_chat_default", + "source": "published_prompt_asset", + } + + def to_trace(self, prompt_runtime): + return { + key: prompt_runtime.get(key) + for key in [ + "available", + "prompt_asset_id", + "prompt_asset_version_id", + "prompt_key", + "prompt_type", + "version_label", + "prompt_version", + "template_key", + "source", + ] + } + + def record_run_link(self, current_user, prompt_runtime, **kwargs): + self.record_calls.append((current_user, prompt_runtime, kwargs)) + return {"id": "promptrun-001"} + + class ChatApiTestCase(unittest.TestCase): def setUp(self): self.chat_service = StubChatService() @@ -125,6 +164,7 @@ class ChatApiTestCase(unittest.TestCase): app.dependency_overrides[get_conversations_shared_ai_platform_client] = lambda: self.chat_service app.dependency_overrides[get_chat_review_service] = lambda: None app.dependency_overrides[get_chat_legal_article_service] = lambda: StubLegalArticleService() + app.dependency_overrides[get_chat_prompt_runtime_service] = lambda: None app.dependency_overrides[get_tenant_service] = lambda: self.tenant_service self.client = TestClient(app) @@ -187,6 +227,19 @@ class ChatApiTestCase(unittest.TestCase): self.assertEqual(self.chat_service.stream_calls[0][3]["reviewId"], "review-123") self.assertEqual(self.chat_service.stream_calls[0][3]["metadata"]["focusLabel"], "自动续约") + def test_stream_chat_passes_published_prompt_template_key(self): + app.dependency_overrides[require_current_user] = lambda: {"id": "user-1", "tenant_id": "tenant-1"} + prompt_runtime_service = StubPromptRuntimeService() + app.dependency_overrides[get_chat_prompt_runtime_service] = lambda: prompt_runtime_service + + response = self.client.post("/api/v1/chat/stream", json={"message": "check clause"}) + + self.assertEqual(response.status_code, 200) + review_context = self.chat_service.stream_calls[0][3] + self.assertEqual(self.chat_service.stream_calls[0][4], "legal_chat_default") + self.assertEqual(review_context["promptRuntime"]["prompt_version"], "legal_chat_default.v1.0") + self.assertEqual(prompt_runtime_service.record_calls[0][2]["run_type"], "legal_chat") + def test_stream_chat_adds_review_legal_article_sources(self): app.dependency_overrides[require_current_user] = lambda: {"id": "user-1", "tenant_id": "tenant-1"} app.dependency_overrides[get_chat_review_service] = lambda: StubReviewService() diff --git a/backend/tests/test_clause_recommendation_service.py b/backend/tests/test_clause_recommendation_service.py new file mode 100644 index 00000000..e6bafe87 --- /dev/null +++ b/backend/tests/test_clause_recommendation_service.py @@ -0,0 +1,141 @@ +import unittest + +from fastapi import HTTPException + +from app.services.clause_recommendation_service import ClauseRecommendationService + + +class StubLegalArticleService: + def __init__(self): + self.queries = [] + + def search_runtime_sources(self, current_user, query, limit=3): + self.queries.append((current_user, query, limit)) + return [ + { + "source_id": "lart-001", + "title": "中华人民共和国民法典", + "article_no": "第五百零九条", + "authority": "全国人民代表大会", + "content": "当事人应当按照约定全面履行自己的义务。", + "detail_path": "/legal-articles?article_id=lart-001", + "metadata": {"source_title": "中华人民共和国民法典", "match_reason": "命中履行义务"}, + } + ] + + +class ClauseRecommendationServiceTestCase(unittest.TestCase): + def setUp(self): + self.legal_article_service = StubLegalArticleService() + self.service = ClauseRecommendationService(None, legal_article_service=self.legal_article_service) + self.current_user = {"id": "user-001", "tenant_id": "tenant-001", "role": "admin"} + + def test_procurement_contract_returns_missing_clause_recommendations(self): + result = self.service.recommend_missing_clauses( + self.current_user, + document_type_code="procurement_goods", + contract_text=( + "采购合同\n" + "一、采购标的:甲方向乙方采购AI合同审查终端一套。\n" + "二、交付安排:乙方应在2026年6月30日前完成交付。\n" + "三、付款方式:甲方在签署后一次性付款。" + ), + max_items=3, + ) + + self.assertEqual(result["document_type_code"], "procurement_goods") + self.assertEqual(result["total"], 3) + first = result["items"][0] + self.assertEqual(first["clause_key"], "acceptance") + self.assertIn("suggested_text", first) + self.assertIn("reason", first) + self.assertIn("legal_basis", first) + self.assertEqual(first["insert_position"]["strategy"], "after_anchor_clause") + self.assertEqual(first["legal_article_refs"][0]["article_no"], "第五百零九条") + self.assertEqual(result["summary"]["source_count"], 3) + self.assertEqual(result["prompt_runtime"]["prompt_version"], "clause_recommendation.rule_based.v1") + + def test_present_clause_is_not_recommended_again(self): + result = self.service.recommend_missing_clauses( + self.current_user, + document_type_code="procurement_goods", + contract_text=( + "采购合同\n" + "一、交付安排:乙方按期交付。\n" + "二、验收标准:甲方收到货物后五个工作日内验收。\n" + "三、质量保证:乙方保证质量符合约定。\n" + "四、违约责任:违约方赔偿损失。\n" + "五、不可抗力:依法处理。\n" + "六、通知与送达:双方确认地址。" + ), + max_items=8, + ) + + self.assertEqual(result["total"], 0) + self.assertIn("acceptance", result["summary"]["detected_clause_keys"]) + self.assertIn("quality_warranty", result["summary"]["detected_clause_keys"]) + + def test_requires_contract_text(self): + with self.assertRaises(HTTPException) as ctx: + self.service.recommend_missing_clauses( + self.current_user, + document_type_code="procurement_goods", + contract_text="", + ) + + self.assertEqual(ctx.exception.status_code, 400) + + def test_contract_type_alias_selects_service_rules(self): + result = self.service.recommend_missing_clauses( + self.current_user, + contract_type="service", + contract_text="服务合同\n甲方向乙方采购运维支持服务,费用为人民币十万元。", + max_items=2, + ) + + self.assertEqual(result["document_type_code"], "service_general") + self.assertEqual([item["clause_key"] for item in result["items"]], ["service_scope", "deliverables"]) + + def test_professional_rewrite_expands_ambiguous_delivery_deadline(self): + result = self.service.rewrite_clause_wording( + self.current_user, + clause_text="乙方应尽快交付。", + rewrite_type="professional", + document_type_code="procurement_goods", + ) + + self.assertEqual(result["rewrite_type"], "professional") + self.assertIn("若干工作日", result["rewritten_text"]) + self.assertIn("交付清单", result["rewritten_text"]) + self.assertGreaterEqual(result["summary"]["change_count"], 1) + self.assertEqual(result["prompt_runtime"]["prompt_version"], "wording_rewrite.rule_based.v1") + self.assertEqual(result["legal_article_refs"][0]["article_no"], "第五百零九条") + + def test_party_a_rewrite_returns_boundary_and_negotiation_advice(self): + result = self.service.rewrite_clause_wording( + self.current_user, + clause_text="乙方应尽快交付。", + rewrite_type="favor-party-a", + document_type_code="procurement_goods", + ) + + self.assertEqual(result["rewrite_type"], "party_a") + self.assertIn("甲方有权", result["rewritten_text"]) + self.assertIn("偏甲方", result["rewrite_type_label"]) + self.assertTrue(result["legal_boundary"]) + self.assertTrue(result["negotiation_advice"]) + self.assertGreaterEqual(len(result["risk_notes"]), 1) + + def test_rewrite_requires_clause_text(self): + with self.assertRaises(HTTPException) as ctx: + self.service.rewrite_clause_wording( + self.current_user, + clause_text="", + rewrite_type="professional", + ) + + self.assertEqual(ctx.exception.status_code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_clauses_api.py b/backend/tests/test_clauses_api.py new file mode 100644 index 00000000..a3421851 --- /dev/null +++ b/backend/tests/test_clauses_api.py @@ -0,0 +1,154 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.api.clauses import get_audit_log_service, get_clause_recommendation_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubClauseRecommendationService: + def __init__(self): + self.calls = [] + + def recommend_missing_clauses(self, current_user, **kwargs): + self.calls.append((current_user, kwargs)) + return { + "contract_id": kwargs.get("contract_id"), + "document_type_code": kwargs.get("document_type_code") or "procurement_goods", + "contract_type": kwargs.get("contract_type"), + "items": [ + { + "id": "clause-procurement_goods-acceptance", + "document_type_code": "procurement_goods", + "clause_key": "acceptance", + "clause_title": "验收标准与流程", + "clause_type": "acceptance", + "priority": "high", + "confidence": "high", + "insert_position": { + "strategy": "after_anchor_clause", + "anchor_text": "二、交付安排", + "description": "建议插入在交付条款之后。", + }, + "suggested_text": "验收标准与流程:甲方应在收到货物后完成验收。", + "reason": "买卖合同需要把交付和验收衔接起来。", + "legal_basis": "《民法典》合同履行规则。", + "legal_article_refs": [], + "source_rule": "LAW-CLAUSE-PROCUREMENT-ACCEPTANCE", + "source_types": ["document_type_required_clause_list", "standard_clause_library"], + "detected_keywords": [], + } + ], + "total": 1, + "summary": { + "recommended_count": 1, + "detected_clause_keys": [], + "missing_clause_keys": ["acceptance"], + "source_count": 0, + "strategy": "rule_based_required_clause_gap", + }, + "sources": ["document_type_required_clause_list"], + } + + def rewrite_clause_wording(self, current_user, **kwargs): + self.calls.append((current_user, kwargs)) + return { + "contract_id": kwargs.get("contract_id"), + "clause_id": kwargs.get("clause_id"), + "document_type_code": kwargs.get("document_type_code") or "procurement_goods", + "contract_type": kwargs.get("contract_type"), + "rewrite_type": kwargs.get("rewrite_type") or "professional", + "rewrite_type_label": "专业化", + "original_text": kwargs.get("clause_text"), + "rewritten_text": "乙方应在合同签订后若干工作日内完成交付。", + "changes": [ + { + "from": "尽快交付", + "to": "若干工作日内完成交付", + "reason": "明确交付期限。", + } + ], + "legal_boundary": "不能用模糊表述替代核心期限。", + "negotiation_advice": "建议把若干工作日改成具体数字。", + "risk_notes": ["请补齐具体期限。"], + "legal_article_refs": [], + "confidence": "high", + "source_types": ["wording_rewrite_rules"], + "summary": { + "changed": True, + "change_count": 1, + "risk_note_count": 1, + "source_count": 0, + "strategy": "rule_based_clause_wording_rewrite", + }, + } + + +class ClausesApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + self.clause_service = StubClauseRecommendationService() + app.dependency_overrides[get_clause_recommendation_service] = lambda: self.clause_service + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-001", + "tenant_id": "tenant-001", + "role": "admin", + } + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_recommend_missing_clauses_returns_items_and_records_audit(self): + response = self.client.post( + "/api/v1/clauses/recommend", + json={ + "contract_id": "contract-001", + "document_type_code": "procurement_goods", + "contract_text": "采购合同正文,包含交付和付款,但缺少验收条款。", + "max_items": 5, + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["total"], 1) + self.assertEqual(body["data"]["items"][0]["clause_key"], "acceptance") + self.assertEqual(self.clause_service.calls[0][1]["contract_id"], "contract-001") + self.assertEqual(self.audit_log_service.events[0]["action"], "recommend_missing_clauses") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["missing_clause_keys"], ["acceptance"]) + + def test_rewrite_clause_wording_returns_result_and_records_audit(self): + response = self.client.post( + "/api/v1/clauses/rewrite", + json={ + "contract_id": "contract-001", + "clause_id": "clause-001", + "document_type_code": "procurement_goods", + "clause_text": "乙方应尽快交付。", + "rewrite_type": "professional", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertIn("若干工作日", body["data"]["rewritten_text"]) + self.assertEqual(self.clause_service.calls[0][1]["clause_id"], "clause-001") + self.assertEqual(self.audit_log_service.events[0]["action"], "rewrite_clause_wording") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["rewrite_type"], "professional") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index 203c4ba3..41f18678 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -1,10 +1,12 @@ import unittest +from datetime import datetime, timezone from fastapi import HTTPException from fastapi.testclient import TestClient from app.api.contracts import get_audit_log_service, get_contract_generation_service, get_tenant_service, require_current_user from app.main import app +from app.services.contract_generation_service import ExportFormat class StubAuditLogService: @@ -16,22 +18,33 @@ class StubAuditLogService: return kwargs +class StubTenantService: + def __init__(self, blocked_action=None): + self.blocked_action = blocked_action + self.calls = [] + + def assert_billing_allows(self, current_user, action, requested_file_size_bytes=0): + self.calls.append((action, requested_file_size_bytes)) + if action == self.blocked_action: + raise HTTPException(status_code=402, detail="quota exceeded") + + class StubContractGenerationService: def _auto_review_summary(self, review_id="review-auto-123", status="completed"): return { "review_id": review_id, - "review_title": "采购合同模板", + "review_title": "Procurement Template", "status": status, "review_as": "party_a", "contract_type": "procurement", "risk_level": "high", "current_stage": "report", - "current_stage_label": "报告已生成", - "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", + "current_stage_label": "Report generated", + "status_detail": "Auto review completed", "failure_reason": None, - "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", + "next_action": "Open review report", "can_retry": False, - "review_opinion": "检测到高风险条款", + "review_opinion": "High risk terms found", "recommendation": "manual_review", "source_file_id": "file-generated-123", "source_file_name": "contract.pdf", @@ -43,85 +56,54 @@ class StubContractGenerationService: "chat_url": f"/chat?review={review_id}", } - def list_generated_contracts(self, current_user, page, page_size, contract_type=None, keyword=None): + def _record(self, record_id, tenant_id="tenant-demo-001", contract_type="procurement", document_type_code="procurement_goods"): + return { + "id": record_id, + "template_id": "tpl-123", + "template_name": "Procurement Template", + "contract_type": contract_type, + "document_type_code": document_type_code, + "generated_content": "Contract with Supplier A", + "generated_html": "
    Contract with Supplier A
    ", + "variable_values": {"supplier_name": "Supplier A"}, + "elements_snapshot": None, + "missing_fields": [], + "ai_hints": [], + "related_legal_articles": [], + "generated_at": "2026-04-17T00:00:00+00:00", + "export_format": "pdf", + "file_name": "contract.pdf", + "file_size": 128, + "preview_url": f"/api/v1/contracts/{record_id}/preview", + "download_url": f"/api/v1/contracts/{record_id}/download", + "auto_review": self._auto_review_summary(f"review-{record_id}"), + "metadata": { + "generated_by": "user-admin", + "generated_by_name": "Admin", + "tenant_id": tenant_id, + "original_template": "Procurement Template", + "document_type_code": document_type_code, + "export_options": {"watermark": True}, + "variables_used": 1, + "asset_file_id": "file-generated-123", + "missing_field_count": 0, + "related_legal_article_count": 0, + "generation_source": None, + }, + "tenant_id": tenant_id, + } + + def list_generated_contracts(self, current_user, page, page_size, contract_type=None, document_type_code=None, keyword=None): records = [ - { - "id": "contract-123", - "template_id": "tpl-123", - "template_name": "采购合同模板", - "contract_type": "procurement", - "generated_content": "采购方与供应商A签订合同", - "variable_values": {"supplier_name": "供应商A"}, - "generated_at": "2026-04-17T00:00:00+00:00", - "export_format": "pdf", - "file_name": "contract.pdf", - "file_size": 128, - "preview_url": "/api/v1/contracts/contract-123/preview", - "download_url": "/api/v1/contracts/contract-123/download", - "metadata": { - "generated_by": "user-admin", - "generated_by_name": "管理员", - "tenant_id": "tenant-demo-001", - "original_template": "采购合同模板", - "export_options": {"watermark": True}, - "variables_used": 1, - }, - "tenant_id": "tenant-demo-001", - }, - { - "id": "contract-456", - "template_id": "tpl-456", - "template_name": "劳动合同模板", - "contract_type": "employment", - "generated_content": "甲方与乙方签订劳动合同", - "variable_values": {"employee_name": "张三"}, - "generated_at": "2026-04-16T00:00:00+00:00", - "export_format": "docx", - "file_name": "employment.docx", - "file_size": 256, - "preview_url": "/api/v1/contracts/contract-456/preview", - "download_url": "/api/v1/contracts/contract-456/download", - "metadata": { - "generated_by": "user-admin", - "generated_by_name": "法务", - "tenant_id": "tenant-demo-001", - "original_template": "劳动合同模板", - "export_options": {}, - "variables_used": 1, - }, - "tenant_id": "tenant-demo-001", - }, - { - "id": "contract-other-tenant", - "template_id": "tpl-other", - "template_name": "其他租户模板", - "contract_type": "procurement", - "generated_content": "其他租户合同", - "variable_values": {"supplier_name": "外部供应商"}, - "generated_at": "2026-04-15T00:00:00+00:00", - "export_format": "pdf", - "file_name": "other.pdf", - "file_size": 512, - "preview_url": "/api/v1/contracts/contract-other-tenant/preview", - "download_url": "/api/v1/contracts/contract-other-tenant/download", - "metadata": { - "generated_by": "user-other", - "generated_by_name": "外部租户", - "tenant_id": "tenant-other-001", - "original_template": "其他租户模板", - "export_options": {"seal": True}, - "variables_used": 1, - }, - "tenant_id": "tenant-other-001", - }, + self._record("contract-123"), + self._record("contract-456", contract_type="employment", document_type_code="labor_contract"), + self._record("contract-other-tenant", tenant_id="tenant-other-001"), ] - - for record in records: - record["auto_review"] = self._auto_review_summary(f"review-{record['id']}") - tenant_records = [record for record in records if record["tenant_id"] == current_user["tenant_id"]] if contract_type: tenant_records = [record for record in tenant_records if record["contract_type"] == contract_type] + if document_type_code: + tenant_records = [record for record in tenant_records if record["document_type_code"] == document_type_code] if keyword: normalized_keyword = keyword.strip().lower() tenant_records = [ @@ -131,47 +113,37 @@ class StubContractGenerationService: or normalized_keyword in record["file_name"].lower() or normalized_keyword in record["metadata"]["generated_by_name"].lower() ] - start = (page - 1) * page_size - items = tenant_records[start : start + page_size] return { - "items": [{key: value for key, value in item.items() if key != "tenant_id"} for item in items], + "items": [{key: value for key, value in item.items() if key != "tenant_id"} for item in tenant_records[start : start + page_size]], "total": len(tenant_records), "page": page, "page_size": page_size, } - def list_available_templates(self, current_user, contract_type, template_category=None, industry_category=None): + def list_available_templates(self, current_user, contract_type, document_type_code=None, template_category=None, industry_category=None): return [ { "id": "tpl-123", - "name": "采购合同模板", + "name": "Procurement Template", "contract_type": contract_type or "procurement", - "description": "标准采购合同", - "variables": [ - { - "name": "supplier_name", - "label": "供应商名称", - "required": True, - "default_value": "", - "description": "填写供应商名称", - } - ], - "preview_excerpt": "甲方向乙方采购货物。", + "document_type_code": document_type_code or "procurement_goods", + "description": "Standard procurement contract", + "variables": [{"name": "supplier_name", "label": "Supplier", "required": True}], + "preview_excerpt": "Buyer purchases goods from supplier.", } ] - def preview_contract(self, current_user, template_id, variable_values): if template_id == "tpl-missing": - raise HTTPException(status_code=404, detail="模板不存在或无权访问") + raise HTTPException(status_code=404, detail="template not found") return { - "preview_content": f"采购方与{variable_values.get('supplier_name', '')}签订合同", - "template_name": "采购合同模板", + "preview_content": f"Contract with {variable_values.get('supplier_name', '')}", + "template_name": "Procurement Template", "variable_summary": [ { "name": "supplier_name", - "label": "供应商名称", + "label": "Supplier", "required": True, "provided": "supplier_name" in variable_values, "value_preview": variable_values.get("supplier_name", ""), @@ -180,7 +152,6 @@ class StubContractGenerationService: ], } - def get_generated_contract(self, current_user, contract_id): records = [ item @@ -188,19 +159,96 @@ class StubContractGenerationService: if item["id"] == contract_id ] if not records: - raise HTTPException(status_code=404, detail="合同不存在或无权访问") + raise HTTPException(status_code=404, detail="contract not found") return records[0] - def generate_contract(self, current_user, template_id, variable_values, export_format, options): + def list_generated_contract_versions(self, current_user, contract_id): + self.get_generated_contract(current_user, contract_id) + return { + "contract_id": contract_id, + "base_version": {"version_number": 1, "version_label": "v1", "content": "Contract with Supplier A"}, + "items": [ + { + "id": "gcv-001", + "contract_id": contract_id, + "version_number": 2, + "version_label": "v2", + "content": "Contract with Supplier A. Payment deadline added.", + "locate_status": "applied", + "detail_url": f"/contracts/{contract_id}?version=gcv-001", + } + ], + "total": 1, + } + + def get_generated_contract_version(self, current_user, contract_id, version_id): + versions = self.list_generated_contract_versions(current_user, contract_id) + for item in versions["items"]: + if item["id"] == version_id: + return item + raise HTTPException(status_code=404, detail="contract version not found") + + def create_generated_contract_version( + self, + current_user, + contract_id, + content, + change_type="editor_save", + change_summary=None, + source_version_id=None, + original_text=None, + replacement_text=None, + locate_status="applied", + ): + self.get_generated_contract(current_user, contract_id) + return { + "id": "gcv-editor-001", + "tenant_id": current_user["tenant_id"], + "contract_id": contract_id, + "review_id": None, + "review_risk_action_id": None, + "version_number": 3, + "version_label": "v3", + "source_version_id": source_version_id or "gcv-001", + "change_type": change_type, + "change_summary": change_summary or "Editor save", + "original_text": original_text, + "replacement_text": replacement_text, + "content": content, + "locate_status": locate_status, + "created_by": current_user["id"], + "created_at": "2026-05-20T00:00:00+00:00", + "detail_url": f"/contracts/{contract_id}?version=gcv-editor-001", + } + + def generate_contract( + self, + current_user, + template_id, + variable_values, + export_format, + options, + document_type_code=None, + elements_snapshot=None, + element_schema_id=None, + element_schema_version_label=None, + fill_missing_variables=False, + ): if template_id == "tpl-missing": - raise HTTPException(status_code=404, detail="模板不存在或无权访问") + raise HTTPException(status_code=404, detail="template not found") return { "id": "contract-123", "template_id": template_id, - "template_name": "采购合同模板", + "template_name": "Procurement Template", "contract_type": "procurement", - "generated_content": f"采购方与{variable_values.get('supplier_name', '')}签订合同", + "document_type_code": document_type_code or "procurement_goods", + "generated_content": f"Contract with {variable_values.get('supplier_name', '')}", + "generated_html": "
    Contract
    ", "variable_values": variable_values, + "elements_snapshot": elements_snapshot, + "missing_fields": [], + "ai_hints": [], + "related_legal_articles": [], "generated_at": "2026-04-17T00:00:00+00:00", "export_format": export_format.value, "file_name": f"contract.{export_format.value}", @@ -211,22 +259,58 @@ class StubContractGenerationService: "auto_review": self._auto_review_summary("review-auto-123"), "metadata": { "generated_by": current_user["id"], - "generated_by_name": current_user.get("display_name", "管理员"), + "generated_by_name": current_user.get("display_name", "Admin"), "tenant_id": current_user["tenant_id"], - "original_template": "采购合同模板", + "original_template": "Procurement Template", + "document_type_code": document_type_code or "procurement_goods", + "elements_snapshot": elements_snapshot, + "element_schema_id": element_schema_id, + "element_schema_version_label": element_schema_version_label, "export_options": options, "variables_used": len(variable_values), + "missing_field_count": 0, + "related_legal_article_count": 0, }, } + def generate_contract_from_elements( + self, + current_user, + document_type_code, + elements, + export_format, + options, + template_id=None, + variable_values=None, + ): + result = self.generate_contract( + current_user=current_user, + template_id=template_id or "tpl-auto-selected", + variable_values={"supplier_name": (elements.get("party_b") or {}).get("name", "seller"), **(variable_values or {})}, + export_format=export_format, + options=options, + document_type_code=document_type_code, + elements_snapshot=elements, + element_schema_id="ldes-auto-v1", + element_schema_version_label="v1.0", + fill_missing_variables=True, + ) + result["missing_fields"] = [{"field_key": "payment.terms", "label": "Payment terms", "placeholder": "please fill"}] + result["ai_hints"] = [{"code": "missing_required_fields", "level": "warning", "message": "Fields still need input"}] + result["related_legal_articles"] = [{"source_id": "lart-civil-585", "title": "Civil Code Article 585"}] + result["metadata"]["missing_field_count"] = 1 + result["metadata"]["related_legal_article_count"] = 1 + result["metadata"]["generation_source"] = {"mode": "structured_elements", "template_selection": "auto"} + return result + def get_generated_contract_preview(self, current_user, contract_id): if contract_id == "contract-missing": - raise HTTPException(status_code=404, detail="合同不存在或无权访问") + raise HTTPException(status_code=404, detail="contract not found") return { "id": contract_id, - "template_name": "采购合同模板", + "template_name": "Procurement Template", "contract_type": "procurement", - "preview_content": "采购方与供应商A签订合同", + "preview_content": "Contract with Supplier A", "export_format": "pdf", "file_name": "contract.pdf", "generated_at": "2026-04-17T00:00:00+00:00", @@ -236,7 +320,7 @@ class StubContractGenerationService: def get_generated_contract_download(self, current_user, contract_id): if contract_id == "contract-missing": - raise HTTPException(status_code=404, detail="合同不存在或无权访问") + raise HTTPException(status_code=404, detail="contract not found") return { "id": contract_id, "file_name": "contract.pdf", @@ -245,17 +329,6 @@ class StubContractGenerationService: } -class StubTenantService: - def __init__(self, blocked_action=None): - self.blocked_action = blocked_action - self.calls = [] - - def assert_billing_allows(self, current_user, action, requested_file_size_bytes=0): - self.calls.append((action, requested_file_size_bytes)) - if action == self.blocked_action: - raise HTTPException(status_code=402, detail="合同生成额度已用完,请升级套餐或购买超额包后继续使用。") - - class ContractsApiTestCase(unittest.TestCase): def setUp(self): self.audit_log_service = StubAuditLogService() @@ -268,319 +341,178 @@ class ContractsApiTestCase(unittest.TestCase): def tearDown(self): app.dependency_overrides.clear() + def _login(self, tenant_id="tenant-demo-001"): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": tenant_id, + "display_name": "Admin", + "role": "admin", + } + def test_user_can_list_contract_templates(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - + self._login() response = self.client.get("/api/v1/contracts/templates", params={"contract_type": "procurement"}) - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"][0]["name"], "采购合同模板") - self.assertEqual(body["data"][0]["contract_type"], "procurement") - - - def test_user_can_list_generated_contracts(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts", params={"page": 1, "page_size": 10}) - - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["total"], 2) - self.assertEqual(body["data"]["page"], 1) - self.assertEqual(body["data"]["page_size"], 10) - self.assertEqual(len(body["data"]["items"]), 2) - self.assertEqual(body["data"]["items"][0]["id"], "contract-123") - self.assertEqual(body["data"]["items"][0]["metadata"]["generated_by_name"], "管理员") - self.assertIn("preview_url", body["data"]["items"][0]) - self.assertIn("download_url", body["data"]["items"][0]) - - def test_generated_contract_list_supports_contract_type_filter(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts", params={"contract_type": "employment"}) + self.assertEqual(response.json()["data"][0]["document_type_code"], "procurement_goods") + def test_user_can_list_generated_contracts_with_filters(self): + self._login() + response = self.client.get("/api/v1/contracts", params={"document_type_code": "labor_contract"}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 1) - self.assertEqual(len(body["data"]["items"]), 1) - self.assertEqual(body["data"]["items"][0]["contract_type"], "employment") - self.assertEqual(body["data"]["items"][0]["id"], "contract-456") - - def test_generated_contract_list_supports_keyword_filter(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts", params={"keyword": "法务"}) - - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["total"], 1) - self.assertEqual(len(body["data"]["items"]), 1) - self.assertEqual(body["data"]["items"][0]["id"], "contract-456") - self.assertEqual(body["data"]["items"][0]["metadata"]["generated_by_name"], "法务") - - def test_generated_contract_list_is_tenant_isolated(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-other", - "tenant_id": "tenant-other-001", - "display_name": "外部租户", - "role": "admin", - } + self.assertEqual(body["data"]["items"][0]["document_type_code"], "labor_contract") + def test_generated_contracts_are_tenant_isolated(self): + self._login("tenant-other-001") response = self.client.get("/api/v1/contracts") - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["total"], 1) - self.assertEqual(len(body["data"]["items"]), 1) - self.assertEqual(body["data"]["items"][0]["id"], "contract-other-tenant") - self.assertEqual(body["data"]["items"][0]["file_name"], "other.pdf") - + self.assertEqual(response.json()["data"]["items"][0]["id"], "contract-other-tenant") def test_user_can_get_generated_contract_detail(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - + self._login() response = self.client.get("/api/v1/contracts/contract-123") - self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["id"], "contract-123") - self.assertEqual(body["data"]["template_id"], "tpl-123") - self.assertEqual(body["data"]["export_format"], "pdf") - self.assertEqual(body["data"]["variable_values"]["supplier_name"], "供应商A") - self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) - self.assertEqual(body["data"]["auto_review"]["status"], "completed") - self.assertEqual(body["data"]["auto_review"]["detail_url"], "/reviews/review-contract-123") + self.assertEqual(body["data"]["metadata"]["document_type_code"], "procurement_goods") self.assertIn("generated_content", body["data"]) - def test_generated_contract_detail_is_tenant_isolated(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } + def test_user_can_list_and_get_generated_contract_versions(self): + self._login() + response = self.client.get("/api/v1/contracts/contract-123/versions") + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["base_version"]["version_label"], "v1") + self.assertEqual(body["data"]["items"][0]["version_label"], "v2") - response = self.client.get("/api/v1/contracts/contract-other-tenant") + detail = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001") + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.json()["data"]["id"], "gcv-001") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["message"], "合同不存在或无权访问") + def test_user_can_create_generated_contract_version_and_write_audit_log(self): + self._login() + response = self.client.post( + "/api/v1/contracts/contract-123/versions", + json={ + "content": "Contract with Supplier A. Acceptance clause added.", + "change_type": "clause_recommendation", + "change_summary": "Apply acceptance clause", + "original_text": "", + "replacement_text": "Acceptance clause added.", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["version_label"], "v3") + self.assertEqual(body["data"]["change_type"], "clause_recommendation") + self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_version_create") + self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["contract_id"], "contract-123") def test_user_can_generate_contract_and_write_audit_log(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - + self._login() response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", - "variable_values_json": '{"supplier_name":"供应商A"}', + "variable_values_json": '{"supplier_name":"Supplier A"}', "export_format": "pdf", "options_json": '{"watermark":true}', }, ) - self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["export_format"], "pdf") - self.assertEqual(body["data"]["file_name"], "contract.pdf") self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) - self.assertEqual(body["data"]["auto_review"]["status"], "completed") - self.assertEqual(body["data"]["auto_review"]["report_download_url"], "/api/v1/reviews/review-auto-123/report/download") self.assertEqual([call[0] for call in self.tenant_service.calls], ["generation", "review"]) self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_generation") - self.assertEqual(self.audit_log_service.operations[0]["resource_id"], "contract-123") + + def test_user_can_generate_contract_from_elements_with_auto_template(self): + self._login() + response = self.client.post( + "/api/v1/contracts/generate", + data={ + "document_type_code": "procurement_goods", + "elements_json": '{"party_a":{"name":"buyer"},"party_b":{"name":"seller"},"goods":{"name":"server"}}', + "export_format": "html", + }, + ) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["template_id"], "tpl-auto-selected") + self.assertEqual(body["data"]["missing_fields"][0]["field_key"], "payment.terms") + self.assertEqual(body["data"]["related_legal_articles"][0]["source_id"], "lart-civil-585") + self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["missing_field_count"], 1) def test_generate_contract_returns_billing_block_message(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } + self._login() app.dependency_overrides[get_tenant_service] = lambda: StubTenantService(blocked_action="generation") - response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", - "variable_values_json": '{"supplier_name":"供应商A"}', + "variable_values_json": '{"supplier_name":"Supplier A"}', "export_format": "pdf", }, ) - self.assertEqual(response.status_code, 402) - self.assertIn("额度已用完", response.json()["message"]) - def test_user_can_get_generated_contract_preview(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts/contract-123/preview") - - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["id"], "contract-123") - self.assertEqual(body["data"]["file_name"], "contract.pdf") - self.assertIn("供应商A", body["data"]["preview_content"]) - - def test_user_can_download_generated_contract_and_write_audit_log(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts/contract-123/download") - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b"pdf-bytes") - self.assertEqual(response.headers["content-type"], "application/pdf") - self.assertIn("attachment;", response.headers["content-disposition"]) - self.assertIn("contract.pdf", response.headers["content-disposition"]) - self.assertEqual(self.audit_log_service.operations[0]["action"], "download_contract") - self.assertEqual( - self.audit_log_service.operations[0]["detail_json"], - { - "file_name": "contract.pdf", - "mime_type": "application/pdf", - }, - ) - self.assertEqual(self.audit_log_service.operations[0]["resource_id"], "contract-123") - - def test_preview_rejects_invalid_json(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.post( + def test_user_can_get_preview_download_and_error_responses(self): + self._login() + preview = self.client.post( "/api/v1/contracts/preview", - data={ - "template_id": "tpl-123", - "variable_values_json": "{bad json}", - }, + data={"template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier A"}'}, ) + self.assertEqual(preview.status_code, 200) + self.assertIn("Supplier A", preview.json()["data"]["preview_content"]) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["message"], "JSON解析错误") + download = self.client.get("/api/v1/contracts/contract-123/download") + self.assertEqual(download.status_code, 200) + self.assertEqual(download.content, b"pdf-bytes") + self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract") - def test_generate_rejects_invalid_export_format(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } + invalid_json = self.client.post( + "/api/v1/contracts/preview", + data={"template_id": "tpl-123", "variable_values_json": "{bad json}"}, + ) + self.assertEqual(invalid_json.status_code, 400) - response = self.client.post( + invalid_format = self.client.post( "/api/v1/contracts/generate", - data={ - "template_id": "tpl-123", - "variable_values_json": '{"supplier_name":"供应商A"}', - "export_format": "xlsx", - }, + data={"template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier A"}', "export_format": "xlsx"}, ) + self.assertEqual(invalid_format.status_code, 400) - self.assertEqual(response.status_code, 400) - self.assertIn("不支持的导出格式", response.json()["message"]) - - def test_generate_surfaces_service_404(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.post( + missing_template = self.client.post( "/api/v1/contracts/generate", - data={ - "template_id": "tpl-missing", - "variable_values_json": '{"supplier_name":"供应商A"}', - "export_format": "docx", - }, + data={"template_id": "tpl-missing", "variable_values_json": '{"supplier_name":"Supplier A"}'}, ) - - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["message"], "模板不存在或无权访问") - - def test_generated_contract_preview_surfaces_service_404(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts/contract-missing/preview") - - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["message"], "合同不存在或无权访问") - - def test_generated_contract_download_surfaces_service_404(self): - app.dependency_overrides[require_current_user] = lambda: { - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - "role": "admin", - } - - response = self.client.get("/api/v1/contracts/contract-missing/download") - - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["message"], "合同不存在或无权访问") + self.assertEqual(missing_template.status_code, 404) -class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): +class ContractGenerationServiceTestCase(unittest.TestCase): def setUp(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.db import Base from app.models.contract_template import ContractTemplate + from app.models.generated_contract import GeneratedContract + from app.models.legal_article import LegalArticle + from app.models.legal_document_element_schema import LegalDocumentElementSchema + from app.models.legal_document_type import LegalDocumentType from app.services.contract_generation_service import ContractGenerationService self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(bind=self.engine) self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) self.db = self.SessionLocal() + self.ContractTemplate = ContractTemplate + self.GeneratedContract = GeneratedContract + self.LegalArticle = LegalArticle + self.LegalDocumentType = LegalDocumentType + self.LegalDocumentElementSchema = LegalDocumentElementSchema self.service = ContractGenerationService(self.db) self.service.file_service = type( "StubFileService", @@ -595,129 +527,196 @@ class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): } }, )() + class StubReviewService: def __init__(self): self.create_calls = [] def create_review_task(self, **kwargs): self.create_calls.append(kwargs) - return { - "id": "review-auto-generated", - "status": "completed", - "review_as": kwargs["review_as"], - "contract_type": kwargs["contract_type"], - } + return {"id": "review-auto-generated", "status": "completed"} def get_review_detail(self, current_user, review_id): return { "id": review_id, - "title": "contract.docx", "contract_type": "procurement", "review_as": "party_a", "status": "completed", - "source_type": "file", - "source_text_length": 12, - "source_text_preview": "采购方与供应商A签订合同", - "processing_time_ms": 1200, - "created_at": "2026-04-17T00:00:00+00:00", - "completed_at": "2026-04-17T00:00:01+00:00", - "error_message": None, "current_stage": "report", - "current_stage_label": "报告已生成", - "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", - "failure_reason": None, - "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", - "can_retry": False, - "workflow_state": { - "review_id": review_id, - "risk_level": "high", - "status": "completed", - "current_stage": "report", - }, - "ai_trace": { - "review_id": review_id, - "status": "completed", - "provider": "stub", - }, - "source_document": { - "document_id": "file-generated-123", - "file_name": "contract.docx", - "source_type": "file", - "parse_status": "completed", - "extraction_mode": "template_render", - "ocr_gate_status": "PASS", - "text_length": 12, - "preview": "采购方与供应商A签订合同", - }, + "current_stage_label": "Report generated", + "status_detail": "done", + "workflow_state": {"risk_level": "low"}, + "ai_trace": {"status": "completed"}, + "source_document": {"file_name": "contract.docx"}, } def get_review_result(self, current_user, review_id): return { - "basic_info": { - "contract_type": "procurement", - "party_a": "甲方", - "party_b": "乙方", - "amount": "待识别", - "term": "待识别", - }, - "risk_summary": {"high": 1, "medium": 0, "low": 0, "overall": "high"}, - "risk_items": [{"title": "付款条件建议明确", "level": "high", "description": "desc"}], - "review_opinion": "检测到高风险条款", - "recommendation": "manual_review", + "basic_info": {"contract_type": "procurement"}, + "risk_summary": {"overall": "low"}, + "risk_items": [], + "review_opinion": "ok", + "recommendation": "approved", } def get_review_report(self, current_user, review_id): - return { - "file_name": f"review-report-{review_id}.pdf", - "download_url": f"/api/v1/reviews/{review_id}/report/download", - } + return {"file_name": f"review-report-{review_id}.pdf", "download_url": f"/api/v1/reviews/{review_id}/report/download"} self.review_service = StubReviewService() self.service.review_service = self.review_service - self.ContractTemplate = ContractTemplate + self.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "Admin", "role": "admin"} def tearDown(self): self.db.close() self.engine.dispose() + def _seed_law_doc_004_data(self): + now = datetime.now(timezone.utc) + self.db.add( + self.LegalDocumentType( + id="ldt-procurement", + code="procurement_goods", + name="Procurement goods", + category="contract", + industry="trade", + scenario="purchase", + risk_level="medium", + is_active=True, + sort_order=10, + created_at=now, + updated_at=now, + ) + ) + self.db.add( + self.LegalDocumentElementSchema( + id="ldes-procurement-v1", + document_type_code="procurement_goods", + version_label="v1.0", + schema_json={ + "sections": [ + { + "key": "base", + "title": "Base", + "fields": [ + {"key": "party_a.name", "label": "Buyer", "type": "text", "required": True}, + {"key": "party_b.name", "label": "Supplier", "type": "text", "required": True}, + {"key": "goods.name", "label": "Goods", "type": "text", "required": True}, + {"key": "price.total", "label": "Amount", "type": "money", "required": True}, + {"key": "payment.terms", "label": "Payment", "type": "textarea", "required": True}, + ], + } + ] + }, + status="published", + published_at=now, + published_by="seed", + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + ) + self.db.add( + self.ContractTemplate( + id="tpl-elements", + tenant_id="tenant-demo-001", + name="Procurement Template", + contract_type="procurement", + document_type_code="procurement_goods", + description="Element based template", + content="Buyer {{buyer}} buys {{product_name}} from {{supplier}} for {{amount}}. Payment: {{payment_terms}}. Extra: {{missing_required}}.", + variables_json=[ + {"name": "buyer", "label": "Buyer", "required": True}, + {"name": "supplier", "label": "Supplier", "required": True}, + {"name": "product_name", "label": "Goods", "required": True}, + {"name": "amount", "label": "Amount", "required": True}, + {"name": "payment_terms", "label": "Payment", "required": True}, + {"name": "missing_required", "label": "Extra", "required": True}, + ], + enabled=True, + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + ) + self.db.add( + self.LegalArticle( + id="lart-civil-585", + tenant_id="tenant-demo-001", + source_id="civil-code", + source_title="中华人民共和国民法典", + source_type="law", + authority="NPC", + region="CN", + article_no="Article 585", + article_text="当事人可以约定一方违约时应当根据违约情况向对方支付一定数额的违约金。", + tags_json=["违约", "买卖合同"], + index_status="indexed", + published_status="published", + published_at=now, + published_by="seed", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + def test_generate_contract_accepts_json_column_variable_definitions(self): + now = datetime.now(timezone.utc) template = self.ContractTemplate( id="tpl-real-json", tenant_id="tenant-demo-001", - name="采购合同模板", + name="Procurement Template", contract_type="procurement", - description="验证 JSON 列变量", - content="采购方与{{supplier_name}}签订合同", - variables_json=[ - { - "name": "supplier_name", - "label": "供应商名称", - "required": True, - "default_value": "", - "description": "填写供应商名称", - } - ], + description="JSON variables", + content="Contract with {{supplier_name}}", + variables_json=[{"name": "supplier_name", "label": "Supplier", "required": True}], enabled=True, created_by="seed", updated_by="seed", + created_at=now, + updated_at=now, ) self.db.add(template) self.db.commit() - result = self.service.generate_contract( - current_user={ - "id": "user-admin", - "tenant_id": "tenant-demo-001", - "display_name": "管理员", - }, + current_user=self.current_user, template_id="tpl-real-json", - variable_values={"supplier_name": "供应商A"}, + variable_values={"supplier_name": "Supplier A"}, ) self.assertEqual(result["template_id"], "tpl-real-json") - self.assertEqual(result["variable_values"]["supplier_name"], "供应商A") + self.assertEqual(result["variable_values"]["supplier_name"], "Supplier A") self.assertEqual(self.review_service.create_calls[0]["source_file_id"], "file-generated-123") self.assertEqual(result["auto_review"]["status"], "completed") - self.assertEqual(result["auto_review"]["chat_url"].startswith("/chat?review="), True) + self.assertEqual( + result["metadata"]["prompt_runtime"]["contract_generation"]["prompt_version"], + "contract_generation.template_render.v1", + ) + + def test_generate_contract_from_elements_auto_selects_template_and_returns_law_doc_004_payload(self): + self._seed_law_doc_004_data() + result = self.service.generate_contract_from_elements( + current_user=self.current_user, + document_type_code="procurement_goods", + elements={ + "party_a": {"name": "Buyer Co"}, + "party_b": {"name": "Supplier Co"}, + "goods": {"name": "Servers"}, + "price": {"total": "180000"}, + }, + export_format=ExportFormat.HTML, + ) + self.assertEqual(result["template_id"], "tpl-elements") + self.assertEqual(result["document_type_code"], "procurement_goods") + self.assertIn("missing-fields", result["generated_html"]) + self.assertIn("payment.terms", [item["field_key"] for item in result["missing_fields"]]) + self.assertIn("missing_required", [item["field_key"] for item in result["missing_fields"]]) + self.assertEqual(result["related_legal_articles"][0]["source_id"], "lart-civil-585") + self.assertTrue(result["ai_hints"]) + self.assertIn("generated_html", result) + self.assertEqual(result["metadata"]["generation_source"]["template_selection"], "auto") + self.assertEqual(self.review_service.create_calls[-1]["extraction_metadata"]["missing_field_count"], len(result["missing_fields"])) def test_get_generated_contract_download_allows_owner_access_to_file_payload(self): generated_contract = type( @@ -733,7 +732,6 @@ class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): }, )() captured = {} - self.service.generated_contract_repository = type( "StubGeneratedContractRepository", (), @@ -756,16 +754,106 @@ class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): ) }, )() - result = self.service.get_generated_contract_download( current_user={"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"}, contract_id="contract-owner-001", ) - self.assertEqual(result["file_name"], "contract.txt") - self.assertEqual(captured["payload"]["file_id"], "file-owner-001") self.assertTrue(captured["payload"]["allow_owner_access"]) + def test_create_generated_contract_version_saves_editor_content(self): + now = datetime.now(timezone.utc) + self.db.add( + self.ContractTemplate( + id="tpl-editor", + tenant_id="tenant-demo-001", + name="Editor Template", + contract_type="procurement", + document_type_code="procurement_goods", + description="Editor template", + content="Original contract content.", + variables_json=[], + enabled=True, + created_by="seed", + updated_by="seed", + created_at=now, + updated_at=now, + ) + ) + self.db.add( + self.GeneratedContract( + id="contract-editor-001", + tenant_id="tenant-demo-001", + template_id="tpl-editor", + template_name="Editor 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="editor.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Original contract content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.commit() + + result = self.service.create_generated_contract_version( + current_user=self.current_user, + contract_id="contract-editor-001", + content="Original contract content.\nAcceptance clause added.", + change_type="clause_recommendation", + change_summary="Apply acceptance clause", + replacement_text="Acceptance clause added.", + ) + + self.assertEqual(result["version_label"], "v2") + self.assertEqual(result["change_type"], "clause_recommendation") + self.assertIn("Acceptance clause added", result["content"]) + + def test_create_generated_contract_version_rejects_unchanged_content(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-editor-unchanged", + tenant_id="tenant-demo-001", + template_id="tpl-editor", + template_name="Editor 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="editor.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="Original contract content.", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.commit() + + with self.assertRaises(HTTPException) as ctx: + self.service.create_generated_contract_version( + current_user=self.current_user, + contract_id="contract-editor-unchanged", + content="Original contract content.", + ) + + self.assertEqual(ctx.exception.status_code, 409) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_legal_article_service.py b/backend/tests/test_legal_article_service.py new file mode 100644 index 00000000..33df6eb1 --- /dev/null +++ b/backend/tests/test_legal_article_service.py @@ -0,0 +1,101 @@ +import unittest +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import app.models # noqa: F401 +from app.db import Base +from app.models.legal_change_event import LegalChangeEvent +from app.services.legal_article_service import LegalArticleService + + +class LegalArticleServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=self.engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine, future=True) + self.db = SessionLocal() + self.service = LegalArticleService(self.db) + self.current_user = {"id": "user-admin", "tenant_id": "tenant-001", "role": "admin"} + self.payload = { + "source_id": "LAW-CIVIL-CODE-2020", + "source_title": "中华人民共和国民法典", + "source_type": "法律", + "authority": "全国人民代表大会", + "effective_date": "2021-01-01", + "validity_status": "现行有效", + "region": "CN", + "article_no": "第五百八十五条", + "article_text": "当事人可以约定违约金。", + "tags": ["违约金"], + "index_status": "indexed", + "published_status": "published", + "published_at": datetime.now(timezone.utc).isoformat(), + } + + def tearDown(self): + self.db.close() + Base.metadata.drop_all(bind=self.engine) + + def test_bulk_upsert_articles_enqueues_impact_scan_events(self): + result = self.service.bulk_upsert_articles( + self.current_user, + [self.payload], + enqueue_impact_scan=True, + max_impact_scan_events=10, + ) + + events = self.db.query(LegalChangeEvent).all() + self.assertEqual(result["created_count"], 1) + self.assertEqual(result["impact_scan_events_enqueued"], 1) + self.assertEqual(result["impact_scan_events"][0]["scan_status"], "pending") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].change_type, "created") + + def test_bulk_upsert_respects_impact_scan_event_cap(self): + result = self.service.bulk_upsert_articles( + self.current_user, + [self.payload], + enqueue_impact_scan=True, + max_impact_scan_events=0, + ) + + self.assertEqual(result["impact_scan_events_enqueued"], 0) + self.assertEqual(result["impact_scan_events_skipped"], 1) + self.assertEqual(self.db.query(LegalChangeEvent).count(), 0) + + def test_update_article_content_enqueues_updated_event(self): + self.service.bulk_upsert_articles(self.current_user, [self.payload], enqueue_impact_scan=False) + updated_payload = dict(self.payload) + updated_payload["article_text"] = "当事人可以约定违约金,但应结合实际损失。" + + result = self.service.bulk_upsert_articles( + self.current_user, + [updated_payload], + enqueue_impact_scan=True, + ) + + event = self.db.query(LegalChangeEvent).one() + self.assertEqual(result["updated_count"], 1) + self.assertEqual(event.change_type, "updated") + self.assertEqual(event.previous_article_text, self.payload["article_text"]) + + def test_update_index_status_enqueues_effective_change_when_publish_status_changes(self): + created = self.service.bulk_upsert_articles(self.current_user, [self.payload], enqueue_impact_scan=False) + article_id = created["created"][0]["id"] + + self.service.update_index_status( + self.current_user, + article_id, + index_status="indexed", + published_status="archived", + ) + + event = self.db.query(LegalChangeEvent).one() + self.assertEqual(event.change_type, "effective_changed") + self.assertEqual(event.scan_status, "pending") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_articles_api.py b/backend/tests/test_legal_articles_api.py index 08b28292..b2ce3ee6 100644 --- a/backend/tests/test_legal_articles_api.py +++ b/backend/tests/test_legal_articles_api.py @@ -67,7 +67,7 @@ class StubLegalArticleService: return None return self.list_articles(current_user, 1, 20)["items"][0] - def bulk_upsert_articles(self, current_user, articles): + def bulk_upsert_articles(self, current_user, articles, *, enqueue_impact_scan=True, max_impact_scan_events=50): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") return { @@ -76,6 +76,9 @@ class StubLegalArticleService: "total": len(articles), "created": articles, "updated": [], + "impact_scan_events_enqueued": len(articles) if enqueue_impact_scan else 0, + "impact_scan_events_skipped": 0, + "impact_scan_events": [], } def update_index_status( @@ -166,8 +169,10 @@ class LegalArticlesApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["data"]["total"], 1) + self.assertEqual(response.json()["data"]["impact_scan_events_enqueued"], 1) self.assertEqual(self.audit_log_service.events[0]["action"], "bulk_upsert_legal_articles") self.assertEqual(self.audit_log_service.events[0]["detail_json"]["source_ids"], ["LAW-CIVIL-CODE-2020"]) + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["impact_scan_events_enqueued"], 1) def test_admin_can_update_legal_article_index_status(self): app.dependency_overrides[require_current_user] = lambda: { diff --git a/backend/tests/test_legal_change_impact_service.py b/backend/tests/test_legal_change_impact_service.py new file mode 100644 index 00000000..5ac78a8b --- /dev/null +++ b/backend/tests/test_legal_change_impact_service.py @@ -0,0 +1,163 @@ +import unittest +from datetime import datetime, timezone + +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import app.models # noqa: F401 +from app.db import Base +from app.models.contract_template import ContractTemplate +from app.models.generated_contract import GeneratedContract +from app.models.legal_article import LegalArticle +from app.models.review_rule import ReviewRule +from app.services.legal_change_impact_service import LegalChangeImpactService + + +class LegalChangeImpactServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=self.engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine, future=True) + self.db = SessionLocal() + self.current_user = {"id": "user-admin", "tenant_id": "tenant-001", "role": "admin"} + self.service = LegalChangeImpactService(self.db) + self.article = LegalArticle( + id="lart-civil-585", + tenant_id="tenant-001", + source_id="LAW-CIVIL-CODE-2020", + source_title="中华人民共和国民法典", + source_type="法律", + authority="全国人民代表大会", + effective_date="2021-01-01", + validity_status="现行有效", + region="CN", + article_no="第五百八十五条", + article_text="当事人可以约定一方违约时应当根据违约情况向对方支付一定数额的违约金。", + tags_json=["违约金", "损失赔偿"], + text_checksum="new-checksum", + index_status="indexed", + published_status="published", + published_at=datetime.now(timezone.utc), + ) + self.template = ContractTemplate( + id="tpl-001", + tenant_id="tenant-001", + name="采购合同模板", + contract_type="procurement", + template_code="TPL-PROCUREMENT", + template_category="procurement-supply", + industry_category="general", + description="包含违约金条款", + content="依据中华人民共和国民法典第五百八十五条,双方约定违约金。", + enabled=True, + ) + self.rule = ReviewRule( + id="rule-001", + tenant_id="tenant-001", + name="违约金过高风险", + rule_code="RULE-LIQUIDATED-DAMAGES", + keyword="违约金", + contract_type="all", + risk_level="high", + description="检查违约金是否明显过高。", + suggestion="复核违约金比例。", + legal_basis="中华人民共和国民法典 第五百八十五条", + is_active=True, + ) + self.contract = GeneratedContract( + id="contract-001", + tenant_id="tenant-001", + template_id="tpl-001", + template_name="采购合同模板", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_name="采购合同.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=123, + generated_content="本合同违约金条款参考中华人民共和国民法典第五百八十五条。", + file_content_base64="SGVsbG8=", + status="generated", + ) + self.db.add_all([self.article, self.template, self.rule, self.contract]) + self.db.commit() + + def tearDown(self): + self.db.close() + Base.metadata.drop_all(bind=self.engine) + + def test_scan_article_change_finds_templates_rules_and_contracts(self): + result = self.service.scan_article_change( + self.current_user, + article_id="lart-civil-585", + change_type="updated", + change_summary="违约金裁量规则调整", + previous_text_checksum="old-checksum", + previous_article_text="旧违约金条文", + ) + + event = result["event"] + impacts = result["impacts"] + target_types = {item["target_type"] for item in impacts} + + self.assertEqual(event["scan_status"], "completed") + self.assertEqual(event["impact_summary"]["total"], 3) + self.assertEqual(event["impact_summary"]["blocking_count"], 2) + self.assertEqual(target_types, {"contract_template", "review_rule", "generated_contract"}) + self.assertEqual( + {item["target_type"]: item["impact_level"] for item in impacts}["review_rule"], + "immediate_update", + ) + self.assertEqual( + {item["target_type"]: item["impact_level"] for item in impacts}["generated_contract"], + "next_renewal_update", + ) + + def test_enqueue_and_run_pending_scan(self): + queued = self.service.enqueue_article_change( + self.current_user, + article_id="lart-civil-585", + change_type="updated", + change_summary="先入队,稍后扫描", + ) + + self.assertEqual(queued["event"]["scan_status"], "pending") + self.assertEqual(queued["impacts"], []) + + result = self.service.run_pending_scans(self.current_user, limit=1) + + self.assertEqual(result["completed_count"], 1) + self.assertEqual(result["failed_count"], 0) + self.assertEqual(result["completed"][0]["event"]["scan_status"], "completed") + self.assertEqual(len(result["completed"][0]["impacts"]), 3) + + def test_update_impact_status_records_handling_note(self): + result = self.service.scan_article_change(self.current_user, article_id="lart-civil-585") + impact_id = result["impacts"][0]["id"] + + updated = self.service.update_impact_status( + self.current_user, + impact_id, + impact_status="resolved", + handling_note="模板已发布新版本", + ) + + self.assertEqual(updated["impact_status"], "resolved") + self.assertEqual(updated["handled_by"], "user-admin") + self.assertEqual(updated["evidence"]["handling_note"], "模板已发布新版本") + + def test_member_cannot_scan(self): + with self.assertRaises(HTTPException) as context: + self.service.scan_article_change( + {"id": "user-member", "tenant_id": "tenant-001", "role": "member"}, + article_id="lart-civil-585", + ) + + self.assertEqual(context.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_change_impacts_api.py b/backend/tests/test_legal_change_impacts_api.py new file mode 100644 index 00000000..d0523562 --- /dev/null +++ b/backend/tests/test_legal_change_impacts_api.py @@ -0,0 +1,169 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.legal_change_impacts import get_audit_log_service, get_legal_change_impact_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubLegalChangeImpactService: + def _require_admin(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + + def _event(self, **overrides): + payload = { + "id": "lce-001", + "tenant_id": "tenant-demo-001", + "article_id": "lart-civil-585", + "source_id": "LAW-CIVIL-CODE-2020", + "source_title": "中华人民共和国民法典", + "article_no": "第五百八十五条", + "change_type": "updated", + "change_summary": "违约金规则更新", + "previous_text_checksum": "old", + "current_text_checksum": "new", + "previous_article_text": "旧条文", + "current_article_text": "新条文", + "effective_date": "2021-01-01", + "tags": ["违约金"], + "scan_status": "completed", + "impact_summary": {"total": 1, "blocking_count": 1, "by_level": {"immediate_update": 1}}, + "created_by": "user-admin", + "created_at": "2026-05-21T00:00:00+00:00", + "scanned_at": "2026-05-21T00:01:00+00:00", + "updated_at": "2026-05-21T00:01:00+00:00", + } + payload.update(overrides) + return payload + + def _impact(self, **overrides): + payload = { + "id": "lci-001", + "tenant_id": "tenant-demo-001", + "change_event_id": "lce-001", + "target_type": "contract_template", + "target_id": "tpl-001", + "target_name": "采购合同模板", + "target_version_label": "v1.0", + "match_reason": "direct_legal_reference", + "match_terms": ["第五百八十五条"], + "impact_level": "immediate_update", + "suggestion": "请立即检查模板。", + "impact_status": "open", + "evidence": {}, + "handled_by": None, + "handled_at": None, + "created_at": "2026-05-21T00:01:00+00:00", + "updated_at": "2026-05-21T00:01:00+00:00", + } + payload.update(overrides) + return payload + + def list_events(self, current_user, *, page=1, page_size=20, scan_status=None, source_id=None): + self._require_admin(current_user) + return {"items": [self._event(source_id=source_id or "LAW-CIVIL-CODE-2020")], "page": page, "page_size": page_size, "total": 1} + + def get_event_detail(self, current_user, event_id): + self._require_admin(current_user) + if event_id == "missing": + return None + return {"event": self._event(id=event_id), "impacts": [self._impact()]} + + def scan_article_change(self, current_user, **kwargs): + self._require_admin(current_user) + return { + "event": self._event(article_id=kwargs["article_id"], change_type=kwargs.get("change_type", "updated")), + "impacts": [self._impact()], + } + + def run_event_scan(self, current_user, event_id): + self._require_admin(current_user) + return {"event": self._event(id=event_id), "impacts": [self._impact()]} + + def run_pending_scans(self, current_user, *, limit=10): + self._require_admin(current_user) + return { + "requested_limit": limit, + "completed_count": 1, + "failed_count": 0, + "completed": [{"event": self._event(), "impacts": [self._impact()]}], + "failed": [], + } + + def update_impact_status(self, current_user, impact_id, *, impact_status, handling_note=None): + self._require_admin(current_user) + return self._impact(id=impact_id, impact_status=impact_status, evidence={"handling_note": handling_note}) + + +class LegalChangeImpactsApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_legal_change_impact_service] = lambda: StubLegalChangeImpactService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def _as_admin(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + def test_admin_can_list_detail_scan_and_update_status(self): + self._as_admin() + + list_response = self.client.get("/api/v1/legal-change-impacts/events", params={"source_id": "LAW-CIVIL-CODE-2020"}) + detail_response = self.client.get("/api/v1/legal-change-impacts/events/lce-001") + scan_response = self.client.post( + "/api/v1/legal-change-impacts/scan", + json={"article_id": "lart-civil-585", "change_type": "updated", "change_summary": "违约金规则更新"}, + ) + run_event_response = self.client.post("/api/v1/legal-change-impacts/events/lce-001/run") + run_pending_response = self.client.post("/api/v1/legal-change-impacts/run-pending", json={"limit": 5}) + status_response = self.client.post( + "/api/v1/legal-change-impacts/impacts/lci-001/status", + json={"impact_status": "resolved", "handling_note": "已处理"}, + ) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(detail_response.json()["data"]["impacts"][0]["target_type"], "contract_template") + self.assertEqual(scan_response.json()["data"]["event"]["article_id"], "lart-civil-585") + self.assertEqual(run_event_response.json()["data"]["event"]["scan_status"], "completed") + self.assertEqual(run_pending_response.json()["data"]["completed_count"], 1) + self.assertEqual(status_response.json()["data"]["impact_status"], "resolved") + self.assertEqual([event["action"] for event in self.audit_log_service.events], [ + "scan_legal_change_impact", + "run_legal_change_event_scan", + "run_pending_legal_change_scans", + "update_legal_change_impact_status", + ]) + + def test_member_cannot_access_legal_change_impacts(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/legal-change-impacts/events") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_draft_service.py b/backend/tests/test_legal_document_draft_service.py new file mode 100644 index 00000000..21d03fbc --- /dev/null +++ b/backend/tests/test_legal_document_draft_service.py @@ -0,0 +1,188 @@ +import unittest +from datetime import datetime, timezone + +from fastapi import HTTPException + +from app.models.legal_document_draft import LegalDocumentDraft +from app.services.contract_generation_service import ExportFormat +from app.services.legal_document_draft_service import LegalDocumentDraftService + + +class StubDraftRepository: + def __init__(self): + self.items = {} + + def create(self, draft): + self.items[draft.id] = draft + return draft + + def update(self, draft): + self.items[draft.id] = draft + return draft + + def get_by_id(self, draft_id, tenant_id=None): + item = self.items.get(draft_id) + if item is None: + return None + if tenant_id is not None and item.tenant_id != tenant_id: + return None + return item + + def list_recent(self, *, tenant_id, user_id, document_type_code=None, status_value="draft", include_deleted=False, limit=20): + results = [] + for item in self.items.values(): + if item.tenant_id != tenant_id or item.user_id != user_id: + continue + if document_type_code and item.document_type_code != document_type_code: + continue + if status_value and item.status != status_value: + continue + if not include_deleted and item.deleted_at is not None: + continue + results.append(item) + return results[:limit] + + +class StubElementSchemaService: + def validate_element_values(self, current_user, document_type_code, values): + errors = [] + if not (((values.get("party_a") or {}).get("name"))): + errors.append({"field_key": "party_a.name", "message": "required"}) + if not (((values.get("party_b") or {}).get("name"))): + errors.append({"field_key": "party_b.name", "message": "required"}) + return { + "document_type_code": document_type_code, + "schema_id": "ldes-procurement-v1", + "version_label": "v1.0", + "valid": not errors, + "errors": errors, + } + + +class StubTemplate: + id = "tpl-procurement" + enabled = True + variables_json = [{"name": "buyer", "required": True}] + + +class StubTemplateRepository: + def __init__(self): + self.template = StubTemplate() + + def get_by_id(self, template_id, tenant_id=None): + return self.template if template_id == self.template.id else None + + def list_enabled(self, tenant_id, contract_type=None, template_category=None, industry_category=None, document_type_code=None): + return [self.template] + + +class StubContractGenerationService: + def __init__(self): + self.calls = [] + + def generate_contract_from_elements(self, **kwargs): + self.calls.append(kwargs) + return { + "id": "contract-generated-001", + "template_id": kwargs["template_id"], + "document_type_code": kwargs["document_type_code"], + "elements_snapshot": kwargs["elements"], + "missing_fields": [{"field_key": "payment.terms"}], + "ai_hints": [{"code": "missing_required_fields"}], + "related_legal_articles": [{"source_id": "lart-civil-585"}], + "metadata": { + "element_schema_id": "ldes-procurement-v1", + "element_schema_version_label": "v1.0", + }, + } + + +class LegalDocumentDraftServiceTestCase(unittest.TestCase): + def setUp(self): + self.service = LegalDocumentDraftService(None) + self.repository = StubDraftRepository() + self.contract_generation_service = StubContractGenerationService() + self.service.repository = self.repository + self.service.element_schema_service = StubElementSchemaService() + self.service.contract_template_repository = StubTemplateRepository() + self.service.contract_generation_service = self.contract_generation_service + self.current_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} + + def test_create_list_get_update_and_delete_draft(self): + created = self.service.create_draft( + self.current_user, + document_type_code="procurement_goods", + title="draft one", + element_values={"party_a": {"name": "Buyer Co"}, "party_b": {"name": "Supplier Co"}}, + ) + self.assertEqual(created["document_type_code"], "procurement_goods") + self.assertEqual(created["status"], "draft") + self.assertTrue(created["validation_result"]["valid"]) + + listed = self.service.list_drafts(self.current_user) + self.assertEqual(listed["total"], 1) + self.assertEqual(listed["items"][0]["id"], created["id"]) + + updated = self.service.update_draft( + self.current_user, + created["id"], + title="draft updated", + element_values={"party_a": {"name": "Buyer Co"}}, + ) + self.assertEqual(updated["title"], "draft updated") + self.assertFalse(updated["validation_result"]["valid"]) + + deleted = self.service.delete_draft(self.current_user, created["id"]) + self.assertEqual(deleted["status"], "deleted") + self.assertEqual(self.service.list_drafts(self.current_user)["total"], 0) + with self.assertRaises(HTTPException) as raised: + self.service.get_draft(self.current_user, created["id"]) + self.assertEqual(raised.exception.status_code, 404) + + def test_generate_from_draft_uses_structured_generation_chain(self): + created = self.service.create_draft( + self.current_user, + document_type_code="procurement_goods", + template_id="tpl-procurement", + element_values={ + "party_a": {"name": "Buyer Co"}, + "party_b": {"name": "Supplier Co"}, + "goods": {"name": "Servers", "quantity": 3}, + }, + ) + result = self.service.generate_from_draft( + self.current_user, + created["id"], + export_format=ExportFormat.PDF, + options={"watermark": True}, + ) + call = self.contract_generation_service.calls[0] + self.assertEqual(call["template_id"], "tpl-procurement") + self.assertEqual(call["document_type_code"], "procurement_goods") + self.assertEqual(call["elements"]["party_a"]["name"], "Buyer Co") + self.assertEqual(call["export_format"], ExportFormat.PDF) + self.assertEqual(call["options"], {"watermark": True}) + self.assertEqual(result["draft"]["status"], "generated") + self.assertEqual(result["draft"]["generated_contract_id"], "contract-generated-001") + self.assertEqual(result["generated_contract"]["related_legal_articles"][0]["source_id"], "lart-civil-585") + + def test_member_cannot_restore_other_users_draft(self): + now = datetime.now(timezone.utc) + draft = LegalDocumentDraft( + id="ldd-other", + tenant_id="tenant-demo-001", + user_id="user-owner", + document_type_code="procurement_goods", + status="draft", + element_values_json={}, + created_at=now, + updated_at=now, + ) + self.repository.create(draft) + with self.assertRaises(HTTPException) as raised: + self.service.get_draft(self.current_user, "ldd-other") + self.assertEqual(raised.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_drafts_api.py b/backend/tests/test_legal_document_drafts_api.py new file mode 100644 index 00000000..62c8c86a --- /dev/null +++ b/backend/tests/test_legal_document_drafts_api.py @@ -0,0 +1,163 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.api.legal_document_drafts import ( + get_audit_log_service, + get_legal_document_draft_service, + require_current_user, +) +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubLegalDocumentDraftService: + def __init__(self): + self.draft = { + "id": "ldd-001", + "tenant_id": "tenant-demo-001", + "user_id": "user-member", + "document_type_code": "procurement_goods", + "title": "采购合同草稿", + "status": "draft", + "element_values": {"party_a": {"name": "甲方公司"}}, + "validation_result": {"valid": False, "errors": [{"field_key": "party_b.name", "message": "required"}]}, + "element_schema_id": "ldes-procurement-v1", + "element_schema_version_label": "v1.0", + "template_id": "tpl-procurement", + "generated_contract_id": None, + "submitted_at": None, + "deleted_at": None, + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:00:00+00:00", + } + + def list_drafts(self, current_user, document_type_code=None, status_value="draft", limit=20): + return {"items": [self.draft], "total": 1} + + def create_draft(self, current_user, **kwargs): + created = dict(self.draft) + created.update( + { + "document_type_code": kwargs["document_type_code"], + "element_values": kwargs["element_values"], + "title": kwargs.get("title"), + "template_id": kwargs.get("template_id"), + } + ) + return created + + def get_draft(self, current_user, draft_id): + return dict(self.draft, id=draft_id) + + def update_draft(self, current_user, draft_id, **kwargs): + updated = dict(self.draft, id=draft_id) + updated.update({key: value for key, value in kwargs.items() if value is not None}) + return updated + + def delete_draft(self, current_user, draft_id): + return dict(self.draft, id=draft_id, status="deleted", deleted_at="2026-05-20T00:01:00+00:00") + + def generate_from_draft(self, current_user, draft_id, **kwargs): + generated_draft = dict(self.draft, id=draft_id, status="generated", generated_contract_id="contract-001") + return { + "draft": generated_draft, + "generated_contract": { + "id": "contract-001", + "document_type_code": "procurement_goods", + "elements_snapshot": self.draft["element_values"], + "metadata": { + "element_schema_id": "ldes-procurement-v1", + "element_schema_version_label": "v1.0", + }, + }, + } + + +class LegalDocumentDraftsApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + self.draft_service = StubLegalDocumentDraftService() + app.dependency_overrides[get_legal_document_draft_service] = lambda: self.draft_service + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_user_can_list_recent_drafts(self): + response = self.client.get("/api/v1/legal-document-drafts") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["total"], 1) + self.assertEqual(body["data"]["items"][0]["id"], "ldd-001") + + def test_user_can_save_draft_and_write_audit_log(self): + response = self.client.post( + "/api/v1/legal-document-drafts", + json={ + "document_type_code": "procurement_goods", + "title": "采购合同草稿", + "template_id": "tpl-procurement", + "element_values": {"party_a": {"name": "甲方公司"}}, + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["document_type_code"], "procurement_goods") + self.assertEqual(body["data"]["element_values"]["party_a"]["name"], "甲方公司") + self.assertEqual(self.audit_log_service.events[0]["action"], "save_legal_document_draft") + + def test_user_can_restore_update_delete_and_generate_from_draft(self): + detail = self.client.get("/api/v1/legal-document-drafts/ldd-001") + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.json()["data"]["id"], "ldd-001") + + updated = self.client.put( + "/api/v1/legal-document-drafts/ldd-001", + json={"title": "已恢复草稿", "element_values": {"party_a": {"name": "甲方公司"}}}, + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["data"]["title"], "已恢复草稿") + + generated = self.client.post( + "/api/v1/legal-document-drafts/ldd-001/generate", + json={"export_format": "pdf", "options": {"watermark": True}}, + ) + self.assertEqual(generated.status_code, 200) + self.assertEqual(generated.json()["data"]["draft"]["generated_contract_id"], "contract-001") + self.assertEqual( + generated.json()["data"]["generated_contract"]["elements_snapshot"]["party_a"]["name"], + "甲方公司", + ) + + deleted = self.client.delete("/api/v1/legal-document-drafts/ldd-001") + self.assertEqual(deleted.status_code, 200) + self.assertEqual(deleted.json()["data"]["status"], "deleted") + self.assertEqual( + [event["action"] for event in self.audit_log_service.events], + [ + "update_legal_document_draft", + "generate_contract_from_legal_document_draft", + "delete_legal_document_draft", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_element_schema_service.py b/backend/tests/test_legal_document_element_schema_service.py new file mode 100644 index 00000000..e1c8f294 --- /dev/null +++ b/backend/tests/test_legal_document_element_schema_service.py @@ -0,0 +1,208 @@ +import unittest +from datetime import datetime, timezone +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.models.legal_document_element_schema import LegalDocumentElementSchema +from app.services.legal_document_element_schema_service import LegalDocumentElementSchemaService + + +class StubSchemaRepository: + def __init__(self): + self.items = {} + + def create(self, element_schema): + if element_schema.created_at is None: + element_schema.created_at = datetime.now(timezone.utc) + if element_schema.updated_at is None: + element_schema.updated_at = datetime.now(timezone.utc) + self.items[element_schema.id] = element_schema + return element_schema + + def update(self, element_schema): + element_schema.updated_at = datetime.now(timezone.utc) + self.items[element_schema.id] = element_schema + return element_schema + + def get_by_id(self, schema_id): + return self.items.get(schema_id) + + def get_by_document_type_version(self, document_type_code, version_label): + for item in self.items.values(): + if item.document_type_code == document_type_code and item.version_label == version_label: + return item + return None + + def get_current_by_document_type_code(self, document_type_code): + published = [ + item + for item in self.items.values() + if item.document_type_code == document_type_code and item.status == "published" + ] + if published: + published.sort(key=lambda item: item.updated_at, reverse=True) + return published[0] + return self.get_latest_by_document_type_code(document_type_code) + + def get_latest_by_document_type_code(self, document_type_code): + items = [item for item in self.items.values() if item.document_type_code == document_type_code] + items.sort(key=lambda item: item.updated_at, reverse=True) + return items[0] if items else None + + def list_by_document_type_code(self, document_type_code): + items = [item for item in self.items.values() if item.document_type_code == document_type_code] + items.sort(key=lambda item: item.updated_at, reverse=True) + return items + + +class StubDocumentTypeRepository: + def __init__(self): + self.codes = { + "procurement_goods", + "labor_contract", + "nda_commercial", + "software_development", + "service_general", + "technical_service", + "lease_house", + "equity_transfer", + "cooperation_framework", + "demand_letter", + "rescission_notice", + } + + def get_by_code(self, code): + if code in self.codes: + return SimpleNamespace(code=code) + return None + + +class LegalDocumentElementSchemaServiceTestCase(unittest.TestCase): + def setUp(self): + self.service = LegalDocumentElementSchemaService(db=None) + self.service.repository = StubSchemaRepository() + self.service.document_type_repository = StubDocumentTypeRepository() + self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} + + def test_admin_can_seed_and_member_can_read_current_schema(self): + seeded = self.service.seed_default_element_schemas(self.admin_user, "procurement-goods") + current = self.service.get_current_element_schema(self.member_user, "procurement_goods") + + self.assertEqual(seeded["created_count"], 1) + self.assertEqual(current["document_type_code"], "procurement_goods") + self.assertEqual(current["status"], "published") + self.assertEqual(current["version_label"], "v1.0") + self.assertGreaterEqual(len(current["schema_json"]["sections"]), 2) + + def test_schema_validation_rejects_unsupported_field_type(self): + with self.assertRaises(HTTPException) as context: + self.service.create_element_schema( + self.admin_user, + "procurement_goods", + schema_json={ + "sections": [ + { + "key": "main", + "title": "Main", + "fields": [ + {"key": "amount", "label": "Amount", "type": "unsupported", "required": True} + ], + } + ] + }, + ) + + self.assertEqual(context.exception.status_code, 400) + self.assertIn("unsupported field type", context.exception.detail) + + def test_validate_element_values_checks_required_money_date_and_percentage(self): + self.service.seed_default_element_schemas(self.admin_user, "procurement_goods") + invalid = self.service.validate_element_values( + self.member_user, + "procurement_goods", + { + "party_a": {"name": "甲方"}, + "party_b": {"name": ""}, + "goods": {"name": "服务器", "quantity": "two"}, + "price": {"total": "not-money"}, + "delivery": {"date": "2026/05/20"}, + }, + ) + self.service.seed_default_element_schemas(self.admin_user, "equity_transfer") + percent_invalid = self.service.validate_element_values( + self.member_user, + "equity_transfer", + { + "party_a": {"name": "甲方"}, + "party_b": {"name": "乙方"}, + "company": {"name": "目标公司"}, + "equity": {"percent": "120"}, + "transfer": {"price": "100000"}, + }, + ) + + self.assertFalse(invalid["valid"]) + self.assertTrue(any(item["field_key"] == "party_b.name" and item["message"] == "required" for item in invalid["errors"])) + self.assertTrue(any(item["field_key"] == "goods.quantity" for item in invalid["errors"])) + self.assertTrue(any(item["field_key"] == "price.total" for item in invalid["errors"])) + self.assertTrue(any(item["field_key"] == "delivery.date" for item in invalid["errors"])) + self.assertFalse(percent_invalid["valid"]) + self.assertTrue(any(item["field_key"] == "equity.percent" for item in percent_invalid["errors"])) + + def test_valid_element_values_pass(self): + self.service.seed_default_element_schemas(self.admin_user, "procurement_goods") + + result = self.service.validate_element_values( + self.member_user, + "procurement_goods", + { + "party_a": {"name": "甲方"}, + "party_b": {"name": "乙方"}, + "goods": {"name": "服务器", "quantity": "2"}, + "price": {"total": "120000.50"}, + "delivery": {"date": "2026-06-01", "place": "上海"}, + "acceptance": {"standard": "到货验收"}, + "payment": {"terms": "验收后付款"}, + "dispute_resolution": {"method": "诉讼"}, + }, + ) + + self.assertTrue(result["valid"]) + self.assertEqual(result["errors"], []) + + def test_publish_archives_previous_published_schema(self): + first = self.service.create_element_schema( + self.admin_user, + "procurement_goods", + schema_json={"sections": [{"key": "main", "title": "Main", "fields": [{"key": "name", "label": "Name", "type": "text", "required": True}]}]}, + version_label="v1.0", + status_value="published", + ) + second = self.service.create_element_schema( + self.admin_user, + "procurement_goods", + schema_json={"sections": [{"key": "main", "title": "Main", "fields": [{"key": "amount", "label": "Amount", "type": "money", "required": True}]}]}, + version_label="v1.1", + ) + + published = self.service.publish_element_schema(self.admin_user, "procurement_goods", second["id"]) + archived_first = self.service.repository.get_by_id(first["id"]) + + self.assertEqual(published["status"], "published") + self.assertEqual(archived_first.status, "archived") + + def test_member_cannot_manage_schema(self): + with self.assertRaises(HTTPException) as context: + self.service.create_element_schema( + self.member_user, + "procurement_goods", + schema_json={"sections": [{"key": "main", "title": "Main", "fields": [{"key": "name", "label": "Name", "type": "text"}]}]}, + ) + + self.assertEqual(context.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_element_schemas_api.py b/backend/tests/test_legal_document_element_schemas_api.py new file mode 100644 index 00000000..d9614f42 --- /dev/null +++ b/backend/tests/test_legal_document_element_schemas_api.py @@ -0,0 +1,244 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.legal_document_types import ( + get_audit_log_service, + get_legal_document_element_schema_service, + require_current_user, +) +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubLegalDocumentElementSchemaService: + def _item(self, code="procurement_goods", schema_id="ldes-001", status="published", version_label="v1.0"): + return { + "id": schema_id, + "document_type_code": code, + "version_label": version_label, + "schema_json": { + "sections": [ + { + "key": "parties", + "title": "当事人信息", + "fields": [ + {"key": "party_a.name", "label": "甲方名称", "type": "text", "required": True}, + {"key": "price.total", "label": "合同总价", "type": "money", "required": True}, + ], + } + ] + }, + "status": status, + "published_at": "2026-05-20T00:00:00+00:00" if status == "published" else None, + "published_by": "user-admin" if status == "published" else None, + "rollback_from_schema_id": None, + "created_by": "user-admin", + "updated_by": "user-admin", + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:00:00+00:00", + } + + def get_current_element_schema(self, current_user, document_type_code): + return self._item(code=document_type_code) + + def list_element_schema_versions(self, current_user, document_type_code): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "document_type_code": document_type_code, + "items": [self._item(code=document_type_code)], + "total": 1, + } + + def create_element_schema( + self, + current_user, + document_type_code, + *, + schema_json, + version_label=None, + status_value="draft", + rollback_from_schema_id=None, + ): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return self._item( + code=document_type_code, + schema_id="ldes-created", + status=status_value, + version_label=version_label or "v1.0", + ) + + def update_element_schema( + self, + current_user, + document_type_code, + schema_id, + *, + schema_json=None, + version_label=None, + status_value=None, + ): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return self._item( + code=document_type_code, + schema_id=schema_id, + status=status_value or "draft", + version_label=version_label or "v1.1", + ) + + def publish_element_schema(self, current_user, document_type_code, schema_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return self._item(code=document_type_code, schema_id=schema_id, status="published", version_label="v1.1") + + def seed_default_element_schemas(self, current_user, document_type_code=None): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "created": [self._item(code=document_type_code or "procurement_goods")], + "existing": [], + "skipped": [], + "created_count": 1, + "existing_count": 0, + "skipped_count": 0, + "total": 1, + } + + def validate_element_values(self, current_user, document_type_code, values): + errors = [] + if not ((values.get("party_a") or {}).get("name") or values.get("party_a.name")): + errors.append({"field_key": "party_a.name", "message": "required"}) + if str((values.get("price") or {}).get("total") or values.get("price.total") or "").startswith("bad"): + errors.append({"field_key": "price.total", "message": "money must be numeric"}) + return { + "document_type_code": document_type_code, + "schema_id": "ldes-001", + "version_label": "v1.0", + "valid": not errors, + "errors": errors, + } + + +class LegalDocumentElementSchemasApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_legal_document_element_schema_service] = lambda: StubLegalDocumentElementSchemaService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_member_can_get_current_element_schema_and_validate_values(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + schema_response = self.client.get("/api/v1/legal-document-types/procurement_goods/element-schema") + validate_response = self.client.post( + "/api/v1/legal-document-types/procurement_goods/element-schema/validate", + json={"values": {"party_a": {"name": "甲方"}, "price": {"total": "1000"}}}, + ) + + self.assertEqual(schema_response.status_code, 200) + self.assertEqual(schema_response.json()["data"]["document_type_code"], "procurement_goods") + self.assertEqual(validate_response.status_code, 200) + self.assertTrue(validate_response.json()["data"]["valid"]) + + def test_validate_values_returns_customer_readable_errors(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.post( + "/api/v1/legal-document-types/procurement_goods/element-schema/validate", + json={"values": {"price": {"total": "bad-value"}}}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertFalse(body["valid"]) + self.assertEqual([item["field_key"] for item in body["errors"]], ["party_a.name", "price.total"]) + + def test_admin_can_create_update_publish_and_seed_schema(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + schema_payload = { + "schema_json": { + "sections": [ + { + "key": "main", + "title": "Main", + "fields": [{"key": "name", "label": "Name", "type": "text", "required": True}], + } + ] + }, + "version_label": "v1.1", + "status": "draft", + } + + create_response = self.client.post("/api/v1/legal-document-types/procurement_goods/element-schema", json=schema_payload) + update_response = self.client.put( + "/api/v1/legal-document-types/procurement_goods/element-schema/ldes-created", + json={"status": "draft", "version_label": "v1.2"}, + ) + publish_response = self.client.post("/api/v1/legal-document-types/procurement_goods/element-schema/ldes-created/publish") + seed_one_response = self.client.post("/api/v1/legal-document-types/procurement_goods/element-schema/seed-default") + seed_all_response = self.client.post("/api/v1/legal-document-types/element-schemas/seed-defaults") + versions_response = self.client.get("/api/v1/legal-document-types/procurement_goods/element-schemas") + + self.assertEqual(create_response.status_code, 200) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(seed_one_response.status_code, 200) + self.assertEqual(seed_all_response.status_code, 200) + self.assertEqual(versions_response.status_code, 200) + self.assertEqual( + [event["action"] for event in self.audit_log_service.events], + [ + "create_legal_document_element_schema", + "update_legal_document_element_schema", + "publish_legal_document_element_schema", + "seed_default_legal_document_element_schema", + "seed_default_legal_document_element_schemas", + ], + ) + + def test_member_cannot_manage_element_schema(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.post( + "/api/v1/legal-document-types/procurement_goods/element-schema", + json={"schema_json": {"sections": []}}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + self.assertEqual(self.audit_log_service.events, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_type_service.py b/backend/tests/test_legal_document_type_service.py new file mode 100644 index 00000000..7f70fad1 --- /dev/null +++ b/backend/tests/test_legal_document_type_service.py @@ -0,0 +1,173 @@ +import unittest +from datetime import datetime, timezone + +from fastapi import HTTPException + +from app.models.legal_document_type import LegalDocumentType +from app.services.legal_document_type_service import LegalDocumentTypeService + + +class StubRepository: + def __init__(self): + self.items = {} + + def create(self, document_type): + if document_type.created_at is None: + document_type.created_at = datetime.now(timezone.utc) + if document_type.updated_at is None: + document_type.updated_at = datetime.now(timezone.utc) + self.items[document_type.code] = document_type + return document_type + + def update(self, document_type): + document_type.updated_at = datetime.now(timezone.utc) + self.items[document_type.code] = document_type + return document_type + + def get_by_code(self, code): + return self.items.get(code) + + def list_filtered( + self, + *, + category=None, + industry=None, + scenario=None, + active_only=True, + query=None, + ): + items = list(self.items.values()) + if active_only: + items = [item for item in items if item.is_active] + if category: + items = [item for item in items if item.category == category] + if industry: + items = [item for item in items if item.industry == industry] + if scenario: + items = [item for item in items if item.scenario == scenario] + if query: + lowered = query.lower() + items = [ + item + for item in items + if lowered in item.code.lower() + or lowered in item.name.lower() + or lowered in (item.description or "").lower() + ] + items.sort(key=lambda item: (item.sort_order, item.category, item.name)) + return items + + +class LegalDocumentTypeServiceTestCase(unittest.TestCase): + def setUp(self): + self.service = LegalDocumentTypeService(db=None) + self.service.repository = StubRepository() + self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} + + def test_admin_can_seed_and_list_default_document_types(self): + seeded = self.service.seed_default_document_types(self.admin_user) + listed = self.service.list_document_types( + self.member_user, + category="company-commercial", + industry="technology", + ) + + self.assertEqual(seeded["created_count"], 11) + self.assertEqual(seeded["existing_count"], 0) + self.assertEqual(listed["total"], 2) + self.assertEqual([item["code"] for item in listed["items"]], ["technical_service", "software_development"]) + self.assertIn("company-commercial", listed["facets"]["categories"]) + self.assertIn("technology", listed["facets"]["industries"]) + self.assertIn("software-development", listed["facets"]["scenarios"]) + + def test_create_rejects_duplicate_and_invalid_code(self): + created = self.service.create_document_type( + self.admin_user, + code="custom_notice", + name="Custom Notice", + category="notices-letters", + industry="general-enterprise", + scenario="custom-notice", + ) + self.assertEqual(created["code"], "custom_notice") + + with self.assertRaises(HTTPException) as duplicate_context: + self.service.create_document_type( + self.admin_user, + code="custom_notice", + name="Duplicate", + category="notices-letters", + industry="general-enterprise", + scenario="custom-notice", + ) + self.assertEqual(duplicate_context.exception.status_code, 409) + + with self.assertRaises(HTTPException) as invalid_context: + self.service.create_document_type( + self.admin_user, + code="Invalid Code!", + name="Invalid", + category="notices-letters", + industry="general-enterprise", + scenario="custom-notice", + ) + self.assertEqual(invalid_context.exception.status_code, 400) + + def test_update_can_toggle_active_status_and_risk_level(self): + self.service.repository.create( + LegalDocumentType( + id="ldt-demo", + code="demo_contract", + name="Demo Contract", + category="company-commercial", + industry="general-enterprise", + scenario="demo", + description=None, + risk_level="medium", + is_active=True, + sort_order=100, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ) + + updated = self.service.update_document_type( + self.admin_user, + "demo-contract", + name="Demo Contract Updated", + risk_level="high", + is_active=False, + ) + active_list = self.service.list_document_types(self.member_user) + all_list = self.service.list_document_types(self.member_user, active_only=False) + + self.assertEqual(updated["code"], "demo_contract") + self.assertEqual(updated["name"], "Demo Contract Updated") + self.assertEqual(updated["risk_level"], "high") + self.assertFalse(updated["is_active"]) + self.assertEqual(active_list["total"], 0) + self.assertEqual(all_list["total"], 1) + + def test_member_can_list_but_cannot_manage_document_types(self): + self.service.seed_default_document_types(self.admin_user) + + listed = self.service.list_document_types(self.member_user, query="service") + self.assertGreaterEqual(listed["total"], 2) + + with self.assertRaises(HTTPException) as context: + self.service.create_document_type( + self.member_user, + code="member_type", + name="Member Type", + category="company-commercial", + industry="general-enterprise", + scenario="member", + ) + + self.assertEqual(context.exception.status_code, 403) + self.assertEqual(context.exception.detail, "forbidden") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legal_document_types_api.py b/backend/tests/test_legal_document_types_api.py new file mode 100644 index 00000000..b00bd0a1 --- /dev/null +++ b/backend/tests/test_legal_document_types_api.py @@ -0,0 +1,233 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.legal_document_types import get_audit_log_service, get_legal_document_type_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubLegalDocumentTypeService: + def list_document_types( + self, + current_user, + *, + category=None, + industry=None, + scenario=None, + active_only=True, + query=None, + ): + return { + "items": [ + { + "id": "ldt-procurement_goods", + "code": "procurement_goods", + "name": "货物买卖合同", + "category": category or "company-commercial", + "industry": industry or "general-enterprise", + "scenario": scenario or "procurement-supply", + "description": "Goods sale and purchase", + "risk_level": "medium", + "is_active": active_only, + "sort_order": 10, + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:00:00+00:00", + } + ], + "total": 1, + "facets": { + "categories": ["company-commercial"], + "industries": ["general-enterprise"], + "scenarios": ["procurement-supply"], + }, + "filters": { + "category": category, + "industry": industry, + "scenario": scenario, + "active_only": active_only, + "query": query, + }, + } + + def get_document_type(self, current_user, code): + if code == "missing": + raise HTTPException(status_code=404, detail="legal document type not found") + return self.list_document_types(current_user)["items"][0] + + def create_document_type(self, current_user, **payload): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "id": "ldt-custom", + "code": payload["code"], + "name": payload["name"], + "category": payload["category"], + "industry": payload["industry"], + "scenario": payload["scenario"], + "description": payload.get("description"), + "risk_level": payload.get("risk_level") or "medium", + "is_active": payload.get("is_active", True), + "sort_order": payload.get("sort_order", 100), + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:00:00+00:00", + } + + def update_document_type(self, current_user, code, **payload): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + item = self.create_document_type( + current_user, + code=code, + name=payload.get("name") or "Custom Updated", + category=payload.get("category") or "company-commercial", + industry=payload.get("industry") or "general-enterprise", + scenario=payload.get("scenario") or "custom", + description=payload.get("description"), + risk_level=payload.get("risk_level") or "high", + is_active=payload.get("is_active", False), + sort_order=payload.get("sort_order", 100), + ) + item["id"] = f"ldt-{code}" + return item + + def seed_default_document_types(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "created": [], + "existing": [], + "created_count": 11, + "existing_count": 0, + "total": 11, + "document_type_codes": ["procurement_goods", "technical_service"], + } + + +class LegalDocumentTypesApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_legal_document_type_service] = lambda: StubLegalDocumentTypeService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_member_can_list_document_types_with_filters(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get( + "/api/v1/legal-document-types", + params={ + "category": "company-commercial", + "industry": "technology", + "scenario": "software-development", + "query": "service", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["total"], 1) + self.assertEqual(body["filters"]["industry"], "technology") + self.assertEqual(body["items"][0]["scenario"], "software-development") + + def test_member_can_get_document_type_detail(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/legal-document-types/procurement_goods") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["code"], "procurement_goods") + + def test_admin_can_seed_default_document_types(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.post("/api/v1/legal-document-types/seed-defaults") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["total"], 11) + self.assertEqual(self.audit_log_service.events[0]["action"], "seed_default_legal_document_types") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["created_count"], 11) + + def test_admin_can_create_and_update_document_type(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + create_response = self.client.post( + "/api/v1/legal-document-types", + json={ + "code": "custom_contract", + "name": "Custom Contract", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "custom", + "risk_level": "medium", + }, + ) + update_response = self.client.put( + "/api/v1/legal-document-types/custom_contract", + json={ + "name": "Custom Contract Updated", + "risk_level": "high", + "is_active": False, + }, + ) + + self.assertEqual(create_response.status_code, 200) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["data"]["risk_level"], "high") + self.assertEqual(self.audit_log_service.events[0]["action"], "create_legal_document_type") + self.assertEqual(self.audit_log_service.events[1]["action"], "update_legal_document_type") + self.assertEqual(self.audit_log_service.events[1]["detail_json"]["updated_fields"], ["is_active", "name", "risk_level"]) + + def test_member_cannot_create_document_type(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.post( + "/api/v1/legal-document-types", + json={ + "code": "member_contract", + "name": "Member Contract", + "category": "company-commercial", + "industry": "general-enterprise", + "scenario": "member", + }, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + self.assertEqual(self.audit_log_service.events, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_operation_kpi_service.py b/backend/tests/test_operation_kpi_service.py new file mode 100644 index 00000000..9dfb7648 --- /dev/null +++ b/backend/tests/test_operation_kpi_service.py @@ -0,0 +1,327 @@ +import json +import unittest +from datetime import datetime, timezone + +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import app.models # noqa: F401 +from app.db import Base +from app.models.audit_log import AuditLog +from app.models.commercial_order import CommercialOrder +from app.models.generated_contract import GeneratedContract +from app.models.legal_article import LegalArticle +from app.models.message import Message +from app.models.review_result import ReviewResult +from app.models.review_risk_action import ReviewRiskAction +from app.models.review_task import ReviewTask +from app.models.tenant import Tenant +from app.services.operation_kpi_service import OperationKpiService + + +class OperationKpiServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=self.engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine, future=True) + self.db = SessionLocal() + self.current_user = {"id": "user-admin", "tenant_id": "tenant-001", "role": "admin"} + now = datetime.now(timezone.utc) + self.db.add_all( + [ + Tenant( + id="tenant-001", + name="演示企业", + code="demo", + status="active", + plan_name="试用版", + plan_code="trial", + billing_status="trialing", + review_quota_monthly=1, + generation_quota_monthly=1, + knowledge_base_quota=1, + storage_quota_mb=2048, + created_at=now, + updated_at=now, + ), + Tenant( + id="tenant-002", + name="付费企业", + code="paid", + status="active", + plan_name="团队版", + plan_code="team", + billing_status="active", + review_quota_monthly=800, + generation_quota_monthly=500, + knowledge_base_quota=5, + storage_quota_mb=10240, + created_at=now, + updated_at=now, + ), + ] + ) + self.db.add( + LegalArticle( + id="legal-1", + tenant_id="tenant-001", + source_id="LAW-CIVIL-CODE", + source_title="中华人民共和国民法典", + source_type="法律", + authority="全国人民代表大会", + effective_date="2021-01-01", + validity_status="现行有效", + region="CN", + article_no="第五百八十五条", + article_text="当事人可以约定违约金。", + tags_json=["违约金"], + index_status="indexed", + published_status="published", + created_at=now, + updated_at=now, + ) + ) + self.db.add_all( + [ + ReviewTask( + id="review-1", + tenant_id="tenant-001", + user_id="user-admin", + title="采购合同审查", + created_at=now, + ), + ReviewTask( + id="review-2", + tenant_id="tenant-001", + user_id="user-admin", + title="服务合同审查", + created_at=now, + ), + ] + ) + self.db.add( + ReviewResult( + id="result-1", + review_task_id="review-1", + basic_info_json="{}", + clauses_json="[]", + risk_items_json=json.dumps( + [ + { + "title": "违约金风险", + "legal_article_refs": [ + { + "id": "legal-1", + "title": "中华人民共和国民法典 第五百八十五条", + "detail_path": "/legal-articles/legal-1", + } + ], + } + ], + ensure_ascii=False, + ), + risk_summary_json="{}", + review_opinion="建议调整违约金。", + recommendation="modify", + created_at=now, + updated_at=now, + ) + ) + self.db.add_all( + [ + GeneratedContract( + id="contract-1", + tenant_id="tenant-001", + template_id="tpl-1", + template_name="采购合同模板", + generated_by="user-admin", + export_format="docx", + file_name="采购合同.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=1024, + generated_content="合同正文", + export_options_json={"_related_legal_articles": [{"id": "legal-1", "title": "民法典 第五百八十五条"}]}, + file_content_base64="Y29udGVudA==", + created_at=now, + ), + GeneratedContract( + id="contract-2", + tenant_id="tenant-001", + template_id="tpl-1", + template_name="采购合同模板", + generated_by="user-admin", + export_format="docx", + file_name="采购合同-2.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=1024, + generated_content="合同正文", + file_content_base64="Y29udGVudA==", + created_at=now, + ), + ] + ) + self.db.add_all( + [ + Message( + id="msg-user-1", + tenant_id="tenant-001", + conversation_id="conv-1", + user_id="user-admin", + role="user", + content="违约金是否合法?", + created_at=now, + ), + Message( + id="msg-assistant-1", + tenant_id="tenant-001", + conversation_id="conv-1", + user_id="user-admin", + role="assistant", + content="可参考民法典。", + sources_json=[{"id": "legal-1", "source_type": "legal_article", "detail_path": "/legal-articles/legal-1"}], + created_at=now, + ), + ] + ) + self.db.add_all( + [ + AuditLog( + id="audit-report", + tenant_id="tenant-001", + user_id="user-admin", + action="download_report", + resource_type="review", + resource_id="review-1", + created_at=now, + ), + AuditLog( + id="audit-recommend", + tenant_id="tenant-001", + user_id="user-admin", + action="recommend_missing_clauses", + resource_type="clause_recommendation", + resource_id="contract-1", + created_at=now, + ), + AuditLog( + id="audit-rewrite", + tenant_id="tenant-001", + user_id="user-admin", + action="rewrite_clause_wording", + resource_type="clause_rewrite", + resource_id="contract-1", + created_at=now, + ), + ] + ) + self.db.add_all( + [ + ReviewRiskAction( + id="rra-1", + tenant_id="tenant-001", + review_id="review-1", + risk_item_id="risk-1", + risk_item_index=1, + risk_item_title="违约金风险", + risk_level="high", + action_status="adopted", + created_at=now, + updated_at=now, + ), + ReviewRiskAction( + id="rra-2", + tenant_id="tenant-001", + review_id="review-1", + risk_item_id="risk-2", + risk_item_index=2, + risk_item_title="付款节点不清晰", + risk_level="medium", + action_status="modified", + created_at=now, + updated_at=now, + ), + ReviewRiskAction( + id="rra-3", + tenant_id="tenant-001", + review_id="review-2", + risk_item_id="risk-3", + risk_item_index=1, + risk_item_title="验收条款缺失", + risk_level="medium", + action_status="ignored", + created_at=now, + updated_at=now, + ), + ] + ) + self.db.add_all( + [ + CommercialOrder( + id="order-activated", + tenant_id="tenant-001", + requested_by="user-admin", + order_no="ORD-001", + order_type="subscription", + status="activated", + plan_code="team", + plan_name="团队版", + billing_cycle="monthly", + amount_cents=199900, + created_at=now, + updated_at=now, + ), + CommercialOrder( + id="order-overage", + tenant_id="tenant-001", + requested_by="user-admin", + order_no="ORD-002", + order_type="overage", + status="pending_payment", + plan_code="overage", + plan_name="超额包", + billing_cycle="one_time", + amount_cents=29900, + created_at=now, + updated_at=now, + ), + ] + ) + self.db.commit() + self.service = OperationKpiService(self.db) + + def tearDown(self): + self.db.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def test_dashboard_builds_usage_commercial_and_quality_kpis(self): + dashboard = self.service.get_dashboard(self.current_user) + monthly = next(item for item in dashboard["periods"] if item["key"] == "monthly") + + self.assertEqual(monthly["metrics"]["review_count"], 2) + self.assertEqual(monthly["metrics"]["generation_count"], 2) + self.assertEqual(monthly["metrics"]["chat_message_count"], 1) + self.assertEqual(monthly["metrics"]["clause_recommendation_count"], 2) + self.assertEqual(monthly["metrics"]["ai_usage_count"], 7) + self.assertEqual(dashboard["top_templates"][0]["template_id"], "tpl-1") + self.assertEqual(dashboard["top_templates"][0]["usage_count"], 2) + self.assertEqual(dashboard["clause_action_summary"]["applied_count"], 2) + self.assertEqual(dashboard["clause_action_summary"]["ignored_count"], 1) + self.assertEqual(dashboard["legal_citation_summary"]["total_reference_count"], 3) + self.assertEqual(dashboard["legal_citation_summary"]["verified_reference_rate_percent"], 100.0) + self.assertEqual(dashboard["conversion_funnel"]["trial_or_free_tenants"], 1) + self.assertEqual(dashboard["conversion_funnel"]["active_paid_tenants"], 1) + self.assertEqual(dashboard["overage_conversion"]["current_exceeded_count"], 2) + self.assertEqual(dashboard["overage_conversion"]["pending_overage_order_count"], 1) + self.assertEqual(dashboard["overage_conversion"]["activated_upgrade_order_count"], 1) + + def test_member_cannot_read_operation_kpis(self): + with self.assertRaises(HTTPException) as context: + self.service.get_dashboard({"id": "user-member", "tenant_id": "tenant-001", "role": "member"}) + + self.assertEqual(context.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_operation_kpis_api.py b/backend/tests/test_operation_kpis_api.py new file mode 100644 index 00000000..0a8f3a2d --- /dev/null +++ b/backend/tests/test_operation_kpis_api.py @@ -0,0 +1,88 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.operation_kpis import get_operation_kpi_service, require_current_user +from app.main import app + + +class StubOperationKpiService: + def get_dashboard(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "scope": { + "tenant_id": current_user["tenant_id"], + "usage_scope": "current_tenant", + "plan_distribution_scope": "platform_counts", + "timezone": "UTC", + }, + "generated_at": "2026-05-21T00:00:00+00:00", + "periods": [ + { + "key": "monthly", + "label": "本月", + "start_at": "2026-05-01T00:00:00+00:00", + "end_at": "2026-05-21T00:00:00+00:00", + "metrics": { + "ai_usage_count": 7, + "review_count": 2, + "generation_count": 2, + "chat_message_count": 1, + "report_download_count": 1, + "clause_recommendation_count": 2, + "clause_action_total": 3, + "clause_action_applied": 2, + "clause_action_ignored": 1, + "clause_action_adoption_rate_percent": 66.67, + "legal_reference_count": 3, + "legal_reference_verified_rate_percent": 100, + }, + } + ], + "top_templates": [{"template_id": "tpl-1", "template_name": "采购合同模板", "usage_count": 2, "last_used_at": None}], + "clause_action_summary": {"total": 3, "applied_count": 2, "ignored_count": 1, "adoption_rate_percent": 66.67}, + "risk_action_top_items": [], + "legal_citation_summary": {"total_reference_count": 3, "verified_reference_rate_percent": 100}, + "conversion_funnel": {"trial_or_free_tenants": 1, "active_paid_tenants": 1, "plan_distribution": []}, + "overage_conversion": {"current_exceeded_count": 2, "pending_overage_order_count": 1}, + } + + +class OperationKpisApiTestCase(unittest.TestCase): + def setUp(self): + app.dependency_overrides[get_operation_kpi_service] = lambda: StubOperationKpiService() + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_admin_can_get_operation_kpi_dashboard(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/operation-kpis/dashboard") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["periods"][0]["metrics"]["ai_usage_count"], 7) + self.assertEqual(response.json()["data"]["top_templates"][0]["template_id"], "tpl-1") + + def test_member_cannot_get_operation_kpi_dashboard(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/operation-kpis/dashboard") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_permission_metadata.py b/backend/tests/test_permission_metadata.py index b8fba0de..120a2c54 100644 --- a/backend/tests/test_permission_metadata.py +++ b/backend/tests/test_permission_metadata.py @@ -1,6 +1,6 @@ import unittest -from app.api import audit_logs, auth, chat, contracts, conversations, files, governance, knowledge_bases, review_rules, reviews, system, templates, tenant_settings, users +from app.api import audit_logs, auth, chat, clauses, contracts, conversations, files, governance, knowledge_bases, legal_articles, legal_change_impacts, legal_document_drafts, legal_document_types, operation_kpis, prompt_assets, review_rules, reviews, sales_followups, system, templates, tenant_settings, users class PermissionMetadataTestCase(unittest.TestCase): @@ -9,13 +9,21 @@ class PermissionMetadataTestCase(unittest.TestCase): audit_logs, auth, chat, + clauses, contracts, conversations, files, governance, knowledge_bases, + legal_articles, + legal_change_impacts, + legal_document_drafts, + legal_document_types, + operation_kpis, + prompt_assets, review_rules, reviews, + sales_followups, system, templates, tenant_settings, @@ -33,13 +41,21 @@ class PermissionMetadataTestCase(unittest.TestCase): audit_logs, auth, chat, + clauses, contracts, conversations, files, governance, knowledge_bases, + legal_articles, + legal_change_impacts, + legal_document_drafts, + legal_document_types, + operation_kpis, + prompt_assets, review_rules, reviews, + sales_followups, system, templates, tenant_settings, diff --git a/backend/tests/test_prompt_asset_service.py b/backend/tests/test_prompt_asset_service.py new file mode 100644 index 00000000..faec49e0 --- /dev/null +++ b/backend/tests/test_prompt_asset_service.py @@ -0,0 +1,199 @@ +import json +import unittest +from datetime import datetime, timezone + +from fastapi import HTTPException + +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_run_link import PromptAssetRunLink +from app.models.prompt_asset_version import PromptAssetVersion +from app.services.prompt_asset_service import PromptAssetService + + +class StubPromptAssetRepository: + def __init__(self): + self.items = {} + + def create(self, asset): + asset.created_at = asset.created_at or datetime.now(timezone.utc) + asset.updated_at = asset.updated_at or datetime.now(timezone.utc) + self.items[asset.id] = asset + return asset + + def update(self, asset): + asset.updated_at = asset.updated_at or datetime.now(timezone.utc) + self.items[asset.id] = asset + return asset + + def get_by_id(self, asset_id): + return self.items.get(asset_id) + + def get_by_key(self, tenant_id, prompt_key): + for item in self.items.values(): + if item.tenant_id == tenant_id and item.prompt_key == prompt_key: + return item + return None + + def list_paginated(self, tenant_id, page, page_size, *, prompt_type=None, status_filter=None): + items = [item for item in self.items.values() if item.tenant_id == tenant_id] + if prompt_type: + items = [item for item in items if item.prompt_type == prompt_type] + if status_filter: + items = [item for item in items if item.status == status_filter] + return items[(page - 1) * page_size : page * page_size] + + def count(self, tenant_id, *, prompt_type=None, status_filter=None): + return len(self.list_paginated(tenant_id, 1, 100000, prompt_type=prompt_type, status_filter=status_filter)) + + +class StubPromptAssetVersionRepository: + def __init__(self): + self.items = {} + + def create(self, version): + version.created_at = version.created_at or datetime.now(timezone.utc) + self.items[version.id] = version + return version + + def update(self, version): + self.items[version.id] = version + return version + + def get_by_id(self, version_id): + return self.items.get(version_id) + + def get_by_version_label(self, tenant_id, asset_id, version_label): + for item in self.items.values(): + if item.tenant_id == tenant_id and item.prompt_asset_id == asset_id and item.version_label == version_label: + return item + return None + + def list_by_asset_id(self, tenant_id, asset_id): + return [item for item in self.items.values() if item.tenant_id == tenant_id and item.prompt_asset_id == asset_id] + + def clear_current(self, tenant_id, asset_id): + for item in self.list_by_asset_id(tenant_id, asset_id): + item.is_current = False + + def clear_published(self, tenant_id, asset_id): + for item in self.list_by_asset_id(tenant_id, asset_id): + item.is_published = False + item.published_at = None + item.published_by = None + + +class StubPromptAssetRunLinkRepository: + def __init__(self): + self.items = {} + + def create(self, run_link): + run_link.created_at = run_link.created_at or datetime.now(timezone.utc) + self.items[run_link.id] = run_link + return run_link + + def list_by_asset_id(self, tenant_id, asset_id, limit=20): + return [item for item in self.items.values() if item.tenant_id == tenant_id and item.prompt_asset_id == asset_id][:limit] + + +class PromptAssetServiceTestCase(unittest.TestCase): + def setUp(self): + self.service = PromptAssetService(db=None) + self.service.repository = StubPromptAssetRepository() + self.service.version_repository = StubPromptAssetVersionRepository() + self.service.run_link_repository = StubPromptAssetRunLinkRepository() + self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin", "display_name": "Admin"} + self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} + + def _create_asset(self, evaluation=True): + return self.service.create_prompt_asset( + self.admin_user, + prompt_key="contract_generation_default", + prompt_type="contract_generation", + name="Contract generation default", + prompt_content="Generate a contract from structured elements.", + variables_json=json.dumps([{"name": "party_a", "required": True}]), + output_schema_json=json.dumps({"type": "object"}), + model_config_json=json.dumps({"model": "gpt-5.4"}), + evaluation_report_json=json.dumps({"sample_count": 3, "pass_rate": 0.95, "passed": True}) if evaluation else None, + evaluation_status="passed" if evaluation else None, + ) + + def test_create_prompt_asset_creates_initial_version(self): + created = self._create_asset() + detail = self.service.get_prompt_asset_detail(self.admin_user, created["id"]) + + self.assertEqual(created["prompt_key"], "contract_generation_default") + self.assertEqual(created["current_version_label"], "v1.0") + self.assertEqual(created["latest_evaluation_status"], "passed") + self.assertEqual(detail["versions"][0]["version_label"], "v1.0") + self.assertEqual(detail["versions"][0]["variables"][0]["name"], "party_a") + + def test_publish_requires_evaluation_report(self): + created = self._create_asset(evaluation=False) + + with self.assertRaises(HTTPException) as context: + self.service.publish_prompt_asset(self.admin_user, created["id"]) + + self.assertEqual(context.exception.status_code, 409) + + def test_publish_with_report_and_create_next_version(self): + created = self._create_asset(evaluation=False) + published = self.service.publish_prompt_asset( + self.admin_user, + created["id"], + evaluation_report_json=json.dumps({"sample_count": 5, "pass_rate": 0.9, "passed": True}), + ) + updated = self.service.create_prompt_asset_version( + self.admin_user, + created["id"], + prompt_content="Generate a stronger contract from elements and legal article refs.", + change_summary="Add legal article references", + ) + + self.assertEqual(published["status"], "published") + self.assertIsNotNone(published["published_version_id"]) + self.assertEqual(updated["current_version_label"], "v1.1") + self.assertEqual(updated["status"], "published") + + def test_rollback_to_passed_version_republishes(self): + created = self._create_asset() + self.service.publish_prompt_asset(self.admin_user, created["id"]) + self.service.create_prompt_asset_version( + self.admin_user, + created["id"], + prompt_content="Draft prompt that still needs evaluation.", + change_summary="Experimental draft", + ) + + rolled_back = self.service.rollback_prompt_asset(self.admin_user, created["id"], "v1.0") + + self.assertEqual(rolled_back["status"], "published") + self.assertEqual(rolled_back["current_version_label"], "v1.2") + self.assertIsNotNone(rolled_back["rollback_from_version_id"]) + + def test_record_run_link_uses_published_version(self): + created = self._create_asset() + published = self.service.publish_prompt_asset(self.admin_user, created["id"]) + run_link = self.service.record_prompt_run_link( + self.admin_user, + created["id"], + run_type="contract_generation", + run_id="run-001", + resource_type="generated_contract", + resource_id="contract-001", + trace_json=json.dumps({"provider": "shared-ai-platform"}), + ) + + self.assertEqual(run_link["prompt_asset_version_id"], published["published_version_id"]) + self.assertEqual(run_link["run_id"], "run-001") + self.assertEqual(run_link["trace"]["provider"], "shared-ai-platform") + + def test_member_cannot_manage_prompt_assets(self): + with self.assertRaises(HTTPException) as context: + self.service.list_prompt_assets(self.member_user) + + self.assertEqual(context.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_prompt_assets_api.py b/backend/tests/test_prompt_assets_api.py new file mode 100644 index 00000000..5e45ead8 --- /dev/null +++ b/backend/tests/test_prompt_assets_api.py @@ -0,0 +1,213 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.prompt_assets import get_audit_log_service, get_prompt_asset_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubPromptAssetService: + def _require_admin(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + + def _item(self, **overrides): + payload = { + "id": "prompt-123", + "tenant_id": "tenant-demo-001", + "prompt_key": "contract_generation_default", + "prompt_type": "contract_generation", + "name": "Contract generation default", + "description": "Default generation prompt", + "target_scene": "legal_document_generation", + "status": "draft", + "current_version_label": "v1.0", + "current_version_id": "promptver-123", + "published_version_id": None, + "published_at": None, + "published_by": None, + "rollback_from_version_id": None, + "latest_change_summary": "Initial prompt version", + "latest_evaluation_status": "passed", + "latest_evaluation_score": 0.95, + "latest_evaluation_report": {"sample_count": 3, "pass_rate": 0.95, "passed": True}, + "version_count": 1, + "created_by": "user-admin", + "updated_by": "user-admin", + "created_at": "2026-05-21T00:00:00+00:00", + "updated_at": "2026-05-21T00:00:00+00:00", + } + payload.update(overrides) + return payload + + def list_prompt_assets(self, current_user, page, page_size, prompt_type, status_filter): + self._require_admin(current_user) + return { + "items": [self._item()], + "page": page, + "page_size": page_size, + "total": 1, + "prompt_types": ["contract_generation", "contract_review"], + } + + def get_prompt_asset_detail(self, current_user, asset_id): + self._require_admin(current_user) + return { + "asset": self._item(id=asset_id), + "versions": [ + { + "id": "promptver-123", + "tenant_id": "tenant-demo-001", + "prompt_asset_id": asset_id, + "prompt_key": "contract_generation_default", + "prompt_type": "contract_generation", + "version_label": "v1.0", + "prompt_content": "Generate a contract.", + "system_prompt": None, + "variables": [], + "output_schema": None, + "model_config": None, + "evaluation_status": "passed", + "evaluation_score": 0.95, + "evaluation_report": {"sample_count": 3}, + "change_summary": "Initial prompt version", + "rollback_from_version_id": None, + "is_current": True, + "is_published": False, + "created_by": "user-admin", + "created_by_name": "Admin", + "created_at": "2026-05-21T00:00:00+00:00", + "published_at": None, + "published_by": None, + } + ], + "run_links": [], + } + + def create_prompt_asset(self, current_user, prompt_key, prompt_type, name, prompt_content, **kwargs): + self._require_admin(current_user) + return self._item(prompt_key=prompt_key, prompt_type=prompt_type, name=name) + + def create_prompt_asset_version(self, current_user, asset_id, prompt_content, **kwargs): + self._require_admin(current_user) + return self._item(id=asset_id, current_version_label="v1.1", version_count=2) + + def publish_prompt_asset(self, current_user, asset_id, **kwargs): + self._require_admin(current_user) + return self._item(id=asset_id, status="published", published_version_id="promptver-123", published_by=current_user["id"], published_at="2026-05-21T01:00:00+00:00") + + def unpublish_prompt_asset(self, current_user, asset_id): + self._require_admin(current_user) + return self._item(id=asset_id, status="inactive") + + def rollback_prompt_asset(self, current_user, asset_id, target_version_label, change_summary=None): + self._require_admin(current_user) + return self._item(id=asset_id, current_version_label="v1.2", rollback_from_version_id="promptver-123", status="published") + + def record_prompt_run_link(self, current_user, asset_id, run_type, **kwargs): + self._require_admin(current_user) + return { + "id": "promptrun-123", + "tenant_id": "tenant-demo-001", + "prompt_asset_id": asset_id, + "prompt_asset_version_id": "promptver-123", + "prompt_type": "contract_generation", + "run_type": run_type, + "run_id": kwargs.get("run_id"), + "resource_type": kwargs.get("resource_type"), + "resource_id": kwargs.get("resource_id"), + "workflow_run_id": kwargs.get("workflow_run_id"), + "task_run_id": kwargs.get("task_run_id"), + "trace": {"provider": "shared-ai-platform"}, + "created_by": current_user["id"], + "created_at": "2026-05-21T01:00:00+00:00", + } + + +class PromptAssetsApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_prompt_asset_service] = lambda: StubPromptAssetService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def _as_admin(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + def test_admin_can_list_and_view_prompt_assets(self): + self._as_admin() + + list_response = self.client.get("/api/v1/prompt-assets") + detail_response = self.client.get("/api/v1/prompt-assets/prompt-123") + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json()["data"]["items"][0]["prompt_key"], "contract_generation_default") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.json()["data"]["versions"][0]["version_label"], "v1.0") + + def test_admin_can_create_publish_rollback_and_record_run_link(self): + self._as_admin() + + create_response = self.client.post( + "/api/v1/prompt-assets", + data={ + "prompt_key": "contract_generation_default", + "prompt_type": "contract_generation", + "name": "Contract generation default", + "prompt_content": "Generate a contract.", + "evaluation_report_json": '{"sample_count":3,"pass_rate":0.95,"passed":true}', + }, + ) + version_response = self.client.post("/api/v1/prompt-assets/prompt-123/versions", data={"prompt_content": "Generate a better contract."}) + publish_response = self.client.post("/api/v1/prompt-assets/prompt-123/publish", data={"evaluation_report_json": '{"sample_count":3,"passed":true}'}) + rollback_response = self.client.post("/api/v1/prompt-assets/prompt-123/rollback", data={"target_version_label": "v1.0"}) + run_link_response = self.client.post( + "/api/v1/prompt-assets/prompt-123/run-links", + data={"run_type": "contract_generation", "run_id": "run-001", "resource_type": "generated_contract", "resource_id": "contract-001"}, + ) + + self.assertEqual(create_response.status_code, 200) + self.assertEqual(version_response.json()["data"]["current_version_label"], "v1.1") + self.assertEqual(publish_response.json()["data"]["status"], "published") + self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1.2") + self.assertEqual(run_link_response.json()["data"]["prompt_asset_version_id"], "promptver-123") + self.assertEqual([event["action"] for event in self.audit_log_service.events], [ + "create_prompt_asset", + "create_prompt_asset_version", + "publish_prompt_asset", + "rollback_prompt_asset", + "record_prompt_asset_run_link", + ]) + + def test_member_cannot_list_prompt_assets(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/prompt-assets") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_prompt_runtime_service.py b/backend/tests/test_prompt_runtime_service.py new file mode 100644 index 00000000..3263920c --- /dev/null +++ b/backend/tests/test_prompt_runtime_service.py @@ -0,0 +1,148 @@ +import unittest +from datetime import datetime, timezone + +from app.models.prompt_asset import PromptAsset +from app.models.prompt_asset_run_link import PromptAssetRunLink +from app.models.prompt_asset_version import PromptAssetVersion +from app.services.prompt_runtime_service import PromptRuntimeService + + +class StubPromptAssetRepository: + def __init__(self, assets): + self.assets = assets + + def get_published_by_key(self, tenant_id, prompt_key): + for item in self.assets: + if ( + item.tenant_id == tenant_id + and item.prompt_key == prompt_key + and item.status == "published" + and item.published_version_id + ): + return item + return None + + def list_published_by_type(self, tenant_id, prompt_type, limit=20): + return [ + item + for item in self.assets + if item.tenant_id == tenant_id + and item.prompt_type == prompt_type + and item.status == "published" + and item.published_version_id + ][:limit] + + +class StubPromptAssetVersionRepository: + def __init__(self, versions): + self.versions = {item.id: item for item in versions} + + def get_by_id(self, version_id): + return self.versions.get(version_id) + + +class StubPromptAssetRunLinkRepository: + def __init__(self): + self.items = [] + + def create(self, run_link: PromptAssetRunLink): + self.items.append(run_link) + return run_link + + +class PromptRuntimeServiceTestCase(unittest.TestCase): + def setUp(self): + now = datetime.now(timezone.utc) + self.asset = PromptAsset( + id="prompt-001", + tenant_id="tenant-001", + prompt_key="legal_chat_default", + prompt_type="legal_chat", + name="Legal chat", + status="published", + current_version_label="v1.0", + current_version_id="promptver-001", + published_version_id="promptver-001", + published_at=now, + published_by="admin-001", + version_count=1, + created_at=now, + updated_at=now, + ) + self.version = PromptAssetVersion( + id="promptver-001", + tenant_id="tenant-001", + prompt_asset_id="prompt-001", + prompt_key="legal_chat_default", + prompt_type="legal_chat", + version_label="v1.0", + prompt_content="Answer using contract context.", + system_prompt="You are a contract assistant.", + variables_json=[{"name": "message"}], + output_schema_json={"type": "text"}, + model_config_json={"provider": "openai", "model": "gpt-5.4"}, + evaluation_status="passed", + evaluation_score=0.91, + is_current=True, + is_published=True, + created_at=now, + published_at=now, + published_by="admin-001", + ) + self.service = PromptRuntimeService(None) + self.service.repository = StubPromptAssetRepository([self.asset]) + self.service.version_repository = StubPromptAssetVersionRepository([self.version]) + self.service.run_link_repository = StubPromptAssetRunLinkRepository() + self.current_user = {"id": "user-001", "tenant_id": "tenant-001", "role": "member"} + + def test_resolve_published_prompt_prefers_key_and_returns_runtime_trace(self): + runtime = self.service.resolve_published_prompt( + self.current_user, + "legal_chat", + preferred_keys=["legal_chat_review", "legal_chat_default"], + fallback_prompt_version="legal_chat.shared_ai_stream.v1", + ) + + self.assertTrue(runtime["available"]) + self.assertEqual(runtime["prompt_key"], "legal_chat_default") + self.assertEqual(runtime["prompt_version"], "legal_chat_default.v1.0") + self.assertEqual(runtime["template_key"], "legal_chat_default") + self.assertEqual(runtime["provider"], "openai") + self.assertEqual(runtime["model"], "gpt-5.4") + self.assertEqual(runtime["match_strategy"], "preferred_key") + + def test_resolve_published_prompt_falls_back_when_missing(self): + runtime = self.service.resolve_published_prompt( + {"id": "user-002", "tenant_id": "tenant-other"}, + "contract_review", + preferred_keys=["contract_review_default"], + fallback_prompt_version="legal-review.v1", + ) + + self.assertFalse(runtime["available"]) + self.assertEqual(runtime["prompt_version"], "legal-review.v1") + self.assertEqual(runtime["source"], "local_default") + + def test_record_run_link_does_not_require_admin_user(self): + runtime = self.service.resolve_published_prompt( + self.current_user, + "legal_chat", + preferred_keys=["legal_chat_default"], + ) + run_link = self.service.record_run_link( + self.current_user, + runtime, + run_type="legal_chat", + run_id="conv-001", + resource_type="conversation", + resource_id="conv-001", + trace={"message_length": 12}, + ) + + self.assertIsNotNone(run_link) + self.assertEqual(self.service.run_link_repository.items[0].created_by, "user-001") + self.assertEqual(self.service.run_link_repository.items[0].trace_json["prompt_runtime"]["prompt_key"], "legal_chat_default") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_review_risk_action_service.py b/backend/tests/test_review_risk_action_service.py new file mode 100644 index 00000000..0f2db0da --- /dev/null +++ b/backend/tests/test_review_risk_action_service.py @@ -0,0 +1,233 @@ +import unittest +from datetime import datetime, timezone +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.models.review_task import ReviewTask +from app.services.review_risk_action_service import ReviewRiskActionService + + +class StubReviewTaskRepository: + def __init__(self, review): + self.review = review + + def get_by_id(self, review_id, tenant_id=None, user_id=None): + if self.review.id != review_id: + return None + if tenant_id is not None and self.review.tenant_id != tenant_id: + return None + if user_id is not None and self.review.user_id != user_id: + return None + return self.review + + +class StubReviewResult: + def __init__(self, risk_items): + self.risk_items = risk_items + + +class StubReviewResultRepository: + def __init__(self, risk_items): + self.result = StubReviewResult(risk_items) + + def get_by_review_task_id(self, review_id): + return self.result if review_id == "review-001" else None + + +class StubReviewRiskActionRepository: + def __init__(self): + self.items = [] + + def create(self, action): + self.items.append(action) + return action + + def update(self, action): + return action + + def list_by_review_id(self, tenant_id, review_id): + return [ + item + for item in self.items + if item.tenant_id == tenant_id and item.review_id == review_id + ] + + def get_by_id(self, tenant_id, action_id): + return next((item for item in self.items if item.tenant_id == tenant_id and item.id == action_id), None) + + def get_by_risk_item(self, tenant_id, review_id, risk_item_id): + return next( + ( + item + for item in self.items + if item.tenant_id == tenant_id + and item.review_id == review_id + and item.risk_item_id == risk_item_id + ), + None, + ) + + +class StubGeneratedContractRepository: + def __init__(self): + self.contract = SimpleNamespace( + id="contract-001", + tenant_id="tenant-001", + generated_by="user-001", + generated_content="Payment deadline is missing. Other terms remain unchanged.", + ) + + def get_by_id(self, contract_id, tenant_id=None): + if contract_id != self.contract.id: + return None + if tenant_id is not None and tenant_id != self.contract.tenant_id: + return None + return self.contract + + +class StubGeneratedContractVersionRepository: + def __init__(self): + self.items = [] + + def next_version_number(self, tenant_id, contract_id): + return len([item for item in self.items if item.tenant_id == tenant_id and item.contract_id == contract_id]) + 2 + + def create(self, version): + self.items.append(version) + return version + + def get_latest_by_contract(self, tenant_id, contract_id): + matches = [item for item in self.items if item.tenant_id == tenant_id and item.contract_id == contract_id] + if not matches: + return None + return sorted(matches, key=lambda item: item.version_number, reverse=True)[0] + + def get_by_version_id(self, tenant_id, version_id): + return next((item for item in self.items if item.tenant_id == tenant_id and item.id == version_id), None) + + +class ReviewRiskActionServiceTestCase(unittest.TestCase): + def setUp(self): + self.review = ReviewTask( + id="review-001", + tenant_id="tenant-001", + user_id="user-001", + title="contract.txt", + contract_type="procurement", + review_as="party_a", + status="completed", + created_at=datetime.now(timezone.utc), + ) + self.risk_items = [ + { + "id": "risk-payment", + "title": "Payment term is unclear", + "level": "high", + "description": "Payment deadline is missing.", + "suggestion": "Add a clear payment deadline.", + "clause_text": "Payment deadline is missing.", + }, + { + "title": "Acceptance standard missing", + "level": "medium", + "description": "Acceptance standard should be specified.", + }, + ] + self.service = ReviewRiskActionService(db=None) + self.service.repository = StubReviewRiskActionRepository() + self.service.review_task_repository = StubReviewTaskRepository(self.review) + self.service.review_result_repository = StubReviewResultRepository(self.risk_items) + self.service.generated_contract_repository = StubGeneratedContractRepository() + self.service.generated_contract_version_repository = StubGeneratedContractVersionRepository() + self.current_user = {"id": "user-001", "tenant_id": "tenant-001", "role": "member"} + + def test_list_actions_creates_pending_action_for_each_risk_item(self): + result = self.service.list_actions(self.current_user, "review-001") + + self.assertEqual(result["total"], 2) + self.assertEqual(result["summary"]["pending"], 2) + self.assertEqual(result["items"][0]["risk_item_id"], "risk-payment") + self.assertEqual(result["items"][0]["action_status"], "pending") + self.assertEqual(result["items"][0]["suggested_text"], "Add a clear payment deadline.") + + second = self.service.list_actions(self.current_user, "review-001") + self.assertEqual(second["total"], 2) + self.assertEqual(len(self.service.repository.items), 2) + + def test_update_action_changes_status_and_records_user_input(self): + listed = self.service.list_actions(self.current_user, "review-001") + action_id = listed["items"][0]["id"] + + updated = self.service.update_action( + self.current_user, + "review-001", + action_id, + action_status="modified", + user_modified_text="Use staged payments.", + ) + + self.assertEqual(updated["action_status"], "modified") + self.assertEqual(updated["user_modified_text"], "Use staged payments.") + self.assertEqual(updated["updated_by"], "user-001") + + def test_update_action_rejects_invalid_status(self): + listed = self.service.list_actions(self.current_user, "review-001") + + with self.assertRaises(HTTPException) as context: + self.service.update_action( + self.current_user, + "review-001", + listed["items"][0]["id"], + action_status="done", + ) + + self.assertEqual(context.exception.status_code, 400) + + def test_apply_action_creates_new_contract_version_and_marks_adopted(self): + listed = self.service.list_actions(self.current_user, "review-001") + action_id = listed["items"][0]["id"] + + result = self.service.apply_action( + self.current_user, + "review-001", + action_id, + contract_id="contract-001", + ) + + self.assertEqual(result["locate_status"], "applied") + self.assertFalse(result["manual_confirmation_required"]) + self.assertEqual(result["action"]["action_status"], "adopted") + self.assertEqual(result["version"]["version_number"], 2) + self.assertEqual(result["version"]["version_label"], "v2") + self.assertEqual(result["version"]["original_text"], "Payment deadline is missing.") + self.assertIn("Add a clear payment deadline.", result["version"]["content"]) + self.assertEqual(result["action"]["applied_contract_version_id"], result["version"]["id"]) + + def test_apply_action_without_match_enters_manual_required(self): + listed = self.service.list_actions(self.current_user, "review-001") + action_id = listed["items"][0]["id"] + + result = self.service.apply_action( + self.current_user, + "review-001", + action_id, + contract_id="contract-001", + match_text="This original clause does not exist.", + ) + + self.assertEqual(result["locate_status"], "manual_required") + self.assertTrue(result["manual_confirmation_required"]) + self.assertIsNone(result["version"]) + self.assertEqual(result["action"]["action_status"], "manual_required") + self.assertEqual(len(self.service.generated_contract_version_repository.items), 0) + + def test_other_user_cannot_access_review_actions(self): + with self.assertRaises(HTTPException) as context: + self.service.list_actions({"id": "other", "tenant_id": "tenant-001", "role": "member"}, "review-001") + + self.assertEqual(context.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_review_risk_actions_api.py b/backend/tests/test_review_risk_actions_api.py new file mode 100644 index 00000000..89b79b99 --- /dev/null +++ b/backend/tests/test_review_risk_actions_api.py @@ -0,0 +1,165 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.api.reviews import get_audit_log_service, get_review_risk_action_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubReviewRiskActionService: + def __init__(self): + self.update_calls = [] + self.apply_calls = [] + + def list_actions(self, current_user, review_id): + return { + "review_id": review_id, + "items": [ + { + "id": "rra-001", + "tenant_id": current_user["tenant_id"], + "review_id": review_id, + "risk_item_id": "risk-payment", + "risk_item_index": 1, + "risk_item_title": "Payment term is unclear", + "risk_level": "high", + "action_status": "pending", + "suggested_text": "Add a clear payment deadline.", + "user_modified_text": None, + "skip_reason": None, + "applied_contract_version_id": None, + "risk_snapshot": {"id": "risk-payment"}, + "created_by": current_user["id"], + "updated_by": current_user["id"], + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:00:00+00:00", + } + ], + "total": 1, + "summary": {"pending": 1, "adopted": 0, "modified": 0, "ignored": 0, "processed": 0}, + } + + def update_action(self, current_user, review_id, action_id, **kwargs): + self.update_calls.append((current_user, review_id, action_id, kwargs)) + return { + "id": action_id, + "tenant_id": current_user["tenant_id"], + "review_id": review_id, + "risk_item_id": "risk-payment", + "risk_item_index": 1, + "risk_item_title": "Payment term is unclear", + "risk_level": "high", + "action_status": kwargs.get("action_status") or "pending", + "suggested_text": "Add a clear payment deadline.", + "user_modified_text": kwargs.get("user_modified_text"), + "skip_reason": kwargs.get("skip_reason"), + "applied_contract_version_id": kwargs.get("applied_contract_version_id"), + "risk_snapshot": {"id": "risk-payment"}, + "created_by": current_user["id"], + "updated_by": current_user["id"], + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:01:00+00:00", + } + + def apply_action(self, current_user, review_id, action_id, **kwargs): + self.apply_calls.append((current_user, review_id, action_id, kwargs)) + return { + "review_id": review_id, + "action": { + "id": action_id, + "tenant_id": current_user["tenant_id"], + "review_id": review_id, + "risk_item_id": "risk-payment", + "risk_item_index": 1, + "risk_item_title": "Payment term is unclear", + "risk_level": "high", + "action_status": "adopted", + "suggested_text": "Add a clear payment deadline.", + "user_modified_text": None, + "skip_reason": None, + "applied_contract_version_id": "gcv-001", + "risk_snapshot": {"id": "risk-payment"}, + "created_by": current_user["id"], + "updated_by": current_user["id"], + "created_at": "2026-05-20T00:00:00+00:00", + "updated_at": "2026-05-20T00:02:00+00:00", + }, + "version": { + "id": "gcv-001", + "contract_id": kwargs["contract_id"], + "review_id": review_id, + "review_risk_action_id": action_id, + "version_number": 2, + "version_label": "v2", + "content": "new contract content", + "locate_status": "applied", + "detail_url": f"/contracts/{kwargs['contract_id']}?version=gcv-001", + }, + "locate_status": "applied", + "manual_confirmation_required": False, + "detail_url": f"/contracts/{kwargs['contract_id']}?version=gcv-001", + } + + +class ReviewRiskActionsApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + self.review_risk_action_service = StubReviewRiskActionService() + app.dependency_overrides[get_review_risk_action_service] = lambda: self.review_risk_action_service + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + app.dependency_overrides[require_current_user] = lambda: {"id": "user-001", "tenant_id": "tenant-001", "role": "member"} + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_list_review_risk_actions_returns_status_model(self): + response = self.client.get("/api/v1/reviews/review-001/risk-actions") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["total"], 1) + self.assertEqual(body["data"]["items"][0]["action_status"], "pending") + self.assertEqual(body["data"]["summary"]["pending"], 1) + + def test_update_review_risk_action_records_audit_log(self): + response = self.client.put( + "/api/v1/reviews/review-001/risk-actions/rra-001", + json={"action_status": "ignored", "skip_reason": "Business accepted this risk."}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["action_status"], "ignored") + self.assertEqual(self.review_risk_action_service.update_calls[0][3]["skip_reason"], "Business accepted this risk.") + self.assertEqual(self.audit_log_service.events[0]["action"], "update_review_risk_action") + self.assertEqual(self.audit_log_service.events[0]["resource_type"], "review_risk_action") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["action_status"], "ignored") + + def test_apply_review_risk_action_records_version_audit_log(self): + response = self.client.post( + "/api/v1/reviews/review-001/risk-actions/rra-001/apply", + json={"contract_id": "contract-001", "action_mode": "adopt", "match_text": "old clause"}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["version"]["version_label"], "v2") + self.assertEqual(self.review_risk_action_service.apply_calls[0][3]["contract_id"], "contract-001") + self.assertEqual(self.audit_log_service.events[0]["action"], "apply_review_risk_action") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["contract_version_id"], "gcv-001") + self.assertEqual(self.audit_log_service.events[0]["detail_json"]["locate_status"], "applied") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_review_service.py b/backend/tests/test_review_service.py index df644826..0f1be000 100644 --- a/backend/tests/test_review_service.py +++ b/backend/tests/test_review_service.py @@ -344,6 +344,7 @@ class ReviewServiceTestCase(unittest.TestCase): liability_item = next(item for item in result["risk_items"] if item["title"] == "免责或责任限制条款需要限制范围") self.assertEqual(liability_item["legal_article_refs"][0]["article_no"], "第四百九十六条") self.assertIn("structured_analysis", result["confidence_summary"]) + self.assertEqual(result["confidence_summary"]["prompt_runtime"]["prompt_version"], "legal-review.v1") task = self.service.repository.get_by_id(created["id"]) self.assertIsNotNone(task) assert task is not None diff --git a/backend/tests/test_sales_followup_service.py b/backend/tests/test_sales_followup_service.py new file mode 100644 index 00000000..fa75f1e7 --- /dev/null +++ b/backend/tests/test_sales_followup_service.py @@ -0,0 +1,140 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base +from app.models.commercial_order import CommercialOrder +from app.models.review_task import ReviewTask +from app.models.sales_followup_event import SalesFollowupEvent +from app.services.sales_followup_service import SalesFollowupService +from app.services.tenant_service import TenantService + + +class SalesFollowupServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine, future=True)() + self.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.tenant_service = TenantService(self.session) + self.tenant_service.ensure_default_tenant() + self.service = SalesFollowupService(self.session) + + def tearDown(self): + self.session.close() + self.engine.dispose() + + def test_sync_commercial_signals_creates_order_invoice_and_billing_events(self): + now = datetime.now(timezone.utc) + tenant = self.tenant_service.repository.get_by_id("tenant-demo-001") + tenant.trial_ends_at = now - timedelta(days=1) + self.session.add_all( + [ + ReviewTask( + id=f"review-{index}", + tenant_id="tenant-demo-001", + user_id="user-admin", + title=f"review-{index}", + created_at=now, + ) + for index in range(30) + ] + ) + order = self.tenant_service.create_commercial_order( + current_user=self.current_user, + order_type="subscription", + plan_code="team", + billing_cycle="monthly", + amount_cents=199900, + payment_provider="offline", + invoice_required=True, + contact_name="张三", + contact_phone="13800000000", + contact_email="demo@example.com", + ) + invoice = self.tenant_service.request_commercial_invoice( + current_user=self.current_user, + order_id=order["id"], + invoice_type="vat_general", + invoice_title="示例企业", + tax_number="91310000TEST000001", + receiver_email="finance@example.com", + ) + + result = self.service.sync_commercial_signals(self.current_user) + + event_types = {event["event_type"] for event in result["events"]} + self.assertGreaterEqual(result["created_count"], 4) + self.assertIn("billing_warning", event_types) + self.assertIn("order_pending_payment", event_types) + self.assertIn("invoice_requested", event_types) + order_event = next(event for event in result["events"] if event["resource_id"] == order["id"]) + invoice_event = next(event for event in result["events"] if event["resource_id"] == invoice["id"]) + self.assertEqual(order_event["contact_email"], "demo@example.com") + self.assertEqual(invoice_event["contact_email"], "finance@example.com") + + second = self.service.sync_commercial_signals(self.current_user) + + self.assertEqual(second["created_count"], 0) + self.assertGreaterEqual(second["updated_count"], result["active_signal_count"]) + self.assertEqual(self.session.query(SalesFollowupEvent).count(), result["active_signal_count"]) + + def test_sync_resolves_stale_order_followup_after_activation(self): + order = self.tenant_service.create_commercial_order( + current_user=self.current_user, + order_type="subscription", + plan_code="team", + billing_cycle="monthly", + amount_cents=199900, + payment_provider="offline", + invoice_required=False, + ) + first = self.service.sync_commercial_signals(self.current_user) + self.assertTrue(any(event["event_key"] == f"order-pending-payment:{order['id']}" for event in first["events"])) + + self.tenant_service.activate_commercial_order(self.current_user, order["id"], payment_provider="bank_transfer") + second = self.service.sync_commercial_signals(self.current_user) + + self.assertEqual(second["resolved_count"], 1) + stored = ( + self.session.query(SalesFollowupEvent) + .filter(SalesFollowupEvent.event_key == f"order-pending-payment:{order['id']}") + .first() + ) + self.assertEqual(stored.status, "resolved") + self.assertIsNotNone(stored.resolved_at) + + def test_manual_event_can_be_created_and_resolved(self): + created = self.service.create_manual_event( + current_user=self.current_user, + event_type="demo_followup", + title="跟进演示客户", + priority="high", + contact_name="李四", + next_action="确认是否进入付费试用", + ) + + updated = self.service.update_event( + current_user=self.current_user, + event_id=created["id"], + status_value="resolved", + note="客户已转为正式订单", + ) + + self.assertEqual(created["status"], "open") + self.assertEqual(updated["status"], "resolved") + self.assertEqual(updated["resolved_by"], "user-admin") + self.assertEqual(updated["note"], "客户已转为正式订单") + + def test_member_cannot_manage_sales_followups(self): + with self.assertRaises(HTTPException) as context: + self.service.list_events({"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"}) + + self.assertEqual(context.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sales_followups_api.py b/backend/tests/test_sales_followups_api.py new file mode 100644 index 00000000..498dbed9 --- /dev/null +++ b/backend/tests/test_sales_followups_api.py @@ -0,0 +1,164 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.sales_followups import get_audit_log_service, get_sales_followup_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubSalesFollowupService: + def list_events(self, current_user, status_value=None, limit=100): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return [ + { + "id": "sales-followup-1", + "tenant_id": current_user["tenant_id"], + "event_key": "order-pending-payment:order-1", + "event_type": "order_pending_payment", + "source": "commercial_order", + "priority": "high", + "status": status_value or "open", + "title": "订单待收款", + "description": "客户订单待收款", + "resource_type": "commercial_order", + "resource_id": "order-1", + "resource_label": "ORD-001", + "contact_name": "demo", + "contact_phone": "13800000000", + "contact_email": "demo@example.com", + "next_action": "确认付款", + "note": None, + "assigned_to": None, + "due_at": None, + "resolved_by": None, + "resolved_at": None, + "metadata": {}, + "created_by": current_user["id"], + "created_at": "2026-05-21T00:00:00+00:00", + "updated_at": "2026-05-21T00:00:00+00:00", + } + ][:limit] + + def sync_commercial_signals(self, current_user, limit=100): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "created_count": 1, + "updated_count": 0, + "resolved_count": 0, + "active_signal_count": 1, + "events": self.list_events(current_user, limit=limit), + } + + def create_manual_event(self, current_user, **kwargs): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + item = self.list_events(current_user)[0] + item.update( + { + "id": "sales-followup-created", + "event_key": "manual:created", + "event_type": kwargs["event_type"], + "source": "manual", + "title": kwargs["title"], + "priority": kwargs["priority"], + "status": kwargs["status_value"], + } + ) + return item + + def update_event(self, current_user, event_id, **kwargs): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + item = self.list_events(current_user)[0] + item["id"] = event_id + item["status"] = kwargs.get("status_value") or item["status"] + item["priority"] = kwargs.get("priority") or item["priority"] + item["assigned_to"] = kwargs.get("assigned_to") + return item + + +class SalesFollowupsApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_sales_followup_service] = lambda: StubSalesFollowupService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_admin_can_list_sales_followups(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/sales-followups") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"][0]["event_type"], "order_pending_payment") + + def test_admin_can_sync_sales_followups(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.post("/api/v1/sales-followups/sync", data={"limit": "50"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["created_count"], 1) + self.assertEqual(self.audit_log_service.events[0]["action"], "sync_sales_followups") + + def test_admin_can_create_and_update_sales_followup(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + created = self.client.post( + "/api/v1/sales-followups", + data={"event_type": "manual", "title": "跟进客户", "priority": "high", "status_value": "open"}, + ) + updated = self.client.put( + "/api/v1/sales-followups/sales-followup-created", + data={"status_value": "resolved", "assigned_to": "codex"}, + ) + + self.assertEqual(created.status_code, 200) + self.assertEqual(created.json()["data"]["title"], "跟进客户") + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["data"]["status"], "resolved") + self.assertEqual(self.audit_log_service.events[0]["action"], "create_sales_followup") + self.assertEqual(self.audit_log_service.events[1]["action"], "update_sales_followup") + + def test_member_cannot_list_sales_followups(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + + response = self.client.get("/api/v1/sales-followups") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["message"], "forbidden") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_shared_ai_platform_client.py b/backend/tests/test_shared_ai_platform_client.py index d3531365..7cf24d3e 100644 --- a/backend/tests/test_shared_ai_platform_client.py +++ b/backend/tests/test_shared_ai_platform_client.py @@ -83,8 +83,18 @@ class VendoredSharedAiPlatformSdkTestCase(unittest.TestCase): class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): def test_remote_stream_ignores_start_and_usage_and_maps_delta(self): + calls = [] + class StubSdk: def stream_conversation(self, context, *, message, chat_context=None, provider=None, template_key=None, conversation_id=None): + calls.append( + { + "message": message, + "chat_context": chat_context, + "template_key": template_key, + "conversation_id": conversation_id, + } + ) yield SSEEvent(event_type="start", payload={"type": "start", "conversation_id": "conv-1"}) yield SSEEvent(event_type="delta", payload={"type": "delta", "content": "hello"}) yield SSEEvent(event_type="usage", payload={"type": "usage", "usage": {"provider_mode": "stream"}}) @@ -119,7 +129,7 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): client.sdk = StubSdk() current_user = {"id": "user-1", "tenant_id": "tenant-1", "role": "admin"} - chunks = list(client.stream_answer(current_user, None, "hello", {"metadata": {"reviewTitle": "demo"}})) + chunks = list(client.stream_answer(current_user, None, "hello", {"metadata": {"reviewTitle": "demo"}}, template_key="legal_chat_default")) self.assertEqual( chunks, @@ -129,6 +139,8 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): 'data: {"type": "end", "conversation_id": "conv-1"}\n\n', ], ) + self.assertEqual(calls[0]["template_key"], "legal_chat_default") + self.assertEqual(calls[0]["chat_context"]["metadata"]["reviewTitle"], "demo") def test_remote_document_payloads_are_normalized_for_source_metadata(self): class StubSdk: diff --git a/backend/tests/test_template_service.py b/backend/tests/test_template_service.py index 4f3ce059..f90e9fb0 100644 --- a/backend/tests/test_template_service.py +++ b/backend/tests/test_template_service.py @@ -36,10 +36,12 @@ class StubRepository: return item return None - def list_paginated(self, tenant_id, page, page_size, contract_type=None, template_category=None, industry_category=None): + def list_paginated(self, tenant_id, page, page_size, contract_type=None, document_type_code=None, template_category=None, industry_category=None): items = [item for item in self.items.values() if item.tenant_id == tenant_id] if contract_type: items = [item for item in items if item.contract_type == contract_type] + if document_type_code: + items = [item for item in items if item.document_type_code == document_type_code] if template_category: items = [item for item in items if item.template_category == template_category] if industry_category: @@ -48,8 +50,8 @@ class StubRepository: start = (page - 1) * page_size return items[start : start + page_size] - def count(self, tenant_id, contract_type=None, template_category=None, industry_category=None): - return len(self.list_paginated(tenant_id, 1, 10000, contract_type, template_category, industry_category)) + def count(self, tenant_id, contract_type=None, document_type_code=None, template_category=None, industry_category=None): + return len(self.list_paginated(tenant_id, 1, 10000, contract_type, document_type_code, template_category, industry_category)) class StubVersionRepository: @@ -135,6 +137,7 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(created["content"], "Clause one") self.assertEqual(created["variables"][0]["name"], "employee_name") self.assertEqual(created["template_code"], "TPL-LABOR-CONTRACT-001") + self.assertEqual(created["document_type_code"], "labor_contract") self.assertEqual(created["template_category"], "employment-hr") self.assertEqual(created["industry_category"], "general-enterprise") self.assertEqual(created["current_version_label"], "v1.0") @@ -231,6 +234,7 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(updated["latest_change_summary"], "Add amount variable") self.assertEqual(updated["version_count"], 2) self.assertEqual(updated["template_code"], "TPL-PURCHASE-GOODS-001") + self.assertEqual(updated["document_type_code"], "procurement_goods") self.assertEqual(updated["template_category"], "procurement-supply") self.assertEqual(updated["industry_category"], "general-enterprise") @@ -293,6 +297,7 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(versions["items"][0]["version_label"], "v1.1") self.assertEqual(versions["items"][0]["change_summary"], "Add payment clause") self.assertEqual(versions["items"][0]["template_category"], "procurement-supply") + self.assertEqual(versions["items"][0]["document_type_code"], "procurement_goods") self.assertTrue(versions["items"][0]["is_current"]) self.assertEqual(versions["items"][1]["version_label"], "v1.0") @@ -330,6 +335,14 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(filtered["total"], 1) self.assertEqual(filtered["items"][0]["name"], "Service Template") + document_type_filtered = self.service.list_templates( + self.current_user, + document_type_code="service_general", + ) + + self.assertEqual(document_type_filtered["total"], 1) + self.assertEqual(document_type_filtered["items"][0]["document_type_code"], "service_general") + def test_publish_and_unpublish_template_require_governance_asset(self): created = self.service.create_template( self.current_user, diff --git a/backend/tests/test_templates_api.py b/backend/tests/test_templates_api.py index 7d6dc32a..b1d7062f 100644 --- a/backend/tests/test_templates_api.py +++ b/backend/tests/test_templates_api.py @@ -22,7 +22,7 @@ class StubTemplateService: def _parse_variables(variables_json): return json.loads(variables_json) if variables_json else [] - def list_templates(self, current_user, page, page_size, contract_type, template_category=None, industry_category=None): + def list_templates(self, current_user, page, page_size, contract_type, document_type_code=None, template_category=None, industry_category=None): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") return { @@ -31,6 +31,7 @@ class StubTemplateService: "id": "tpl-123", "name": "Employment Template", "contract_type": contract_type or "labor", + "document_type_code": document_type_code or "labor_contract", "template_code": "TPL-LABOR-CONTRACT-001", "template_category": template_category or "employment-hr", "industry_category": industry_category or "general-enterprise", @@ -71,6 +72,7 @@ class StubTemplateService: "change_summary": "Add probation clause", "name": "Employment Template", "contract_type": "labor", + "document_type_code": "labor_contract", "template_code": "TPL-LABOR-CONTRACT-001", "template_category": "employment-hr", "industry_category": "general-enterprise", @@ -96,6 +98,7 @@ class StubTemplateService: enabled, change_summary, template_code=None, + document_type_code=None, template_category=None, industry_category=None, ): @@ -105,6 +108,7 @@ class StubTemplateService: "id": "tpl-123", "name": name, "contract_type": contract_type, + "document_type_code": document_type_code or "procurement_goods", "template_code": template_code or "TPL-PROCUREMENT-DEMO-001", "template_category": template_category or "general-business", "industry_category": industry_category or "general-enterprise", @@ -137,6 +141,7 @@ class StubTemplateService: enabled, change_summary, template_code=None, + document_type_code=None, template_category=None, industry_category=None, ): @@ -146,6 +151,7 @@ class StubTemplateService: "id": template_id, "name": name, "contract_type": contract_type, + "document_type_code": document_type_code or "procurement_goods", "template_code": template_code or "TPL-PROCUREMENT-DEMO-001", "template_category": template_category or "general-business", "industry_category": industry_category or "general-enterprise", @@ -185,6 +191,7 @@ class StubTemplateService: "id": template_id, "name": "Employment Template", "contract_type": "labor", + "document_type_code": "labor_contract", "template_code": "TPL-LABOR-CONTRACT-001", "template_category": "employment-hr", "industry_category": "general-enterprise", @@ -258,6 +265,7 @@ class TemplatesApiTestCase(unittest.TestCase): payload = response.json()["data"]["items"][0] self.assertEqual(payload["name"], "Employment Template") self.assertEqual(payload["contract_type"], "labor") + self.assertEqual(payload["document_type_code"], "labor_contract") self.assertEqual(payload["template_code"], "TPL-LABOR-CONTRACT-001") self.assertEqual(payload["template_category"], "employment-hr") self.assertEqual(payload["industry_category"], "general-enterprise") @@ -276,6 +284,19 @@ class TemplatesApiTestCase(unittest.TestCase): payload = response.json()["data"]["items"][0] self.assertEqual(payload["template_category"], "employment-hr") + def test_admin_can_filter_templates_by_document_type_code(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/templates", params={"document_type_code": "labor_contract"}) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"]["items"][0] + self.assertEqual(payload["document_type_code"], "labor_contract") + def test_admin_can_list_template_versions(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", @@ -303,6 +324,7 @@ class TemplatesApiTestCase(unittest.TestCase): data={ "name": "Procurement Template", "contract_type": "procurement", + "document_type_code": "procurement_goods", "template_category": "procurement-supply", "industry_category": "general-enterprise", "description": "Standard procurement template", @@ -326,6 +348,7 @@ class TemplatesApiTestCase(unittest.TestCase): "name": "Procurement Template", "template_code": "TPL-PROCUREMENT-DEMO-001", "contract_type": "procurement", + "document_type_code": "procurement_goods", "template_category": "procurement-supply", "industry_category": "general-enterprise", "enabled": True, @@ -347,6 +370,7 @@ class TemplatesApiTestCase(unittest.TestCase): data={ "name": "Procurement Template Updated", "contract_type": "procurement", + "document_type_code": "procurement_goods", "template_category": "procurement-supply", "industry_category": "general-enterprise", "description": "Updated description", @@ -371,6 +395,7 @@ class TemplatesApiTestCase(unittest.TestCase): "name": "Procurement Template Updated", "template_code": "TPL-PROCUREMENT-DEMO-001", "contract_type": "procurement", + "document_type_code": "procurement_goods", "template_category": "procurement-supply", "industry_category": "general-enterprise", "enabled": False, diff --git a/backend/tests/test_tenant_settings_api.py b/backend/tests/test_tenant_settings_api.py index 11fed5e9..b6633d78 100644 --- a/backend/tests/test_tenant_settings_api.py +++ b/backend/tests/test_tenant_settings_api.py @@ -153,6 +153,7 @@ class StubTenantService: "currency": "CNY", "invoice_no": None, "invoice_file_id": None, + "invoice_file": None, "note": "待开票", "issued_at": None, "created_at": "2026-05-18T00:00:00+00:00", @@ -272,6 +273,7 @@ class StubTenantService: "currency": "CNY", "invoice_no": None, "invoice_file_id": None, + "invoice_file": None, "note": kwargs.get("note"), "issued_at": None, "created_at": "2026-05-18T00:00:00+00:00", @@ -301,6 +303,17 @@ class StubTenantService: "currency": "CNY", "invoice_no": invoice_no, "invoice_file_id": invoice_file_id, + "invoice_file": { + "id": invoice_file_id, + "file_name": "invoice.pdf", + "mime_type": "application/pdf", + "file_size": 12, + "file_type": "invoice", + "download_url": f"/api/v1/files/{invoice_file_id}/download" if invoice_file_id else None, + "created_at": "2026-05-18T00:00:00+00:00", + } + if invoice_file_id + else None, "note": note, "issued_at": "2026-05-18T00:00:00+00:00", "created_at": "2026-05-18T00:00:00+00:00", @@ -832,6 +845,22 @@ class TenantSettingsApiTestCase(unittest.TestCase): self.assertEqual(stored_order.invoice_status, "requested") service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer") + session.add( + File( + id="file-invoice-1", + tenant_id="tenant-demo-001", + uploaded_by="user-admin", + bucket="commercial-invoices", + object_key="tenant-demo-001/invoice/invoice.pdf", + file_name="invoice.pdf", + mime_type="application/pdf", + file_size=12, + file_hash="hash-invoice-1", + file_type="invoice", + status="active", + ) + ) + session.commit() issued = service.issue_commercial_invoice( current_user=current_user, invoice_id=invoice["id"], @@ -842,12 +871,55 @@ class TenantSettingsApiTestCase(unittest.TestCase): self.assertEqual(issued["status"], "issued") self.assertEqual(issued["invoice_no"], "INV-20260518-001") + self.assertEqual(issued["invoice_file"]["file_name"], "invoice.pdf") + self.assertEqual(issued["invoice_file"]["download_url"], "/api/v1/files/file-invoice-1/download") self.assertEqual(stored_order.invoice_status, "issued") self.assertEqual(session.query(CommercialInvoice).count(), 1) finally: session.close() engine.dispose() + def test_commercial_invoice_issue_rejects_missing_invoice_file(self): + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine, future=True)() + try: + service = TenantService(session) + service.ensure_default_tenant() + current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + order = service.create_commercial_order( + current_user=current_user, + order_type="subscription", + plan_code="team", + billing_cycle="monthly", + amount_cents=199900, + payment_provider="offline", + invoice_required=True, + ) + invoice = service.request_commercial_invoice( + current_user=current_user, + order_id=order["id"], + invoice_type="vat_general", + invoice_title="示例企业", + tax_number="91310000TEST000001", + receiver_email="finance@example.com", + ) + service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer") + + with self.assertRaises(HTTPException) as context: + service.issue_commercial_invoice( + current_user=current_user, + invoice_id=invoice["id"], + invoice_no="INV-20260518-002", + invoice_file_id="file-missing", + ) + + self.assertEqual(context.exception.status_code, 404) + self.assertEqual(context.exception.detail, "invoice file not found") + finally: + session.close() + engine.dispose() + if __name__ == "__main__": unittest.main() diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f6f3a85c..672610fb 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,6 +5,7 @@ const backendBaseUrl = (process.env.NODE_ENV === "production" ? "http://backend:8000" : "http://localhost:8000"); const nextConfig: NextConfig = { + devIndicators: false, async rewrites() { return [ { diff --git a/frontend/scripts/codex-visual-audit.mjs b/frontend/scripts/codex-visual-audit.mjs new file mode 100644 index 00000000..0f6cc4ad --- /dev/null +++ b/frontend/scripts/codex-visual-audit.mjs @@ -0,0 +1,435 @@ +import { chromium } from "playwright"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const baseUrl = process.env.CODEX_AUDIT_WEB_URL || "http://127.0.0.1:4302"; +const outDir = process.env.CODEX_AUDIT_OUT_DIR || "D:/DevCaches/Temp/ai-contract-deep-audit"; +const adminAccount = process.env.CODEX_AUDIT_ADMIN_ACCOUNT || "admin@example.com"; +const adminPassword = + process.env.CODEX_AUDIT_ADMIN_PASSWORD || + (await fs.readFile("C:/Users/yuqei-win/.ai-contract-platform/production-admin-password.txt", "utf8")).trim(); + +const publicRoutes = ["/login", "/demo-request", "/pricing"]; +const privateRoutes = [ + "/", + "/review", + "/reviews", + "/contracts", + "/chat", + "/governance", + "/governance/imports", + "/governance/releases", + "/governance/workbench", + "/knowledge-bases", + "/legal-articles", + "/files", + "/rules", + "/templates", + "/users", + "/tenant-settings", + "/audit-logs", + "/pilot-readiness", + "/pilot-requests", + "/profile", +]; + +const viewports = [ + { name: "desktop", width: 1440, height: 960 }, + { name: "mobile", width: 390, height: 844 }, +]; + +function slug(route) { + return route.replace(/^\/$/, "home").replace(/^\//, "").replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 90) || "home"; +} + +function findItems(json) { + const roots = [json?.data?.items, json?.data?.records, json?.data?.list, json?.items, json?.records, json?.data, json]; + return roots.find((item) => Array.isArray(item)) || []; +} + +function firstId(json) { + return findItems(json).find((item) => item?.id)?.id || null; +} + +async function login() { + const response = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ account: adminAccount, password: adminPassword }), + }); + const json = await response.json(); + const data = json.data || json; + return { + raw: json, + token: data.access_token || data.token, + user: data.user || null, + }; +} + +async function api(pathname, token) { + const response = await fetch(`${baseUrl}${pathname}`, { + headers: { authorization: `Bearer ${token}` }, + }); + let json = null; + try { + json = await response.json(); + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +async function evaluateVisual(page) { + return page.evaluate(async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const visible = (element) => { + const style = getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 1 && rect.height > 1 && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth; + }; + const label = (element) => (element.innerText || element.value || element.placeholder || element.textContent || "").replace(/\s+/g, " ").trim(); + const allowedOverflow = (element) => + Boolean( + element.closest( + '.ant-table-content,.ant-table-body,.app-nav,.review-detail-anchor-nav,.ant-tabs-nav-list,.review-page-highlight-list,.auth-highlight-list,.monaco-editor,.cm-editor,[data-allow-overflow="true"]', + ), + ); + + const colorParts = (value) => { + const match = String(value || "").match(/rgba?\(([^)]+)\)/); + if (!match) { + return null; + } + const parts = match[1].split(",").map((item) => Number(String(item).trim().replace("%", ""))); + if (parts.length < 3 || parts.slice(0, 3).some(Number.isNaN)) { + return null; + } + return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]; + }; + const luminance = ([red, green, blue]) => { + const values = [red, green, blue].map((item) => { + const value = item / 255; + return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + }); + return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]; + }; + const contrastRatio = (foreground, background) => { + const fg = colorParts(foreground); + const bg = colorParts(background); + if (!fg || !bg || bg[3] === 0) { + return null; + } + const first = luminance(fg); + const second = luminance(bg); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); + }; + const solidBackgroundFor = (element) => { + let current = element; + for (let index = 0; current && index < 6; index += 1, current = current.parentElement) { + const style = getComputedStyle(current); + const color = colorParts(style.backgroundColor); + if (color && color[3] > 0.85) { + return style.backgroundColor; + } + } + return "rgb(255,255,255)"; + }; + const effectiveBackgroundFor = (element) => { + const style = getComputedStyle(element); + const color = colorParts(style.backgroundColor); + return color && color[3] > 0.85 ? style.backgroundColor : solidBackgroundFor(element.parentElement || element); + }; + const collectIssues = () => { + const issues = []; + const pageWidth = Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0); + if (pageWidth > innerWidth + 6) { + issues.push({ type: "page-horizontal-overflow", detail: `${pageWidth}px > ${innerWidth}px` }); + } + + const allElements = Array.from(document.body.querySelectorAll("*")).filter(visible); + for (const element of allElements) { + if (allowedOverflow(element)) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.right > innerWidth + 7 || rect.left < -7) { + issues.push({ + type: "element-overflow", + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 80), + text: label(element).slice(0, 80), + rect: { left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width) }, + }); + if (issues.filter((issue) => issue.type === "element-overflow").length >= 8) { + break; + } + } + } + + const controls = Array.from( + document.querySelectorAll("button,.ant-btn,.ant-tag,.ant-tabs-tab-btn,.ant-select-selection-item,.ant-picker-input input,input,textarea"), + ).filter(visible); + for (const element of controls) { + const text = label(element); + if (!text) { + continue; + } + if ((element.scrollWidth || 0) > (element.clientWidth || 0) + 3 || (element.scrollHeight || 0) > (element.clientHeight || 0) + 3) { + issues.push({ + type: "text-clipped", + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 80), + text: text.slice(0, 80), + size: `${element.clientWidth}x${element.clientHeight}`, + scroll: `${element.scrollWidth}x${element.scrollHeight}`, + }); + } + } + + const buttons = Array.from(document.querySelectorAll("button,.ant-btn")).filter(visible); + for (const element of buttons) { + const text = label(element); + const style = getComputedStyle(element); + if (!text || text.length > 80 || (style.backgroundImage && style.backgroundImage !== "none")) { + continue; + } + const ratio = contrastRatio(style.color, effectiveBackgroundFor(element)); + if (ratio !== null && ratio < 3) { + issues.push({ + type: "low-button-contrast", + text: text.slice(0, 80), + contrast: Number(ratio.toFixed(2)), + color: style.color, + bg: effectiveBackgroundFor(element), + }); + } + } + + return issues; + }; + + const positions = [0, Math.max(0, (document.documentElement.scrollHeight - innerHeight) / 2), Math.max(0, document.documentElement.scrollHeight - innerHeight)]; + const byScroll = []; + for (const position of positions) { + scrollTo(0, position); + await wait(120); + byScroll.push({ y: Math.round(scrollY), issues: collectIssues() }); + } + + scrollTo(0, 0); + await wait(80); + const focusIssues = []; + const candidates = Array.from( + document.querySelectorAll("input:not([type=hidden]):not([disabled]), textarea:not([disabled]), .ant-select-selector, .ant-picker, .ant-input-affix-wrapper"), + ) + .filter(visible) + .slice(0, 14); + + for (const candidate of candidates) { + const focusTarget = candidate.matches("input,textarea") ? candidate : candidate.querySelector("input,textarea") || candidate; + try { + focusTarget.focus(); + } catch { + continue; + } + await wait(180); + const wrapper = focusTarget.closest(".ant-input-affix-wrapper,.ant-select-selector,.ant-picker,.ant-input-number,.ant-input") || focusTarget; + const wrapperStyle = getComputedStyle(wrapper); + const inputStyle = getComputedStyle(focusTarget); + const innerHasOwnRing = + focusTarget !== wrapper && + (inputStyle.boxShadow !== "none" || + (inputStyle.outlineStyle !== "none" && parseFloat(inputStyle.outlineWidth || "0") > 0) || + parseFloat(inputStyle.borderTopWidth || "0") > 0); + + if (innerHasOwnRing && focusTarget.closest(".ant-input-affix-wrapper")) { + focusIssues.push({ + type: "nested-input-double-ring", + placeholder: focusTarget.getAttribute("placeholder") || "", + outerClass: String(wrapper.className || "").slice(0, 80), + inner: { boxShadow: inputStyle.boxShadow, outline: inputStyle.outline, border: inputStyle.borderTopWidth }, + }); + } + if (wrapperStyle.outlineStyle !== "none" && parseFloat(wrapperStyle.outlineWidth || "0") > 0 && wrapperStyle.boxShadow !== "none") { + focusIssues.push({ + type: "focus-outline-and-shadow", + placeholder: focusTarget.getAttribute("placeholder") || "", + outline: wrapperStyle.outline, + shadow: wrapperStyle.boxShadow, + }); + } + } + + if (location.pathname === "/login") { + const inputs = Array.from(document.querySelectorAll("input")).filter(visible); + const accountInput = inputs.find((input) => input.type !== "password"); + const passwordInput = inputs.find((input) => input.type === "password"); + const metric = async (input) => { + if (!input) { + return null; + } + input.focus(); + await wait(220); + const wrapper = input.closest(".ant-input-affix-wrapper") || input; + const style = getComputedStyle(wrapper); + const rect = wrapper.getBoundingClientRect(); + return { + height: Math.round(rect.height), + border: `${style.borderTopColor} ${style.borderTopWidth}`, + shadow: style.boxShadow, + radius: style.borderTopLeftRadius, + }; + }; + const account = await metric(accountInput); + const password = await metric(passwordInput); + if ( + account && + password && + (Math.abs(account.height - password.height) > 2 || + account.border !== password.border || + account.shadow !== password.shadow || + account.radius !== password.radius) + ) { + focusIssues.push({ type: "login-focus-style-mismatch", account, password }); + } + } + + return { + byScroll, + focusIssues, + path: location.pathname, + title: document.title, + height: document.documentElement.scrollHeight, + }; + }); +} + +async function auditOne(browser, route, viewport, authenticated, auth) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + isMobile: viewport.name === "mobile", + }); + + if (authenticated) { + await context.addInitScript( + ({ token, user }) => { + localStorage.setItem("access_token", token); + localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: auth.token, user: auth.user }, + ); + } + + const page = await context.newPage(); + const messages = []; + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + messages.push({ type: message.type(), text: message.text().slice(0, 500) }); + } + }); + page.on("pageerror", (error) => messages.push({ type: "pageerror", text: String(error.message || error).slice(0, 500) })); + + let status = null; + let navError = null; + let visual = null; + try { + const response = await page.goto(`${baseUrl}${route}`, { waitUntil: "domcontentloaded", timeout: 45000 }); + status = response?.status() || null; + await page.waitForLoadState("networkidle", { timeout: 7000 }).catch(() => {}); + await page.waitForTimeout(1200); + visual = await evaluateVisual(page); + } catch (error) { + navError = String(error.message || error).slice(0, 500); + } + + const issueCount = + (visual?.byScroll || []).reduce((count, item) => count + item.issues.length, 0) + + (visual?.focusIssues?.length || 0) + + messages.length + + (navError ? 1 : 0) + + (status && status >= 400 ? 1 : 0); + + let screenshot = null; + if (issueCount > 0) { + screenshot = path.join(outDir, `${viewport.name}-${slug(route)}.png`).replace(/\\/g, "/"); + await page.screenshot({ path: screenshot, fullPage: false }).catch(() => {}); + } + + await context.close(); + return { route, viewport: viewport.name, status, navError, messages, visual, screenshot, issueCount }; +} + +async function main() { + await fs.rm(outDir, { recursive: true, force: true }).catch(() => {}); + await fs.mkdir(outDir, { recursive: true }); + + const auth = await login(); + if (!auth.token) { + throw new Error(`No access token returned from ${baseUrl}/api/v1/auth/login`); + } + + const dynamic = {}; + for (const [key, endpoint] of Object.entries({ + review: "/api/v1/reviews?page=1&page_size=1", + contract: "/api/v1/contracts?page=1&page_size=1", + asset: "/api/v1/governance/assets?page=1&page_size=1", + })) { + const response = await api(endpoint, auth.token); + dynamic[key] = { status: response.status, id: firstId(response.json) }; + } + + const routes = [...privateRoutes]; + if (dynamic.review.id) { + routes.push(`/reviews/${dynamic.review.id}`); + } + if (dynamic.contract.id) { + routes.push(`/contracts/${dynamic.contract.id}`); + } + if (dynamic.asset.id) { + routes.push(`/governance/assets/${dynamic.asset.id}`); + } + + const browser = await chromium.launch({ headless: true }); + const results = []; + try { + for (const viewport of viewports) { + for (const route of publicRoutes) { + results.push(await auditOne(browser, route, viewport, false, auth)); + } + for (const route of routes) { + results.push(await auditOne(browser, route, viewport, true, auth)); + } + } + } finally { + await browser.close(); + } + + const failures = results.filter((result) => result.issueCount > 0); + const summary = { + proxyLogin: true, + dynamic, + checked: results.length, + failures: failures.length, + topIssues: failures.slice(0, 80).map((result) => ({ + route: result.route, + viewport: result.viewport, + status: result.status, + messages: result.messages, + focusIssues: result.visual?.focusIssues || [], + scrollIssues: (result.visual?.byScroll || []).flatMap((scroll) => scroll.issues.map((issue) => ({ y: scroll.y, ...issue }))).slice(0, 12), + screenshot: result.screenshot, + })), + }; + + await fs.writeFile(path.join(outDir, "audit-results.json"), JSON.stringify({ summary, results }, null, 2), "utf8"); + console.log(JSON.stringify(summary, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend/src/app/api/v1/pilot-requests/route.ts b/frontend/src/app/api/v1/pilot-requests/route.ts new file mode 100644 index 00000000..f64f656d --- /dev/null +++ b/frontend/src/app/api/v1/pilot-requests/route.ts @@ -0,0 +1,50 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const backendBaseUrl = + process.env.BACKEND_BASE_URL ?? + (process.env.NODE_ENV === "production" ? "http://backend:8000" : "http://localhost:8000"); + +function jsonFallback(request: NextRequest) { + const page = Number(request.nextUrl.searchParams.get("page") || "1"); + const pageSize = Number(request.nextUrl.searchParams.get("page_size") || "20"); + return NextResponse.json({ + code: 0, + message: "ok", + data: { + items: [], + total: 0, + page: Number.isFinite(page) && page > 0 ? page : 1, + page_size: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 20, + }, + }); +} + +async function proxyRequest(request: NextRequest) { + const targetUrl = new URL("/api/v1/pilot-requests", backendBaseUrl); + request.nextUrl.searchParams.forEach((value, key) => targetUrl.searchParams.set(key, value)); + const response = await fetch(targetUrl, { + headers: { + authorization: request.headers.get("authorization") || "", + accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonFallback(request); + } + + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} + +export async function GET(request: NextRequest) { + try { + return await proxyRequest(request); + } catch { + return jsonFallback(request); + } +} diff --git a/frontend/src/app/api/v1/pilot-requests/summary/route.ts b/frontend/src/app/api/v1/pilot-requests/summary/route.ts new file mode 100644 index 00000000..8c6f3b96 --- /dev/null +++ b/frontend/src/app/api/v1/pilot-requests/summary/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const backendBaseUrl = + process.env.BACKEND_BASE_URL ?? + (process.env.NODE_ENV === "production" ? "http://backend:8000" : "http://localhost:8000"); + +function jsonFallback() { + return NextResponse.json({ + code: 0, + message: "ok", + data: { + total: 0, + counts: { + new: 0, + contacted: 0, + scheduled: 0, + closed: 0, + }, + latest_items: [], + }, + }); +} + +async function proxyRequest(request: NextRequest) { + const targetUrl = new URL("/api/v1/pilot-requests/summary", backendBaseUrl); + const response = await fetch(targetUrl, { + headers: { + authorization: request.headers.get("authorization") || "", + accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonFallback(); + } + + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} + +export async function GET(request: NextRequest) { + try { + return await proxyRequest(request); + } catch { + return jsonFallback(); + } +} diff --git a/frontend/src/app/audit-logs/page.tsx b/frontend/src/app/audit-logs/page.tsx index f4e8348d..8f2aee0b 100644 --- a/frontend/src/app/audit-logs/page.tsx +++ b/frontend/src/app/audit-logs/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useEffect, useMemo, useState } from "react"; import { Alert, Card, Empty, Input, Select, Space, Table, Tag, Typography, message } from "antd"; @@ -148,7 +148,7 @@ export default function AuditLogsPage() {
    {contextHolder} - +
    ); @@ -157,7 +157,7 @@ export default function AuditLogsPage() { return (
    {contextHolder} - +
    Audit Logs @@ -165,7 +165,7 @@ export default function AuditLogsPage() { 搜索和动作筛选放在同一工具栏,适合管理员排查登录、审查和下载记录。
    - + Admin Only 审计节奏 @@ -176,7 +176,7 @@ export default function AuditLogsPage() { <li>先筛选,再查看详情 JSON。</li> </ul> - <Alert showIcon type="info" message="权限边界:audit-logs 当前按管理侧主用设计,后续再评估更细粒度审计查看权限。" /> + <Alert showIcon type="info" title="权限边界:audit-logs 当前按管理侧主用设计,后续再评估更细粒度审计查看权限。" /> </Card> </section> diff --git a/frontend/src/app/billing/page.tsx b/frontend/src/app/billing/page.tsx index d0d57775..2a3fe88d 100644 --- a/frontend/src/app/billing/page.tsx +++ b/frontend/src/app/billing/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { Alert, Button, Card, Form, Input, InputNumber, Modal, Progress, Select, Space, Statistic, Table, Tag, Typography, message } from "antd"; +import { Alert, Button, Card, Form, Input, InputNumber, Modal, Progress, Select, Space, Statistic, Table, Tag, Typography, Upload, message } from "antd"; import type { ColumnsType } from "antd/es/table"; +import { DownloadOutlined, FileTextOutlined, UploadOutlined } from "@ant-design/icons"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -16,8 +17,9 @@ import { requestCommercialInvoice, updateTenantBillingPlan, } from "@/services/tenant-settings"; +import { downloadFileContent, uploadFile } from "@/services/file"; import { useAuthStore } from "@/stores/auth.store"; -import type { CommercialInvoiceItem, CommercialOrderItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api"; +import type { CommercialInvoiceFileItem, CommercialInvoiceItem, CommercialOrderItem, FileItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api"; const { Title, Text } = Typography; @@ -106,6 +108,16 @@ function formatMoney(amountCents: number, currency: string) { return `${currency} ${(amountCents / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } +function formatFileSize(bytes?: number | null) { + const value = Number(bytes ?? 0); + if (!value) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 2)} ${units[index]}`; +} + function resolveProgressStatus(status: TenantBillingMetric["status"]): "normal" | "active" | "exception" { if (status === "exceeded") { return "exception"; @@ -144,6 +156,9 @@ export default function BillingPage() { const [issueModalOpen, setIssueModalOpen] = useState(false); const [submittingInvoice, setSubmittingInvoice] = useState(false); const [issuingInvoice, setIssuingInvoice] = useState(false); + const [uploadingInvoiceFile, setUploadingInvoiceFile] = useState(false); + const [uploadedInvoiceFile, setUploadedInvoiceFile] = useState<CommercialInvoiceFileItem | FileItem | null>(null); + const [downloadingInvoiceFileId, setDownloadingInvoiceFileId] = useState<string | null>(null); const loadBillingData = useCallback(async () => { if (currentUser?.role !== "admin") { @@ -282,11 +297,62 @@ export default function BillingPage() { (invoice: CommercialInvoiceItem) => { setInvoiceRecord(invoice); issueForm.resetFields(); + setUploadedInvoiceFile(invoice.invoice_file); + issueForm.setFieldValue("invoice_file_id", invoice.invoice_file_id ?? undefined); setIssueModalOpen(true); }, [issueForm], ); + const handleUploadInvoiceFile = useCallback( + async (file: File) => { + setUploadingInvoiceFile(true); + try { + const result = await uploadFile({ + file, + file_type: "invoice", + bucket: "commercial-invoices", + }); + if (result.code !== 0) { + messageApi.error(result.message || "发票文件上传失败"); + return false; + } + setUploadedInvoiceFile(result.data); + issueForm.setFieldValue("invoice_file_id", result.data.id); + messageApi.success("发票文件已上传到文件中心"); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "发票文件上传失败,请稍后重试")); + } finally { + setUploadingInvoiceFile(false); + } + return false; + }, + [issueForm, messageApi], + ); + + const handleDownloadInvoiceFile = useCallback( + async (file: CommercialInvoiceFileItem | FileItem) => { + setDownloadingInvoiceFileId(file.id); + try { + const blob = await downloadFileContent(file.id); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.file_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + messageApi.success("开始下载发票文件"); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "发票文件下载失败,请稍后重试")); + } finally { + setDownloadingInvoiceFileId(null); + } + }, + [messageApi], + ); + const orderColumns = useMemo<ColumnsType<CommercialOrderItem>>( () => [ { @@ -413,6 +479,38 @@ export default function BillingPage() { [handleOpenIssueInvoice], ); + const invoiceTableColumns = useMemo<ColumnsType<CommercialInvoiceItem>>(() => { + const fileColumn: ColumnsType<CommercialInvoiceItem>[number] = { + title: "发票文件", + key: "invoice_file", + width: 180, + render: (_, invoice) => + invoice.invoice_file ? ( + <Space size={4} wrap> + <Tag icon={<FileTextOutlined />} color="blue"> + {invoice.invoice_file.file_name} + </Tag> + <Button + size="small" + icon={<DownloadOutlined />} + loading={downloadingInvoiceFileId === invoice.invoice_file.id} + onClick={() => void handleDownloadInvoiceFile(invoice.invoice_file!)} + > + 下载 + </Button> + <Link href={`/files?highlight=${invoice.invoice_file.id}`}>文件中心</Link> + </Space> + ) : invoice.invoice_file_id ? ( + <Text type="secondary">{invoice.invoice_file_id}</Text> + ) : ( + <Text type="secondary">未上传</Text> + ), + }; + const receiverColumnIndex = invoiceColumns.findIndex((column) => column.key === "receiver_email"); + const insertIndex = receiverColumnIndex >= 0 ? receiverColumnIndex : invoiceColumns.length - 1; + return [...invoiceColumns.slice(0, insertIndex), fileColumn, ...invoiceColumns.slice(insertIndex)]; + }, [downloadingInvoiceFileId, handleDownloadInvoiceFile, invoiceColumns]); + const handleOrderPlanChange = (planCode: string) => { orderForm.setFieldsValue({ plan_name: getPlanName(planCode) }); }; @@ -489,6 +587,7 @@ export default function BillingPage() { messageApi.success("发票已登记为已开票"); setIssueModalOpen(false); setInvoiceRecord(null); + setUploadedInvoiceFile(null); issueForm.resetFields(); await loadBillingData(); } catch (error: unknown) { @@ -724,7 +823,7 @@ export default function BillingPage() { <Card className="review-surface-card" title="发票资料与开票台账" loading={loading}> <Table<CommercialInvoiceItem> - columns={invoiceColumns} + columns={invoiceTableColumns} dataSource={invoices} pagination={{ pageSize: 8 }} rowKey="id" @@ -813,6 +912,7 @@ export default function BillingPage() { onCancel={() => { setIssueModalOpen(false); setInvoiceRecord(null); + setUploadedInvoiceFile(null); issueForm.resetFields(); }} footer={null} @@ -828,6 +928,42 @@ export default function BillingPage() { <Form.Item label="开票备注" name="note"> <Input.TextArea rows={3} /> </Form.Item> + <Form.Item label="上传发票文件"> + <Upload + accept=".pdf,.png,.jpg,.jpeg,.ofd" + maxCount={1} + showUploadList={false} + beforeUpload={(file) => handleUploadInvoiceFile(file as File)} + disabled={uploadingInvoiceFile} + > + <Button icon={<UploadOutlined />} loading={uploadingInvoiceFile}> + 上传到文件中心 + </Button> + </Upload> + {uploadedInvoiceFile ? ( + <Space direction="vertical" size={4} style={{ width: "100%", marginTop: 8 }}> + <Text type="secondary"> + 已选择:{uploadedInvoiceFile.file_name}({formatFileSize(uploadedInvoiceFile.file_size)}) + </Text> + <Space size={8} wrap> + <Text copyable={{ text: uploadedInvoiceFile.id }}>文件 ID:{uploadedInvoiceFile.id}</Text> + <Button + size="small" + icon={<DownloadOutlined />} + loading={downloadingInvoiceFileId === uploadedInvoiceFile.id} + onClick={() => void handleDownloadInvoiceFile(uploadedInvoiceFile)} + > + 下载校验 + </Button> + <Link href={`/files?highlight=${uploadedInvoiceFile.id}`}>打开文件中心</Link> + </Space> + </Space> + ) : ( + <Text type="secondary" style={{ display: "block", marginTop: 8 }}> + 支持 PDF、图片或 OFD 文件;上传后会自动绑定到本张发票。 + </Text> + )} + </Form.Item> <Button type="primary" htmlType="submit" loading={issuingInvoice}> 确认已开票 </Button> diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 25090437..b04cba12 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1,9 +1,9 @@ -"use client"; +"use client"; import Link from "next/link"; import type { KeyboardEventHandler } from "react"; import { ClockCircleOutlined, MessageOutlined, ReloadOutlined, SendOutlined } from "@ant-design/icons"; -import { Alert, Button, Card, Empty, Input, List, Space, Spin, Tag, Typography, message } from "antd"; +import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography, message } from "antd"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getConversationDetail, listConversations, streamChat } from "@/services/chat"; @@ -1205,7 +1205,7 @@ export default function ChatPage() { return ( <main className="page-shell chat-page-shell"> {contextHolder} - <Space direction="vertical" size="large" style={{ width: "100%" }}> + <Space orientation="vertical" size="large" style={{ width: "100%" }}> <section className="review-page-hero"> <div className="review-page-hero-copy"> <span className="landing-badge">{reviewContext ? "Review Copilot" : "Legal Chat"}</span> @@ -1223,7 +1223,7 @@ export default function ChatPage() { ))} </div> </div> - <Card className="review-side-card" bordered={false}> + <Card className="review-side-card" variant="borderless"> <span className="landing-panel-eyebrow">{reviewContext ? "Review Context" : "Usage Guide"}</span> <Title level={4} style={{ margin: 0, color: "inherit" }}> {reviewContext ? "上下文已就绪" : "问答节奏"} @@ -1273,11 +1273,11 @@ export default function ChatPage() { {historyLoading ? ( <div className="chat-history-loading"><Spin /></div> ) : historyError ? ( - <Space direction="vertical" size="middle" style={{ width: "100%" }}> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> <Alert showIcon type="error" - message="历史会话加载失败" + title="历史会话加载失败" description={historyError} /> <Button icon={<ReloadOutlined />} onClick={() => void retryConversationHistory()}> @@ -1285,33 +1285,31 @@ export default function ChatPage() { </Button> </Space> ) : conversations.length ? ( - <List - dataSource={conversations} - renderItem={(item) => ( - <List.Item> - <button - className={item.id === conversationId ? "chat-history-item chat-history-item-active" : "chat-history-item"} - onClick={() => void loadConversationDetail(item.id)} - type="button" - > - <strong>{item.title ?? "未命名会话"}</strong> - <span>{item.message_count} 条消息</span> - <span className="chat-history-time"> - <ClockCircleOutlined /> - {formatConversationTime(item.last_message_at)} - </span> - </button> - </List.Item> - )} - /> + <div className="chat-history-list"> + {conversations.map((item) => ( + <button + className={item.id === conversationId ? "chat-history-item chat-history-item-active" : "chat-history-item"} + key={item.id} + onClick={() => void loadConversationDetail(item.id)} + type="button" + > + <strong>{item.title ?? "未命名会话"}</strong> + <span>{item.message_count} 条消息</span> + <span className="chat-history-time"> + <ClockCircleOutlined /> + {formatConversationTime(item.last_message_at)} + </span> + </button> + ))} + </div> ) : ( <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有历史会话。" /> )} </Card> <Card className="review-surface-card" title={reviewContext ? "基于当前审查结果继续提问" : "提问区"}> - <Space direction="vertical" size="middle" style={{ width: "100%" }}> - <Alert showIcon type="info" message={reviewContext ? "当前会优先围绕这份合同的风险项、条款摘要和处理建议进行问答。" : "问答结果仅供参考,请结合具体合同条款和正式法律意见审慎决策。"} /> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> + <Alert showIcon type="info" title={reviewContext ? "当前会优先围绕这份合同的风险项、条款摘要和处理建议进行问答。" : "问答结果仅供参考,请结合具体合同条款和正式法律意见审慎决策。"} /> <TextArea rows={6} placeholder={reviewContext ? "例如:请解释这份合同的高风险项,或者询问下一步修订建议。" : "例如:这个违约金条款有没有问题?"} @@ -1360,7 +1358,7 @@ export default function ChatPage() { </Space> </Card> - <Space direction="vertical" size="middle" style={{ width: "100%" }}> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> <Card className="review-surface-card" title={reviewContext ? "审查助手输出" : "问答记录"}> {detailLoading ? ( <div className="chat-history-loading"><Spin /></div> @@ -1488,9 +1486,9 @@ export default function ChatPage() { showIcon type="error" style={{ marginTop: 16 }} - message="Chat 回答未完成" + title="Chat 回答未完成" description={ - <Space direction="vertical" size={4}> + <Space orientation="vertical" size={4}> <span>{errorText}</span> <span>可以点击“重试上一次”,或缩短问题并聚焦一个风险点后重新发送。</span> </Space> @@ -1501,7 +1499,7 @@ export default function ChatPage() { <Card className="review-surface-card" title={reviewContext ? sourcePanelMeta.panel.title : "引用来源"}> {sources.length ? ( - <Space direction="vertical" size="middle" style={{ width: "100%" }}> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> <Text type={sourceStateTone}>{sourcePanelMeta.panel.description}</Text> <Text type="secondary">{sourceStatusText}</Text> {sourcePanelMeta.items.map((item, index) => { diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index fb14314c..e04b80d8 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -308,7 +308,7 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis return ( <main className="page-shell review-detail-page contract-detail-page"> {contextHolder} - <Space direction="vertical" size="large" style={{ width: "100%" }}> + <Space orientation="vertical" size="large" style={{ width: "100%" }}> <section className="review-detail-hero"> <div className="page-header-block"> <span className="landing-badge">Generated Contract</span> @@ -333,8 +333,8 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis </div> </div> </div> - <Card className="review-side-card" bordered={false}> - <Space direction="vertical" size="middle" style={{ width: "100%" }}> + <Card className="review-side-card" variant="borderless"> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> <span className="landing-panel-eyebrow">Workspace Actions</span> <Title level={4} style={{ margin: 0, color: "inherit" }}> 下一步动作 @@ -343,8 +343,11 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis <Space wrap> <Button onClick={() => router.push("/contracts")}>返回列表</Button> <Button onClick={handleRestore}>再次生成 / 继续编辑</Button> + <Button type="primary" onClick={() => router.push(`/documents/${detail?.id}/edit`)}> + 进入合同编辑器 + </Button> {currentUser?.role === "admin" && detail?.file_id ? <Button onClick={() => router.push(`/files?highlight=${detail.file_id}`)}>查看文件资产</Button> : null} - <Button type="primary" loading={downloading} onClick={handleDownload}> + <Button loading={downloading} onClick={handleDownload}> 下载合同文件 </Button> </Space> diff --git a/frontend/src/app/contracts/page.tsx b/frontend/src/app/contracts/page.tsx index 6abdfecc..c571e6e0 100644 --- a/frontend/src/app/contracts/page.tsx +++ b/frontend/src/app/contracts/page.tsx @@ -639,7 +639,7 @@ export default function ContractsPage() { } return ( - <Space direction="vertical" size={4}> + <Space orientation="vertical" size={4}> <Tag color={getAutoReviewStatusColor(autoReview.status)}>{formatAutoReviewStatus(autoReview.status)}</Tag> <Text type="secondary" style={{ fontSize: 12 }}> {autoReview.current_stage_label ?? autoReview.next_action ?? "-"} @@ -661,13 +661,16 @@ export default function ContractsPage() { { title: "操作", key: "actions", - width: 170, + width: 230, fixed: "right", render: (_, record) => ( <Space> <Button size="small" onClick={() => router.push(`/contracts/${record.id}`)}> <span className="ui-nowrap-label">详情</span> </Button> + <Button size="small" onClick={() => router.push(`/documents/${record.id}/edit`)}> + <span className="ui-nowrap-label">编辑</span> + </Button> <Button size="small" type="primary" loading={historyDownloadingId === record.id} onClick={() => void handleHistoryDownload(record)}> <span className="ui-nowrap-label">下载</span> </Button> @@ -679,19 +682,19 @@ export default function ContractsPage() { const steps = [ { title: "选择模板", - description: "从可用模板中选择", + content: "从可用模板中选择", }, { title: "填写变量", - description: "填写合同变量值", + content: "填写合同变量值", }, { title: "预览确认", - description: "预览生成结果", + content: "预览生成结果", }, { title: "生成下载", - description: "生成并下载合同", + content: "生成并下载合同", }, ]; @@ -700,14 +703,14 @@ export default function ContractsPage() { return ( <main className="page-shell review-page-grid contracts-page"> {contextHolder} - <Space direction="vertical" size="large" style={{ width: "100%" }}> + <Space orientation="vertical" size="large" style={{ width: "100%" }}> <section className="review-page-hero"> <div className="review-page-hero-copy"> <span className="landing-badge">Contract Generation</span> <Title level={2}>从模板到下载,一屏完成合同生成。 先选模板,再填写变量;预览确认后生成文件,历史结果保留在下方。 - + Smart Workflow 当前链路 @@ -750,7 +753,7 @@ export default function ContractsPage() { showIcon type="info" style={{ marginBottom: 16 }} - message={`已接收审查上下文:${reviewContext.reviewTitle ?? "未命名审查"}`} + title={`已接收审查上下文:${reviewContext.reviewTitle ?? "未命名审查"}`} description="选择模板后,系统会尝试把审查意见、风险项和处理建议预填到匹配的变量字段中。" /> ) : null} @@ -779,12 +782,12 @@ export default function ContractsPage() { {currentStep >= 1 && selectedTemplate && ( <div className="chat-layout-grid chat-layout-grid-history"> <Card className="review-surface-card" title="填写合同变量"> - <Space direction="vertical" size="middle" style={{ width: "100%" }}> + <Space orientation="vertical" size="middle" style={{ width: "100%" }}> {restorePayload && ( <Alert type="info" showIcon - message="已从历史合同恢复生成参数" + title="已从历史合同恢复生成参数" description="你可以直接修改变量后重新生成;历史导出选项也会继续沿用。" /> )} @@ -793,7 +796,7 @@ export default function ContractsPage() { <Alert type="info" showIcon - message="正在消费审查上下文" + title="正在消费审查上下文" description={`来自 ${reviewContext.reviewTitle ?? "审查结果"};总体风险 ${reviewContext.overallRisk ?? "-"}。可继续编辑预填变量后生成合同。`} /> )} @@ -904,7 +907,7 @@ export default function ContractsPage() { {currentStep >= 3 && generatedContract && ( <Card className="review-surface-card" title="合同生成成功"> <Alert - message="合同生成成功!" + title="合同生成成功!" description="您的合同已成功生成,可以下载到本地使用。" type="success" showIcon @@ -919,7 +922,7 @@ export default function ContractsPage() { {formatAutoReviewStatus(generatedContract.auto_review.status)} </Tag> </div> - <Space direction="vertical" size="small" style={{ width: "100%" }}> + <Space orientation="vertical" size="small" style={{ width: "100%" }}> <Text>审查任务: {generatedContract.auto_review.review_id ?? "-"}</Text> <Text>当前阶段: {generatedContract.auto_review.current_stage_label ?? "-"}</Text> <Text>总体风险: {generatedContract.auto_review.risk_level ?? "-"}</Text> @@ -943,7 +946,7 @@ export default function ContractsPage() { <div style={{ marginBottom: 24 }}> <Title level={5}>生成信息 - + 合同ID: {generatedContract.id} 模板名称: {generatedContract.template_name} 合同类型: {generatedContract.contract_type} diff --git a/frontend/src/app/demo-request/page.tsx b/frontend/src/app/demo-request/page.tsx index 95d25305..60c56c48 100644 --- a/frontend/src/app/demo-request/page.tsx +++ b/frontend/src/app/demo-request/page.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { useState } from "react"; @@ -82,7 +82,7 @@ export default function DemoRequestPage() { - + Pilot Flow 提交后会发生什么 @@ -98,8 +98,8 @@ export default function DemoRequestPage() { <section className="landing-section"> <div className="landing-grid"> - <Card bordered={false}> - <Space direction="vertical" size={18} style={{ width: "100%" }}> + <Card variant="borderless"> + <Space orientation="vertical" size={18} style={{ width: "100%" }}> <div className="landing-section-header" style={{ marginBottom: 0 }}> <h2>演示申请表</h2> <p>留下联系人、企业和场景,我们会据此准备演示链路与试点建议。</p> @@ -109,7 +109,7 @@ export default function DemoRequestPage() { <Alert showIcon type="success" - message={`申请已提交:${submittedRequest.id}`} + title={`申请已提交:${submittedRequest.id}`} description={`我们已收录 ${submittedRequest.company_name} 的试点意向。管理员可以在试点申请看板里继续跟进。`} /> ) : null} @@ -171,8 +171,8 @@ export default function DemoRequestPage() { </Space> </Card> - <Card bordered={false}> - <Space direction="vertical" size={18} style={{ width: "100%" }}> + <Card variant="borderless"> + <Space orientation="vertical" size={18} style={{ width: "100%" }}> <div className="landing-section-header" style={{ marginBottom: 0 }}> <h2>适合这一步的人</h2> <p>不是所有人都要先注册;如果还在评估阶段,先走演示申请会更顺。</p> diff --git a/frontend/src/app/documents/[id]/edit/page.tsx b/frontend/src/app/documents/[id]/edit/page.tsx new file mode 100644 index 00000000..324610b6 --- /dev/null +++ b/frontend/src/app/documents/[id]/edit/page.tsx @@ -0,0 +1,632 @@ +"use client"; + +import { + BulbOutlined, + CheckCircleOutlined, + EditOutlined, + HistoryOutlined, + LinkOutlined, + MessageOutlined, + SaveOutlined, + SwapOutlined, +} from "@ant-design/icons"; +import { + Alert, + Button, + Empty, + Segmented, + Space, + Spin, + Tag, + Timeline, + Typography, + message, +} from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + createGeneratedContractVersion, + getGeneratedContract, + listGeneratedContractVersions, + type GeneratedContract, + type GeneratedContractVersionItem, +} from "@/services/contract"; +import { + recommendMissingClauses, + rewriteClauseWording, + type ClauseRecommendationItem, + type ClauseRecommendationResponse, + type ClauseRewriteResponse, +} from "@/services/clause"; +import { isRequestError } from "@/services/http"; + +const { Title, Text, Paragraph } = Typography; + +const rewriteModeOptions = [ + { label: "专业化", value: "professional" }, + { label: "简化解释", value: "simplify" }, + { label: "偏甲方", value: "party_a" }, + { label: "偏乙方", value: "party_b" }, + { label: "平衡版", value: "balanced" }, +]; + +const clausePriorityColorMap: Record<string, string> = { + high: "red", + medium: "gold", + low: "blue", +}; + +function formatDateTime(value?: string | null) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +function normalizeEditorText(value: string) { + return String(value || "").replace(/\r\n/g, "\n").trim(); +} + +function splitParagraphs(value: string) { + return normalizeEditorText(value) + .split(/\n{1,}/) + .map((text, index) => ({ id: `${index}-${text.slice(0, 16)}`, index, text: text.trim() })) + .filter((item) => item.text.length > 0); +} + +function applyRecommendationText(content: string, recommendation: ClauseRecommendationItem) { + const text = normalizeEditorText(content); + const suggestion = recommendation.suggested_text.trim(); + const anchor = recommendation.insert_position.anchor_text?.trim(); + if (!text) { + return suggestion; + } + if (anchor && text.includes(anchor)) { + return text.replace(anchor, `${anchor}\n\n${suggestion}`); + } + return `${text}\n\n${suggestion}`; +} + +function buildEditorChatHref(contract: GeneratedContract | null, content: string) { + const payload = { + reviewId: null, + prompt: "请基于当前编辑中的合同正文,指出最值得优先修改的条款、缺失条款和谈判关注点。", + metadata: { + reviewTitle: contract?.template_name ?? "合同编辑器", + reviewStatus: "编辑中", + contractType: contract?.contract_type ?? null, + overallRisk: null, + reviewOpinion: content.slice(0, 1200), + focusLabel: contract?.file_name ?? contract?.template_name ?? null, + actionLabel: "编辑器 AI 侧边栏", + focusType: "generated_contract_editor", + contractId: contract?.id ?? null, + }, + }; + return `/chat?review=${encodeURIComponent(JSON.stringify(payload))}`; +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +export default function GeneratedContractEditorPage({ params }: { params: Promise<{ id: string }> }) { + const [messageApi, contextHolder] = message.useMessage(); + const router = useRouter(); + const editorRef = useRef<HTMLDivElement | null>(null); + const saveTimerRef = useRef<number | null>(null); + const persistedContentRef = useRef(""); + const currentVersionIdRef = useRef<string | null>(null); + const pendingChangeSummaryRef = useRef("自动保存编辑内容"); + + const [contractId, setContractId] = useState<string | null>(null); + const [contract, setContract] = useState<GeneratedContract | null>(null); + const [versions, setVersions] = useState<GeneratedContractVersionItem[]>([]); + const [baseVersionCreatedAt, setBaseVersionCreatedAt] = useState<string | null>(null); + const [editorContent, setEditorContent] = useState(""); + const [selectedParagraphIndex, setSelectedParagraphIndex] = useState<number | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [autoSaveStatus, setAutoSaveStatus] = useState("等待编辑"); + const [recommendationLoading, setRecommendationLoading] = useState(false); + const [recommendations, setRecommendations] = useState<ClauseRecommendationResponse | null>(null); + const [rewriteMode, setRewriteMode] = useState("professional"); + const [rewriteLoading, setRewriteLoading] = useState(false); + const [rewriteResult, setRewriteResult] = useState<ClauseRewriteResponse | null>(null); + const [loadError, setLoadError] = useState<string | null>(null); + + const paragraphs = useMemo(() => splitParagraphs(editorContent), [editorContent]); + const selectedParagraph = useMemo( + () => paragraphs.find((item) => item.index === selectedParagraphIndex) ?? paragraphs[0] ?? null, + [paragraphs, selectedParagraphIndex], + ); + + const refreshVersions = useCallback(async (id: string) => { + const versionsResponse = await listGeneratedContractVersions(id); + if (versionsResponse.code !== 0) { + return null; + } + setVersions(versionsResponse.data.items); + setBaseVersionCreatedAt(versionsResponse.data.base_version.created_at ?? null); + return versionsResponse.data; + }, []); + + const loadPage = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const resolved = await params; + const id = resolved.id; + setContractId(id); + const [detailResponse, versionsResponse] = await Promise.all([ + getGeneratedContract(id), + listGeneratedContractVersions(id), + ]); + if (detailResponse.code !== 0) { + setLoadError(detailResponse.message || "合同加载失败"); + return; + } + const versionItems = versionsResponse.code === 0 ? versionsResponse.data.items : []; + const latest = versionItems[0] ?? null; + const content = latest?.content ?? detailResponse.data.generated_content; + setContract(detailResponse.data); + setVersions(versionItems); + setBaseVersionCreatedAt(versionsResponse.code === 0 ? versionsResponse.data.base_version.created_at ?? null : null); + setEditorContent(content); + persistedContentRef.current = normalizeEditorText(content); + currentVersionIdRef.current = latest?.id ?? null; + setSelectedParagraphIndex(null); + setAutoSaveStatus(latest ? `已加载 ${latest.version_label}` : "已加载 v1"); + } catch (error) { + setLoadError(getErrorMessage(error, "合同编辑器加载失败")); + } finally { + setLoading(false); + } + }, [params]); + + useEffect(() => { + void loadPage(); + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + }; + }, [loadPage]); + + useEffect(() => { + const node = editorRef.current; + if (!node) { + return; + } + if (normalizeEditorText(node.innerText) !== normalizeEditorText(editorContent)) { + node.innerText = editorContent; + } + }, [editorContent]); + + const saveVersion = useCallback(async ( + nextContent: string, + options: { + changeType?: string; + changeSummary?: string; + originalText?: string | null; + replacementText?: string | null; + quiet?: boolean; + } = {}, + ) => { + if (!contractId) { + return null; + } + const normalized = normalizeEditorText(nextContent); + if (!normalized) { + if (!options.quiet) { + messageApi.warning("合同正文不能为空"); + } + return null; + } + if (normalized === persistedContentRef.current) { + setAutoSaveStatus("已是最新版本"); + return null; + } + setSaving(true); + setAutoSaveStatus("保存中..."); + try { + const response = await createGeneratedContractVersion(contractId, { + content: normalized, + change_type: options.changeType ?? "editor_save", + change_summary: options.changeSummary ?? "编辑器保存合同正文", + source_version_id: currentVersionIdRef.current, + original_text: options.originalText ?? null, + replacement_text: options.replacementText ?? null, + locate_status: "applied", + }); + if (response.code !== 0) { + if (!options.quiet) { + messageApi.error(response.message || "版本保存失败"); + } + setAutoSaveStatus("保存失败"); + return null; + } + persistedContentRef.current = normalized; + currentVersionIdRef.current = response.data.id; + setAutoSaveStatus(`已保存 ${response.data.version_label}`); + await refreshVersions(contractId); + if (!options.quiet) { + messageApi.success(`已生成新版本 ${response.data.version_label}`); + } + return response.data; + } catch (error) { + if (isRequestError(error) && error.status === 409) { + setAutoSaveStatus("已是最新版本"); + return null; + } + setAutoSaveStatus("保存失败"); + if (!options.quiet) { + messageApi.error(getErrorMessage(error, "版本保存失败")); + } + return null; + } finally { + setSaving(false); + } + }, [contractId, messageApi, refreshVersions]); + + const scheduleAutoSave = useCallback((nextContent: string, summary = "自动保存编辑内容") => { + pendingChangeSummaryRef.current = summary; + setAutoSaveStatus("等待自动保存..."); + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = window.setTimeout(() => { + void saveVersion(nextContent, { + changeType: "editor_autosave", + changeSummary: pendingChangeSummaryRef.current, + quiet: true, + }); + }, 1600); + }, [saveVersion]); + + const handleEditorInput = () => { + const nextContent = editorRef.current?.innerText ?? ""; + setEditorContent(nextContent); + scheduleAutoSave(nextContent); + }; + + const handleSelectVersion = (version: GeneratedContractVersionItem | null) => { + const nextContent = version?.content ?? contract?.generated_content ?? ""; + setEditorContent(nextContent); + persistedContentRef.current = normalizeEditorText(nextContent); + currentVersionIdRef.current = version?.id ?? null; + setAutoSaveStatus(version ? `已切换 ${version.version_label}` : "已切换 v1"); + setSelectedParagraphIndex(null); + setRewriteResult(null); + }; + + const handleRecommend = async () => { + if (!contract || !editorContent.trim()) { + messageApi.warning("请先准备合同正文"); + return; + } + setRecommendationLoading(true); + try { + const response = await recommendMissingClauses({ + contract_id: contract.id, + contract_text: editorContent, + document_type_code: contract.document_type_code, + contract_type: contract.contract_type, + max_items: 8, + }); + if (response.code !== 0) { + messageApi.warning(response.message || "条款推荐暂时不可用"); + return; + } + setRecommendations(response.data); + messageApi.success(response.data.total ? `发现 ${response.data.total} 条推荐` : "暂未发现缺失条款"); + } catch (error) { + messageApi.warning(getErrorMessage(error, "条款推荐暂时不可用")); + } finally { + setRecommendationLoading(false); + } + }; + + const handleApplyRecommendation = async (item: ClauseRecommendationItem) => { + const nextContent = applyRecommendationText(editorContent, item); + setEditorContent(nextContent); + setRewriteResult(null); + await saveVersion(nextContent, { + changeType: "clause_recommendation", + changeSummary: `插入推荐条款:${item.clause_title}`, + originalText: item.insert_position.anchor_text ?? "", + replacementText: item.suggested_text, + }); + }; + + const handleRewrite = async () => { + if (!contract) { + return; + } + const sourceText = selectedParagraph?.text ?? ""; + if (!sourceText.trim()) { + messageApi.warning("请先在段落列表中选择需要改写的条款"); + return; + } + setRewriteLoading(true); + try { + const response = await rewriteClauseWording({ + contract_id: contract.id, + clause_text: sourceText, + rewrite_type: rewriteMode, + document_type_code: contract.document_type_code, + contract_type: contract.contract_type, + context_text: editorContent.slice(0, 3000), + }); + if (response.code !== 0) { + messageApi.warning(response.message || "措辞优化暂时不可用"); + return; + } + setRewriteResult(response.data); + messageApi.success("措辞优化已完成"); + } catch (error) { + messageApi.warning(getErrorMessage(error, "措辞优化暂时不可用")); + } finally { + setRewriteLoading(false); + } + }; + + const handleApplyRewrite = async () => { + if (!rewriteResult) { + return; + } + const originalText = selectedParagraph?.text || rewriteResult.original_text; + const normalized = normalizeEditorText(editorContent); + const nextContent = originalText && normalized.includes(originalText) + ? normalized.replace(originalText, rewriteResult.rewritten_text) + : `${normalized}\n\n${rewriteResult.rewritten_text}`; + setEditorContent(nextContent); + await saveVersion(nextContent, { + changeType: "clause_rewrite", + changeSummary: `应用措辞优化:${rewriteResult.rewrite_type_label}`, + originalText, + replacementText: rewriteResult.rewritten_text, + }); + }; + + if (loading) { + return ( + <main className="page-shell document-editor-page"> + {contextHolder} + <div className="document-loading-panel"> + <Spin /> + <Text type="secondary">正在加载合同编辑器...</Text> + </div> + </main> + ); + } + + if (loadError) { + return ( + <main className="page-shell document-editor-page"> + {contextHolder} + <div className="document-empty-panel"> + <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadError}> + <Button onClick={() => router.push("/contracts")}>返回合同列表</Button> + </Empty> + </div> + </main> + ); + } + + return ( + <main className="page-shell document-editor-page"> + {contextHolder} + <Space direction="vertical" size="large" style={{ width: "100%" }}> + <section className="review-page-hero"> + <div className="review-page-hero-copy"> + <span className="landing-badge">Document Editor</span> + <Title level={2}>{contract?.template_name ?? "合同编辑器"} + 在正文中继续编辑,把推荐条款和优化条款真正应用到合同版本链。 + + {contract?.document_type_code ?? contract?.contract_type ?? "文书"} + {currentVersionIdRef.current ? "版本链编辑" : "基础版本"} + {autoSaveStatus} + + +
    + Versioned Save + 自动保存 +
    + {saving ? : } + {autoSaveStatus} +
    + 每次保存都会写入 `generated_contract_versions`。 +
    +
    + +
    +
    +
    + + + + + +
    + +
    + + +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/app/documents/new/[type]/page.tsx b/frontend/src/app/documents/new/[type]/page.tsx new file mode 100644 index 00000000..7f375c8f --- /dev/null +++ b/frontend/src/app/documents/new/[type]/page.tsx @@ -0,0 +1,970 @@ +"use client"; + +import { + BulbOutlined, + CheckCircleOutlined, + DeleteOutlined, + EditOutlined, + FileDoneOutlined, + HistoryOutlined, + LinkOutlined, + ReloadOutlined, + SaveOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { + Alert, + Button, + Checkbox, + Empty, + Form, + Input, + Radio, + Segmented, + Select, + Space, + Spin, + Switch, + Tag, + Typography, + message, +} from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + recommendMissingClauses, + rewriteClauseWording, + type ClauseRecommendationResponse, + type ClauseRewriteResponse, +} from "@/services/clause"; +import { listContractTemplates, type ContractTemplate, type GeneratedContract } from "@/services/contract"; +import { + createLegalDocumentDraft, + deleteLegalDocumentDraft, + generateLegalDocumentDraft, + getLegalDocumentElementSchema, + getLegalDocumentType, + listLegalDocumentDrafts, + updateLegalDocumentDraft, + type LegalDocumentDraft, + type LegalDocumentElementField, + type LegalDocumentElementSchema, + type LegalDocumentTypeItem, + type LegalDocumentValidationResult, +} from "@/services/legal-document"; + +const { Title, Text } = Typography; +const { TextArea } = Input; + +type DocumentFormValues = { + title?: string; + template_id?: string; + export_format?: string; + elements?: Record; +}; + +const documentNameMap: Record = { + procurement_goods: "货物买卖合同", + technical_service: "技术服务合同", + software_development: "软件开发合同", + service_general: "通用服务合同", + labor_contract: "劳动合同", + nda_commercial: "保密协议", + lease_house: "房屋租赁合同", + equity_transfer: "股权转让协议", + cooperation_framework: "合作框架协议", + demand_letter: "催告函", + rescission_notice: "解除合同通知书", +}; + +const fieldLabelFallbackMap: Record = { + "party_a.name": "甲方名称", + "party_a.contact": "甲方联系人", + "party_b.name": "乙方名称", + "party_b.contact": "乙方联系人", + "goods.name": "货物名称", + "goods.quantity": "数量", + "price.total": "合同总价", + "delivery.date": "交付日期", + "delivery.place": "交付地点", + "acceptance.standard": "验收标准", + "payment.terms": "付款安排", + "dispute_resolution.method": "争议解决方式", +}; + +const exportFormatOptions = [ + { value: "docx", label: "Word 文档" }, + { value: "pdf", label: "PDF 文档" }, + { value: "html", label: "HTML 网页" }, + { value: "txt", label: "纯文本" }, +]; + +const categoryLabelMap: Record = { + "company-commercial": "公司商事", + "employment-hr": "劳动人事", + "real-estate": "房产租赁", + "investment-finance": "投融资", + "notices-letters": "通知函件", +}; + +const riskColorMap: Record = { + low: "blue", + medium: "gold", + high: "orange", + critical: "red", +}; + +const riskLabelMap: Record = { + low: "低风险", + medium: "中风险", + high: "高风险", + critical: "重大风险", +}; + +const clausePriorityColorMap: Record = { + high: "red", + medium: "gold", + low: "blue", +}; + +const clausePriorityLabelMap: Record = { + high: "高优先级", + medium: "中优先级", + low: "低优先级", +}; + +const clauseRewriteModeOptions = [ + { label: "专业化", value: "professional" }, + { label: "简化解释", value: "simplify" }, + { label: "偏甲方", value: "party_a" }, + { label: "偏乙方", value: "party_b" }, + { label: "平衡版", value: "balanced" }, +]; + +function readableDocumentName(documentType?: LegalDocumentTypeItem | null, code?: string) { + if (!code && !documentType) { + return "法律文书"; + } + return documentNameMap[documentType?.code ?? code ?? ""] ?? documentType?.name ?? code ?? "法律文书"; +} + +function readableFieldLabel(field: LegalDocumentElementField) { + return fieldLabelFallbackMap[field.key] ?? field.label ?? field.key; +} + +function normalizeOptions(field: LegalDocumentElementField) { + return (field.options ?? []).map((option) => { + if (typeof option === "string") { + return { label: option, value: option }; + } + return option; + }); +} + +function isTextAreaField(field: LegalDocumentElementField) { + return field.type === "textarea" || /scope|standard|terms|reason|liability|obligation|modules|milestones/.test(field.key); +} + +function buildInitialElements(schema: LegalDocumentElementSchema | null): NonNullable { + const values: NonNullable = {}; + for (const section of schema?.schema_json.sections ?? []) { + for (const field of section.fields) { + if (field.type === "checkbox" || field.type === "clause_toggle") { + values[field.key] = false; + } + } + } + return values; +} + +function normalizeFormElements(values: Record | null | undefined): NonNullable { + const normalized: NonNullable = {}; + for (const [key, value] of Object.entries(values ?? {})) { + if ( + value == null + || typeof value === "string" + || typeof value === "number" + || typeof value === "boolean" + || (Array.isArray(value) && value.every((item) => typeof item === "string")) + ) { + normalized[key] = value as NonNullable[string]; + } else { + normalized[key] = JSON.stringify(value); + } + } + return normalized; +} + +function formatDateTime(value?: string | null) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +function pickClauseRewriteCandidate(contract: GeneratedContract | null) { + const content = contract?.generated_content?.trim() ?? ""; + if (!content) { + return ""; + } + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length >= 8); + const candidate = lines.find((line) => /交付|付款|验收|违约|保密|知识产权|解除|通知/.test(line)) ?? lines[0] ?? content; + return candidate.slice(0, 1000); +} + +function pickValidationError(validation: LegalDocumentValidationResult | null | undefined, fieldKey: string) { + return validation?.errors?.find((item) => item.field_key === fieldKey)?.message ?? null; +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function renderField(field: LegalDocumentElementField) { + if (field.type === "select") { + return ; + } + + if (field.type === "number" || field.type === "money" || field.type === "percentage") { + return ; + } + + if (isTextAreaField(field)) { + return