feat: add intelligent support workflow
This commit is contained in:
1
backend/apps/workitems/__init__.py
Normal file
1
backend/apps/workitems/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
101
backend/apps/workitems/api.py
Normal file
101
backend/apps/workitems/api.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Count, F
|
||||
from django.utils import timezone
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.workitems.models import WorkItem
|
||||
from apps.workitems.schemas import WorkItemMetricsOut, WorkItemOut
|
||||
|
||||
router = Router(tags=["tasks"])
|
||||
|
||||
|
||||
def serialize_work_item(item: WorkItem) -> dict:
|
||||
now = timezone.now()
|
||||
if item.status != "active":
|
||||
completed_overdue = (
|
||||
item.due_at and item.completed_at and item.completed_at > item.due_at
|
||||
)
|
||||
sla_status = "completed_overdue" if completed_overdue else "completed"
|
||||
elif item.due_at and item.due_at <= now:
|
||||
sla_status = "overdue"
|
||||
elif item.due_at and item.due_at <= now + timedelta(hours=2):
|
||||
sla_status = "due_soon"
|
||||
else:
|
||||
sla_status = "on_track"
|
||||
return {
|
||||
"public_id": str(item.public_id),
|
||||
"task_type": item.task_type,
|
||||
"status": item.status,
|
||||
"object_type": item.object_type,
|
||||
"object_id": item.object_id,
|
||||
"ticket_id": item.ticket_id,
|
||||
"ticket_no": item.ticket.ticket_no if item.ticket_id else None,
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"payload": item.payload,
|
||||
"assignee_id": item.assignee_id,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"completed_at": item.completed_at,
|
||||
"due_at": item.due_at,
|
||||
"sla_status": sla_status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics", response=WorkItemMetricsOut)
|
||||
def get_work_item_metrics(request):
|
||||
require_permission(request, "ticket.view")
|
||||
now = timezone.now()
|
||||
active = WorkItem.objects.filter(status="active")
|
||||
completed = WorkItem.objects.filter(status="completed", completed_at__isnull=False)
|
||||
by_task_type = {
|
||||
item["task_type"]: item["total"]
|
||||
for item in active.values("task_type").annotate(total=Count("id"))
|
||||
}
|
||||
return {
|
||||
"active_count": active.count(),
|
||||
"overdue_count": active.filter(due_at__lte=now).count(),
|
||||
"due_soon_count": active.filter(
|
||||
due_at__gt=now,
|
||||
due_at__lte=now + timedelta(hours=2),
|
||||
).count(),
|
||||
"completed_within_sla_count": completed.filter(
|
||||
completed_at__lte=F("due_at")
|
||||
).count(),
|
||||
"completed_overdue_count": completed.filter(
|
||||
completed_at__gt=F("due_at")
|
||||
).count(),
|
||||
"by_task_type": by_task_type,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response=list[WorkItemOut])
|
||||
def list_work_items(
|
||||
request,
|
||||
status: str | None = "active",
|
||||
task_type: str | None = None,
|
||||
ticket_id: int | None = None,
|
||||
):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = WorkItem.objects.select_related("ticket", "assignee")
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if task_type:
|
||||
queryset = queryset.filter(task_type=task_type)
|
||||
if ticket_id:
|
||||
queryset = queryset.filter(ticket_id=ticket_id)
|
||||
return [serialize_work_item(item) for item in queryset[:200]]
|
||||
|
||||
|
||||
@router.get("/{public_id}", response=WorkItemOut)
|
||||
def get_work_item(request, public_id: str):
|
||||
require_permission(request, "ticket.view")
|
||||
item = WorkItem.objects.select_related("ticket", "assignee").filter(
|
||||
public_id=public_id
|
||||
).first()
|
||||
if item is None:
|
||||
raise HttpError(404, "任务不存在")
|
||||
return serialize_work_item(item)
|
||||
6
backend/apps/workitems/apps.py
Normal file
6
backend/apps/workitems/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkItemsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.workitems"
|
||||
45
backend/apps/workitems/migrations/0001_initial.py
Normal file
45
backend/apps/workitems/migrations/0001_initial.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 02:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0008_ticketmessagetimelineitem'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('task_type', models.CharField(choices=[('ticket_missing_info', '工单信息待补全')], max_length=64)),
|
||||
('status', models.CharField(choices=[('active', '待处理'), ('completed', '已完成'), ('cancelled', '已取消')], default='active', max_length=24)),
|
||||
('object_type', models.CharField(max_length=64)),
|
||||
('object_id', models.CharField(max_length=80)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_work_items', to=settings.AUTH_USER_MODEL)),
|
||||
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_work_items', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_work_items', to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_items', to='tickets.ticket')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['status', 'task_type', 'created_at'], name='workitems_w_status_86e375_idx'), models.Index(fields=['object_type', 'object_id'], name='workitems_w_object__e6cbdc_idx'), models.Index(fields=['assignee', 'status', 'created_at'], name='workitems_w_assigne_930002_idx')],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('status', 'active')), fields=('task_type', 'object_type', 'object_id'), name='unique_active_work_item_target')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 03:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workitems', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('ticket_missing_info', '工单信息待补全'), ('ticket_intake_failed', '智能受理待人工接管')], max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 04:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workitems', '0002_alter_workitem_task_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('ticket_missing_info', '工单信息待补全'), ('ticket_intake_failed', '智能受理待人工接管'), ('ticket_customer_reply', '客户回复待确认')], max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 05:09
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_due_at(apps, schema_editor):
|
||||
WorkItem = apps.get_model("workitems", "WorkItem")
|
||||
hours_by_type = {
|
||||
"ticket_intake_failed": 4,
|
||||
"support_analysis_failed": 4,
|
||||
"ticket_customer_reply": 8,
|
||||
"ticket_close": 24,
|
||||
}
|
||||
for item in WorkItem.objects.filter(due_at__isnull=True).iterator():
|
||||
item.due_at = item.created_at + timedelta(
|
||||
hours=hours_by_type.get(item.task_type, 24)
|
||||
)
|
||||
item.save(update_fields=["due_at"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workitems', '0003_alter_workitem_task_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workitem',
|
||||
name='due_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.RunPython(backfill_due_at, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('ticket_missing_info', '工单信息待补全'), ('ticket_intake_failed', '智能受理待人工接管'), ('ticket_customer_reply', '客户回复待确认'), ('ticket_close', '工单待人工关闭'), ('support_analysis_failed', '支持分析待人工接管')], max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 05:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workitems', '0004_workitem_due_at_alter_workitem_task_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('ticket_missing_info', '工单信息待补全'), ('ticket_intake_failed', '智能受理待人工接管'), ('ticket_customer_reply', '客户回复待确认'), ('ticket_close', '工单待人工关闭'), ('support_analysis_failed', '支持分析待人工接管'), ('knowledge_distillation_failed', '知识蒸馏待人工接管'), ('asset_review', '知识资产待审核')], max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 05:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workitems', '0005_alter_workitem_task_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workitem',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('ticket_missing_info', '工单信息待补全'), ('ticket_intake_failed', '智能受理待人工接管'), ('ticket_customer_reply', '客户回复待确认'), ('ticket_close', '工单待人工关闭'), ('support_analysis_failed', '支持分析待人工接管'), ('knowledge_distillation_failed', '知识蒸馏待人工接管'), ('asset_review', '知识资产待审核'), ('tool_confirmation', '高风险工具待确认')], max_length=64),
|
||||
),
|
||||
]
|
||||
1
backend/apps/workitems/migrations/__init__.py
Normal file
1
backend/apps/workitems/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
87
backend/apps/workitems/models.py
Normal file
87
backend/apps/workitems/models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class WorkItemStatus(models.TextChoices):
|
||||
ACTIVE = "active", "待处理"
|
||||
COMPLETED = "completed", "已完成"
|
||||
CANCELLED = "cancelled", "已取消"
|
||||
|
||||
|
||||
class WorkItemType(models.TextChoices):
|
||||
TICKET_MISSING_INFO = "ticket_missing_info", "工单信息待补全"
|
||||
TICKET_INTAKE_FAILED = "ticket_intake_failed", "智能受理待人工接管"
|
||||
TICKET_CUSTOMER_REPLY = "ticket_customer_reply", "客户回复待确认"
|
||||
TICKET_CLOSE = "ticket_close", "工单待人工关闭"
|
||||
SUPPORT_ANALYSIS_FAILED = "support_analysis_failed", "支持分析待人工接管"
|
||||
KNOWLEDGE_DISTILLATION_FAILED = (
|
||||
"knowledge_distillation_failed",
|
||||
"知识蒸馏待人工接管",
|
||||
)
|
||||
ASSET_REVIEW = "asset_review", "知识资产待审核"
|
||||
TOOL_CONFIRMATION = "tool_confirmation", "高风险工具待确认"
|
||||
|
||||
|
||||
class WorkItem(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
task_type = models.CharField(max_length=64, choices=WorkItemType.choices)
|
||||
status = models.CharField(
|
||||
max_length=24,
|
||||
choices=WorkItemStatus.choices,
|
||||
default=WorkItemStatus.ACTIVE,
|
||||
)
|
||||
object_type = models.CharField(max_length=64)
|
||||
object_id = models.CharField(max_length=80)
|
||||
ticket = models.ForeignKey(
|
||||
"tickets.Ticket",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="work_items",
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="assigned_work_items",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_work_items",
|
||||
)
|
||||
completed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="completed_work_items",
|
||||
)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
due_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["task_type", "object_type", "object_id"],
|
||||
condition=Q(status=WorkItemStatus.ACTIVE),
|
||||
name="unique_active_work_item_target",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["status", "task_type", "created_at"]),
|
||||
models.Index(fields=["object_type", "object_id"]),
|
||||
models.Index(fields=["assignee", "status", "created_at"]),
|
||||
]
|
||||
31
backend/apps/workitems/schemas.py
Normal file
31
backend/apps/workitems/schemas.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class WorkItemOut(Schema):
|
||||
public_id: str
|
||||
task_type: str
|
||||
status: str
|
||||
object_type: str
|
||||
object_id: str
|
||||
ticket_id: int | None
|
||||
ticket_no: str | None
|
||||
title: str
|
||||
description: str
|
||||
payload: dict
|
||||
assignee_id: int | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
completed_at: datetime | None
|
||||
due_at: datetime | None
|
||||
sla_status: str
|
||||
|
||||
|
||||
class WorkItemMetricsOut(Schema):
|
||||
active_count: int
|
||||
overdue_count: int
|
||||
due_soon_count: int
|
||||
completed_within_sla_count: int
|
||||
completed_overdue_count: int
|
||||
by_task_type: dict[str, int]
|
||||
516
backend/apps/workitems/services.py
Normal file
516
backend/apps/workitems/services.py
Normal file
@@ -0,0 +1,516 @@
|
||||
"""任务中心领域服务,负责活动任务去重、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
|
||||
Reference in New Issue
Block a user