Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/voicelife_audio_esp/src/es8311_codec_control.cc
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,11 @@ voicelife::Result<void*> InitializeEs8311(const Es8311ControlConfig& config) {
// esp_codec_dev initializes output volume to 0. XiaoZhi explicitly
// restores its user volume immediately after open; without this call the
// ES8311 remains muted even when PCM reaches the I2S TX channel.
// Match Runtime's serial-validation volume before the first PCM frame.
// Match Runtime's output volume before the first PCM frame.
#if CONFIG_VOICELIFE_SERIAL_VOICE_TEST
constexpr uint8_t kSparkBotDefaultOutputVolume = 35;
#else
constexpr uint8_t kSparkBotDefaultOutputVolume = 70;
constexpr uint8_t kSparkBotDefaultOutputVolume = 100;
#endif
const esp_err_t volume_err = esp_codec_dev_set_out_vol(dev, kSparkBotDefaultOutputVolume);
if (volume_err != ESP_OK) {
Expand Down
659 changes: 291 additions & 368 deletions components/voicelife_mcp/src/tools/schedule_mcp_tools.cc

Large diffs are not rendered by default.

250 changes: 208 additions & 42 deletions components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions components/voicelife_mcp/src/tools/schedule_mcp_tools_input.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ struct ParsedRepeat {
*/
ParsedRepeat ParseRepeat(const std::optional<JsonValue>& repeat, bool require_anchor);

/**
* @brief 解析扁平周期字段。
* @param properties MCP 调用参数。
* @param require_anchor 是否要求 freq_type、start_date 和 start_time 必填。
* @return 解析后的周期字段或错误。
*/
ParsedRepeat ParseRuleProperties(const PropertyList& properties, bool require_anchor);

/**
* @brief 从 MCP 参数和 repeat 字段构造创建周期规则命令。
* @param properties MCP 调用参数。
Expand All @@ -58,15 +66,30 @@ schedule::UpdateScheduleRuleCommand UpdateRuleCommand(const PropertyList& proper
/** @brief 创建 schedule.create 工具参数定义。 @return 参数定义。 */
PropertyList CreateProperties();

/** @brief 创建 schedule.create_rule 工具参数定义。 @return 参数定义。 */
PropertyList CreateRuleProperties();

/** @brief 创建 schedule.query 工具参数定义。 @return 参数定义。 */
PropertyList QueryProperties();

/** @brief 创建 schedule.update 工具参数定义。 @return 参数定义。 */
PropertyList UpdateProperties();

/** @brief 创建 schedule.update_occurrence 工具参数定义。 @return 参数定义。 */
PropertyList UpdateOccurrenceProperties();

/** @brief 创建 schedule.update_rule 工具参数定义。 @return 参数定义。 */
PropertyList UpdateRuleProperties();

/** @brief 创建 schedule.delete 工具参数定义。 @return 参数定义。 */
PropertyList DeleteProperties();

/** @brief 创建 schedule.delete_rule 工具参数定义。 @return 参数定义。 */
PropertyList DeleteRuleProperties();

/** @brief 创建 schedule.skip_occurrence 工具参数定义。 @return 参数定义。 */
PropertyList SkipOccurrenceProperties();

/** @brief 创建 schedule.operation_query 工具参数定义。 @return 参数定义。 */
PropertyList OperationQueryProperties();

Expand Down
7 changes: 4 additions & 3 deletions components/voicelife_runtime/src/runtime.cc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#include "voicelife/runtime/runtime.h"

#include <algorithm>
Expand Down Expand Up @@ -2665,12 +2665,13 @@
vTaskDelete(nullptr);
#endif
}
// Keep production at 70. Serial voice validation runs at half volume so
// physical-board stress tests do not disturb the surrounding environment.
// Production uses full output volume. Serial voice validation runs at
// reduced volume so physical-board stress tests do not disturb the
// surrounding environment.
#if defined(ESP_PLATFORM) && CONFIG_VOICELIFE_SERIAL_VOICE_TEST
int volume_ = 35;
#else
int volume_ = 70;
int volume_ = 100;
#endif
std::atomic<int64_t> capture_started_us_{0};
std::string stt_display_text_;
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture/mcp-tool-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,11 @@ MCP Tool 回调只做四件事:
- `schedules`:已物化日程。
- `future_occurrences`:未来周期候选日程。
- `exceptions`:`schedule_rule_exception` 实体。
7. 语音链路使用数量、最近一条和必要提醒构成摘要文本;当 IM Reporting 可用时,使用
`schedule-query:<request_id>` 作为幂等业务事件 ID,将完整范围、总数、三类条目和查询时间提交到
`POST /v1/im/schedule-query-results`。IM 失败只返回可重试状态,不改变语音摘要。
7. 将完整范围、总数、三类条目和查询时间提交到 `POST /v1/im/schedule-query-results`;使用
`schedule-query:<request_id>` 作为幂等业务事件 ID。IM 失败只影响 `im_delivery` 字段,
不改变返回给模型的结构化结果。
8. 向模型返回结构化 JSON(`schedules`、`future_occurrences`、`exceptions`、`result_count`、
`recent`、`im_delivery`),不再返回自然语言文本摘要。

### `schedule.update` 回调编排

Expand Down Expand Up @@ -303,7 +305,7 @@ MCP Tool 回调只做四件事:
| `schedule.query` | `future_occurrences` | array | 是 | 未来周期候选日程列表,元素为 `future_occurrence` |
| `schedule.query` | `exceptions` | array | 是 | 周期单次例外列表,元素为 `exception` |
| `schedule.query` | `result_count` | integer | 是 | `schedules` 与 `future_occurrences` 的总条数 |
| `schedule.query` | `recent` | object/null | 是 | 语音摘要使用的最近一条日程 |
| `schedule.query` | `recent` | object/null | 是 | 结构化结果中的最近一条日程 |
| `schedule.query` | `im_delivery` | string | 否 | `submitted`、`retryable_failed` 或 `failed` |

## Tool 3:`schedule.update`
Expand Down
9 changes: 6 additions & 3 deletions tests/host/schedule_mcp_tools_branch_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ void CheckRuleUpdateReminderResyncFailure() {

const auto created = fixture.server.call({
.request_id = "create-before-resync-failure",
.name = "schedule.create",
.arguments = {{"event", std::string("待重同步规则")}, {"repeat", DailyRepeat("2099-01-01")}},
.name = "schedule.create_rule",
.arguments = {{"event", std::string("待重同步规则")},
{"freq_type", std::string("daily")},
{"start_date", std::string("2099-01-01")},
{"start_time", std::string("09:00:00")}},
});
Check(created.status.ok() && OutputString(created, "status") == "success", "更新前应成功创建周期规则");

fixture.timing.register_acceptance = CommandAcceptance::kUnavailable;
const auto updated = fixture.server.call({
.request_id = "update-rule-resync-failure",
.name = "schedule.update",
.name = "schedule.update_rule",
.arguments = {{"rule_id", int64_t{fixture.rules.rules.back().id}}, {"event", std::string("重同步失败规则")}},
});
Check(updated.status.ok() && OutputString(updated, "status") == "failure" &&
Expand Down
11 changes: 5 additions & 6 deletions tests/host/schedule_mcp_tools_delete_confirmation_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,16 @@ int main() {
"错误 ID 时不得取消任何日程");

const ToolResult wrong_id_update = server.call({
.request_id = "update-cancel-wrong-id",
.name = "schedule.update",
.request_id = "delete-cancel-wrong-id",
.name = "schedule.delete",
.arguments = {{"schedule_id", int64_t{2}},
{"expected_event", std::string("带紫色水彩笔")},
{"expected_start_time", std::string("2026-08-27 14:13:00")},
{"status", std::string("cancelled")}},
{"expected_start_time", std::string("2026-08-27 14:13:00")}},
});
Check(OutputString(wrong_id_update, "status") == "failure", "update 取消遇到错误 ID 也必须失败");
Check(OutputString(wrong_id_update, "status") == "failure", "删除遇到错误 ID 也必须失败");
Check(schedules.FindSchedule(1).value->status == ScheduleStatus::kActive &&
schedules.FindSchedule(2).value->status == ScheduleStatus::kActive,
"update 取消遇到错误 ID 时不得取消任何日程");
"删除遇到错误 ID 时不得取消任何日程");

const ToolResult correct_target = Call(server, "delete-correct-target",
{{"schedule_id", int64_t{1}},
Expand Down
31 changes: 12 additions & 19 deletions tests/host/schedule_mcp_tools_failure_coverage_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,6 @@ std::string OutputString(const ToolResult& result, const std::string& key) {
return {};
}

/** @brief 构造每日周期 repeat 对象。 @return repeat JSON 对象。 */
JsonValue DailyRepeat() {
return JsonValue::Object({
{"freq_type", JsonValue::String("daily")},
{"start_date", JsonValue::String("2099-01-01")},
{"start_time", JsonValue::String("09:00:00")},
});
}

/** @brief 构造测试规则。 @param id 规则标识。 @return 周期规则。 */
ScheduleRule Rule(ScheduleRuleId id) {
ScheduleRule rule;
Expand Down Expand Up @@ -414,8 +405,11 @@ int main() {
rules.next_insert_failure = Status::Error(ErrorCode::kUnavailable, "规则写入失败");
const auto create_rule_failed = server.call({
.request_id = "create-rule-failed",
.name = "schedule.create",
.arguments = {{"event", std::string("周期失败")}, {"repeat", DailyRepeat()}},
.name = "schedule.create_rule",
.arguments = {{"event", std::string("周期失败")},
{"freq_type", std::string("daily")},
{"start_date", std::string("2099-01-01")},
{"start_time", std::string("09:00:00")}},
});
Check(OutputString(create_rule_failed, "status") == "failure", "周期规则创建非冲突失败应返回 failure");

Expand Down Expand Up @@ -456,23 +450,23 @@ int main() {
rules.next_cancel_failure = Status::Error(ErrorCode::kUnavailable, "取消规则失败");
const auto delete_rule_failed = server.call({
.request_id = "delete-rule-failed",
.name = "schedule.delete",
.name = "schedule.delete_rule",
.arguments = {{"rule_id", int64_t{600}}},
});
Check(OutputString(delete_rule_failed, "status") == "failure", "取消周期规则失败应返回 failure");

exceptions.next_upsert_failure = Status::Error(ErrorCode::kUnavailable, "跳过失败");
const auto delete_occurrence_failed = server.call({
.request_id = "delete-occurrence-failed",
.name = "schedule.delete",
.name = "schedule.skip_occurrence",
.arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-03 09:00:00")}},
});
Check(OutputString(delete_occurrence_failed, "status") == "failure", "删除未来单次失败应返回 failure");

exceptions.next_upsert_failure = Status::Error(ErrorCode::kUnavailable, "单次更新失败");
const auto update_occurrence_failed = server.call({
.request_id = "update-occurrence-failed",
.name = "schedule.update",
.name = "schedule.update_occurrence",
.arguments = {{"rule_id", int64_t{600}},
{"original_start_time", std::string("2099-01-04 09:00:00")},
{"event", std::string("失败更新")}},
Expand Down Expand Up @@ -531,14 +525,13 @@ int main() {
failing_schedules.find_by_id_failure.reset();
failing_schedules.delete_failure = Status::Error(ErrorCode::kUnavailable, "取消失败");
const auto update_cancel_failed = failing_server.call({
.request_id = "update-cancel-failed",
.name = "schedule.update",
.request_id = "delete-cancel-failed",
.name = "schedule.delete",
.arguments = {{"schedule_id", int64_t{20}},
{"expected_event", std::string("待更新日程")},
{"expected_start_time", std::string("2030-03-18 01:46:40")},
{"status", std::string("cancelled")}},
{"expected_start_time", std::string("2030-03-18 01:46:40")}},
});
Check(OutputString(update_cancel_failed, "status") == "failure", "update 取消失败应返回 failure");
Check(OutputString(update_cancel_failed, "status") == "failure", "delete 取消失败应返回 failure");

failing_schedules.delete_failure.reset();
failing_schedules.find_failure = Status::Error(ErrorCode::kUnavailable, "删除快照失败");
Expand Down
52 changes: 49 additions & 3 deletions tests/host/schedule_mcp_tools_input_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,21 @@
#include "voicelife/schedule/schedule_rule_commands.h"

using voicelife::JsonValue;
using voicelife::ToolArguments;
using voicelife::mcp::PropertyList;
using voicelife::mcp::schedule_tool_input::CreateProperties;
using voicelife::mcp::schedule_tool_input::CreateRuleCommand;
using voicelife::mcp::schedule_tool_input::CreateRuleProperties;
using voicelife::mcp::schedule_tool_input::DeleteProperties;
using voicelife::mcp::schedule_tool_input::DeleteRuleProperties;
using voicelife::mcp::schedule_tool_input::ParseRepeat;
using voicelife::mcp::schedule_tool_input::ParseRuleProperties;
using voicelife::mcp::schedule_tool_input::QueryProperties;
using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties;
using voicelife::mcp::schedule_tool_input::UpdateOccurrenceProperties;
using voicelife::mcp::schedule_tool_input::UpdateProperties;
using voicelife::mcp::schedule_tool_input::UpdateRuleCommand;
using voicelife::mcp::schedule_tool_input::UpdateRuleProperties;
using voicelife::schedule::Frequency;
using voicelife::schedule::MonthlyMode;
using voicelife::test::Check;
Expand Down Expand Up @@ -89,6 +96,38 @@ int main() {
std::optional<JsonValue>{JsonValue::Object({{"end_date", JsonValue::String("2099-00-01")}})}, false);
Check(!bad_end_date.ok(), "无效 end_date 应失败");

// ParseRuleProperties(扁平周期字段)的字段级校验。
const auto parse_flat = [](ToolArguments args, bool require_anchor = false) {
return ParseRuleProperties(CreateRuleProperties().with_values(std::move(args)), require_anchor);
};
const auto flat_ok = parse_flat({{"freq_type", std::string("weekly")},
{"start_date", std::string("2099-01-01")},
{"start_time", std::string("09:00:00")},
{"interval_val", int64_t{2}}},
true);
Check(flat_ok.ok() && flat_ok.interval_val == 2, "扁平周期字段应解析成功");

const auto flat_missing_anchor = parse_flat({{"freq_type", std::string("daily")}}, true);
Check(!flat_missing_anchor.ok(), "扁平周期缺少 anchor 应失败");
const auto flat_bad_start_time = parse_flat({{"start_time", std::string("25:00:00")}});
Check(!flat_bad_start_time.ok(), "扁平 start_time 非法格式应失败");
const auto flat_bad_end_time = parse_flat({{"end_time", std::string("99:00:00")}});
Check(!flat_bad_end_time.ok(), "扁平 end_time 非法格式应失败");
const auto flat_bad_end_date = parse_flat({{"end_date", std::string("2099-00-01")}});
Check(!flat_bad_end_date.ok(), "扁平 end_date 非法格式应失败");
const auto flat_bad_interval = parse_flat({{"interval_val", int64_t{0}}});
Check(!flat_bad_interval.ok(), "扁平 interval_val 越界应失败");
const auto flat_bad_weekdays = parse_flat({{"weekdays_mask", int64_t{128}}});
Check(!flat_bad_weekdays.ok(), "扁平 weekdays_mask 越界应失败");
const auto flat_bad_day = parse_flat({{"day_of_month", int64_t{32}}});
Check(!flat_bad_day.ok(), "扁平 day_of_month 越界应失败");
const auto flat_bad_month = parse_flat({{"month_of_year", int64_t{13}}});
Check(!flat_bad_month.ok(), "扁平 month_of_year 越界应失败");
const auto flat_bad_mode = parse_flat({{"monthly_mode", std::string("ordinal_weekday")}});
Check(!flat_bad_mode.ok(), "扁平 monthly_mode 非法值应失败");
const auto flat_bad_count = parse_flat({{"occurrence_count", int64_t{0}}});
Check(!flat_bad_count.ok(), "扁平 occurrence_count 越界应失败");

PropertyList create_properties;
const auto create = CreateRuleCommand(create_properties, parsed);
Check(create.freq_type == Frequency::kWeekly && create.interval_val == 2 && create.weekdays_mask == 3 &&
Expand All @@ -103,11 +142,18 @@ int main() {
update.monthly_mode == MonthlyMode::kSpecificDay && update.occurrence_count == 7,
"UpdateRuleCommand 应把 repeat 字段写入更新命令");

Check(CreateProperties().to_schema().properties.contains("repeat"), "create 工具应声明 repeat 参数");
Check(CreateProperties().to_schema().properties.contains("event"), "create 工具应声明 event 参数");
Check(CreateRuleProperties().to_schema().properties.contains("freq_type"), "create_rule 工具应声明 freq_type 参数");
Check(QueryProperties().to_schema().properties.contains("keyword"), "query 工具应声明 keyword 参数");
Check(UpdateProperties().to_schema().properties.contains("repeat"), "update 工具应声明 repeat 参数");
Check(DeleteProperties().to_schema().properties.contains("rule_id"), "delete 工具应声明 rule_id 参数");
Check(UpdateProperties().to_schema().properties.contains("schedule_id"), "update 工具应声明 schedule_id 参数");
Check(UpdateOccurrenceProperties().to_schema().properties.contains("original_start_time"),
"update_occurrence 工具应声明 original_start_time 参数");
Check(UpdateRuleProperties().to_schema().properties.contains("freq_type"), "update_rule 工具应声明 freq_type 参数");
Check(DeleteProperties().to_schema().properties.contains("schedule_id"), "delete 工具应声明 schedule_id 参数");
Check(DeleteProperties().to_schema().properties.contains("expected_event"), "delete 工具应声明目标确认事件");
Check(DeleteProperties().to_schema().properties.contains("expected_start_time"), "delete 工具应声明目标确认时间");
Check(DeleteRuleProperties().to_schema().properties.contains("rule_id"), "delete_rule 工具应声明 rule_id 参数");
Check(SkipOccurrenceProperties().to_schema().properties.contains("original_start_time"),
"skip_occurrence 工具应声明 original_start_time 参数");
return 0;
}
8 changes: 4 additions & 4 deletions tests/host/schedule_mcp_tools_operation_coverage_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ void CheckScheduleQueryReportingPaths() {
.arguments = {{"keyword", std::string("上报")}},
});
Check(submitted.status.ok() && OutputString(submitted, "im_delivery") == "submitted" &&
submitted.text_output.has_value() && submitted.text_output->find("已通过 IM 提交") != std::string::npos,
"IM 上报成功应返回 submitted 状态和用户摘要");
!submitted.text_output.has_value(),
"IM 上报成功应返回 submitted 状态和结构化 JSON 而非文本摘要");

runtime_fixture.transport->next_post_response = {
.status = ImTransportStatus::kNetworkFailure, .status_code = 0, .body = {}, .message = "network down"};
Expand All @@ -224,8 +224,8 @@ void CheckScheduleQueryReportingPaths() {
.arguments = {},
});
Check(retryable.status.ok() && OutputString(retryable, "im_delivery") == "retryable_failed" &&
retryable.text_output.has_value() && retryable.text_output->find("可重试") != std::string::npos,
"IM 上报失败应返回 retryable_failed 和可重试摘要");
!retryable.text_output.has_value(),
"IM 上报失败应返回 retryable_failed 和结构化 JSON 而非文本摘要");
}

} // namespace
Expand Down
Loading
Loading