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

新增 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")
async def stream_conversation_message(
conversation_id: int,

View File

@ -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):

View File

@ -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 推理生成,实际执行前请咨询品控部门确认。",