完善会话报告与消息撤回控制

新增 AI 案例推理方案报告字段,支持方案追加到当前报告;增加会话消息成对撤回接口并记录审计日志;扩展流式响应消息标识,支持前端停止生成与撤回处理。
This commit is contained in:
kx_guo 2026-09-09 15:39:59 +08:00
parent 5c7163d79a
commit 9e13c95ba8
3 changed files with 93 additions and 4 deletions

View File

@ -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") @router.post("/{conversation_id}/stream")
async def stream_conversation_message( async def stream_conversation_message(
conversation_id: int, conversation_id: int,

View File

@ -85,6 +85,24 @@ class SimilarCase(ApiSchema):
similarity: float | None = None 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): class RiskAssessmentResult(ApiSchema):
subject_id: str subject_id: str
score: float score: float
@ -99,6 +117,7 @@ class RiskAssessmentResult(ApiSchema):
dimension_scores: list[RiskDimensionScore] = Field(default_factory=list) dimension_scores: list[RiskDimensionScore] = Field(default_factory=list)
similar_cases: list[SimilarCase] = Field(default_factory=list) similar_cases: list[SimilarCase] = Field(default_factory=list)
ai_suggestions: list[str] = Field(default_factory=list) ai_suggestions: list[str] = Field(default_factory=list)
ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list)
class ModelInfo(ApiSchema): class ModelInfo(ApiSchema):

View File

@ -7,11 +7,18 @@ from collections.abc import AsyncGenerator, Sequence
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Any, Literal 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.core.exceptions import AppException
from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue, RiskLevel 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.repositories.conversations import ConversationRepository
from app.schemas.conversation import ( from app.schemas.conversation import (
ConversationData, ConversationData,
@ -202,7 +209,10 @@ class ConversationService:
await self.session.flush() await self.session.flush()
await self.session.commit() await self.session.commit()
yield {"type": "start", "data": {"conversationId": conversation.id}} yield {
"type": "start",
"data": {"conversationId": conversation.id, "messageId": user_message.id},
}
try: try:
semantics = await self.llm.extract_semantics( semantics = await self.llm.extract_semantics(
text, text,
@ -567,6 +577,7 @@ class ConversationService:
if hasattr(self, "session") if hasattr(self, "session")
else None else None
) )
has_order_data = bool(cooperation_count) has_order_data = bool(cooperation_count)
cooperation_display = cooperation_count if has_order_data else None cooperation_display = cooperation_count if has_order_data else None
problems = await self._top_anomaly_types( problems = await self._top_anomaly_types(
@ -614,6 +625,46 @@ class ConversationService:
"dataAvailable": has_order_data, "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]: async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET"))
material = material or self._semantic_text(semantics, "packaging") or "未指定物料" material = material or self._semantic_text(semantics, "packaging") or "未指定物料"
@ -790,7 +841,10 @@ class ConversationService:
), ),
"standardFallbackEnabled": standard_fallback_enabled, "standardFallbackEnabled": standard_fallback_enabled,
"standards": [self._standard_dict(item) for item in standards[:4]], "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, "risks": risks,
"suggestions": suggestions, "suggestions": suggestions,
"warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。",