feat: add clause recommendation funnel

This commit is contained in:
2026-05-22 08:05:01 +08:00
parent ca17e1f09d
commit 748aab08e0
17 changed files with 1073 additions and 11 deletions
+46 -1
View File
@@ -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
View File
@@ -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()
+45
View File
@@ -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()
+114
View File
@@ -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)
+26
View File
@@ -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},
+95 -4
View File
@@ -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>
+90 -4
View File
@@ -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>
+57
View File
@@ -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}>
+40
View File
@@ -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;
}
+29
View File
@@ -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 主线代码缺口。