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

@@ -45,6 +45,7 @@ const users = ref<AdminUser[]>([]);
const selectedUserDetail = ref<AdminUserDetail | null>(null);
const userDetailOpen = ref(false);
const userDetailLoading = ref(false);
const userReportGenerating = ref("");
const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]);
const editingEntitlementPlanId = ref<number | null>(null);
@@ -416,6 +417,20 @@ async function openUserDetail(row: AdminUser) {
}
}
async function generateUserReport(type: "weekly" | "monthly" | "stage") {
if (!selectedUserDetail.value) return;
userReportGenerating.value = type;
try {
await api.generateUserReport(selectedUserDetail.value.user.id, { reportType: type });
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
ElMessage.success("报告已生成");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
} finally {
userReportGenerating.value = "";
}
}
async function loadRecordTab(tab = recordTab.value) {
loading.value = true;
try {
@@ -1682,6 +1697,39 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</section>
<el-empty v-else description="该用户暂无成长档案沉淀" :image-size="64" />
<section class="user-report-head">
<div>
<h3 class="detail-title">周期报告</h3>
<p>基于已沉淀的主题摘要生成不读取整段原始聊天同一周期重复生成会覆盖旧报告</p>
</div>
<div class="user-report-actions">
<el-button size="small" :loading="userReportGenerating === 'weekly'" @click="generateUserReport('weekly')">生成周报</el-button>
<el-button size="small" :loading="userReportGenerating === 'monthly'" @click="generateUserReport('monthly')">生成月报</el-button>
<el-button size="small" :loading="userReportGenerating === 'stage'" @click="generateUserReport('stage')">生成阶段总结</el-button>
</div>
</section>
<el-collapse v-if="selectedUserDetail.recentReports.length" class="topic-summary-collapse">
<el-collapse-item
v-for="report in selectedUserDetail.recentReports"
:key="report.id"
:title="`#${report.id} ${report.title} / ${report.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>类型{{ report.reportTypeLabel }}</span>
<span>周期{{ report.periodStart }} {{ report.periodEnd }}</span>
<span>来源主题{{ report.sourceTopicIds.length }}</span>
<span>来源摘要{{ report.sourceSummaryIds.length }}</span>
<span>模型{{ report.modelName || '-' }}</span>
<span>生成{{ report.generatedAt }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">生成失败{{ report.errorMessage }}</p>
<pre>{{ report.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无周期报告" :image-size="64" />
<h3 class="detail-title">最近主题</h3>
<el-collapse v-if="selectedUserDetail.recentTopics.length" class="topic-summary-collapse">
<el-collapse-item

View File

@@ -29,6 +29,7 @@ import type {
UserImportResult,
UserEntitlementSummary,
PageResult,
PeriodicReportRecord,
PromptDetail,
PromptHistoryItem,
QuestionInsightSummary,
@@ -127,6 +128,9 @@ export const api = {
},
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`),
generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) =>
request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }),
createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>

View File

@@ -2054,6 +2054,44 @@ textarea {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.user-report-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-top: 18px;
}
.user-report-head .detail-title {
margin-bottom: 4px;
}
.user-report-head p {
margin: 0 0 10px;
color: #71827c;
font-size: 13px;
}
.user-report-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 10px;
}
.user-report-actions .el-button {
margin-left: 0;
white-space: nowrap;
}
.report-error {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #fff2f0;
color: #b42318;
}
.chat-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -73,6 +73,7 @@ export interface AdminUserDetail {
recentTopics: TopicSessionRecord[];
recentHelpCards: TeacherHelpCardRecord[];
recentShareDrafts: ShareDraftRecord[];
recentReports: PeriodicReportRecord[];
}
export interface AdminUserMetrics {
@@ -503,6 +504,26 @@ export interface UserGrowthProfileRecord {
updatedAt: string;
}
export interface PeriodicReportRecord {
id: number;
userId: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
periodEnd: string;
title: string;
content: string;
sourceSummaryIds: number[];
sourceTopicIds: number[];
modelName?: string | null;
status: "success" | "failed" | "empty" | string;
errorMessage?: string | null;
generatedBy: string;
generatedAt: string;
createdAt: string;
updatedAt: string;
}
export interface TopicSessionRecord {
id: number;
userId: number;

View File

@@ -0,0 +1,52 @@
"""periodic reports
Revision ID: 0019_periodic_reports
Revises: 0018_ai_cost_tracking
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0019_periodic_reports"
down_revision = "0018_ai_cost_tracking"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_periodic_report",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("report_type", sa.String(length=30), nullable=False),
sa.Column("period_start", sa.DateTime(), nullable=False),
sa.Column("period_end", sa.DateTime(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("source_summary_ids", sa.Text(), nullable=True),
sa.Column("source_topic_ids", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="success"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("generated_by", sa.String(length=30), nullable=False, server_default="manual"),
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
)
op.create_index("ix_sys_periodic_report_user_id", "sys_periodic_report", ["user_id"])
op.create_index("ix_sys_periodic_report_status", "sys_periodic_report", ["status"])
op.create_index("ix_sys_periodic_report_user_type_created", "sys_periodic_report", ["user_id", "report_type", "created_at"])
op.create_index("ix_sys_periodic_report_status_created", "sys_periodic_report", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_sys_periodic_report_status_created", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_user_type_created", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_status", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_user_id", table_name="sys_periodic_report")
op.drop_table("sys_periodic_report")

View File

@@ -7,6 +7,7 @@ from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import BaseModel
from pydantic import ValidationError
from sqlalchemy import extract, func, select
from sqlalchemy.orm import Session
@@ -26,6 +27,7 @@ from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict, topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
from app.services.share_draft_service import share_draft_dict
from app.api.pagination import page_result
@@ -34,6 +36,12 @@ router = APIRouter()
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
class AdminGenerateReportRequest(BaseModel):
reportType: str
periodStart: datetime | None = None
periodEnd: datetime | None = None
@router.get("/user/list")
def list_users(
keyword: str = Query(default=""),
@@ -259,10 +267,49 @@ def user_operation_detail(
"recentTopics": _recent_topics(db, user.id),
"recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)],
"recentShareDrafts": [share_draft_dict(item) for item in _recent_share_drafts(db, user.id)],
"recentReports": [periodic_report_dict(item) for item in PeriodicReportService.list_user_reports(db, user_id=user.id, limit=10)],
}
)
@router.get("/user/{user_id}/reports")
def user_reports(
user_id: int,
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
reports = PeriodicReportService.list_user_reports(db, user_id=user.id, limit=limit)
return api_success([periodic_report_dict(item) for item in reports])
@router.post("/user/{user_id}/reports/generate")
def generate_user_report(
user_id: int,
payload: AdminGenerateReportRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
if payload.reportType not in ("weekly", "monthly", "stage"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的报告类型")
try:
report = PeriodicReportService.generate_for_user(
db,
user=user,
report_type=payload.reportType, # type: ignore[arg-type]
period_start=payload.periodStart,
period_end=payload.periodEnd,
generated_by=f"admin:{current_admin.id}",
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="user_report", action="generate", target_id=report.id)
db.commit()
return api_success(periodic_report_dict(report))
@router.put("/user/{user_id}")
def update_user(
user_id: int,

View File

@@ -10,6 +10,7 @@ from app.models.user import User
from app.schemas.user import UserProfile
from app.services.entitlement_service import EntitlementService, entitlement_dict
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
from app.services.topic_session_service import TopicSessionService
router = APIRouter()
@@ -53,3 +54,13 @@ def growth_profile(
],
}
)
@router.get("/periodic-report/list")
def periodic_reports(
limit: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
reports = PeriodicReportService.list_user_reports(db, user_id=current_user.id, limit=max(1, min(limit, 50)))
return api_success([periodic_report_dict(item) for item in reports])

View File

@@ -3,7 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.growth import GrowthProfileRevision, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
@@ -48,6 +48,7 @@ __all__ = [
"HumanAttentionRecord",
"ModelConfig",
"OperationLog",
"PeriodicReport",
"LogRetentionPolicy",
"StorageSnapshot",
"TopicSession",

View File

@@ -100,3 +100,29 @@ class ShareDraft(Base):
copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class PeriodicReport(Base):
__tablename__ = "sys_periodic_report"
__table_args__ = (
UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
Index("ix_sys_periodic_report_user_type_created", "user_id", "report_type", "created_at"),
Index("ix_sys_periodic_report_status_created", "status", "created_at"),
)
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)
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)
title: Mapped[str] = mapped_column(String(160), nullable=False)
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
source_summary_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
source_topic_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_by: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
generated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

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)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
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.growth import PeriodicReport, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
def _db() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def test_generate_periodic_report_from_topic_summaries():
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")
summary = TopicSummary(
id=1,
user_id=1,
topic_session_id=1,
summary="本周反复看到表达时身体发紧。",
emotions="害怕、紧张",
body_feelings="喉咙紧、胸口堵",
recommended_homework="表达障碍练习",
next_observation="先观察身体反应",
generated_at=now - timedelta(days=1),
)
db.add_all([user, session, topic, summary, UserGrowthProfile(user_id=1, profile_text="用户常在表达前身体发紧。")])
db.commit()
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.content
data = periodic_report_dict(report)
assert data["sourceSummaryIds"] == [1]
assert data["sourceTopicIds"] == [1]
assert db.query(PeriodicReport).filter_by(user_id=1).count() == 1
def test_generate_empty_periodic_report_when_no_summaries():
with _db() as db:
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
db.add(user)
db.commit()
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"] == []

View File

@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, PeriodicReport, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
@@ -32,6 +32,7 @@ const shareDraft = ref<ShareDraft | null>(null);
const shareDraftContent = ref("");
const helpCardHistory = ref<TeacherHelpCard[]>([]);
const shareDraftHistory = ref<ShareDraft[]>([]);
const reportHistory = ref<PeriodicReport[]>([]);
const profileLoading = ref(false);
const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
@@ -294,14 +295,16 @@ async function openGrowthProfile() {
profileDialogOpen.value = true;
profileLoading.value = true;
try {
const [profileResult, helpCards, shareDrafts] = await Promise.all([
const [profileResult, helpCards, shareDrafts, reports] = await Promise.all([
api.growthProfile(),
api.helpCards(10),
api.shareDrafts(10),
api.periodicReports(10),
]);
growthProfile.value = profileResult;
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
} catch (error) {
handleError(error, "成长档案加载失败");
} finally {
@@ -491,6 +494,14 @@ async function copyText(text: string) {
<p>{{ item.summary }}</p>
</article>
</div>
<div v-if="reportHistory.length" class="recent-topic-summaries periodic-report-list">
<h3>周期实修报告</h3>
<article v-for="item in reportHistory" :key="`report-${item.id}`">
<time>{{ item.reportTypeLabel }} · {{ item.periodStart }} {{ item.periodEnd }} · {{ item.generatedAt }}</time>
<p class="report-title">{{ item.title }}</p>
<pre>{{ item.content }}</pre>
</article>
</div>
<div v-if="helpCardHistory.length" class="recent-topic-summaries">
<h3>最近老师求助卡</h3>
<article v-for="item in helpCardHistory" :key="`help-${item.id}`">

View File

@@ -6,6 +6,7 @@ import type {
FinishTopicResult,
GrowthProfileResult,
LoginResult,
PeriodicReport,
ShareDraft,
TeacherHelpCard,
UserProfile,
@@ -95,6 +96,7 @@ export const api = {
markShareDraftCopied: (draftId: number) => request<ShareDraft>(`/chat/share-draft/${draftId}/copied`, { method: "POST", body: JSON.stringify({}) }),
shareDrafts: (limit = 20) => request<ShareDraft[]>(`/chat/share-draft/list?limit=${limit}`),
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
};

View File

@@ -1810,6 +1810,19 @@ textarea:focus-visible {
line-height: 1.65;
}
.periodic-report-list .report-title {
color: var(--chat-text);
font-weight: 650;
}
.periodic-report-list pre {
max-height: 260px;
margin: 8px 0 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.dialog-actions {
display: grid;
grid-template-columns: 1fr 1fr;

View File

@@ -60,6 +60,18 @@ export interface GrowthProfileResult {
recentSummaries: TopicSummary[];
}
export interface PeriodicReport {
id: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
periodEnd: string;
title: string;
content: string;
status: "success" | "failed" | "empty" | string;
generatedAt: string;
}
export interface FinishTopicResult {
topic: Record<string, unknown>;
summary: TopicSummary & Record<string, unknown>;