Files
ai-ticket-platform/backend/apps/workitems/services.py

517 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""任务中心领域服务,负责活动任务去重、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(of=("self",))
.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