rebuild(stage-10): deliver mentor and unified llm streaming

This commit is contained in:
leefer
2026-07-30 05:52:12 +08:00
parent 532f0cfc11
commit f1fa104641
62 changed files with 6880 additions and 7 deletions
+136
View File
@@ -0,0 +1,136 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-10");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page, username, password) {
await page.goto("/");
await page.getByLabel("账号名").fill(username);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
const mentors = [
{
id: "emotion-mentor",
name: "情绪模型",
description: "情绪周期与短线节奏",
tagline: "先看环境,再谈机会。",
focus: ["情绪周期", "风险控制"],
grade: "A",
evidence_label: "公开资料",
evidence_note: "已核验",
private: false,
pinned: false,
sort_order: 0,
},
{
id: "macro-mentor",
name: "宏观模型",
description: "指数、ETF与资金方向",
tagline: "把复杂行情回归常识。",
focus: ["宏观", "指数"],
grade: "B",
evidence_label: "公开资料",
evidence_note: "已核验",
private: false,
pinned: false,
sort_order: 1,
},
];
async function mockMentor(page) {
await page.route("**/api/mentors/setup?*", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ trade_date: "2026-07-30", mentors }),
}));
await page.route("**/api/mentors/messages?*", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify(route.request().method() === "DELETE" ? { deleted: 2 } : []),
}));
await page.route("**/api/mentors/preferences", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ order: mentors.map((item) => item.id), pinned: [] }),
}));
await page.route("**/api/mentors/chat", (route) => route.fulfill({
contentType: "application/x-ndjson",
body: [
JSON.stringify({ type: "delta", content: "第一段", request_id: "request-1" }),
JSON.stringify({ type: "delta", content: "第二段", request_id: "request-1" }),
JSON.stringify({ type: "done", request_id: "request-1" }),
"",
].join("\n"),
}));
}
test("stage 10 mentor library, streaming answer and responsive workspace", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) {
consoleErrors.push(message.text());
}
});
await mockMentor(page);
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await page.goto("/workspace/mentor");
await page.getByRole("button", { name: "夜间" }).click();
await expect(page.getByRole("heading", { name: "思维模型" })).toBeVisible();
await expect(page.getByText("2 位", { exact: true })).toBeVisible();
await page.getByLabel("搜索思维模型").fill("宏观");
await expect(page.getByText("1 位", { exact: true })).toBeVisible();
await expect(page.locator(".mentor-list").getByText("情绪模型", { exact: true })).toHaveCount(0);
await page.getByLabel("搜索思维模型").fill("");
await page.getByRole("button", { name: "A级" }).click();
await expect(page.locator(".mentor-list").getByText("情绪模型", { exact: true })).toBeVisible();
await expect(page.locator(".mentor-list").getByText("宏观模型", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "全部" }).click();
const macro = page.locator(".mentor-item").filter({ hasText: "宏观模型" });
await expect(macro).toBeVisible();
await macro.getByRole("button", { name: "置顶" }).click();
await expect(macro.getByRole("button", { name: "取消置顶" })).toBeVisible();
await page.getByPlaceholder("输入你的复盘问题").fill("请复盘今天的市场");
await page.getByRole("button", { name: "发送", exact: true }).click();
await expect(page.getByText("第一段第二段", { exact: true })).toBeVisible();
await expect(page.getByText("第一段第二段第一段第二段", { exact: true })).toHaveCount(0);
await expect(page.locator(".mentor-composer")).toBeInViewport();
await page.screenshot({ path: path.join(evidence, "mentor-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.locator(".mentor-chat-header").getByRole("button", { name: "清空" }).click();
await expect(page.getByRole("dialog", { name: "清空当前对话" })).toBeVisible();
await page.locator(".dialog .btn-primary").click();
await expect(page.getByText("先看环境,再谈机会。")).toBeVisible();
await page.getByRole("button", { name: "日间" }).click();
await page.setViewportSize({ width: 3840, height: 2160 });
await expect(page.locator(".mentor-composer")).toBeInViewport();
await page.screenshot({ path: path.join(evidence, "mentor-light-3840x2160.jpg"), type: "jpeg", quality: 82 });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "模型库" })).toBeVisible();
await page.getByRole("button", { name: "模型库" }).click();
await expect(page.locator(".mentor-library")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "mentor-light-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("nonmembers retain the complete grey mentor structure", async ({ page }) => {
await mockMentor(page);
await authenticate(page, "stage4user", "Stage4-user-123!");
await page.goto("/workspace/mentor");
await expect(page.getByText("问师仅对会员开放")).toBeVisible();
await expect(page.getByRole("heading", { name: "思维模型" })).toBeVisible();
await expect(page.locator(".mentor-workspace")).toHaveClass(/locked-content/);
await expect(page.getByPlaceholder("输入你的复盘问题")).toBeDisabled();
});
+2
View File
@@ -25,6 +25,8 @@ def test_explicit_encryption_key_is_reusable(tmp_path) -> None:
debug=settings.debug,
data_directory=settings.data_directory,
database_path=settings.database_path,
mentor_skills_directory=settings.mentor_skills_directory,
private_mentor_skills_directory=settings.private_mentor_skills_directory,
log_file=settings.log_file,
log_level=settings.log_level,
host=settings.host,
+355
View File
@@ -0,0 +1,355 @@
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"]
+6 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8)
assert {
"users",
"memberships",
@@ -135,8 +135,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"strategy_tracks",
"strategy_track_bars",
"strategy_track_events",
"mentor_preferences",
"mentor_messages",
"llm_requests",
"llm_attempts",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (7, 6, 5, 4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (8, 7, 6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)