refactor: 重构 Agent 模块为 AgentScope,删除旧版 CrewAI/LiteLLM 实现
This commit is contained in:
@@ -4,7 +4,7 @@ from uuid import UUID
|
||||
from pydantic import Field
|
||||
|
||||
from core.auth.jwt_verifier import JwtVerifier, TokenValidationError
|
||||
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
|
||||
from core.agentscope.tools.custom.calendar_backend_ops import (
|
||||
_execute_list_calendar_events,
|
||||
_execute_mutate_calendar_event,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
||||
from v1.schedule_items.schemas import (
|
||||
ScheduleItemCreateRequest,
|
||||
ScheduleItemMetadata,
|
||||
ScheduleItemStatus,
|
||||
ScheduleItemUpdateRequest,
|
||||
)
|
||||
from v1.schedule_items.service import ScheduleItemService
|
||||
|
||||
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_positive_int(
|
||||
value: object,
|
||||
*,
|
||||
default: int,
|
||||
minimum: int,
|
||||
maximum: int,
|
||||
) -> int:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
candidate: int | float | str
|
||||
if isinstance(value, (int, float, str)):
|
||||
candidate = value
|
||||
else:
|
||||
return default
|
||||
if isinstance(candidate, str):
|
||||
candidate = candidate.strip()
|
||||
try:
|
||||
parsed = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if parsed < minimum:
|
||||
return minimum
|
||||
if parsed > maximum:
|
||||
return maximum
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_event_id(value: object) -> UUID:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("eventId is required")
|
||||
try:
|
||||
return UUID(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("eventId must be a valid UUID") from exc
|
||||
|
||||
|
||||
def _service(session: AsyncSession, owner_id: UUID) -> ScheduleItemService:
|
||||
return ScheduleItemService(
|
||||
repository=SQLAlchemyScheduleItemRepository(session),
|
||||
session=session,
|
||||
current_user=CurrentUser(id=owner_id),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_metadata(tool_args: dict[str, object]) -> ScheduleItemMetadata:
|
||||
location = tool_args.get("location")
|
||||
location_value = location.strip() if isinstance(location, str) else None
|
||||
color = tool_args.get("color")
|
||||
raw_color = color.strip() if isinstance(color, str) and color.strip() else "#4F46E5"
|
||||
color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5"
|
||||
reminder_raw = tool_args.get("reminderMinutes")
|
||||
reminder_value: int | None = None
|
||||
if isinstance(reminder_raw, bool):
|
||||
reminder_value = None
|
||||
elif isinstance(reminder_raw, (int, float, str)):
|
||||
try:
|
||||
parsed = int(str(reminder_raw).strip())
|
||||
if parsed < 0 or parsed > 10080:
|
||||
raise ValueError("reminderMinutes must be 0..10080")
|
||||
reminder_value = parsed
|
||||
except ValueError as exc:
|
||||
raise ValueError("reminderMinutes must be an integer in 0..10080") from exc
|
||||
return ScheduleItemMetadata(
|
||||
location=location_value,
|
||||
color=color_value,
|
||||
reminder_minutes=reminder_value,
|
||||
)
|
||||
|
||||
|
||||
def _event_payload(event: object) -> dict[str, object]:
|
||||
event_id = str(getattr(event, "id"))
|
||||
metadata = getattr(event, "metadata", None)
|
||||
location_value = getattr(metadata, "location", None)
|
||||
color_value = getattr(metadata, "color", None) or "#4F46E5"
|
||||
reminder_minutes_value = getattr(metadata, "reminder_minutes", None)
|
||||
return {
|
||||
"id": event_id,
|
||||
"title": getattr(event, "title"),
|
||||
"description": getattr(event, "description"),
|
||||
"startAt": getattr(event, "start_at").isoformat(),
|
||||
"endAt": getattr(event, "end_at").isoformat()
|
||||
if getattr(event, "end_at") is not None
|
||||
else None,
|
||||
"timezone": getattr(event, "timezone"),
|
||||
"location": location_value,
|
||||
"color": color_value,
|
||||
"reminderMinutes": reminder_minutes_value,
|
||||
}
|
||||
|
||||
|
||||
async def _execute_list_calendar_events(
|
||||
session: AsyncSession,
|
||||
owner_id: UUID,
|
||||
tool_args: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
page = _parse_positive_int(
|
||||
tool_args.get("page"),
|
||||
default=1,
|
||||
minimum=1,
|
||||
maximum=100000,
|
||||
)
|
||||
page_size = _parse_positive_int(
|
||||
tool_args.get("pageSize"),
|
||||
default=20,
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
)
|
||||
service = _service(session, owner_id)
|
||||
items, total = await service.list_paginated(page=page, page_size=page_size)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
|
||||
return {
|
||||
"type": "calendar_event_list.v1",
|
||||
"version": "v1",
|
||||
"data": {
|
||||
"items": [_event_payload(item) for item in items],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
"totalPages": total_pages,
|
||||
},
|
||||
"ok": True,
|
||||
"message": "已获取日程列表",
|
||||
},
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_create(
|
||||
*,
|
||||
service: ScheduleItemService,
|
||||
tool_args: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
title = str(tool_args.get("title", "新的日程")).strip() or "新的日程"
|
||||
description = str(tool_args.get("description", "")).strip() or None
|
||||
start_at = _parse_datetime(tool_args.get("startAt"))
|
||||
if start_at is None:
|
||||
start_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
end_at = _parse_datetime(tool_args.get("endAt"))
|
||||
timezone_value = (
|
||||
str(tool_args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai"
|
||||
)
|
||||
created = await service.create_agent_generated(
|
||||
ScheduleItemCreateRequest(
|
||||
title=title,
|
||||
description=description,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
timezone=timezone_value,
|
||||
metadata=_resolve_metadata(tool_args),
|
||||
)
|
||||
)
|
||||
event_data = _event_payload(created)
|
||||
event_id = str(event_data["id"])
|
||||
return {
|
||||
"type": "calendar_card.v1",
|
||||
"version": "v1",
|
||||
"data": {
|
||||
**event_data,
|
||||
"sourceType": "agent_generated",
|
||||
"ok": True,
|
||||
"message": "日程已创建",
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "link",
|
||||
"label": "查看详情",
|
||||
"target": f"/calendar/events/{event_id}",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_update(
|
||||
*,
|
||||
service: ScheduleItemService,
|
||||
tool_args: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
event_id = _parse_event_id(tool_args.get("eventId"))
|
||||
update_data: dict[str, object] = {}
|
||||
for source_key, target_key in (
|
||||
("title", "title"),
|
||||
("description", "description"),
|
||||
("timezone", "timezone"),
|
||||
):
|
||||
value = tool_args.get(source_key)
|
||||
if isinstance(value, str):
|
||||
update_data[target_key] = value.strip()
|
||||
start_at = _parse_datetime(tool_args.get("startAt"))
|
||||
if start_at is not None:
|
||||
update_data["start_at"] = start_at
|
||||
end_at = _parse_datetime(tool_args.get("endAt"))
|
||||
if end_at is not None:
|
||||
update_data["end_at"] = end_at
|
||||
status_value = tool_args.get("status")
|
||||
if isinstance(status_value, str) and status_value.strip():
|
||||
try:
|
||||
update_data["status"] = ScheduleItemStatus(status_value.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"status must be one of: active, completed, canceled, archived"
|
||||
) from exc
|
||||
has_location = isinstance(tool_args.get("location"), str)
|
||||
has_color = isinstance(tool_args.get("color"), str)
|
||||
has_reminder = "reminderMinutes" in tool_args
|
||||
if has_location or has_color or has_reminder:
|
||||
existing = await service.get_by_id(event_id)
|
||||
metadata_dump = (
|
||||
existing.metadata.model_dump() if existing.metadata is not None else {}
|
||||
)
|
||||
if has_location:
|
||||
metadata_dump["location"] = str(tool_args.get("location")).strip() or None
|
||||
if has_color:
|
||||
color = str(tool_args.get("color")).strip()
|
||||
if not color:
|
||||
metadata_dump["color"] = None
|
||||
elif _HEX_COLOR_PATTERN.match(color):
|
||||
metadata_dump["color"] = color
|
||||
else:
|
||||
raise ValueError("color must be a hex string like #RRGGBB")
|
||||
if has_reminder:
|
||||
reminder_raw = tool_args.get("reminderMinutes")
|
||||
if reminder_raw is None:
|
||||
metadata_dump["reminder_minutes"] = None
|
||||
elif isinstance(reminder_raw, bool):
|
||||
raise ValueError("reminderMinutes must be an integer in 0..10080")
|
||||
else:
|
||||
try:
|
||||
reminder = int(str(reminder_raw).strip())
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"reminderMinutes must be an integer in 0..10080"
|
||||
) from exc
|
||||
if reminder < 0 or reminder > 10080:
|
||||
raise ValueError("reminderMinutes must be 0..10080")
|
||||
metadata_dump["reminder_minutes"] = reminder
|
||||
update_data["metadata"] = ScheduleItemMetadata.model_validate(metadata_dump)
|
||||
|
||||
updated = await service.update(
|
||||
event_id,
|
||||
ScheduleItemUpdateRequest.model_validate(update_data),
|
||||
)
|
||||
event_data = _event_payload(updated)
|
||||
return {
|
||||
"type": "calendar_card.v1",
|
||||
"version": "v1",
|
||||
"data": {
|
||||
**event_data,
|
||||
"sourceType": "agent_generated",
|
||||
"ok": True,
|
||||
"message": "日程已更新",
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "link",
|
||||
"label": "查看详情",
|
||||
"target": f"/calendar/events/{event_data['id']}",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_delete(
|
||||
*,
|
||||
service: ScheduleItemService,
|
||||
tool_args: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
event_id = _parse_event_id(tool_args.get("eventId"))
|
||||
await service.delete(event_id)
|
||||
return {
|
||||
"type": "calendar_operation.v1",
|
||||
"version": "v1",
|
||||
"data": {
|
||||
"operation": "delete",
|
||||
"id": str(event_id),
|
||||
"ok": True,
|
||||
"message": "日程已删除",
|
||||
},
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_mutate_calendar_event(
|
||||
session: AsyncSession,
|
||||
owner_id: UUID,
|
||||
tool_args: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
operation_raw = tool_args.get("operation")
|
||||
if not isinstance(operation_raw, str) or not operation_raw.strip():
|
||||
raise ValueError("operation is required")
|
||||
operation = operation_raw.strip().lower()
|
||||
service = _service(session, owner_id)
|
||||
if operation == "create":
|
||||
return await _execute_create(service=service, tool_args=tool_args)
|
||||
if operation == "update":
|
||||
return await _execute_update(service=service, tool_args=tool_args)
|
||||
if operation == "delete":
|
||||
return await _execute_delete(service=service, tool_args=tool_args)
|
||||
raise ValueError("operation must be one of: create, update, delete")
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from services.base.supabase import supabase_service
|
||||
|
||||
|
||||
class SupabaseToolResultStorage:
|
||||
def _bucket_client(self, *, bucket: str) -> Any:
|
||||
client = supabase_service.get_admin_client()
|
||||
storage = getattr(client, "storage", None)
|
||||
if storage is None:
|
||||
raise RuntimeError("Supabase storage client unavailable")
|
||||
from_bucket = getattr(storage, "from_", None)
|
||||
if not callable(from_bucket):
|
||||
raise RuntimeError("Supabase storage bucket accessor unavailable")
|
||||
return from_bucket(bucket)
|
||||
|
||||
async def upload_json(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
path: str,
|
||||
payload: dict[str, object],
|
||||
) -> str:
|
||||
data = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
def _upload() -> object:
|
||||
bucket_client = self._bucket_client(bucket=bucket)
|
||||
upload = getattr(bucket_client, "upload", None)
|
||||
if not callable(upload):
|
||||
raise RuntimeError("Supabase storage upload is unavailable")
|
||||
return upload(
|
||||
path,
|
||||
data,
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"upsert": "true",
|
||||
},
|
||||
)
|
||||
|
||||
result = await asyncio.to_thread(_upload)
|
||||
return str(result or "")
|
||||
|
||||
async def read_json(self, *, bucket: str, path: str) -> dict[str, object] | None:
|
||||
def _download() -> object:
|
||||
bucket_client = self._bucket_client(bucket=bucket)
|
||||
download = getattr(bucket_client, "download", None)
|
||||
if not callable(download):
|
||||
raise RuntimeError("Supabase storage download is unavailable")
|
||||
return download(path)
|
||||
|
||||
raw = await asyncio.to_thread(_download)
|
||||
if isinstance(raw, bytes):
|
||||
text = raw.decode("utf-8")
|
||||
elif isinstance(raw, str):
|
||||
text = raw
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def create_tool_result_storage() -> SupabaseToolResultStorage | None:
|
||||
try:
|
||||
supabase_service.get_admin_client()
|
||||
except Exception:
|
||||
return None
|
||||
return SupabaseToolResultStorage()
|
||||
Reference in New Issue
Block a user