feat: 实现日历提醒完整功能(操作执行、通知服务重构、归档)

- 新增 ReminderActionExecutor 处理取消/稍后提醒操作
- 新增 ReminderOutboxStore 本地存储待处理操作
- 重构 LocalNotificationService 支持聚合提醒和交互操作
- 新增 event_color_resolver 工具类统一颜色解析
- 新增 CalendarService.archiveEvent 归档方法
- 增强 ModelTracking 支持缓存命中、推理token和成本追踪
- 添加 qwen3.5-35b-a3b 模型配置
- 更新 AndroidManifest 全屏intent权限
- 补充相关单元测试和文档
This commit is contained in:
qzl
2026-03-18 19:12:47 +08:00
parent 257cb0f5d5
commit 00f37d7e19
35 changed files with 2676 additions and 244 deletions
@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import Any, cast
from core.agentscope.runtime.model_tracking import TrackingChatModel
class _FakeMetadata:
def model_dump(self) -> dict[str, object]:
return {
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"prompt_tokens_details": {
"cached_tokens": 80,
},
"prompt_cache_hit_tokens": 80,
"prompt_cache_miss_tokens": 40,
"completion_tokens_details": {
"reasoning_tokens": 5,
},
}
class _FakeUsage:
input_tokens = 120
output_tokens = 30
time = 1.234
metadata = _FakeMetadata()
class _FakeResponse:
usage = _FakeUsage()
class _FakeModel:
stream = False
async def __call__(self, *args: object, **kwargs: object) -> _FakeResponse:
return _FakeResponse()
class _FakeUsageWithProviderCost:
input_tokens = 50
output_tokens = 10
time = 0.5
cost = 0.0123
metadata = _FakeMetadata()
class _FakeResponseWithProviderCost:
usage = _FakeUsageWithProviderCost()
class _FakeModelWithProviderCost:
stream = False
async def __call__(
self, *args: object, **kwargs: object
) -> _FakeResponseWithProviderCost:
return _FakeResponseWithProviderCost()
class _FakeResponseWithoutUsage:
usage = None
class _FakeModelWithoutUsage:
stream = False
async def __call__(
self, *args: object, **kwargs: object
) -> _FakeResponseWithoutUsage:
return _FakeResponseWithoutUsage()
async def test_tracking_chat_model_collects_primary_usage_fields() -> None:
model = TrackingChatModel(cast(Any, _FakeModel()))
await model("prompt")
summary = model.usage_summary()
assert summary["input_tokens"] == 120
assert summary["output_tokens"] == 30
assert summary["total_tokens"] == 150
assert summary["latency_ms"] == 1234
assert summary["cached_prompt_tokens"] == 80
assert summary["prompt_cache_hit_tokens"] == 80
assert summary["prompt_cache_miss_tokens"] == 40
assert summary["reasoning_tokens"] == 5
assert summary["direct_cost"] == 0.0
assert summary["direct_cost_observed"] == 0
assert summary["direct_cost_complete"] == 0
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 1
assert summary["direct_cost_records"] == 0
assert summary["cost_source"] == "catalog_fallback"
async def test_tracking_chat_model_prefers_provider_cost_when_available() -> None:
model = TrackingChatModel(cast(Any, _FakeModelWithProviderCost()))
await model("prompt")
summary = model.usage_summary()
assert summary["direct_cost"] == 0.0123
assert summary["direct_cost_observed"] == 1
assert summary["direct_cost_complete"] == 1
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 1
assert summary["direct_cost_records"] == 1
assert summary["cost_source"] == "provider"
async def test_tracking_chat_model_marks_direct_cost_incomplete_when_usage_missing() -> (
None
):
model = TrackingChatModel(cast(Any, _FakeModelWithoutUsage()))
await model("prompt")
summary = model.usage_summary()
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 0
assert summary["direct_cost_records"] == 0
assert summary["direct_cost_complete"] == 0
@@ -44,10 +44,75 @@ def test_build_usage_metadata_calculates_cost_from_usage_summary() -> None:
},
)
assert metadata == {
"model": "dashscope/qwen3.5-flash",
"inputTokens": 2000,
"outputTokens": 100,
"cost": pytest.approx(0.00051),
"latencyMs": 321,
}
assert metadata["model"] == "dashscope/qwen3.5-flash"
assert metadata["inputTokens"] == 2000
assert metadata["outputTokens"] == 100
assert metadata["totalTokens"] == 2100
assert metadata["cachedPromptTokens"] == 500
assert metadata["promptCacheHitTokens"] == 500
assert metadata["promptCacheMissTokens"] == 1500
assert metadata["reasoningTokens"] == 0
assert metadata["cost"] == pytest.approx(0.00051)
assert metadata["costSource"] == "catalog_fallback"
assert metadata["usageComplete"] is True
assert metadata["latencyMs"] == 321
def test_build_usage_metadata_prefers_provider_direct_cost() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 1000,
"output_tokens": 100,
"latency_ms": 100,
"cached_prompt_tokens": 0,
"direct_cost": 0.1234,
"direct_cost_observed": 1,
"direct_cost_complete": 1,
},
)
assert metadata["cost"] == pytest.approx(0.1234)
assert metadata["costSource"] == "provider"
assert metadata["usageComplete"] is True
def test_build_usage_metadata_falls_back_when_provider_cost_incomplete() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 1000,
"output_tokens": 100,
"latency_ms": 100,
"cached_prompt_tokens": 0,
"direct_cost": 0.1234,
"direct_cost_observed": 1,
"direct_cost_complete": 0,
},
)
assert metadata["cost"] == pytest.approx(0.0023)
assert metadata["costSource"] == "catalog_fallback_incomplete_provider_cost"
def test_build_usage_metadata_marks_incomplete_usage_fallback() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 0,
"output_tokens": 0,
"latency_ms": 0,
"cached_prompt_tokens": 0,
"model_call_records": 1,
"usage_records": 0,
},
)
assert metadata["costSource"] == "incomplete_usage_fallback"
assert metadata["usageComplete"] is False
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from models.schedule_items import (
@@ -13,6 +14,7 @@ from models.schedule_items import (
)
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemMetadata,
ScheduleItemUpdateRequest,
)
@@ -43,6 +45,7 @@ def _create_mock_schedule_item(
class FakeRepo:
def __init__(self, item: ScheduleItem | None) -> None:
self._item = item
self.archive_expired_called = 0
async def get_by_item_id(
self, item_id: UUID, owner_id: UUID
@@ -89,8 +92,9 @@ class FakeRepo:
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]:
del owner_id, page, page_size
del owner_id, page, page_size, query
return ([self._item] if self._item else [], 1 if self._item else 0)
async def create_subscription(self, data: dict):
@@ -104,7 +108,20 @@ class FakeRepo:
end_at: datetime,
):
del subscriber_id, start_at, end_at
return []
if self._item is None:
return []
subscription = MagicMock()
subscription.permission = 1
return [(self._item, subscription)]
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
self.archive_expired_called += 1
return 0
async def get_user_subscriptions(self, subscriber_id: UUID):
del subscriber_id
@@ -376,3 +393,110 @@ async def test_update_maps_null_metadata_to_extra_metadata_null(
assert "extra_metadata" in captured
assert captured["extra_metadata"] is None
assert "metadata" not in captured
@pytest.mark.asyncio
async def test_list_by_date_range_archives_expired_before_query(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
repo = FakeRepo(item)
service = ScheduleItemService(
repository=repo,
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
assert repo.archive_expired_called == 1
@pytest.mark.asyncio
async def test_list_by_date_range_commits_when_archived_changed(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
class ArchiveRepo(FakeRepo):
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
self.archive_expired_called += 1
return 2
repo = ArchiveRepo(item)
service = ScheduleItemService(
repository=repo,
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_by_date_range_rolls_back_when_query_fails_after_archive(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
class FailingRepo(FakeRepo):
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
return 1
async def list_subscribed_items_by_date_range(
self,
subscriber_id: UUID,
start_at: datetime,
end_at: datetime,
):
del subscriber_id, start_at, end_at
raise SQLAlchemyError("db unavailable")
service = ScheduleItemService(
repository=FailingRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
with pytest.raises(HTTPException) as exc_info:
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
assert exc_info.value.status_code == 503
mock_session.rollback.assert_awaited_once()
mock_session.commit.assert_not_awaited()