merge: 同步上游 dev 最新内容

This commit is contained in:
DawnARC
2026-05-06 00:53:11 +08:00
125 changed files with 3069 additions and 1271 deletions

View File

@@ -193,7 +193,6 @@ def _build_incoming_message(
message.is_command = False
message.is_notify = False
message.processed_plain_text = text
message.display_message = text
message.initialized = True
return message

View File

@@ -754,15 +754,6 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
"""
)
).mappings().one()
action_row = connection.execute(
text(
"""
SELECT session_id, action_name, action_display_prompt
FROM action_records
WHERE action_id = 'action-1'
"""
)
).mappings().one()
tool_row = connection.execute(
text(
"""
@@ -796,6 +787,8 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
assert snapshot.has_table("chat_sessions")
assert snapshot.has_table("mai_messages")
assert snapshot.has_table("tool_records")
assert not snapshot.has_table("action_records")
assert not snapshot.has_column("mai_messages", "display_message")
unpacked_raw_content = msgpack.unpackb(message_row["raw_content"], raw=False)
additional_config = json.loads(message_row["additional_config"])
@@ -807,9 +800,6 @@ def test_default_bootstrapper_can_migrate_legacy_v1_database(tmp_path: Path) ->
assert message_row["processed_plain_text"] == "你好"
assert unpacked_raw_content == [{"type": "text", "data": "你好呀"}]
assert additional_config == {"priority_mode": "high", "source": "legacy"}
assert action_row["session_id"] == "session-1"
assert action_row["action_name"] == "search"
assert action_row["action_display_prompt"] == "执行搜索"
assert tool_row["session_id"] == "session-1"
assert tool_row["tool_name"] == "search"
assert tool_row["tool_display_prompt"] == "执行搜索"
@@ -848,8 +838,8 @@ def test_legacy_v1_migration_reports_table_progress(tmp_path: Path) -> None:
migration_plan = manager.migrate(target_version=LATEST_SCHEMA_VERSION)
assert migration_plan.step_count() == 1
assert len(reporter_instances) == 1
assert migration_plan.step_count() == 3
assert len(reporter_instances) == 3
reporter_events = reporter_instances[0].events
assert reporter_events[0] == ("open", None, None, None)
@@ -894,10 +884,6 @@ def test_initialize_database_calls_bootstrapper_before_create_all(
del bind
call_order.append("create_all")
def _fake_migrate_action_records() -> None:
"""记录轻量补迁移调用。"""
call_order.append("migrate_action_records")
def _fake_finalize_database(migration_state: DatabaseMigrationState) -> None:
"""记录迁移收尾调用。
@@ -912,13 +898,11 @@ def test_initialize_database_calls_bootstrapper_before_create_all(
monkeypatch.setattr(database_module._migration_bootstrapper, "prepare_database", _fake_prepare_database)
monkeypatch.setattr(database_module._migration_bootstrapper, "finalize_database", _fake_finalize_database)
monkeypatch.setattr(database_module.SQLModel.metadata, "create_all", _fake_create_all)
monkeypatch.setattr(database_module, "_migrate_action_records_to_tool_records", _fake_migrate_action_records)
database_module.initialize_database()
assert call_order == [
"prepare_database",
"create_all",
"migrate_action_records",
"finalize_database",
]

View File

@@ -0,0 +1,11 @@
from src.config.model_configs import ModelInfo
def test_model_identifier_strips_surrounding_whitespace() -> None:
model_info = ModelInfo(
api_provider="test-provider",
model_identifier=" glm-5.1 ",
name="test-model",
)
assert model_info.model_identifier == "glm-5.1"

View File

@@ -178,7 +178,6 @@ def _install_stub_modules(monkeypatch):
class _EmojiConfig:
max_reg_num = 20
content_filtration = False
filtration_prompt = ""
steal_emoji = False
do_replace = False
check_interval = 1
@@ -1956,7 +1955,6 @@ async def test_build_emoji_description_content_filtration_reject(monkeypatch):
logger = emoji_manager_new.logger
emoji_manager_new.global_config.emoji.content_filtration = True
emoji_manager_new.global_config.emoji.filtration_prompt = "rule"
def _read_bytes(_path):
return b""
@@ -1994,13 +1992,15 @@ async def test_build_emoji_description_content_filtration_pass(monkeypatch):
logger = emoji_manager_new.logger
emoji_manager_new.global_config.emoji.content_filtration = True
emoji_manager_new.global_config.emoji.filtration_prompt = "rule"
def _read_bytes(_path):
return b""
async def _vlm_response(prompt, *_args, **_kwargs):
if "rule" in str(prompt):
call_count = {"n": 0}
async def _vlm_response(*_args, **_kwargs):
call_count["n"] += 1
if call_count["n"] == 2:
return "", None
return "desc", None

View File

@@ -87,7 +87,46 @@ def test_list_prompt_templates_prefers_locale_specific_files(tmp_path: Path) ->
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
assert prompt_templates["replyer"].read_text(encoding="utf-8") == "English"
assert prompt_templates["replyer"].path.read_text(encoding="utf-8") == "English"
def test_list_prompt_templates_loads_directory_metadata(tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "中文")
metadata_path = prompts_root / "zh-CN" / ".meta.toml"
metadata_path.write_text(
"""
[replyer]
display_name = "回复器"
advanced = true
description = "用于生成回复的主模板"
""".strip(),
encoding="utf-8",
)
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
metadata = prompt_templates["replyer"].metadata
assert metadata.display_name == "回复器"
assert metadata.advanced is True
assert metadata.description == "用于生成回复的主模板"
def test_list_prompt_templates_loads_prompt_specific_metadata(tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
write_prompt(prompts_root, "zh-CN", "replyer", "中文")
metadata_path = prompts_root / "zh-CN" / "replyer.meta.json"
metadata_path.write_text(
'{"display_name": "Replyer", "advanced": false, "description": "Prompt specific metadata"}',
encoding="utf-8",
)
prompt_templates = list_prompt_templates(prompts_root=prompts_root)
metadata = prompt_templates["replyer"].metadata
assert metadata.display_name == "Replyer"
assert metadata.advanced is False
assert metadata.description == "Prompt specific metadata"
def test_list_prompt_templates_reports_duplicate_name_with_custom_root(tmp_path: Path) -> None:

View File

@@ -41,7 +41,6 @@ def test_build_message_returns_session_message_with_maisaka_metadata() -> None:
assert message.message_id == "maisaka-msg-1"
assert message.timestamp == timestamp
assert message.processed_plain_text == "展示消息内容"
assert message.display_message == "展示消息内容"
assert message.raw_message is raw_message
assert get_message_role(message) == "assistant"

View File

@@ -554,7 +554,6 @@ async def test_inbound_codec_resolves_at_to_group_cardname() -> None:
)
assert message_dict["processed_plain_text"] == "@群昵称"
assert message_dict["display_message"] == "@群昵称"
assert message_dict["raw_message"] == [
{
"type": "at",
@@ -599,7 +598,6 @@ async def test_inbound_codec_falls_back_to_qq_nickname_when_group_cardname_is_em
)
assert message_dict["processed_plain_text"] == "@QQ昵称"
assert message_dict["display_message"] == "@QQ昵称"
assert message_dict["raw_message"] == [
{
"type": "at",
@@ -640,7 +638,6 @@ async def test_inbound_codec_falls_back_to_stranger_nickname_when_group_profile_
)
assert message_dict["processed_plain_text"] == "@QQ昵称"
assert message_dict["display_message"] == "@QQ昵称"
assert message_dict["raw_message"] == [
{
"type": "at",

View File

@@ -1,16 +1,59 @@
from types import SimpleNamespace
import pytest
from src.config.model_configs import APIProvider, ReasoningParseMode, ToolArgumentParseMode
from src.llm_models.model_client.openai_client import (
_OpenAIStreamAccumulator,
_build_reasoning_key,
_default_normal_response_parser,
_parse_tool_arguments,
_sanitize_messages_for_toolless_request,
)
from src.llm_models.payload_content.message import Message, RoleType, TextMessagePart
from src.llm_models.payload_content.tool_option import ToolCall
@pytest.mark.parametrize("parse_mode", list(ToolArgumentParseMode))
def test_parse_tool_arguments_treats_blank_arguments_as_empty_dict(parse_mode: ToolArgumentParseMode) -> None:
assert _parse_tool_arguments("", parse_mode, None) == {}
assert _parse_tool_arguments(" ", parse_mode, None) == {}
def test_normal_response_parser_accepts_empty_string_arguments_for_parameterless_tool() -> None:
response = SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="tool_calls",
message=SimpleNamespace(
content=None,
tool_calls=[
SimpleNamespace(
id="finish-call",
type="function",
function=SimpleNamespace(name="finish", arguments=""),
)
],
),
)
],
usage=None,
model="glm-5.1",
)
api_response, usage_record = _default_normal_response_parser(
response,
reasoning_parse_mode=ReasoningParseMode.AUTO,
tool_argument_parse_mode=ToolArgumentParseMode.AUTO,
reasoning_key=None,
)
assert len(api_response.tool_calls) == 1
assert api_response.tool_calls[0].func_name == "finish"
assert api_response.tool_calls[0].args == {}
assert usage_record is None
def test_sanitize_messages_for_toolless_request_drops_assistant_tool_call_without_parts() -> None:
messages = [
Message(

View File

@@ -31,7 +31,6 @@ def test_plugin_message_utils_preserves_binary_components_and_reply_metadata() -
)
message.session_id = "qq:20001:10001"
message.processed_plain_text = "binary payload"
message.display_message = "binary payload"
message.raw_message = MessageSequence(
components=[
TextComponent("hello"),

View File

@@ -298,7 +298,7 @@ async def test_private_outbound_message_preserves_bot_sender_and_receiver_user(
outbound_message = send_service._build_outbound_session_message(
message_sequence=MessageSequence(components=[TextComponent(text="你好")]),
stream_id="test-session",
display_message="你好",
processed_plain_text="你好",
)
assert outbound_message is not None
@@ -329,7 +329,7 @@ async def test_group_outbound_message_preserves_bot_sender_and_target_group(
outbound_message = send_service._build_outbound_session_message(
message_sequence=MessageSequence(components=[TextComponent(text="大家好")]),
stream_id="group-session",
display_message="大家好",
processed_plain_text="大家好",
)
assert outbound_message is not None

View File

@@ -1,16 +1,16 @@
"""Expression routes pytest tests"""
from typing import Generator
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI, APIRouter
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlalchemy import text
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select
from src.common.database.database_model import Expression
from src.common.database.database_model import Expression, ModifiedBy
from src.webui.dependencies import require_auth
def create_test_app() -> FastAPI:
@@ -63,6 +63,7 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
@contextmanager
def get_test_db_session():
yield test_session
test_session.commit()
monkeypatch.setattr("src.webui.routers.expression.get_db_session", get_test_db_session)
@@ -71,10 +72,11 @@ def client_fixture(test_session: Session, monkeypatch) -> Generator[TestClient,
@pytest.fixture(name="mock_auth")
def mock_auth_fixture(monkeypatch):
def mock_auth_fixture():
"""Mock authentication to always return True"""
mock_verify = MagicMock(return_value=True)
monkeypatch.setattr("src.webui.routers.expression.verify_auth_token_from_cookie_or_header", mock_verify)
app.dependency_overrides[require_auth] = lambda: "test-token"
yield
app.dependency_overrides.clear()
@pytest.fixture(name="sample_expression")
@@ -82,8 +84,8 @@ def sample_expression_fixture(test_session: Session) -> Expression:
"""Insert a sample expression into test database"""
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (1, '测试情景', '测试风格', '测试上下文', '测试上文', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '测试情景', '测试风格', '[\"测试内容1\", \"测试内容2\"]', 10, '2026-02-17 12:00:00', '2026-02-15 10:00:00', 'test_chat_001', 0, 0)"
)
)
test_session.commit()
@@ -131,8 +133,8 @@ def test_list_expressions_pagination(client: TestClient, mock_auth, test_session
for i in range(5):
test_session.execute(
text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}')"
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, '2026-02-17 12:0{i}:00', '2026-02-15 10:00:00', 'chat_{i}', 0, 0)"
)
)
test_session.commit()
@@ -158,14 +160,14 @@ def test_list_expressions_search(client: TestClient, mock_auth, test_session: Se
"""Test GET /expression/list with search filter"""
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (1, '找人吃饭', '热情', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '找人吃饭', '热情', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
)
)
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (2, '拒绝邀请', '礼貌', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_002')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (2, '拒绝邀请', '礼貌', '[]', 0, datetime('now'), datetime('now'), 'chat_002', 0, 0)"
)
)
test_session.commit()
@@ -183,14 +185,14 @@ def test_list_expressions_chat_filter(client: TestClient, mock_auth, test_sessio
"""Test GET /expression/list with chat_id filter"""
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (1, '情景A', '风格A', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_A')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '情景A', '风格A', '[]', 0, datetime('now'), datetime('now'), 'chat_A', 0, 0)"
)
)
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (2, '情景B', '风格B', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_B')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (2, '情景B', '风格B', '[]', 0, datetime('now'), datetime('now'), 'chat_B', 0, 0)"
)
)
test_session.commit()
@@ -378,8 +380,8 @@ def test_batch_delete_expressions_success(client: TestClient, mock_auth, test_se
for i in range(3):
test_session.execute(
text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}')"
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '批量删除{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i}', 0, 0)"
)
)
expression_ids.append(i + 1)
@@ -416,8 +418,8 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
for i in range(3):
test_session.execute(
text(
f"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}')"
f"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
f"VALUES ({i + 1}, '情景{i}', '风格{i}', '[]', 0, datetime('now'), datetime('now'), 'chat_{i % 2}', 0, 0)"
)
)
test_session.commit()
@@ -432,11 +434,11 @@ def test_get_expression_stats(client: TestClient, mock_auth, test_session: Sessi
def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
"""Test GET /expression/review/stats returns hardcoded 0 counts"""
"""Test GET /expression/review/stats returns review status counts"""
test_session.execute(
text(
"INSERT INTO expressions (id, situation, style, context, up_content, content_list, count, last_active_time, create_time, session_id) "
"VALUES (1, '待审核', '风格', '', '', '[]', 0, datetime('now'), datetime('now'), 'chat_001')"
"INSERT INTO expressions (id, situation, style, content_list, count, last_active_time, create_time, session_id, checked, rejected) "
"VALUES (1, '待审核', '风格', '[]', 0, datetime('now'), datetime('now'), 'chat_001', 0, 0)"
)
)
test_session.commit()
@@ -445,9 +447,8 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
assert response.status_code == 200
data = response.json()
# Verify all review counts are 0 (hardcoded in refactored code)
assert data["total"] == 1 # Total expressions exists
assert data["unchecked"] == 0
assert data["unchecked"] == 1
assert data["passed"] == 0
assert data["rejected"] == 0
assert data["ai_checked"] == 0
@@ -455,14 +456,14 @@ def test_get_review_stats(client: TestClient, mock_auth, test_session: Session):
def test_get_review_list_filter_unchecked(client: TestClient, mock_auth, sample_expression: Expression):
"""Test GET /expression/review/list with filter_type=unchecked returns empty (legacy behavior)"""
# filter_type=unchecked should return no results (legacy removed)
"""Test GET /expression/review/list with filter_type=unchecked returns unchecked expressions"""
response = client.get("/api/webui/expression/review/list?filter_type=unchecked")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["total"] == 0 # No results (legacy fields removed)
assert data["total"] == 1
assert len(data["data"]) == 1
def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expression: Expression):
@@ -476,8 +477,8 @@ def test_get_review_list_filter_all(client: TestClient, mock_auth, sample_expres
assert len(data["data"]) == 1
def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sample_expression: Expression):
"""Test POST /expression/review/batch returns failure for require_unchecked=True"""
def test_batch_review_expressions_with_unchecked_marker(client: TestClient, mock_auth, sample_expression: Expression):
"""Test POST /expression/review/batch succeeds with require_unchecked=True"""
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
response = client.post("/api/webui/expression/review/batch", json=review_payload)
@@ -485,8 +486,34 @@ def test_batch_review_expressions_unsupported(client: TestClient, mock_auth, sam
data = response.json()
assert data["success"] is True
assert data["failed"] == 1 # Should fail because require_unchecked=True
assert "不支持审核状态过滤" in data["results"][0]["message"]
assert data["succeeded"] == 1
assert data["results"][0]["success"] is True
def test_batch_review_expressions_overwrites_ai_checked(
client: TestClient, mock_auth, test_session: Session, sample_expression: Expression
):
"""Test POST /expression/review/batch lets manual review override AI checked state"""
sample_expression.checked = True
sample_expression.rejected = True
sample_expression.modified_by = ModifiedBy.AI
test_session.add(sample_expression)
test_session.commit()
review_payload = {"items": [{"id": sample_expression.id, "rejected": False, "require_unchecked": True}]}
response = client.post("/api/webui/expression/review/batch", json=review_payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["succeeded"] == 1
test_session.expire_all()
reviewed_expression = test_session.exec(select(Expression).where(Expression.id == sample_expression.id)).first()
assert reviewed_expression is not None
assert reviewed_expression.checked is True
assert reviewed_expression.rejected is False
assert reviewed_expression.modified_by == ModifiedBy.USER
def test_batch_review_expressions_no_unchecked_check(client: TestClient, mock_auth, sample_expression: Expression):