feat: add periodic practice reports
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user