solana-agent 11.1.1__py3-none-any.whl → 11.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solana_agent/ai.py +1413 -172
- {solana_agent-11.1.1.dist-info → solana_agent-11.3.0.dist-info}/METADATA +22 -3
- solana_agent-11.3.0.dist-info/RECORD +6 -0
- solana_agent-11.1.1.dist-info/RECORD +0 -6
- {solana_agent-11.1.1.dist-info → solana_agent-11.3.0.dist-info}/LICENSE +0 -0
- {solana_agent-11.1.1.dist-info → solana_agent-11.3.0.dist-info}/WHEEL +0 -0
solana_agent/ai.py
CHANGED
@@ -15,6 +15,7 @@ import datetime
|
|
15
15
|
import json
|
16
16
|
import re
|
17
17
|
import traceback
|
18
|
+
from unittest.mock import AsyncMock
|
18
19
|
import uuid
|
19
20
|
from enum import Enum
|
20
21
|
from typing import (
|
@@ -299,22 +300,31 @@ class AgentType(str, Enum):
|
|
299
300
|
|
300
301
|
|
301
302
|
class Ticket(BaseModel):
|
302
|
-
"""
|
303
|
-
|
304
|
-
id: str
|
303
|
+
"""Model for a support ticket."""
|
304
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
305
305
|
user_id: str
|
306
306
|
query: str
|
307
|
-
status: TicketStatus
|
308
|
-
assigned_to: str
|
309
|
-
created_at: datetime.datetime
|
307
|
+
status: TicketStatus = TicketStatus.NEW
|
308
|
+
assigned_to: str = ""
|
309
|
+
created_at: datetime.datetime = Field(
|
310
|
+
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
|
311
|
+
updated_at: Optional[datetime.datetime] = None
|
312
|
+
resolved_at: Optional[datetime.datetime] = None
|
313
|
+
resolution_confidence: Optional[float] = None
|
314
|
+
resolution_reasoning: Optional[str] = None
|
315
|
+
handoff_reason: Optional[str] = None
|
310
316
|
complexity: Optional[Dict[str, Any]] = None
|
311
|
-
|
317
|
+
agent_context: Optional[Dict[str, Any]] = None
|
312
318
|
is_parent: bool = False
|
313
319
|
is_subtask: bool = False
|
314
320
|
parent_id: Optional[str] = None
|
315
|
-
|
316
|
-
|
317
|
-
|
321
|
+
|
322
|
+
# Add fields for resource integration
|
323
|
+
description: Optional[str] = None
|
324
|
+
scheduled_start: Optional[datetime.datetime] = None
|
325
|
+
scheduled_end: Optional[datetime.datetime] = None
|
326
|
+
required_resources: List[Dict[str, Any]] = []
|
327
|
+
resource_assignments: List[Dict[str, Any]] = []
|
318
328
|
|
319
329
|
|
320
330
|
class Handoff(BaseModel):
|
@@ -455,17 +465,22 @@ class PlanStatus(BaseModel):
|
|
455
465
|
|
456
466
|
|
457
467
|
class SubtaskModel(BaseModel):
|
458
|
-
"""
|
468
|
+
"""Model for a subtask in a complex task breakdown."""
|
459
469
|
|
460
470
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
461
|
-
parent_id: str
|
471
|
+
parent_id: Optional[str] = None
|
462
472
|
title: str
|
463
473
|
description: str
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
474
|
+
estimated_minutes: int
|
475
|
+
dependencies: List[str] = []
|
476
|
+
status: str = "pending"
|
477
|
+
priority: Optional[int] = None
|
478
|
+
assigned_to: Optional[str] = None
|
479
|
+
scheduled_start: Optional[datetime.datetime] = None
|
480
|
+
specialization_tags: List[str] = []
|
481
|
+
sequence: int = 0 # Added missing sequence field
|
482
|
+
required_resources: List[Dict[str, Any]] = []
|
483
|
+
resource_assignments: List[Dict[str, Any]] = []
|
469
484
|
|
470
485
|
|
471
486
|
class WorkCapacity(BaseModel):
|
@@ -482,10 +497,117 @@ class WorkCapacity(BaseModel):
|
|
482
497
|
)
|
483
498
|
|
484
499
|
|
500
|
+
class ResourceType(str, Enum):
|
501
|
+
"""Types of resources that can be booked."""
|
502
|
+
ROOM = "room"
|
503
|
+
VEHICLE = "vehicle"
|
504
|
+
EQUIPMENT = "equipment"
|
505
|
+
SEAT = "seat"
|
506
|
+
DESK = "desk"
|
507
|
+
OTHER = "other"
|
508
|
+
|
509
|
+
|
510
|
+
class ResourceStatus(str, Enum):
|
511
|
+
"""Status of a resource."""
|
512
|
+
AVAILABLE = "available"
|
513
|
+
IN_USE = "in_use"
|
514
|
+
MAINTENANCE = "maintenance"
|
515
|
+
UNAVAILABLE = "unavailable"
|
516
|
+
|
517
|
+
|
518
|
+
class ResourceLocation(BaseModel):
|
519
|
+
"""Physical location of a resource."""
|
520
|
+
address: Optional[str] = None
|
521
|
+
building: Optional[str] = None
|
522
|
+
floor: Optional[int] = None
|
523
|
+
room: Optional[str] = None
|
524
|
+
coordinates: Optional[Dict[str, float]] = None # Lat/Long if applicable
|
525
|
+
|
526
|
+
|
527
|
+
class TimeWindow(BaseModel):
|
528
|
+
"""Time window model for availability and exceptions."""
|
529
|
+
start: datetime.datetime
|
530
|
+
end: datetime.datetime
|
531
|
+
|
532
|
+
def overlaps_with(self, other: 'TimeWindow') -> bool:
|
533
|
+
"""Check if this window overlaps with another one."""
|
534
|
+
return self.start < other.end and self.end > other.start
|
535
|
+
|
536
|
+
|
537
|
+
class ResourceAvailabilityWindow(BaseModel):
|
538
|
+
"""Availability window for a resource with recurring pattern options."""
|
539
|
+
day_of_week: Optional[List[int]] = None # 0 = Monday, 6 = Sunday
|
540
|
+
start_time: str # Format: "HH:MM", 24h format
|
541
|
+
end_time: str # Format: "HH:MM", 24h format
|
542
|
+
timezone: str = "UTC"
|
543
|
+
|
544
|
+
|
545
|
+
class Resource(BaseModel):
|
546
|
+
"""Model for a bookable resource."""
|
547
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
548
|
+
name: str
|
549
|
+
description: Optional[str] = None
|
550
|
+
resource_type: ResourceType
|
551
|
+
status: ResourceStatus = ResourceStatus.AVAILABLE
|
552
|
+
location: Optional[ResourceLocation] = None
|
553
|
+
capacity: Optional[int] = None # For rooms/vehicles
|
554
|
+
tags: List[str] = []
|
555
|
+
attributes: Dict[str, str] = {} # Custom attributes
|
556
|
+
availability_schedule: List[ResourceAvailabilityWindow] = []
|
557
|
+
# Overrides for maintenance, holidays
|
558
|
+
availability_exceptions: List[TimeWindow] = []
|
559
|
+
created_at: datetime.datetime = Field(
|
560
|
+
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
|
561
|
+
updated_at: Optional[datetime.datetime] = None
|
562
|
+
|
563
|
+
def is_available_at(self, time_window: TimeWindow) -> bool:
|
564
|
+
"""Check if resource is available during the specified time window."""
|
565
|
+
# Check if resource is generally available
|
566
|
+
if self.status != ResourceStatus.AVAILABLE:
|
567
|
+
return False
|
568
|
+
|
569
|
+
# Check against exceptions (maintenance, holidays)
|
570
|
+
for exception in self.availability_exceptions:
|
571
|
+
if exception.overlaps_with(time_window):
|
572
|
+
return False
|
573
|
+
|
574
|
+
# Check if the requested time falls within regular availability
|
575
|
+
day_of_week = time_window.start.weekday()
|
576
|
+
start_time = time_window.start.strftime("%H:%M")
|
577
|
+
end_time = time_window.end.strftime("%H:%M")
|
578
|
+
|
579
|
+
for window in self.availability_schedule:
|
580
|
+
if window.day_of_week is None or day_of_week in window.day_of_week:
|
581
|
+
if window.start_time <= start_time and window.end_time >= end_time:
|
582
|
+
return True
|
583
|
+
|
584
|
+
# Default available if no schedule defined
|
585
|
+
return len(self.availability_schedule) == 0
|
586
|
+
|
587
|
+
|
588
|
+
class ResourceBooking(BaseModel):
|
589
|
+
"""Model for a resource booking."""
|
590
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
591
|
+
resource_id: str
|
592
|
+
user_id: str
|
593
|
+
title: str
|
594
|
+
description: Optional[str] = None
|
595
|
+
start_time: datetime.datetime
|
596
|
+
end_time: datetime.datetime
|
597
|
+
status: str = "confirmed" # confirmed, cancelled, completed
|
598
|
+
booking_reference: Optional[str] = None
|
599
|
+
payment_status: Optional[str] = None
|
600
|
+
payment_amount: Optional[float] = None
|
601
|
+
notes: Optional[str] = None
|
602
|
+
created_at: datetime.datetime = Field(
|
603
|
+
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
|
604
|
+
updated_at: Optional[datetime.datetime] = None
|
605
|
+
|
485
606
|
#############################################
|
486
607
|
# INTERFACES
|
487
608
|
#############################################
|
488
609
|
|
610
|
+
|
489
611
|
class LLMProvider(Protocol):
|
490
612
|
"""Interface for language model providers."""
|
491
613
|
|
@@ -1025,6 +1147,217 @@ class PineconeAdapter:
|
|
1025
1147
|
# IMPLEMENTATIONS - REPOSITORIES
|
1026
1148
|
#############################################
|
1027
1149
|
|
1150
|
+
class ResourceRepository:
|
1151
|
+
"""Repository for managing resources."""
|
1152
|
+
|
1153
|
+
def __init__(self, db_provider):
|
1154
|
+
"""Initialize with database provider."""
|
1155
|
+
self.db = db_provider
|
1156
|
+
self.resources_collection = "resources"
|
1157
|
+
self.bookings_collection = "resource_bookings"
|
1158
|
+
|
1159
|
+
# Ensure collections exist
|
1160
|
+
self.db.create_collection(self.resources_collection)
|
1161
|
+
self.db.create_collection(self.bookings_collection)
|
1162
|
+
|
1163
|
+
# Create indexes
|
1164
|
+
self.db.create_index(self.resources_collection, [("resource_type", 1)])
|
1165
|
+
self.db.create_index(self.resources_collection, [("status", 1)])
|
1166
|
+
self.db.create_index(self.resources_collection, [("tags", 1)])
|
1167
|
+
|
1168
|
+
self.db.create_index(self.bookings_collection, [("resource_id", 1)])
|
1169
|
+
self.db.create_index(self.bookings_collection, [("user_id", 1)])
|
1170
|
+
self.db.create_index(self.bookings_collection, [("start_time", 1)])
|
1171
|
+
self.db.create_index(self.bookings_collection, [("end_time", 1)])
|
1172
|
+
self.db.create_index(self.bookings_collection, [("status", 1)])
|
1173
|
+
|
1174
|
+
# Resource CRUD operations
|
1175
|
+
def create_resource(self, resource: Resource) -> str:
|
1176
|
+
"""Create a new resource."""
|
1177
|
+
resource_dict = resource.model_dump(mode="json")
|
1178
|
+
return self.db.insert_one(self.resources_collection, resource_dict)
|
1179
|
+
|
1180
|
+
def get_resource(self, resource_id: str) -> Optional[Resource]:
|
1181
|
+
"""Get a resource by ID."""
|
1182
|
+
data = self.db.find_one(self.resources_collection, {"id": resource_id})
|
1183
|
+
return Resource(**data) if data else None
|
1184
|
+
|
1185
|
+
def update_resource(self, resource: Resource) -> bool:
|
1186
|
+
"""Update a resource."""
|
1187
|
+
resource.updated_at = datetime.datetime.now(datetime.timezone.utc)
|
1188
|
+
resource_dict = resource.model_dump(mode="json")
|
1189
|
+
return self.db.update_one(
|
1190
|
+
self.resources_collection,
|
1191
|
+
{"id": resource.id},
|
1192
|
+
{"$set": resource_dict}
|
1193
|
+
)
|
1194
|
+
|
1195
|
+
def delete_resource(self, resource_id: str) -> bool:
|
1196
|
+
"""Delete a resource."""
|
1197
|
+
return self.db.delete_one(self.resources_collection, {"id": resource_id})
|
1198
|
+
|
1199
|
+
def find_resources(
|
1200
|
+
self,
|
1201
|
+
query: Dict[str, Any],
|
1202
|
+
sort_by: Optional[str] = None,
|
1203
|
+
limit: int = 0
|
1204
|
+
) -> List[Resource]:
|
1205
|
+
"""Find resources matching query."""
|
1206
|
+
sort_params = [(sort_by, 1)] if sort_by else [("name", 1)]
|
1207
|
+
data = self.db.find(self.resources_collection,
|
1208
|
+
query, sort_params, limit)
|
1209
|
+
return [Resource(**item) for item in data]
|
1210
|
+
|
1211
|
+
def find_available_resources(
|
1212
|
+
self,
|
1213
|
+
resource_type: Optional[str] = None,
|
1214
|
+
tags: Optional[List[str]] = None,
|
1215
|
+
start_time: Optional[datetime.datetime] = None,
|
1216
|
+
end_time: Optional[datetime.datetime] = None,
|
1217
|
+
capacity: Optional[int] = None
|
1218
|
+
) -> List[Resource]:
|
1219
|
+
"""Find available resources matching the criteria."""
|
1220
|
+
# Build base query for available resources
|
1221
|
+
query = {"status": ResourceStatus.AVAILABLE}
|
1222
|
+
|
1223
|
+
if resource_type:
|
1224
|
+
query["resource_type"] = resource_type
|
1225
|
+
|
1226
|
+
if tags:
|
1227
|
+
query["tags"] = {"$all": tags}
|
1228
|
+
|
1229
|
+
if capacity:
|
1230
|
+
query["capacity"] = {"$gte": capacity}
|
1231
|
+
|
1232
|
+
# First get resources that match base criteria
|
1233
|
+
resources = self.find_resources(query)
|
1234
|
+
|
1235
|
+
# If no time range specified, return all matching resources
|
1236
|
+
if not start_time or not end_time:
|
1237
|
+
return resources
|
1238
|
+
|
1239
|
+
# Filter by time availability (check bookings and exceptions)
|
1240
|
+
time_window = TimeWindow(start=start_time, end=end_time)
|
1241
|
+
|
1242
|
+
# Check each resource's availability
|
1243
|
+
available_resources = []
|
1244
|
+
for resource in resources:
|
1245
|
+
if resource.is_available_at(time_window):
|
1246
|
+
# Check existing bookings
|
1247
|
+
if not self._has_conflicting_bookings(resource.id, start_time, end_time):
|
1248
|
+
available_resources.append(resource)
|
1249
|
+
|
1250
|
+
return available_resources
|
1251
|
+
|
1252
|
+
# Booking CRUD operations
|
1253
|
+
def create_booking(self, booking: ResourceBooking) -> str:
|
1254
|
+
"""Create a new booking."""
|
1255
|
+
booking_dict = booking.model_dump(mode="json")
|
1256
|
+
return self.db.insert_one(self.bookings_collection, booking_dict)
|
1257
|
+
|
1258
|
+
def get_booking(self, booking_id: str) -> Optional[ResourceBooking]:
|
1259
|
+
"""Get a booking by ID."""
|
1260
|
+
data = self.db.find_one(self.bookings_collection, {"id": booking_id})
|
1261
|
+
return ResourceBooking(**data) if data else None
|
1262
|
+
|
1263
|
+
def update_booking(self, booking: ResourceBooking) -> bool:
|
1264
|
+
"""Update a booking."""
|
1265
|
+
booking.updated_at = datetime.datetime.now(datetime.timezone.utc)
|
1266
|
+
booking_dict = booking.model_dump(mode="json")
|
1267
|
+
return self.db.update_one(
|
1268
|
+
self.bookings_collection,
|
1269
|
+
{"id": booking.id},
|
1270
|
+
{"$set": booking_dict}
|
1271
|
+
)
|
1272
|
+
|
1273
|
+
def cancel_booking(self, booking_id: str) -> bool:
|
1274
|
+
"""Cancel a booking."""
|
1275
|
+
return self.db.update_one(
|
1276
|
+
self.bookings_collection,
|
1277
|
+
{"id": booking_id},
|
1278
|
+
{
|
1279
|
+
"$set": {
|
1280
|
+
"status": "cancelled",
|
1281
|
+
"updated_at": datetime.datetime.now(datetime.timezone.utc)
|
1282
|
+
}
|
1283
|
+
}
|
1284
|
+
)
|
1285
|
+
|
1286
|
+
def get_resource_bookings(
|
1287
|
+
self,
|
1288
|
+
resource_id: str,
|
1289
|
+
start_time: Optional[datetime.datetime] = None,
|
1290
|
+
end_time: Optional[datetime.datetime] = None,
|
1291
|
+
include_cancelled: bool = False
|
1292
|
+
) -> List[ResourceBooking]:
|
1293
|
+
"""Get all bookings for a resource within a time range."""
|
1294
|
+
query = {"resource_id": resource_id}
|
1295
|
+
|
1296
|
+
if not include_cancelled:
|
1297
|
+
query["status"] = {"$ne": "cancelled"}
|
1298
|
+
|
1299
|
+
if start_time or end_time:
|
1300
|
+
time_query = {}
|
1301
|
+
if start_time:
|
1302
|
+
time_query["$lte"] = end_time
|
1303
|
+
if end_time:
|
1304
|
+
time_query["$gte"] = start_time
|
1305
|
+
if time_query:
|
1306
|
+
query["$or"] = [
|
1307
|
+
{"start_time": time_query},
|
1308
|
+
{"end_time": time_query},
|
1309
|
+
{
|
1310
|
+
"$and": [
|
1311
|
+
{"start_time": {"$lte": start_time}},
|
1312
|
+
{"end_time": {"$gte": end_time}}
|
1313
|
+
]
|
1314
|
+
}
|
1315
|
+
]
|
1316
|
+
|
1317
|
+
data = self.db.find(self.bookings_collection,
|
1318
|
+
query, sort=[("start_time", 1)])
|
1319
|
+
return [ResourceBooking(**item) for item in data]
|
1320
|
+
|
1321
|
+
def get_user_bookings(
|
1322
|
+
self,
|
1323
|
+
user_id: str,
|
1324
|
+
include_cancelled: bool = False
|
1325
|
+
) -> List[ResourceBooking]:
|
1326
|
+
"""Get all bookings for a user."""
|
1327
|
+
query = {"user_id": user_id}
|
1328
|
+
|
1329
|
+
if not include_cancelled:
|
1330
|
+
query["status"] = {"$ne": "cancelled"}
|
1331
|
+
|
1332
|
+
data = self.db.find(self.bookings_collection,
|
1333
|
+
query, sort=[("start_time", 1)])
|
1334
|
+
return [ResourceBooking(**item) for item in data]
|
1335
|
+
|
1336
|
+
def _has_conflicting_bookings(
|
1337
|
+
self,
|
1338
|
+
resource_id: str,
|
1339
|
+
start_time: datetime.datetime,
|
1340
|
+
end_time: datetime.datetime
|
1341
|
+
) -> bool:
|
1342
|
+
"""Check if there are any conflicting bookings."""
|
1343
|
+
query = {
|
1344
|
+
"resource_id": resource_id,
|
1345
|
+
"status": {"$ne": "cancelled"},
|
1346
|
+
"$or": [
|
1347
|
+
{"start_time": {"$lt": end_time, "$gte": start_time}},
|
1348
|
+
{"end_time": {"$gt": start_time, "$lte": end_time}},
|
1349
|
+
{
|
1350
|
+
"$and": [
|
1351
|
+
{"start_time": {"$lte": start_time}},
|
1352
|
+
{"end_time": {"$gte": end_time}}
|
1353
|
+
]
|
1354
|
+
}
|
1355
|
+
]
|
1356
|
+
}
|
1357
|
+
|
1358
|
+
return self.db.count_documents(self.bookings_collection, query) > 0
|
1359
|
+
|
1360
|
+
|
1028
1361
|
class MongoMemoryProvider:
|
1029
1362
|
"""MongoDB implementation of MemoryProvider."""
|
1030
1363
|
|
@@ -1355,6 +1688,15 @@ class MongoTicketRepository:
|
|
1355
1688
|
"""Count tickets matching query."""
|
1356
1689
|
return self.db.count_documents(self.collection, query)
|
1357
1690
|
|
1691
|
+
def find_stalled_tickets(self, cutoff_time, statuses):
|
1692
|
+
"""Find tickets that haven't been updated since the cutoff time."""
|
1693
|
+
query = {
|
1694
|
+
"status": {"$in": [status.value if isinstance(status, Enum) else status for status in statuses]},
|
1695
|
+
"updated_at": {"$lt": cutoff_time}
|
1696
|
+
}
|
1697
|
+
tickets = self.db_adapter.find("tickets", query)
|
1698
|
+
return [Ticket(**ticket) for ticket in tickets]
|
1699
|
+
|
1358
1700
|
|
1359
1701
|
class MongoHandoffRepository:
|
1360
1702
|
"""MongoDB implementation of HandoffRepository."""
|
@@ -2644,6 +2986,143 @@ class AgentService:
|
|
2644
2986
|
return self.ai_agents
|
2645
2987
|
|
2646
2988
|
|
2989
|
+
class ResourceService:
|
2990
|
+
"""Service for managing resources and bookings."""
|
2991
|
+
|
2992
|
+
def __init__(self, resource_repository: ResourceRepository):
|
2993
|
+
"""Initialize with resource repository."""
|
2994
|
+
self.repository = resource_repository
|
2995
|
+
|
2996
|
+
async def create_resource(self, resource_data, resource_type):
|
2997
|
+
"""Create a new resource from dictionary data."""
|
2998
|
+
# Generate UUID for ID since it can't be None
|
2999
|
+
resource_id = str(uuid.uuid4())
|
3000
|
+
|
3001
|
+
resource = Resource(
|
3002
|
+
id=resource_id,
|
3003
|
+
name=resource_data["name"],
|
3004
|
+
resource_type=resource_type,
|
3005
|
+
description=resource_data.get("description"),
|
3006
|
+
location=resource_data.get("location"),
|
3007
|
+
capacity=resource_data.get("capacity"),
|
3008
|
+
tags=resource_data.get("tags", []),
|
3009
|
+
attributes=resource_data.get("attributes", {}),
|
3010
|
+
availability_schedule=resource_data.get(
|
3011
|
+
"availability_schedule", [])
|
3012
|
+
)
|
3013
|
+
|
3014
|
+
# Don't use await when calling repository methods
|
3015
|
+
return self.repository.create_resource(resource)
|
3016
|
+
|
3017
|
+
async def get_resource(self, resource_id):
|
3018
|
+
"""Get a resource by ID."""
|
3019
|
+
# Don't use await
|
3020
|
+
return self.repository.get_resource(resource_id)
|
3021
|
+
|
3022
|
+
async def update_resource(self, resource_id, updates):
|
3023
|
+
"""Update a resource."""
|
3024
|
+
resource = self.repository.get_resource(resource_id)
|
3025
|
+
if not resource:
|
3026
|
+
return False
|
3027
|
+
|
3028
|
+
# Apply updates
|
3029
|
+
for key, value in updates.items():
|
3030
|
+
if hasattr(resource, key):
|
3031
|
+
setattr(resource, key, value)
|
3032
|
+
|
3033
|
+
# Don't use await
|
3034
|
+
return self.repository.update_resource(resource)
|
3035
|
+
|
3036
|
+
async def list_resources(self, resource_type=None):
|
3037
|
+
"""List all resources, optionally filtered by type."""
|
3038
|
+
# Don't use await
|
3039
|
+
return self.repository.list_resources(resource_type)
|
3040
|
+
|
3041
|
+
async def find_available_resources(self, start_time, end_time, capacity=None, tags=None, resource_type=None):
|
3042
|
+
"""Find available resources for a time period."""
|
3043
|
+
# Don't use await
|
3044
|
+
resources = self.repository.find_resources(
|
3045
|
+
resource_type, capacity, tags)
|
3046
|
+
|
3047
|
+
# Filter by availability
|
3048
|
+
available = []
|
3049
|
+
for resource in resources:
|
3050
|
+
time_window = TimeWindow(start=start_time, end=end_time)
|
3051
|
+
if resource.is_available_at(time_window):
|
3052
|
+
if not self.repository._has_conflicting_bookings(resource.id, start_time, end_time):
|
3053
|
+
available.append(resource)
|
3054
|
+
|
3055
|
+
return available
|
3056
|
+
|
3057
|
+
async def create_booking(self, resource_id, user_id, title, start_time, end_time, description=None, notes=None):
|
3058
|
+
"""Create a booking for a resource."""
|
3059
|
+
# Check if resource exists
|
3060
|
+
resource = self.repository.get_resource(resource_id)
|
3061
|
+
if not resource:
|
3062
|
+
return False, None, "Resource not found"
|
3063
|
+
|
3064
|
+
# Check for conflicts
|
3065
|
+
if self.repository._has_conflicting_bookings(resource_id, start_time, end_time):
|
3066
|
+
return False, None, "Resource is already booked during the requested time"
|
3067
|
+
|
3068
|
+
# Create booking
|
3069
|
+
booking_data = ResourceBooking(
|
3070
|
+
id=str(uuid.uuid4()),
|
3071
|
+
resource_id=resource_id,
|
3072
|
+
user_id=user_id,
|
3073
|
+
title=title,
|
3074
|
+
description=description,
|
3075
|
+
status="confirmed",
|
3076
|
+
start_time=start_time,
|
3077
|
+
end_time=end_time,
|
3078
|
+
notes=notes,
|
3079
|
+
created_at=datetime.datetime.now(datetime.timezone.utc)
|
3080
|
+
)
|
3081
|
+
|
3082
|
+
booking_id = self.repository.create_booking(booking_data)
|
3083
|
+
|
3084
|
+
# Return (success, booking_id, error)
|
3085
|
+
return True, booking_id, None
|
3086
|
+
|
3087
|
+
async def cancel_booking(self, booking_id, user_id):
|
3088
|
+
"""Cancel a booking."""
|
3089
|
+
# Verify booking exists
|
3090
|
+
booking = self.repository.get_booking(booking_id)
|
3091
|
+
if not booking:
|
3092
|
+
return False, "Booking not found"
|
3093
|
+
|
3094
|
+
# Verify user owns the booking
|
3095
|
+
if booking.user_id != user_id:
|
3096
|
+
return False, "Not authorized to cancel this booking"
|
3097
|
+
|
3098
|
+
# Cancel booking
|
3099
|
+
result = self.repository.cancel_booking(booking_id)
|
3100
|
+
if result:
|
3101
|
+
return True, None
|
3102
|
+
return False, "Failed to cancel booking"
|
3103
|
+
|
3104
|
+
async def get_resource_schedule(self, resource_id, start_date, end_date):
|
3105
|
+
"""Get a resource's schedule for a date range."""
|
3106
|
+
return self.repository.get_resource_schedule(resource_id, start_date, end_date)
|
3107
|
+
|
3108
|
+
async def get_user_bookings(self, user_id, include_cancelled=False):
|
3109
|
+
"""Get all bookings for a user with resource details."""
|
3110
|
+
bookings = self.repository.get_user_bookings(
|
3111
|
+
user_id,
|
3112
|
+
include_cancelled
|
3113
|
+
)
|
3114
|
+
|
3115
|
+
result = []
|
3116
|
+
for booking in bookings:
|
3117
|
+
resource = self.repository.get_resource(booking.resource_id)
|
3118
|
+
result.append({
|
3119
|
+
"booking": booking.model_dump(),
|
3120
|
+
"resource": resource.model_dump() if resource else None
|
3121
|
+
})
|
3122
|
+
|
3123
|
+
return result
|
3124
|
+
|
3125
|
+
|
2647
3126
|
class TaskPlanningService:
|
2648
3127
|
"""Service for managing complex task planning and breakdown."""
|
2649
3128
|
|
@@ -2998,6 +3477,181 @@ class TaskPlanningService:
|
|
2998
3477
|
"domain_knowledge": 5,
|
2999
3478
|
}
|
3000
3479
|
|
3480
|
+
async def generate_subtasks_with_resources(
|
3481
|
+
self, ticket_id: str, task_description: str
|
3482
|
+
) -> List[SubtaskModel]:
|
3483
|
+
"""Generate subtasks for a complex task with resource requirements."""
|
3484
|
+
# Fetch ticket to verify it exists
|
3485
|
+
ticket = self.ticket_repository.get_by_id(ticket_id)
|
3486
|
+
if not ticket:
|
3487
|
+
raise ValueError(f"Ticket {ticket_id} not found")
|
3488
|
+
|
3489
|
+
# Mark parent ticket as a parent
|
3490
|
+
self.ticket_repository.update(
|
3491
|
+
ticket_id, {"is_parent": True, "status": TicketStatus.PLANNING}
|
3492
|
+
)
|
3493
|
+
|
3494
|
+
# Generate subtasks using LLM
|
3495
|
+
agent_name = next(iter(self.agent_service.get_all_ai_agents().keys()))
|
3496
|
+
agent_config = self.agent_service.get_all_ai_agents()[agent_name]
|
3497
|
+
model = agent_config.get("model", "gpt-4o-mini")
|
3498
|
+
|
3499
|
+
prompt = f"""
|
3500
|
+
Break down the following complex task into logical subtasks with resource requirements:
|
3501
|
+
|
3502
|
+
TASK: {task_description}
|
3503
|
+
|
3504
|
+
For each subtask, provide:
|
3505
|
+
1. A brief title
|
3506
|
+
2. A clear description of what needs to be done
|
3507
|
+
3. An estimate of time required in minutes
|
3508
|
+
4. Any dependencies (which subtasks must be completed first)
|
3509
|
+
5. Required resources with these details:
|
3510
|
+
- Resource type (room, equipment, etc.)
|
3511
|
+
- Quantity needed
|
3512
|
+
- Specific requirements (e.g., "room with projector", "laptop with design software")
|
3513
|
+
|
3514
|
+
Format as a JSON array of objects with these fields:
|
3515
|
+
- title: string
|
3516
|
+
- description: string
|
3517
|
+
- estimated_minutes: number
|
3518
|
+
- dependencies: array of previous subtask titles that must be completed first
|
3519
|
+
- required_resources: array of objects with fields:
|
3520
|
+
- resource_type: string
|
3521
|
+
- quantity: number
|
3522
|
+
- requirements: string (specific features needed)
|
3523
|
+
|
3524
|
+
The subtasks should be in a logical sequence. Keep dependencies minimal and avoid circular dependencies.
|
3525
|
+
"""
|
3526
|
+
|
3527
|
+
response_text = ""
|
3528
|
+
async for chunk in self.llm_provider.generate_text(
|
3529
|
+
ticket.user_id,
|
3530
|
+
prompt,
|
3531
|
+
system_prompt="You are an expert project planner who breaks down complex tasks efficiently and identifies required resources.",
|
3532
|
+
stream=False,
|
3533
|
+
model=model,
|
3534
|
+
temperature=0.2,
|
3535
|
+
response_format={"type": "json_object"},
|
3536
|
+
):
|
3537
|
+
response_text += chunk
|
3538
|
+
|
3539
|
+
try:
|
3540
|
+
data = json.loads(response_text)
|
3541
|
+
subtasks_data = data.get("subtasks", [])
|
3542
|
+
|
3543
|
+
# Create subtask objects
|
3544
|
+
subtasks = []
|
3545
|
+
for i, task_data in enumerate(subtasks_data):
|
3546
|
+
subtask = SubtaskModel(
|
3547
|
+
parent_id=ticket_id,
|
3548
|
+
title=task_data.get("title", f"Subtask {i+1}"),
|
3549
|
+
description=task_data.get("description", ""),
|
3550
|
+
estimated_minutes=task_data.get("estimated_minutes", 30),
|
3551
|
+
dependencies=[], # We'll fill this after all subtasks are created
|
3552
|
+
status="planning",
|
3553
|
+
required_resources=task_data.get("required_resources", []),
|
3554
|
+
is_subtask=True,
|
3555
|
+
created_at=datetime.datetime.now(datetime.timezone.utc)
|
3556
|
+
)
|
3557
|
+
subtasks.append(subtask)
|
3558
|
+
|
3559
|
+
# Process dependencies (convert title references to IDs)
|
3560
|
+
title_to_id = {task.title: task.id for task in subtasks}
|
3561
|
+
for i, task_data in enumerate(subtasks_data):
|
3562
|
+
dependency_titles = task_data.get("dependencies", [])
|
3563
|
+
for title in dependency_titles:
|
3564
|
+
if title in title_to_id:
|
3565
|
+
subtasks[i].dependencies.append(title_to_id[title])
|
3566
|
+
|
3567
|
+
# Store subtasks in database
|
3568
|
+
for subtask in subtasks:
|
3569
|
+
self.ticket_repository.create(subtask)
|
3570
|
+
|
3571
|
+
return subtasks
|
3572
|
+
|
3573
|
+
except Exception as e:
|
3574
|
+
print(f"Error generating subtasks with resources: {e}")
|
3575
|
+
return []
|
3576
|
+
|
3577
|
+
async def allocate_resources(
|
3578
|
+
self, subtask_id: str, resource_service: ResourceService
|
3579
|
+
) -> Tuple[bool, str]:
|
3580
|
+
"""Allocate resources to a subtask."""
|
3581
|
+
# Get the subtask
|
3582
|
+
subtask = self.ticket_repository.get_by_id(subtask_id)
|
3583
|
+
if not subtask or not subtask.is_subtask:
|
3584
|
+
return False, "Subtask not found"
|
3585
|
+
|
3586
|
+
if not subtask.required_resources:
|
3587
|
+
return True, "No resources required"
|
3588
|
+
|
3589
|
+
if not subtask.scheduled_start or not subtask.scheduled_end:
|
3590
|
+
return False, "Subtask must be scheduled before resources can be allocated"
|
3591
|
+
|
3592
|
+
# For each required resource
|
3593
|
+
resource_assignments = []
|
3594
|
+
for resource_req in subtask.required_resources:
|
3595
|
+
resource_type = resource_req.get("resource_type")
|
3596
|
+
requirements = resource_req.get("requirements", "")
|
3597
|
+
quantity = resource_req.get("quantity", 1)
|
3598
|
+
|
3599
|
+
# Find available resources matching the requirements
|
3600
|
+
resources = await resource_service.find_available_resources(
|
3601
|
+
start_time=subtask.scheduled_start,
|
3602
|
+
end_time=subtask.scheduled_end,
|
3603
|
+
resource_type=resource_type,
|
3604
|
+
tags=requirements.split() if requirements else None,
|
3605
|
+
capacity=None # Could use quantity here if it represents capacity
|
3606
|
+
)
|
3607
|
+
|
3608
|
+
if not resources or len(resources) < quantity:
|
3609
|
+
return False, f"Insufficient {resource_type} resources available"
|
3610
|
+
|
3611
|
+
# Allocate the resources by creating bookings
|
3612
|
+
allocated_resources = []
|
3613
|
+
for i in range(quantity):
|
3614
|
+
if i >= len(resources):
|
3615
|
+
break
|
3616
|
+
|
3617
|
+
resource = resources[i]
|
3618
|
+
success, booking_id, error = await resource_service.create_booking(
|
3619
|
+
resource_id=resource.id,
|
3620
|
+
user_id=subtask.assigned_to or "system",
|
3621
|
+
# Use query instead of title
|
3622
|
+
title=f"Task: {subtask.query}",
|
3623
|
+
start_time=subtask.scheduled_start,
|
3624
|
+
end_time=subtask.scheduled_end,
|
3625
|
+
description=subtask.description
|
3626
|
+
)
|
3627
|
+
|
3628
|
+
if success:
|
3629
|
+
allocated_resources.append({
|
3630
|
+
"resource_id": resource.id,
|
3631
|
+
"resource_name": resource.name,
|
3632
|
+
"booking_id": booking_id,
|
3633
|
+
"resource_type": resource.resource_type
|
3634
|
+
})
|
3635
|
+
else:
|
3636
|
+
# Clean up any allocations already made
|
3637
|
+
for alloc in allocated_resources:
|
3638
|
+
await resource_service.cancel_booking(alloc["booking_id"], subtask.assigned_to or "system")
|
3639
|
+
return False, f"Failed to book resource: {error}"
|
3640
|
+
|
3641
|
+
resource_assignments.append({
|
3642
|
+
"requirement": resource_req,
|
3643
|
+
"allocated": allocated_resources
|
3644
|
+
})
|
3645
|
+
|
3646
|
+
# Update the subtask with resource assignments
|
3647
|
+
subtask.resource_assignments = resource_assignments
|
3648
|
+
self.ticket_repository.update(subtask_id, {
|
3649
|
+
"resource_assignments": resource_assignments,
|
3650
|
+
"updated_at": datetime.datetime.now(datetime.timezone.utc)
|
3651
|
+
})
|
3652
|
+
|
3653
|
+
return True, f"Successfully allocated {len(resource_assignments)} resource types"
|
3654
|
+
|
3001
3655
|
|
3002
3656
|
class ProjectApprovalService:
|
3003
3657
|
"""Service for managing human approval of new projects."""
|
@@ -3018,27 +3672,6 @@ class ProjectApprovalService:
|
|
3018
3672
|
if agent_id in self.human_agent_registry.get_all_human_agents():
|
3019
3673
|
self.approvers.append(agent_id)
|
3020
3674
|
|
3021
|
-
async def submit_for_approval(self, ticket: Ticket) -> None:
|
3022
|
-
"""Submit a project for human approval."""
|
3023
|
-
# Update ticket status
|
3024
|
-
self.ticket_repository.update(
|
3025
|
-
ticket.id,
|
3026
|
-
{
|
3027
|
-
"status": TicketStatus.PENDING,
|
3028
|
-
"approval_status": "awaiting_approval",
|
3029
|
-
"updated_at": datetime.datetime.now(datetime.timezone.utc),
|
3030
|
-
},
|
3031
|
-
)
|
3032
|
-
|
3033
|
-
# Notify approvers
|
3034
|
-
if self.notification_service:
|
3035
|
-
for approver_id in self.approvers:
|
3036
|
-
self.notification_service.send_notification(
|
3037
|
-
approver_id,
|
3038
|
-
f"New project requires approval: {ticket.query}",
|
3039
|
-
{"ticket_id": ticket.id, "type": "approval_request"},
|
3040
|
-
)
|
3041
|
-
|
3042
3675
|
async def process_approval(
|
3043
3676
|
self, ticket_id: str, approver_id: str, approved: bool, comments: str = ""
|
3044
3677
|
) -> None:
|
@@ -3073,6 +3706,27 @@ class ProjectApprovalService:
|
|
3073
3706
|
},
|
3074
3707
|
)
|
3075
3708
|
|
3709
|
+
async def submit_for_approval(self, ticket: Ticket) -> None:
|
3710
|
+
"""Submit a project for human approval."""
|
3711
|
+
# Update ticket status
|
3712
|
+
self.ticket_repository.update(
|
3713
|
+
ticket.id,
|
3714
|
+
{
|
3715
|
+
"status": TicketStatus.PENDING,
|
3716
|
+
"approval_status": "awaiting_approval",
|
3717
|
+
"updated_at": datetime.datetime.now(datetime.timezone.utc),
|
3718
|
+
},
|
3719
|
+
)
|
3720
|
+
|
3721
|
+
# Notify approvers
|
3722
|
+
if self.notification_service and self.approvers:
|
3723
|
+
for approver_id in self.approvers:
|
3724
|
+
await self.notification_service.send_notification(
|
3725
|
+
approver_id,
|
3726
|
+
f"New project requires approval: {ticket.query}",
|
3727
|
+
{"ticket_id": ticket.id, "type": "approval_request"},
|
3728
|
+
)
|
3729
|
+
|
3076
3730
|
|
3077
3731
|
class ProjectSimulationService:
|
3078
3732
|
"""Service for simulating project feasibility and requirements using historical data."""
|
@@ -3767,6 +4421,77 @@ class SchedulingService:
|
|
3767
4421
|
|
3768
4422
|
return task
|
3769
4423
|
|
4424
|
+
async def find_optimal_time_slot_with_resources(
|
4425
|
+
self,
|
4426
|
+
task: ScheduledTask,
|
4427
|
+
resource_service: ResourceService,
|
4428
|
+
agent_schedule: Optional[AgentSchedule] = None
|
4429
|
+
) -> Optional[TimeWindow]:
|
4430
|
+
"""Find the optimal time slot for a task based on both agent and resource availability."""
|
4431
|
+
if not task.assigned_to:
|
4432
|
+
return None
|
4433
|
+
|
4434
|
+
# First, find potential time slots based on agent availability
|
4435
|
+
agent_id = task.assigned_to
|
4436
|
+
duration = task.estimated_minutes or 30
|
4437
|
+
|
4438
|
+
# Start no earlier than now
|
4439
|
+
start_after = datetime.datetime.now(datetime.timezone.utc)
|
4440
|
+
|
4441
|
+
# Apply task constraints
|
4442
|
+
for constraint in task.constraints:
|
4443
|
+
if constraint.get("type") == "must_start_after" and constraint.get("time"):
|
4444
|
+
constraint_time = datetime.datetime.fromisoformat(
|
4445
|
+
constraint["time"])
|
4446
|
+
if constraint_time > start_after:
|
4447
|
+
start_after = constraint_time
|
4448
|
+
|
4449
|
+
# Get potential time slots for the agent
|
4450
|
+
agent_slots = await self.find_available_time_slots(
|
4451
|
+
agent_id,
|
4452
|
+
duration,
|
4453
|
+
start_after,
|
4454
|
+
count=3, # Get multiple slots to try with resources
|
4455
|
+
agent_schedule=agent_schedule
|
4456
|
+
)
|
4457
|
+
|
4458
|
+
if not agent_slots:
|
4459
|
+
return None
|
4460
|
+
|
4461
|
+
# Check if task has resource requirements
|
4462
|
+
required_resources = getattr(task, "required_resources", [])
|
4463
|
+
if not required_resources:
|
4464
|
+
# If no resources needed, return the first available agent slot
|
4465
|
+
return agent_slots[0]
|
4466
|
+
|
4467
|
+
# For each potential time slot, check resource availability
|
4468
|
+
for time_slot in agent_slots:
|
4469
|
+
all_resources_available = True
|
4470
|
+
|
4471
|
+
for resource_req in required_resources:
|
4472
|
+
resource_type = resource_req.get("resource_type")
|
4473
|
+
requirements = resource_req.get("requirements", "")
|
4474
|
+
quantity = resource_req.get("quantity", 1)
|
4475
|
+
|
4476
|
+
# Find available resources for this time slot
|
4477
|
+
resources = await resource_service.find_available_resources(
|
4478
|
+
start_time=time_slot.start,
|
4479
|
+
end_time=time_slot.end,
|
4480
|
+
resource_type=resource_type,
|
4481
|
+
tags=requirements.split() if requirements else None
|
4482
|
+
)
|
4483
|
+
|
4484
|
+
if len(resources) < quantity:
|
4485
|
+
all_resources_available = False
|
4486
|
+
break
|
4487
|
+
|
4488
|
+
# If all resources are available, use this time slot
|
4489
|
+
if all_resources_available:
|
4490
|
+
return time_slot
|
4491
|
+
|
4492
|
+
# If no time slot has all resources available, default to first slot
|
4493
|
+
return agent_slots[0]
|
4494
|
+
|
3770
4495
|
async def optimize_schedule(self) -> Dict[str, Any]:
|
3771
4496
|
"""Optimize the entire schedule to maximize efficiency."""
|
3772
4497
|
# Get all pending and scheduled tasks
|
@@ -4421,6 +5146,7 @@ class SchedulingService:
|
|
4421
5146
|
|
4422
5147
|
return formatted_requests
|
4423
5148
|
|
5149
|
+
|
4424
5150
|
#############################################
|
4425
5151
|
# MAIN AGENT PROCESSOR
|
4426
5152
|
#############################################
|
@@ -4446,6 +5172,7 @@ class QueryProcessor:
|
|
4446
5172
|
project_simulation_service: Optional[ProjectSimulationService] = None,
|
4447
5173
|
require_human_approval: bool = False,
|
4448
5174
|
scheduling_service: Optional[SchedulingService] = None,
|
5175
|
+
stalled_ticket_timeout: Optional[int] = 60,
|
4449
5176
|
):
|
4450
5177
|
self.agent_service = agent_service
|
4451
5178
|
self.routing_service = routing_service
|
@@ -4463,11 +5190,37 @@ class QueryProcessor:
|
|
4463
5190
|
self.require_human_approval = require_human_approval
|
4464
5191
|
self._shutdown_event = asyncio.Event()
|
4465
5192
|
self.scheduling_service = scheduling_service
|
5193
|
+
self.stalled_ticket_timeout = stalled_ticket_timeout
|
5194
|
+
|
5195
|
+
self._stalled_ticket_task = None
|
5196
|
+
|
5197
|
+
# Start background task for stalled ticket detection if not already running
|
5198
|
+
if self.stalled_ticket_timeout is not None and self._stalled_ticket_task is None:
|
5199
|
+
try:
|
5200
|
+
self._stalled_ticket_task = asyncio.create_task(
|
5201
|
+
self._run_stalled_ticket_checks())
|
5202
|
+
except RuntimeError:
|
5203
|
+
# No running event loop - likely in test environment
|
5204
|
+
pass
|
4466
5205
|
|
4467
5206
|
async def process(
|
4468
5207
|
self, user_id: str, user_text: str, timezone: str = None
|
4469
5208
|
) -> AsyncGenerator[str, None]:
|
4470
5209
|
"""Process the user request with appropriate agent and handle ticket management."""
|
5210
|
+
# Start background task for stalled ticket detection if not already running
|
5211
|
+
if self.stalled_ticket_timeout is not None and self._stalled_ticket_task is None:
|
5212
|
+
try:
|
5213
|
+
loop = asyncio.get_running_loop()
|
5214
|
+
self._stalled_ticket_task = loop.create_task(
|
5215
|
+
self._run_stalled_ticket_checks())
|
5216
|
+
except RuntimeError:
|
5217
|
+
# No running event loop - likely in test environment
|
5218
|
+
# Instead of just passing, log a message for clarity
|
5219
|
+
import logging
|
5220
|
+
logging.warning(
|
5221
|
+
"No running event loop available for stalled ticket checker.")
|
5222
|
+
# Don't try to create the task - this prevents the coroutine warning
|
5223
|
+
|
4471
5224
|
try:
|
4472
5225
|
# Handle human agent messages differently
|
4473
5226
|
if await self._is_human_agent(user_id):
|
@@ -4515,7 +5268,9 @@ class QueryProcessor:
|
|
4515
5268
|
except Exception as e:
|
4516
5269
|
print(f"Error in request processing: {str(e)}")
|
4517
5270
|
print(traceback.format_exc())
|
4518
|
-
yield
|
5271
|
+
# Use yield instead of direct function calling to avoid coroutine warning
|
5272
|
+
error_msg = "\n\nI apologize for the technical difficulty.\n\n"
|
5273
|
+
yield error_msg
|
4519
5274
|
|
4520
5275
|
async def _is_human_agent(self, user_id: str) -> bool:
|
4521
5276
|
"""Check if the user is a registered human agent."""
|
@@ -4583,6 +5338,69 @@ class QueryProcessor:
|
|
4583
5338
|
|
4584
5339
|
return response
|
4585
5340
|
|
5341
|
+
async def shutdown(self):
|
5342
|
+
"""Clean shutdown of the query processor."""
|
5343
|
+
self._shutdown_event.set()
|
5344
|
+
|
5345
|
+
# Cancel the stalled ticket task if running
|
5346
|
+
if hasattr(self, '_stalled_ticket_task') and self._stalled_ticket_task is not None:
|
5347
|
+
self._stalled_ticket_task.cancel()
|
5348
|
+
try:
|
5349
|
+
await self._stalled_ticket_task
|
5350
|
+
except (asyncio.CancelledError, TypeError):
|
5351
|
+
# Either properly cancelled coroutine or a mock that can't be awaited
|
5352
|
+
pass
|
5353
|
+
|
5354
|
+
async def _run_stalled_ticket_checks(self):
|
5355
|
+
"""Run periodic checks for stalled tickets."""
|
5356
|
+
try:
|
5357
|
+
while not self._shutdown_event.is_set():
|
5358
|
+
await self._check_for_stalled_tickets()
|
5359
|
+
# Check every 5 minutes or half the timeout period, whichever is smaller
|
5360
|
+
check_interval = min(
|
5361
|
+
300, self.stalled_ticket_timeout * 30) if self.stalled_ticket_timeout else 300
|
5362
|
+
await asyncio.sleep(check_interval)
|
5363
|
+
except asyncio.CancelledError:
|
5364
|
+
# Task was cancelled, clean exit
|
5365
|
+
pass
|
5366
|
+
except Exception as e:
|
5367
|
+
print(f"Error in stalled ticket check: {e}")
|
5368
|
+
|
5369
|
+
async def _check_for_stalled_tickets(self):
|
5370
|
+
"""Check for tickets that haven't been updated in a while and reassign them."""
|
5371
|
+
# If stalled ticket detection is disabled, exit early
|
5372
|
+
if self.stalled_ticket_timeout is None:
|
5373
|
+
return
|
5374
|
+
|
5375
|
+
# Find tickets that haven't been updated in the configured time
|
5376
|
+
stalled_cutoff = datetime.datetime.now(
|
5377
|
+
datetime.timezone.utc) - datetime.timedelta(minutes=self.stalled_ticket_timeout)
|
5378
|
+
|
5379
|
+
# Query for stalled tickets using the find_stalled_tickets method
|
5380
|
+
stalled_tickets = await self.ticket_service.ticket_repository.find_stalled_tickets(
|
5381
|
+
stalled_cutoff, [TicketStatus.ACTIVE, TicketStatus.TRANSFERRED]
|
5382
|
+
)
|
5383
|
+
|
5384
|
+
for ticket in stalled_tickets:
|
5385
|
+
# Re-route using routing service to find the optimal agent
|
5386
|
+
new_agent = await self.routing_service.route_query(ticket.query)
|
5387
|
+
|
5388
|
+
# Skip if the routing didn't change
|
5389
|
+
if new_agent == ticket.assigned_to:
|
5390
|
+
continue
|
5391
|
+
|
5392
|
+
# Process as handoff
|
5393
|
+
await self.handoff_service.process_handoff(
|
5394
|
+
ticket.id,
|
5395
|
+
ticket.assigned_to or "unassigned",
|
5396
|
+
new_agent,
|
5397
|
+
f"Automatically reassigned after {self.stalled_ticket_timeout} minutes of inactivity"
|
5398
|
+
)
|
5399
|
+
|
5400
|
+
# Log the reassignment
|
5401
|
+
print(
|
5402
|
+
f"Stalled ticket {ticket.id} reassigned from {ticket.assigned_to or 'unassigned'} to {new_agent}")
|
5403
|
+
|
4586
5404
|
async def _process_system_commands(
|
4587
5405
|
self, user_id: str, user_text: str
|
4588
5406
|
) -> Optional[str]:
|
@@ -4607,7 +5425,7 @@ class QueryProcessor:
|
|
4607
5425
|
|
4608
5426
|
return response
|
4609
5427
|
|
4610
|
-
|
5428
|
+
if command == "!plan" and args:
|
4611
5429
|
# Create a new plan from the task description
|
4612
5430
|
if not self.task_planning_service:
|
4613
5431
|
return "Task planning service is not available."
|
@@ -4619,8 +5437,8 @@ class QueryProcessor:
|
|
4619
5437
|
user_id, args, complexity
|
4620
5438
|
)
|
4621
5439
|
|
4622
|
-
# Generate subtasks
|
4623
|
-
subtasks = await self.task_planning_service.
|
5440
|
+
# Generate subtasks with resource requirements
|
5441
|
+
subtasks = await self.task_planning_service.generate_subtasks_with_resources(
|
4624
5442
|
ticket.id, args
|
4625
5443
|
)
|
4626
5444
|
|
@@ -4634,17 +5452,44 @@ class QueryProcessor:
|
|
4634
5452
|
for i, subtask in enumerate(subtasks, 1):
|
4635
5453
|
response += f"{i}. **{subtask.title}**\n"
|
4636
5454
|
response += f" - Description: {subtask.description}\n"
|
4637
|
-
response +=
|
4638
|
-
|
4639
|
-
|
5455
|
+
response += f" - Estimated time: {subtask.estimated_minutes} minutes\n"
|
5456
|
+
|
5457
|
+
if subtask.required_resources:
|
5458
|
+
response += f" - Required resources:\n"
|
5459
|
+
for res in subtask.required_resources:
|
5460
|
+
res_type = res.get("resource_type", "unknown")
|
5461
|
+
quantity = res.get("quantity", 1)
|
5462
|
+
requirements = res.get("requirements", "")
|
5463
|
+
response += f" * {quantity} {res_type}" + \
|
5464
|
+
(f" ({requirements})" if requirements else "") + "\n"
|
5465
|
+
|
4640
5466
|
if subtask.dependencies:
|
4641
|
-
response += (
|
4642
|
-
|
4643
|
-
)
|
5467
|
+
response += f" - Dependencies: {len(subtask.dependencies)} subtasks\n"
|
5468
|
+
|
4644
5469
|
response += "\n"
|
4645
5470
|
|
4646
5471
|
return response
|
4647
5472
|
|
5473
|
+
# Add a new command for allocating resources to tasks
|
5474
|
+
elif command == "!allocate-resources" and args:
|
5475
|
+
parts = args.split()
|
5476
|
+
if len(parts) < 1:
|
5477
|
+
return "Usage: !allocate-resources [subtask_id]"
|
5478
|
+
|
5479
|
+
subtask_id = parts[0]
|
5480
|
+
|
5481
|
+
if not hasattr(self, "resource_service") or not self.resource_service:
|
5482
|
+
return "Resource service is not available."
|
5483
|
+
|
5484
|
+
success, message = await self.task_planning_service.allocate_resources(
|
5485
|
+
subtask_id, self.resource_service
|
5486
|
+
)
|
5487
|
+
|
5488
|
+
if success:
|
5489
|
+
return f"✅ Resources allocated successfully: {message}"
|
5490
|
+
else:
|
5491
|
+
return f"❌ Failed to allocate resources: {message}"
|
5492
|
+
|
4648
5493
|
elif command == "!status" and args:
|
4649
5494
|
# Show status of a specific plan
|
4650
5495
|
if not self.task_planning_service:
|
@@ -4739,154 +5584,530 @@ class QueryProcessor:
|
|
4739
5584
|
)
|
4740
5585
|
return f"Project {ticket_id} has been {'approved' if approved else 'rejected'}."
|
4741
5586
|
|
4742
|
-
|
4743
|
-
|
4744
|
-
|
4745
|
-
|
4746
|
-
|
4747
|
-
|
4748
|
-
|
4749
|
-
|
4750
|
-
|
4751
|
-
|
4752
|
-
|
4753
|
-
|
4754
|
-
|
4755
|
-
|
4756
|
-
|
4757
|
-
|
4758
|
-
|
4759
|
-
|
4760
|
-
|
4761
|
-
|
4762
|
-
|
4763
|
-
|
4764
|
-
|
4765
|
-
|
4766
|
-
|
4767
|
-
|
4768
|
-
|
4769
|
-
|
5587
|
+
elif command == "!schedule" and args and self.scheduling_service:
|
5588
|
+
# Format: !schedule task_id [agent_id] [YYYY-MM-DD HH:MM]
|
5589
|
+
parts = args.strip().split(" ", 2)
|
5590
|
+
if len(parts) < 1:
|
5591
|
+
return "Usage: !schedule task_id [agent_id] [YYYY-MM-DD HH:MM]"
|
5592
|
+
|
5593
|
+
task_id = parts[0]
|
5594
|
+
agent_id = parts[1] if len(parts) > 1 else None
|
5595
|
+
time_str = parts[2] if len(parts) > 2 else None
|
5596
|
+
|
5597
|
+
# Fetch the task from ticket repository
|
5598
|
+
ticket = self.ticket_service.ticket_repository.get_by_id(
|
5599
|
+
task_id)
|
5600
|
+
if not ticket:
|
5601
|
+
return f"Task {task_id} not found."
|
5602
|
+
|
5603
|
+
# Convert ticket to scheduled task
|
5604
|
+
scheduled_task = ScheduledTask(
|
5605
|
+
task_id=task_id,
|
5606
|
+
title=ticket.query[:50] +
|
5607
|
+
"..." if len(ticket.query) > 50 else ticket.query,
|
5608
|
+
description=ticket.query,
|
5609
|
+
estimated_minutes=ticket.complexity.get(
|
5610
|
+
"estimated_minutes", 30) if ticket.complexity else 30,
|
5611
|
+
priority=5, # Default priority
|
5612
|
+
assigned_to=agent_id or ticket.assigned_to,
|
5613
|
+
# Use current agent as a specialization tag
|
5614
|
+
specialization_tags=[ticket.assigned_to],
|
5615
|
+
)
|
4770
5616
|
|
4771
|
-
|
4772
|
-
|
4773
|
-
|
4774
|
-
|
4775
|
-
|
4776
|
-
|
4777
|
-
|
4778
|
-
|
4779
|
-
|
4780
|
-
|
5617
|
+
# Set scheduled time if provided
|
5618
|
+
if time_str:
|
5619
|
+
try:
|
5620
|
+
scheduled_time = datetime.datetime.fromisoformat(
|
5621
|
+
time_str)
|
5622
|
+
scheduled_task.scheduled_start = scheduled_time
|
5623
|
+
scheduled_task.scheduled_end = scheduled_time + datetime.timedelta(
|
5624
|
+
minutes=scheduled_task.estimated_minutes
|
5625
|
+
)
|
5626
|
+
except ValueError:
|
5627
|
+
return "Invalid date format. Use YYYY-MM-DD HH:MM."
|
4781
5628
|
|
4782
|
-
|
4783
|
-
|
4784
|
-
|
4785
|
-
|
5629
|
+
# Schedule the task
|
5630
|
+
result = await self.scheduling_service.schedule_task(
|
5631
|
+
scheduled_task, preferred_agent_id=agent_id
|
5632
|
+
)
|
4786
5633
|
|
4787
|
-
|
4788
|
-
|
4789
|
-
|
4790
|
-
|
4791
|
-
|
4792
|
-
|
4793
|
-
|
5634
|
+
# Update ticket with scheduling info
|
5635
|
+
self.ticket_service.update_ticket_status(
|
5636
|
+
task_id,
|
5637
|
+
ticket.status,
|
5638
|
+
scheduled_start=result.scheduled_start,
|
5639
|
+
scheduled_agent=result.assigned_to
|
5640
|
+
)
|
4794
5641
|
|
4795
|
-
|
4796
|
-
|
4797
|
-
|
4798
|
-
|
4799
|
-
|
4800
|
-
|
5642
|
+
# Format response
|
5643
|
+
response = "# Task Scheduled\n\n"
|
5644
|
+
response += f"**Task:** {scheduled_task.title}\n"
|
5645
|
+
response += f"**Assigned to:** {result.assigned_to}\n"
|
5646
|
+
response += f"**Scheduled start:** {result.scheduled_start.strftime('%Y-%m-%d %H:%M')}\n"
|
5647
|
+
response += f"**Estimated duration:** {result.estimated_minutes} minutes"
|
4801
5648
|
|
4802
|
-
|
5649
|
+
return response
|
4803
5650
|
|
4804
|
-
|
4805
|
-
|
4806
|
-
|
4807
|
-
|
4808
|
-
|
4809
|
-
|
5651
|
+
elif command == "!timeoff" and args and self.scheduling_service:
|
5652
|
+
# Format: !timeoff request YYYY-MM-DD HH:MM YYYY-MM-DD HH:MM reason
|
5653
|
+
# or: !timeoff cancel request_id
|
5654
|
+
parts = args.strip().split(" ", 1)
|
5655
|
+
if len(parts) < 2:
|
5656
|
+
return "Usage: \n- !timeoff request START_DATE END_DATE reason\n- !timeoff cancel request_id"
|
4810
5657
|
|
4811
|
-
|
4812
|
-
|
5658
|
+
action = parts[0].lower()
|
5659
|
+
action_args = parts[1]
|
4813
5660
|
|
4814
|
-
|
4815
|
-
|
4816
|
-
|
4817
|
-
|
4818
|
-
|
5661
|
+
if action == "request":
|
5662
|
+
# Parse request args
|
5663
|
+
request_parts = action_args.split(" ", 2)
|
5664
|
+
if len(request_parts) < 3:
|
5665
|
+
return "Usage: !timeoff request YYYY-MM-DD YYYY-MM-DD reason"
|
4819
5666
|
|
4820
|
-
|
4821
|
-
|
4822
|
-
|
5667
|
+
start_str = request_parts[0]
|
5668
|
+
end_str = request_parts[1]
|
5669
|
+
reason = request_parts[2]
|
4823
5670
|
|
4824
|
-
|
4825
|
-
|
4826
|
-
|
4827
|
-
|
4828
|
-
|
4829
|
-
|
4830
|
-
|
4831
|
-
|
4832
|
-
|
4833
|
-
|
5671
|
+
try:
|
5672
|
+
start_time = datetime.datetime.fromisoformat(start_str)
|
5673
|
+
end_time = datetime.datetime.fromisoformat(end_str)
|
5674
|
+
except ValueError:
|
5675
|
+
return "Invalid date format. Use YYYY-MM-DD HH:MM."
|
5676
|
+
|
5677
|
+
# Submit time off request
|
5678
|
+
success, status, request_id = await self.scheduling_service.request_time_off(
|
5679
|
+
user_id, start_time, end_time, reason
|
5680
|
+
)
|
4834
5681
|
|
4835
|
-
|
4836
|
-
|
4837
|
-
|
4838
|
-
|
5682
|
+
if success:
|
5683
|
+
return f"Time off request submitted and automatically approved. Request ID: {request_id}"
|
5684
|
+
else:
|
5685
|
+
return f"Time off request {status}. Request ID: {request_id}"
|
5686
|
+
|
5687
|
+
elif action == "cancel":
|
5688
|
+
request_id = action_args.strip()
|
5689
|
+
success, status = await self.scheduling_service.cancel_time_off_request(
|
5690
|
+
user_id, request_id
|
5691
|
+
)
|
5692
|
+
|
5693
|
+
if success:
|
5694
|
+
return f"Time off request {request_id} cancelled successfully."
|
5695
|
+
else:
|
5696
|
+
return f"Failed to cancel request: {status}"
|
5697
|
+
|
5698
|
+
return "Unknown timeoff action. Use 'request' or 'cancel'."
|
5699
|
+
|
5700
|
+
elif command == "!schedule-view" and self.scheduling_service:
|
5701
|
+
# View agent's schedule for the next week
|
5702
|
+
# Default to current user if no agent specified
|
5703
|
+
agent_id = args.strip() if args else user_id
|
5704
|
+
|
5705
|
+
start_time = datetime.datetime.now(datetime.timezone.utc)
|
5706
|
+
end_time = start_time + datetime.timedelta(days=7)
|
4839
5707
|
|
4840
|
-
|
4841
|
-
|
4842
|
-
|
4843
|
-
user_id, request_id
|
5708
|
+
# Get tasks for the specified time period
|
5709
|
+
tasks = await self.scheduling_service.get_agent_tasks(
|
5710
|
+
agent_id, start_time, end_time, include_completed=False
|
4844
5711
|
)
|
4845
5712
|
|
4846
|
-
if
|
4847
|
-
return f"
|
4848
|
-
else:
|
4849
|
-
return f"Failed to cancel request: {status}"
|
5713
|
+
if not tasks:
|
5714
|
+
return f"No scheduled tasks found for {agent_id} in the next 7 days."
|
4850
5715
|
|
4851
|
-
|
5716
|
+
# Sort by start time
|
5717
|
+
tasks.sort(
|
5718
|
+
key=lambda t: t.scheduled_start or datetime.datetime.max)
|
4852
5719
|
|
4853
|
-
|
4854
|
-
|
4855
|
-
# Default to current user if no agent specified
|
4856
|
-
agent_id = args.strip() if args else user_id
|
5720
|
+
# Format response
|
5721
|
+
response = f"# Schedule for {agent_id}\n\n"
|
4857
5722
|
|
4858
|
-
|
4859
|
-
|
5723
|
+
current_day = None
|
5724
|
+
for task in tasks:
|
5725
|
+
# Group by day
|
5726
|
+
task_day = task.scheduled_start.strftime(
|
5727
|
+
"%Y-%m-%d") if task.scheduled_start else "Unscheduled"
|
4860
5728
|
|
4861
|
-
|
4862
|
-
|
4863
|
-
|
4864
|
-
)
|
5729
|
+
if task_day != current_day:
|
5730
|
+
response += f"\n## {task_day}\n\n"
|
5731
|
+
current_day = task_day
|
4865
5732
|
|
4866
|
-
|
4867
|
-
|
5733
|
+
start_time = task.scheduled_start.strftime(
|
5734
|
+
"%H:%M") if task.scheduled_start else "TBD"
|
5735
|
+
response += f"- **{start_time}** ({task.estimated_minutes} min): {task.title}\n"
|
4868
5736
|
|
4869
|
-
|
4870
|
-
tasks.sort(key=lambda t: t.scheduled_start or datetime.datetime.max)
|
5737
|
+
return response
|
4871
5738
|
|
4872
|
-
|
4873
|
-
|
5739
|
+
elif command == "!resources" and self.resource_service:
|
5740
|
+
# Format: !resources [list|find|show|create|update|delete]
|
5741
|
+
parts = args.strip().split(" ", 1)
|
5742
|
+
subcommand = parts[0] if parts else "list"
|
5743
|
+
subcmd_args = parts[1] if len(parts) > 1 else ""
|
5744
|
+
|
5745
|
+
if subcommand == "list":
|
5746
|
+
# List available resources, optionally filtered by type
|
5747
|
+
resource_type = subcmd_args if subcmd_args else None
|
5748
|
+
|
5749
|
+
query = {}
|
5750
|
+
if resource_type:
|
5751
|
+
query["resource_type"] = resource_type
|
5752
|
+
|
5753
|
+
resources = self.resource_service.repository.find_resources(
|
5754
|
+
query)
|
5755
|
+
|
5756
|
+
if not resources:
|
5757
|
+
return "No resources found."
|
5758
|
+
|
5759
|
+
response = "# Available Resources\n\n"
|
5760
|
+
|
5761
|
+
# Group by type
|
5762
|
+
resources_by_type = {}
|
5763
|
+
for resource in resources:
|
5764
|
+
r_type = resource.resource_type
|
5765
|
+
if r_type not in resources_by_type:
|
5766
|
+
resources_by_type[r_type] = []
|
5767
|
+
resources_by_type[r_type].append(resource)
|
5768
|
+
|
5769
|
+
for r_type, r_list in resources_by_type.items():
|
5770
|
+
response += f"## {r_type.capitalize()}\n\n"
|
5771
|
+
for resource in r_list:
|
5772
|
+
status_emoji = "🟢" if resource.status == "available" else "🔴"
|
5773
|
+
response += f"{status_emoji} **{resource.name}** (ID: {resource.id})\n"
|
5774
|
+
if resource.description:
|
5775
|
+
response += f" {resource.description}\n"
|
5776
|
+
if resource.location and resource.location.building:
|
5777
|
+
response += f" Location: {resource.location.building}"
|
5778
|
+
if resource.location.room:
|
5779
|
+
response += f", Room {resource.location.room}"
|
5780
|
+
response += "\n"
|
5781
|
+
if resource.capacity:
|
5782
|
+
response += f" Capacity: {resource.capacity}\n"
|
5783
|
+
response += "\n"
|
4874
5784
|
|
4875
|
-
|
4876
|
-
for task in tasks:
|
4877
|
-
# Group by day
|
4878
|
-
task_day = task.scheduled_start.strftime(
|
4879
|
-
"%Y-%m-%d") if task.scheduled_start else "Unscheduled"
|
5785
|
+
return response
|
4880
5786
|
|
4881
|
-
|
4882
|
-
|
4883
|
-
|
5787
|
+
elif subcommand == "show" and subcmd_args:
|
5788
|
+
# Show details for a specific resource
|
5789
|
+
resource_id = subcmd_args.strip()
|
5790
|
+
resource = await self.resource_service.get_resource(resource_id)
|
5791
|
+
|
5792
|
+
if not resource:
|
5793
|
+
return f"Resource with ID {resource_id} not found."
|
5794
|
+
|
5795
|
+
response = f"# Resource: {resource.name}\n\n"
|
5796
|
+
response += f"**ID**: {resource.id}\n"
|
5797
|
+
response += f"**Type**: {resource.resource_type}\n"
|
5798
|
+
response += f"**Status**: {resource.status}\n"
|
5799
|
+
|
5800
|
+
if resource.description:
|
5801
|
+
response += f"\n**Description**: {resource.description}\n"
|
5802
|
+
|
5803
|
+
if resource.location:
|
5804
|
+
response += "\n**Location**:\n"
|
5805
|
+
if resource.location.address:
|
5806
|
+
response += f"- Address: {resource.location.address}\n"
|
5807
|
+
if resource.location.building:
|
5808
|
+
response += f"- Building: {resource.location.building}\n"
|
5809
|
+
if resource.location.floor is not None:
|
5810
|
+
response += f"- Floor: {resource.location.floor}\n"
|
5811
|
+
if resource.location.room:
|
5812
|
+
response += f"- Room: {resource.location.room}\n"
|
5813
|
+
|
5814
|
+
if resource.capacity:
|
5815
|
+
response += f"\n**Capacity**: {resource.capacity}\n"
|
5816
|
+
|
5817
|
+
if resource.tags:
|
5818
|
+
response += f"\n**Tags**: {', '.join(resource.tags)}\n"
|
5819
|
+
|
5820
|
+
# Show availability schedule
|
5821
|
+
if resource.availability_schedule:
|
5822
|
+
response += "\n**Regular Availability**:\n"
|
5823
|
+
for window in resource.availability_schedule:
|
5824
|
+
days = "Every day"
|
5825
|
+
if window.day_of_week:
|
5826
|
+
day_names = [
|
5827
|
+
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
5828
|
+
days = ", ".join([day_names[d]
|
5829
|
+
for d in window.day_of_week])
|
5830
|
+
response += f"- {days}: {window.start_time} - {window.end_time} ({window.timezone})\n"
|
5831
|
+
|
5832
|
+
# Show upcoming bookings
|
5833
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
5834
|
+
next_month = now + datetime.timedelta(days=30)
|
5835
|
+
bookings = self.resource_service.repository.get_resource_bookings(
|
5836
|
+
resource_id, now, next_month)
|
5837
|
+
|
5838
|
+
if bookings:
|
5839
|
+
response += "\n**Upcoming Bookings**:\n"
|
5840
|
+
for booking in bookings:
|
5841
|
+
start_str = booking.start_time.strftime(
|
5842
|
+
"%Y-%m-%d %H:%M")
|
5843
|
+
end_str = booking.end_time.strftime("%H:%M")
|
5844
|
+
response += f"- {start_str} - {end_str}: {booking.title}\n"
|
4884
5845
|
|
4885
|
-
|
4886
|
-
"%H:%M") if task.scheduled_start else "TBD"
|
4887
|
-
response += f"- **{start_time}** ({task.estimated_minutes} min): {task.title}\n"
|
5846
|
+
return response
|
4888
5847
|
|
4889
|
-
|
5848
|
+
elif subcommand == "find":
|
5849
|
+
# Find available resources for a time period
|
5850
|
+
# Format: !resources find room 2023-03-15 14:00 16:00
|
5851
|
+
parts = subcmd_args.split()
|
5852
|
+
if len(parts) < 4:
|
5853
|
+
return "Usage: !resources find [type] [date] [start_time] [end_time] [capacity]"
|
5854
|
+
|
5855
|
+
resource_type = parts[0]
|
5856
|
+
date_str = parts[1]
|
5857
|
+
start_time_str = parts[2]
|
5858
|
+
end_time_str = parts[3]
|
5859
|
+
capacity = int(parts[4]) if len(parts) > 4 else None
|
5860
|
+
|
5861
|
+
try:
|
5862
|
+
# Parse date and times
|
5863
|
+
date_obj = datetime.datetime.strptime(
|
5864
|
+
date_str, "%Y-%m-%d").date()
|
5865
|
+
start_time = datetime.datetime.combine(
|
5866
|
+
date_obj,
|
5867
|
+
datetime.datetime.strptime(
|
5868
|
+
start_time_str, "%H:%M").time(),
|
5869
|
+
tzinfo=datetime.timezone.utc
|
5870
|
+
)
|
5871
|
+
end_time = datetime.datetime.combine(
|
5872
|
+
date_obj,
|
5873
|
+
datetime.datetime.strptime(
|
5874
|
+
end_time_str, "%H:%M").time(),
|
5875
|
+
tzinfo=datetime.timezone.utc
|
5876
|
+
)
|
5877
|
+
|
5878
|
+
# Find available resources
|
5879
|
+
resources = await self.resource_service.find_available_resources(
|
5880
|
+
resource_type=resource_type,
|
5881
|
+
start_time=start_time,
|
5882
|
+
end_time=end_time,
|
5883
|
+
capacity=capacity
|
5884
|
+
)
|
5885
|
+
|
5886
|
+
if not resources:
|
5887
|
+
return f"No {resource_type}s available for the requested time period."
|
5888
|
+
|
5889
|
+
response = f"# Available {resource_type.capitalize()}s\n\n"
|
5890
|
+
response += f"**Date**: {date_str}\n"
|
5891
|
+
response += f"**Time**: {start_time_str} - {end_time_str}\n"
|
5892
|
+
if capacity:
|
5893
|
+
response += f"**Minimum Capacity**: {capacity}\n"
|
5894
|
+
response += "\n"
|
5895
|
+
|
5896
|
+
for resource in resources:
|
5897
|
+
response += f"- **{resource.name}** (ID: {resource.id})\n"
|
5898
|
+
if resource.description:
|
5899
|
+
response += f" {resource.description}\n"
|
5900
|
+
if resource.capacity:
|
5901
|
+
response += f" Capacity: {resource.capacity}\n"
|
5902
|
+
if resource.location and resource.location.building:
|
5903
|
+
response += f" Location: {resource.location.building}"
|
5904
|
+
if resource.location.room:
|
5905
|
+
response += f", Room {resource.location.room}"
|
5906
|
+
response += "\n"
|
5907
|
+
response += "\n"
|
5908
|
+
|
5909
|
+
return response
|
5910
|
+
|
5911
|
+
except ValueError as e:
|
5912
|
+
return f"Error parsing date/time: {e}"
|
5913
|
+
|
5914
|
+
elif subcommand == "book":
|
5915
|
+
# Book a resource
|
5916
|
+
# Format: !resources book [resource_id] [date] [start_time] [end_time] [title]
|
5917
|
+
parts = subcmd_args.split(" ", 5)
|
5918
|
+
if len(parts) < 5:
|
5919
|
+
return "Usage: !resources book [resource_id] [date] [start_time] [end_time] [title]"
|
5920
|
+
|
5921
|
+
resource_id = parts[0]
|
5922
|
+
date_str = parts[1]
|
5923
|
+
start_time_str = parts[2]
|
5924
|
+
end_time_str = parts[3]
|
5925
|
+
title = parts[4] if len(parts) > 4 else "Booking"
|
5926
|
+
|
5927
|
+
try:
|
5928
|
+
# Parse date and times
|
5929
|
+
date_obj = datetime.datetime.strptime(
|
5930
|
+
date_str, "%Y-%m-%d").date()
|
5931
|
+
start_time = datetime.datetime.combine(
|
5932
|
+
date_obj,
|
5933
|
+
datetime.datetime.strptime(
|
5934
|
+
start_time_str, "%H:%M").time(),
|
5935
|
+
tzinfo=datetime.timezone.utc
|
5936
|
+
)
|
5937
|
+
end_time = datetime.datetime.combine(
|
5938
|
+
date_obj,
|
5939
|
+
datetime.datetime.strptime(
|
5940
|
+
end_time_str, "%H:%M").time(),
|
5941
|
+
tzinfo=datetime.timezone.utc
|
5942
|
+
)
|
5943
|
+
|
5944
|
+
# Create booking
|
5945
|
+
success, booking, error = await self.resource_service.create_booking(
|
5946
|
+
resource_id=resource_id,
|
5947
|
+
user_id=user_id,
|
5948
|
+
title=title,
|
5949
|
+
start_time=start_time,
|
5950
|
+
end_time=end_time
|
5951
|
+
)
|
5952
|
+
|
5953
|
+
if not success:
|
5954
|
+
return f"Failed to book resource: {error}"
|
5955
|
+
|
5956
|
+
# Get resource details
|
5957
|
+
resource = await self.resource_service.get_resource(resource_id)
|
5958
|
+
resource_name = resource.name if resource else resource_id
|
5959
|
+
|
5960
|
+
response = "# Booking Confirmed\n\n"
|
5961
|
+
response += f"**Resource**: {resource_name}\n"
|
5962
|
+
response += f"**Date**: {date_str}\n"
|
5963
|
+
response += f"**Time**: {start_time_str} - {end_time_str}\n"
|
5964
|
+
response += f"**Title**: {title}\n"
|
5965
|
+
response += f"**Booking ID**: {booking.id}\n\n"
|
5966
|
+
response += "Your booking has been confirmed and added to your schedule."
|
5967
|
+
|
5968
|
+
return response
|
5969
|
+
|
5970
|
+
except ValueError as e:
|
5971
|
+
return f"Error parsing date/time: {e}"
|
5972
|
+
|
5973
|
+
elif subcommand == "bookings":
|
5974
|
+
# View all bookings for the current user
|
5975
|
+
include_cancelled = "all" in subcmd_args.lower()
|
5976
|
+
|
5977
|
+
bookings = await self.resource_service.get_user_bookings(user_id, include_cancelled)
|
5978
|
+
|
5979
|
+
if not bookings:
|
5980
|
+
return "You don't have any bookings." + (
|
5981
|
+
" (Use 'bookings all' to include cancelled bookings)" if not include_cancelled else ""
|
5982
|
+
)
|
5983
|
+
|
5984
|
+
response = "# Your Bookings\n\n"
|
5985
|
+
|
5986
|
+
# Group bookings by date
|
5987
|
+
bookings_by_date = {}
|
5988
|
+
for booking_data in bookings:
|
5989
|
+
booking = booking_data["booking"]
|
5990
|
+
resource = booking_data["resource"]
|
5991
|
+
|
5992
|
+
date_str = booking["start_time"].strftime("%Y-%m-%d")
|
5993
|
+
if date_str not in bookings_by_date:
|
5994
|
+
bookings_by_date[date_str] = []
|
5995
|
+
|
5996
|
+
bookings_by_date[date_str].append((booking, resource))
|
5997
|
+
|
5998
|
+
# Sort dates
|
5999
|
+
for date_str in sorted(bookings_by_date.keys()):
|
6000
|
+
response += f"## {date_str}\n\n"
|
6001
|
+
|
6002
|
+
for booking, resource in bookings_by_date[date_str]:
|
6003
|
+
start_time = booking["start_time"].strftime(
|
6004
|
+
"%H:%M")
|
6005
|
+
end_time = booking["end_time"].strftime("%H:%M")
|
6006
|
+
resource_name = resource["name"] if resource else "Unknown Resource"
|
6007
|
+
|
6008
|
+
status_emoji = "🟢" if booking["status"] == "confirmed" else "🔴"
|
6009
|
+
response += f"{status_emoji} **{start_time}-{end_time}**: {booking['title']}\n"
|
6010
|
+
response += f" Resource: {resource_name}\n"
|
6011
|
+
response += f" Booking ID: {booking['id']}\n\n"
|
6012
|
+
|
6013
|
+
return response
|
6014
|
+
|
6015
|
+
elif subcommand == "cancel" and subcmd_args:
|
6016
|
+
# Cancel a booking
|
6017
|
+
booking_id = subcmd_args.strip()
|
6018
|
+
|
6019
|
+
success, error = await self.resource_service.cancel_booking(booking_id, user_id)
|
6020
|
+
|
6021
|
+
if success:
|
6022
|
+
return "✅ Your booking has been successfully cancelled."
|
6023
|
+
else:
|
6024
|
+
return f"❌ Failed to cancel booking: {error}"
|
6025
|
+
|
6026
|
+
elif subcommand == "schedule" and subcmd_args:
|
6027
|
+
# View resource schedule
|
6028
|
+
# Format: !resources schedule resource_id [YYYY-MM-DD] [days]
|
6029
|
+
parts = subcmd_args.split()
|
6030
|
+
if len(parts) < 1:
|
6031
|
+
return "Usage: !resources schedule resource_id [YYYY-MM-DD] [days]"
|
6032
|
+
|
6033
|
+
resource_id = parts[0]
|
6034
|
+
|
6035
|
+
# Default to today and 7 days
|
6036
|
+
start_date = datetime.datetime.now(datetime.timezone.utc)
|
6037
|
+
days = 7
|
6038
|
+
|
6039
|
+
if len(parts) > 1:
|
6040
|
+
try:
|
6041
|
+
start_date = datetime.datetime.strptime(
|
6042
|
+
parts[1], "%Y-%m-%d"
|
6043
|
+
).replace(tzinfo=datetime.timezone.utc)
|
6044
|
+
except ValueError:
|
6045
|
+
return "Invalid date format. Use YYYY-MM-DD."
|
6046
|
+
|
6047
|
+
if len(parts) > 2:
|
6048
|
+
try:
|
6049
|
+
days = min(int(parts[2]), 31) # Limit to 31 days
|
6050
|
+
except ValueError:
|
6051
|
+
return "Days must be a number."
|
6052
|
+
|
6053
|
+
end_date = start_date + datetime.timedelta(days=days)
|
6054
|
+
|
6055
|
+
# Get the resource
|
6056
|
+
resource = await self.resource_service.get_resource(resource_id)
|
6057
|
+
if not resource:
|
6058
|
+
return f"Resource with ID {resource_id} not found."
|
6059
|
+
|
6060
|
+
# Get schedule
|
6061
|
+
schedule = await self.resource_service.get_resource_schedule(
|
6062
|
+
resource_id, start_date, end_date
|
6063
|
+
)
|
6064
|
+
|
6065
|
+
# Create calendar visualization
|
6066
|
+
response = f"# Schedule for {resource.name}\n\n"
|
6067
|
+
response += f"Period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}\n\n"
|
6068
|
+
|
6069
|
+
# Group by date
|
6070
|
+
schedule_by_date = {}
|
6071
|
+
current_date = start_date
|
6072
|
+
while current_date < end_date:
|
6073
|
+
date_str = current_date.strftime("%Y-%m-%d")
|
6074
|
+
schedule_by_date[date_str] = []
|
6075
|
+
current_date += datetime.timedelta(days=1)
|
6076
|
+
|
6077
|
+
# Add entries to appropriate dates
|
6078
|
+
for entry in schedule:
|
6079
|
+
date_str = entry["start_time"].strftime("%Y-%m-%d")
|
6080
|
+
if date_str in schedule_by_date:
|
6081
|
+
schedule_by_date[date_str].append(entry)
|
6082
|
+
|
6083
|
+
# Generate calendar view
|
6084
|
+
for date_str, entries in schedule_by_date.items():
|
6085
|
+
# Convert to datetime for day of week
|
6086
|
+
entry_date = datetime.datetime.strptime(
|
6087
|
+
date_str, "%Y-%m-%d")
|
6088
|
+
day_of_week = entry_date.strftime("%A")
|
6089
|
+
|
6090
|
+
response += f"## {date_str} ({day_of_week})\n\n"
|
6091
|
+
|
6092
|
+
if not entries:
|
6093
|
+
response += "No bookings or exceptions\n\n"
|
6094
|
+
continue
|
6095
|
+
|
6096
|
+
# Sort by start time
|
6097
|
+
entries.sort(key=lambda x: x["start_time"])
|
6098
|
+
|
6099
|
+
for entry in entries:
|
6100
|
+
start_time = entry["start_time"].strftime("%H:%M")
|
6101
|
+
end_time = entry["end_time"].strftime("%H:%M")
|
6102
|
+
|
6103
|
+
if entry["type"] == "booking":
|
6104
|
+
response += f"- **{start_time}-{end_time}**: {entry['title']} (by {entry['user_id']})\n"
|
6105
|
+
else: # exception
|
6106
|
+
response += f"- **{start_time}-{end_time}**: {entry['status']} (Unavailable)\n"
|
6107
|
+
|
6108
|
+
response += "\n"
|
6109
|
+
|
6110
|
+
return response
|
4890
6111
|
|
4891
6112
|
return None
|
4892
6113
|
|
@@ -5589,7 +6810,8 @@ class SolanaAgentFactory:
|
|
5589
6810
|
project_approval_service=project_approval_service,
|
5590
6811
|
project_simulation_service=project_simulation_service,
|
5591
6812
|
require_human_approval=config.get("require_human_approval", False),
|
5592
|
-
scheduling_service=scheduling_service
|
6813
|
+
scheduling_service=scheduling_service,
|
6814
|
+
stalled_ticket_timeout=config.get("stalled_ticket_timeout", 60),
|
5593
6815
|
)
|
5594
6816
|
|
5595
6817
|
return query_processor
|
@@ -5876,6 +7098,22 @@ class TenantManager:
|
|
5876
7098
|
llm_provider, task_planning_service, ticket_repo
|
5877
7099
|
)
|
5878
7100
|
|
7101
|
+
# Create scheduling repository and service
|
7102
|
+
tenant_db_adapter = self._create_tenant_db_adapter(
|
7103
|
+
tenant) # Get the DB adapter properly
|
7104
|
+
scheduling_repository = SchedulingRepository(
|
7105
|
+
tenant_db_adapter) # Use the correct adapter
|
7106
|
+
|
7107
|
+
scheduling_service = SchedulingService(
|
7108
|
+
scheduling_repository=scheduling_repository,
|
7109
|
+
task_planning_service=task_planning_service,
|
7110
|
+
agent_service=agent_service
|
7111
|
+
)
|
7112
|
+
|
7113
|
+
# Update task_planning_service with scheduling_service if needed
|
7114
|
+
if task_planning_service:
|
7115
|
+
task_planning_service.scheduling_service = scheduling_service
|
7116
|
+
|
5879
7117
|
# Create query processor
|
5880
7118
|
return QueryProcessor(
|
5881
7119
|
agent_service=agent_service,
|
@@ -5895,6 +7133,9 @@ class TenantManager:
|
|
5895
7133
|
require_human_approval=tenant.get_config_value(
|
5896
7134
|
"require_human_approval", False
|
5897
7135
|
),
|
7136
|
+
scheduling_service=scheduling_service,
|
7137
|
+
stalled_ticket_timeout=tenant.get_config_value(
|
7138
|
+
"stalled_ticket_timeout"),
|
5898
7139
|
)
|
5899
7140
|
|
5900
7141
|
def _deep_merge(self, target: Dict, source: Dict) -> None:
|