feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
366 lines
14 KiB
Python
366 lines
14 KiB
Python
from datetime import timedelta
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine, select, update
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.database import Base
|
|
from app.core.utils.time import utc_now
|
|
from app.modules.ai_memory.constants import AIMemoryScope
|
|
from app.modules.ai_memory.models import AIMemoryEntry
|
|
from app.modules.ai_memory.service import AIMemoryService
|
|
from app.modules.business.models import MarketWatchlist
|
|
from app.modules.feishu_users.models import FeishuUser
|
|
from app.modules.market.service import MarketService
|
|
from app.modules.personalization.constants import (
|
|
CONVERSATION_MAX_MESSAGES,
|
|
PersonalizationContextKey,
|
|
)
|
|
from app.modules.personalization.models import (
|
|
AIConversation,
|
|
AIConversationMessage,
|
|
PersonalDataErasureRequest,
|
|
UserPreference,
|
|
)
|
|
from app.modules.personalization.services import (
|
|
ConversationService,
|
|
PersonalDataErasureService,
|
|
PersonalizationContextService,
|
|
PreferenceService,
|
|
)
|
|
|
|
|
|
def _session_factory():
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
|
|
def _user(db: Session, suffix: str, *, open_id: str | None = None) -> FeishuUser:
|
|
record = FeishuUser(
|
|
code=f"USR-{suffix}",
|
|
tenant_key=f"tenant-{suffix}",
|
|
open_id=open_id or f"open-{suffix}",
|
|
)
|
|
db.add(record)
|
|
db.commit()
|
|
db.refresh(record)
|
|
return record
|
|
|
|
|
|
def test_rules_memory_and_watchlists_are_owner_scoped(monkeypatch) -> None:
|
|
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
|
|
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
|
|
get_settings.cache_clear()
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
first = _user(db, "first", open_id="same-open-id")
|
|
second = _user(db, "second", open_id="same-open-id")
|
|
memory = AIMemoryService(db)
|
|
company_rule = memory.create_rule(
|
|
content="公司规则",
|
|
scope=AIMemoryScope.GLOBAL,
|
|
subject="company",
|
|
priority=90,
|
|
tags=[],
|
|
actor="service",
|
|
)
|
|
first_rule = memory.create_rule(
|
|
content="相同个人规则",
|
|
scope=AIMemoryScope.USER,
|
|
subject="profile",
|
|
priority=50,
|
|
tags=[],
|
|
actor="first",
|
|
owner_id=first.id,
|
|
)
|
|
second_rule = memory.create_rule(
|
|
content="相同个人规则",
|
|
scope=AIMemoryScope.USER,
|
|
subject="profile",
|
|
priority=50,
|
|
tags=[],
|
|
actor="second",
|
|
owner_id=second.id,
|
|
)
|
|
|
|
assert [item["code"] for item in memory.list_rules()] == [company_rule["code"]]
|
|
assert [item["code"] for item in memory.list_rules(owner_id=first.id)] == [
|
|
first_rule["code"]
|
|
]
|
|
assert [item["code"] for item in memory.list_rules(owner_id=second.id)] == [
|
|
second_rule["code"]
|
|
]
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
memory.update_rule(
|
|
code=second_rule["code"],
|
|
content="越权修改",
|
|
priority=None,
|
|
tags=None,
|
|
enabled=None,
|
|
actor="first",
|
|
owner_id=first.id,
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
first_memory = memory.auto_write(
|
|
prompt="Remember concise project risk summaries",
|
|
context={"scope": "user", "subject": "profile"},
|
|
answer="Use concise project risk summaries.",
|
|
actor="first",
|
|
owner_id=first.id,
|
|
)
|
|
second_memory = memory.auto_write(
|
|
prompt="Remember concise project risk summaries",
|
|
context={"scope": "user", "subject": "profile"},
|
|
answer="Use concise project risk summaries.",
|
|
actor="second",
|
|
owner_id=second.id,
|
|
)
|
|
assert first_memory is not None
|
|
assert second_memory is not None
|
|
assert first_memory.code != second_memory.code
|
|
assert first_memory.fingerprint != second_memory.fingerprint
|
|
|
|
market = MarketService(db)
|
|
market.add_watchlist("same-open-id", "600000", owner_id=first.id)
|
|
market.add_watchlist("same-open-id", "600000", owner_id=second.id)
|
|
assert market.watchlist("ignored", owner_id=first.id) == [{"symbol": "600000.SH"}]
|
|
assert market.watchlist("ignored", owner_id=second.id) == [{"symbol": "600000.SH"}]
|
|
|
|
market.add_watchlist("legacy-open", "000001")
|
|
assert market.watchlist("legacy-open") == [{"symbol": "000001.SZ"}]
|
|
assert market.claim_legacy_watchlist(first.id, "legacy-open") == 1
|
|
assert market.watchlist("legacy-open") == []
|
|
assert market.watchlist("ignored", owner_id=first.id) == [
|
|
{"symbol": "600000.SH"},
|
|
{"symbol": "000001.SZ"},
|
|
]
|
|
finally:
|
|
get_settings.cache_clear()
|
|
engine.dispose()
|
|
|
|
|
|
def test_preferences_are_allowlisted_sensitive_safe_and_owner_scoped() -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
first = _user(db, "pref-first")
|
|
second = _user(db, "pref-second")
|
|
service = PreferenceService(db)
|
|
first_pref = service.upsert(first.id, "tone", "简洁直接")
|
|
second_pref = service.upsert(second.id, "tone", "简洁直接")
|
|
|
|
assert first_pref["code"] != second_pref["code"]
|
|
assert service.list_preferences(first.id) == [first_pref]
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
service.delete(first.id, second_pref["code"])
|
|
assert exc_info.value.status_code == 404
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
service.upsert(first.id, "topic", "记住我的银行账号 123456")
|
|
assert exc_info.value.status_code == 422
|
|
|
|
assert (
|
|
service.save_auto_extraction(
|
|
first.id,
|
|
provider_name="noop",
|
|
user_text="以后请用中文",
|
|
structured_payload={
|
|
"preferences": [{"category": "language", "value": "中文"}]
|
|
},
|
|
)
|
|
== []
|
|
)
|
|
assert (
|
|
service.save_auto_extraction(
|
|
first.id,
|
|
provider_name="direct_llm",
|
|
user_text="今天怎么样",
|
|
structured_payload={
|
|
"preferences": [{"category": "language", "value": "中文"}]
|
|
},
|
|
)
|
|
== []
|
|
)
|
|
saved = service.save_auto_extraction(
|
|
first.id,
|
|
provider_name="direct_llm",
|
|
user_text="以后请用中文,并记住我的健康诊断",
|
|
structured_payload={
|
|
"preferences": [
|
|
{"category": "language", "value": "中文"},
|
|
{"category": "topic", "value": "我的健康诊断"},
|
|
]
|
|
},
|
|
)
|
|
assert [(item["category"], item["value"]) for item in saved] == [
|
|
("language", "中文")
|
|
]
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_conversations_keep_twenty_turns_and_isolate_sessions() -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
first = _user(db, "conversation-first")
|
|
second = _user(db, "conversation-second")
|
|
service = ConversationService(db)
|
|
assert (
|
|
service.record_turn(
|
|
second.id,
|
|
"private",
|
|
"chat-1",
|
|
user_content="placeholder",
|
|
assistant_content="placeholder",
|
|
provider_name="noop",
|
|
)
|
|
is False
|
|
)
|
|
for index in range(21):
|
|
assert service.record_turn(
|
|
first.id,
|
|
"private",
|
|
"chat-1",
|
|
user_content=f"user-{index}",
|
|
assistant_content=f"assistant-{index}",
|
|
provider_name="direct_llm",
|
|
)
|
|
|
|
history = service.history(first.id, "private", "chat-1")
|
|
assert len(history) == CONVERSATION_MAX_MESSAGES
|
|
assert history[0]["content"] == "user-1"
|
|
assert history[-1]["content"] == "assistant-20"
|
|
assert service.history(second.id, "private", "chat-1") == []
|
|
assert service.provider_session_id(first.id, "private", "chat-1") == (
|
|
service.provider_session_id(first.id, "p2p", "chat-1")
|
|
)
|
|
assert service.provider_session_id(first.id, "group", "chat-1") != (
|
|
service.provider_session_id(second.id, "group", "chat-1")
|
|
)
|
|
|
|
conversation = db.execute(
|
|
select(AIConversation).where(AIConversation.owner_id == first.id)
|
|
).scalar_one()
|
|
db.execute(
|
|
update(AIConversationMessage)
|
|
.where(AIConversationMessage.conversation_id == conversation.id)
|
|
.values(created_at=utc_now() - timedelta(days=31))
|
|
)
|
|
db.commit()
|
|
assert service.history(first.id, "private", "chat-1") == []
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_context_order_and_erasure_core_hooks() -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
owner = _user(db, "context-owner")
|
|
other = _user(db, "context-other")
|
|
memory = AIMemoryService(db)
|
|
memory.create_rule(
|
|
content="公司规则优先",
|
|
scope="global",
|
|
subject="company",
|
|
priority=100,
|
|
tags=[],
|
|
actor="service",
|
|
)
|
|
memory.create_rule(
|
|
content="个人规则",
|
|
scope="global",
|
|
subject="profile",
|
|
priority=80,
|
|
tags=[],
|
|
actor="owner",
|
|
owner_id=owner.id,
|
|
)
|
|
PreferenceService(db).upsert(owner.id, "tone", "简洁")
|
|
PreferenceService(db).upsert(owner.id, "interest", "人工智能")
|
|
MarketService(db).add_watchlist("owner-open", "600000", owner_id=owner.id)
|
|
ConversationService(db).record_turn(
|
|
owner.id,
|
|
"private",
|
|
"chat-context",
|
|
user_content="上一问",
|
|
assistant_content="上一答",
|
|
provider_name="direct_llm",
|
|
)
|
|
|
|
context = PersonalizationContextService(db).build(
|
|
owner_id=owner.id,
|
|
request="当前请求",
|
|
system_constraints="只读安全规则",
|
|
chat_type="private",
|
|
chat_key="chat-context",
|
|
subject="profile",
|
|
actor="owner",
|
|
)
|
|
assert [str(key) for key in context.as_ordered_dict()] == [
|
|
PersonalizationContextKey.SYSTEM_CONSTRAINTS,
|
|
PersonalizationContextKey.COMPANY_RULES,
|
|
PersonalizationContextKey.PERSONAL_RULES,
|
|
PersonalizationContextKey.CURRENT_REQUEST,
|
|
PersonalizationContextKey.PREFERENCES,
|
|
PersonalizationContextKey.INTERESTS,
|
|
PersonalizationContextKey.PERSONAL_MEMORY,
|
|
PersonalizationContextKey.CONVERSATION_HISTORY,
|
|
]
|
|
assert context.company_rules[0]["rule"] == "公司规则优先"
|
|
assert context.personal_rules[0]["rule"] == "个人规则"
|
|
assert {item["value"] for item in context.interests} == {
|
|
"人工智能",
|
|
"600000.SH",
|
|
}
|
|
assert context.conversation_history[-1]["content"] == "上一答"
|
|
|
|
confirmation = PersonalDataErasureService(db).request_confirmation(owner.id)
|
|
with pytest.raises(HTTPException):
|
|
PersonalDataErasureService(db).confirm_and_erase(owner.id, "BAD-CODE")
|
|
|
|
seen: dict[str, str | int] = {}
|
|
|
|
def integration_hook(
|
|
_db: Session,
|
|
owner_id: int,
|
|
anonymous_id: str,
|
|
) -> dict[str, int]:
|
|
seen.update(owner_id=owner_id, anonymous_id=anonymous_id)
|
|
return {"subscriptions": 0}
|
|
|
|
erased = PersonalDataErasureService(db).confirm_and_erase(
|
|
owner.id,
|
|
confirmation.confirmation_code,
|
|
extra_hooks=(integration_hook,),
|
|
)
|
|
assert erased.deleted["preferences"] == 2
|
|
assert erased.deleted["conversations"] == 1
|
|
assert erased.deleted["ai_memory"] == 1
|
|
assert erased.deleted["watchlist"] == 1
|
|
assert seen["owner_id"] == owner.id
|
|
assert seen["anonymous_id"] == erased.anonymous_id
|
|
assert db.scalar(
|
|
select(UserPreference).where(UserPreference.owner_id == owner.id)
|
|
) is None
|
|
assert db.scalar(
|
|
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner.id)
|
|
) is None
|
|
assert db.scalar(
|
|
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner.id)
|
|
) is None
|
|
assert db.scalar(
|
|
select(PersonalDataErasureRequest).where(
|
|
PersonalDataErasureRequest.owner_id == owner.id
|
|
)
|
|
) is None
|
|
assert db.get(FeishuUser, other.id) is not None
|
|
assert memory.active_rules()[0]["rule"] == "公司规则优先"
|
|
finally:
|
|
engine.dispose()
|