feat: 完善周期报告与用户行为分析

- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
2026-08-19 11:53:34 +08:00
parent 833763c461
commit efe835be81
75 changed files with 3325 additions and 578 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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])

View File

@@ -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]

View File

@@ -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"

View File

@@ -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])

View File

@@ -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"