feat: 将成长档案调整为近期实修回顾

This commit is contained in:
2026-08-03 12:25:51 +08:00
parent d2e08ab36c
commit 7747334ea4
29 changed files with 566 additions and 344 deletions

View File

@@ -174,7 +174,7 @@ def _seed_default_plans() -> None:
{
"name": "五个月深度陪伴版",
"plan_type": "deep",
"description": "支持长期成长档案、阶段报告和更高主题会话额度。",
"description": "支持近期实修回顾、周期实修回顾和更高主题会话额度。",
"validity_days": 150,
"monthly_topic_limit": 90,
"enable_growth_profile": 1,

View File

@@ -0,0 +1,70 @@
"""replace long-term growth profiling with recent practice review
Revision ID: 0025_recent_practice_review
Revises: 0024_topic_settlement_jobs
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0025_recent_practice_review"
down_revision = "0024_topic_settlement_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_topic_summary",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.alter_column("sys_topic_summary", "schema_version", server_default="2")
op.add_column(
"sys_user_growth_profile",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column("sys_user_growth_profile", sa.Column("recent_review", sa.Text(), nullable=True))
op.add_column("sys_user_growth_profile", sa.Column("current_focus", sa.Text(), nullable=True))
op.add_column("sys_user_growth_profile", sa.Column("source_summary_ids", sa.Text(), nullable=True))
op.alter_column("sys_user_growth_profile", "schema_version", server_default="2")
op.add_column(
"sys_periodic_report",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.alter_column("sys_periodic_report", "schema_version", server_default="2")
# 仅替换系统初始深度版的旧说明,避免覆盖管理员已经自定义的文案。
op.execute(
sa.text(
"UPDATE sys_entitlement_plan "
"SET description = :new_description "
"WHERE plan_type = 'deep' AND description = :old_description"
).bindparams(
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
)
)
def downgrade() -> None:
op.execute(
sa.text(
"UPDATE sys_entitlement_plan "
"SET description = :old_description "
"WHERE plan_type = 'deep' AND description = :new_description"
).bindparams(
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
)
)
op.drop_column("sys_periodic_report", "schema_version")
op.drop_column("sys_user_growth_profile", "source_summary_ids")
op.drop_column("sys_user_growth_profile", "current_focus")
op.drop_column("sys_user_growth_profile", "recent_review")
op.drop_column("sys_user_growth_profile", "schema_version")
op.drop_column("sys_topic_summary", "schema_version")

View File

@@ -45,8 +45,9 @@ def growth_profile(
{
"id": item.id,
"topicSessionId": item.topic_session_id,
"schemaVersion": item.schema_version,
"summary": item.summary,
"recommendedHomework": item.recommended_homework,
"currentFocus": item.main_events,
"nextObservation": item.next_observation,
"status": item.status,
"errorMessage": item.error_message,

View File

@@ -21,6 +21,7 @@ class TopicSummary(Base):
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
topic_session_id: Mapped[int] = mapped_column(ForeignKey("sys_topic_session.id"), index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
main_events: Mapped[str | None] = mapped_column(Text, nullable=True)
emotions: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -50,6 +51,11 @@ class UserGrowthProfile(Base):
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
recent_review: Mapped[str | None] = mapped_column(Text, nullable=True)
current_focus: Mapped[str | None] = mapped_column(Text, nullable=True)
source_summary_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
# 以下字段仅为兼容历史数据保留。V2 近期实修回顾不再生成、展示或注入这些画像字段。
profile_text: Mapped[str] = mapped_column(Text, default="", nullable=False)
recurring_topics: Mapped[str | None] = mapped_column(Text, nullable=True)
common_emotions: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -125,6 +131,7 @@ class PeriodicReport(Base):
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
report_type: Mapped[str] = mapped_column(String(30), nullable=False)
period_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
period_end: Mapped[datetime] = mapped_column(DateTime, nullable=False)

View File

@@ -163,7 +163,7 @@ class AgentDebugService:
"userId": user.id,
"userName": user.name,
"entitlement": entitlement_dict(entitlement),
"growthProfileUsed": bool(growth_context),
"recentPracticeReviewUsed": bool(growth_context),
"productContextUsed": True,
"topic": {
"id": topic.id,

View File

@@ -130,7 +130,7 @@ class ChatService:
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
context_trace.append({"tool": "load_recent_practice_review", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = list(context_trace or [])

View File

@@ -79,7 +79,7 @@ class ChatStreamService:
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
context_trace.append({"tool": "load_recent_practice_review", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = _append_runtime_context_trace(
@@ -262,7 +262,7 @@ class ChatStreamService:
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
if growth_context:
context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
context_trace.append({"tool": "load_recent_practice_review", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = _append_runtime_context_trace(

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import HTTPException, status
@@ -18,6 +18,11 @@ from app.services.external_errors import ExternalServiceError
from app.services.tracked_generation_service import TrackedGenerationService
RECENT_REVIEW_SCHEMA_VERSION = 2
RECENT_REVIEW_DAYS = 30
RECENT_REVIEW_TOPIC_LIMIT = 10
class GrowthProfileService:
@staticmethod
def finish_active_topic(db: Session, *, user: User, session: ChatSession, force: bool = False) -> dict:
@@ -117,7 +122,7 @@ class GrowthProfileService:
def process_topic_settlement(db: Session, *, summary: TopicSummary) -> tuple[TopicSummary, UserGrowthProfile | None]:
topic = db.get(TopicSession, summary.topic_session_id)
# Redis 失效时允许多个进程依靠数据库继续消费;用户行锁保证同一学员的
# 多个主题不会并发覆盖长期成长档案
# 多个主题不会并发覆盖近期实修回顾
user = db.scalar(select(User).where(User.id == summary.user_id).with_for_update())
if topic is None or user is None or user.is_deleted:
raise ValueError("主题或用户不存在")
@@ -129,7 +134,8 @@ class GrowthProfileService:
process_job=True,
allow_fallback=False,
)
topic.recommended_homework = generated.recommended_homework
# V2 不再固化或展示“建议功课”,历史列仅保留兼容。
topic.recommended_homework = None
db.add(topic)
entitlement = EntitlementService.active_entitlement(db, user)
profile = None
@@ -148,6 +154,8 @@ class GrowthProfileService:
allow_fallback: bool = True,
) -> TopicSummary:
existing = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id))
if existing is not None and existing.schema_version < RECENT_REVIEW_SCHEMA_VERSION:
force = True
if existing is not None and not force:
if existing.status in {"success", "fallback"}:
return existing
@@ -165,8 +173,9 @@ class GrowthProfileService:
)
if not messages:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容")
conversation = _messages_text(messages)
prompt = _topic_summary_prompt(topic, conversation)
user_content = _messages_text(messages, roles={"user"})
assistant_context = _messages_text(messages, roles={"assistant"})
prompt = _topic_summary_prompt(topic, user_content, assistant_context)
model_name = None
try:
completion = TrackedGenerationService.generate(
@@ -178,19 +187,20 @@ class GrowthProfileService:
raw = completion.answer
model_name = completion.model_name
parsed = _parse_summary_json(raw)
data = parsed if parsed and "summary" in parsed else _fallback_summary(topic, conversation, raw)
data = parsed if parsed and "summary" in parsed else _fallback_summary(topic, user_content, raw)
status_value = "success"
error_message = None
except ExternalServiceError as exc:
if not allow_fallback:
raise
data = _fallback_summary(topic, conversation, "")
data = _fallback_summary(topic, user_content, "")
status_value = "fallback"
error_message = str(exc)
if existing is None:
existing = TopicSummary(topic_session_id=topic.id, user_id=user.id)
_apply_summary(existing, data)
existing.schema_version = RECENT_REVIEW_SCHEMA_VERSION
existing.model_name = model_name
existing.status = status_value
existing.error_message = error_message
@@ -206,7 +216,11 @@ class GrowthProfileService:
@staticmethod
def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile:
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
if profile is not None and profile.last_topic_summary_id == topic_summary.id:
if (
profile is not None
and profile.schema_version >= RECENT_REVIEW_SCHEMA_VERSION
and profile.last_topic_summary_id == topic_summary.id
):
return profile
before = growth_profile_dict(profile) if profile is not None else None
if profile is None:
@@ -214,7 +228,8 @@ class GrowthProfileService:
db.add(profile)
db.flush()
prompt = _growth_profile_prompt(profile, topic_summary)
summaries = _recent_review_summaries(db, user_id=user.id)
prompt = _recent_review_prompt(summaries)
try:
completion = TrackedGenerationService.generate(
db,
@@ -224,11 +239,11 @@ class GrowthProfileService:
)
raw = completion.answer
parsed = _parse_summary_json(raw)
data = parsed if parsed and "profileText" in parsed else _fallback_profile(profile, topic_summary, raw)
data = parsed if parsed and "reviewText" in parsed else _fallback_recent_review(summaries, raw)
except ExternalServiceError:
data = _fallback_profile(profile, topic_summary, "")
data = _fallback_recent_review(summaries, "")
_apply_profile(profile, data)
_apply_recent_review(profile, data, summaries)
profile.last_topic_summary_id = topic_summary.id
profile.updated_by = "system"
db.add(profile)
@@ -241,7 +256,7 @@ class GrowthProfileService:
topic_summary_id=topic_summary.id,
before_json=json.dumps(before, ensure_ascii=False, default=str) if before else None,
after_json=json.dumps(after, ensure_ascii=False, default=str),
reason="topic_summary",
reason="recent_practice_review_v2",
)
)
return profile
@@ -255,7 +270,10 @@ class GrowthProfileService:
return list(
db.scalars(
select(TopicSummary)
.where(TopicSummary.user_id == user_id)
.where(
TopicSummary.user_id == user_id,
TopicSummary.schema_version == RECENT_REVIEW_SCHEMA_VERSION,
)
.order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc())
.limit(limit)
)
@@ -264,13 +282,20 @@ class GrowthProfileService:
@staticmethod
def prompt_context(db: Session, user: User) -> str | None:
profile = GrowthProfileService.get_growth_profile(db, user.id)
if profile is None or not profile.profile_text.strip():
if (
profile is None
or profile.schema_version < RECENT_REVIEW_SCHEMA_VERSION
or not (profile.recent_review or "").strip()
):
return None
focus = (profile.current_focus or "").strip()
return (
"[长期成长档案]\n"
"以下是用户跨主题沉淀出的简要成长档案,只能用于理解用户长期模式和延续陪伴,"
"不能替代本轮可靠知识,也不能臆造课程内容\n"
f"{_limit(profile.profile_text, 1800)}"
"[近期实修回顾]\n"
"以下内容只用于保持近期对话连续性。必须优先依据用户本轮表达,不得把历史回顾当成人格、"
"情绪、身体或关系模式,不得据此评价成长结果,也不能替代本轮可靠知识。\n"
f"近期回顾:{_limit(profile.recent_review or '', 1400)}\n"
+ (f"近期关注:{_limit(focus, 600)}\n" if focus else "")
+ "回答时帮助用户回到当前问题和当下,不要主动复述或强化历史标签。"
)
@@ -286,7 +311,6 @@ def topic_dict(topic: TopicSession) -> dict:
"tokenInput": topic.token_input,
"tokenOutput": topic.token_output,
"quotaDeducted": bool(topic.quota_deducted),
"recommendedHomework": topic.recommended_homework,
"startedAt": topic.started_at,
"endedAt": topic.ended_at,
"createdAt": topic.created_at,
@@ -294,18 +318,16 @@ def topic_dict(topic: TopicSession) -> dict:
}
def topic_summary_dict(summary: TopicSummary) -> dict:
def topic_summary_dict(summary: TopicSummary) -> dict | None:
if summary.schema_version < RECENT_REVIEW_SCHEMA_VERSION:
return None
return {
"id": summary.id,
"topicSessionId": summary.topic_session_id,
"userId": summary.user_id,
"schemaVersion": summary.schema_version,
"summary": summary.summary,
"mainEvents": summary.main_events,
"emotions": summary.emotions,
"bodyFeelings": summary.body_feelings,
"beliefs": summary.beliefs,
"recommendedHomework": summary.recommended_homework,
"insights": summary.insights,
"currentFocus": summary.main_events,
"nextObservation": summary.next_observation,
"modelName": summary.model_name,
"status": summary.status,
@@ -320,19 +342,15 @@ def topic_summary_dict(summary: TopicSummary) -> dict:
def growth_profile_dict(profile: UserGrowthProfile | None) -> dict | None:
if profile is None:
if profile is None or profile.schema_version < RECENT_REVIEW_SCHEMA_VERSION:
return None
return {
"id": profile.id,
"userId": profile.user_id,
"profileText": profile.profile_text,
"recurringTopics": profile.recurring_topics,
"commonEmotions": profile.common_emotions,
"bodyPatterns": profile.body_patterns,
"relationPatterns": profile.relation_patterns,
"homeworkDone": profile.homework_done,
"effectiveHomework": profile.effective_homework,
"recentProgress": profile.recent_progress,
"schemaVersion": profile.schema_version,
"reviewText": profile.recent_review or "",
"currentFocus": profile.current_focus,
"sourceSummaryIds": _json_int_list(profile.source_summary_ids),
"lastTopicSummaryId": profile.last_topic_summary_id,
"updatedBy": profile.updated_by,
"createdAt": profile.created_at,
@@ -342,78 +360,89 @@ def growth_profile_dict(profile: UserGrowthProfile | None) -> dict | None:
def _apply_summary(summary: TopicSummary, data: dict[str, Any]) -> None:
summary.summary = _field(data, "summary", "本主题已沉淀。", 4000)
summary.main_events = _field(data, "mainEvents", "", 2000) or None
summary.emotions = _field(data, "emotions", "", 1000) or None
summary.body_feelings = _field(data, "bodyFeelings", "", 1000) or None
summary.beliefs = _field(data, "beliefs", "", 1000) or None
summary.recommended_homework = _field(data, "recommendedHomework", "", 1500) or None
summary.insights = _field(data, "insights", "", 1500) or None
summary.main_events = _field(data, "currentFocus", "", 1200) or None
summary.next_observation = _field(data, "nextObservation", "", 1500) or None
summary.emotions = None
summary.body_feelings = None
summary.beliefs = None
summary.recommended_homework = None
summary.insights = None
def _apply_profile(profile: UserGrowthProfile, data: dict[str, Any]) -> None:
profile.profile_text = _field(data, "profileText", profile.profile_text or "暂无成长档案。", 5000)
profile.recurring_topics = _field(data, "recurringTopics", "", 2000) or profile.recurring_topics
profile.common_emotions = _field(data, "commonEmotions", "", 1500) or profile.common_emotions
profile.body_patterns = _field(data, "bodyPatterns", "", 1500) or profile.body_patterns
profile.relation_patterns = _field(data, "relationPatterns", "", 1500) or profile.relation_patterns
profile.homework_done = _field(data, "homeworkDone", "", 1500) or profile.homework_done
profile.effective_homework = _field(data, "effectiveHomework", "", 1500) or profile.effective_homework
profile.recent_progress = _field(data, "recentProgress", "", 1500) or profile.recent_progress
def _apply_recent_review(
profile: UserGrowthProfile,
data: dict[str, Any],
summaries: list[TopicSummary],
) -> None:
profile.schema_version = RECENT_REVIEW_SCHEMA_VERSION
profile.recent_review = _field(data, "reviewText", "近期还没有可回顾的主题。", 3000)
profile.current_focus = _field(data, "currentFocus", "", 1000) or None
profile.source_summary_ids = json.dumps([int(item.id) for item in summaries], ensure_ascii=False)
# 主动清空旧画像字段,保证后续任何兼容代码也不会继续读取标签化内容。
profile.profile_text = ""
profile.recurring_topics = None
profile.common_emotions = None
profile.body_patterns = None
profile.relation_patterns = None
profile.homework_done = None
profile.effective_homework = None
profile.recent_progress = None
def _topic_summary_prompt(topic: TopicSession, conversation: str) -> str:
def _topic_summary_prompt(topic: TopicSession, user_content: str, assistant_context: str) -> str:
return (
"你是大本营千问千答的主题沉淀助手。请把一个主题会话总结为结构化 JSON"
"只依据对话内容,不要扩展课程知识,不要评价用户人格。输出字段:"
"summary, mainEvents, emotions, bodyFeelings, beliefs, recommendedHomework, insights, nextObservation"
"内容要偏“回到当下、回到自身、觉察情绪和感受、如是释放”,不要给很多术层面的建议。\n\n"
f"主题标题:{topic.title}\n核心问题:{topic.core_question}\n\n对话:\n{_limit(conversation, 12000)}"
"你是大本营千问千答的近期主题回顾助手。请输出结构化 JSON字段仅包含:"
"summary, currentFocus, nextObservation。"
"用户原话是唯一可以形成用户结论的证据AI回复只用于理解上下文不能成为用户特征或结论"
"只描述本次明确谈到的内容和当下关注,不分析人格、潜意识、情绪模式、身体模式、关系模式、"
"成长阶段、功课效果或近期变化,不评分、不贴标签、不扩展课程知识。"
"nextObservation 最多一句,使用‘可以继续留意……’的开放表达,不设目标、不追求结果。\n\n"
f"主题标题:{topic.title}\n核心问题:{topic.core_question}\n\n"
f"用户原话:\n{_limit(user_content, 9000)}\n\n"
f"AI回复仅作上下文参考\n{_limit(assistant_context, 3000)}"
)
def _growth_profile_prompt(profile: UserGrowthProfile, summary: TopicSummary) -> str:
current = profile.profile_text or "暂无"
def _recent_review_prompt(summaries: list[TopicSummary]) -> str:
evidence = [
{
"summaryId": item.id,
"summary": item.summary,
"currentFocus": item.main_events,
"nextObservation": item.next_observation,
"generatedAt": item.generated_at,
}
for item in summaries
]
return (
"你是大本营千问千答的长期成长档案整理助手。请根据旧档案和最新主题摘要"
"滚动更新一份结构化 JSON。只保留稳定模式和最近进展避免堆叠流水账。"
"输出字段profileText, recurringTopics, commonEmotions, bodyPatterns, relationPatterns, "
"homeworkDone, effectiveHomework, recentProgress。语气客观、温和不诊断、不贴标签。\n\n"
f"旧档案:\n{_limit(current, 5000)}\n\n最新主题摘要:\n{json.dumps(topic_summary_dict(summary), ensure_ascii=False, default=str)}"
"你是大本营千问千答的近期实修回顾助手。请依据最近30天内、最多10条主题摘要输出 JSON"
"字段仅包含 reviewText 和 currentFocus。reviewText 用一小段中性文字回顾最近明确谈到的主题;"
"currentFocus 最多列出3项近期关注。不得继承或推断长期人格、情绪模式、身体模式、关系模式"
"不得记录做过或有效的功课,不评价进步、变化、阶段或结果,不把一次表达固化为长期特征。"
"使用‘最近谈到/近期关注’而不是‘你总是/你已经形成’。证据不足时直接说明近期记录较少。\n\n"
f"近期主题摘要:\n{json.dumps(evidence, ensure_ascii=False, default=str)}"
)
def _fallback_summary(topic: TopicSession, conversation: str, raw: str) -> dict[str, str]:
source = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else conversation
def _fallback_summary(topic: TopicSession, user_content: str, raw: str) -> dict[str, str]:
source = user_content or raw.strip()
user_lines = [line for line in source.splitlines() if line.startswith("用户:")]
assistant_lines = [line for line in source.splitlines() if line.startswith("大本营答疑助手:")]
first_question = user_lines[0].removeprefix("用户:").strip() if user_lines else topic.core_question
last_answer = assistant_lines[-1].removeprefix("大本营答疑助手").strip() if assistant_lines else ""
last_user_text = user_lines[-1].removeprefix("用户").strip() if user_lines else first_question
return {
"summary": _limit(f"用户围绕“{first_question}”进行了提问,本主题的主要回答和沉淀为{last_answer}", 1800),
"mainEvents": _limit(first_question, 800),
"emotions": _join_markers(source, ("害怕", "委屈", "愤怒", "抗拒", "焦虑", "担心", "难受")),
"bodyFeelings": _join_markers(source, ("身体", "", "心口", "", "", "", "", "", "")),
"beliefs": _join_markers(source, ("必须", "应该", "不能", "总是", "一定", "不配")),
"recommendedHomework": _join_markers(source, ("静心", "觉察", "功课", "练习", "释放", "内省")),
"insights": _limit(last_answer, 1000),
"nextObservation": "下次继续从当下最明显的身体感受、情绪和自动念头开始观察。",
"summary": _limit(f"本次主要谈到{last_user_text}", 1800),
"currentFocus": _limit(first_question, 800),
"nextObservation": "可以继续留意这个问题在当下实际发生时,自己最直接的体验是什么。",
}
def _fallback_profile(profile: UserGrowthProfile, summary: TopicSummary, raw: str) -> dict[str, str]:
previous = profile.profile_text.strip()
addition = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else summary.summary
text = "\n\n".join(part for part in [previous, f"最近主题:{addition}"] if part)
def _fallback_recent_review(summaries: list[TopicSummary], raw: str) -> dict[str, str]:
usable = [item for item in summaries if item.summary.strip()]
review_parts = [item.summary.strip() for item in usable[:3]]
focus_parts = [item.main_events.strip() for item in usable if item.main_events and item.main_events.strip()]
return {
"profileText": _limit(text or "暂无成长档案", 4500),
"recurringTopics": summary.main_events or "",
"commonEmotions": summary.emotions or "",
"bodyPatterns": summary.body_feelings or "",
"relationPatterns": summary.beliefs or "",
"homeworkDone": summary.recommended_homework or "",
"effectiveHomework": summary.recommended_homework or "",
"recentProgress": summary.insights or summary.summary,
"reviewText": _limit("".join(review_parts) or "近期沉淀记录较少", 2600),
"currentFocus": _limit("".join(dict.fromkeys(focus_parts[:3])), 900),
}
@@ -423,7 +452,7 @@ def _parse_summary_json(raw: str) -> dict[str, Any] | None:
return None
# 部分模型会把推理过程和最终 JSON 一起放进 answer甚至在推理中先给出一份
# 草稿 JSON。先移除推理块再从后向前选取可完整解析的对象避免把思考过程
# 或多个 JSON 拼接后写入用户成长档案
# 或多个 JSON 拼接后写入近期实修回顾
text = re.sub(r"<(?:think|analysis)>[\s\S]*?</(?:think|analysis)>", "", text, flags=re.IGNORECASE).strip()
fenced = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
candidates = [*fenced, text]
@@ -449,9 +478,13 @@ def _last_json_object(text: str) -> dict[str, Any] | None:
return parsed_objects[-1] if parsed_objects else None
def _messages_text(messages: list[ChatMessage]) -> str:
def _messages_text(messages: list[ChatMessage], *, roles: set[str] | None = None) -> str:
labels = {"user": "用户", "assistant": "大本营答疑助手"}
return "\n".join(f"{labels.get(message.role, message.role)}{message.content}" for message in messages)
return "\n".join(
f"{labels.get(message.role, message.role)}{message.content}"
for message in messages
if roles is None or message.role in roles
)
def _field(data: dict[str, Any], key: str, default: str, max_len: int) -> str:
@@ -463,9 +496,33 @@ def _field(data: dict[str, Any], key: str, default: str, max_len: int) -> str:
return _limit(value.strip(), max_len)
def _join_markers(text: str, markers: tuple[str, ...]) -> str:
found = [marker for marker in markers if marker in text]
return "".join(found)
def _json_int_list(raw: str | None) -> list[int]:
if not raw:
return []
try:
value = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(value, list):
return []
return [int(item) for item in value if isinstance(item, int) or (isinstance(item, str) and item.isdigit())]
def _recent_review_summaries(db: Session, *, user_id: int) -> list[TopicSummary]:
cutoff = _now() - timedelta(days=RECENT_REVIEW_DAYS)
return list(
db.scalars(
select(TopicSummary)
.where(
TopicSummary.user_id == user_id,
TopicSummary.schema_version == RECENT_REVIEW_SCHEMA_VERSION,
TopicSummary.status.in_(("success", "fallback")),
TopicSummary.generated_at >= cutoff,
)
.order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc())
.limit(RECENT_REVIEW_TOPIC_LIMIT)
)
)
def _limit(text: str, max_len: int) -> str:
@@ -477,6 +534,7 @@ def _now() -> datetime:
def _reset_summary_job(summary: TopicSummary) -> None:
summary.schema_version = RECENT_REVIEW_SCHEMA_VERSION
summary.summary = ""
summary.main_events = None
summary.emotions = None

View File

@@ -87,7 +87,7 @@ def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSess
def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary) -> str:
data = topic_summary_dict(summary)
data = topic_summary_dict(summary) or {}
return (
"【给老师的求助卡】\n"
"说明:这是我根据本次 AI 对话整理出的求助信息,请老师帮我确认方向。"
@@ -99,14 +99,11 @@ def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary)
f"{topic.core_question or '(请补充)'}\n\n"
"2. AI 已经帮我梳理出的重点\n"
f"{data.get('summary') or '(暂无摘要)'}\n\n"
"3. 我现在最明显的情绪 / 身体感受\n"
f"情绪:{data.get('emotions') or '(请补充)'}\n"
f"身体感受:{data.get('bodyFeelings') or '(请补充)'}\n\n"
"4. 我已经尝试过或被建议的功课\n"
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
"5. 我仍然卡住的地方\n"
f"{data.get('beliefs') or data.get('nextObservation') or '(请补充)'}\n\n"
"6. 我想请老师确认的问题\n"
"3. 我这次主要关注的内容\n"
f"{data.get('currentFocus') or topic.core_question or '(请补充)'}\n\n"
"4. 我还想继续留意的地方\n"
f"{data.get('nextObservation') or '(请补充)'}\n\n"
"5. 我想请老师确认的问题\n"
"(请把最想确认的一两个问题写在这里)\n\n"
"备注:这张卡片不会自动发送给老师,也不代表已经转人工处理。"
)
@@ -120,4 +117,3 @@ def _format_time(value: datetime | None) -> str:
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -10,17 +10,19 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
from app.models.growth import PeriodicReport, TopicSummary
from app.models.user import User
from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.tracked_generation_service import TrackedGenerationService
ReportType = Literal["weekly", "monthly", "stage"]
REPORT_TYPE_LABELS = {
"weekly": "每周实修小结",
"monthly": "每月成长报告",
"stage": "阶段成长总结",
"monthly": "每月实修回顾",
"stage": "阶段实修回顾",
}
REPORT_SCHEMA_VERSION = 2
class PeriodicReportService:
@@ -46,7 +48,10 @@ class PeriodicReportService:
limit: int = 20,
statuses: Iterable[str] | None = None,
) -> list[PeriodicReport]:
query = select(PeriodicReport).where(PeriodicReport.user_id == user_id)
query = select(PeriodicReport).where(
PeriodicReport.user_id == user_id,
PeriodicReport.schema_version == REPORT_SCHEMA_VERSION,
)
if statuses is not None:
query = query.where(PeriodicReport.status.in_(tuple(statuses)))
return list(
@@ -104,6 +109,7 @@ class PeriodicReportService:
if report.status == "running":
return report
report.title = _report_title(report_type, period_start, period_end)
report.schema_version = REPORT_SCHEMA_VERSION
report.status = "pending"
report.error_message = None
report.generated_by = generated_by
@@ -159,9 +165,9 @@ class PeriodicReportService:
raise ValueError("不支持的报告类型")
period_start = report.period_start
period_end = report.period_end
report.schema_version = REPORT_SCHEMA_VERSION
report.title = _report_title(report_type, period_start, period_end)
summaries = _period_summaries(db, user_id=user.id, period_start=period_start, period_end=period_end)
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
topic_ids = sorted({int(item.topic_session_id) for item in summaries})
summary_ids = [int(item.id) for item in summaries]
report.source_topic_ids = json.dumps(topic_ids, ensure_ascii=False)
@@ -176,14 +182,20 @@ class PeriodicReportService:
return report
try:
prompt = _report_prompt(user=user, report_type=report_type, period_start=period_start, period_end=period_end, summaries=summaries, profile=profile)
prompt = _report_prompt(
user=user,
report_type=report_type,
period_start=period_start,
period_end=period_end,
summaries=summaries,
)
completion = TrackedGenerationService.generate(
db,
prompt=prompt,
scenario="report",
user_id=user.id,
)
report.content = completion.answer.strip() or _fallback_report(summaries)
report.content = ReasoningPolicyService.strip_reasoning(completion.answer).strip() or _fallback_report(summaries)
report.model_name = completion.model_name
report.status = "success"
report.error_message = None
@@ -199,6 +211,7 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
return {
"id": report.id,
"userId": report.user_id,
"schemaVersion": report.schema_version,
"reportType": report.report_type,
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
"periodStart": _local_datetime(report.period_start),
@@ -289,6 +302,7 @@ def _new_report(
) -> PeriodicReport:
return PeriodicReport(
user_id=user.id,
schema_version=REPORT_SCHEMA_VERSION,
report_type=report_type,
period_start=period_start,
period_end=period_end,
@@ -332,6 +346,7 @@ def _period_summaries(db: Session, *, user_id: int, period_start: datetime, peri
select(TopicSummary)
.where(
TopicSummary.user_id == user_id,
TopicSummary.schema_version == REPORT_SCHEMA_VERSION,
TopicSummary.status == "success",
TopicSummary.generated_at >= period_start,
TopicSummary.generated_at < period_end,
@@ -349,7 +364,6 @@ def _report_prompt(
period_start: datetime,
period_end: datetime,
summaries: list[TopicSummary],
profile: UserGrowthProfile | None,
) -> str:
local_start = _local_datetime(period_start)
local_end = _local_datetime(period_end)
@@ -357,11 +371,7 @@ def _report_prompt(
(
f"主题摘要 {index}\n"
f"摘要:{item.summary}\n"
f"主要事件{item.main_events or ''}\n"
f"情绪:{item.emotions or ''}\n"
f"身体感受:{item.body_feelings or ''}\n"
f"推荐功课:{item.recommended_homework or ''}\n"
f"看见/变化:{item.insights or ''}\n"
f"近期关注{item.main_events or ''}\n"
f"下一步观察:{item.next_observation or ''}"
)
for index, item in enumerate(summaries, start=1)
@@ -369,23 +379,21 @@ def _report_prompt(
return (
f"请为学员“{user.name or user.nickname or user.phone}”生成一份{REPORT_TYPE_LABELS[report_type]}\n"
f"周期:{local_start:%Y-%m-%d %H:%M}{local_end:%Y-%m-%d %H:%M}\n\n"
"产品定位:这是大本营千问千答的实修陪伴报告,不写成医疗诊断心理咨询结论或营销文\n"
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案\n"
"请使用 Markdown 输出,结构包含:本周期主要议题、做过或被建议的功课、反复出现的情绪/身体模式、已有变化、下一步观察方向、可以带给老师确认的问题。\n"
"产品定位:这是帮助学员回到当下的近期实修回顾,不是成长成绩单、医疗诊断心理咨询结论。\n"
"只依据本周期主题摘要,使用 Markdown 输出:本周期谈到的主题、近期关注、可以继续留意的问题\n"
"不要统计或归纳情绪、身体、关系模式,不列做过或有效的功课,不评价进步、变化、阶段和结果,"
"不设置下一阶段目标。使用‘最近谈到/可以继续留意’,避免‘你总是/你已经形成’。\n"
"如果证据不足,请明确说“本周期沉淀记录较少”,不要编造。\n\n"
f"长期成长档案:\n{profile.profile_text if profile else '暂无'}\n\n"
f"本周期主题摘要:\n{summary_text}"
)
def _fallback_report(summaries: list[TopicSummary]) -> str:
lines = ["## 本周期实修小结", "", "模型生成失败时,系统先根据已沉淀主题摘要生成基础版本"]
lines = ["## 本周期实修回顾", "", "以下内容根据本周期已沉淀主题整理"]
for item in summaries[:20]:
lines.extend(["", f"- {item.summary}"])
if item.recommended_homework:
lines.append(f" - 建议功课:{item.recommended_homework}")
if item.next_observation:
lines.append(f" - 下一步观察{item.next_observation}")
lines.append(f" - 可以继续留意{item.next_observation}")
return "\n".join(lines)

View File

@@ -86,26 +86,21 @@ def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSess
def _render_share_draft(*, topic: TopicSession, summary: TopicSummary) -> str:
data = topic_summary_dict(summary)
data = topic_summary_dict(summary) or {}
return (
"【实修分享稿草稿】\n"
"说明:这是根据我本次对话整理出的分享草稿,系统不会自动发送到任何群,"
"我会按真实情况删改后再决定是否发到班级群。\n\n"
"大家好,我想分享一下这次实修里看到的一点东西\n\n"
"1. 我这次观察到的\n"
"大家好,我想分享一下这次实修中正在关注的内容\n\n"
"1. 我这次到的\n"
f"{topic.core_question or topic.title}\n\n"
"2. 我看见了什么\n"
f"{data.get('summary') or '(请用自己的话补充真实看见'}\n\n"
"3. 我感受到的情绪和身体反应\n"
f"情绪:{data.get('emotions') or '(请补充)'}\n"
f"身体:{data.get('bodyFeelings') or '(请补充)'}\n\n"
"4. 我做了什么功课 / 准备继续做什么\n"
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
"5. 当下的一点变化\n"
f"{data.get('insights') or '(请补充真实变化,不需要夸大)'}\n\n"
"6. 我还在继续观察的方向\n"
"2. 这次对话的简要回顾\n"
f"{data.get('summary') or '(请用自己的话补充)'}\n\n"
"3. 我近期正在关注什么\n"
f"{data.get('currentFocus') or '(请补充)'}\n\n"
"4. 我还想继续留意的方向\n"
f"{data.get('nextObservation') or '(请补充)'}\n\n"
"备注:这只是我的阶段性观察,不代表已经彻底解决,也不是建议别人照搬。"
"备注:这只是我当下的一次回顾,不代表结论,也不是建议别人照搬。"
)

View File

@@ -127,9 +127,7 @@ class TopicSessionService:
f"核心问题:{_limit(topic.core_question, 1200)}",
f"主题状态:{topic.status}",
]
if topic.recommended_homework:
lines.append(f"已记录的建议功课:{_limit(topic.recommended_homework, 1000)}")
if summary is not None and summary.status in {"success", "fallback"}:
if summary is not None and summary.schema_version >= 2 and summary.status in {"success", "fallback"}:
lines.extend(
[
"\n[所选主题摘要]",
@@ -137,12 +135,8 @@ class TopicSessionService:
]
)
details = [
("主要事件", summary.main_events),
("情绪", summary.emotions),
("身体感受", summary.body_feelings),
("信念", summary.beliefs),
("建议功课", summary.recommended_homework),
("下一步观察", summary.next_observation),
("本次关注", summary.main_events),
("可以继续留意", summary.next_observation),
]
lines.extend(f"{label}{_limit(value, 800)}" for label, value in details if value)
return "\n".join(lines), True

View File

@@ -37,7 +37,15 @@ def test_agent_debug_can_simulate_user_growth_profile_context():
status=1,
)
)
db.add(UserGrowthProfile(user_id=1, profile_text="用户在表达障碍主题上反复出现身体紧绷。"))
db.add(
UserGrowthProfile(
user_id=1,
schema_version=2,
recent_review="最近谈到表达时不知道怎么回到当下。",
current_focus="表达时先看见当下",
profile_text="旧版画像不得注入",
)
)
db.commit()
result = asyncio.run(
@@ -53,10 +61,11 @@ def test_agent_debug_can_simulate_user_growth_profile_context():
)
rendered = "\n".join(item["content"] for item in result.messages)
assert "[长期成长档案]" in rendered
assert "表达障碍" in rendered
assert "[近期实修回顾]" in rendered
assert "表达时先看见当下" in rendered
assert "旧版画像不得注入" not in rendered
assert result.tool_trace[0]["tool"] == "load_debug_user_context"
assert result.tool_trace[0]["response"]["growthProfileUsed"] is True
assert result.tool_trace[0]["response"]["recentPracticeReviewUsed"] is True
assert "[当前产品权益]" in rendered
@@ -88,7 +97,13 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
allow_share_draft=0,
status=1,
),
UserGrowthProfile(user_id=1, profile_text="用户在表达时容易身体紧绷。"),
UserGrowthProfile(
user_id=1,
schema_version=2,
recent_review="最近谈到面对领导时不敢表达。",
current_focus="面对领导时的当下表达",
profile_text="",
),
ChatMessage(
id=100,
session_id=20,
@@ -109,6 +124,7 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
topic_session_id=30,
user_id=1,
summary="学员正在观察面对权威时的紧绷。",
main_events="面对领导时如何表达",
emotions="害怕",
body_feelings="心口紧",
status="success",
@@ -134,6 +150,9 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
rendered = "\n".join(item["content"] for item in result.messages)
assert "[当前对话主题]" in rendered
assert "[所选主题摘要]" in rendered
assert "本次关注:面对领导时如何表达" in rendered
assert "情绪:害怕" not in rendered
assert "身体感受:心口紧" not in rendered
assert "我又不敢说话了" in rendered
assert "我想继续刚才的主题" in rendered
assert "老师求助卡:可由学员主动生成" in rendered

View File

@@ -72,7 +72,15 @@ def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_ena
assert completed.status == "success"
assert completed.summary
profile = db.query(UserGrowthProfile).filter_by(user_id=1).one()
assert "最近主题" in profile.profile_text
assert profile.schema_version == 2
assert "本次主要谈到" in (profile.recent_review or "")
assert profile.current_focus == "我做阴影人格会抗拒,身体发紧"
assert profile.profile_text == ""
assert profile.common_emotions is None
assert profile.body_patterns is None
assert profile.homework_done is None
assert profile.effective_homework is None
assert profile.recent_progress is None
assert db.query(UserGrowthProfile).filter_by(user_id=1).count() == 1
assert db.query(GrowthProfileRevision).filter_by(user_id=1).count() == 1
@@ -167,11 +175,21 @@ def test_stale_topic_settlement_is_recovered_after_restart():
assert "自动恢复" in (summary.error_message or "")
def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_context():
def test_prompt_includes_only_v2_recent_review_without_replacing_knowledge_context():
with _db() as db:
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
db.add(user)
db.add(UserGrowthProfile(user_id=1, profile_text="用户最近反复在表达障碍和身体紧绷之间观察。"))
db.add(
UserGrowthProfile(
user_id=1,
schema_version=2,
recent_review="最近谈到面对领导时不敢表达。",
current_focus="面对领导时如何回到当下",
profile_text="旧画像不应进入提示词",
common_emotions="紧张",
body_patterns="身体紧绷",
)
)
db.commit()
growth_context = GrowthProfileService.prompt_context(db, user)
@@ -183,24 +201,45 @@ def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_c
)
content = "\n".join(item["content"] for item in messages)
assert "[长期成长档案]" in content
assert "表达障碍" in content
assert "[近期实修回顾]" in content
assert "面对领导时如何回到当下" in content
assert "旧画像不应进入提示词" not in content
assert "身体紧绷" not in content
assert "常见情绪" not in content
assert "[本轮可靠知识上下文]" in content
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)
db.add_all(
[
user,
UserGrowthProfile(
user_id=1,
schema_version=1,
profile_text="旧版长期人格画像",
common_emotions="焦虑",
),
]
)
db.commit()
assert GrowthProfileService.prompt_context(db, user) is None
def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
raw = """
<think>
先分析一下,并给出一个未完成草稿:
```json
{"profileText": "不应采用的草稿"}
{"reviewText": "不应采用的草稿"}
```
</think>
```json
{
"profileText": "只保留最终成长档案",
"commonEmotions": ["紧张"],
"recentProgress": ["开始观察身体感受"]
"reviewText": "只保留最终近期回顾",
"currentFocus": ["当下正在谈到的问题"]
}
```
"""
@@ -208,5 +247,5 @@ def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
parsed = _parse_summary_json(raw)
assert parsed is not None
assert parsed["profileText"] == "只保留最终成长档案"
assert parsed["reviewText"] == "只保留最终近期回顾"
assert "think" not in str(parsed).lower()

View File

@@ -54,6 +54,8 @@ def test_generate_help_card_from_topic_summary_and_mark_copied():
assert "给老师的求助卡" in card.content
assert "不会自动发送给老师" in card.content
assert "阴影人格练习步骤是否正确" in card.content
assert "情绪 / 身体感受" not in card.content
assert "已经尝试过或被建议的功课" not in card.content
assert db.get(TopicSession, 1).help_card_generated == 1
assert db.query(TeacherHelpCard).count() == 1
@@ -61,4 +63,3 @@ def test_generate_help_card_from_topic_summary_and_mark_copied():
assert copied.copied == 1
assert copied.copied_at is not None

View File

@@ -14,6 +14,7 @@ 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.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
from app.services.model_service import ModelCompletion
from app.services.tracked_generation_service import TrackedGenerationService
@@ -51,7 +52,11 @@ def test_generate_periodic_report_from_topic_summaries():
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.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]
@@ -71,6 +76,68 @@ def test_generate_empty_periodic_report_when_no_summaries():
assert periodic_report_dict(report)["sourceSummaryIds"] == []
def test_periodic_report_strips_model_reasoning_before_saving(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=1, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="当下", core_question="我想先看当下", status="completed")
summary = TopicSummary(
id=1,
user_id=1,
topic_session_id=1,
summary="最近谈到想先看当下。",
generated_at=now - timedelta(days=1),
)
db.add_all([user, session, topic, summary])
db.commit()
monkeypatch.setattr(
TrackedGenerationService,
"generate",
staticmethod(
lambda *_args, **_kwargs: ModelCompletion(
answer="<think>内部分析与旧画像标签</think>\n## 本周期谈到的主题\n\n最近谈到想先看当下。",
model_id=1,
model_name="test-model",
input_token=1,
output_token=1,
)
),
)
report = PeriodicReportService.generate_for_user(
db,
user=user,
report_type="weekly",
period_start=now - timedelta(days=7),
period_end=now,
)
assert report.content.startswith("## 本周期谈到的主题")
assert "内部分析" not in report.content
assert "<think>" not in report.content
def test_legacy_periodic_reports_are_hidden_until_regenerated():
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
legacy = PeriodicReport(
user_id=1,
schema_version=1,
report_type="weekly",
period_start=now - timedelta(days=7),
period_end=now,
title="旧版成长报告",
content="常见情绪、身体模式、有效功课和近期变化",
status="success",
)
db.add_all([user, legacy])
db.commit()
assert PeriodicReportService.list_user_reports(db, user_id=1) == []
def test_async_report_job_is_durable_and_idempotent():
with _db() as db:
now = _now()

View File

@@ -53,7 +53,10 @@ def test_generate_share_draft_from_topic_summary_and_mark_copied():
assert "实修分享稿草稿" in draft.content
assert "系统不会自动发送到任何群" in draft.content
assert "不代表已经彻底解决" in draft.content
assert "不代表结论" in draft.content
assert "情绪和身体反应" not in draft.content
assert "做了什么功课" not in draft.content
assert "当下的一点变化" not in draft.content
assert "我看见自己不敢表达" in draft.content
assert db.get(TopicSession, 1).share_draft_generated == 1
assert db.query(ShareDraft).count() == 1