feat: open source multi-user AI notebook with observability

This commit is contained in:
wuyang
2026-07-28 14:28:00 +08:00
commit 935c411847
30 changed files with 6522 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
from pathlib import Path
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import main
from app.auth import COOKIE_NAME
from app.config import Settings, UserSeed
from app.curator import Curator
from app.db import Database
WRITE_HEADERS = {"X-Note-Client": "xuxiang-web"}
def test_login_isolation_roles_and_debug_privacy(
tmp_path: Path, monkeypatch
):
hasher = PasswordHasher()
admin_key = "key-admin-test"
member_key = "key-member-test"
users = (
UserSeed(
id="admin",
label="管理员",
role="admin",
access_key_hash=hasher.hash(admin_key),
),
UserSeed(
id="member",
label="用户二",
role="member",
access_key_hash=hasher.hash(member_key),
),
)
settings = Settings(
data_dir=tmp_path,
database_path=tmp_path / "api.sqlite3",
users=users,
session_secret="session-secret-for-tests",
deepseek_api_key=None,
deepseek_base_url="https://api.deepseek.com",
deepseek_model="deepseek-v4-pro",
cookie_secure=False,
auth_disabled=False,
)
database = Database(settings.database_path)
monkeypatch.setattr(main, "settings", settings)
monkeypatch.setattr(main, "db", database)
monkeypatch.setattr(main, "curator", Curator(database, settings))
with TestClient(main.app) as client:
assert client.post(
"/api/login", json={"access_key": "wrong"}
).status_code == 401
admin_login = client.post(
"/api/login", json={"access_key": admin_key}
)
assert admin_login.status_code == 200
assert admin_login.json()["user"]["role"] == "admin"
admin_cookie = client.cookies.get(COOKIE_NAME)
member_login = client.post(
"/api/login", json={"access_key": member_key}
)
assert member_login.status_code == 200
member_cookie = client.cookies.get(COOKIE_NAME)
client.cookies.set(COOKIE_NAME, admin_cookie)
admin_fragment = client.post(
"/api/fragments",
json={"content": "管理员的记录"},
headers=WRITE_HEADERS,
)
assert admin_fragment.status_code == 201
client.cookies.set(COOKIE_NAME, member_cookie)
member_fragment = client.post(
"/api/fragments",
json={"content": "用户二的记录"},
headers=WRITE_HEADERS,
)
assert member_fragment.status_code == 201
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [
"用户二的记录"
]
assert client.get("/api/admin/overview").status_code == 403
run_id = database.start_agent_run(
"member",
member_fragment.json()["id"],
"deepseek-v4-pro",
"prompt.v1",
"high",
)
database.add_agent_event(
run_id,
"member",
"decision_committed",
{
"standalone": False,
"assessments": [{"title": "私密产物", "maturity": 42}],
},
)
client.cookies.set(COOKIE_NAME, admin_cookie)
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [
"管理员的记录"
]
redacted = client.get(f"/api/admin/runs/{run_id}").json()
assert redacted["fragment"]["content_visible"] is False
assert redacted["events"][-1]["payload"] == {
"standalone": False,
"assessment_count": 1,
}
client.cookies.set(COOKIE_NAME, member_cookie)
shared = client.patch(
"/api/account/debug-sharing",
json={"enabled": True},
headers=WRITE_HEADERS,
)
assert shared.status_code == 200
client.cookies.set(COOKIE_NAME, admin_cookie)
visible = client.get(f"/api/admin/runs/{run_id}").json()
assert visible["fragment"]["content_visible"] is True
assert visible["fragment"]["content"] == "用户二的记录"
+190
View File
@@ -0,0 +1,190 @@
from pathlib import Path
from app.config import UserSeed
from app.db import Database
ADMIN = UserSeed(
id="admin-user",
label="管理员",
role="admin",
access_key_hash="hash-admin",
)
MEMBER = UserSeed(
id="member-user",
label="用户二",
role="member",
access_key_hash="hash-member",
)
def database(tmp_path: Path) -> Database:
db = Database(tmp_path / "test.sqlite3")
db.initialize([ADMIN, MEMBER])
return db
def assessment(idea_id: str | None = None, maturity: float = 38):
return {
"idea_id": idea_id,
"title": "无分类的笔记入口",
"summary": "先保留念头,再让结构在后台形成。",
"maturity": maturity,
"confidence": 0.76,
"motion": "从抱怨聚成原则",
"position": "已经有清楚的交互原则,还没有碰到真实使用。",
"tension": "自由输入与系统内部结构之间需要同时成立。",
"trajectory": "从输入摩擦的感受,收束成了产品边界。",
"possible_moves": ["做一个只保留原文的输入原型"],
"relevance": 1,
}
def test_fragment_is_saved_before_analysis(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个还没有分类的念头")
assert db.list_fragments(ADMIN.id) == [
{
"id": fragment["id"],
"content": "一个还没有分类的念头",
"created_at": fragment["created_at"],
}
]
assert db.get_fragment(fragment["id"], ADMIN.id)["analysis_status"] == "pending"
def test_user_data_is_isolated_at_query_layer(tmp_path: Path):
db = database(tmp_path)
admin_fragment = db.create_fragment(ADMIN.id, "管理员的私密念头")
member_fragment = db.create_fragment(MEMBER.id, "用户二的私密念头")
db.apply_assessments(ADMIN.id, admin_fragment["id"], [assessment()])
admin_idea = db.list_ideas(ADMIN.id)[0]
assert [item["content"] for item in db.list_fragments(ADMIN.id)] == [
"管理员的私密念头"
]
assert [item["content"] for item in db.list_fragments(MEMBER.id)] == [
"用户二的私密念头"
]
assert db.get_fragment(member_fragment["id"], ADMIN.id) is None
assert db.get_idea(MEMBER.id, admin_idea["id"]) is None
assert db.update_idea_override(MEMBER.id, admin_idea["id"], 80) is None
assert db.curator_context(MEMBER.id)[0] == []
def test_apply_assessment_builds_trajectory(tmp_path: Path):
db = database(tmp_path)
first = db.create_fragment(ADMIN.id, "笔记不应该要求先分类")
db.apply_assessments(ADMIN.id, first["id"], [assessment()])
ideas = db.list_ideas(ADMIN.id)
assert len(ideas) == 1
assert ideas[0]["maturity"] == 38
assert ideas[0]["fragment_count"] == 1
second = db.create_fragment(ADMIN.id, "AI 的判断不能出现在记录流里")
db.apply_assessments(
ADMIN.id,
second["id"],
[assessment(ideas[0]["id"], maturity=46)],
)
idea = db.get_idea(ADMIN.id, ideas[0]["id"])
assert idea["fragment_count"] == 2
assert len(idea["snapshots"]) == 2
assert idea["maturity"] == 46
def test_manual_position_is_authoritative(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个想法")
db.apply_assessments(ADMIN.id, fragment["id"], [assessment(maturity=30)])
idea = db.list_ideas(ADMIN.id)[0]
overridden = db.update_idea_override(ADMIN.id, idea["id"], 61)
assert overridden["maturity"] == 61
assert overridden["maturity_ai"] == 30
assert overridden["is_overridden"] is True
restored = db.update_idea_override(ADMIN.id, idea["id"], None)
assert restored["maturity"] == 30
assert restored["is_overridden"] is False
def test_existing_unowned_data_migrates_to_first_admin(tmp_path: Path):
db = Database(tmp_path / "legacy.sqlite3")
db.initialize()
with db.connect() as connection:
connection.execute(
"""
INSERT INTO fragments (
id, user_id, content, created_at, analysis_status
) VALUES ('legacy-fragment', NULL, '旧记录', '2026-01-01', 'done')
"""
)
connection.execute(
"""
INSERT INTO ideas (
id, user_id, title, summary, maturity_ai, confidence,
motion, position, tension, trajectory, possible_moves,
created_at, updated_at
) VALUES (
'legacy-idea', NULL, '旧想法', '摘要', 10, .5,
'浮现', '位置', '张力', '轨迹', '[]',
'2026-01-01', '2026-01-01'
)
"""
)
db.initialize([ADMIN, MEMBER])
assert db.get_fragment("legacy-fragment", ADMIN.id)["content"] == "旧记录"
assert db.get_idea(ADMIN.id, "legacy-idea")["title"] == "旧想法"
assert db.get_fragment("legacy-fragment", MEMBER.id) is None
def test_agent_run_records_metrics_and_events(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "可观测的一次分析")
run_id = db.start_agent_run(
ADMIN.id,
fragment["id"],
"deepseek-v4-pro",
"prompt.v1",
"high",
)
db.add_agent_event(
run_id,
ADMIN.id,
"tool_search_ideas",
{"query": "可观测", "result_count": 0},
duration_ms=3,
)
db.finish_agent_run(
run_id,
status="success",
duration_ms=1200,
attempt_count=1,
model_rounds=2,
tool_calls=1,
search_calls=1,
inspection_calls=0,
input_tokens=100,
output_tokens=50,
reasoning_tokens=30,
cached_tokens=20,
)
run = db.get_agent_run(run_id)
events = db.list_agent_events(run_id)
overview = db.admin_overview()
assert run["status"] == "success"
assert run["reasoning_tokens"] == 30
assert [event["event_type"] for event in events] == [
"run_started",
"tool_search_ideas",
]
assert overview["last_24h"]["success_rate"] == 100