Files
ai-contract-platform/backend/app/api/reviews.py
T
yuqei 3df85fdb04 feat: land phase15 legal docs and commercial ops
Includes legal document generation workflows, prompt governance runtime wiring, legal change impact scans, operation KPI dashboards, invoice files, sales follow-up events, mobile review polish, deployment automation, and updated project docs.
2026-05-21 14:42:44 +08:00

348 lines
13 KiB
Python

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
router = APIRouter(prefix="/reviews", tags=["reviews"])
ROUTE_PERMISSION_SCOPE = "review_workflow"
ROUTE_PERMISSION_ACTIONS = {
"create": "reviews:create",
"list": "reviews:list",
"detail": "reviews:detail",
"result": "reviews:result",
"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)
def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService:
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)
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("")
async def create_review(
request: Request,
file: Optional[UploadFile] = File(default=None),
text: Optional[str] = Form(default=None),
contract_type: Optional[str] = Form(default=None),
review_as: str = Form(default="party_a"),
review_service: ReviewService = Depends(get_review_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
tenant_service: TenantService = Depends(get_tenant_service),
current_user: dict = Depends(require_current_user),
):
tenant_service.assert_billing_allows(current_user, "review")
normalized_text = text.strip() if text else None
file_name = file.filename if file else None
file_text: Optional[str] = None
extraction: Optional[dict] = None
if file is not None:
file_bytes = await file.read()
review_service.validate_review_upload(file_name=file_name, mime_type=file.content_type, file_bytes=file_bytes)
extraction = review_service.extract_review_upload_text(file_bytes=file_bytes, file_name=file_name)
file_text = extraction["text"]
combined_text = review_service.merge_review_text(text=normalized_text, file_text=file_text)
if file is None and not combined_text:
return {"code": 40001, "message": "file 和 text 至少提供一个", "data": None}
if file is not None and not file_text and not normalized_text:
return {
"code": 40002,
"message": extraction["failure_message"],
"data": None,
}
result = review_service.create_review_task(
tenant_id=current_user["tenant_id"],
user_id=current_user["id"],
review_as=review_as,
contract_type=contract_type,
text=combined_text,
file_name=file_name,
extracted_text=file_text,
extraction_metadata=extraction["metadata"] if extraction else None,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="create_review",
resource_type="review",
resource_id=result["id"],
detail_json={
"contract_type": contract_type,
"review_as": review_as,
"source_type": "file" if file is not None else "text",
"file_name": file_name,
"text_length": len(combined_text or ""),
"status": result.get("status"),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return success_response(result)
@router.get("")
def list_reviews(
review_service: ReviewService = Depends(get_review_service),
_current_user: dict = Depends(require_current_user),
):
return success_response(review_service.list_reviews(_current_user))
@router.get("/{review_id}")
def get_review_detail(
review_id: str,
review_service: ReviewService = Depends(get_review_service),
_current_user: dict = Depends(require_current_user),
):
detail = review_service.get_review_detail(_current_user, review_id)
if detail is None:
raise HTTPException(status_code=404, detail="review not found")
return success_response(detail)
@router.post("/{review_id}/retry")
def retry_review(
review_id: str,
request: Request,
review_service: ReviewService = Depends(get_review_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
tenant_service: TenantService = Depends(get_tenant_service),
current_user: dict = Depends(require_current_user),
):
tenant_service.assert_billing_allows(current_user, "review")
result = review_service.retry_review_task(current_user, review_id)
if result is None:
raise HTTPException(status_code=404, detail="review not found")
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="retry_review",
resource_type="review",
resource_id=result["id"],
detail_json={
"original_review_id": review_id,
"new_review_id": result["id"],
"status": result.get("status"),
"current_stage": result.get("current_stage"),
"next_action": result.get("next_action"),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
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,
review_service: ReviewService = Depends(get_review_service),
_current_user: dict = Depends(require_current_user),
):
result = review_service.get_review_result(_current_user, review_id)
if result is None:
raise HTTPException(status_code=404, detail="review not found")
return success_response(result)
@router.get("/{review_id}/report")
def get_review_report(
review_id: str,
review_service: ReviewService = Depends(get_review_service),
_current_user: dict = Depends(require_current_user),
):
report = review_service.get_review_report(_current_user, review_id)
if report is None:
raise HTTPException(status_code=404, detail="review not found")
return success_response(report)
@router.get("/{review_id}/report/download")
def download_review_report(
review_id: str,
request: Request,
review_service: ReviewService = Depends(get_review_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
report = review_service.get_review_report(current_user, review_id)
if report is None:
raise HTTPException(status_code=404, detail="review not found")
content = review_service.build_report_pdf(current_user, review_id)
if content is None:
raise HTTPException(status_code=404, detail="review not found")
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
action="download_report",
resource_type="review",
resource_id=review_id,
detail_json={
"file_name": report["file_name"],
"download_url": report["download_url"],
"legal_article_ref_count": report.get("legal_article_ref_count", 0),
"legal_article_ref_ids": (
((report.get("report_metadata") or {}).get("legal_article_refs") or {}).get("legal_article_ref_ids")
or []
),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(report['file_name'])}"}
return Response(content=content, media_type="application/pdf", headers=headers)