diff --git a/backend/app/api/clauses.py b/backend/app/api/clauses.py index 0d86b372..33e415ae 100644 --- a/backend/app/api/clauses.py +++ b/backend/app/api/clauses.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f844e29b..a18460ef 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/clause_recommendation.py b/backend/app/models/clause_recommendation.py new file mode 100644 index 00000000..96544ece --- /dev/null +++ b/backend/app/models/clause_recommendation.py @@ -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), + ) diff --git a/backend/app/services/clause_recommendation_service.py b/backend/app/services/clause_recommendation_service.py index 5973eca5..8d82008a 100644 --- a/backend/app/services/clause_recommendation_service.py +++ b/backend/app/services/clause_recommendation_service.py @@ -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) diff --git a/backend/app/services/operation_kpi_service.py b/backend/app/services/operation_kpi_service.py index be5a9cbe..9cbcb9c1 100644 --- a/backend/app/services/operation_kpi_service.py +++ b/backend/app/services/operation_kpi_service.py @@ -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) diff --git a/backend/migrations/versions/20260522_0039_create_clause_recommendations.py b/backend/migrations/versions/20260522_0039_create_clause_recommendations.py new file mode 100644 index 00000000..7ef907ac --- /dev/null +++ b/backend/migrations/versions/20260522_0039_create_clause_recommendations.py @@ -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") diff --git a/backend/tests/test_clause_recommendation_service.py b/backend/tests/test_clause_recommendation_service.py index 3e2315e2..0364d84f 100644 --- a/backend/tests/test_clause_recommendation_service.py +++ b/backend/tests/test_clause_recommendation_service.py @@ -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() diff --git a/backend/tests/test_clauses_api.py b/backend/tests/test_clauses_api.py index 788d6f0c..3789f1c1 100644 --- a/backend/tests/test_clauses_api.py +++ b/backend/tests/test_clauses_api.py @@ -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() diff --git a/backend/tests/test_operation_kpi_service.py b/backend/tests/test_operation_kpi_service.py index 9dfb7648..9e311388 100644 --- a/backend/tests/test_operation_kpi_service.py +++ b/backend/tests/test_operation_kpi_service.py @@ -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) diff --git a/backend/tests/test_operation_kpis_api.py b/backend/tests/test_operation_kpis_api.py index 0a8f3a2d..b4bc9082 100644 --- a/backend/tests/test_operation_kpis_api.py +++ b/backend/tests/test_operation_kpis_api.py @@ -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}, diff --git a/frontend/src/app/documents/[id]/edit/page.tsx b/frontend/src/app/documents/[id]/edit/page.tsx index c57563f3..44dd4b41 100644 --- a/frontend/src/app/documents/[id]/edit/page.tsx +++ b/frontend/src/app/documents/[id]/edit/page.tsx @@ -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; + }, + ) => { + 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 {item.practice_basis ?? item.legal_basis} - + + + + ))} diff --git a/frontend/src/app/documents/new/[type]/page.tsx b/frontend/src/app/documents/new/[type]/page.tsx index 4fdd6077..d8a0c060 100644 --- a/frontend/src/app/documents/new/[type]/page.tsx +++ b/frontend/src/app/documents/new/[type]/page.tsx @@ -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; + }, + ) => { + 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<{ + {item.insert_position.description} diff --git a/frontend/src/app/operation-kpis/page.tsx b/frontend/src/app/operation-kpis/page.tsx index 1815f645..815481e9 100644 --- a/frontend/src/app/operation-kpis/page.tsx +++ b/frontend/src/app/operation-kpis/page.tsx @@ -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 = { + 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 = [ + { + title: "推荐条款", + key: "clause", + render: (_, item) => ( + + {item.clause_title || item.clause_key} + {item.recommendation_kind} + + ), + }, + { + 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 = [ { 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 (
@@ -383,6 +413,33 @@ export default function OperationKpisPage() { /> + + + +
+ + + + + + + + + + + + +
+ + `${item.clause_key}-${item.recommendation_kind}-${item.action_status}`} + columns={recommendationColumns} + dataSource={dashboard?.clause_recommendation_top_items ?? []} + pagination={{ pageSize: 5 }} + locale={{ emptyText: }} + /> + + diff --git a/frontend/src/services/clause.ts b/frontend/src/services/clause.ts index b8f4f30a..3742bd1d 100644 --- a/frontend/src/services/clause.ts +++ b/frontend/src/services/clause.ts @@ -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 | 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>("/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 | null; + }, +): Promise> { + const response = await http.post>( + `/clauses/recommendations/${recommendationId}/action`, + payload, + ); + return response.data; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index fe9126f5..376ec656 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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; + 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; diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 6cb80464..f6d390c0 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -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` 远程烟测。 diff --git a/project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐复核-2026-05-22.md b/project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐复核-2026-05-22.md index 497bc7eb..b703c104 100644 --- a/project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐复核-2026-05-22.md +++ b/project-docs/AI合同起草与审查助手-法律文书能力吸收开发方案-代码对齐复核-2026-05-22.md @@ -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 主线代码缺口。