356 lines
12 KiB
Python
356 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.database.connection import Database
|
|
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
|
from backend.features.accounts.models import MembershipRecord, Principal, UserRecord
|
|
from backend.features.mentor.context import (
|
|
MentorContextBuilder,
|
|
_matched_identifiers,
|
|
_requires_dragon_context,
|
|
)
|
|
from backend.features.mentor.repository import MentorRepository
|
|
from backend.features.mentor.skills import MentorSkillRegistry
|
|
from backend.llm.gateway import LLMGateway, LLMGatewayError
|
|
from backend.llm.provider import ProviderFailure
|
|
from backend.llm.repository import LLMRepository
|
|
from backend.llm.streaming import TextAccumulator
|
|
|
|
|
|
def _skill(root: Path, identifier: str, title: str, grade: str = "A") -> None:
|
|
directory = root / identifier
|
|
directory.mkdir(parents=True)
|
|
content = (
|
|
f'---\nname: {identifier}\ndescription: "用途:{title}复盘"\n---\n'
|
|
f'# {title}\n\n> "守住边界"\n'
|
|
)
|
|
(directory / "SKILL.md").write_text(
|
|
content,
|
|
encoding="utf-8",
|
|
)
|
|
(root / "mentor_catalog.json").write_text(
|
|
'{"mentors":{"'
|
|
+ identifier
|
|
+ '":{"evidence":{"grade":"'
|
|
+ grade
|
|
+ '","label":"公开资料","note":"已核验"}}}}',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _database(tmp_path: Path) -> Database:
|
|
database = Database(tmp_path / "mentor.db")
|
|
MigrationRunner(database).upgrade(MIGRATIONS)
|
|
with database.transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
id, username, username_key, password_hash, is_admin,
|
|
status, created_at, updated_at
|
|
) VALUES (1, 'member', 'member', 'hash', 0, 'active', ?, ?)
|
|
""",
|
|
("2026-07-30T00:00:00+00:00", "2026-07-30T00:00:00+00:00"),
|
|
)
|
|
for model_id in (1, 2):
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO llm_models (
|
|
id, display_name, display_name_key, base_url, model_identifier,
|
|
encrypted_api_key, created_at, updated_at, updated_by
|
|
) VALUES (?, ?, ?, 'https://example.invalid/v1', ?, 'secret', ?, ?, 1)
|
|
""",
|
|
(
|
|
model_id,
|
|
f"model-{model_id}",
|
|
f"model-{model_id}",
|
|
f"model-{model_id}",
|
|
"2026-07-30T00:00:00+00:00",
|
|
"2026-07-30T00:00:00+00:00",
|
|
),
|
|
)
|
|
return database
|
|
|
|
|
|
def _principal() -> Principal:
|
|
now = datetime.now(UTC)
|
|
user = UserRecord(1, "member", "member", "hash", False, "active", now, now)
|
|
membership = MembershipRecord(1, "active", None, True, 50, now, 1)
|
|
return Principal("token", "csrf", user, membership)
|
|
|
|
|
|
class _Memberships:
|
|
def view_for(self, _principal: Principal):
|
|
return SimpleNamespace(quota_exempt=False, daily_limit=50)
|
|
|
|
def can_use_smart_features(self, _principal: Principal) -> bool:
|
|
return True
|
|
|
|
|
|
class _ModelPool:
|
|
def runtime_config(self):
|
|
return SimpleNamespace(
|
|
primary=SimpleNamespace(
|
|
id=1,
|
|
base_url="https://example.invalid/v1",
|
|
model_identifier="primary",
|
|
),
|
|
fallback=SimpleNamespace(
|
|
id=2,
|
|
base_url="https://example.invalid/v1",
|
|
model_identifier="fallback",
|
|
),
|
|
)
|
|
|
|
def decrypt_api_key(self, _record) -> str:
|
|
return "secret"
|
|
|
|
|
|
class _ScriptedProvider:
|
|
def __init__(self, scripts: list[object]) -> None:
|
|
self.scripts = scripts
|
|
self.calls: list[str] = []
|
|
|
|
def stream(self, profile, _messages):
|
|
self.calls.append(profile.role)
|
|
script = self.scripts.pop(0)
|
|
if isinstance(script, Exception):
|
|
raise script
|
|
yield from script
|
|
|
|
|
|
def _gateway(database: Database, provider: _ScriptedProvider) -> LLMGateway:
|
|
return LLMGateway(
|
|
database,
|
|
LLMRepository(),
|
|
_Memberships(),
|
|
_ModelPool(),
|
|
provider,
|
|
)
|
|
|
|
|
|
def test_skill_registry_discovers_public_and_protects_private_overrides(tmp_path) -> None:
|
|
public = tmp_path / "public"
|
|
private = tmp_path / "private"
|
|
_skill(public, "same", "公开模型", "B")
|
|
_skill(private, "same", "私有模型", "A")
|
|
|
|
registry = MentorSkillRegistry(public, private)
|
|
public_only = registry.list(False)
|
|
admin = registry.list(True)
|
|
|
|
assert [(item.id, item.name, item.grade, item.private) for item in public_only] == [
|
|
("same", "公开模型", "B", False)
|
|
]
|
|
assert [(item.id, item.name, item.grade, item.private) for item in admin] == [
|
|
("same", "私有模型", "A", True)
|
|
]
|
|
assert "score" not in admin[0].public()
|
|
|
|
|
|
def test_preferences_messages_and_clear_are_strictly_scoped(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
repository = MentorRepository()
|
|
with database.transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
id, username, username_key, password_hash, is_admin,
|
|
status, created_at, updated_at
|
|
) VALUES (2, 'other', 'other', 'hash', 0, 'active', 'now', 'now')
|
|
"""
|
|
)
|
|
repository.save_preferences(connection, 1, ["a", "b"], {"b"}, "now")
|
|
repository.save_preferences(connection, 2, ["b", "a"], {"a"}, "now")
|
|
for user_id, mentor_id, trade_date in (
|
|
(1, "a", "2026-07-30"),
|
|
(1, "b", "2026-07-30"),
|
|
(1, "a", "2026-07-29"),
|
|
(2, "a", "2026-07-30"),
|
|
):
|
|
repository.add_message(
|
|
connection,
|
|
user_id=user_id,
|
|
mentor_id=mentor_id,
|
|
trade_date=trade_date,
|
|
role="user",
|
|
content="question",
|
|
request_id=None,
|
|
status="complete",
|
|
created_at="now",
|
|
)
|
|
deleted = repository.clear_messages(connection, 1, "a", "2026-07-30")
|
|
|
|
with database.read() as connection:
|
|
assert repository.preferences(connection, 1)["b"]["pinned"] == 1
|
|
assert repository.preferences(connection, 2)["a"]["pinned"] == 1
|
|
assert deleted == 1
|
|
assert repository.messages(connection, 1, "a", "2026-07-30") == ()
|
|
assert len(repository.messages(connection, 1, "b", "2026-07-30")) == 1
|
|
assert len(repository.messages(connection, 1, "a", "2026-07-29")) == 1
|
|
assert len(repository.messages(connection, 2, "a", "2026-07-30")) == 1
|
|
|
|
|
|
def test_main_failure_before_first_delta_falls_back_and_counts_once(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
provider = _ScriptedProvider([ProviderFailure("capacity"), ["回", "答"]])
|
|
gateway = _gateway(database, provider)
|
|
call = gateway.prepare(
|
|
_principal(),
|
|
feature="mentor",
|
|
prompt_version="test",
|
|
business_id="mentor:date",
|
|
input_chars=10,
|
|
)
|
|
|
|
events = list(gateway.stream(call, [{"role": "user", "content": "问题"}]))
|
|
|
|
assert provider.calls == ["primary", "fallback"]
|
|
assert "".join(item.content for item in events if item.type == "delta") == "回答"
|
|
with database.read() as connection:
|
|
request = connection.execute("SELECT * FROM llm_requests").fetchone()
|
|
attempts = connection.execute("SELECT * FROM llm_attempts ORDER BY id").fetchall()
|
|
usage = connection.execute("SELECT successful_calls FROM llm_usage_daily").fetchone()
|
|
assert request["status"] == "success"
|
|
assert [row["status"] for row in attempts] == ["failed", "success"]
|
|
assert usage["successful_calls"] == 1
|
|
|
|
|
|
def test_failure_after_first_delta_keeps_partial_and_never_falls_back(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
|
|
def interrupted():
|
|
yield "部分"
|
|
raise ProviderFailure("network")
|
|
|
|
provider = _ScriptedProvider([interrupted(), ["不应调用"]])
|
|
gateway = _gateway(database, provider)
|
|
call = gateway.prepare(
|
|
_principal(),
|
|
feature="mentor",
|
|
prompt_version="test",
|
|
business_id="mentor:date",
|
|
input_chars=10,
|
|
)
|
|
stream = gateway.stream(call, [{"role": "user", "content": "问题"}])
|
|
|
|
first = next(stream)
|
|
assert first.content == "部分"
|
|
with pytest.raises(LLMGatewayError, match="连接中断") as captured:
|
|
list(stream)
|
|
assert captured.value.partial is True
|
|
assert provider.calls == ["primary"]
|
|
with database.read() as connection:
|
|
request = connection.execute("SELECT * FROM llm_requests").fetchone()
|
|
usage = connection.execute("SELECT * FROM llm_usage_daily").fetchone()
|
|
assert request["status"] == "failed"
|
|
assert request["output_chars"] == 2
|
|
assert usage is None
|
|
|
|
|
|
def test_closing_after_first_delta_marks_request_stopped(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
provider = _ScriptedProvider([["第一段", "第二段"]])
|
|
gateway = _gateway(database, provider)
|
|
call = gateway.prepare(
|
|
_principal(),
|
|
feature="mentor",
|
|
prompt_version="test",
|
|
business_id="mentor:date",
|
|
input_chars=10,
|
|
)
|
|
stream = gateway.stream(call, [])
|
|
|
|
assert next(stream).content == "第一段"
|
|
stream.close()
|
|
|
|
with database.read() as connection:
|
|
request = connection.execute("SELECT * FROM llm_requests").fetchone()
|
|
attempt = connection.execute("SELECT * FROM llm_attempts").fetchone()
|
|
assert request["status"] == "stopped"
|
|
assert attempt["status"] == "stopped"
|
|
assert request["output_chars"] == 3
|
|
|
|
|
|
def test_unexpected_provider_error_closes_reservation(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
gateway = _gateway(database, _ScriptedProvider([RuntimeError("private detail")]))
|
|
call = gateway.prepare(
|
|
_principal(),
|
|
feature="mentor",
|
|
prompt_version="test",
|
|
business_id="mentor:date",
|
|
input_chars=10,
|
|
)
|
|
|
|
with pytest.raises(LLMGatewayError, match="暂不可用"):
|
|
list(gateway.stream(call, []))
|
|
with database.read() as connection:
|
|
request = connection.execute("SELECT * FROM llm_requests").fetchone()
|
|
assert request["status"] == "failed"
|
|
assert "private detail" not in request["error_type"]
|
|
|
|
|
|
def test_stream_accumulator_does_not_repeat_final_snapshot() -> None:
|
|
accumulator = TextAccumulator()
|
|
|
|
chunks = [
|
|
accumulator.feed({"delta": {"content": "第一"}}),
|
|
accumulator.feed({"delta": {"content": "段"}}),
|
|
accumulator.feed({"message": {"content": "第一段"}}),
|
|
]
|
|
|
|
assert "".join(chunks) == "第一段"
|
|
assert accumulator.text == "第一段"
|
|
|
|
|
|
def test_context_profiles_are_distinct_and_dragon_data_is_question_driven() -> None:
|
|
summary = {
|
|
"overview": {"limit_up_count": 20},
|
|
"sentiment": {"temperature": 42},
|
|
"limits": [{"code": "000001", "streak": 3, "amount": 10}],
|
|
"broken": [{"code": "000002"}],
|
|
"sector_rotation": [{"name": "银行"}],
|
|
"ladders": [{"level": 3}],
|
|
}
|
|
popularity = {"combined": [{"code": "000001"}]}
|
|
indexes = [{"code": "000300.SH", "available": False}]
|
|
etfs = [{"code": "510300.SH", "available": False}]
|
|
leader: dict = {}
|
|
macro: dict = {}
|
|
|
|
MentorContextBuilder._apply_profile(
|
|
leader, "leader", summary, popularity, indexes, etfs
|
|
)
|
|
MentorContextBuilder._apply_profile(
|
|
macro, "macro", summary, popularity, indexes, etfs
|
|
)
|
|
|
|
assert leader["multi_board_leaders"][0]["code"] == "000001"
|
|
assert leader["popularity_core"][0]["code"] == "000001"
|
|
assert "broad_indexes" not in leader
|
|
assert macro["broad_indexes"] == indexes
|
|
assert macro["core_etfs"] == etfs
|
|
assert "multi_board_leaders" not in macro
|
|
assert _requires_dragon_context("看看龙虎榜席位") is True
|
|
assert _requires_dragon_context("看看市场情绪") is False
|
|
|
|
|
|
def test_question_stock_matching_is_stable_and_limited_to_two() -> None:
|
|
directory = {
|
|
"000001.SZ": {"symbol": "000001", "name": "平安银行"},
|
|
"000002.SZ": {"symbol": "000002", "name": "万科A"},
|
|
"000003.SZ": {"symbol": "000003", "name": "国华网安"},
|
|
}
|
|
|
|
matched = _matched_identifiers(
|
|
directory,
|
|
"比较平安银行、000002和国华网安的强弱",
|
|
)
|
|
|
|
assert matched == ["000001.SZ", "000002.SZ"]
|