"""任务中心领域服务,负责活动任务去重、SLA 截止时间和自动完成。""" from datetime import timedelta from django.conf import settings from django.db import transaction from django.utils import timezone from apps.tickets.models import Ticket, TicketDraft, TicketSupportAnalysis from apps.workitems.models import WorkItem, WorkItemStatus, WorkItemType def work_item_due_at(task_type: str): hours_by_type = { WorkItemType.TICKET_INTAKE_FAILED: settings.WORK_ITEM_INTAKE_FAILURE_SLA_HOURS, WorkItemType.SUPPORT_ANALYSIS_FAILED: settings.WORK_ITEM_SUPPORT_FAILURE_SLA_HOURS, WorkItemType.TICKET_CUSTOMER_REPLY: settings.WORK_ITEM_CUSTOMER_REPLY_SLA_HOURS, WorkItemType.TICKET_CLOSE: settings.WORK_ITEM_TICKET_CLOSE_SLA_HOURS, } hours = max(int(hours_by_type.get(task_type, settings.WORK_ITEM_DEFAULT_SLA_HOURS)), 1) return timezone.now() + timedelta(hours=hours) @transaction.atomic def sync_asset_review_work_item(version, *, actor=None) -> WorkItem | None: """按资产版本状态创建、更新或完成唯一审核任务。""" from apps.knowledge.models import GovernedAssetVersionStatus active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.ASSET_REVIEW, object_type="governed_asset_version", object_id=str(version.id), status=WorkItemStatus.ACTIVE, ).first() if version.status != GovernedAssetVersionStatus.PENDING_REVIEW: if active is not None: active.status = WorkItemStatus.COMPLETED active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "asset_public_id": str(version.asset.public_id), "asset_type": version.asset.asset_type, "object_id": version.asset.object_id, "version": version.version, "submitted_by_id": version.submitted_by_id, } if active is None: return WorkItem.objects.create( task_type=WorkItemType.ASSET_REVIEW, object_type="governed_asset_version", object_id=str(version.id), title=f"审核{version.asset.get_asset_type_display()} v{version.version}", description="知识资产已提交审核,发布前需由非提交人核对内容与适用范围。", payload=payload, created_by=actor, due_at=work_item_due_at(WorkItemType.ASSET_REVIEW), ) active.payload = payload active.save(update_fields=["payload", "updated_at"]) return active @transaction.atomic def sync_knowledge_distillation_work_item( ticket: Ticket, *, run=None, error_code: str = "", error_message: str = "", actor=None, ) -> WorkItem | None: """根据缺失字段同步唯一待补全任务,无缺失时自动完成。""" ticket = Ticket.objects.select_for_update().get(id=ticket.id) active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.KNOWLEDGE_DISTILLATION_FAILED, object_type="ticket", object_id=str(ticket.id), status=WorkItemStatus.ACTIVE, ).first() failed = bool(error_code) or bool(run and run.status == "failed") if not failed: if active is not None: active.status = WorkItemStatus.COMPLETED active.completed_by = actor or (run.created_by if run else None) active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "agent_run_public_id": str(run.public_id) if run is not None else "", "error_code": error_code or (run.error_code if run is not None else ""), "error_message": error_message or (run.error_message if run is not None else ""), } if active is None: return WorkItem.objects.create( task_type=WorkItemType.KNOWLEDGE_DISTILLATION_FAILED, object_type="ticket", object_id=str(ticket.id), ticket=ticket, title=f"接管工单 {ticket.ticket_no} 知识沉淀", description="知识价值评估或草稿生成未完成,需人工判断是否沉淀知识。", payload=payload, assignee=ticket.assignee, created_by=actor or (run.created_by if run is not None else None), due_at=work_item_due_at(WorkItemType.KNOWLEDGE_DISTILLATION_FAILED), ) active.payload = payload active.assignee = ticket.assignee active.save(update_fields=["payload", "assignee", "updated_at"]) return active @transaction.atomic def sync_tool_confirmation_work_item(call, *, actor=None) -> WorkItem | None: active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TOOL_CONFIRMATION, object_type="tool_call", object_id=str(call.public_id), status=WorkItemStatus.ACTIVE, ).first() if call.status != "pending_confirmation": if active is not None: active.status = ( WorkItemStatus.CANCELLED if call.status == "rejected" else WorkItemStatus.COMPLETED ) active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "tool_call_public_id": str(call.public_id), "tool_code": call.tool.code, "risk_level": call.tool.risk_level, "requested_by_id": call.requested_by_id, "agent_run_public_id": ( str(call.agent_run.public_id) if call.agent_run_id else "" ), } if active is None: return WorkItem.objects.create( task_type=WorkItemType.TOOL_CONFIRMATION, object_type="tool_call", object_id=str(call.public_id), title=f"确认高风险工具:{call.tool.name}", description="工具调用已暂停,需由非申请人核对输入、风险和业务影响。", payload=payload, created_by=call.requested_by, due_at=work_item_due_at(WorkItemType.TOOL_CONFIRMATION), ) active.payload = payload active.save(update_fields=["payload", "updated_at"]) return active @transaction.atomic def sync_ticket_missing_info_work_item( ticket: Ticket, missing_fields: list[str], *, questions: list[str] | None = None, draft_public_id: str | None = None, actor=None, ) -> WorkItem | None: ticket = Ticket.objects.select_for_update().get(id=ticket.id) active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TICKET_MISSING_INFO, object_type="ticket", object_id=str(ticket.id), status=WorkItemStatus.ACTIVE, ).first() normalized_missing = sorted({str(code) for code in missing_fields if str(code)}) if ticket.status != "pending_info" or not normalized_missing: if active is not None: active.status = WorkItemStatus.COMPLETED active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None previous_payload = active.payload if active is not None else {} payload = { "missing_fields": normalized_missing, "questions": ( [str(item) for item in questions if str(item).strip()] if questions is not None else previous_payload.get("questions", []) ), "draft_public_id": ( draft_public_id if draft_public_id is not None else previous_payload.get("draft_public_id", "") ), } if active is None: return WorkItem.objects.create( task_type=WorkItemType.TICKET_MISSING_INFO, object_type="ticket", object_id=str(ticket.id), ticket=ticket, title=f"补全工单 {ticket.ticket_no} 信息", description="该工单由智能受理创建,仍有业务信息需要补充。", payload=payload, assignee=ticket.assignee, created_by=actor, due_at=work_item_due_at(WorkItemType.TICKET_MISSING_INFO), ) active.payload = payload active.assignee = ticket.assignee active.save(update_fields=["payload", "assignee", "updated_at"]) return active def complete_ticket_missing_info_work_item(ticket: Ticket, *, actor=None) -> None: sync_ticket_missing_info_work_item(ticket, [], actor=actor) @transaction.atomic def sync_ticket_intake_failure_work_item( draft: TicketDraft, *, run=None, actor=None, ) -> WorkItem | None: draft = ( TicketDraft.objects.select_for_update() .select_related("created_by", "latest_agent_run") .get(id=draft.id) ) active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TICKET_INTAKE_FAILED, object_type="ticket_draft", object_id=str(draft.public_id), status=WorkItemStatus.ACTIVE, ).first() if draft.status != "failed": if active is not None: active.status = WorkItemStatus.COMPLETED active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None current_run = run or draft.latest_agent_run payload = { "draft_public_id": str(draft.public_id), "agent_run_public_id": ( str(current_run.public_id) if current_run is not None else "" ), "error_code": draft.error_code, "error_message": draft.error_message, } if active is None: return WorkItem.objects.create( task_type=WorkItemType.TICKET_INTAKE_FAILED, object_type="ticket_draft", object_id=str(draft.public_id), title="接管失败的智能受理", description="自动受理未完成,原始文本、材料和已提取字段仍可恢复。", payload=payload, assignee=draft.created_by, created_by=actor or draft.created_by, due_at=work_item_due_at(WorkItemType.TICKET_INTAKE_FAILED), ) active.payload = payload active.assignee = draft.created_by active.save(update_fields=["payload", "assignee", "updated_at"]) return active @transaction.atomic def complete_ticket_intake_failure_work_item( draft: TicketDraft, *, actor=None, cancelled: bool = False, ) -> None: active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TICKET_INTAKE_FAILED, object_type="ticket_draft", object_id=str(draft.public_id), status=WorkItemStatus.ACTIVE, ).first() if active is None: return active.status = WorkItemStatus.CANCELLED if cancelled else WorkItemStatus.COMPLETED active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) @transaction.atomic def sync_customer_reply_work_item(reply_task, *, actor=None) -> WorkItem | None: """同步唯一客户回复任务,并以回复任务状态作为完成依据。""" active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TICKET_CUSTOMER_REPLY, object_type="ticket", object_id=str(reply_task.ticket_id), status=WorkItemStatus.ACTIVE, ).first() if reply_task.status not in {"draft", "approved"}: if active is not None: active.status = ( WorkItemStatus.COMPLETED if reply_task.status == "sent" else WorkItemStatus.CANCELLED ) active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "reply_task_id": reply_task.id, "support_analysis_id": reply_task.support_analysis_id, "agent_run_public_id": ( str(reply_task.support_analysis.run.public_id) if reply_task.support_analysis_id else "" ), "revision": reply_task.revision, "reply_status": reply_task.status, } if active is None: return WorkItem.objects.create( task_type=WorkItemType.TICKET_CUSTOMER_REPLY, object_type="ticket", object_id=str(reply_task.ticket_id), ticket=reply_task.ticket, title=f"确认并发送工单 {reply_task.ticket.ticket_no} 客户回复", description="Agent 已生成回复草稿,需人工核对、编辑并确认已发送。", payload=payload, assignee=reply_task.ticket.assignee or reply_task.created_by, created_by=actor or reply_task.created_by, due_at=work_item_due_at(WorkItemType.TICKET_CUSTOMER_REPLY), ) active.payload = payload active.assignee = reply_task.ticket.assignee or reply_task.created_by active.title = ( f"等待工单 {reply_task.ticket.ticket_no} 渠道送达回执" if reply_task.status == "approved" else f"确认并发送工单 {reply_task.ticket.ticket_no} 客户回复" ) active.description = ( "回复版本已由人工确认并锁定,等待渠道送达;失败时使用原消息重试。" if reply_task.status == "approved" else "Agent 已生成回复草稿,需人工核对、编辑并确认已发送。" ) active.save( update_fields=["payload", "assignee", "title", "description", "updated_at"] ) return active @transaction.atomic def sync_ticket_close_work_item(ticket: Ticket, *, actor=None) -> WorkItem | None: """为已解决但未关闭工单维护唯一待关闭任务。""" ticket = Ticket.objects.select_for_update().get(id=ticket.id) active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.TICKET_CLOSE, object_type="ticket", object_id=str(ticket.id), status=WorkItemStatus.ACTIVE, ).first() if ticket.status != "resolved": if active is not None: active.status = ( WorkItemStatus.COMPLETED if ticket.status == "closed" else WorkItemStatus.CANCELLED ) active.completed_by = actor active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "resolved_at": ticket.resolved_at.isoformat() if ticket.resolved_at else "", } if active is None: return WorkItem.objects.create( task_type=WorkItemType.TICKET_CLOSE, object_type="ticket", object_id=str(ticket.id), ticket=ticket, title=f"关闭已解决工单 {ticket.ticket_no}", description="工单已有解决结论,需人工确认后关闭。", payload=payload, assignee=ticket.assignee, created_by=actor, due_at=work_item_due_at(WorkItemType.TICKET_CLOSE), ) active.payload = payload active.assignee = ticket.assignee active.save(update_fields=["payload", "assignee", "updated_at"]) return active @transaction.atomic def sync_support_analysis_work_item( run, *, error_code: str = "", error_message: str = "", ) -> WorkItem | None: try: analysis = run.ticket_support_analysis except TicketSupportAnalysis.DoesNotExist: return None active = WorkItem.objects.select_for_update().filter( task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED, object_type="ticket", object_id=str(analysis.ticket_id), status=WorkItemStatus.ACTIVE, ).first() failed = run.status == "failed" or bool(error_code) if not failed: if active is not None: active.status = WorkItemStatus.COMPLETED active.completed_by = run.created_by active.completed_at = timezone.now() active.save( update_fields=[ "status", "completed_by", "completed_at", "updated_at", ] ) return None payload = { "support_analysis_id": analysis.id, "agent_run_public_id": str(run.public_id), "error_code": error_code or run.error_code, "error_message": error_message or run.error_message, } if active is None: return WorkItem.objects.create( task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED, object_type="ticket", object_id=str(analysis.ticket_id), ticket=analysis.ticket, title=f"接管工单 {analysis.ticket.ticket_no} 支持分析", description="支持分析未形成可信结果,需人工排查或重新运行。", payload=payload, assignee=analysis.ticket.assignee or run.created_by, created_by=run.created_by, due_at=work_item_due_at(WorkItemType.SUPPORT_ANALYSIS_FAILED), ) active.payload = payload active.assignee = analysis.ticket.assignee or run.created_by active.save(update_fields=["payload", "assignee", "updated_at"]) return active