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

@@ -1660,30 +1660,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div><span>生成记录</span><strong>求助卡 {{ selectedUserDetail.metrics.helpCardCount }} / 分享稿 {{ selectedUserDetail.metrics.shareDraftCount }}</strong></div>
</section>
<h3 class="detail-title">成长档案摘要</h3>
<h3 class="detail-title">近期实修回顾</h3>
<section v-if="selectedUserDetail.growthProfile" class="topic-summary-card">
<pre>{{ selectedUserDetail.growthProfile.profileText || '暂无成长档案摘要' }}</pre>
<div class="topic-summary-fields">
<p v-if="selectedUserDetail.growthProfile.recurringTopics"><strong>反复议题</strong>{{ selectedUserDetail.growthProfile.recurringTopics }}</p>
<p v-if="selectedUserDetail.growthProfile.commonEmotions"><strong>常见情绪</strong>{{ selectedUserDetail.growthProfile.commonEmotions }}</p>
<p v-if="selectedUserDetail.growthProfile.bodyPatterns"><strong>身体模式</strong>{{ selectedUserDetail.growthProfile.bodyPatterns }}</p>
<p v-if="selectedUserDetail.growthProfile.homeworkDone"><strong>做过功课</strong>{{ selectedUserDetail.growthProfile.homeworkDone }}</p>
<p v-if="selectedUserDetail.growthProfile.effectiveHomework"><strong>有效功课</strong>{{ selectedUserDetail.growthProfile.effectiveHomework }}</p>
<p v-if="selectedUserDetail.growthProfile.recentProgress"><strong>近期变化</strong>{{ selectedUserDetail.growthProfile.recentProgress }}</p>
<pre>{{ selectedUserDetail.growthProfile.reviewText || '暂无近期回顾' }}</pre>
<div v-if="selectedUserDetail.growthProfile.currentFocus" class="topic-summary-fields">
<p><strong>近期关注</strong>{{ selectedUserDetail.growthProfile.currentFocus }}</p>
</div>
</section>
<el-empty v-else description="该用户暂无成长档案沉淀" :image-size="64" />
<el-empty v-else description="该用户暂无新版近期实修回顾,将在下一次主题沉淀后生成" :image-size="64" />
<section class="user-report-head">
<div>
<h3 class="detail-title">周期报告</h3>
<p>基于已沉淀的主题摘要生成不读取整段原始聊天同一周期重复生成会覆盖旧报告</p>
<h3 class="detail-title">周期实修回顾</h3>
<p>基于本周期主题摘要回顾近期关注不评价成长结果同一周期重复生成会覆盖旧版本</p>
</div>
<div class="user-report-actions">
<el-button size="small" @click="refreshSelectedUserDetail">刷新状态</el-button>
<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>
<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">
@@ -1738,12 +1733,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div>
<p v-if="topic.summary.errorMessage" class="report-error">最近错误{{ topic.summary.errorMessage }}</p>
<pre v-if="topic.summary.status === 'success' || topic.summary.status === 'fallback'">{{ topic.summary.summary }}</pre>
<p v-else-if="topic.summary.status === 'pending' || topic.summary.status === 'running'" class="report-pending">主题已结束摘要和成长档案正在后台生成</p>
<p v-else-if="topic.summary.status === 'pending' || topic.summary.status === 'running'" class="report-pending">主题已结束摘要和近期实修回顾正在后台生成</p>
<div v-if="topic.summary.status === 'success' || topic.summary.status === 'fallback'" class="topic-summary-fields">
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
<p v-if="topic.summary.currentFocus"><strong>本次关注</strong>{{ topic.summary.currentFocus }}</p>
<p v-if="topic.summary.nextObservation"><strong>可以继续留意</strong>{{ topic.summary.nextObservation }}</p>
</div>
<el-button
v-if="topic.summary.status === 'failed'"
@@ -1844,11 +1837,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.mainEvents"><strong>主要事件</strong>{{ topic.summary.mainEvents }}</p>
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
<p v-if="topic.summary.currentFocus"><strong>本次关注</strong>{{ topic.summary.currentFocus }}</p>
<p v-if="topic.summary.nextObservation"><strong>可以继续留意</strong>{{ topic.summary.nextObservation }}</p>
</div>
</template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />

View File

@@ -569,7 +569,7 @@ function errorMessage(error: unknown, fallback: string) {
:remote-method="searchDebugUsers"
:loading="debugUserLoading"
:disabled="agentDebugging"
placeholder="不选择则不注入学员权益和成长档案"
placeholder="不选择则不注入学员权益和近期实修回顾"
@change="handleDebugUserChange"
>
<el-option
@@ -579,7 +579,7 @@ function errorMessage(error: unknown, fallback: string) {
:value="user.id"
/>
</el-select>
<div class="agent-form-help">加载该学员的权益和长期成长档案不会消耗额度或改动正式会话</div>
<div class="agent-form-help">加载该学员的权益和近期实修回顾只用于保持近期对话连续性不会消耗额度或改动正式会话</div>
</el-form-item>
<el-form-item label="模拟主题(可选)">
<el-select

View File

@@ -37,8 +37,8 @@ const enabledCount = computed(() => props.plans.filter((plan) => plan.status ===
const capabilityOptions = [
{
key: "enableGrowthProfile" as const,
title: "成长档案",
description: "持续沉淀学员主题、变化与成长记录",
title: "近期实修回顾",
description: "基于近期主题保持连续性,不做长期画像和成长评价",
},
{
key: "enablePeriodicReports" as const,
@@ -251,7 +251,7 @@ async function editPlan(plan: EntitlementPlan) {
<el-table-column label="能力" min-width="360">
<template #default="{ row }">
<div class="entitlement-capabilities">
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">成长档案</el-tag>
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">近期实修回顾</el-tag>
<el-tag v-if="row.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>

View File

@@ -489,13 +489,9 @@ export interface TopicSummaryRecord {
id: number;
topicSessionId: number;
userId: number;
schemaVersion: number;
summary: string;
mainEvents?: string | null;
emotions?: string | null;
bodyFeelings?: string | null;
beliefs?: string | null;
recommendedHomework?: string | null;
insights?: string | null;
currentFocus?: string | null;
nextObservation?: string | null;
modelName?: string | null;
status: string;
@@ -511,14 +507,10 @@ export interface TopicSummaryRecord {
export interface UserGrowthProfileRecord {
id: number;
userId: number;
profileText: string;
recurringTopics?: string | null;
commonEmotions?: string | null;
bodyPatterns?: string | null;
relationPatterns?: string | null;
homeworkDone?: string | null;
effectiveHomework?: string | null;
recentProgress?: string | null;
schemaVersion: number;
reviewText: string;
currentFocus?: string | null;
sourceSummaryIds: number[];
lastTopicSummaryId?: number | null;
updatedBy: string;
createdAt: string;
@@ -528,6 +520,7 @@ export interface UserGrowthProfileRecord {
export interface PeriodicReportRecord {
id: number;
userId: number;
schemaVersion: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
@@ -561,7 +554,6 @@ export interface TopicSessionRecord {
tokenInput: number;
tokenOutput: number;
quotaDeducted: boolean;
recommendedHomework?: string | null;
startedAt: string;
endedAt?: string | null;
createdAt?: string | null;

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

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, PeriodicReport, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
@@ -25,7 +25,7 @@ const logoutDialogOpen = ref(false);
const profileDialogOpen = ref(false);
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const growthProfile = ref<GrowthProfileResult | null>(null);
const practiceReview = ref<PracticeReviewResult | null>(null);
const helpCard = ref<TeacherHelpCard | null>(null);
const helpCardContent = ref("");
const shareDraft = ref<ShareDraft | null>(null);
@@ -218,7 +218,7 @@ async function finishCurrentTopic() {
try {
const result = await api.finishTopic(activeSessionId.value);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀");
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了近期实修回顾" : "本主题已沉淀");
} else {
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
void watchTopicSettlement(result.summary.id);
@@ -240,7 +240,7 @@ async function watchTopicSettlement(summaryId: number) {
try {
const result = await api.topicSettlement(summaryId);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了成长档案" : "实修记录已沉淀完成");
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了近期实修回顾" : "实修记录已沉淀完成");
await refreshProfile();
return;
}
@@ -330,22 +330,22 @@ async function copyShareDraft() {
}
}
async function openGrowthProfile() {
async function openPracticeReview() {
profileDialogOpen.value = true;
profileLoading.value = true;
try {
const [profileResult, helpCards, shareDrafts, reports] = await Promise.all([
api.growthProfile(),
api.practiceReview(),
api.helpCards(10),
api.shareDrafts(10),
api.periodicReports(10),
]);
growthProfile.value = profileResult;
practiceReview.value = profileResult;
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
} catch (error) {
handleError(error, "成长档案加载失败");
handleError(error, "近期实修回顾加载失败");
} finally {
profileLoading.value = false;
}
@@ -484,7 +484,7 @@ async function copyText(text: string) {
:generating-help-card="generatingHelpCard"
:generating-share-draft="generatingShareDraft"
@finish-topic="finishCurrentTopic"
@open-profile="openGrowthProfile"
@open-profile="openPracticeReview"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>
@@ -512,23 +512,19 @@ async function copyText(text: string) {
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<AppDialog v-if="profileDialogOpen" title="我的实修档案" labelled-by="growth-profile-title" @close="profileDialogOpen = false">
<AppDialog v-if="profileDialogOpen" title="近期实修回顾" labelled-by="growth-profile-title" @close="profileDialogOpen = false">
<section class="growth-profile-dialog" :aria-busy="profileLoading">
<p v-if="profileLoading" class="profile-empty">正在加载成长档案...</p>
<template v-else-if="growthProfile?.profile">
<pre>{{ growthProfile.profile.profileText }}</pre>
<div class="growth-profile-fields">
<p v-if="growthProfile.profile.recurringTopics"><strong>反复议题</strong>{{ growthProfile.profile.recurringTopics }}</p>
<p v-if="growthProfile.profile.commonEmotions"><strong>常见情绪</strong>{{ growthProfile.profile.commonEmotions }}</p>
<p v-if="growthProfile.profile.bodyPatterns"><strong>身体感受</strong>{{ growthProfile.profile.bodyPatterns }}</p>
<p v-if="growthProfile.profile.homeworkDone"><strong>做过的功课</strong>{{ growthProfile.profile.homeworkDone }}</p>
<p v-if="growthProfile.profile.recentProgress"><strong>最近进展</strong>{{ growthProfile.profile.recentProgress }}</p>
<p v-if="profileLoading" class="profile-empty">正在加载近期回顾...</p>
<template v-else-if="practiceReview?.profile">
<pre>{{ practiceReview.profile.reviewText }}</pre>
<div v-if="practiceReview.profile.currentFocus" class="growth-profile-fields">
<p><strong>近期关注</strong>{{ practiceReview.profile.currentFocus }}</p>
</div>
</template>
<p v-else class="profile-empty">还没有成长档案你可以在完成一次主题对话后点击沉淀本主题</p>
<div v-if="growthProfile?.recentSummaries.length" class="recent-topic-summaries">
<p v-else class="profile-empty">还没有近期回顾你可以在完成一次主题对话后点击沉淀本主题</p>
<div v-if="practiceReview?.recentSummaries.length" class="recent-topic-summaries">
<h3>最近主题沉淀</h3>
<article v-for="item in growthProfile.recentSummaries" :key="item.id">
<article v-for="item in practiceReview.recentSummaries" :key="item.id">
<time>{{ item.generatedAt }} · {{ settlementStatusLabel(item.status) }}</time>
<p v-if="item.status === 'success' || item.status === 'fallback'">{{ item.summary }}</p>
<p v-else-if="item.status === 'failed'">沉淀暂时失败对话内容仍已保留</p>
@@ -536,7 +532,7 @@ async function copyText(text: string) {
</article>
</div>
<div v-if="reportHistory.length" class="recent-topic-summaries periodic-report-list">
<h3>周期实修报告</h3>
<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>

View File

@@ -52,7 +52,7 @@ const guidanceText = computed(() => {
class="quota-link"
@click="$emit('openProfile')"
>
我的档案
近期回顾
</button>
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
{{ finishing ? '沉淀中' : '沉淀本主题' }}

View File

@@ -4,7 +4,7 @@ import type {
ChatMessage,
ChatSession,
FinishTopicResult,
GrowthProfileResult,
PracticeReviewResult,
LoginResult,
PeriodicReport,
ShareDraft,
@@ -96,7 +96,7 @@ export const api = {
generateShareDraft: (sessionId: number) => request<ShareDraft>(`/chat/session/${sessionId}/share-draft`, { method: "POST", body: JSON.stringify({}) }),
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"),
practiceReview: () => request<PracticeReviewResult>("/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

@@ -35,8 +35,9 @@ export interface UserEntitlementSummary {
export interface TopicSummary {
id: number;
topicSessionId: number;
schemaVersion: number;
summary: string;
recommendedHomework?: string | null;
currentFocus?: string | null;
nextObservation?: string | null;
status: "pending" | "running" | "success" | "fallback" | "failed" | string;
errorMessage?: string | null;
@@ -48,27 +49,24 @@ export interface TopicSummary {
generatedAt: string;
}
export interface GrowthProfile {
export interface PracticeReview {
id: number;
userId: number;
profileText: string;
recurringTopics?: string | null;
commonEmotions?: string | null;
bodyPatterns?: string | null;
relationPatterns?: string | null;
homeworkDone?: string | null;
effectiveHomework?: string | null;
recentProgress?: string | null;
schemaVersion: number;
reviewText: string;
currentFocus?: string | null;
sourceSummaryIds: number[];
updatedAt: string;
}
export interface GrowthProfileResult {
profile: GrowthProfile | null;
export interface PracticeReviewResult {
profile: PracticeReview | null;
recentSummaries: TopicSummary[];
}
export interface PeriodicReport {
id: number;
schemaVersion: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
@@ -82,7 +80,7 @@ export interface PeriodicReport {
export interface FinishTopicResult {
topic: Record<string, unknown>;
summary: TopicSummary & Record<string, unknown>;
profile: GrowthProfile | null;
profile: PracticeReview | null;
growthProfileEnabled: boolean;
settlementStatus: string;
}

View File

@@ -165,12 +165,12 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
## 主题沉淀后台任务
用户点击“沉淀本主题”后,接口只结束当前主题并创建持久化任务,不再等待模型生成摘要和成长档案。用户可以立即继续聊天、切换会话或关闭页面。
用户点击“沉淀本主题”后,接口只结束当前主题并创建持久化任务,不再等待模型生成主题摘要和近期实修回顾。用户可以立即继续聊天、切换会话或关闭页面。
任务状态保存在 `sys_topic_summary`
- `pending`:等待后台执行;
- `running`:正在生成摘要和成长档案
- `running`:正在生成主题摘要和近期实修回顾
- `success` / `fallback`:沉淀完成;
- `failed`:达到最大重试次数后仍失败,可在后台用户详情中手动重试。
@@ -190,7 +190,7 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
- 可用模型:允许后台任务选择,可同时启用多个;
- 默认主模型:只能有一个,承担未分流的正式聊天、追问改写和检索重排。
周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
周期实修回顾会选择“周期报告”能力已开启的可用模型,主题摘要和近期实修回顾会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
正式问答的分流规则在“系统配置 / AI 问答 / 正式问答模型分流规则”中配置:

View File

@@ -56,7 +56,7 @@
- [ ] AI 请求日志有记录。
- [ ] 操作日志有记录。
- [ ] 后台 Agent 预览能够选择学员和主题,并在追踪中看到上下文与模型路由。
- [ ] 求助卡、分享稿、成长档案和周期报告能够按权益生成与查看。
- [ ] 求助卡、分享稿、近期实修回顾和周期实修回顾能够按权益生成与查看。
- [ ] 访问日志为单行 JSON且可用响应头中的 `X-Request-ID` 定位同一次请求。
- [ ] 已确认备份任务生成 `.sql.gz` 和对应 `.sha256` 文件。
- [ ] 已完成至少一次隔离数据库恢复演练。

View File

@@ -47,18 +47,18 @@
主题会话机制
主题摘要 / 成长档案
主题摘要 / 近期实修回顾
Agent 调用链接入成长档案与权益
Agent 调用链接入近期实修回顾与权益
用户端实修能力:求助卡、分享稿、我的档案
用户端实修能力:求助卡、分享稿、近期实修回顾
后台运营能力:用户详情、成本、问题洞察二期
报告体系:周报、月报、五个月总结
```
不要先做周报、分享稿或漂亮页面。它们依赖主题会话和成长档案,提前做会变成“无数据可用的壳”。
不要先做周报、分享稿或漂亮页面。它们依赖主题会话和近期实修回顾,提前做会变成“无数据可用的壳”。
## 4. TODO 清单
@@ -102,7 +102,7 @@ Agent 调用链接入成长档案与权益
- 类型:基础版 / 深度陪伴版 / 高频加购包;
- 有效天数;
- 每月主题会话额度;
- 是否开启成长档案
- 是否开启近期实修回顾
- 是否开启周报/月报;
- 是否允许生成老师求助卡;
- 是否允许生成班级分享稿;
@@ -216,65 +216,49 @@ Agent 调用链接入成长档案与权益
- 一个主题只扣一次主题额度;
- 管理后台能看到主题会话;
- 主题能关联原始聊天消息;
- 主题结束后可用于成长档案沉淀。
- 主题结束后可用于近期实修回顾沉淀。
---
### 03. 主题摘要与成长档案
### 03. 主题摘要与近期实修回顾
- 优先级P0
- 改动大小L
- 影响范围数据库、Agent 上下文、模型调用、用户端“我的档案”、后台用户详情
- 影响范围数据库、Agent 上下文、模型调用、用户端“近期回顾”、后台用户详情
- 依赖:主题会话机制
#### 当前现状
当前已有会话摘要和上下文消息数,用于解决当前会话记忆问题。
但它不是产品层面的“五个月成长档案”,还不能表达:
- 反复议题;
- 做过的功课;
- 情绪和身体感受变化;
- 用户成长轨迹;
- 哪些功课有效。
当前已有会话摘要和上下文消息数,用于解决当前会话记忆问题。产品不再建设长期人格化成长画像,避免用户和 Agent 对过程、标签及结果形成执着。
#### 目标
把每个主题会话沉淀成主题摘要,再把多个主题摘要汇总成用户成长档案
把每个主题会话沉淀成简短回顾再从最近30天、最多10个新版主题生成“近期实修回顾”。只帮助保持连续性和回到当下不评价成长结果
#### 需要新增的数据
建议新增:
- `sys_topic_summary`:主题摘要;
- `sys_user_growth_profile`用户成长档案
- `sys_growth_profile_revision`成长档案版本记录。
- `sys_user_growth_profile`近期实修回顾(历史画像字段仅兼容保留)
- `sys_growth_profile_revision`近期回顾版本记录。
主题摘要字段建议包含:
- 主题 ID
- 用户原始问题摘要;
- 主要事件
- 主要情绪
- 身体感受;
- 信念/程序;
- 推荐功课;
- 用户已经看见的内容;
- 下一步建议观察方向;
- 本次关注
- 可以继续留意的问题
- 生成模型;
- 生成时间。
成长档案字段建议包含:
近期实修回顾字段包含:
- 用户 ID
- 反复议题
- 常见情绪
- 常见身体反应
- 常见关系模式
- 常做功课;
- 有效功课;
- 最近进展;
- 近期回顾
- 近期关注最多3项
- 来源主题摘要 ID
- 数据版本
- 最近更新时间。
#### 需要改的代码模块
@@ -282,32 +266,33 @@ Agent 调用链接入成长档案与权益
- 后端:
- 主题结束逻辑;
- 摘要生成服务;
- 成长档案更新服务;
- 近期回顾更新服务;
- Agent Prompt 构建逻辑;
- 模型服务。
- 用户端:
- 我的实修档案
- 近期实修回顾
- 主题摘要展示。
- 管理端:
- 用户详情里的成长档案
- 用户详情里的近期实修回顾
- 摘要重生成。
#### 验收标准
- 一个主题结束后可以生成摘要;
- 用户成长档案能跨主题更新;
- Agent 回答时能引用成长档案摘要
- 近期实修回顾能基于限定时间窗口更新;
- Agent 只能把近期回顾用于保持连续性,并优先依据本轮表达
- 旧画像、情绪/身体/关系模式、做过或有效功课、近期变化均不再生成、展示或注入;
- 不把全部历史原文塞进模型;
- 后台能查看档案更新记录。
---
### 04. Agent 调用链接入权益、主题与成长档案
### 04. Agent 调用链接入权益、主题与近期实修回顾
- 优先级P0
- 改动大小L
- 影响范围Agent 主链路、调试预览、用户端流式输出、检索日志
- 依赖:权益版本体系、主题会话机制、成长档案
- 依赖:权益版本体系、主题会话机制、近期实修回顾
#### 当前现状
@@ -318,20 +303,20 @@ Agent 已经可以:
- 使用上下文摘要;
- 记录检索日志;
- 支持后台预览。
- 用户端正式聊天已按权益决定是否注入成长档案
- 后台 Agent 预览已支持选择“模拟学员”,用于注入该学员权益和成长档案上下文。
- 用户端正式聊天已按权益决定是否注入近期实修回顾
- 后台 Agent 预览已支持选择“模拟学员”,用于注入该学员权益和近期实修回顾上下文。
但后续要让 Agent 区分:
- 当前用户是什么权益;
- 当前问题属于哪个主题;
- 是否可使用成长档案
- 是否可使用近期实修回顾
- 是否需要提醒找人工老师确认;
- 是否允许生成求助卡/分享稿。
#### 目标
让 Agent 不只是“回答问题”,而是运行在“权益 + 主题 + 成长档案 + 知识库”的产品上下文中。
让 Agent 不只是“回答问题”,而是运行在“权益 + 主题 + 近期实修回顾 + 知识库”的产品上下文中。
#### 需要改的代码模块
@@ -349,16 +334,16 @@ Agent 已经可以:
- 用户端和后台预览链路参数一致;
- Agent 能拿到当前主题摘要;
- 深度版用户可以使用成长档案
- 基础版不会调用长期成长档案
- 深度版用户可以使用近期实修回顾
- 基础版不会调用近期实修回顾
- 生成日志能看出本次用了哪些上下文。
#### 开发进度
- 2026-07-31已补齐后台预览的“模拟学员”入口选择学员后预览链路会加载该学员权益、主题额度使用量和长期成长档案,并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。
- 2026-07-31后台预览已支持继续选择具体主题加载主题摘要、滚动摘要和最近消息正式聊天与后台预览均会按权益注入成长档案、求助卡和分享稿能力,并在追踪中记录模型路由与上下文来源。
- 2026-07-31一期全链路已完成隔离生产编排、真实模型问答、流式响应、主题上下文、成长档案、求助卡、分享稿、异步报告和记录审计验收。详细结果见《一期全链路验收记录》。
- 2026-08-03主题结束已改为持久化异步沉淀。接口快速结束主题并创建唯一任务后台独立生成摘要和成长档案;支持自动重试、服务重启恢复、用户端状态查询和后台手动重试,原同步模型调用不再阻塞用户操作。
- 2026-07-31已补齐后台预览的“模拟学员”入口选择学员后预览链路会加载该学员权益、主题额度使用量和实修回顾上下文,并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。
- 2026-07-31后台预览已支持继续选择具体主题加载主题摘要、滚动摘要和最近消息正式聊天与后台预览均会按权益注入实修回顾、求助卡和分享稿能力,并在追踪中记录模型路由与上下文来源。
- 2026-07-31一期全链路已完成隔离生产编排、真实模型问答、流式响应、主题上下文、实修回顾、求助卡、分享稿、异步报告和记录审计验收。详细结果见《一期全链路验收记录》。
- 2026-08-03主题结束已改为持久化异步沉淀。接口快速结束主题并创建唯一任务后台独立生成主题摘要和近期实修回顾;支持自动重试、服务重启恢复、用户端状态查询和后台手动重试,原同步模型调用不再阻塞用户操作。
---
@@ -657,24 +642,23 @@ AI 日志增加:
---
### 10. 用户端“我的实修档案
### 10. 用户端“近期实修回顾
- 优先级P1
- 改动大小M
- 影响范围:用户端、成长档案接口、主题历史
- 依赖:主题会话机制、成长档案
- 影响范围:用户端、近期回顾接口、主题历史
- 依赖:主题会话机制、近期实修回顾
#### 目标
让用户感受到“五个月成长被记录下来了”
帮助用户看见最近谈到的内容并回到当下,不形成成长成绩单或长期自我标签
#### 用户端建议展示
- 最近主题;
- 已完成的功课记录;
- 主题摘要;
- 每周小结;
- 每月报告
- 每月回顾
- 老师求助卡历史;
- 班级分享稿历史。
@@ -685,12 +669,13 @@ AI 日志增加:
#### 验收标准
- 用户能查看自己的主题记录;
- 用户能查看成长档案摘要
- 用户能查看近期回顾
- 基础版和深度版展示边界不同。
#### 开发进度
- 2026-07-31用户端“我的档案”弹窗已扩展为实修记录视图可查看成长档案摘要、最近主题沉淀、最近老师求助卡和最近班级分享稿;仍保持“我的实修记录”表达,不做后台画像式展示。
- 2026-07-31用户端“我的档案”弹窗已扩展为实修记录视图,可查看实修回顾、最近主题沉淀、最近老师求助卡和最近班级分享稿;仍保持“我的实修记录”表达,不做后台画像式展示。
- 2026-08-03升级为 V2“近期实修回顾”。移除常见情绪、身体/关系模式、做过或有效功课、近期变化等字段;旧版数据不再展示或注入 Agent下一次主题沉淀时按最近30天、最多10个新版主题懒重建。
---
@@ -699,7 +684,7 @@ AI 日志增加:
- 优先级P1
- 改动大小M
- 影响范围:管理端用户详情、用户接口、统计接口
- 依赖:权益版本体系、主题会话机制、成长档案
- 依赖:权益版本体系、主题会话机制、近期实修回顾
#### 当前现状
@@ -717,7 +702,7 @@ AI 日志增加:
- 使用频率;
- Token/成本;
- 最近主题;
- 成长档案摘要
- 近期实修回顾
- 求助卡生成记录;
- 分享稿生成记录;
- 是否高频使用。
@@ -731,7 +716,7 @@ AI 日志增加:
#### 开发进度
- 2026-07-31后台用户管理新增“详情”抽屉独立接口按需读取用户运营摘要展示当前权益、权益有效期、本月主题数、近30天活跃天数、会话/消息/Token/成本、最近主题、成长档案摘要、老师求助卡和班级分享稿记录。列表接口仍只返回分页摘要,避免用户管理打开变慢。
- 2026-07-31后台用户管理新增“详情”抽屉独立接口按需读取用户运营摘要展示当前权益、权益有效期、本月主题数、近30天活跃天数、会话/消息/Token/成本、最近主题、近期实修回顾、老师求助卡和班级分享稿记录。列表接口仍只返回分页摘要,避免用户管理打开变慢。
---
@@ -739,12 +724,12 @@ AI 日志增加:
- 优先级P2
- 改动大小L
- 影响范围:异步任务、模型调用、成长档案、用户端、后台
- 依赖:主题会话机制、成长档案、成本统计
- 影响范围:异步任务、模型调用、近期实修回顾、用户端、后台
- 依赖:主题会话机制、近期实修回顾、成本统计
#### 不建议过早做
报告依赖主题摘要和成长档案。如果先做,只能从原始聊天拼凑,成本高且质量不稳定。
报告依赖主题摘要和近期实修回顾。如果先做,只能从原始聊天拼凑,成本高且质量不稳定。
#### 目标
@@ -753,16 +738,14 @@ AI 日志增加:
#### 报告类型
- 每周实修小结;
- 每月成长报告
- 五个月成长总结
- 每月实修回顾
- 阶段实修回顾
#### 报告内容建议
- 本周期主要议题;
- 做过的功课
- 重复出现的情绪/模式
- 有变化的地方;
- 仍需继续观察的方向;
- 近期关注
- 可以继续留意的问题
- 可以带给老师确认的问题。
#### 需要改的代码模块
@@ -792,6 +775,7 @@ AI 日志增加:
- 2026-07-31一期已新增周期报告数据表 `sys_periodic_report`,支持周报、月报、阶段总结三种类型;后台用户详情可手动生成/重新生成报告并查看状态、来源主题/摘要、失败原因;用户端“我的实修档案”可查看已生成报告。当前版本先做同步手动生成,后续再接定时/异步任务,聊天链路不会被报告生成阻塞。
- 2026-07-31报告生成已改为数据库持久化异步任务。管理员点击生成后接口立即返回后台 Worker 独立生成;支持等待、执行中、成功、空报告、失败状态,最多自动重试 3 次,服务重启后会恢复超时任务。同一用户、类型和周期由唯一约束保证幂等。
- 2026-07-31已接入自然周和自然月定时生成。默认按 `Asia/Shanghai` 在每周一 02:00 生成上一自然周周报、每月 1 日 03:00 生成上一自然月月报;只为权益开启周期报告且本周期存在成功主题摘要的有效用户入队。用户端只显示成功报告,后台可查看任务状态、执行次数、失败原因并手动重新生成。
- 2026-08-03周期报告升级为 V2 实修回顾,只读取新版主题摘要,不再读取长期画像,也不生成情绪/身体/关系模式、功课效果、成长变化、评分或下一阶段目标;旧版报告默认隐藏,重新生成后升级为 V2。
---
@@ -856,8 +840,8 @@ AI 日志增加:
1. 权益版本体系;
2. 主题会话机制;
3. 主题摘要与成长档案
4. Agent 调用链接入权益、主题与成长档案
3. 主题摘要与近期实修回顾
4. Agent 调用链接入权益、主题与近期实修回顾
这一批改动最大,但越早做越少返工。
@@ -897,6 +881,6 @@ AI 日志增加:
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
- 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环;
- 一期主要功能链路已验收通过;主题摘要和成长档案已改为持久化异步生成,原同步等待风险已经关闭;
- 一期主要功能链路已验收通过;主题摘要和近期实修回顾已改为持久化异步生成,原同步等待风险已经关闭;
- 问题洞察的知识库补充建议、人工合并和拆分暂缓;
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。

View File

@@ -4,14 +4,16 @@
- 验收日期2026-07-31
- 验收分支:`codex/h5-qa-experience`
- 验收范围:权益体系、用户聊天、后台 Agent 预览、主题上下文、成长档案、求助卡、分享稿、周期报告、记录审计、数据库迁移与生产部署链路
- 验收范围:权益体系、用户聊天、后台 Agent 预览、主题上下文、近期实修回顾、求助卡、分享稿、周期实修回顾、记录审计、数据库迁移与生产部署链路
- 验收方式:浏览器真实操作、接口调用、真实模型调用、自动化测试、隔离 MySQL 迁移、隔离生产编排及上线冒烟
## 2. 验收结论
一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。
2026-08-03 已完成主题沉淀异步化:结束主题接口只完成状态变更和任务入队,摘要、成长档案由持久化后台任务生成;重复点击返回同一任务,失败自动重试,服务重启能够恢复,最终失败可由管理员手动重试。原先约 42 秒的同步等待不再阻塞用户请求。
2026-08-03 已完成主题沉淀异步化:结束主题接口只完成状态变更和任务入队,主题摘要、近期实修回顾由持久化后台任务生成;重复点击返回同一任务,失败自动重试,服务重启能够恢复,最终失败可由管理员手动重试。原先约 42 秒的同步等待不再阻塞用户请求。
2026-08-03 已完成实修记录 V2 改造:不再生成、展示或注入常见情绪、身体/关系模式、做过或有效功课、近期变化等长期画像字段;仅保留最近 30 天、最多 10 个新版主题形成的近期回顾、近期关注和可以继续留意的问题。旧版记录保留在数据库中用于兼容和回滚,但不会进入用户端或 Agent 上下文。
## 3. 功能验收结果
@@ -22,27 +24,27 @@
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和成长档案 |
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和近期实修回顾 |
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
| 主题沉淀 | 通过 | 结束主题快速入队;摘要与成长档案后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
| 主题沉淀 | 通过 | 结束主题快速入队;主题摘要与近期实修回顾后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
| 求助卡 | 通过 | 可生成非空内容并记录复制状态 |
| 分享稿 | 通过 | 可生成非空内容并记录复制状态 |
| 周期报告 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存报告 |
| 用户成长档案 | 通过 | 用户端读取档案、主题摘要和周期报告 |
| 后台用户详情 | 通过 | 可查看成长档案、求助卡、分享稿和周期报告 |
| 周期实修回顾 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存回顾;模型思考内容会在入库前剔除 |
| 近期实修回顾 | 通过 | 用户端读取新版近期回顾、主题摘要和周期实修回顾,旧版画像不返回 |
| 后台用户详情 | 通过 | 可查看近期实修回顾、求助卡、分享稿和周期实修回顾 |
| 记录审计 | 通过 | 聊天列表与按需加载详情均正常 |
## 4. 工程与部署验收
| 验收项 | 结果 | 实际结果 |
| --- | --- | --- |
| 后端自动化测试 | 通过 | 111 项测试全部通过,包含异步入队、幂等、重试、重启恢复及推理内容过滤 |
| 后端自动化测试 | 通过 | 114 项测试全部通过,包含 V2 数据隔离、异步入队、幂等、重试、重启恢复及推理内容过滤 |
| OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
| 管理后台构建 | 通过 | 生产构建成功 |
| 用户端构建 | 通过 | 生产构建成功 |
| Alembic 离线 SQL | 通过 | 成功生成 1130 行迁移 SQL并覆盖到 `0024_topic_settlement_jobs` |
| 现有数据库迁移 | 通过 | 开发 MySQL 已 `0023_simple_knowledge_route` 升级至 `0024_topic_settlement_jobs` |
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0024_topic_settlement_jobs`,主题任务字段和索引均已生成,验收库随后清理 |
| Alembic 离线 SQL | 通过 | 成功生成 1154 行迁移 SQL并覆盖到 `0025_recent_practice_review` |
| 现有数据库迁移 | 通过 | 开发 MySQL 已升级至 `0025_recent_practice_review`,数据库与 Redis 就绪 |
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0025_recent_practice_review`V2 字段与默认值均已生成,验收库随后清理 |
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
| 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 |
@@ -53,7 +55,9 @@
全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。
2026-08-03 浏览器验收时发现,个别模型可能把 `<think>` 推理过程和多个 JSON 草稿一起返回,旧解析逻辑可能将其降级为成长档案正文。现已在结构化解析前剔除推理块,并从后向前选择最后一个可完整解析的 JSON已补回归测试思考过程不会再写入成长档案
2026-08-03 浏览器验收时发现,个别模型可能把 `<think>` 推理过程和多个 JSON 草稿一起返回,旧解析逻辑可能将其降级为回顾正文。现已在结构化解析前剔除推理块,并从后向前选择最后一个可完整解析的 JSON已补回归测试思考过程不会再写入近期或周期实修回顾
2026-08-03 对真实模型完成主题沉淀和阶段回顾验收:新版主题只保存“摘要 / 本次关注 / 可以继续留意”,近期回顾只保存“回顾正文 / 近期关注”;后台页面未出现旧画像字段,周期回顾正文未出现 `<think>` 或“思考过程”。
## 6. 上线前风险与建议