diff --git a/app/api/v1/conversations.py b/app/api/v1/conversations.py index d4bac29..e311284 100644 --- a/app/api/v1/conversations.py +++ b/app/api/v1/conversations.py @@ -74,6 +74,22 @@ async def send_conversation_message( ) +@router.delete("/{conversation_id}/messages/{message_id}", response_model=ApiResponse[ConversationData]) +async def withdraw_conversation_message( + conversation_id: int, + message_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[ConversationData]: + return ApiResponse( + data=await ConversationService(session).withdraw_turn( + conversation_id, + message_id, + current_user, + ) + ) + + @router.post("/{conversation_id}/stream") async def stream_conversation_message( conversation_id: int, diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 6926af1..9af0d2b 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -85,6 +85,24 @@ class SimilarCase(ApiSchema): similarity: float | None = None +class AiSolutionRisk(ApiSchema): + level: str + title: str + detail: str + + +class AiSolutionReport(ApiSchema): + title: str = "AI 案例推理生成" + confidence: str | None = None + analysis_base: str | None = None + standard_fallback_enabled: bool = False + standards: list[StandardJudgment] = Field(default_factory=list) + similar_cases: list[SimilarCase] = Field(default_factory=list) + risks: list[AiSolutionRisk] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + warning: str | None = None + + class RiskAssessmentResult(ApiSchema): subject_id: str score: float @@ -99,6 +117,7 @@ class RiskAssessmentResult(ApiSchema): dimension_scores: list[RiskDimensionScore] = Field(default_factory=list) similar_cases: list[SimilarCase] = Field(default_factory=list) ai_suggestions: list[str] = Field(default_factory=list) + ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list) class ModelInfo(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 33dfc67..90579df 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -7,11 +7,18 @@ from collections.abc import AsyncGenerator, Sequence from datetime import UTC, date, datetime, timedelta from typing import Any, Literal -from sqlalchemy import func, or_, select +from sqlalchemy import delete, func, or_, select from app.core.exceptions import AppException from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue, RiskLevel -from app.models.entities import AnomalyCase, Conversation, ConversationMessage, StandardRule, UserAccount +from app.models.entities import ( + AnomalyCase, + Conversation, + ConversationMessage, + OperationAudit, + StandardRule, + UserAccount, +) from app.repositories.conversations import ConversationRepository from app.schemas.conversation import ( ConversationData, @@ -202,7 +209,10 @@ class ConversationService: await self.session.flush() await self.session.commit() - yield {"type": "start", "data": {"conversationId": conversation.id}} + yield { + "type": "start", + "data": {"conversationId": conversation.id, "messageId": user_message.id}, + } try: semantics = await self.llm.extract_semantics( text, @@ -567,6 +577,7 @@ class ConversationService: if hasattr(self, "session") else None ) + has_order_data = bool(cooperation_count) cooperation_display = cooperation_count if has_order_data else None problems = await self._top_anomaly_types( @@ -614,6 +625,46 @@ class ConversationService: "dataAvailable": has_order_data, } + async def withdraw_turn( + self, + conversation_id: int, + message_id: int, + user: UserAccount, + ) -> ConversationData: + """Delete one user turn and its immediately paired assistant reply.""" + + conversation = await self.repository.get_for_user(conversation_id, user.id) + if conversation is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + message = next((item for item in conversation.messages if item.id == message_id), None) + if message is None or message.role != "user": + raise AppException("只能撤回用户消息", code=422, status_code=422) + messages = sorted(conversation.messages, key=lambda item: (item.created_at, item.id)) + index = messages.index(message) + ids = [message.id] + if index + 1 < len(messages) and messages[index + 1].role == "assistant": + ids.append(messages[index + 1].id) + await self.session.execute( + delete(ConversationMessage).where(ConversationMessage.id.in_(ids)) + ) + conversation.updated_at = datetime.now(UTC) + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="conversation.withdraw", + resource_type="conversation_message", + resource_id=str(message_id), + audit_metadata={"deleted_message_ids": ids}, + ) + ) + await self.session.flush() + page = await self.repository.get_page_for_user(conversation_id, user.id, limit=20, offset=0) + if page is None: + raise AppException("对话读取失败", code=500, status_code=500) + persisted, page_messages, total = page + return self._data(persisted, messages=page_messages, total=total, offset=0) + async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) material = material or self._semantic_text(semantics, "packaging") or "未指定物料" @@ -790,7 +841,10 @@ class ConversationService: ), "standardFallbackEnabled": standard_fallback_enabled, "standards": [self._standard_dict(item) for item in standards[:4]], - "similarCases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases], + "similarCases": [ + item.case.model_dump(mode="json", by_alias=False) + for item in similar_cases + ], "risks": risks, "suggestions": suggestions, "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。",