feat: add clause recommendation funnel
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -15,6 +15,7 @@ ROUTE_PERMISSION_SCOPE = "clause_recommendation"
|
||||
ROUTE_PERMISSION_ACTIONS = {
|
||||
"recommend": "clauses:recommend",
|
||||
"rewrite": "clauses:rewrite",
|
||||
"recommendation_action": "clauses:recommendations:action",
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +37,13 @@ class ClauseRewriteRequest(BaseModel):
|
||||
context_text: Optional[str] = None
|
||||
|
||||
|
||||
class ClauseRecommendationActionRequest(BaseModel):
|
||||
action_status: str = Field(description="shown/applied/ignored/modified")
|
||||
action_note: Optional[str] = None
|
||||
applied_contract_version_id: Optional[str] = None
|
||||
action_detail: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
def get_clause_recommendation_service(db: Session = Depends(get_db_session)) -> ClauseRecommendationService:
|
||||
return ClauseRecommendationService(db)
|
||||
|
||||
@@ -128,3 +136,40 @@ def rewrite_clause_wording(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/recommendations/{recommendation_id}/action")
|
||||
def record_clause_recommendation_action(
|
||||
recommendation_id: str,
|
||||
payload: ClauseRecommendationActionRequest,
|
||||
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.update_recommendation_action(
|
||||
current_user,
|
||||
recommendation_id,
|
||||
action_status=payload.action_status,
|
||||
action_note=payload.action_note,
|
||||
applied_contract_version_id=payload.applied_contract_version_id,
|
||||
action_detail=payload.action_detail,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="record_clause_recommendation_action",
|
||||
resource_type="clause_recommendation",
|
||||
resource_id=recommendation_id,
|
||||
detail_json={
|
||||
"action_status": result.get("action_status"),
|
||||
"contract_id": result.get("contract_id"),
|
||||
"document_type_code": result.get("document_type_code"),
|
||||
"clause_key": result.get("clause_key"),
|
||||
"recommendation_kind": result.get("recommendation_kind"),
|
||||
"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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.clause_recommendation import ClauseRecommendation
|
||||
from app.models.commercial_invoice import CommercialInvoice
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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 ClauseRecommendation(Base):
|
||||
__tablename__ = "clause_recommendations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
recommendation_run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
contract_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(64), nullable=False, default="contract_text", index=True)
|
||||
document_type_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
contract_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
clause_key: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
clause_title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
clause_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
recommendation_kind: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
basis_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), nullable=False, default="medium", index=True)
|
||||
confidence: Mapped[str] = mapped_column(String(32), nullable=False, default="medium")
|
||||
insert_strategy: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
insert_anchor_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
suggested_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
legal_basis: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
practice_basis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
legal_article_refs_json: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSON, nullable=True)
|
||||
source_rule: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
source_types_json: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True)
|
||||
prompt_runtime_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
action_status: Mapped[str] = mapped_column(String(32), nullable=False, default="shown", index=True)
|
||||
action_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
action_detail_json: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
|
||||
applied_contract_version_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=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)
|
||||
acted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=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),
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.clause_recommendation import ClauseRecommendation
|
||||
from app.services.legal_article_service import LegalArticleService
|
||||
from app.services.prompt_runtime_service import PromptRuntimeService
|
||||
|
||||
@@ -691,6 +693,8 @@ SOFTWARE_DEVELOPMENT_PRACTICE_RULES = [
|
||||
INDUSTRY_PRACTICE_RULES["technical_service"] = TECHNICAL_SERVICE_PRACTICE_RULES
|
||||
INDUSTRY_PRACTICE_RULES["software_development"] = SOFTWARE_DEVELOPMENT_PRACTICE_RULES
|
||||
|
||||
VALID_CLAUSE_RECOMMENDATION_ACTION_STATUS = {"shown", "applied", "ignored", "modified"}
|
||||
|
||||
|
||||
class ClauseRecommendationService:
|
||||
def __init__(self, db: Optional[Session], legal_article_service: Optional[LegalArticleService] = None):
|
||||
@@ -771,11 +775,27 @@ class ClauseRecommendationService:
|
||||
for item in recommendations
|
||||
if item.get("recommendation_kind") == "industry_practice"
|
||||
]
|
||||
recommendation_run_id = f"clause-rec-run-{uuid4().hex[:12]}"
|
||||
prompt_runtime_trace = self.prompt_runtime_service.to_trace(prompt_runtime)
|
||||
persisted_recommendations = self._persist_recommendation_items(
|
||||
current_user,
|
||||
recommendation_run_id=recommendation_run_id,
|
||||
contract_id=contract_id,
|
||||
document_type_code=normalized_document_type,
|
||||
contract_type=contract_type,
|
||||
prompt_runtime_trace=prompt_runtime_trace,
|
||||
items=recommendations,
|
||||
)
|
||||
if persisted_recommendations:
|
||||
for item, recommendation in zip(recommendations, persisted_recommendations):
|
||||
item["recommendation_record_id"] = recommendation.id
|
||||
|
||||
result = {
|
||||
"contract_id": contract_id,
|
||||
"recommendation_run_id": recommendation_run_id,
|
||||
"document_type_code": normalized_document_type,
|
||||
"contract_type": contract_type,
|
||||
"prompt_runtime": self.prompt_runtime_service.to_trace(prompt_runtime),
|
||||
"prompt_runtime": prompt_runtime_trace,
|
||||
"items": recommendations,
|
||||
"total": len(recommendations),
|
||||
"summary": {
|
||||
@@ -789,6 +809,7 @@ class ClauseRecommendationService:
|
||||
"industry_practice_count": len(industry_practice_keys),
|
||||
"source_count": sum(len(item.get("legal_article_refs") or []) for item in recommendations),
|
||||
"strategy": "rule_based_required_clause_gap_and_industry_practice",
|
||||
"funnel_recorded_count": len(persisted_recommendations),
|
||||
},
|
||||
"sources": [
|
||||
"document_type_required_clause_list",
|
||||
@@ -802,7 +823,7 @@ class ClauseRecommendationService:
|
||||
current_user,
|
||||
prompt_runtime,
|
||||
run_type="clause_recommendation",
|
||||
run_id=contract_id or f"clause-rec-{uuid4().hex[:12]}",
|
||||
run_id=recommendation_run_id,
|
||||
resource_type="generated_contract" if contract_id else "contract_text",
|
||||
resource_id=contract_id,
|
||||
trace={
|
||||
@@ -814,10 +835,47 @@ class ClauseRecommendationService:
|
||||
"required_missing_clause_keys": required_missing_keys,
|
||||
"industry_practice_keys": industry_practice_keys,
|
||||
"text_length": len(normalized_text),
|
||||
"funnel_recorded_count": len(persisted_recommendations),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
def update_recommendation_action(
|
||||
self,
|
||||
current_user: dict[str, Any],
|
||||
recommendation_id: str,
|
||||
*,
|
||||
action_status: str,
|
||||
action_note: Optional[str] = None,
|
||||
applied_contract_version_id: Optional[str] = None,
|
||||
action_detail: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
self._require_authenticated(current_user)
|
||||
if self.db is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="clause recommendation storage is not configured")
|
||||
normalized_status = self._normalize_action_status(action_status)
|
||||
recommendation = (
|
||||
self.db.query(ClauseRecommendation)
|
||||
.filter(
|
||||
ClauseRecommendation.tenant_id == current_user["tenant_id"],
|
||||
ClauseRecommendation.id == recommendation_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if recommendation is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="clause recommendation not found")
|
||||
recommendation.action_status = normalized_status
|
||||
recommendation.action_note = self._optional_text(action_note, max_length=2000)
|
||||
recommendation.applied_contract_version_id = self._optional_text(applied_contract_version_id, max_length=64)
|
||||
recommendation.action_detail_json = action_detail if isinstance(action_detail, dict) else None
|
||||
recommendation.updated_by = current_user.get("id")
|
||||
recommendation.acted_at = self._now()
|
||||
recommendation.updated_at = self._now()
|
||||
self.db.add(recommendation)
|
||||
self.db.commit()
|
||||
self.db.refresh(recommendation)
|
||||
return self._to_recommendation_action_item(recommendation)
|
||||
|
||||
def rewrite_clause_wording(
|
||||
self,
|
||||
current_user: dict[str, Any],
|
||||
@@ -920,6 +978,90 @@ class ClauseRecommendationService:
|
||||
fallback_prompt_version=fallback_prompt_version,
|
||||
)
|
||||
|
||||
def _persist_recommendation_items(
|
||||
self,
|
||||
current_user: dict[str, Any],
|
||||
*,
|
||||
recommendation_run_id: str,
|
||||
contract_id: Optional[str],
|
||||
document_type_code: str,
|
||||
contract_type: Optional[str],
|
||||
prompt_runtime_trace: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
) -> list[ClauseRecommendation]:
|
||||
if self.db is None or not items:
|
||||
return []
|
||||
now = self._now()
|
||||
records: list[ClauseRecommendation] = []
|
||||
source_type = "generated_contract" if contract_id else "contract_text"
|
||||
for item in items:
|
||||
insert_position = item.get("insert_position") if isinstance(item.get("insert_position"), dict) else {}
|
||||
record = ClauseRecommendation(
|
||||
id=f"clrec-{uuid4().hex[:12]}",
|
||||
tenant_id=current_user["tenant_id"],
|
||||
recommendation_run_id=recommendation_run_id,
|
||||
contract_id=contract_id,
|
||||
source_type=source_type,
|
||||
document_type_code=document_type_code,
|
||||
contract_type=contract_type,
|
||||
clause_key=str(item.get("clause_key") or ""),
|
||||
clause_title=str(item.get("clause_title") or ""),
|
||||
clause_type=str(item.get("clause_type") or ""),
|
||||
recommendation_kind=str(item.get("recommendation_kind") or "missing_clause"),
|
||||
basis_type=item.get("basis_type"),
|
||||
priority=str(item.get("priority") or "medium"),
|
||||
confidence=str(item.get("confidence") or "medium"),
|
||||
insert_strategy=str(insert_position.get("strategy") or "append_before_end"),
|
||||
insert_anchor_text=insert_position.get("anchor_text"),
|
||||
suggested_text=str(item.get("suggested_text") or ""),
|
||||
reason=str(item.get("reason") or ""),
|
||||
legal_basis=str(item.get("legal_basis") or ""),
|
||||
practice_basis=item.get("practice_basis"),
|
||||
legal_article_refs_json=item.get("legal_article_refs") or [],
|
||||
source_rule=str(item.get("source_rule") or ""),
|
||||
source_types_json=item.get("source_types") or [],
|
||||
prompt_runtime_json=prompt_runtime_trace,
|
||||
action_status="shown",
|
||||
created_by=current_user.get("id"),
|
||||
updated_by=current_user.get("id"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.db.add(record)
|
||||
records.append(record)
|
||||
self.db.commit()
|
||||
for record in records:
|
||||
self.db.refresh(record)
|
||||
return records
|
||||
|
||||
@classmethod
|
||||
def _normalize_action_status(cls, value: str) -> str:
|
||||
normalized = cls._required_text(value, "action_status").lower()
|
||||
if normalized not in VALID_CLAUSE_RECOMMENDATION_ACTION_STATUS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="action_status must be shown, applied, ignored, or modified")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _to_recommendation_action_item(recommendation: ClauseRecommendation) -> dict[str, Any]:
|
||||
return {
|
||||
"id": recommendation.id,
|
||||
"recommendation_run_id": recommendation.recommendation_run_id,
|
||||
"contract_id": recommendation.contract_id,
|
||||
"document_type_code": recommendation.document_type_code,
|
||||
"contract_type": recommendation.contract_type,
|
||||
"clause_key": recommendation.clause_key,
|
||||
"clause_title": recommendation.clause_title,
|
||||
"recommendation_kind": recommendation.recommendation_kind,
|
||||
"priority": recommendation.priority,
|
||||
"action_status": recommendation.action_status,
|
||||
"action_note": recommendation.action_note,
|
||||
"applied_contract_version_id": recommendation.applied_contract_version_id,
|
||||
"action_detail": recommendation.action_detail_json,
|
||||
"created_at": recommendation.created_at.isoformat() if recommendation.created_at else None,
|
||||
"updated_at": recommendation.updated_at.isoformat() if recommendation.updated_at else None,
|
||||
"acted_at": recommendation.acted_at.isoformat() if recommendation.acted_at else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_recommendation_item(
|
||||
cls,
|
||||
@@ -1022,6 +1164,26 @@ class ClauseRecommendationService:
|
||||
return re.sub(r"\s+", " ", line)[:120]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def _required_text(cls, value: Any, field_name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _optional_text(value: Optional[str], *, max_length: int) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:max_length]
|
||||
|
||||
@classmethod
|
||||
def _rewrite_text(cls, clause_text: str, rewrite_type: str) -> tuple[str, list[dict[str, str]]]:
|
||||
normalized = cls._normalize_clause_text(clause_text)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.clause_recommendation import ClauseRecommendation
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
from app.models.legal_article import LegalArticle
|
||||
@@ -46,6 +47,8 @@ class OperationKpiService:
|
||||
"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),
|
||||
"clause_recommendation_funnel": self._build_clause_recommendation_funnel(tenant_id, None),
|
||||
"clause_recommendation_top_items": self._build_clause_recommendation_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),
|
||||
@@ -79,6 +82,7 @@ class OperationKpiService:
|
||||
["recommend_missing_clauses", "rewrite_clause_wording"],
|
||||
)
|
||||
clause_actions = self._build_clause_action_summary(tenant_id, start_at)
|
||||
recommendation_funnel = self._build_clause_recommendation_funnel(tenant_id, start_at)
|
||||
legal_citations = self._build_legal_citation_summary(tenant_id, start_at)
|
||||
|
||||
return {
|
||||
@@ -97,6 +101,12 @@ class OperationKpiService:
|
||||
"clause_action_applied": clause_actions["applied_count"],
|
||||
"clause_action_ignored": clause_actions["ignored_count"],
|
||||
"clause_action_adoption_rate_percent": clause_actions["adoption_rate_percent"],
|
||||
"clause_recommendation_shown_count": recommendation_funnel["total"],
|
||||
"clause_recommendation_applied_count": recommendation_funnel["applied_count"],
|
||||
"clause_recommendation_modified_count": recommendation_funnel["modified_count"],
|
||||
"clause_recommendation_ignored_count": recommendation_funnel["ignored_count"],
|
||||
"clause_recommendation_adoption_rate_percent": recommendation_funnel["adoption_rate_percent"],
|
||||
"clause_recommendation_action_rate_percent": recommendation_funnel["action_rate_percent"],
|
||||
"legal_reference_count": legal_citations["total_reference_count"],
|
||||
"legal_reference_verified_rate_percent": legal_citations["verified_reference_rate_percent"],
|
||||
},
|
||||
@@ -183,6 +193,65 @@ class OperationKpiService:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def _build_clause_recommendation_funnel(self, tenant_id: str, start_at: Optional[datetime]) -> dict[str, Any]:
|
||||
query = self.db.query(ClauseRecommendation.action_status, func.count(ClauseRecommendation.id)).filter(
|
||||
ClauseRecommendation.tenant_id == tenant_id
|
||||
)
|
||||
if start_at is not None:
|
||||
query = query.filter(ClauseRecommendation.created_at >= start_at)
|
||||
rows = query.group_by(ClauseRecommendation.action_status).all()
|
||||
by_status = {str(status_value or "shown"): int(count or 0) for status_value, count in rows}
|
||||
total = sum(by_status.values())
|
||||
applied_count = by_status.get("applied", 0)
|
||||
modified_count = by_status.get("modified", 0)
|
||||
ignored_count = by_status.get("ignored", 0)
|
||||
shown_only_count = by_status.get("shown", 0)
|
||||
adopted_count = applied_count + modified_count
|
||||
acted_count = adopted_count + ignored_count
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"shown_only_count": shown_only_count,
|
||||
"applied_count": applied_count,
|
||||
"modified_count": modified_count,
|
||||
"ignored_count": ignored_count,
|
||||
"adopted_count": adopted_count,
|
||||
"acted_count": acted_count,
|
||||
"adoption_rate_percent": self._percent(adopted_count, total),
|
||||
"action_rate_percent": self._percent(acted_count, total),
|
||||
}
|
||||
|
||||
def _build_clause_recommendation_top_items(self, tenant_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
self.db.query(
|
||||
ClauseRecommendation.clause_title,
|
||||
ClauseRecommendation.clause_key,
|
||||
ClauseRecommendation.recommendation_kind,
|
||||
ClauseRecommendation.action_status,
|
||||
func.count(ClauseRecommendation.id).label("action_count"),
|
||||
)
|
||||
.filter(ClauseRecommendation.tenant_id == tenant_id)
|
||||
.group_by(
|
||||
ClauseRecommendation.clause_title,
|
||||
ClauseRecommendation.clause_key,
|
||||
ClauseRecommendation.recommendation_kind,
|
||||
ClauseRecommendation.action_status,
|
||||
)
|
||||
.order_by(func.count(ClauseRecommendation.id).desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"clause_title": row.clause_title,
|
||||
"clause_key": row.clause_key,
|
||||
"recommendation_kind": row.recommendation_kind,
|
||||
"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)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""create clause recommendation funnel table"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260522_0039"
|
||||
down_revision = "20260521_0038"
|
||||
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)
|
||||
if "clause_recommendations" not in _table_names(inspector):
|
||||
op.create_table(
|
||||
"clause_recommendations",
|
||||
sa.Column("id", sa.String(length=64), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("recommendation_run_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("contract_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False, server_default="contract_text"),
|
||||
sa.Column("document_type_code", sa.String(length=64), nullable=False),
|
||||
sa.Column("contract_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("clause_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("clause_title", sa.String(length=255), nullable=False),
|
||||
sa.Column("clause_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("recommendation_kind", sa.String(length=64), nullable=False),
|
||||
sa.Column("basis_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("confidence", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("insert_strategy", sa.String(length=64), nullable=False),
|
||||
sa.Column("insert_anchor_text", sa.Text(), nullable=True),
|
||||
sa.Column("suggested_text", sa.Text(), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("legal_basis", sa.Text(), nullable=False),
|
||||
sa.Column("practice_basis", sa.Text(), nullable=True),
|
||||
sa.Column("legal_article_refs_json", sa.JSON(), nullable=True),
|
||||
sa.Column("source_rule", sa.String(length=128), nullable=False),
|
||||
sa.Column("source_types_json", sa.JSON(), nullable=True),
|
||||
sa.Column("prompt_runtime_json", sa.JSON(), nullable=True),
|
||||
sa.Column("action_status", sa.String(length=32), nullable=False, server_default="shown"),
|
||||
sa.Column("action_note", sa.Text(), nullable=True),
|
||||
sa.Column("action_detail_json", sa.JSON(), nullable=True),
|
||||
sa.Column("applied_contract_version_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("acted_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_clause_recommendations_tenant_id", "clause_recommendations", ["tenant_id"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_run_id", "clause_recommendations", ["recommendation_run_id"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_contract_id", "clause_recommendations", ["contract_id"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_source_type", "clause_recommendations", ["source_type"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_document_type_code", "clause_recommendations", ["document_type_code"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_contract_type", "clause_recommendations", ["contract_type"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_clause_key", "clause_recommendations", ["clause_key"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_clause_type", "clause_recommendations", ["clause_type"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_kind", "clause_recommendations", ["recommendation_kind"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_priority", "clause_recommendations", ["priority"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_source_rule", "clause_recommendations", ["source_rule"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_action_status", "clause_recommendations", ["action_status"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_applied_version", "clause_recommendations", ["applied_contract_version_id"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_created_by", "clause_recommendations", ["created_by"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_updated_by", "clause_recommendations", ["updated_by"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_acted_at", "clause_recommendations", ["acted_at"])
|
||||
_create_index_if_missing(inspector, "ix_clause_recommendations_created_at", "clause_recommendations", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "clause_recommendations" not in _table_names(inspector):
|
||||
return
|
||||
for index_name in [
|
||||
"ix_clause_recommendations_created_at",
|
||||
"ix_clause_recommendations_acted_at",
|
||||
"ix_clause_recommendations_updated_by",
|
||||
"ix_clause_recommendations_created_by",
|
||||
"ix_clause_recommendations_applied_version",
|
||||
"ix_clause_recommendations_action_status",
|
||||
"ix_clause_recommendations_source_rule",
|
||||
"ix_clause_recommendations_priority",
|
||||
"ix_clause_recommendations_kind",
|
||||
"ix_clause_recommendations_clause_type",
|
||||
"ix_clause_recommendations_clause_key",
|
||||
"ix_clause_recommendations_contract_type",
|
||||
"ix_clause_recommendations_document_type_code",
|
||||
"ix_clause_recommendations_source_type",
|
||||
"ix_clause_recommendations_contract_id",
|
||||
"ix_clause_recommendations_run_id",
|
||||
"ix_clause_recommendations_tenant_id",
|
||||
]:
|
||||
_drop_index_if_present(inspector, index_name, "clause_recommendations")
|
||||
op.drop_table("clause_recommendations")
|
||||
@@ -1,7 +1,13 @@
|
||||
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.clause_recommendation import ClauseRecommendation
|
||||
from app.services.clause_recommendation_service import ClauseRecommendationService
|
||||
|
||||
|
||||
@@ -167,5 +173,58 @@ class ClauseRecommendationServiceTestCase(unittest.TestCase):
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
|
||||
|
||||
class ClauseRecommendationPersistenceTestCase(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.legal_article_service = StubLegalArticleService()
|
||||
self.service = ClauseRecommendationService(self.db, legal_article_service=self.legal_article_service)
|
||||
self.current_user = {"id": "user-001", "tenant_id": "tenant-001", "role": "admin"}
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_recommendation_items_are_persisted_and_action_status_updates(self):
|
||||
result = self.service.recommend_missing_clauses(
|
||||
self.current_user,
|
||||
contract_id="contract-001",
|
||||
document_type_code="procurement_goods",
|
||||
contract_text=(
|
||||
"采购合同\n"
|
||||
"一、采购标的:甲方向乙方采购设备。\n"
|
||||
"二、交付安排:乙方应在2026年6月30日前完成交付。\n"
|
||||
"三、付款方式:甲方在签署后付款。"
|
||||
),
|
||||
max_items=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["funnel_recorded_count"], 1)
|
||||
self.assertTrue(result["recommendation_run_id"].startswith("clause-rec-run-"))
|
||||
record_id = result["items"][0]["recommendation_record_id"]
|
||||
record = self.db.get(ClauseRecommendation, record_id)
|
||||
self.assertIsNotNone(record)
|
||||
self.assertEqual(record.action_status, "shown")
|
||||
self.assertEqual(record.contract_id, "contract-001")
|
||||
|
||||
action = self.service.update_recommendation_action(
|
||||
self.current_user,
|
||||
record_id,
|
||||
action_status="applied",
|
||||
action_note="插入正文并生成版本",
|
||||
applied_contract_version_id="gcv-001",
|
||||
action_detail={"tested_at": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
|
||||
self.assertEqual(action["action_status"], "applied")
|
||||
self.assertEqual(action["applied_contract_version_id"], "gcv-001")
|
||||
self.assertIsNotNone(action["acted_at"])
|
||||
self.db.refresh(record)
|
||||
self.assertEqual(record.action_status, "applied")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -23,11 +23,13 @@ class StubClauseRecommendationService:
|
||||
self.calls.append((current_user, kwargs))
|
||||
return {
|
||||
"contract_id": kwargs.get("contract_id"),
|
||||
"recommendation_run_id": "clause-rec-run-test",
|
||||
"document_type_code": kwargs.get("document_type_code") or "procurement_goods",
|
||||
"contract_type": kwargs.get("contract_type"),
|
||||
"items": [
|
||||
{
|
||||
"id": "clause-procurement_goods-acceptance",
|
||||
"recommendation_record_id": "clrec-test-001",
|
||||
"document_type_code": "procurement_goods",
|
||||
"clause_key": "acceptance",
|
||||
"clause_title": "验收标准与流程",
|
||||
@@ -64,10 +66,32 @@ class StubClauseRecommendationService:
|
||||
"industry_practice_count": 0,
|
||||
"source_count": 0,
|
||||
"strategy": "rule_based_required_clause_gap_and_industry_practice",
|
||||
"funnel_recorded_count": 1,
|
||||
},
|
||||
"sources": ["document_type_required_clause_list"],
|
||||
}
|
||||
|
||||
def update_recommendation_action(self, current_user, recommendation_id, **kwargs):
|
||||
self.calls.append((current_user, {"recommendation_id": recommendation_id, **kwargs}))
|
||||
return {
|
||||
"id": recommendation_id,
|
||||
"recommendation_run_id": "clause-rec-run-test",
|
||||
"contract_id": "contract-001",
|
||||
"document_type_code": "procurement_goods",
|
||||
"contract_type": None,
|
||||
"clause_key": "acceptance",
|
||||
"clause_title": "验收标准与流程",
|
||||
"recommendation_kind": "missing_clause",
|
||||
"priority": "high",
|
||||
"action_status": kwargs.get("action_status"),
|
||||
"action_note": kwargs.get("action_note"),
|
||||
"applied_contract_version_id": kwargs.get("applied_contract_version_id"),
|
||||
"action_detail": kwargs.get("action_detail"),
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
"acted_at": None,
|
||||
}
|
||||
|
||||
def rewrite_clause_wording(self, current_user, **kwargs):
|
||||
self.calls.append((current_user, kwargs))
|
||||
return {
|
||||
@@ -159,6 +183,27 @@ class ClausesApiTestCase(unittest.TestCase):
|
||||
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")
|
||||
|
||||
def test_record_clause_recommendation_action_updates_status_and_records_audit(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/clauses/recommendations/clrec-test-001/action",
|
||||
json={
|
||||
"action_status": "applied",
|
||||
"action_note": "插入正文并生成版本",
|
||||
"applied_contract_version_id": "gcv-001",
|
||||
"action_detail": {"clause_key": "acceptance"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["code"], 0)
|
||||
self.assertEqual(body["data"]["action_status"], "applied")
|
||||
self.assertEqual(body["data"]["applied_contract_version_id"], "gcv-001")
|
||||
self.assertEqual(self.clause_service.calls[0][1]["recommendation_id"], "clrec-test-001")
|
||||
self.assertEqual(self.clause_service.calls[0][1]["action_status"], "applied")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "record_clause_recommendation_action")
|
||||
self.assertEqual(self.audit_log_service.events[0]["detail_json"]["clause_key"], "acceptance")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.clause_recommendation import ClauseRecommendation
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
from app.models.legal_article import LegalArticle
|
||||
@@ -255,6 +256,110 @@ class OperationKpiServiceTestCase(unittest.TestCase):
|
||||
),
|
||||
]
|
||||
)
|
||||
self.db.add_all(
|
||||
[
|
||||
ClauseRecommendation(
|
||||
id="clrec-shown",
|
||||
tenant_id="tenant-001",
|
||||
recommendation_run_id="clause-rec-run-test",
|
||||
contract_id="contract-1",
|
||||
source_type="generated_contract",
|
||||
document_type_code="procurement_goods",
|
||||
clause_key="acceptance",
|
||||
clause_title="验收标准与流程",
|
||||
clause_type="acceptance",
|
||||
recommendation_kind="missing_clause",
|
||||
priority="high",
|
||||
confidence="high",
|
||||
insert_strategy="after_anchor_clause",
|
||||
suggested_text="验收标准与流程:甲方应及时验收。",
|
||||
reason="补齐验收闭环。",
|
||||
legal_basis="合同履行规则。",
|
||||
source_rule="LAW-CLAUSE-PROCUREMENT-ACCEPTANCE",
|
||||
action_status="shown",
|
||||
created_by="user-admin",
|
||||
updated_by="user-admin",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
ClauseRecommendation(
|
||||
id="clrec-applied",
|
||||
tenant_id="tenant-001",
|
||||
recommendation_run_id="clause-rec-run-test",
|
||||
contract_id="contract-1",
|
||||
source_type="generated_contract",
|
||||
document_type_code="procurement_goods",
|
||||
clause_key="quality_warranty",
|
||||
clause_title="质量保证",
|
||||
clause_type="quality",
|
||||
recommendation_kind="missing_clause",
|
||||
priority="high",
|
||||
confidence="high",
|
||||
insert_strategy="after_anchor_clause",
|
||||
suggested_text="质量保证:乙方保证货物质量。",
|
||||
reason="补齐质量闭环。",
|
||||
legal_basis="买卖合同规则。",
|
||||
source_rule="LAW-CLAUSE-PROCUREMENT-QUALITY",
|
||||
action_status="applied",
|
||||
applied_contract_version_id="gcv-001",
|
||||
created_by="user-admin",
|
||||
updated_by="user-admin",
|
||||
acted_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
ClauseRecommendation(
|
||||
id="clrec-modified",
|
||||
tenant_id="tenant-001",
|
||||
recommendation_run_id="clause-rec-run-test",
|
||||
contract_id="contract-2",
|
||||
source_type="generated_contract",
|
||||
document_type_code="procurement_goods",
|
||||
clause_key="breach_liability",
|
||||
clause_title="违约责任",
|
||||
clause_type="breach",
|
||||
recommendation_kind="missing_clause",
|
||||
priority="high",
|
||||
confidence="high",
|
||||
insert_strategy="append_before_end",
|
||||
suggested_text="违约责任:违约方承担责任。",
|
||||
reason="补齐违约责任。",
|
||||
legal_basis="违约责任规则。",
|
||||
source_rule="LAW-CLAUSE-GENERAL-BREACH",
|
||||
action_status="modified",
|
||||
created_by="user-admin",
|
||||
updated_by="user-admin",
|
||||
acted_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
ClauseRecommendation(
|
||||
id="clrec-ignored",
|
||||
tenant_id="tenant-001",
|
||||
recommendation_run_id="clause-rec-run-test",
|
||||
contract_id="contract-2",
|
||||
source_type="generated_contract",
|
||||
document_type_code="procurement_goods",
|
||||
clause_key="notice_delivery",
|
||||
clause_title="通知与送达",
|
||||
clause_type="notice",
|
||||
recommendation_kind="industry_practice",
|
||||
priority="medium",
|
||||
confidence="medium",
|
||||
insert_strategy="append_before_end",
|
||||
suggested_text="通知与送达:双方确认送达地址。",
|
||||
reason="补齐送达证据链。",
|
||||
legal_basis="行业惯例。",
|
||||
source_rule="LAW-CLAUSE-PROCUREMENT-NOTICE",
|
||||
action_status="ignored",
|
||||
created_by="user-admin",
|
||||
updated_by="user-admin",
|
||||
acted_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
self.db.add_all(
|
||||
[
|
||||
CommercialOrder(
|
||||
@@ -308,6 +413,15 @@ class OperationKpiServiceTestCase(unittest.TestCase):
|
||||
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(monthly["metrics"]["clause_recommendation_shown_count"], 4)
|
||||
self.assertEqual(monthly["metrics"]["clause_recommendation_applied_count"], 1)
|
||||
self.assertEqual(monthly["metrics"]["clause_recommendation_modified_count"], 1)
|
||||
self.assertEqual(monthly["metrics"]["clause_recommendation_ignored_count"], 1)
|
||||
self.assertEqual(dashboard["clause_recommendation_funnel"]["total"], 4)
|
||||
self.assertEqual(dashboard["clause_recommendation_funnel"]["adopted_count"], 2)
|
||||
self.assertEqual(dashboard["clause_recommendation_funnel"]["adoption_rate_percent"], 50.0)
|
||||
self.assertEqual(dashboard["clause_recommendation_funnel"]["action_rate_percent"], 75.0)
|
||||
self.assertEqual(dashboard["clause_recommendation_top_items"][0]["action_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)
|
||||
|
||||
@@ -36,6 +36,12 @@ class StubOperationKpiService:
|
||||
"clause_action_applied": 2,
|
||||
"clause_action_ignored": 1,
|
||||
"clause_action_adoption_rate_percent": 66.67,
|
||||
"clause_recommendation_shown_count": 4,
|
||||
"clause_recommendation_applied_count": 1,
|
||||
"clause_recommendation_modified_count": 1,
|
||||
"clause_recommendation_ignored_count": 1,
|
||||
"clause_recommendation_adoption_rate_percent": 50.0,
|
||||
"clause_recommendation_action_rate_percent": 75.0,
|
||||
"legal_reference_count": 3,
|
||||
"legal_reference_verified_rate_percent": 100,
|
||||
},
|
||||
@@ -44,6 +50,26 @@ class StubOperationKpiService:
|
||||
"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": [],
|
||||
"clause_recommendation_funnel": {
|
||||
"total": 4,
|
||||
"shown_only_count": 1,
|
||||
"applied_count": 1,
|
||||
"modified_count": 1,
|
||||
"ignored_count": 1,
|
||||
"adopted_count": 2,
|
||||
"acted_count": 3,
|
||||
"adoption_rate_percent": 50.0,
|
||||
"action_rate_percent": 75.0,
|
||||
},
|
||||
"clause_recommendation_top_items": [
|
||||
{
|
||||
"clause_title": "验收标准与流程",
|
||||
"clause_key": "acceptance",
|
||||
"recommendation_kind": "missing_clause",
|
||||
"action_status": "shown",
|
||||
"action_count": 1,
|
||||
}
|
||||
],
|
||||
"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},
|
||||
|
||||
@@ -39,8 +39,10 @@ import {
|
||||
} from "@/services/contract";
|
||||
import {
|
||||
recommendMissingClauses,
|
||||
recordClauseRecommendationAction,
|
||||
rewriteClauseWording,
|
||||
type ClauseRecommendationItem,
|
||||
type ClauseRecommendationActionStatus,
|
||||
type ClauseRecommendationResponse,
|
||||
type ClauseRewriteResponse,
|
||||
} from "@/services/clause";
|
||||
@@ -419,16 +421,100 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
}
|
||||
};
|
||||
|
||||
const removeRecommendationFromList = useCallback((target: ClauseRecommendationItem) => {
|
||||
setRecommendations((previous) => {
|
||||
if (!previous) {
|
||||
return previous;
|
||||
}
|
||||
const items = previous.items.filter((item) => item.id !== target.id);
|
||||
return {
|
||||
...previous,
|
||||
items,
|
||||
total: items.length,
|
||||
summary: {
|
||||
...previous.summary,
|
||||
recommended_count: items.length,
|
||||
recommendation_keys: items.map((item) => item.clause_key),
|
||||
missing_clause_keys: previous.summary.missing_clause_keys.filter((key) => key !== target.clause_key),
|
||||
required_missing_clause_keys: previous.summary.required_missing_clause_keys?.filter((key) => key !== target.clause_key),
|
||||
industry_practice_keys: previous.summary.industry_practice_keys?.filter((key) => key !== target.clause_key),
|
||||
industry_practice_count: items.filter((item) => item.recommendation_kind === "industry_practice").length,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recordRecommendationAction = useCallback(async (
|
||||
item: ClauseRecommendationItem,
|
||||
actionStatus: ClauseRecommendationActionStatus,
|
||||
payload: {
|
||||
action_note?: string;
|
||||
applied_contract_version_id?: string | null;
|
||||
action_detail?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
if (!item.recommendation_record_id) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const response = await recordClauseRecommendationAction(item.recommendation_record_id, {
|
||||
action_status: actionStatus,
|
||||
action_note: payload.action_note,
|
||||
applied_contract_version_id: payload.applied_contract_version_id,
|
||||
action_detail: payload.action_detail,
|
||||
});
|
||||
if (response.code !== 0) {
|
||||
messageApi.warning(response.message || "推荐行为记录失败,请稍后重试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
messageApi.warning(getErrorMessage(error, "推荐行为记录失败,请稍后重试"));
|
||||
return false;
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
const handleApplyRecommendation = async (item: ClauseRecommendationItem) => {
|
||||
const nextContent = applyRecommendationText(editorContent, item);
|
||||
setEditorContent(nextContent);
|
||||
setRewriteResult(null);
|
||||
await saveVersion(nextContent, {
|
||||
const savedVersion = await saveVersion(nextContent, {
|
||||
changeType: "clause_recommendation",
|
||||
changeSummary: `插入推荐条款:${item.clause_title}`,
|
||||
originalText: item.insert_position.anchor_text ?? "",
|
||||
replacementText: item.suggested_text,
|
||||
});
|
||||
if (!savedVersion) {
|
||||
return;
|
||||
}
|
||||
const recorded = await recordRecommendationAction(item, "applied", {
|
||||
action_note: "插入正文并生成版本",
|
||||
applied_contract_version_id: savedVersion.id,
|
||||
action_detail: {
|
||||
clause_key: item.clause_key,
|
||||
clause_title: item.clause_title,
|
||||
recommendation_kind: item.recommendation_kind,
|
||||
change_type: "clause_recommendation",
|
||||
},
|
||||
});
|
||||
if (recorded) {
|
||||
removeRecommendationFromList(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnoreRecommendation = async (item: ClauseRecommendationItem) => {
|
||||
const recorded = await recordRecommendationAction(item, "ignored", {
|
||||
action_note: "用户在编辑器中忽略推荐",
|
||||
action_detail: {
|
||||
clause_key: item.clause_key,
|
||||
clause_title: item.clause_title,
|
||||
recommendation_kind: item.recommendation_kind,
|
||||
},
|
||||
});
|
||||
if (recorded) {
|
||||
removeRecommendationFromList(item);
|
||||
messageApi.success("已忽略该条款推荐");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewrite = async () => {
|
||||
@@ -719,9 +805,14 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis
|
||||
<Text type="secondary">
|
||||
{item.practice_basis ?? item.legal_basis}
|
||||
</Text>
|
||||
<Button size="small" type="primary" onClick={() => void handleApplyRecommendation(item)}>
|
||||
插入正文并生成版本
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Button size="small" type="primary" onClick={() => void handleApplyRecommendation(item)}>
|
||||
插入正文并生成版本
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void handleIgnoreRecommendation(item)}>
|
||||
忽略
|
||||
</Button>
|
||||
</Space>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,10 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
recommendMissingClauses,
|
||||
recordClauseRecommendationAction,
|
||||
rewriteClauseWording,
|
||||
type ClauseRecommendationActionStatus,
|
||||
type ClauseRecommendationItem,
|
||||
type ClauseRecommendationResponse,
|
||||
type ClauseRewriteResponse,
|
||||
} from "@/services/clause";
|
||||
@@ -473,6 +476,89 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
|
||||
}
|
||||
}, [messageApi, typeCode]);
|
||||
|
||||
const removeClauseRecommendationFromList = useCallback((target: ClauseRecommendationItem) => {
|
||||
setClauseRecommendations((previous) => {
|
||||
if (!previous) {
|
||||
return previous;
|
||||
}
|
||||
const items = previous.items.filter((item) => item.id !== target.id);
|
||||
return {
|
||||
...previous,
|
||||
items,
|
||||
total: items.length,
|
||||
summary: {
|
||||
...previous.summary,
|
||||
recommended_count: items.length,
|
||||
recommendation_keys: items.map((item) => item.clause_key),
|
||||
missing_clause_keys: previous.summary.missing_clause_keys.filter((key) => key !== target.clause_key),
|
||||
required_missing_clause_keys: previous.summary.required_missing_clause_keys?.filter((key) => key !== target.clause_key),
|
||||
industry_practice_keys: previous.summary.industry_practice_keys?.filter((key) => key !== target.clause_key),
|
||||
industry_practice_count: items.filter((item) => item.recommendation_kind === "industry_practice").length,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const recordRecommendationAction = useCallback(async (
|
||||
item: ClauseRecommendationItem,
|
||||
actionStatus: ClauseRecommendationActionStatus,
|
||||
payload: {
|
||||
action_note?: string;
|
||||
applied_contract_version_id?: string | null;
|
||||
action_detail?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
if (!item.recommendation_record_id) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const response = await recordClauseRecommendationAction(item.recommendation_record_id, {
|
||||
action_status: actionStatus,
|
||||
action_note: payload.action_note,
|
||||
applied_contract_version_id: payload.applied_contract_version_id,
|
||||
action_detail: payload.action_detail,
|
||||
});
|
||||
if (response.code !== 0) {
|
||||
messageApi.warning(response.message || "推荐行为记录失败,请稍后重试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
messageApi.warning(getErrorMessage(error, "推荐行为记录失败,请稍后重试"));
|
||||
return false;
|
||||
}
|
||||
}, [messageApi]);
|
||||
|
||||
const handleUseRecommendationForRewrite = async (item: ClauseRecommendationItem) => {
|
||||
await recordRecommendationAction(item, "modified", {
|
||||
action_note: "从文书生成结果进入措辞优化",
|
||||
action_detail: {
|
||||
clause_key: item.clause_key,
|
||||
clause_title: item.clause_title,
|
||||
recommendation_kind: item.recommendation_kind,
|
||||
target: "clause_rewrite_panel",
|
||||
},
|
||||
});
|
||||
setClauseRewriteText(item.suggested_text);
|
||||
setClauseRewriteResult(null);
|
||||
messageApi.success("已放入措辞优化区");
|
||||
};
|
||||
|
||||
const handleIgnoreRecommendation = async (item: ClauseRecommendationItem) => {
|
||||
const recorded = await recordRecommendationAction(item, "ignored", {
|
||||
action_note: "用户在生成结果中忽略推荐",
|
||||
action_detail: {
|
||||
clause_key: item.clause_key,
|
||||
clause_title: item.clause_title,
|
||||
recommendation_kind: item.recommendation_kind,
|
||||
},
|
||||
});
|
||||
if (recorded) {
|
||||
removeClauseRecommendationFromList(item);
|
||||
messageApi.success("已忽略该条款推荐");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewriteClause = async () => {
|
||||
const clauseText = clauseRewriteText.trim();
|
||||
if (!clauseText) {
|
||||
@@ -819,13 +905,13 @@ export default function LegalDocumentBuilderPage({ params }: { params: Promise<{
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setClauseRewriteText(item.suggested_text);
|
||||
setClauseRewriteResult(null);
|
||||
}}
|
||||
onClick={() => void handleUseRecommendationForRewrite(item)}
|
||||
>
|
||||
优化此条款
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void handleIgnoreRecommendation(item)}>
|
||||
忽略
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{item.insert_position.description}</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getOperationKpiDashboard } from "@/services/operation-kpi";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import type {
|
||||
OperationClauseRecommendationTopItem,
|
||||
OperationKpiDashboard,
|
||||
OperationKpiPeriodKey,
|
||||
OperationOrderStatusDistributionItem,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
const actionStatusMeta: Record<string, { label: string; color: string }> = {
|
||||
shown: { label: "已展示", color: "default" },
|
||||
applied: { label: "已插入正文", color: "green" },
|
||||
adopted: { label: "已采纳", color: "green" },
|
||||
modified: { label: "已优化后采纳", color: "blue" },
|
||||
ignored: { label: "已忽略", color: "default" },
|
||||
@@ -150,6 +153,32 @@ export default function OperationKpisPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const recommendationColumns: ColumnsType<OperationClauseRecommendationTopItem> = [
|
||||
{
|
||||
title: "推荐条款",
|
||||
key: "clause",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text strong>{item.clause_title || item.clause_key}</Text>
|
||||
<Text type="secondary">{item.recommendation_kind}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "处理状态",
|
||||
dataIndex: "action_status",
|
||||
key: "action_status",
|
||||
width: 140,
|
||||
render: (value: string) => statusTag(value),
|
||||
},
|
||||
{
|
||||
title: "次数",
|
||||
dataIndex: "action_count",
|
||||
key: "action_count",
|
||||
width: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const planColumns: ColumnsType<OperationPlanDistributionItem> = [
|
||||
{
|
||||
title: "套餐",
|
||||
@@ -224,6 +253,7 @@ export default function OperationKpisPage() {
|
||||
const conversion = dashboard?.conversion_funnel;
|
||||
const overage = dashboard?.overage_conversion;
|
||||
const clauseSummary = dashboard?.clause_action_summary;
|
||||
const recommendationFunnel = dashboard?.clause_recommendation_funnel;
|
||||
|
||||
return (
|
||||
<main className="page-shell review-page-grid">
|
||||
@@ -383,6 +413,33 @@ export default function OperationKpisPage() {
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="条款推荐行为漏斗" loading={loading}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div className="landing-stats">
|
||||
<Card className="landing-stat-card">
|
||||
<Statistic title="推荐展示" value={recommendationFunnel?.total ?? 0} />
|
||||
</Card>
|
||||
<Card className="landing-stat-card">
|
||||
<Statistic title="插入正文" value={recommendationFunnel?.applied_count ?? 0} />
|
||||
</Card>
|
||||
<Card className="landing-stat-card">
|
||||
<Statistic title="进入优化" value={recommendationFunnel?.modified_count ?? 0} />
|
||||
</Card>
|
||||
<Card className="landing-stat-card">
|
||||
<Statistic title="推荐采纳率" value={recommendationFunnel?.adoption_rate_percent ?? 0} suffix="%" precision={2} />
|
||||
</Card>
|
||||
</div>
|
||||
<Progress percent={recommendationFunnel?.action_rate_percent ?? 0} status="active" />
|
||||
<Table
|
||||
rowKey={(item) => `${item.clause_key}-${item.recommendation_kind}-${item.action_status}`}
|
||||
columns={recommendationColumns}
|
||||
dataSource={dashboard?.clause_recommendation_top_items ?? []}
|
||||
pagination={{ pageSize: 5 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无条款推荐行为数据" /> }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="review-surface-card" title="法条引用质量" loading={loading}>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ClauseRecommendationInsertPosition {
|
||||
|
||||
export interface ClauseRecommendationItem {
|
||||
id: string;
|
||||
recommendation_record_id?: string | null;
|
||||
document_type_code: string;
|
||||
clause_key: string;
|
||||
clause_title: string;
|
||||
@@ -41,6 +42,7 @@ export interface ClauseRecommendationItem {
|
||||
|
||||
export interface ClauseRecommendationResponse {
|
||||
contract_id?: string | null;
|
||||
recommendation_run_id?: string | null;
|
||||
document_type_code: string;
|
||||
contract_type?: string | null;
|
||||
items: ClauseRecommendationItem[];
|
||||
@@ -56,10 +58,32 @@ export interface ClauseRecommendationResponse {
|
||||
industry_practice_count?: number;
|
||||
source_count: number;
|
||||
strategy: string;
|
||||
funnel_recorded_count?: number;
|
||||
};
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export type ClauseRecommendationActionStatus = "shown" | "applied" | "ignored" | "modified";
|
||||
|
||||
export interface ClauseRecommendationActionResponse {
|
||||
id: string;
|
||||
recommendation_run_id: string;
|
||||
contract_id?: string | null;
|
||||
document_type_code: string;
|
||||
contract_type?: string | null;
|
||||
clause_key: string;
|
||||
clause_title: string;
|
||||
recommendation_kind: string;
|
||||
priority: string;
|
||||
action_status: ClauseRecommendationActionStatus | string;
|
||||
action_note?: string | null;
|
||||
applied_contract_version_id?: string | null;
|
||||
action_detail?: Record<string, unknown> | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
acted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface ClauseRewriteChange {
|
||||
from: string;
|
||||
to: string;
|
||||
@@ -122,3 +146,19 @@ export async function rewriteClauseWording(
|
||||
const response = await http.post<ApiResponse<ClauseRewriteResponse>>("/clauses/rewrite", payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function recordClauseRecommendationAction(
|
||||
recommendationId: string,
|
||||
payload: {
|
||||
action_status: ClauseRecommendationActionStatus;
|
||||
action_note?: string | null;
|
||||
applied_contract_version_id?: string | null;
|
||||
action_detail?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<ApiResponse<ClauseRecommendationActionResponse>> {
|
||||
const response = await http.post<ApiResponse<ClauseRecommendationActionResponse>>(
|
||||
`/clauses/recommendations/${recommendationId}/action`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -757,6 +757,12 @@ export type OperationKpiPeriodMetrics = {
|
||||
clause_action_applied: number;
|
||||
clause_action_ignored: number;
|
||||
clause_action_adoption_rate_percent: number;
|
||||
clause_recommendation_shown_count?: number;
|
||||
clause_recommendation_applied_count?: number;
|
||||
clause_recommendation_modified_count?: number;
|
||||
clause_recommendation_ignored_count?: number;
|
||||
clause_recommendation_adoption_rate_percent?: number;
|
||||
clause_recommendation_action_rate_percent?: number;
|
||||
legal_reference_count: number;
|
||||
legal_reference_verified_rate_percent: number;
|
||||
};
|
||||
@@ -796,6 +802,27 @@ export type OperationRiskActionTopItem = {
|
||||
action_count: number;
|
||||
};
|
||||
|
||||
export type OperationClauseRecommendationFunnel = {
|
||||
total: number;
|
||||
by_status?: Record<string, number>;
|
||||
shown_only_count: number;
|
||||
applied_count: number;
|
||||
modified_count: number;
|
||||
ignored_count: number;
|
||||
adopted_count: number;
|
||||
acted_count: number;
|
||||
adoption_rate_percent: number;
|
||||
action_rate_percent: number;
|
||||
};
|
||||
|
||||
export type OperationClauseRecommendationTopItem = {
|
||||
clause_title: string;
|
||||
clause_key: string;
|
||||
recommendation_kind: string;
|
||||
action_status: string;
|
||||
action_count: number;
|
||||
};
|
||||
|
||||
export type OperationLegalCitationSummary = {
|
||||
review_results_scanned?: number;
|
||||
review_reports_with_legal_refs?: number;
|
||||
@@ -864,6 +891,8 @@ export type OperationKpiDashboard = {
|
||||
top_templates: OperationTopTemplate[];
|
||||
clause_action_summary: OperationClauseActionSummary;
|
||||
risk_action_top_items: OperationRiskActionTopItem[];
|
||||
clause_recommendation_funnel?: OperationClauseRecommendationFunnel;
|
||||
clause_recommendation_top_items?: OperationClauseRecommendationTopItem[];
|
||||
legal_citation_summary: OperationLegalCitationSummary;
|
||||
conversion_funnel: OperationConversionFunnel;
|
||||
overage_conversion: OperationOverageConversion;
|
||||
|
||||
@@ -4449,3 +4449,43 @@
|
||||
- 3.90 服务器本身没有故障。
|
||||
- 这次问题已经从“手动恢复”提升到“部署脚本后续重建也保留自启动策略”。
|
||||
- 2026-05-22 远程 HTTP 冒烟已补齐。
|
||||
|
||||
## 2026-05-22 条款推荐行为漏斗完整闭环
|
||||
|
||||
- 本轮目标:
|
||||
- 把“条款推荐”从实时展示能力补成可追踪、可审计、可运营分析的行为漏斗。
|
||||
- 避免 `/operation-kpis` 继续把审查风险建议处理数据当成条款推荐采纳率。
|
||||
- 后端新增能力:
|
||||
- 新增模型 `ClauseRecommendation`。
|
||||
- 新增表 `clause_recommendations`,迁移版本 `20260522_0039_create_clause_recommendations.py`。
|
||||
- `/api/v1/clauses/recommend` 现在返回 `recommendation_run_id` 和每条推荐的 `recommendation_record_id`。
|
||||
- 推荐展示即落库为 `shown`,并保存来源规则、推荐类型、法条引用、Prompt 运行证据和推荐正文。
|
||||
- 新增 `/api/v1/clauses/recommendations/{recommendation_id}/action`:
|
||||
- 支持 `shown`、`applied`、`ignored`、`modified`。
|
||||
- 支持记录动作说明、应用到的合同版本 ID 和动作详情。
|
||||
- 动作更新会写入审计日志 `record_clause_recommendation_action`。
|
||||
- 前端新增能力:
|
||||
- `/documents/[id]/edit`
|
||||
- “插入正文并生成版本”后记录 `applied`,并关联新生成版本。
|
||||
- 新增“忽略”按钮,记录 `ignored`。
|
||||
- `/documents/new/[type]`
|
||||
- “优化此条款”记录 `modified`。
|
||||
- 新增“忽略”按钮,记录 `ignored`。
|
||||
- `/operation-kpis`
|
||||
- 新增“条款推荐行为漏斗”卡片。
|
||||
- 展示推荐展示、插入正文、进入优化、忽略、采纳率、动作率和 Top 条款行为明细。
|
||||
- 本地验证:
|
||||
- `python -m compileall backend\app\models\clause_recommendation.py backend\app\api\clauses.py backend\app\services\clause_recommendation_service.py backend\app\services\operation_kpi_service.py`
|
||||
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_clause_recommendation_service backend.tests.test_clauses_api backend.tests.test_operation_kpi_service backend.tests.test_operation_kpis_api`
|
||||
- 结果:`Ran 16 tests in 0.481s`,`OK`。
|
||||
- `cd frontend; npx eslint "src/app/documents/[id]/edit/page.tsx" "src/app/documents/new/[type]/page.tsx" "src/app/operation-kpis/page.tsx" "src/services/clause.ts" "src/types/api.ts"`
|
||||
- `cd frontend; npm run build`
|
||||
- 构建结果:`Compiled successfully`,静态页面生成 `40/40`。
|
||||
- 文档更新:
|
||||
- 已更新 `project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐复核-2026-05-22.md`。
|
||||
- 旧结论“条款推荐完整行为漏斗未沉淀”已修订为“条款推荐行为漏斗已闭环”。
|
||||
- 当前结论:
|
||||
- 条款推荐已形成“推荐生成 -> 展示落库 -> 采纳/忽略/进入优化动作记录 -> 合同版本关联 -> 审计日志 -> 运营 KPI 漏斗展示”的完整闭环。
|
||||
- 后续部署要求:
|
||||
- 测试服务器需要执行 Alembic 到 `20260522_0039`。
|
||||
- 需要重新部署后端和前端后,再做 `/operation-kpis`、`/documents/new/[type]`、`/documents/[id]/edit` 远程烟测。
|
||||
|
||||
@@ -184,3 +184,28 @@ Generating static pages (40/40)
|
||||
|
||||
1. 先恢复 `192.168.3.90` 应用容器,补当天远程冒烟。
|
||||
2. 再按业务价值选择增强项:条款推荐行为漏斗、通用质量评测域、专业交付增强、全量法规向量化、真实支付、电子签章或小程序。
|
||||
|
||||
## 九、2026-05-22 同日修订:条款推荐行为漏斗已闭环
|
||||
|
||||
本轮已把上文“条款推荐完整行为漏斗未沉淀”的增强项补齐,因此该项不再作为未完成事项保留。
|
||||
|
||||
已完成内容:
|
||||
|
||||
1. 新增 `clause_recommendations` 持久化表和 Alembic `20260522_0039`,记录每条推荐的展示、来源、Prompt 运行证据、法条引用和后续动作状态。
|
||||
2. `/api/v1/clauses/recommend` 返回 `recommendation_run_id` 和每条推荐的 `recommendation_record_id`,推荐展示即落库为 `shown`。
|
||||
3. 新增 `/api/v1/clauses/recommendations/{recommendation_id}/action`,支持记录 `applied`、`ignored`、`modified` 和版本号/动作详情,并写入审计日志。
|
||||
4. `/documents/[id]/edit` 已在“插入正文并生成版本”时记录 `applied`,在“忽略”时记录 `ignored`。
|
||||
5. `/documents/new/[type]` 已在“优化此条款”时记录 `modified`,在“忽略”时记录 `ignored`。
|
||||
6. `/operation-kpis` 已新增“条款推荐行为漏斗”,独立展示推荐展示数、插入正文数、进入优化数、忽略数、采纳率和动作率,不再把审查风险处理数据混用为条款推荐采纳率。
|
||||
|
||||
本地验证结果:
|
||||
|
||||
- `python -m compileall backend\app\models\clause_recommendation.py backend\app\api\clauses.py backend\app\services\clause_recommendation_service.py backend\app\services\operation_kpi_service.py`:通过。
|
||||
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_clause_recommendation_service backend.tests.test_clauses_api backend.tests.test_operation_kpi_service backend.tests.test_operation_kpis_api`:`Ran 16 tests`,`OK`。
|
||||
- `cd frontend; npx eslint "src/app/documents/[id]/edit/page.tsx" "src/app/documents/new/[type]/page.tsx" "src/app/operation-kpis/page.tsx" "src/services/clause.ts" "src/types/api.ts"`:通过。
|
||||
- `cd frontend; npm run build`:`Compiled successfully`,静态页面生成 `40/40`。
|
||||
|
||||
新的剩余边界:
|
||||
|
||||
1. 远程测试服务器部署后需要执行 Alembic 到 `20260522_0039`,并重新部署后端、前端。
|
||||
2. 通用质量评测域、全量法规向量化、真实支付、电子签章或小程序仍属于后续增强项,不是当前 Phase15 主线代码缺口。
|
||||
|
||||
Reference in New Issue
Block a user