refactor: finalize agent schemas and profile model

- Remove deprecated calendar UI tests
- Update profile model with phone field support
- Update agent schemas with runtime_models and forwarded_props
- Update system_agent with new agent configuration
This commit is contained in:
qzl
2026-03-19 18:43:35 +08:00
parent 524b91f454
commit 7fd536e976
11 changed files with 195 additions and 324 deletions
@@ -1,51 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/ui/calendar_time_utils.dart';
void main() {
group('calendar_time_utils', () {
test('returns week start on sunday', () {
final date = DateTime(2026, 2, 11);
final weekStart = weekStartFor(date);
expect(weekStart.year, 2026);
expect(weekStart.month, 2);
expect(weekStart.day, 8);
});
test('shows current marker only for selected today', () {
final now = DateTime(2026, 2, 11, 15, 28);
expect(shouldShowCurrentMarker(DateTime(2026, 2, 11), now), isTrue);
expect(shouldShowCurrentMarker(DateTime(2026, 2, 10), now), isFalse);
});
test('formats hour minute with zero pad', () {
expect(formatHm(DateTime(2026, 2, 11, 7, 5)), '07:05');
expect(formatHm(DateTime(2026, 2, 11, 15, 28)), '15:28');
});
test('parses and formats ymd date string', () {
final parsed = parseYmd('2026-02-11');
expect(parsed, isNotNull);
expect(parsed!.year, 2026);
expect(parsed.month, 2);
expect(parsed.day, 11);
expect(formatYmd(parsed), '2026-02-11');
});
test('returns null for invalid ymd date string', () {
expect(parseYmd('2026/02/11'), isNull);
expect(parseYmd('bad-input'), isNull);
expect(parseYmd(null), isNull);
});
test('builds all dates for month', () {
final dates = monthDatesFor(DateTime(2026, 2, 9));
expect(dates.length, 28);
expect(formatYmd(dates.first), '2026-02-01');
expect(formatYmd(dates.last), '2026-02-28');
});
});
}
@@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/widgets/create_event_sheet.dart';
void main() {
testWidgets('编辑日程时支持非默认提醒值', (tester) async {
final event = ScheduleItemModel(
id: 'evt_1',
ownerId: 'user_1',
title: '测试日程',
startAt: DateTime(2026, 3, 18, 10, 0),
endAt: DateTime(2026, 3, 18, 11, 0),
metadata: ScheduleMetadata(reminderMinutes: 20),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: CreateEventSheet(editingEvent: event)),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('进阶'));
await tester.pumpAndSettle();
expect(find.text('开始前20分钟'), findsOneWidget);
});
}
@@ -1,47 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
group('时间自动对齐逻辑', () {
test('开始时间改变后,结束时间早于开始时间应自动对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 9, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
test('结束时间晚于开始时间则不需要对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 12, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
test('结束时间等于开始时间也需要对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 10, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
});
}
@@ -1,104 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_event_layout_engine.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_timeline_metrics.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
void main() {
group('DayEventLayoutEngine', () {
const engine = DayEventLayoutEngine();
const scale = DayViewScale(hourHeight: 60);
test('maps event top and height by exact minutes', () {
final event = _event(
id: 'a',
start: DateTime(2026, 3, 18, 10, 15),
end: DateTime(2026, 3, 18, 11, 45),
);
final layouts = engine.layout(
events: [event],
scale: scale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(1));
expect(layouts.first.startMinutes, 615);
expect(layouts.first.endMinutes, 705);
expect(layouts.first.top, 615);
expect(layouts.first.geometryHeight, 90);
expect(layouts.first.visualHeight, 90);
});
test('splits overlapped events into columns', () {
final e1 = _event(
id: 'a',
start: DateTime(2026, 3, 18, 9, 0),
end: DateTime(2026, 3, 18, 10, 0),
);
final e2 = _event(
id: 'b',
start: DateTime(2026, 3, 18, 9, 30),
end: DateTime(2026, 3, 18, 10, 30),
);
final layouts = engine.layout(
events: [e1, e2],
scale: scale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(2));
expect(layouts[0].columnCount, 2);
expect(layouts[1].columnCount, 2);
expect(layouts[0].width, closeTo(98, 0.001));
expect(layouts[1].width, closeTo(98, 0.001));
expect(layouts[0].left, DayTimelineMetrics.eventAreaLeft());
expect(
layouts[1].left,
closeTo(DayTimelineMetrics.eventAreaLeft() + 102, 0.001),
);
});
test('uses 1 pixel minimum visual height but preserves geometry', () {
final event = _event(
id: 'a',
start: DateTime(2026, 3, 18, 9, 0),
end: DateTime(2026, 3, 18, 9, 1),
);
final tinyScale = const DayViewScale(
hourHeight: DayViewScale.minHourHeight,
);
final layouts = engine.layout(
events: [event],
scale: tinyScale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(1));
expect(
layouts.first.geometryHeight,
closeTo(tinyScale.pixelsForMinutes(1), 0.001),
);
expect(layouts.first.visualHeight, greaterThanOrEqualTo(1));
});
});
}
ScheduleItemModel _event({
required String id,
required DateTime start,
required DateTime end,
}) {
return ScheduleItemModel(
id: id,
ownerId: 'owner',
title: 'event-$id',
startAt: start,
endAt: end,
);
}
@@ -1,31 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
void main() {
group('DayViewScale', () {
test('maps minutes to pixels and back', () {
const scale = DayViewScale(hourHeight: 60);
expect(scale.pixelsForMinutes(30), 30);
expect(scale.pixelsForMinutes(75), 75);
expect(scale.minutesForPixels(90), 90);
});
test('clamps zoom height at boundaries', () {
const scale = DayViewScale(hourHeight: 34);
final zoomIn = scale.zoomByFactor(20);
final zoomOut = scale.zoomByFactor(0.01);
expect(zoomIn.hourHeight, DayViewScale.maxHourHeight);
expect(zoomOut.hourHeight, DayViewScale.minHourHeight);
});
test('ignores invalid zoom factor', () {
const scale = DayViewScale(hourHeight: 34);
expect(scale.zoomByFactor(0).hourHeight, 34);
expect(scale.zoomByFactor(-1).hourHeight, 34);
});
});
}
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/utils/event_color_resolver.dart';
void main() {
test('returns gray for archived status regardless of custom color', () {
final color = resolveEventColor(
status: ScheduleStatus.archived,
colorHex: '#EF4444',
);
expect(color, AppColors.slate400);
});
test('returns parsed color for active status', () {
final color = resolveEventColor(
status: ScheduleStatus.active,
colorHex: '#3B82F6',
);
expect(color.value, const Color(0xFF3B82F6).value);
});
}
+1 -7
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import ForeignKey, String, Text from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -43,9 +43,3 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base):
nullable=False, nullable=False,
server_default="{}", server_default="{}",
) )
referred_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
+49
View File
@@ -1,15 +1,39 @@
from schemas.agent.consumer_registry import AgentConsumerBinding, ConsumerRegistry
from schemas.agent.forwarded_props import ( from schemas.agent.forwarded_props import (
ClientTimeContext, ClientTimeContext,
ForwardedPropsPayload,
parse_forwarded_props_agent_type,
parse_forwarded_props_client_time, parse_forwarded_props_client_time,
) )
from schemas.agent.pipeline_spec import (
ContextPolicy,
ContextWindowMode,
ExecutorKind,
PipelineSpec,
StageSpec,
)
from schemas.agent.runtime_models import ( from schemas.agent.runtime_models import (
AgentOutput, AgentOutput,
ConstraintItem,
ExecutionMode,
KeyEntity,
NormalizedTaskInput,
ResultTyping,
ResultType, ResultType,
RouterAgentOutput,
RouterUiDecision,
RunStatus, RunStatus,
TaskType,
TaskTyping,
ToolAgentOutput, ToolAgentOutput,
ToolStatus, ToolStatus,
UiMode,
WorkerAgentOutputLite,
WorkerAgentOutputRich,
resolve_worker_output_model,
) )
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask
from schemas.agent.ui_hints import ( from schemas.agent.ui_hints import (
UiHintAction, UiHintAction,
UiHintIntent, UiHintIntent,
@@ -21,16 +45,41 @@ from schemas.agent.ui_hints import (
__all__ = [ __all__ = [
"AgentType", "AgentType",
"AgentOutput", "AgentOutput",
"AgentConsumerBinding",
"ConstraintItem",
"ConsumerRegistry",
"ContextPolicy",
"ContextWindowMode",
"ExecutionMode",
"ExecutorKind",
"ForwardedPropsPayload",
"KeyEntity",
"NormalizedTaskInput",
"PipelineSpec",
"ResultTyping",
"ClientTimeContext", "ClientTimeContext",
"ResultType", "ResultType",
"RouterAgentOutput",
"RouterUiDecision",
"RunStatus", "RunStatus",
"TaskType",
"TaskTyping",
"SystemAgentLLMConfig", "SystemAgentLLMConfig",
"SystemVisibilityBit",
"StageSpec",
"ToolAgentOutput", "ToolAgentOutput",
"ToolStatus", "ToolStatus",
"UiMode",
"UiHintAction", "UiHintAction",
"UiHintIntent", "UiHintIntent",
"UiHintSection", "UiHintSection",
"UiHintStatus", "UiHintStatus",
"UiHintsPayload", "UiHintsPayload",
"VisibilityMask",
"WorkerAgentOutputLite",
"WorkerAgentOutputRich",
"bit_mask",
"parse_forwarded_props_agent_type",
"parse_forwarded_props_client_time", "parse_forwarded_props_client_time",
"resolve_worker_output_model",
] ]
+25 -8
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import re import re
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import ( from pydantic import (
@@ -63,16 +62,34 @@ class ClientTimeContext(BaseModel):
class ForwardedPropsPayload(BaseModel): class ForwardedPropsPayload(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
agent_type: str = Field(..., min_length=1, max_length=64)
client_time: ClientTimeContext | None = None client_time: ClientTimeContext | None = None
@field_validator("agent_type")
@classmethod
def validate_agent_type(cls, value: str) -> str:
normalized = value.strip().lower()
if not normalized:
raise ValueError("invalid forwarded_props.agent_type")
return normalized
def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload:
if not isinstance(forwarded_props, dict):
raise ValueError("invalid RunAgentInput.forwardedProps")
try:
return ForwardedPropsPayload.model_validate(forwarded_props)
except ValidationError as exc:
raise ValueError("invalid RunAgentInput.forwardedProps") from exc
def parse_forwarded_props_client_time( def parse_forwarded_props_client_time(
forwarded_props: Any, forwarded_props: object,
) -> ClientTimeContext | None: ) -> ClientTimeContext | None:
if not isinstance(forwarded_props, dict): payload = parse_forwarded_props(forwarded_props)
return None
try:
payload = ForwardedPropsPayload.model_validate(forwarded_props)
except ValidationError as exc:
raise ValueError("invalid RunAgentInput.forwardedProps") from exc
return payload.client_time return payload.client_time
def parse_forwarded_props_agent_type(forwarded_props: object) -> str:
payload = parse_forwarded_props(forwarded_props)
return payload.agent_type
+106 -7
View File
@@ -8,6 +8,22 @@ from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.ui_hints import UiHintsPayload from schemas.agent.ui_hints import UiHintsPayload
class TaskType(str, Enum):
KNOWLEDGE = "knowledge"
RECOMMENDATION = "recommendation"
PLANNING = "planning"
SCHEDULING = "scheduling"
REMINDER_MANAGEMENT = "reminder_management"
TODO_MANAGEMENT = "todo_management"
COMMUNICATION_DRAFTING = "communication_drafting"
INFORMATION_ORGANIZATION = "information_organization"
STATUS_TRACKING = "status_tracking"
TRANSACTION_ASSIST = "transaction_assist"
ACTION_EXECUTION = "action_execution"
TROUBLESHOOTING = "troubleshooting"
UNKNOWN = "unknown"
class ResultType(str, Enum): class ResultType(str, Enum):
DIRECT_ANSWER = "direct_answer" DIRECT_ANSWER = "direct_answer"
OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation" OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation"
@@ -26,6 +42,31 @@ class ResultType(str, Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
class TaskTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: TaskType
secondary: list[TaskType] = Field(default_factory=list, max_length=3)
class ResultTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: ResultType
secondary: list[ResultType] = Field(default_factory=list, max_length=3)
class ExecutionMode(str, Enum):
ONESTEP = "onestep"
TOOL_ASSISTED = "tool_assisted"
MULTISTEP = "multistep"
class UiMode(str, Enum):
NONE = "none"
RICH = "rich"
class RunStatus(str, Enum): class RunStatus(str, Enum):
SUCCESS = "success" SUCCESS = "success"
PARTIAL_SUCCESS = "partial_success" PARTIAL_SUCCESS = "partial_success"
@@ -38,13 +79,55 @@ class ToolStatus(str, Enum):
PARTIAL = "partial" PARTIAL = "partial"
class KeyEntity(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
type: str
value: str | None = None
class ConstraintItem(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
value: str
required: bool = True
class NormalizedTaskInput(BaseModel):
model_config = ConfigDict(extra="forbid")
user_text: str
multimodal_summary: list[str] = Field(default_factory=list)
class RouterUiDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
ui_mode: UiMode
ui_decision_reason: str
class RouterAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
normalized_task_input: NormalizedTaskInput
key_entities: list[KeyEntity] = Field(default_factory=list)
constraints: list[ConstraintItem] = Field(default_factory=list)
task_typing: TaskTyping
execution_mode: ExecutionMode
result_typing: ResultTyping
ui: RouterUiDecision
class ErrorInfo(BaseModel): class ErrorInfo(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
code: str = Field(..., description="Stable error code for programmatic handling.") code: str
message: str = Field(..., description="Human-readable error message.") message: str
retryable: bool = Field(default=False) retryable: bool = False
details: dict[str, Any] | None = Field(default=None) details: dict[str, Any] | None = None
class ToolAgentOutput(BaseModel): class ToolAgentOutput(BaseModel):
@@ -58,13 +141,29 @@ class ToolAgentOutput(BaseModel):
error: ErrorInfo | None = None error: ErrorInfo | None = None
class AgentOutput(BaseModel): class WorkerAgentOutputLite(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
status: RunStatus = Field(default=RunStatus.SUCCESS) status: RunStatus = RunStatus.SUCCESS
answer: str answer: str
key_points: list[str] = Field(default_factory=list) key_points: list[str] = Field(default_factory=list)
result_type: ResultType = Field(default=ResultType.UNKNOWN) result_type: ResultType = ResultType.UNKNOWN
suggested_actions: list[str] = Field(default_factory=list) suggested_actions: list[str] = Field(default_factory=list)
error: ErrorInfo | None = None error: ErrorInfo | None = None
class WorkerAgentOutputRich(WorkerAgentOutputLite):
ui_hints: UiHintsPayload | None = None ui_hints: UiHintsPayload | None = None
class AgentOutput(WorkerAgentOutputRich):
model_config = ConfigDict(extra="forbid")
WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich
def resolve_worker_output_model(ui_mode: UiMode) -> type[WorkerAgentOutputLite]:
if ui_mode == UiMode.RICH:
return WorkerAgentOutputRich
return WorkerAgentOutputLite
+13 -14
View File
@@ -4,10 +4,11 @@ from enum import Enum
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from core.agentscope.tools.tool_config import ToolGroup from core.agentscope.tools.tool_config import AgentTool, parse_agent_tool
class AgentType(str, Enum): class AgentType(str, Enum):
ROUTER = "router"
WORKER = "worker" WORKER = "worker"
MEMORY = "memory" MEMORY = "memory"
@@ -29,24 +30,22 @@ class SystemAgentLLMConfig(BaseModel):
context_messages: ContextMessagesConfig = Field( context_messages: ContextMessagesConfig = Field(
default_factory=ContextMessagesConfig default_factory=ContextMessagesConfig
) )
enabled_tool_groups: list[ToolGroup] = Field(default_factory=list, max_length=8) visibility_consumer_bit: int = Field(default=16, ge=16, le=63)
enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32)
@field_validator("enabled_tool_groups", mode="before") @field_validator("enabled_tools", mode="before")
@classmethod @classmethod
def _normalize_enabled_tool_groups(cls, value: object) -> list[ToolGroup]: def _normalize_enabled_tools(cls, value: object) -> list[AgentTool]:
if value is None: if value is None:
return [] return []
if not isinstance(value, list): if not isinstance(value, list):
raise ValueError("enabled_tool_groups must be a list") raise ValueError("enabled_tools must be a list")
normalized: list[ToolGroup] = [] normalized: list[AgentTool] = []
for item in value: for item in value:
if isinstance(item, ToolGroup): raw_item = str(item or "").strip()
group = item if not raw_item:
else:
raw_group = str(item or "").strip().lower()
if not raw_group:
continue continue
group = ToolGroup(raw_group) tool = parse_agent_tool(raw_item)
if group not in normalized: if tool not in normalized:
normalized.append(group) normalized.append(tool)
return normalized return normalized