feat: add periodic practice reports

This commit is contained in:
2026-07-31 16:37:25 +08:00
parent 88ed92d78c
commit 99e23b33ec
16 changed files with 602 additions and 3 deletions

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import TopicSession
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.model_service import ModelClientService
ReportType = Literal["weekly", "monthly", "stage"]
REPORT_TYPE_LABELS = {
"weekly": "每周实修小结",
"monthly": "每月成长报告",
"stage": "阶段成长总结",
}
class PeriodicReportService:
@staticmethod
def default_period(report_type: ReportType, now: datetime | None = None) -> tuple[datetime, datetime]:
current = (now or datetime.now(UTC)).replace(tzinfo=None)
if report_type == "weekly":
end = current
start = end - timedelta(days=7)
elif report_type == "monthly":
end = current
start = end - timedelta(days=30)
else:
end = current
start = end - timedelta(days=150)
return start.replace(microsecond=0), end.replace(microsecond=0)
@staticmethod
def list_user_reports(db: Session, *, user_id: int, limit: int = 20) -> list[PeriodicReport]:
return list(
db.scalars(
select(PeriodicReport)
.where(PeriodicReport.user_id == user_id)
.order_by(PeriodicReport.period_end.desc(), PeriodicReport.id.desc())
.limit(limit)
)
)
@staticmethod
def generate_for_user(
db: Session,
*,
user: User,
report_type: ReportType,
period_start: datetime | None = None,
period_end: datetime | None = None,
generated_by: str = "manual",
) -> PeriodicReport:
if report_type not in REPORT_TYPE_LABELS:
raise ValueError("不支持的报告类型")
if period_start is None or period_end is None:
default_start, default_end = PeriodicReportService.default_period(report_type)
period_start = period_start or default_start
period_end = period_end or default_end
period_start = period_start.replace(tzinfo=None, microsecond=0)
period_end = period_end.replace(tzinfo=None, microsecond=0)
if period_start >= period_end:
raise ValueError("报告开始时间必须早于结束时间")
report = _get_or_create_report(db, user=user, report_type=report_type, period_start=period_start, period_end=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)
report.source_summary_ids = json.dumps(summary_ids, ensure_ascii=False)
report.generated_by = generated_by
report.generated_at = _now()
if not summaries:
report.status = "empty"
report.error_message = None
report.content = _empty_report_content(report_type=report_type, period_start=period_start, period_end=period_end)
db.add(report)
db.commit()
db.refresh(report)
return report
try:
prompt = _report_prompt(user=user, report_type=report_type, period_start=period_start, period_end=period_end, summaries=summaries, profile=profile)
report.content = ModelClientService.generate_text_or_raise(db, prompt).strip() or _fallback_report(summaries)
model = ModelClientService._get_enabled_model(db)
report.model_name = model.model_name if model else None
report.status = "success"
report.error_message = None
except Exception as exc:
report.status = "failed"
report.error_message = str(exc)[:2000]
report.content = _fallback_report(summaries)
db.add(report)
db.commit()
db.refresh(report)
return report
def periodic_report_dict(report: PeriodicReport) -> dict:
return {
"id": report.id,
"userId": report.user_id,
"reportType": report.report_type,
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
"periodStart": report.period_start,
"periodEnd": report.period_end,
"title": report.title,
"content": report.content,
"sourceSummaryIds": _parse_json_list(report.source_summary_ids),
"sourceTopicIds": _parse_json_list(report.source_topic_ids),
"modelName": report.model_name,
"status": report.status,
"errorMessage": report.error_message,
"generatedBy": report.generated_by,
"generatedAt": report.generated_at,
"createdAt": report.created_at,
"updatedAt": report.updated_at,
}
def _get_or_create_report(
db: Session,
*,
user: User,
report_type: ReportType,
period_start: datetime,
period_end: datetime,
) -> PeriodicReport:
report = db.scalar(
select(PeriodicReport).where(
PeriodicReport.user_id == user.id,
PeriodicReport.report_type == report_type,
PeriodicReport.period_start == period_start,
PeriodicReport.period_end == period_end,
)
)
title = f"{REPORT_TYPE_LABELS[report_type]}{period_start:%Y-%m-%d}{period_end:%Y-%m-%d}"
if report is None:
report = PeriodicReport(
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
title=title,
)
else:
report.title = title
return report
def _period_summaries(db: Session, *, user_id: int, period_start: datetime, period_end: datetime) -> list[TopicSummary]:
return list(
db.scalars(
select(TopicSummary)
.where(
TopicSummary.user_id == user_id,
TopicSummary.status == "success",
TopicSummary.generated_at >= period_start,
TopicSummary.generated_at <= period_end,
)
.order_by(TopicSummary.generated_at.asc(), TopicSummary.id.asc())
.limit(200)
)
)
def _report_prompt(
*,
user: User,
report_type: ReportType,
period_start: datetime,
period_end: datetime,
summaries: list[TopicSummary],
profile: UserGrowthProfile | None,
) -> str:
summary_text = "\n\n".join(
(
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.next_observation or ''}"
)
for index, item in enumerate(summaries, start=1)
)
return (
f"请为学员“{user.name or user.nickname or user.phone}”生成一份{REPORT_TYPE_LABELS[report_type]}\n"
f"周期:{period_start:%Y-%m-%d %H:%M}{period_end:%Y-%m-%d %H:%M}\n\n"
"产品定位:这是大本营千问千答的实修陪伴报告,不写成医疗诊断、心理咨询结论或营销文。\n"
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案。\n"
"请使用 Markdown 输出,结构包含:本周期主要议题、做过或被建议的功课、反复出现的情绪/身体模式、已有变化、下一步观察方向、可以带给老师确认的问题。\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 = ["## 本周期实修小结", "", "模型生成失败时,系统先根据已沉淀主题摘要生成基础版本:"]
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}")
return "\n".join(lines)
def _empty_report_content(*, report_type: ReportType, period_start: datetime, period_end: datetime) -> str:
return (
f"## {REPORT_TYPE_LABELS[report_type]}\n\n"
f"周期:{period_start:%Y-%m-%d}{period_end:%Y-%m-%d}\n\n"
"本周期还没有可用于生成报告的主题沉淀。可以在完成一次主题对话后,先点击“沉淀本主题”,再生成报告。"
)
def _parse_json_list(raw: str | None) -> list[int]:
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
return [int(item) for item in data if isinstance(item, (int, str)) and str(item).isdigit()]
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)