feat: 完善周期报告与用户行为分析
- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
@@ -70,7 +70,7 @@ def test_agent_preview_topic_options_are_lightweight_and_paginated():
|
||||
|
||||
detail = user_operation_detail(1, db=db, current_admin=object())["data"]
|
||||
assert detail["user"]["id"] == 1
|
||||
assert detail["metrics"]["totalTopics"] == 12
|
||||
assert "totalTopics" not in detail["metrics"]
|
||||
|
||||
searched = user_topic_options(1, keyword="核心问题12", page=1, pageSize=10, db=db, current_admin=object())["data"]
|
||||
assert searched["total"] == 1
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_super_admin_has_all_permissions() -> None:
|
||||
assert permissions_for(admin) == ALL_PERMISSION_CODES
|
||||
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
|
||||
assert "prompt.batch" in ALL_PERMISSION_CODES
|
||||
assert "behavior.view" in ALL_PERMISSION_CODES
|
||||
|
||||
|
||||
def test_role_permissions_are_restricted_to_catalog() -> None:
|
||||
@@ -47,3 +48,34 @@ def test_agent_options_only_require_agent_view_permission() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_admin_access(knowledge_request, admin)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_user_behavior_routes_require_behavior_permission() -> None:
|
||||
allowed_role = Role(code="analyst", name="行为分析员", permissions=json.dumps(["behavior.view"]))
|
||||
allowed = Admin(
|
||||
id=4,
|
||||
username="analyst",
|
||||
password="hash",
|
||||
name="行为分析员",
|
||||
status=1,
|
||||
must_change_password=0,
|
||||
is_super_admin=0,
|
||||
role=allowed_role,
|
||||
)
|
||||
request = Request({"type": "http", "method": "GET", "path": "/api/admin/user-behavior/overview", "headers": []})
|
||||
assert enforce_admin_access(request, allowed) is allowed
|
||||
|
||||
denied_role = Role(code="records", name="记录查看员", permissions=json.dumps(["records.view"]))
|
||||
denied = Admin(
|
||||
id=5,
|
||||
username="records",
|
||||
password="hash",
|
||||
name="记录查看员",
|
||||
status=1,
|
||||
must_change_password=0,
|
||||
is_super_admin=0,
|
||||
role=denied_role,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_admin_access(request, denied)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@@ -32,7 +32,6 @@ def test_agent_debug_can_simulate_user_growth_profile_context():
|
||||
id=10,
|
||||
name="深度陪伴版",
|
||||
plan_type="deep",
|
||||
monthly_topic_limit=90,
|
||||
enable_growth_profile=1,
|
||||
status=1,
|
||||
)
|
||||
@@ -91,7 +90,6 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
|
||||
id=10,
|
||||
name="深度陪伴版",
|
||||
plan_type="deep",
|
||||
monthly_topic_limit=90,
|
||||
enable_growth_profile=1,
|
||||
allow_help_card=1,
|
||||
allow_share_draft=0,
|
||||
|
||||
@@ -95,6 +95,30 @@ def test_messages_already_covered_by_summary_are_not_sent_twice_after_limit_incr
|
||||
assert turns == ["消息4", "现在的问题"]
|
||||
|
||||
|
||||
def test_session_history_crosses_topic_boundaries_but_not_session_or_user_boundaries():
|
||||
with _database() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ChatMessage(id=1, session_id=1, topic_session_id=10, user_id=1, role="user", content="旧主题问题"),
|
||||
ChatMessage(id=2, session_id=1, topic_session_id=10, user_id=1, role="assistant", content="旧主题回答"),
|
||||
ChatMessage(id=3, session_id=1, topic_session_id=11, user_id=1, role="user", content="新主题问题"),
|
||||
ChatMessage(id=4, session_id=2, topic_session_id=20, user_id=1, role="assistant", content="其他会话"),
|
||||
ChatMessage(id=5, session_id=1, topic_session_id=11, user_id=2, role="assistant", content="其他用户"),
|
||||
ChatMessage(id=6, session_id=1, topic_session_id=11, user_id=1, role="user", content="当前问题"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
history = ChatContextService.load_session_history(
|
||||
db,
|
||||
session_id=1,
|
||||
user_id=1,
|
||||
before_message_id=6,
|
||||
)
|
||||
|
||||
assert [message.content for message in history] == ["旧主题问题", "旧主题回答", "新主题问题"]
|
||||
|
||||
|
||||
def test_summary_failure_is_visible_and_does_not_break_chat(monkeypatch, caplog):
|
||||
with _database("2") as db:
|
||||
session = ChatSession(id=9, user_id=1, title="测试", summary=None, message_count=4)
|
||||
|
||||
@@ -52,6 +52,25 @@ def test_template_preview_keeps_locked_notice_and_rejects_unknown_variables():
|
||||
assert malformed.value.status_code == 400
|
||||
|
||||
|
||||
def test_weekly_and_monthly_report_defaults_have_independent_variables_and_safety_notices():
|
||||
weekly = ContentGenerationConfigService.preview(
|
||||
"weekly_report",
|
||||
ContentGenerationConfigService.definition("weekly_report").template,
|
||||
)
|
||||
monthly = ContentGenerationConfigService.preview(
|
||||
"monthly_report",
|
||||
ContentGenerationConfigService.definition("monthly_report").template,
|
||||
)
|
||||
|
||||
assert "本周纳入 36 条聊天消息" in weekly
|
||||
assert "本周报告根据报告周期内的聊天记录自动整理" in weekly
|
||||
assert "本月纳入 5 份周报告" in monthly
|
||||
assert "本月报告根据本月覆盖的周报告自动整理" in monthly
|
||||
assert {item["name"] for item in default_variables("weekly_report")} != {
|
||||
item["name"] for item in default_variables("monthly_report")
|
||||
}
|
||||
|
||||
|
||||
def test_config_versions_save_reset_and_restore_without_overwriting_history():
|
||||
with _db() as db:
|
||||
first = ContentGenerationConfigService.save(
|
||||
|
||||
@@ -36,40 +36,6 @@ def _seed_user_session(db: Session) -> tuple[User, ChatSession]:
|
||||
return user, session
|
||||
|
||||
|
||||
def test_monthly_topic_count_uses_shanghai_calendar_boundary():
|
||||
with _db() as db:
|
||||
user, session = _seed_user_session(db)
|
||||
db.add_all(
|
||||
[
|
||||
TopicSession(
|
||||
user_id=user.id,
|
||||
chat_session_id=session.id,
|
||||
title="七月主题",
|
||||
core_question="七月",
|
||||
quota_deducted=1,
|
||||
started_at=datetime(2026, 7, 31, 15, 59, 59),
|
||||
),
|
||||
TopicSession(
|
||||
user_id=user.id,
|
||||
chat_session_id=session.id,
|
||||
title="八月主题",
|
||||
core_question="八月",
|
||||
quota_deducted=1,
|
||||
started_at=datetime(2026, 7, 31, 16, 0, 0),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
used = TopicSessionService.monthly_used_count(
|
||||
db,
|
||||
user.id,
|
||||
at=datetime(2026, 8, 15, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert used == 1
|
||||
|
||||
|
||||
def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment():
|
||||
with _db() as db:
|
||||
user, _session = _seed_user_session(db)
|
||||
@@ -80,23 +46,45 @@ def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment():
|
||||
plan_type="basic",
|
||||
description="基础知识问答权益",
|
||||
validity_days=180,
|
||||
monthly_topic_limit=30,
|
||||
status=1,
|
||||
sort_order=10,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=3)
|
||||
view = EntitlementService.active_entitlement(db, user)
|
||||
|
||||
assert view.plan_id == 10
|
||||
assert view.name == "大本营基础版"
|
||||
assert view.description == "基础知识问答权益"
|
||||
assert view.validity_days == 180
|
||||
assert view.source == "default"
|
||||
assert view.monthly_topic_remaining == 27
|
||||
assert entitlement_dict(view)["description"] == "基础知识问答权益"
|
||||
assert entitlement_dict(view)["validityDays"] == 180
|
||||
serialized = entitlement_dict(view)
|
||||
assert serialized["description"] == "基础知识问答权益"
|
||||
assert serialized["validityDays"] == 180
|
||||
assert "monthlyTopicLimit" not in serialized
|
||||
assert "monthlyTopicUsed" not in serialized
|
||||
assert "deductQuota" not in serialized
|
||||
|
||||
|
||||
def test_daily_chat_quota_is_the_only_chat_usage_limit():
|
||||
with _db() as db:
|
||||
user, session = _seed_user_session(db)
|
||||
user.daily_chat_used = user.daily_chat_limit
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ChatService._ensure_quota(user)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
user.daily_chat_used = 0
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question="新的对话片段",
|
||||
)
|
||||
assert topic.status == "active"
|
||||
|
||||
|
||||
def test_legacy_teacher_plan_is_not_exposed_or_used_as_default():
|
||||
@@ -107,7 +95,6 @@ def test_legacy_teacher_plan_is_not_exposed_or_used_as_default():
|
||||
id=10,
|
||||
name="旧老师工作版",
|
||||
plan_type="teacher",
|
||||
monthly_topic_limit=None,
|
||||
status=1,
|
||||
sort_order=1,
|
||||
)
|
||||
@@ -126,8 +113,8 @@ def test_assign_user_plan_replaces_previous_active_plan():
|
||||
user, _session = _seed_user_session(db)
|
||||
db.add_all(
|
||||
[
|
||||
EntitlementPlan(id=10, name="基础版", plan_type="basic", monthly_topic_limit=30, status=1, sort_order=10),
|
||||
EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1, sort_order=20),
|
||||
EntitlementPlan(id=10, name="基础版", plan_type="basic", status=1, sort_order=10),
|
||||
EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", status=1, sort_order=20),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
@@ -138,13 +125,12 @@ def test_assign_user_plan_replaces_previous_active_plan():
|
||||
|
||||
db.refresh(first)
|
||||
db.refresh(second)
|
||||
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=4)
|
||||
view = EntitlementService.active_entitlement(db, user)
|
||||
|
||||
assert first.status == "replaced"
|
||||
assert second.status == "active"
|
||||
assert view.plan_id == 20
|
||||
assert view.source == "assigned"
|
||||
assert view.monthly_topic_remaining == 86
|
||||
|
||||
|
||||
def test_expired_entitlement_falls_back_and_keeps_previous_plan_context():
|
||||
@@ -152,8 +138,8 @@ def test_expired_entitlement_falls_back_and_keeps_previous_plan_context():
|
||||
user, _session = _seed_user_session(db)
|
||||
db.add_all(
|
||||
[
|
||||
EntitlementPlan(id=10, name="基础版", plan_type="basic", monthly_topic_limit=30, status=1, sort_order=10),
|
||||
EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1, sort_order=20),
|
||||
EntitlementPlan(id=10, name="基础版", plan_type="basic", status=1, sort_order=10),
|
||||
EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", status=1, sort_order=20),
|
||||
UserEntitlement(
|
||||
id=100,
|
||||
user_id=user.id,
|
||||
@@ -183,7 +169,7 @@ def test_renew_user_plan_extends_from_current_expiry_and_is_idempotent():
|
||||
with _db() as db:
|
||||
user, _session = _seed_user_session(db)
|
||||
current_expiry = _now() + timedelta(days=5)
|
||||
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1))
|
||||
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", status=1))
|
||||
db.add(
|
||||
UserEntitlement(
|
||||
id=100,
|
||||
@@ -224,7 +210,7 @@ def test_renew_user_plan_extends_from_current_expiry_and_is_idempotent():
|
||||
def test_renew_expired_user_plan_restores_same_plan_from_now():
|
||||
with _db() as db:
|
||||
user, _session = _seed_user_session(db)
|
||||
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1))
|
||||
db.add(EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", status=1))
|
||||
db.add(
|
||||
UserEntitlement(
|
||||
id=100,
|
||||
@@ -253,44 +239,6 @@ def test_renew_expired_user_plan_restores_same_plan_from_now():
|
||||
assert view.source == "assigned"
|
||||
|
||||
|
||||
def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic():
|
||||
with _db() as db:
|
||||
user, session = _seed_user_session(db)
|
||||
db.add(EntitlementPlan(id=10, name="限额版", plan_type="basic", monthly_topic_limit=1, status=1, sort_order=10))
|
||||
db.add(
|
||||
TopicSession(
|
||||
id=100,
|
||||
user_id=user.id,
|
||||
chat_session_id=99,
|
||||
title="旧主题",
|
||||
core_question="旧主题",
|
||||
status="active",
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
created_at=_now(),
|
||||
updated_at=_now(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
entitlement = EntitlementService.active_entitlement(db, user, monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question="当前主题",
|
||||
deduct_quota=True,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
|
||||
def test_formal_chat_passes_topic_and_product_context_to_rag(monkeypatch):
|
||||
with _db() as db:
|
||||
message_sequence = iter(range(1000, 1010))
|
||||
@@ -307,7 +255,6 @@ def test_formal_chat_passes_topic_and_product_context_to_rag(monkeypatch):
|
||||
id=10,
|
||||
name="基础陪伴版",
|
||||
plan_type="basic",
|
||||
monthly_topic_limit=30,
|
||||
allow_help_card=1,
|
||||
allow_share_draft=0,
|
||||
status=1,
|
||||
@@ -336,7 +283,7 @@ def test_formal_chat_passes_topic_and_product_context_to_rag(monkeypatch):
|
||||
assert "班级分享稿:当前权益不可生成" in captured["product_context"]
|
||||
|
||||
|
||||
def test_new_topic_resets_previous_topic_rolling_summary():
|
||||
def test_new_topic_preserves_parent_chat_session_rolling_summary():
|
||||
with _db() as db:
|
||||
user, session = _seed_user_session(db)
|
||||
session.summary = "上一个主题的滚动摘要"
|
||||
@@ -358,10 +305,9 @@ def test_new_topic_resets_previous_topic_rolling_summary():
|
||||
user=user,
|
||||
session=session,
|
||||
question="这是一个新主题",
|
||||
deduct_quota=True,
|
||||
)
|
||||
|
||||
assert topic.id != 90
|
||||
assert topic.status == "active"
|
||||
assert session.summary is None
|
||||
assert session.summary_up_to_message_id is None
|
||||
assert session.summary == "上一个主题的滚动摘要"
|
||||
assert session.summary_up_to_message_id == 88
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthPro
|
||||
from app.models.user import User
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.growth_profile_service import GrowthProfileService, _parse_summary_json
|
||||
from app.services.model_service import ModelCompletion
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
from app.services.rag_service import PromptService
|
||||
from app.services.topic_settlement_worker import TopicSettlementWorker
|
||||
|
||||
@@ -31,7 +33,7 @@ def _now() -> datetime:
|
||||
def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_enabled_plan():
|
||||
with _db() as db:
|
||||
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
|
||||
db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, enable_growth_profile=1, status=1))
|
||||
db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", enable_growth_profile=1, status=1))
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="阴影人格", message_count=2, last_message_at=_now(), is_deleted=0)
|
||||
topic = TopicSession(
|
||||
@@ -42,7 +44,6 @@ def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_ena
|
||||
core_question="阴影人格练习步骤是什么",
|
||||
status="active",
|
||||
message_count=2,
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add_all([user, session, topic])
|
||||
@@ -209,6 +210,66 @@ def test_prompt_includes_only_v2_recent_review_without_replacing_knowledge_conte
|
||||
assert "[本轮可靠知识上下文]" in content
|
||||
|
||||
|
||||
def test_same_topic_final_summary_refreshes_recent_review(monkeypatch):
|
||||
with _db() as db:
|
||||
now = _now()
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="持续对话", message_count=4, last_message_at=now, is_deleted=0)
|
||||
topic = TopicSession(
|
||||
id=1,
|
||||
user_id=1,
|
||||
chat_session_id=1,
|
||||
title="持续对话",
|
||||
core_question="先聊两轮",
|
||||
status="completed",
|
||||
started_at=now - timedelta(hours=1),
|
||||
ended_at=now,
|
||||
)
|
||||
summary = TopicSummary(
|
||||
id=1,
|
||||
user_id=1,
|
||||
topic_session_id=1,
|
||||
schema_version=2,
|
||||
summary="第二轮阶段摘要",
|
||||
status="success",
|
||||
generated_at=now,
|
||||
)
|
||||
db.add_all([user, session, topic, summary])
|
||||
db.commit()
|
||||
answers = iter(
|
||||
[
|
||||
'{"reviewText":"第二轮时的近期回顾","currentFocus":"阶段关注"}',
|
||||
'{"reviewText":"主题结束后的完整近期回顾","currentFocus":"最终关注"}',
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
TrackedGenerationService,
|
||||
"generate",
|
||||
staticmethod(
|
||||
lambda *_args, **_kwargs: ModelCompletion(
|
||||
answer=next(answers),
|
||||
model_id=1,
|
||||
model_name="test-model",
|
||||
input_token=1,
|
||||
output_token=1,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
first = GrowthProfileService.update_growth_profile(db, user=user, topic_summary=summary)
|
||||
db.commit()
|
||||
summary.summary = "主题结束后的完整摘要"
|
||||
db.add(summary)
|
||||
db.flush()
|
||||
second = GrowthProfileService.update_growth_profile(db, user=user, topic_summary=summary)
|
||||
db.commit()
|
||||
|
||||
assert first.id == second.id
|
||||
assert second.recent_review == "主题结束后的完整近期回顾"
|
||||
assert second.current_focus == "最终关注"
|
||||
assert db.query(GrowthProfileRevision).filter_by(user_id=1).count() == 2
|
||||
|
||||
|
||||
def test_legacy_growth_profile_is_not_injected_before_v2_rebuild():
|
||||
with _db() as db:
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
|
||||
@@ -41,7 +41,6 @@ def test_generate_help_card_from_topic_summary_and_mark_copied():
|
||||
core_question="阴影人格练习步骤是否正确",
|
||||
status="active",
|
||||
message_count=2,
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add_all([user, plan, session, topic])
|
||||
|
||||
@@ -7,12 +7,14 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.chat import ChatSession, TopicSession
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement
|
||||
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
|
||||
from app.models.user import User
|
||||
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
|
||||
from app.core.config import get_settings
|
||||
from app.services.periodic_report_lazy_service import PeriodicReportLazyService
|
||||
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict, periodic_report_user_dict
|
||||
from app.services.content_generation_config_service import ContentGenerationConfigService
|
||||
from app.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
|
||||
from app.services.model_service import ModelCompletion
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
@@ -28,10 +30,28 @@ def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def test_generate_periodic_report_from_topic_summaries():
|
||||
def _report_completion(answer: str) -> ModelCompletion:
|
||||
return ModelCompletion(
|
||||
answer=answer,
|
||||
model_id=1,
|
||||
model_name="test-model",
|
||||
input_token=1,
|
||||
output_token=1,
|
||||
)
|
||||
|
||||
|
||||
def _weekly_json() -> str:
|
||||
return (
|
||||
'{"topic_overview":"- 用户询问表达时身体发紧怎么办。",'
|
||||
'"current_focus":"关注表达前的紧张感。",'
|
||||
'"useful_responses":"AI 已梳理先描述当下感受。",'
|
||||
'"continued_attention":"可以继续留意紧张刚出现时的想法。"}'
|
||||
)
|
||||
|
||||
|
||||
def test_generate_weekly_report_from_all_chat_messages(monkeypatch):
|
||||
with _db() as db:
|
||||
now = _now()
|
||||
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="表达障碍", message_count=2, last_message_at=now, is_deleted=0)
|
||||
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="表达障碍", core_question="我不敢表达", status="completed")
|
||||
@@ -46,24 +66,30 @@ def test_generate_periodic_report_from_topic_summaries():
|
||||
next_observation="先观察身体反应",
|
||||
generated_at=now - timedelta(days=1),
|
||||
)
|
||||
db.add_all([user, session, topic, summary, UserGrowthProfile(user_id=1, profile_text="用户常在表达前身体发紧。")])
|
||||
messages = [
|
||||
ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我一表达就身体发紧。", created_at=now - timedelta(days=1)),
|
||||
ChatMessage(id=2, session_id=1, topic_session_id=1, user_id=1, role="assistant", content="可以先描述紧张出现时的身体感受。", created_at=now - timedelta(days=1, seconds=-1)),
|
||||
]
|
||||
db.add_all([user, session, topic, summary, *messages, UserGrowthProfile(user_id=1, profile_text="用户常在表达前身体发紧。")])
|
||||
db.commit()
|
||||
monkeypatch.setattr(TrackedGenerationService, "generate", staticmethod(lambda *_args, **_kwargs: _report_completion(_weekly_json())))
|
||||
|
||||
report = PeriodicReportService.generate_for_user(db, user=user, report_type="weekly", period_start=now - timedelta(days=7), period_end=now)
|
||||
|
||||
assert report.status == "success"
|
||||
assert report.schema_version == 2
|
||||
assert report.schema_version == 3
|
||||
assert report.content
|
||||
assert "长期成长档案" not in report.content
|
||||
assert "推荐功课:" not in report.content
|
||||
assert "情绪:害怕" not in report.content
|
||||
data = periodic_report_dict(report)
|
||||
assert data["sourceSummaryIds"] == [1]
|
||||
assert data["sourceTopicIds"] == [1]
|
||||
assert data["sourceMessageIds"] == [1, 2]
|
||||
assert data["sourceSummaryIds"] == []
|
||||
assert "用户询问表达时身体发紧" in report.content
|
||||
assert db.query(PeriodicReport).filter_by(user_id=1).count() == 1
|
||||
|
||||
|
||||
def test_generate_empty_periodic_report_when_no_summaries():
|
||||
def test_generate_empty_periodic_report_when_no_source_chats_or_weekly_reports():
|
||||
with _db() as db:
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
db.add(user)
|
||||
@@ -72,8 +98,8 @@ def test_generate_empty_periodic_report_when_no_summaries():
|
||||
report = PeriodicReportService.generate_for_user(db, user=user, report_type="monthly")
|
||||
|
||||
assert report.status == "empty"
|
||||
assert "还没有可用于生成报告的主题沉淀" in report.content
|
||||
assert periodic_report_dict(report)["sourceSummaryIds"] == []
|
||||
assert "还没有可用于生成报告的对话回顾" in report.content
|
||||
assert periodic_report_dict(report)["sourceReportIds"] == []
|
||||
|
||||
|
||||
def test_periodic_report_strips_model_reasoning_before_saving(monkeypatch):
|
||||
@@ -89,14 +115,15 @@ def test_periodic_report_strips_model_reasoning_before_saving(monkeypatch):
|
||||
summary="最近谈到想先看当下。",
|
||||
generated_at=now - timedelta(days=1),
|
||||
)
|
||||
db.add_all([user, session, topic, summary])
|
||||
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我想先看当下。", created_at=now - timedelta(days=1))
|
||||
db.add_all([user, session, topic, summary, message])
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
TrackedGenerationService,
|
||||
"generate",
|
||||
staticmethod(
|
||||
lambda *_args, **_kwargs: ModelCompletion(
|
||||
answer="<think>内部分析与旧画像标签</think>\n## 本周期谈到的主题\n\n最近谈到想先看当下。",
|
||||
answer=f"<think>内部分析与旧画像标签</think>\n{_weekly_json()}",
|
||||
model_id=1,
|
||||
model_name="test-model",
|
||||
input_token=1,
|
||||
@@ -113,11 +140,171 @@ def test_periodic_report_strips_model_reasoning_before_saving(monkeypatch):
|
||||
period_end=now,
|
||||
)
|
||||
|
||||
assert report.content.startswith("## 本周期谈到的主题")
|
||||
assert report.content.startswith("## 本周实修回顾")
|
||||
assert "内部分析" not in report.content
|
||||
assert "<think>" not in report.content
|
||||
|
||||
|
||||
def test_long_weekly_chat_is_reduced_in_batches_without_dropping_source_ids(monkeypatch):
|
||||
with _db() as db:
|
||||
now = _now()
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="长对话", message_count=4, last_message_at=now, is_deleted=0)
|
||||
messages = [
|
||||
ChatMessage(
|
||||
id=index,
|
||||
session_id=1,
|
||||
user_id=1,
|
||||
role="user" if index % 2 else "assistant",
|
||||
content=f"第 {index} 段具体聊天:" + ("保留这段事实。" * 700),
|
||||
created_at=now - timedelta(days=1) + timedelta(seconds=index),
|
||||
)
|
||||
for index in range(1, 5)
|
||||
]
|
||||
db.add_all([user, session, *messages])
|
||||
db.commit()
|
||||
monkeypatch.setattr(get_settings(), "periodic_report_source_chunk_chars", 6000)
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_generate(_db, *, prompt, scenario, user_id):
|
||||
calls.append(prompt)
|
||||
if "周期报告做" in prompt:
|
||||
return _report_completion("分批事实笔记:保留各段用户问题和 AI 回应。")
|
||||
return _report_completion(_weekly_json())
|
||||
|
||||
monkeypatch.setattr(TrackedGenerationService, "generate", staticmethod(fake_generate))
|
||||
|
||||
report = PeriodicReportService.generate_for_user(
|
||||
db,
|
||||
user=user,
|
||||
report_type="weekly",
|
||||
period_start=now - timedelta(days=7),
|
||||
period_end=now,
|
||||
)
|
||||
|
||||
assert report.status == "success"
|
||||
assert periodic_report_dict(report)["sourceMessageIds"] == [1, 2, 3, 4]
|
||||
assert sum("周期报告做" in prompt for prompt in calls) >= 2
|
||||
assert "本周谈到的内容" in report.content
|
||||
|
||||
|
||||
def test_monthly_report_uses_weekly_reports_and_configured_template(monkeypatch):
|
||||
with _db() as db:
|
||||
now = _now().replace(microsecond=0)
|
||||
month_start = now - timedelta(days=30)
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
weekly = PeriodicReport(
|
||||
id=1,
|
||||
user_id=1,
|
||||
schema_version=3,
|
||||
report_type="weekly",
|
||||
period_start=month_start + timedelta(days=2),
|
||||
period_end=month_start + timedelta(days=9),
|
||||
title="第一周报告",
|
||||
content="周报告原文:本周完整讨论了练习顺序和身体紧绷。",
|
||||
status="success",
|
||||
)
|
||||
db.add_all([user, weekly])
|
||||
ContentGenerationConfigService.save(
|
||||
db,
|
||||
config_type="monthly_report",
|
||||
template_content="## 自定义月报\n周期:{{period_range}}\n{{month_digest}}",
|
||||
instruction_content="完整保留各周主要内容",
|
||||
variables=[
|
||||
{"name": "period_range", "label": "周期", "description": "月报周期", "valueSource": "context", "sourceKey": "period_range", "sampleValue": "示例周期"},
|
||||
{"name": "month_digest", "label": "月度内容", "description": "从周报中整理月度内容", "valueSource": "ai", "sourceKey": None, "sampleValue": "示例内容"},
|
||||
],
|
||||
updated_by=1,
|
||||
)
|
||||
monthly = PeriodicReportService.enqueue_for_user(
|
||||
db,
|
||||
user=user,
|
||||
report_type="monthly",
|
||||
period_start=month_start,
|
||||
period_end=now,
|
||||
source_report_ids=[weekly.id],
|
||||
)
|
||||
db.commit()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_generate(_db, *, prompt, scenario, user_id):
|
||||
captured["prompt"] = prompt
|
||||
return _report_completion('{"month_digest":"本月梳理了练习顺序和身体紧绷。"}')
|
||||
|
||||
monkeypatch.setattr(TrackedGenerationService, "generate", staticmethod(fake_generate))
|
||||
|
||||
PeriodicReportService.generate_existing(db, report=monthly, user=user)
|
||||
|
||||
assert monthly.status == "success"
|
||||
assert monthly.content.startswith("## 自定义月报")
|
||||
assert "周报告原文" in captured["prompt"]
|
||||
assert periodic_report_dict(monthly)["sourceReportIds"] == [1]
|
||||
|
||||
|
||||
def test_monthly_worker_waits_for_missing_weekly_dependency_then_completes():
|
||||
with _db() as db:
|
||||
month_start = datetime(2026, 7, 31, 16, 0, 0)
|
||||
month_end = datetime(2026, 8, 31, 16, 0, 0)
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="月内对话", message_count=1, last_message_at=month_start + timedelta(days=5), is_deleted=0)
|
||||
message = ChatMessage(
|
||||
id=1,
|
||||
session_id=1,
|
||||
user_id=1,
|
||||
role="user",
|
||||
content="这个月我想梳理练习顺序。",
|
||||
created_at=month_start + timedelta(days=5),
|
||||
)
|
||||
db.add_all([user, session, message])
|
||||
for config_type, template, variables in [
|
||||
(
|
||||
"weekly_report",
|
||||
"周报告 {{message_count}} 条",
|
||||
[{"name": "message_count", "label": "消息数", "description": "消息数量", "valueSource": "context", "sourceKey": "message_count", "sampleValue": "1"}],
|
||||
),
|
||||
(
|
||||
"monthly_report",
|
||||
"月报告 {{weekly_report_count}} 份",
|
||||
[{"name": "weekly_report_count", "label": "周报数", "description": "周报数量", "valueSource": "context", "sourceKey": "weekly_report_count", "sampleValue": "1"}],
|
||||
),
|
||||
]:
|
||||
ContentGenerationConfigService.save(
|
||||
db,
|
||||
config_type=config_type,
|
||||
template_content=template,
|
||||
instruction_content="忠实整理",
|
||||
variables=variables,
|
||||
updated_by=1,
|
||||
)
|
||||
monthly = PeriodicReportService.enqueue_for_user(
|
||||
db,
|
||||
user=user,
|
||||
report_type="monthly",
|
||||
period_start=month_start,
|
||||
period_end=month_end,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
monthly_id = PeriodicReportWorker.claim_next(db, worker_id="dependency-worker", now=_now() + timedelta(seconds=1))
|
||||
waiting = PeriodicReportWorker.execute_claimed(db, report_id=monthly_id, worker_id="dependency-worker")
|
||||
|
||||
assert waiting is not None
|
||||
assert waiting.status == "pending"
|
||||
assert waiting.attempt_count == 0
|
||||
weekly = db.scalar(select(PeriodicReport).where(PeriodicReport.report_type == "weekly"))
|
||||
assert weekly is not None
|
||||
assert weekly.generated_by == "dependency:monthly"
|
||||
|
||||
weekly_id = PeriodicReportWorker.claim_next(db, worker_id="dependency-worker", now=_now() + timedelta(seconds=2))
|
||||
completed_weekly = PeriodicReportWorker.execute_claimed(db, report_id=weekly_id, worker_id="dependency-worker")
|
||||
assert completed_weekly is not None and completed_weekly.status == "success"
|
||||
|
||||
monthly_id = PeriodicReportWorker.claim_next(db, worker_id="dependency-worker", now=_now() + timedelta(seconds=20))
|
||||
completed_monthly = PeriodicReportWorker.execute_claimed(db, report_id=monthly_id, worker_id="dependency-worker")
|
||||
assert completed_monthly is not None and completed_monthly.status == "success"
|
||||
assert periodic_report_dict(completed_monthly)["sourceReportIds"] == [weekly.id]
|
||||
|
||||
|
||||
def test_legacy_periodic_reports_are_hidden_until_regenerated():
|
||||
with _db() as db:
|
||||
now = _now()
|
||||
@@ -141,7 +328,6 @@ def test_legacy_periodic_reports_are_hidden_until_regenerated():
|
||||
def test_async_report_job_is_durable_and_idempotent():
|
||||
with _db() as db:
|
||||
now = _now()
|
||||
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="本周主题", message_count=2, last_message_at=now, is_deleted=0)
|
||||
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="本周主题", core_question="我该怎么观察", status="completed")
|
||||
@@ -152,7 +338,19 @@ def test_async_report_job_is_durable_and_idempotent():
|
||||
summary="本周看见了身体紧张。",
|
||||
generated_at=now - timedelta(days=1),
|
||||
)
|
||||
db.add_all([user, session, topic, summary])
|
||||
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我该怎么观察身体紧张?", created_at=now - timedelta(days=1))
|
||||
db.add_all([user, session, topic, summary, message])
|
||||
ContentGenerationConfigService.save(
|
||||
db,
|
||||
config_type="weekly_report",
|
||||
template_content="周期:{{period_range}}\n消息:{{message_count}}",
|
||||
instruction_content="忠实整理",
|
||||
variables=[
|
||||
{"name": "period_range", "label": "周期", "description": "报告周期", "valueSource": "context", "sourceKey": "period_range", "sampleValue": "示例周期"},
|
||||
{"name": "message_count", "label": "消息数", "description": "消息数量", "valueSource": "context", "sourceKey": "message_count", "sampleValue": "1"},
|
||||
],
|
||||
updated_by=1,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
period_start = now - timedelta(days=7)
|
||||
@@ -206,7 +404,8 @@ def test_failed_async_report_is_retried(monkeypatch):
|
||||
summary="本周看见了身体紧张。",
|
||||
generated_at=now - timedelta(days=1),
|
||||
)
|
||||
db.add_all([user, session, topic, summary])
|
||||
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我该怎么观察身体紧张?", created_at=now - timedelta(days=1))
|
||||
db.add_all([user, session, topic, summary, message])
|
||||
db.commit()
|
||||
report = PeriodicReportService.enqueue_for_user(
|
||||
db,
|
||||
@@ -252,8 +451,17 @@ def test_schedule_enqueues_only_users_with_enabled_report_entitlement():
|
||||
summary="本周主题沉淀",
|
||||
generated_at=datetime(2026, 8, 1, 8, 0, 0),
|
||||
)
|
||||
message = ChatMessage(
|
||||
id=1,
|
||||
session_id=1,
|
||||
topic_session_id=1,
|
||||
user_id=1,
|
||||
role="user",
|
||||
content="本周我想确认练习顺序。",
|
||||
created_at=datetime(2026, 8, 1, 8, 0, 0),
|
||||
)
|
||||
entitlement = UserEntitlement(id=1, user_id=1, plan_id=1, status="active")
|
||||
db.add_all([plan, user, session, topic, summary, entitlement])
|
||||
db.add_all([plan, user, session, topic, summary, message, entitlement])
|
||||
db.commit()
|
||||
|
||||
first = PeriodicReportWorker.enqueue_due_schedules(db, now_utc=now)
|
||||
@@ -308,3 +516,161 @@ def test_stale_running_job_is_recovered_after_restart():
|
||||
assert report.locked_by is None
|
||||
assert report.next_run_at == now
|
||||
assert "自动恢复" in (report.error_message or "")
|
||||
|
||||
|
||||
def test_authenticated_user_lazy_check_backfills_missing_weekly_and_monthly_reports():
|
||||
with _db() as db:
|
||||
now = datetime(2026, 8, 18, 4, 0, 0) # 上海时间 2026-08-18 12:00
|
||||
plan = EntitlementPlan(
|
||||
id=1,
|
||||
name="五个月深度陪伴版",
|
||||
plan_type="deep",
|
||||
enable_periodic_reports=1,
|
||||
status=1,
|
||||
)
|
||||
user = User(
|
||||
id=1,
|
||||
phone="13800000001",
|
||||
name="学员",
|
||||
daily_chat_limit=100,
|
||||
daily_chat_used=0,
|
||||
effective_at=datetime(2026, 7, 1),
|
||||
expired_at=datetime(2026, 12, 31),
|
||||
)
|
||||
entitlement = UserEntitlement(
|
||||
id=1,
|
||||
user_id=1,
|
||||
plan_id=1,
|
||||
status="active",
|
||||
effective_at=datetime(2026, 7, 31, 0, 0, 0),
|
||||
expired_at=datetime(2026, 12, 31, 0, 0, 0),
|
||||
)
|
||||
session = ChatSession(id=1, user_id=1, title="历史对话", message_count=4, last_message_at=now, is_deleted=0)
|
||||
july_topic = TopicSession(
|
||||
id=1,
|
||||
user_id=1,
|
||||
chat_session_id=1,
|
||||
title="七月回顾",
|
||||
core_question="七月的问题",
|
||||
status="completed",
|
||||
started_at=datetime(2026, 7, 31, 2, 0, 0),
|
||||
ended_at=datetime(2026, 7, 31, 4, 0, 0),
|
||||
)
|
||||
august_topic = TopicSession(
|
||||
id=2,
|
||||
user_id=1,
|
||||
chat_session_id=1,
|
||||
title="八月回顾",
|
||||
core_question="八月的问题",
|
||||
status="completed",
|
||||
started_at=datetime(2026, 8, 11, 2, 0, 0),
|
||||
ended_at=datetime(2026, 8, 12, 4, 0, 0),
|
||||
)
|
||||
july_summary = TopicSummary(
|
||||
id=1,
|
||||
user_id=1,
|
||||
topic_session_id=1,
|
||||
summary="七月底谈到的内容",
|
||||
status="success",
|
||||
generated_at=datetime(2026, 8, 2, 0, 0, 0),
|
||||
)
|
||||
august_summary = TopicSummary(
|
||||
id=2,
|
||||
user_id=1,
|
||||
topic_session_id=2,
|
||||
summary="上周谈到的内容",
|
||||
status="success",
|
||||
generated_at=datetime(2026, 8, 18, 0, 0, 0),
|
||||
)
|
||||
messages = [
|
||||
ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="七月底的问题", created_at=datetime(2026, 7, 31, 2, 0, 0)),
|
||||
ChatMessage(id=2, session_id=1, topic_session_id=1, user_id=1, role="assistant", content="七月底的回应", created_at=datetime(2026, 7, 31, 3, 0, 0)),
|
||||
ChatMessage(id=3, session_id=1, topic_session_id=2, user_id=1, role="user", content="八月的问题", created_at=datetime(2026, 8, 12, 2, 0, 0)),
|
||||
ChatMessage(id=4, session_id=1, topic_session_id=2, user_id=1, role="assistant", content="八月的回应", created_at=datetime(2026, 8, 12, 3, 0, 0)),
|
||||
]
|
||||
db.add_all([plan, user, entitlement, session, july_topic, august_topic, july_summary, august_summary, *messages])
|
||||
db.commit()
|
||||
|
||||
first = PeriodicReportLazyService.enqueue_missing_reports(db, user=user, now_utc=now)
|
||||
db.commit()
|
||||
weekly_reports = list(db.scalars(select(PeriodicReport).where(PeriodicReport.report_type == "weekly")))
|
||||
for weekly in weekly_reports:
|
||||
weekly.status = "success"
|
||||
weekly.content = f"周报告 {weekly.id}"
|
||||
db.commit()
|
||||
second = PeriodicReportLazyService.enqueue_missing_reports(db, user=user, now_utc=now)
|
||||
db.commit()
|
||||
third = PeriodicReportLazyService.enqueue_missing_reports(db, user=user, now_utc=now)
|
||||
db.commit()
|
||||
|
||||
reports = list(db.scalars(select(PeriodicReport).order_by(PeriodicReport.report_type, PeriodicReport.period_end)))
|
||||
assert first == {"weekly": 2, "monthly": 0}
|
||||
assert second == {"weekly": 0, "monthly": 1}
|
||||
assert third == {"weekly": 0, "monthly": 0}
|
||||
assert len(reports) == 3
|
||||
assert sum(report.status == "success" for report in reports) == 2
|
||||
assert sum(report.status == "pending" for report in reports) == 1
|
||||
assert all(report.generated_by.startswith("lazy:") for report in reports)
|
||||
assert {item for report in reports for item in periodic_report_dict(report)["sourceMessageIds"]} == {1, 2, 3, 4}
|
||||
monthly = next(report for report in reports if report.report_type == "monthly")
|
||||
july_weekly = next(report for report in weekly_reports if report.period_start < monthly.period_end and report.period_end > monthly.period_start)
|
||||
assert periodic_report_dict(monthly)["sourceReportIds"] == [july_weekly.id]
|
||||
|
||||
|
||||
def test_lazy_check_does_not_enqueue_without_current_periodic_report_entitlement():
|
||||
with _db() as db:
|
||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
result = PeriodicReportLazyService.enqueue_missing_reports(
|
||||
db,
|
||||
user=user,
|
||||
now_utc=datetime(2026, 8, 18, 4, 0, 0),
|
||||
)
|
||||
|
||||
assert result == {"weekly": 0, "monthly": 0}
|
||||
assert db.query(PeriodicReport).count() == 0
|
||||
|
||||
|
||||
def test_user_report_payload_exposes_async_status_without_internal_error():
|
||||
now = _now()
|
||||
report = PeriodicReport(
|
||||
id=1,
|
||||
user_id=1,
|
||||
report_type="weekly",
|
||||
period_start=now - timedelta(days=7),
|
||||
period_end=now,
|
||||
title="周报",
|
||||
content="内部降级内容",
|
||||
status="failed",
|
||||
error_message="密钥错误",
|
||||
)
|
||||
|
||||
payload = periodic_report_user_dict(report)
|
||||
|
||||
assert payload["status"] == "failed"
|
||||
assert payload["content"] == ""
|
||||
assert "errorMessage" not in payload
|
||||
|
||||
|
||||
def test_report_payload_tracks_chat_and_weekly_report_sources():
|
||||
now = _now()
|
||||
report = PeriodicReport(
|
||||
id=1,
|
||||
user_id=1,
|
||||
schema_version=3,
|
||||
report_type="monthly",
|
||||
period_start=now - timedelta(days=30),
|
||||
period_end=now,
|
||||
title="月报告",
|
||||
content="内容",
|
||||
source_message_ids="[1,2]",
|
||||
source_report_ids="[11,12]",
|
||||
status="success",
|
||||
)
|
||||
|
||||
payload = periodic_report_dict(report)
|
||||
|
||||
assert payload["sourceMessageIds"] == [1, 2]
|
||||
assert payload["sourceReportIds"] == [11, 12]
|
||||
|
||||
@@ -6,14 +6,14 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.auth_context import ChatAccessScope
|
||||
from app.models import Base
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.growth import TopicSummary
|
||||
from app.models.user import User
|
||||
from app.core.auth_context import ChatAccessScope
|
||||
from app.services.chat_service import ChatService
|
||||
from app.services.topic_auto_settlement_service import TopicAutoSettlementService
|
||||
from app.services.practice_review_auto_settlement_service import PracticeReviewAutoSettlementService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ def _seed(db: Session) -> tuple[User, ChatSession, TopicSession]:
|
||||
core_question="原始问题",
|
||||
status="active",
|
||||
message_count=0,
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add_all([user, session, topic])
|
||||
@@ -73,16 +72,16 @@ def _add_round(db: Session, topic: TopicSession, round_number: int, *, status: s
|
||||
db.flush()
|
||||
|
||||
|
||||
def test_default_second_successful_round_creates_snapshot_without_consuming_another_topic():
|
||||
def test_default_second_successful_round_creates_practice_review_snapshot():
|
||||
with _db() as db:
|
||||
user, session, topic = _seed(db)
|
||||
|
||||
_add_round(db, topic, 1)
|
||||
assert TopicAutoSettlementService.queue_if_due(db, user=user, topic=topic) is None
|
||||
assert PracticeReviewAutoSettlementService.queue_if_due(db, user=user, topic=topic) is None
|
||||
assert topic.status == "active"
|
||||
|
||||
_add_round(db, topic, 2)
|
||||
summary = TopicAutoSettlementService.queue_if_due(db, user=user, topic=topic)
|
||||
summary = PracticeReviewAutoSettlementService.queue_if_due(db, user=user, topic=topic)
|
||||
db.commit()
|
||||
|
||||
assert summary is not None
|
||||
@@ -95,7 +94,6 @@ def test_default_second_successful_round_creates_snapshot_without_consuming_anot
|
||||
user=user,
|
||||
session=session,
|
||||
question="继续聊另一个问题",
|
||||
deduct_quota=True,
|
||||
)
|
||||
assert same_topic.id == topic.id
|
||||
|
||||
@@ -114,25 +112,23 @@ def test_default_second_successful_round_creates_snapshot_without_consuming_anot
|
||||
user=user,
|
||||
session=new_session,
|
||||
question="真正的新议题",
|
||||
deduct_quota=True,
|
||||
)
|
||||
assert next_topic.id != topic.id
|
||||
assert next_topic.quota_deducted == 1
|
||||
|
||||
|
||||
def test_configured_round_limit_only_counts_finished_assistant_messages():
|
||||
with _db() as db:
|
||||
user, _session, topic = _seed(db)
|
||||
db.add(SystemConfig(config_key="topic_auto_settle_successful_rounds", config_value="3"))
|
||||
db.add(SystemConfig(config_key="practice_review_auto_settle_successful_rounds", config_value="3"))
|
||||
db.flush()
|
||||
|
||||
_add_round(db, topic, 1)
|
||||
_add_round(db, topic, 2, status="FAILED")
|
||||
_add_round(db, topic, 3)
|
||||
assert TopicAutoSettlementService.queue_if_due(db, user=user, topic=topic) is None
|
||||
assert PracticeReviewAutoSettlementService.queue_if_due(db, user=user, topic=topic) is None
|
||||
|
||||
_add_round(db, topic, 4)
|
||||
summary = TopicAutoSettlementService.queue_if_due(db, user=user, topic=topic)
|
||||
summary = PracticeReviewAutoSettlementService.queue_if_due(db, user=user, topic=topic)
|
||||
|
||||
assert summary is not None
|
||||
assert topic.status == "active"
|
||||
@@ -40,7 +40,6 @@ def test_generate_share_draft_from_topic_summary_and_mark_copied():
|
||||
core_question="我看见自己不敢表达",
|
||||
status="active",
|
||||
message_count=2,
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add_all([user, plan, session, topic])
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.models.behavior import UserBehaviorEvent
|
||||
from app.models.user import User
|
||||
from app.schemas.behavior import UserBehaviorEventCreate
|
||||
from app.services.user_behavior_service import UserBehaviorService
|
||||
|
||||
|
||||
def _engine():
|
||||
return create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
|
||||
|
||||
def _event(event_id: str, code: str, *, target_type: str | None = None, target_id: int | None = None):
|
||||
return UserBehaviorEventCreate(
|
||||
clientEventId=event_id,
|
||||
eventCode=code,
|
||||
targetType=target_type,
|
||||
targetId=target_id,
|
||||
occurredAt=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_behavior_batch_is_whitelisted_and_idempotent() -> None:
|
||||
engine = _engine()
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
user = User(id=1, phone="13800000000", name="测试用户")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
event_id = "11111111-1111-1111-1111-111111111111"
|
||||
accepted = UserBehaviorService.record_batch(
|
||||
db,
|
||||
user=user,
|
||||
items=[
|
||||
_event(event_id, "send_question_click", target_type="session", target_id=9),
|
||||
_event("22222222-2222-2222-2222-222222222222", "unknown_event"),
|
||||
],
|
||||
)
|
||||
assert accepted == 1
|
||||
assert UserBehaviorService.record_batch(
|
||||
db,
|
||||
user=user,
|
||||
items=[_event(event_id, "send_question_click", target_type="session", target_id=9)],
|
||||
) == 0
|
||||
row = db.scalar(select(UserBehaviorEvent))
|
||||
assert row is not None
|
||||
assert row.user_id == user.id
|
||||
assert row.event_name == "发送问题"
|
||||
assert row.target_type == "session"
|
||||
assert row.target_id == 9
|
||||
|
||||
|
||||
def test_behavior_overview_user_list_and_timeline() -> None:
|
||||
engine = _engine()
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
users = [
|
||||
User(id=1, phone="13800000000", name="甲用户"),
|
||||
User(id=2, phone="13900000000", name="乙用户"),
|
||||
]
|
||||
db.add_all(users)
|
||||
db.commit()
|
||||
UserBehaviorService.record_batch(
|
||||
db,
|
||||
user=users[0],
|
||||
items=[
|
||||
_event("11111111-1111-1111-1111-111111111111", "app_open"),
|
||||
_event("22222222-2222-2222-2222-222222222222", "send_question_click", target_type="session", target_id=3),
|
||||
],
|
||||
)
|
||||
UserBehaviorService.record_batch(
|
||||
db,
|
||||
user=users[1],
|
||||
items=[_event("33333333-3333-3333-3333-333333333333", "app_open")],
|
||||
)
|
||||
overview = UserBehaviorService.overview(db, start=None, end=None)
|
||||
assert overview["totalEvents"] == 3
|
||||
assert overview["activeUsers"] == 2
|
||||
assert overview["pageDialogOpens"] == 2
|
||||
assert overview["buttonClicks"] == 1
|
||||
assert overview["eventRanking"][0]["eventName"] == "进入答疑页面"
|
||||
|
||||
listed = UserBehaviorService.users(db, start=None, end=None, keyword="甲", page=1, page_size=20)
|
||||
assert listed["total"] == 1
|
||||
assert listed["items"][0]["eventCount"] == 2
|
||||
timeline = UserBehaviorService.timeline(db, user_id=1, start=None, end=None, page=1, page_size=50)
|
||||
assert [item["eventName"] for item in timeline["items"]] == ["进入答疑页面", "发送问题"]
|
||||
|
||||
|
||||
def test_behavior_retention_deletes_only_expired_rows() -> None:
|
||||
engine = _engine()
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
db.add_all(
|
||||
[
|
||||
UserBehaviorEvent(
|
||||
client_event_id="11111111-1111-1111-1111-111111111111",
|
||||
user_id=1,
|
||||
event_code="app_open",
|
||||
event_name="进入答疑页面",
|
||||
event_type="page",
|
||||
occurred_at=now - timedelta(days=31),
|
||||
),
|
||||
UserBehaviorEvent(
|
||||
client_event_id="22222222-2222-2222-2222-222222222222",
|
||||
user_id=1,
|
||||
event_code="app_open",
|
||||
event_name="进入答疑页面",
|
||||
event_type="page",
|
||||
occurred_at=now - timedelta(days=2),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
assert UserBehaviorService.delete_expired(db, retention_days=30) == 1
|
||||
assert db.scalar(select(UserBehaviorEvent.client_event_id)) == "22222222-2222-2222-2222-222222222222"
|
||||
Reference in New Issue
Block a user