feat: 将成长档案调整为近期实修回顾
This commit is contained in:
@@ -1660,30 +1660,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<div><span>生成记录</span><strong>求助卡 {{ selectedUserDetail.metrics.helpCardCount }} / 分享稿 {{ selectedUserDetail.metrics.shareDraftCount }}</strong></div>
|
<div><span>生成记录</span><strong>求助卡 {{ selectedUserDetail.metrics.helpCardCount }} / 分享稿 {{ selectedUserDetail.metrics.shareDraftCount }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h3 class="detail-title">成长档案摘要</h3>
|
<h3 class="detail-title">近期实修回顾</h3>
|
||||||
<section v-if="selectedUserDetail.growthProfile" class="topic-summary-card">
|
<section v-if="selectedUserDetail.growthProfile" class="topic-summary-card">
|
||||||
<pre>{{ selectedUserDetail.growthProfile.profileText || '暂无成长档案摘要' }}</pre>
|
<pre>{{ selectedUserDetail.growthProfile.reviewText || '暂无近期回顾' }}</pre>
|
||||||
<div class="topic-summary-fields">
|
<div v-if="selectedUserDetail.growthProfile.currentFocus" class="topic-summary-fields">
|
||||||
<p v-if="selectedUserDetail.growthProfile.recurringTopics"><strong>反复议题</strong>{{ selectedUserDetail.growthProfile.recurringTopics }}</p>
|
<p><strong>近期关注</strong>{{ selectedUserDetail.growthProfile.currentFocus }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<el-empty v-else description="该用户暂无成长档案沉淀" :image-size="64" />
|
<el-empty v-else description="该用户暂无新版近期实修回顾,将在下一次主题沉淀后生成" :image-size="64" />
|
||||||
|
|
||||||
<section class="user-report-head">
|
<section class="user-report-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="detail-title">周期报告</h3>
|
<h3 class="detail-title">周期实修回顾</h3>
|
||||||
<p>基于已沉淀的主题摘要生成,不读取整段原始聊天;同一周期重复生成会覆盖旧报告。</p>
|
<p>只基于本周期主题摘要回顾近期关注,不评价成长结果;同一周期重复生成会覆盖旧版本。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-report-actions">
|
<div class="user-report-actions">
|
||||||
<el-button size="small" @click="refreshSelectedUserDetail">刷新状态</el-button>
|
<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 === 'weekly'" @click="generateUserReport('weekly')">生成周报</el-button>
|
||||||
<el-button size="small" :loading="userReportGenerating === 'monthly'" @click="generateUserReport('monthly')">生成月报</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<el-collapse v-if="selectedUserDetail.recentReports.length" class="topic-summary-collapse">
|
<el-collapse v-if="selectedUserDetail.recentReports.length" class="topic-summary-collapse">
|
||||||
@@ -1738,12 +1733,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="topic.summary.errorMessage" class="report-error">最近错误:{{ topic.summary.errorMessage }}</p>
|
<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>
|
<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">
|
<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.currentFocus"><strong>本次关注</strong>{{ topic.summary.currentFocus }}</p>
|
||||||
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
|
<p v-if="topic.summary.nextObservation"><strong>可以继续留意</strong>{{ topic.summary.nextObservation }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="topic.summary.status === 'failed'"
|
v-if="topic.summary.status === 'failed'"
|
||||||
@@ -1844,11 +1837,8 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<template v-if="topic.summary">
|
<template v-if="topic.summary">
|
||||||
<pre>{{ topic.summary.summary }}</pre>
|
<pre>{{ topic.summary.summary }}</pre>
|
||||||
<div class="topic-summary-fields">
|
<div class="topic-summary-fields">
|
||||||
<p v-if="topic.summary.mainEvents"><strong>主要事件</strong>{{ topic.summary.mainEvents }}</p>
|
<p v-if="topic.summary.currentFocus"><strong>本次关注</strong>{{ topic.summary.currentFocus }}</p>
|
||||||
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
|
<p v-if="topic.summary.nextObservation"><strong>可以继续留意</strong>{{ topic.summary.nextObservation }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
|
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ function errorMessage(error: unknown, fallback: string) {
|
|||||||
:remote-method="searchDebugUsers"
|
:remote-method="searchDebugUsers"
|
||||||
:loading="debugUserLoading"
|
:loading="debugUserLoading"
|
||||||
:disabled="agentDebugging"
|
:disabled="agentDebugging"
|
||||||
placeholder="不选择则不注入学员权益和成长档案"
|
placeholder="不选择则不注入学员权益和近期实修回顾"
|
||||||
@change="handleDebugUserChange"
|
@change="handleDebugUserChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -579,7 +579,7 @@ function errorMessage(error: unknown, fallback: string) {
|
|||||||
:value="user.id"
|
:value="user.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="agent-form-help">加载该学员的权益和长期成长档案;不会消耗额度或改动正式会话。</div>
|
<div class="agent-form-help">加载该学员的权益和近期实修回顾;只用于保持近期对话连续性,不会消耗额度或改动正式会话。</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模拟主题(可选)">
|
<el-form-item label="模拟主题(可选)">
|
||||||
<el-select
|
<el-select
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ const enabledCount = computed(() => props.plans.filter((plan) => plan.status ===
|
|||||||
const capabilityOptions = [
|
const capabilityOptions = [
|
||||||
{
|
{
|
||||||
key: "enableGrowthProfile" as const,
|
key: "enableGrowthProfile" as const,
|
||||||
title: "成长档案",
|
title: "近期实修回顾",
|
||||||
description: "持续沉淀学员主题、变化与成长记录",
|
description: "基于近期主题保持连续性,不做长期画像和成长评价",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enablePeriodicReports" as const,
|
key: "enablePeriodicReports" as const,
|
||||||
@@ -251,7 +251,7 @@ async function editPlan(plan: EntitlementPlan) {
|
|||||||
<el-table-column label="能力" min-width="360">
|
<el-table-column label="能力" min-width="360">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="entitlement-capabilities">
|
<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.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
|
||||||
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
|
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
|
||||||
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
|
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
|
||||||
|
|||||||
@@ -489,13 +489,9 @@ export interface TopicSummaryRecord {
|
|||||||
id: number;
|
id: number;
|
||||||
topicSessionId: number;
|
topicSessionId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
schemaVersion: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
mainEvents?: string | null;
|
currentFocus?: string | null;
|
||||||
emotions?: string | null;
|
|
||||||
bodyFeelings?: string | null;
|
|
||||||
beliefs?: string | null;
|
|
||||||
recommendedHomework?: string | null;
|
|
||||||
insights?: string | null;
|
|
||||||
nextObservation?: string | null;
|
nextObservation?: string | null;
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -511,14 +507,10 @@ export interface TopicSummaryRecord {
|
|||||||
export interface UserGrowthProfileRecord {
|
export interface UserGrowthProfileRecord {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
profileText: string;
|
schemaVersion: number;
|
||||||
recurringTopics?: string | null;
|
reviewText: string;
|
||||||
commonEmotions?: string | null;
|
currentFocus?: string | null;
|
||||||
bodyPatterns?: string | null;
|
sourceSummaryIds: number[];
|
||||||
relationPatterns?: string | null;
|
|
||||||
homeworkDone?: string | null;
|
|
||||||
effectiveHomework?: string | null;
|
|
||||||
recentProgress?: string | null;
|
|
||||||
lastTopicSummaryId?: number | null;
|
lastTopicSummaryId?: number | null;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -528,6 +520,7 @@ export interface UserGrowthProfileRecord {
|
|||||||
export interface PeriodicReportRecord {
|
export interface PeriodicReportRecord {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
schemaVersion: number;
|
||||||
reportType: "weekly" | "monthly" | "stage" | string;
|
reportType: "weekly" | "monthly" | "stage" | string;
|
||||||
reportTypeLabel: string;
|
reportTypeLabel: string;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
@@ -561,7 +554,6 @@ export interface TopicSessionRecord {
|
|||||||
tokenInput: number;
|
tokenInput: number;
|
||||||
tokenOutput: number;
|
tokenOutput: number;
|
||||||
quotaDeducted: boolean;
|
quotaDeducted: boolean;
|
||||||
recommendedHomework?: string | null;
|
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt?: string | null;
|
endedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ def _seed_default_plans() -> None:
|
|||||||
{
|
{
|
||||||
"name": "五个月深度陪伴版",
|
"name": "五个月深度陪伴版",
|
||||||
"plan_type": "deep",
|
"plan_type": "deep",
|
||||||
"description": "支持长期成长档案、阶段报告和更高主题会话额度。",
|
"description": "支持近期实修回顾、周期实修回顾和更高主题会话额度。",
|
||||||
"validity_days": 150,
|
"validity_days": 150,
|
||||||
"monthly_topic_limit": 90,
|
"monthly_topic_limit": 90,
|
||||||
"enable_growth_profile": 1,
|
"enable_growth_profile": 1,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -45,8 +45,9 @@ def growth_profile(
|
|||||||
{
|
{
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
"topicSessionId": item.topic_session_id,
|
"topicSessionId": item.topic_session_id,
|
||||||
|
"schemaVersion": item.schema_version,
|
||||||
"summary": item.summary,
|
"summary": item.summary,
|
||||||
"recommendedHomework": item.recommended_homework,
|
"currentFocus": item.main_events,
|
||||||
"nextObservation": item.next_observation,
|
"nextObservation": item.next_observation,
|
||||||
"status": item.status,
|
"status": item.status,
|
||||||
"errorMessage": item.error_message,
|
"errorMessage": item.error_message,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class TopicSummary(Base):
|
|||||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
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)
|
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)
|
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)
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
main_events: Mapped[str | None] = mapped_column(Text, nullable=True)
|
main_events: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
emotions: 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)
|
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)
|
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)
|
profile_text: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
recurring_topics: Mapped[str | None] = mapped_column(Text, nullable=True)
|
recurring_topics: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
common_emotions: 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)
|
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)
|
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)
|
report_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
period_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
period_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
period_end: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
period_end: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class AgentDebugService:
|
|||||||
"userId": user.id,
|
"userId": user.id,
|
||||||
"userName": user.name,
|
"userName": user.name,
|
||||||
"entitlement": entitlement_dict(entitlement),
|
"entitlement": entitlement_dict(entitlement),
|
||||||
"growthProfileUsed": bool(growth_context),
|
"recentPracticeReviewUsed": bool(growth_context),
|
||||||
"productContextUsed": True,
|
"productContextUsed": True,
|
||||||
"topic": {
|
"topic": {
|
||||||
"id": topic.id,
|
"id": topic.id,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class ChatService:
|
|||||||
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
|
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
|
||||||
if growth_context:
|
if growth_context:
|
||||||
context_trace = list(context_trace or [])
|
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)
|
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
|
||||||
product_context = entitlement_prompt_context(entitlement)
|
product_context = entitlement_prompt_context(entitlement)
|
||||||
context_trace = list(context_trace or [])
|
context_trace = list(context_trace or [])
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ChatStreamService:
|
|||||||
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
|
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
|
||||||
if growth_context:
|
if growth_context:
|
||||||
context_trace = list(context_trace or [])
|
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)
|
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
|
||||||
product_context = entitlement_prompt_context(entitlement)
|
product_context = entitlement_prompt_context(entitlement)
|
||||||
context_trace = _append_runtime_context_trace(
|
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
|
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
|
||||||
if growth_context:
|
if growth_context:
|
||||||
context_trace = list(context_trace or [])
|
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)
|
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
|
||||||
product_context = entitlement_prompt_context(entitlement)
|
product_context = entitlement_prompt_context(entitlement)
|
||||||
context_trace = _append_runtime_context_trace(
|
context_trace = _append_runtime_context_trace(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -18,6 +18,11 @@ from app.services.external_errors import ExternalServiceError
|
|||||||
from app.services.tracked_generation_service import TrackedGenerationService
|
from app.services.tracked_generation_service import TrackedGenerationService
|
||||||
|
|
||||||
|
|
||||||
|
RECENT_REVIEW_SCHEMA_VERSION = 2
|
||||||
|
RECENT_REVIEW_DAYS = 30
|
||||||
|
RECENT_REVIEW_TOPIC_LIMIT = 10
|
||||||
|
|
||||||
|
|
||||||
class GrowthProfileService:
|
class GrowthProfileService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def finish_active_topic(db: Session, *, user: User, session: ChatSession, force: bool = False) -> dict:
|
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]:
|
def process_topic_settlement(db: Session, *, summary: TopicSummary) -> tuple[TopicSummary, UserGrowthProfile | None]:
|
||||||
topic = db.get(TopicSession, summary.topic_session_id)
|
topic = db.get(TopicSession, summary.topic_session_id)
|
||||||
# Redis 失效时允许多个进程依靠数据库继续消费;用户行锁保证同一学员的
|
# Redis 失效时允许多个进程依靠数据库继续消费;用户行锁保证同一学员的
|
||||||
# 多个主题不会并发覆盖长期成长档案。
|
# 多个主题不会并发覆盖近期实修回顾。
|
||||||
user = db.scalar(select(User).where(User.id == summary.user_id).with_for_update())
|
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:
|
if topic is None or user is None or user.is_deleted:
|
||||||
raise ValueError("主题或用户不存在")
|
raise ValueError("主题或用户不存在")
|
||||||
@@ -129,7 +134,8 @@ class GrowthProfileService:
|
|||||||
process_job=True,
|
process_job=True,
|
||||||
allow_fallback=False,
|
allow_fallback=False,
|
||||||
)
|
)
|
||||||
topic.recommended_homework = generated.recommended_homework
|
# V2 不再固化或展示“建议功课”,历史列仅保留兼容。
|
||||||
|
topic.recommended_homework = None
|
||||||
db.add(topic)
|
db.add(topic)
|
||||||
entitlement = EntitlementService.active_entitlement(db, user)
|
entitlement = EntitlementService.active_entitlement(db, user)
|
||||||
profile = None
|
profile = None
|
||||||
@@ -148,6 +154,8 @@ class GrowthProfileService:
|
|||||||
allow_fallback: bool = True,
|
allow_fallback: bool = True,
|
||||||
) -> TopicSummary:
|
) -> TopicSummary:
|
||||||
existing = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id))
|
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 is not None and not force:
|
||||||
if existing.status in {"success", "fallback"}:
|
if existing.status in {"success", "fallback"}:
|
||||||
return existing
|
return existing
|
||||||
@@ -165,8 +173,9 @@ class GrowthProfileService:
|
|||||||
)
|
)
|
||||||
if not messages:
|
if not messages:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容")
|
||||||
conversation = _messages_text(messages)
|
user_content = _messages_text(messages, roles={"user"})
|
||||||
prompt = _topic_summary_prompt(topic, conversation)
|
assistant_context = _messages_text(messages, roles={"assistant"})
|
||||||
|
prompt = _topic_summary_prompt(topic, user_content, assistant_context)
|
||||||
model_name = None
|
model_name = None
|
||||||
try:
|
try:
|
||||||
completion = TrackedGenerationService.generate(
|
completion = TrackedGenerationService.generate(
|
||||||
@@ -178,19 +187,20 @@ class GrowthProfileService:
|
|||||||
raw = completion.answer
|
raw = completion.answer
|
||||||
model_name = completion.model_name
|
model_name = completion.model_name
|
||||||
parsed = _parse_summary_json(raw)
|
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"
|
status_value = "success"
|
||||||
error_message = None
|
error_message = None
|
||||||
except ExternalServiceError as exc:
|
except ExternalServiceError as exc:
|
||||||
if not allow_fallback:
|
if not allow_fallback:
|
||||||
raise
|
raise
|
||||||
data = _fallback_summary(topic, conversation, "")
|
data = _fallback_summary(topic, user_content, "")
|
||||||
status_value = "fallback"
|
status_value = "fallback"
|
||||||
error_message = str(exc)
|
error_message = str(exc)
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
existing = TopicSummary(topic_session_id=topic.id, user_id=user.id)
|
existing = TopicSummary(topic_session_id=topic.id, user_id=user.id)
|
||||||
_apply_summary(existing, data)
|
_apply_summary(existing, data)
|
||||||
|
existing.schema_version = RECENT_REVIEW_SCHEMA_VERSION
|
||||||
existing.model_name = model_name
|
existing.model_name = model_name
|
||||||
existing.status = status_value
|
existing.status = status_value
|
||||||
existing.error_message = error_message
|
existing.error_message = error_message
|
||||||
@@ -206,7 +216,11 @@ class GrowthProfileService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile:
|
def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile:
|
||||||
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
|
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
|
return profile
|
||||||
before = growth_profile_dict(profile) if profile is not None else None
|
before = growth_profile_dict(profile) if profile is not None else None
|
||||||
if profile is None:
|
if profile is None:
|
||||||
@@ -214,7 +228,8 @@ class GrowthProfileService:
|
|||||||
db.add(profile)
|
db.add(profile)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
prompt = _growth_profile_prompt(profile, topic_summary)
|
summaries = _recent_review_summaries(db, user_id=user.id)
|
||||||
|
prompt = _recent_review_prompt(summaries)
|
||||||
try:
|
try:
|
||||||
completion = TrackedGenerationService.generate(
|
completion = TrackedGenerationService.generate(
|
||||||
db,
|
db,
|
||||||
@@ -224,11 +239,11 @@ class GrowthProfileService:
|
|||||||
)
|
)
|
||||||
raw = completion.answer
|
raw = completion.answer
|
||||||
parsed = _parse_summary_json(raw)
|
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:
|
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.last_topic_summary_id = topic_summary.id
|
||||||
profile.updated_by = "system"
|
profile.updated_by = "system"
|
||||||
db.add(profile)
|
db.add(profile)
|
||||||
@@ -241,7 +256,7 @@ class GrowthProfileService:
|
|||||||
topic_summary_id=topic_summary.id,
|
topic_summary_id=topic_summary.id,
|
||||||
before_json=json.dumps(before, ensure_ascii=False, default=str) if before else None,
|
before_json=json.dumps(before, ensure_ascii=False, default=str) if before else None,
|
||||||
after_json=json.dumps(after, ensure_ascii=False, default=str),
|
after_json=json.dumps(after, ensure_ascii=False, default=str),
|
||||||
reason="topic_summary",
|
reason="recent_practice_review_v2",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return profile
|
return profile
|
||||||
@@ -255,7 +270,10 @@ class GrowthProfileService:
|
|||||||
return list(
|
return list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(TopicSummary)
|
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())
|
.order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
@@ -264,13 +282,20 @@ class GrowthProfileService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def prompt_context(db: Session, user: User) -> str | None:
|
def prompt_context(db: Session, user: User) -> str | None:
|
||||||
profile = GrowthProfileService.get_growth_profile(db, user.id)
|
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
|
return None
|
||||||
|
focus = (profile.current_focus or "").strip()
|
||||||
return (
|
return (
|
||||||
"[长期成长档案]\n"
|
"[近期实修回顾]\n"
|
||||||
"以下是用户跨主题沉淀出的简要成长档案,只能用于理解用户长期模式和延续陪伴,"
|
"以下内容只用于保持近期对话连续性。必须优先依据用户本轮表达,不得把历史回顾当成人格、"
|
||||||
"不能替代本轮可靠知识,也不能臆造课程内容。\n"
|
"情绪、身体或关系模式,不得据此评价成长结果,也不能替代本轮可靠知识。\n"
|
||||||
f"{_limit(profile.profile_text, 1800)}"
|
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,
|
"tokenInput": topic.token_input,
|
||||||
"tokenOutput": topic.token_output,
|
"tokenOutput": topic.token_output,
|
||||||
"quotaDeducted": bool(topic.quota_deducted),
|
"quotaDeducted": bool(topic.quota_deducted),
|
||||||
"recommendedHomework": topic.recommended_homework,
|
|
||||||
"startedAt": topic.started_at,
|
"startedAt": topic.started_at,
|
||||||
"endedAt": topic.ended_at,
|
"endedAt": topic.ended_at,
|
||||||
"createdAt": topic.created_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 {
|
return {
|
||||||
"id": summary.id,
|
"id": summary.id,
|
||||||
"topicSessionId": summary.topic_session_id,
|
"topicSessionId": summary.topic_session_id,
|
||||||
"userId": summary.user_id,
|
"userId": summary.user_id,
|
||||||
|
"schemaVersion": summary.schema_version,
|
||||||
"summary": summary.summary,
|
"summary": summary.summary,
|
||||||
"mainEvents": summary.main_events,
|
"currentFocus": summary.main_events,
|
||||||
"emotions": summary.emotions,
|
|
||||||
"bodyFeelings": summary.body_feelings,
|
|
||||||
"beliefs": summary.beliefs,
|
|
||||||
"recommendedHomework": summary.recommended_homework,
|
|
||||||
"insights": summary.insights,
|
|
||||||
"nextObservation": summary.next_observation,
|
"nextObservation": summary.next_observation,
|
||||||
"modelName": summary.model_name,
|
"modelName": summary.model_name,
|
||||||
"status": summary.status,
|
"status": summary.status,
|
||||||
@@ -320,19 +342,15 @@ def topic_summary_dict(summary: TopicSummary) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def growth_profile_dict(profile: UserGrowthProfile | None) -> dict | None:
|
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 None
|
||||||
return {
|
return {
|
||||||
"id": profile.id,
|
"id": profile.id,
|
||||||
"userId": profile.user_id,
|
"userId": profile.user_id,
|
||||||
"profileText": profile.profile_text,
|
"schemaVersion": profile.schema_version,
|
||||||
"recurringTopics": profile.recurring_topics,
|
"reviewText": profile.recent_review or "",
|
||||||
"commonEmotions": profile.common_emotions,
|
"currentFocus": profile.current_focus,
|
||||||
"bodyPatterns": profile.body_patterns,
|
"sourceSummaryIds": _json_int_list(profile.source_summary_ids),
|
||||||
"relationPatterns": profile.relation_patterns,
|
|
||||||
"homeworkDone": profile.homework_done,
|
|
||||||
"effectiveHomework": profile.effective_homework,
|
|
||||||
"recentProgress": profile.recent_progress,
|
|
||||||
"lastTopicSummaryId": profile.last_topic_summary_id,
|
"lastTopicSummaryId": profile.last_topic_summary_id,
|
||||||
"updatedBy": profile.updated_by,
|
"updatedBy": profile.updated_by,
|
||||||
"createdAt": profile.created_at,
|
"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:
|
def _apply_summary(summary: TopicSummary, data: dict[str, Any]) -> None:
|
||||||
summary.summary = _field(data, "summary", "本主题已沉淀。", 4000)
|
summary.summary = _field(data, "summary", "本主题已沉淀。", 4000)
|
||||||
summary.main_events = _field(data, "mainEvents", "", 2000) or None
|
summary.main_events = _field(data, "currentFocus", "", 1200) 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.next_observation = _field(data, "nextObservation", "", 1500) 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:
|
def _apply_recent_review(
|
||||||
profile.profile_text = _field(data, "profileText", profile.profile_text or "暂无成长档案。", 5000)
|
profile: UserGrowthProfile,
|
||||||
profile.recurring_topics = _field(data, "recurringTopics", "", 2000) or profile.recurring_topics
|
data: dict[str, Any],
|
||||||
profile.common_emotions = _field(data, "commonEmotions", "", 1500) or profile.common_emotions
|
summaries: list[TopicSummary],
|
||||||
profile.body_patterns = _field(data, "bodyPatterns", "", 1500) or profile.body_patterns
|
) -> None:
|
||||||
profile.relation_patterns = _field(data, "relationPatterns", "", 1500) or profile.relation_patterns
|
profile.schema_version = RECENT_REVIEW_SCHEMA_VERSION
|
||||||
profile.homework_done = _field(data, "homeworkDone", "", 1500) or profile.homework_done
|
profile.recent_review = _field(data, "reviewText", "近期还没有可回顾的主题。", 3000)
|
||||||
profile.effective_homework = _field(data, "effectiveHomework", "", 1500) or profile.effective_homework
|
profile.current_focus = _field(data, "currentFocus", "", 1000) or None
|
||||||
profile.recent_progress = _field(data, "recentProgress", "", 1500) or profile.recent_progress
|
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 (
|
return (
|
||||||
"你是大本营千问千答的主题沉淀助手。请把一个主题会话总结为结构化 JSON,"
|
"你是大本营千问千答的近期主题回顾助手。请输出结构化 JSON,字段仅包含:"
|
||||||
"只依据对话内容,不要扩展课程知识,不要评价用户人格。输出字段:"
|
"summary, currentFocus, nextObservation。"
|
||||||
"summary, mainEvents, emotions, bodyFeelings, beliefs, recommendedHomework, insights, nextObservation。"
|
"用户原话是唯一可以形成用户结论的证据;AI回复只用于理解上下文,不能成为用户特征或结论。"
|
||||||
"内容要偏“回到当下、回到自身、觉察情绪和感受、如是释放”,不要给很多术层面的建议。\n\n"
|
"只描述本次明确谈到的内容和当下关注,不分析人格、潜意识、情绪模式、身体模式、关系模式、"
|
||||||
f"主题标题:{topic.title}\n核心问题:{topic.core_question}\n\n对话:\n{_limit(conversation, 12000)}"
|
"成长阶段、功课效果或近期变化,不评分、不贴标签、不扩展课程知识。"
|
||||||
|
"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:
|
def _recent_review_prompt(summaries: list[TopicSummary]) -> str:
|
||||||
current = profile.profile_text or "暂无"
|
evidence = [
|
||||||
|
{
|
||||||
|
"summaryId": item.id,
|
||||||
|
"summary": item.summary,
|
||||||
|
"currentFocus": item.main_events,
|
||||||
|
"nextObservation": item.next_observation,
|
||||||
|
"generatedAt": item.generated_at,
|
||||||
|
}
|
||||||
|
for item in summaries
|
||||||
|
]
|
||||||
return (
|
return (
|
||||||
"你是大本营千问千答的长期成长档案整理助手。请根据旧档案和最新主题摘要,"
|
"你是大本营千问千答的近期实修回顾助手。请依据最近30天内、最多10条主题摘要输出 JSON,"
|
||||||
"滚动更新一份结构化 JSON。只保留稳定模式和最近进展,避免堆叠流水账。"
|
"字段仅包含 reviewText 和 currentFocus。reviewText 用一小段中性文字回顾最近明确谈到的主题;"
|
||||||
"输出字段:profileText, recurringTopics, commonEmotions, bodyPatterns, relationPatterns, "
|
"currentFocus 最多列出3项近期关注。不得继承或推断长期人格、情绪模式、身体模式、关系模式,"
|
||||||
"homeworkDone, effectiveHomework, recentProgress。语气客观、温和,不诊断、不贴标签。\n\n"
|
"不得记录做过或有效的功课,不评价进步、变化、阶段或结果,不把一次表达固化为长期特征。"
|
||||||
f"旧档案:\n{_limit(current, 5000)}\n\n最新主题摘要:\n{json.dumps(topic_summary_dict(summary), ensure_ascii=False, default=str)}"
|
"使用‘最近谈到/近期关注’而不是‘你总是/你已经形成’。证据不足时直接说明近期记录较少。\n\n"
|
||||||
|
f"近期主题摘要:\n{json.dumps(evidence, ensure_ascii=False, default=str)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fallback_summary(topic: TopicSession, conversation: str, raw: str) -> dict[str, str]:
|
def _fallback_summary(topic: TopicSession, user_content: str, raw: str) -> dict[str, str]:
|
||||||
source = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else conversation
|
source = user_content or raw.strip()
|
||||||
user_lines = [line for line in source.splitlines() if line.startswith("用户:")]
|
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
|
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 {
|
return {
|
||||||
"summary": _limit(f"用户围绕“{first_question}”进行了提问,本主题的主要回答和沉淀为:{last_answer}", 1800),
|
"summary": _limit(f"本次主要谈到:{last_user_text}", 1800),
|
||||||
"mainEvents": _limit(first_question, 800),
|
"currentFocus": _limit(first_question, 800),
|
||||||
"emotions": _join_markers(source, ("害怕", "委屈", "愤怒", "抗拒", "焦虑", "担心", "难受")),
|
"nextObservation": "可以继续留意这个问题在当下实际发生时,自己最直接的体验是什么。",
|
||||||
"bodyFeelings": _join_markers(source, ("身体", "胸", "心口", "头", "胃", "麻", "胀", "紧", "堵")),
|
|
||||||
"beliefs": _join_markers(source, ("必须", "应该", "不能", "总是", "一定", "不配")),
|
|
||||||
"recommendedHomework": _join_markers(source, ("静心", "觉察", "功课", "练习", "释放", "内省")),
|
|
||||||
"insights": _limit(last_answer, 1000),
|
|
||||||
"nextObservation": "下次继续从当下最明显的身体感受、情绪和自动念头开始观察。",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fallback_profile(profile: UserGrowthProfile, summary: TopicSummary, raw: str) -> dict[str, str]:
|
def _fallback_recent_review(summaries: list[TopicSummary], raw: str) -> dict[str, str]:
|
||||||
previous = profile.profile_text.strip()
|
usable = [item for item in summaries if item.summary.strip()]
|
||||||
addition = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else summary.summary
|
review_parts = [item.summary.strip() for item in usable[:3]]
|
||||||
text = "\n\n".join(part for part in [previous, f"最近主题:{addition}"] if part)
|
focus_parts = [item.main_events.strip() for item in usable if item.main_events and item.main_events.strip()]
|
||||||
return {
|
return {
|
||||||
"profileText": _limit(text or "暂无成长档案。", 4500),
|
"reviewText": _limit(";".join(review_parts) or "近期沉淀记录较少。", 2600),
|
||||||
"recurringTopics": summary.main_events or "",
|
"currentFocus": _limit(";".join(dict.fromkeys(focus_parts[:3])), 900),
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -423,7 +452,7 @@ def _parse_summary_json(raw: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
# 部分模型会把推理过程和最终 JSON 一起放进 answer,甚至在推理中先给出一份
|
# 部分模型会把推理过程和最终 JSON 一起放进 answer,甚至在推理中先给出一份
|
||||||
# 草稿 JSON。先移除推理块,再从后向前选取可完整解析的对象,避免把思考过程
|
# 草稿 JSON。先移除推理块,再从后向前选取可完整解析的对象,避免把思考过程
|
||||||
# 或多个 JSON 拼接后写入用户成长档案。
|
# 或多个 JSON 拼接后写入近期实修回顾。
|
||||||
text = re.sub(r"<(?:think|analysis)>[\s\S]*?</(?:think|analysis)>", "", text, flags=re.IGNORECASE).strip()
|
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)
|
fenced = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
|
||||||
candidates = [*fenced, text]
|
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
|
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": "大本营答疑助手"}
|
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:
|
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)
|
return _limit(value.strip(), max_len)
|
||||||
|
|
||||||
|
|
||||||
def _join_markers(text: str, markers: tuple[str, ...]) -> str:
|
def _json_int_list(raw: str | None) -> list[int]:
|
||||||
found = [marker for marker in markers if marker in text]
|
if not raw:
|
||||||
return ";".join(found)
|
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:
|
def _limit(text: str, max_len: int) -> str:
|
||||||
@@ -477,6 +534,7 @@ def _now() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def _reset_summary_job(summary: TopicSummary) -> None:
|
def _reset_summary_job(summary: TopicSummary) -> None:
|
||||||
|
summary.schema_version = RECENT_REVIEW_SCHEMA_VERSION
|
||||||
summary.summary = ""
|
summary.summary = ""
|
||||||
summary.main_events = None
|
summary.main_events = None
|
||||||
summary.emotions = None
|
summary.emotions = None
|
||||||
|
|||||||
@@ -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:
|
def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary) -> str:
|
||||||
data = topic_summary_dict(summary)
|
data = topic_summary_dict(summary) or {}
|
||||||
return (
|
return (
|
||||||
"【给老师的求助卡】\n"
|
"【给老师的求助卡】\n"
|
||||||
"说明:这是我根据本次 AI 对话整理出的求助信息,请老师帮我确认方向。"
|
"说明:这是我根据本次 AI 对话整理出的求助信息,请老师帮我确认方向。"
|
||||||
@@ -99,14 +99,11 @@ def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary)
|
|||||||
f"{topic.core_question or '(请补充)'}\n\n"
|
f"{topic.core_question or '(请补充)'}\n\n"
|
||||||
"2. AI 已经帮我梳理出的重点\n"
|
"2. AI 已经帮我梳理出的重点\n"
|
||||||
f"{data.get('summary') or '(暂无摘要)'}\n\n"
|
f"{data.get('summary') or '(暂无摘要)'}\n\n"
|
||||||
"3. 我现在最明显的情绪 / 身体感受\n"
|
"3. 我这次主要关注的内容\n"
|
||||||
f"情绪:{data.get('emotions') or '(请补充)'}\n"
|
f"{data.get('currentFocus') or topic.core_question or '(请补充)'}\n\n"
|
||||||
f"身体感受:{data.get('bodyFeelings') or '(请补充)'}\n\n"
|
"4. 我还想继续留意的地方\n"
|
||||||
"4. 我已经尝试过或被建议的功课\n"
|
f"{data.get('nextObservation') or '(请补充)'}\n\n"
|
||||||
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
|
"5. 我想请老师确认的问题\n"
|
||||||
"5. 我仍然卡住的地方\n"
|
|
||||||
f"{data.get('beliefs') or data.get('nextObservation') or '(请补充)'}\n\n"
|
|
||||||
"6. 我想请老师确认的问题\n"
|
|
||||||
"(请把最想确认的一两个问题写在这里)\n\n"
|
"(请把最想确认的一两个问题写在这里)\n\n"
|
||||||
"备注:这张卡片不会自动发送给老师,也不代表已经转人工处理。"
|
"备注:这张卡片不会自动发送给老师,也不代表已经转人工处理。"
|
||||||
)
|
)
|
||||||
@@ -120,4 +117,3 @@ def _format_time(value: datetime | None) -> str:
|
|||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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.models.user import User
|
||||||
|
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||||
from app.services.tracked_generation_service import TrackedGenerationService
|
from app.services.tracked_generation_service import TrackedGenerationService
|
||||||
|
|
||||||
ReportType = Literal["weekly", "monthly", "stage"]
|
ReportType = Literal["weekly", "monthly", "stage"]
|
||||||
|
|
||||||
REPORT_TYPE_LABELS = {
|
REPORT_TYPE_LABELS = {
|
||||||
"weekly": "每周实修小结",
|
"weekly": "每周实修小结",
|
||||||
"monthly": "每月成长报告",
|
"monthly": "每月实修回顾",
|
||||||
"stage": "阶段成长总结",
|
"stage": "阶段实修回顾",
|
||||||
}
|
}
|
||||||
|
REPORT_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
class PeriodicReportService:
|
class PeriodicReportService:
|
||||||
@@ -46,7 +48,10 @@ class PeriodicReportService:
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
statuses: Iterable[str] | None = None,
|
statuses: Iterable[str] | None = None,
|
||||||
) -> list[PeriodicReport]:
|
) -> 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:
|
if statuses is not None:
|
||||||
query = query.where(PeriodicReport.status.in_(tuple(statuses)))
|
query = query.where(PeriodicReport.status.in_(tuple(statuses)))
|
||||||
return list(
|
return list(
|
||||||
@@ -104,6 +109,7 @@ class PeriodicReportService:
|
|||||||
if report.status == "running":
|
if report.status == "running":
|
||||||
return report
|
return report
|
||||||
report.title = _report_title(report_type, period_start, period_end)
|
report.title = _report_title(report_type, period_start, period_end)
|
||||||
|
report.schema_version = REPORT_SCHEMA_VERSION
|
||||||
report.status = "pending"
|
report.status = "pending"
|
||||||
report.error_message = None
|
report.error_message = None
|
||||||
report.generated_by = generated_by
|
report.generated_by = generated_by
|
||||||
@@ -159,9 +165,9 @@ class PeriodicReportService:
|
|||||||
raise ValueError("不支持的报告类型")
|
raise ValueError("不支持的报告类型")
|
||||||
period_start = report.period_start
|
period_start = report.period_start
|
||||||
period_end = report.period_end
|
period_end = report.period_end
|
||||||
|
report.schema_version = REPORT_SCHEMA_VERSION
|
||||||
report.title = _report_title(report_type, period_start, period_end)
|
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)
|
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})
|
topic_ids = sorted({int(item.topic_session_id) for item in summaries})
|
||||||
summary_ids = [int(item.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_topic_ids = json.dumps(topic_ids, ensure_ascii=False)
|
||||||
@@ -176,14 +182,20 @@ class PeriodicReportService:
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
try:
|
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(
|
completion = TrackedGenerationService.generate(
|
||||||
db,
|
db,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
scenario="report",
|
scenario="report",
|
||||||
user_id=user.id,
|
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.model_name = completion.model_name
|
||||||
report.status = "success"
|
report.status = "success"
|
||||||
report.error_message = None
|
report.error_message = None
|
||||||
@@ -199,6 +211,7 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": report.id,
|
"id": report.id,
|
||||||
"userId": report.user_id,
|
"userId": report.user_id,
|
||||||
|
"schemaVersion": report.schema_version,
|
||||||
"reportType": report.report_type,
|
"reportType": report.report_type,
|
||||||
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
|
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
|
||||||
"periodStart": _local_datetime(report.period_start),
|
"periodStart": _local_datetime(report.period_start),
|
||||||
@@ -289,6 +302,7 @@ def _new_report(
|
|||||||
) -> PeriodicReport:
|
) -> PeriodicReport:
|
||||||
return PeriodicReport(
|
return PeriodicReport(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
schema_version=REPORT_SCHEMA_VERSION,
|
||||||
report_type=report_type,
|
report_type=report_type,
|
||||||
period_start=period_start,
|
period_start=period_start,
|
||||||
period_end=period_end,
|
period_end=period_end,
|
||||||
@@ -332,6 +346,7 @@ def _period_summaries(db: Session, *, user_id: int, period_start: datetime, peri
|
|||||||
select(TopicSummary)
|
select(TopicSummary)
|
||||||
.where(
|
.where(
|
||||||
TopicSummary.user_id == user_id,
|
TopicSummary.user_id == user_id,
|
||||||
|
TopicSummary.schema_version == REPORT_SCHEMA_VERSION,
|
||||||
TopicSummary.status == "success",
|
TopicSummary.status == "success",
|
||||||
TopicSummary.generated_at >= period_start,
|
TopicSummary.generated_at >= period_start,
|
||||||
TopicSummary.generated_at < period_end,
|
TopicSummary.generated_at < period_end,
|
||||||
@@ -349,7 +364,6 @@ def _report_prompt(
|
|||||||
period_start: datetime,
|
period_start: datetime,
|
||||||
period_end: datetime,
|
period_end: datetime,
|
||||||
summaries: list[TopicSummary],
|
summaries: list[TopicSummary],
|
||||||
profile: UserGrowthProfile | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
local_start = _local_datetime(period_start)
|
local_start = _local_datetime(period_start)
|
||||||
local_end = _local_datetime(period_end)
|
local_end = _local_datetime(period_end)
|
||||||
@@ -357,11 +371,7 @@ def _report_prompt(
|
|||||||
(
|
(
|
||||||
f"主题摘要 {index}\n"
|
f"主题摘要 {index}\n"
|
||||||
f"摘要:{item.summary}\n"
|
f"摘要:{item.summary}\n"
|
||||||
f"主要事件:{item.main_events or '无'}\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 '无'}"
|
f"下一步观察:{item.next_observation or '无'}"
|
||||||
)
|
)
|
||||||
for index, item in enumerate(summaries, start=1)
|
for index, item in enumerate(summaries, start=1)
|
||||||
@@ -369,23 +379,21 @@ def _report_prompt(
|
|||||||
return (
|
return (
|
||||||
f"请为学员“{user.name or user.nickname or user.phone}”生成一份{REPORT_TYPE_LABELS[report_type]}。\n"
|
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"
|
f"周期:{local_start:%Y-%m-%d %H:%M} 至 {local_end:%Y-%m-%d %H:%M}。\n\n"
|
||||||
"产品定位:这是大本营千问千答的实修陪伴报告,不写成医疗诊断、心理咨询结论或营销文。\n"
|
"产品定位:这是帮助学员回到当下的近期实修回顾,不是成长成绩单、医疗诊断或心理咨询结论。\n"
|
||||||
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案。\n"
|
"只依据本周期主题摘要,使用 Markdown 输出:本周期谈到的主题、近期关注、可以继续留意的问题。\n"
|
||||||
"请使用 Markdown 输出,结构包含:本周期主要议题、做过或被建议的功课、反复出现的情绪/身体模式、已有变化、下一步观察方向、可以带给老师确认的问题。\n"
|
"不要统计或归纳情绪、身体、关系模式,不列做过或有效的功课,不评价进步、变化、阶段和结果,"
|
||||||
|
"不设置下一阶段目标。使用‘最近谈到/可以继续留意’,避免‘你总是/你已经形成’。\n"
|
||||||
"如果证据不足,请明确说“本周期沉淀记录较少”,不要编造。\n\n"
|
"如果证据不足,请明确说“本周期沉淀记录较少”,不要编造。\n\n"
|
||||||
f"长期成长档案:\n{profile.profile_text if profile else '暂无'}\n\n"
|
|
||||||
f"本周期主题摘要:\n{summary_text}"
|
f"本周期主题摘要:\n{summary_text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fallback_report(summaries: list[TopicSummary]) -> str:
|
def _fallback_report(summaries: list[TopicSummary]) -> str:
|
||||||
lines = ["## 本周期实修小结", "", "模型生成失败时,系统先根据已沉淀主题摘要生成基础版本:"]
|
lines = ["## 本周期实修回顾", "", "以下内容根据本周期已沉淀的主题整理:"]
|
||||||
for item in summaries[:20]:
|
for item in summaries[:20]:
|
||||||
lines.extend(["", f"- {item.summary}"])
|
lines.extend(["", f"- {item.summary}"])
|
||||||
if item.recommended_homework:
|
|
||||||
lines.append(f" - 建议功课:{item.recommended_homework}")
|
|
||||||
if item.next_observation:
|
if item.next_observation:
|
||||||
lines.append(f" - 下一步观察:{item.next_observation}")
|
lines.append(f" - 可以继续留意:{item.next_observation}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,26 +86,21 @@ def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSess
|
|||||||
|
|
||||||
|
|
||||||
def _render_share_draft(*, topic: TopicSession, summary: TopicSummary) -> str:
|
def _render_share_draft(*, topic: TopicSession, summary: TopicSummary) -> str:
|
||||||
data = topic_summary_dict(summary)
|
data = topic_summary_dict(summary) or {}
|
||||||
return (
|
return (
|
||||||
"【实修分享稿草稿】\n"
|
"【实修分享稿草稿】\n"
|
||||||
"说明:这是根据我本次对话整理出的分享草稿,系统不会自动发送到任何群,"
|
"说明:这是根据我本次对话整理出的分享草稿,系统不会自动发送到任何群,"
|
||||||
"我会按真实情况删改后再决定是否发到班级群。\n\n"
|
"我会按真实情况删改后再决定是否发到班级群。\n\n"
|
||||||
"大家好,我想分享一下这次实修里看到的一点东西。\n\n"
|
"大家好,我想分享一下这次实修中正在关注的内容。\n\n"
|
||||||
"1. 我这次观察到的议题\n"
|
"1. 我这次谈到的主题\n"
|
||||||
f"{topic.core_question or topic.title}\n\n"
|
f"{topic.core_question or topic.title}\n\n"
|
||||||
"2. 我看见了什么\n"
|
"2. 这次对话的简要回顾\n"
|
||||||
f"{data.get('summary') or '(请用自己的话补充真实看见)'}\n\n"
|
f"{data.get('summary') or '(请用自己的话补充)'}\n\n"
|
||||||
"3. 我感受到的情绪和身体反应\n"
|
"3. 我近期正在关注什么\n"
|
||||||
f"情绪:{data.get('emotions') or '(请补充)'}\n"
|
f"{data.get('currentFocus') or '(请补充)'}\n\n"
|
||||||
f"身体:{data.get('bodyFeelings') or '(请补充)'}\n\n"
|
"4. 我还想继续留意的方向\n"
|
||||||
"4. 我做了什么功课 / 准备继续做什么\n"
|
|
||||||
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
|
|
||||||
"5. 当下的一点变化\n"
|
|
||||||
f"{data.get('insights') or '(请补充真实变化,不需要夸大)'}\n\n"
|
|
||||||
"6. 我还在继续观察的方向\n"
|
|
||||||
f"{data.get('nextObservation') or '(请补充)'}\n\n"
|
f"{data.get('nextObservation') or '(请补充)'}\n\n"
|
||||||
"备注:这只是我的阶段性观察,不代表已经彻底解决,也不是建议别人照搬。"
|
"备注:这只是我当下的一次回顾,不代表结论,也不是建议别人照搬。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -127,9 +127,7 @@ class TopicSessionService:
|
|||||||
f"核心问题:{_limit(topic.core_question, 1200)}",
|
f"核心问题:{_limit(topic.core_question, 1200)}",
|
||||||
f"主题状态:{topic.status}",
|
f"主题状态:{topic.status}",
|
||||||
]
|
]
|
||||||
if topic.recommended_homework:
|
if summary is not None and summary.schema_version >= 2 and summary.status in {"success", "fallback"}:
|
||||||
lines.append(f"已记录的建议功课:{_limit(topic.recommended_homework, 1000)}")
|
|
||||||
if summary is not None and summary.status in {"success", "fallback"}:
|
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"\n[所选主题摘要]",
|
"\n[所选主题摘要]",
|
||||||
@@ -137,12 +135,8 @@ class TopicSessionService:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
details = [
|
details = [
|
||||||
("主要事件", summary.main_events),
|
("本次关注", summary.main_events),
|
||||||
("情绪", summary.emotions),
|
("可以继续留意", summary.next_observation),
|
||||||
("身体感受", summary.body_feelings),
|
|
||||||
("信念", summary.beliefs),
|
|
||||||
("建议功课", summary.recommended_homework),
|
|
||||||
("下一步观察", summary.next_observation),
|
|
||||||
]
|
]
|
||||||
lines.extend(f"{label}:{_limit(value, 800)}" for label, value in details if value)
|
lines.extend(f"{label}:{_limit(value, 800)}" for label, value in details if value)
|
||||||
return "\n".join(lines), True
|
return "\n".join(lines), True
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ def test_agent_debug_can_simulate_user_growth_profile_context():
|
|||||||
status=1,
|
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()
|
db.commit()
|
||||||
|
|
||||||
result = asyncio.run(
|
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)
|
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]["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
|
assert "[当前产品权益]" in rendered
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +97,13 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
|
|||||||
allow_share_draft=0,
|
allow_share_draft=0,
|
||||||
status=1,
|
status=1,
|
||||||
),
|
),
|
||||||
UserGrowthProfile(user_id=1, profile_text="用户在表达时容易身体紧绷。"),
|
UserGrowthProfile(
|
||||||
|
user_id=1,
|
||||||
|
schema_version=2,
|
||||||
|
recent_review="最近谈到面对领导时不敢表达。",
|
||||||
|
current_focus="面对领导时的当下表达",
|
||||||
|
profile_text="",
|
||||||
|
),
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
id=100,
|
id=100,
|
||||||
session_id=20,
|
session_id=20,
|
||||||
@@ -109,6 +124,7 @@ def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
|
|||||||
topic_session_id=30,
|
topic_session_id=30,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
summary="学员正在观察面对权威时的紧绷。",
|
summary="学员正在观察面对权威时的紧绷。",
|
||||||
|
main_events="面对领导时如何表达",
|
||||||
emotions="害怕",
|
emotions="害怕",
|
||||||
body_feelings="心口紧",
|
body_feelings="心口紧",
|
||||||
status="success",
|
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)
|
rendered = "\n".join(item["content"] for item in result.messages)
|
||||||
assert "[当前对话主题]" in rendered
|
assert "[当前对话主题]" in rendered
|
||||||
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
|
assert "我想继续刚才的主题" in rendered
|
||||||
assert "老师求助卡:可由学员主动生成" in rendered
|
assert "老师求助卡:可由学员主动生成" in rendered
|
||||||
|
|||||||
@@ -72,7 +72,15 @@ def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_ena
|
|||||||
assert completed.status == "success"
|
assert completed.status == "success"
|
||||||
assert completed.summary
|
assert completed.summary
|
||||||
profile = db.query(UserGrowthProfile).filter_by(user_id=1).one()
|
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(UserGrowthProfile).filter_by(user_id=1).count() == 1
|
||||||
assert db.query(GrowthProfileRevision).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 "")
|
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:
|
with _db() as db:
|
||||||
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
|
||||||
db.add(user)
|
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()
|
db.commit()
|
||||||
|
|
||||||
growth_context = GrowthProfileService.prompt_context(db, user)
|
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)
|
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
|
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():
|
def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
|
||||||
raw = """
|
raw = """
|
||||||
<think>
|
<think>
|
||||||
先分析一下,并给出一个未完成草稿:
|
先分析一下,并给出一个未完成草稿:
|
||||||
```json
|
```json
|
||||||
{"profileText": "不应采用的草稿"}
|
{"reviewText": "不应采用的草稿"}
|
||||||
```
|
```
|
||||||
</think>
|
</think>
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"profileText": "只保留最终成长档案",
|
"reviewText": "只保留最终近期回顾",
|
||||||
"commonEmotions": ["紧张"],
|
"currentFocus": ["当下正在谈到的问题"]
|
||||||
"recentProgress": ["开始观察身体感受"]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -208,5 +247,5 @@ def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
|
|||||||
parsed = _parse_summary_json(raw)
|
parsed = _parse_summary_json(raw)
|
||||||
|
|
||||||
assert parsed is not None
|
assert parsed is not None
|
||||||
assert parsed["profileText"] == "只保留最终成长档案"
|
assert parsed["reviewText"] == "只保留最终近期回顾"
|
||||||
assert "think" not in str(parsed).lower()
|
assert "think" not in str(parsed).lower()
|
||||||
|
|||||||
@@ -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 "不会自动发送给老师" 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.get(TopicSession, 1).help_card_generated == 1
|
||||||
assert db.query(TeacherHelpCard).count() == 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 == 1
|
||||||
assert copied.copied_at is not None
|
assert copied.copied_at is not None
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
|
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
|
||||||
from app.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
|
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
|
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)
|
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.status == "success"
|
||||||
|
assert report.schema_version == 2
|
||||||
assert report.content
|
assert report.content
|
||||||
|
assert "长期成长档案" not in report.content
|
||||||
|
assert "推荐功课:" not in report.content
|
||||||
|
assert "情绪:害怕" not in report.content
|
||||||
data = periodic_report_dict(report)
|
data = periodic_report_dict(report)
|
||||||
assert data["sourceSummaryIds"] == [1]
|
assert data["sourceSummaryIds"] == [1]
|
||||||
assert data["sourceTopicIds"] == [1]
|
assert data["sourceTopicIds"] == [1]
|
||||||
@@ -71,6 +76,68 @@ def test_generate_empty_periodic_report_when_no_summaries():
|
|||||||
assert periodic_report_dict(report)["sourceSummaryIds"] == []
|
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():
|
def test_async_report_job_is_durable_and_idempotent():
|
||||||
with _db() as db:
|
with _db() as db:
|
||||||
now = _now()
|
now = _now()
|
||||||
|
|||||||
@@ -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 "不代表已经彻底解决" 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 "我看见自己不敢表达" in draft.content
|
||||||
assert db.get(TopicSession, 1).share_draft_generated == 1
|
assert db.get(TopicSession, 1).share_draft_generated == 1
|
||||||
assert db.query(ShareDraft).count() == 1
|
assert db.query(ShareDraft).count() == 1
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
|||||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||||
import SessionQuota from "./components/SessionQuota.vue";
|
import SessionQuota from "./components/SessionQuota.vue";
|
||||||
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
|
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 user = ref<UserProfile | null>(null);
|
||||||
const sessions = ref<ChatSession[]>([]);
|
const sessions = ref<ChatSession[]>([]);
|
||||||
@@ -25,7 +25,7 @@ const logoutDialogOpen = ref(false);
|
|||||||
const profileDialogOpen = ref(false);
|
const profileDialogOpen = ref(false);
|
||||||
const helpCardDialogOpen = ref(false);
|
const helpCardDialogOpen = ref(false);
|
||||||
const shareDraftDialogOpen = 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 helpCard = ref<TeacherHelpCard | null>(null);
|
||||||
const helpCardContent = ref("");
|
const helpCardContent = ref("");
|
||||||
const shareDraft = ref<ShareDraft | null>(null);
|
const shareDraft = ref<ShareDraft | null>(null);
|
||||||
@@ -218,7 +218,7 @@ async function finishCurrentTopic() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.finishTopic(activeSessionId.value);
|
const result = await api.finishTopic(activeSessionId.value);
|
||||||
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
|
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
|
||||||
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀");
|
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了近期实修回顾" : "本主题已沉淀");
|
||||||
} else {
|
} else {
|
||||||
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
|
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
|
||||||
void watchTopicSettlement(result.summary.id);
|
void watchTopicSettlement(result.summary.id);
|
||||||
@@ -240,7 +240,7 @@ async function watchTopicSettlement(summaryId: number) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.topicSettlement(summaryId);
|
const result = await api.topicSettlement(summaryId);
|
||||||
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
|
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
|
||||||
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了成长档案" : "实修记录已沉淀完成");
|
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了近期实修回顾" : "实修记录已沉淀完成");
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -330,22 +330,22 @@ async function copyShareDraft() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openGrowthProfile() {
|
async function openPracticeReview() {
|
||||||
profileDialogOpen.value = true;
|
profileDialogOpen.value = true;
|
||||||
profileLoading.value = true;
|
profileLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const [profileResult, helpCards, shareDrafts, reports] = await Promise.all([
|
const [profileResult, helpCards, shareDrafts, reports] = await Promise.all([
|
||||||
api.growthProfile(),
|
api.practiceReview(),
|
||||||
api.helpCards(10),
|
api.helpCards(10),
|
||||||
api.shareDrafts(10),
|
api.shareDrafts(10),
|
||||||
api.periodicReports(10),
|
api.periodicReports(10),
|
||||||
]);
|
]);
|
||||||
growthProfile.value = profileResult;
|
practiceReview.value = profileResult;
|
||||||
helpCardHistory.value = helpCards;
|
helpCardHistory.value = helpCards;
|
||||||
shareDraftHistory.value = shareDrafts;
|
shareDraftHistory.value = shareDrafts;
|
||||||
reportHistory.value = reports;
|
reportHistory.value = reports;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, "成长档案加载失败");
|
handleError(error, "近期实修回顾加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
profileLoading.value = false;
|
profileLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -484,7 +484,7 @@ async function copyText(text: string) {
|
|||||||
:generating-help-card="generatingHelpCard"
|
:generating-help-card="generatingHelpCard"
|
||||||
:generating-share-draft="generatingShareDraft"
|
:generating-share-draft="generatingShareDraft"
|
||||||
@finish-topic="finishCurrentTopic"
|
@finish-topic="finishCurrentTopic"
|
||||||
@open-profile="openGrowthProfile"
|
@open-profile="openPracticeReview"
|
||||||
@generate-help-card="generateHelpCard"
|
@generate-help-card="generateHelpCard"
|
||||||
@generate-share-draft="generateShareDraft"
|
@generate-share-draft="generateShareDraft"
|
||||||
/>
|
/>
|
||||||
@@ -512,23 +512,19 @@ async function copyText(text: string) {
|
|||||||
|
|
||||||
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
|
<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">
|
<section class="growth-profile-dialog" :aria-busy="profileLoading">
|
||||||
<p v-if="profileLoading" class="profile-empty">正在加载成长档案...</p>
|
<p v-if="profileLoading" class="profile-empty">正在加载近期回顾...</p>
|
||||||
<template v-else-if="growthProfile?.profile">
|
<template v-else-if="practiceReview?.profile">
|
||||||
<pre>{{ growthProfile.profile.profileText }}</pre>
|
<pre>{{ practiceReview.profile.reviewText }}</pre>
|
||||||
<div class="growth-profile-fields">
|
<div v-if="practiceReview.profile.currentFocus" class="growth-profile-fields">
|
||||||
<p v-if="growthProfile.profile.recurringTopics"><strong>反复议题</strong>{{ growthProfile.profile.recurringTopics }}</p>
|
<p><strong>近期关注</strong>{{ practiceReview.profile.currentFocus }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="profile-empty">还没有成长档案。你可以在完成一次主题对话后点击“沉淀本主题”。</p>
|
<p v-else class="profile-empty">还没有近期回顾。你可以在完成一次主题对话后点击“沉淀本主题”。</p>
|
||||||
<div v-if="growthProfile?.recentSummaries.length" class="recent-topic-summaries">
|
<div v-if="practiceReview?.recentSummaries.length" class="recent-topic-summaries">
|
||||||
<h3>最近主题沉淀</h3>
|
<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>
|
<time>{{ item.generatedAt }} · {{ settlementStatusLabel(item.status) }}</time>
|
||||||
<p v-if="item.status === 'success' || item.status === 'fallback'">{{ item.summary }}</p>
|
<p v-if="item.status === 'success' || item.status === 'fallback'">{{ item.summary }}</p>
|
||||||
<p v-else-if="item.status === 'failed'">沉淀暂时失败,对话内容仍已保留。</p>
|
<p v-else-if="item.status === 'failed'">沉淀暂时失败,对话内容仍已保留。</p>
|
||||||
@@ -536,7 +532,7 @@ async function copyText(text: string) {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="reportHistory.length" class="recent-topic-summaries periodic-report-list">
|
<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}`">
|
<article v-for="item in reportHistory" :key="`report-${item.id}`">
|
||||||
<time>{{ item.reportTypeLabel }} · {{ item.periodStart }} 至 {{ item.periodEnd }} · {{ item.generatedAt }}</time>
|
<time>{{ item.reportTypeLabel }} · {{ item.periodStart }} 至 {{ item.periodEnd }} · {{ item.generatedAt }}</time>
|
||||||
<p class="report-title">{{ item.title }}</p>
|
<p class="report-title">{{ item.title }}</p>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const guidanceText = computed(() => {
|
|||||||
class="quota-link"
|
class="quota-link"
|
||||||
@click="$emit('openProfile')"
|
@click="$emit('openProfile')"
|
||||||
>
|
>
|
||||||
我的档案
|
近期回顾
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
||||||
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
FinishTopicResult,
|
FinishTopicResult,
|
||||||
GrowthProfileResult,
|
PracticeReviewResult,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
PeriodicReport,
|
PeriodicReport,
|
||||||
ShareDraft,
|
ShareDraft,
|
||||||
@@ -96,7 +96,7 @@ export const api = {
|
|||||||
generateShareDraft: (sessionId: number) => request<ShareDraft>(`/chat/session/${sessionId}/share-draft`, { method: "POST", body: JSON.stringify({}) }),
|
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({}) }),
|
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}`),
|
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}`),
|
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
|
||||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ export interface UserEntitlementSummary {
|
|||||||
export interface TopicSummary {
|
export interface TopicSummary {
|
||||||
id: number;
|
id: number;
|
||||||
topicSessionId: number;
|
topicSessionId: number;
|
||||||
|
schemaVersion: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
recommendedHomework?: string | null;
|
currentFocus?: string | null;
|
||||||
nextObservation?: string | null;
|
nextObservation?: string | null;
|
||||||
status: "pending" | "running" | "success" | "fallback" | "failed" | string;
|
status: "pending" | "running" | "success" | "fallback" | "failed" | string;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
@@ -48,27 +49,24 @@ export interface TopicSummary {
|
|||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrowthProfile {
|
export interface PracticeReview {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
profileText: string;
|
schemaVersion: number;
|
||||||
recurringTopics?: string | null;
|
reviewText: string;
|
||||||
commonEmotions?: string | null;
|
currentFocus?: string | null;
|
||||||
bodyPatterns?: string | null;
|
sourceSummaryIds: number[];
|
||||||
relationPatterns?: string | null;
|
|
||||||
homeworkDone?: string | null;
|
|
||||||
effectiveHomework?: string | null;
|
|
||||||
recentProgress?: string | null;
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrowthProfileResult {
|
export interface PracticeReviewResult {
|
||||||
profile: GrowthProfile | null;
|
profile: PracticeReview | null;
|
||||||
recentSummaries: TopicSummary[];
|
recentSummaries: TopicSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeriodicReport {
|
export interface PeriodicReport {
|
||||||
id: number;
|
id: number;
|
||||||
|
schemaVersion: number;
|
||||||
reportType: "weekly" | "monthly" | "stage" | string;
|
reportType: "weekly" | "monthly" | "stage" | string;
|
||||||
reportTypeLabel: string;
|
reportTypeLabel: string;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
@@ -82,7 +80,7 @@ export interface PeriodicReport {
|
|||||||
export interface FinishTopicResult {
|
export interface FinishTopicResult {
|
||||||
topic: Record<string, unknown>;
|
topic: Record<string, unknown>;
|
||||||
summary: TopicSummary & Record<string, unknown>;
|
summary: TopicSummary & Record<string, unknown>;
|
||||||
profile: GrowthProfile | null;
|
profile: PracticeReview | null;
|
||||||
growthProfileEnabled: boolean;
|
growthProfileEnabled: boolean;
|
||||||
settlementStatus: string;
|
settlementStatus: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,12 +165,12 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
|
|||||||
|
|
||||||
## 主题沉淀后台任务
|
## 主题沉淀后台任务
|
||||||
|
|
||||||
用户点击“沉淀本主题”后,接口只结束当前主题并创建持久化任务,不再等待模型生成摘要和成长档案。用户可以立即继续聊天、切换会话或关闭页面。
|
用户点击“沉淀本主题”后,接口只结束当前主题并创建持久化任务,不再等待模型生成主题摘要和近期实修回顾。用户可以立即继续聊天、切换会话或关闭页面。
|
||||||
|
|
||||||
任务状态保存在 `sys_topic_summary`:
|
任务状态保存在 `sys_topic_summary`:
|
||||||
|
|
||||||
- `pending`:等待后台执行;
|
- `pending`:等待后台执行;
|
||||||
- `running`:正在生成摘要和成长档案;
|
- `running`:正在生成主题摘要和近期实修回顾;
|
||||||
- `success` / `fallback`:沉淀完成;
|
- `success` / `fallback`:沉淀完成;
|
||||||
- `failed`:达到最大重试次数后仍失败,可在后台用户详情中手动重试。
|
- `failed`:达到最大重试次数后仍失败,可在后台用户详情中手动重试。
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
|
|||||||
- 可用模型:允许后台任务选择,可同时启用多个;
|
- 可用模型:允许后台任务选择,可同时启用多个;
|
||||||
- 默认主模型:只能有一个,承担未分流的正式聊天、追问改写和检索重排。
|
- 默认主模型:只能有一个,承担未分流的正式聊天、追问改写和检索重排。
|
||||||
|
|
||||||
周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
|
周期实修回顾会选择“周期报告”能力已开启的可用模型,主题摘要和近期实修回顾会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
|
||||||
|
|
||||||
正式问答的分流规则在“系统配置 / AI 问答 / 正式问答模型分流规则”中配置:
|
正式问答的分流规则在“系统配置 / AI 问答 / 正式问答模型分流规则”中配置:
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
- [ ] AI 请求日志有记录。
|
- [ ] AI 请求日志有记录。
|
||||||
- [ ] 操作日志有记录。
|
- [ ] 操作日志有记录。
|
||||||
- [ ] 后台 Agent 预览能够选择学员和主题,并在追踪中看到上下文与模型路由。
|
- [ ] 后台 Agent 预览能够选择学员和主题,并在追踪中看到上下文与模型路由。
|
||||||
- [ ] 求助卡、分享稿、成长档案和周期报告能够按权益生成与查看。
|
- [ ] 求助卡、分享稿、近期实修回顾和周期实修回顾能够按权益生成与查看。
|
||||||
- [ ] 访问日志为单行 JSON,且可用响应头中的 `X-Request-ID` 定位同一次请求。
|
- [ ] 访问日志为单行 JSON,且可用响应头中的 `X-Request-ID` 定位同一次请求。
|
||||||
- [ ] 已确认备份任务生成 `.sql.gz` 和对应 `.sha256` 文件。
|
- [ ] 已确认备份任务生成 `.sql.gz` 和对应 `.sha256` 文件。
|
||||||
- [ ] 已完成至少一次隔离数据库恢复演练。
|
- [ ] 已完成至少一次隔离数据库恢复演练。
|
||||||
|
|||||||
@@ -47,18 +47,18 @@
|
|||||||
↓
|
↓
|
||||||
主题会话机制
|
主题会话机制
|
||||||
↓
|
↓
|
||||||
主题摘要 / 成长档案
|
主题摘要 / 近期实修回顾
|
||||||
↓
|
↓
|
||||||
Agent 调用链接入成长档案与权益
|
Agent 调用链接入近期实修回顾与权益
|
||||||
↓
|
↓
|
||||||
用户端实修能力:求助卡、分享稿、我的档案
|
用户端实修能力:求助卡、分享稿、近期实修回顾
|
||||||
↓
|
↓
|
||||||
后台运营能力:用户详情、成本、问题洞察二期
|
后台运营能力:用户详情、成本、问题洞察二期
|
||||||
↓
|
↓
|
||||||
报告体系:周报、月报、五个月总结
|
报告体系:周报、月报、五个月总结
|
||||||
```
|
```
|
||||||
|
|
||||||
不要先做周报、分享稿或漂亮页面。它们依赖主题会话和成长档案,提前做会变成“无数据可用的壳”。
|
不要先做周报、分享稿或漂亮页面。它们依赖主题会话和近期实修回顾,提前做会变成“无数据可用的壳”。
|
||||||
|
|
||||||
## 4. TODO 清单
|
## 4. TODO 清单
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Agent 调用链接入成长档案与权益
|
|||||||
- 类型:基础版 / 深度陪伴版 / 高频加购包;
|
- 类型:基础版 / 深度陪伴版 / 高频加购包;
|
||||||
- 有效天数;
|
- 有效天数;
|
||||||
- 每月主题会话额度;
|
- 每月主题会话额度;
|
||||||
- 是否开启成长档案;
|
- 是否开启近期实修回顾;
|
||||||
- 是否开启周报/月报;
|
- 是否开启周报/月报;
|
||||||
- 是否允许生成老师求助卡;
|
- 是否允许生成老师求助卡;
|
||||||
- 是否允许生成班级分享稿;
|
- 是否允许生成班级分享稿;
|
||||||
@@ -216,65 +216,49 @@ Agent 调用链接入成长档案与权益
|
|||||||
- 一个主题只扣一次主题额度;
|
- 一个主题只扣一次主题额度;
|
||||||
- 管理后台能看到主题会话;
|
- 管理后台能看到主题会话;
|
||||||
- 主题能关联原始聊天消息;
|
- 主题能关联原始聊天消息;
|
||||||
- 主题结束后可用于成长档案沉淀。
|
- 主题结束后可用于近期实修回顾沉淀。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 03. 主题摘要与成长档案
|
### 03. 主题摘要与近期实修回顾
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
- 改动大小:L
|
- 改动大小:L
|
||||||
- 影响范围:数据库、Agent 上下文、模型调用、用户端“我的档案”、后台用户详情
|
- 影响范围:数据库、Agent 上下文、模型调用、用户端“近期回顾”、后台用户详情
|
||||||
- 依赖:主题会话机制
|
- 依赖:主题会话机制
|
||||||
|
|
||||||
#### 当前现状
|
#### 当前现状
|
||||||
|
|
||||||
当前已有会话摘要和上下文消息数,用于解决当前会话记忆问题。
|
当前已有会话摘要和上下文消息数,用于解决当前会话记忆问题。产品不再建设长期人格化成长画像,避免用户和 Agent 对过程、标签及结果形成执着。
|
||||||
|
|
||||||
但它不是产品层面的“五个月成长档案”,还不能表达:
|
|
||||||
|
|
||||||
- 反复议题;
|
|
||||||
- 做过的功课;
|
|
||||||
- 情绪和身体感受变化;
|
|
||||||
- 用户成长轨迹;
|
|
||||||
- 哪些功课有效。
|
|
||||||
|
|
||||||
#### 目标
|
#### 目标
|
||||||
|
|
||||||
把每个主题会话沉淀成主题摘要,再把多个主题摘要汇总成用户成长档案。
|
把每个主题会话沉淀成简短回顾,再从最近30天、最多10个新版主题生成“近期实修回顾”。只帮助保持连续性和回到当下,不评价成长结果。
|
||||||
|
|
||||||
#### 需要新增的数据
|
#### 需要新增的数据
|
||||||
|
|
||||||
建议新增:
|
建议新增:
|
||||||
|
|
||||||
- `sys_topic_summary`:主题摘要;
|
- `sys_topic_summary`:主题摘要;
|
||||||
- `sys_user_growth_profile`:用户成长档案;
|
- `sys_user_growth_profile`:近期实修回顾(历史画像字段仅兼容保留);
|
||||||
- `sys_growth_profile_revision`:成长档案版本记录。
|
- `sys_growth_profile_revision`:近期回顾版本记录。
|
||||||
|
|
||||||
主题摘要字段建议包含:
|
主题摘要字段建议包含:
|
||||||
|
|
||||||
- 主题 ID;
|
- 主题 ID;
|
||||||
- 用户原始问题摘要;
|
- 用户原始问题摘要;
|
||||||
- 主要事件;
|
- 本次关注;
|
||||||
- 主要情绪;
|
- 可以继续留意的问题;
|
||||||
- 身体感受;
|
|
||||||
- 信念/程序;
|
|
||||||
- 推荐功课;
|
|
||||||
- 用户已经看见的内容;
|
|
||||||
- 下一步建议观察方向;
|
|
||||||
- 生成模型;
|
- 生成模型;
|
||||||
- 生成时间。
|
- 生成时间。
|
||||||
|
|
||||||
成长档案字段建议包含:
|
近期实修回顾字段包含:
|
||||||
|
|
||||||
- 用户 ID;
|
- 用户 ID;
|
||||||
- 反复议题;
|
- 近期回顾;
|
||||||
- 常见情绪;
|
- 近期关注(最多3项);
|
||||||
- 常见身体反应;
|
- 来源主题摘要 ID;
|
||||||
- 常见关系模式;
|
- 数据版本;
|
||||||
- 常做功课;
|
|
||||||
- 有效功课;
|
|
||||||
- 最近进展;
|
|
||||||
- 最近更新时间。
|
- 最近更新时间。
|
||||||
|
|
||||||
#### 需要改的代码模块
|
#### 需要改的代码模块
|
||||||
@@ -282,32 +266,33 @@ Agent 调用链接入成长档案与权益
|
|||||||
- 后端:
|
- 后端:
|
||||||
- 主题结束逻辑;
|
- 主题结束逻辑;
|
||||||
- 摘要生成服务;
|
- 摘要生成服务;
|
||||||
- 成长档案更新服务;
|
- 近期回顾更新服务;
|
||||||
- Agent Prompt 构建逻辑;
|
- Agent Prompt 构建逻辑;
|
||||||
- 模型服务。
|
- 模型服务。
|
||||||
- 用户端:
|
- 用户端:
|
||||||
- 我的实修档案;
|
- 近期实修回顾;
|
||||||
- 主题摘要展示。
|
- 主题摘要展示。
|
||||||
- 管理端:
|
- 管理端:
|
||||||
- 用户详情里的成长档案;
|
- 用户详情里的近期实修回顾;
|
||||||
- 摘要重生成。
|
- 摘要重生成。
|
||||||
|
|
||||||
#### 验收标准
|
#### 验收标准
|
||||||
|
|
||||||
- 一个主题结束后可以生成摘要;
|
- 一个主题结束后可以生成摘要;
|
||||||
- 用户成长档案能跨主题更新;
|
- 近期实修回顾能基于限定时间窗口更新;
|
||||||
- Agent 回答时能引用成长档案摘要;
|
- Agent 只能把近期回顾用于保持连续性,并优先依据本轮表达;
|
||||||
|
- 旧画像、情绪/身体/关系模式、做过或有效功课、近期变化均不再生成、展示或注入;
|
||||||
- 不把全部历史原文塞进模型;
|
- 不把全部历史原文塞进模型;
|
||||||
- 后台能查看档案更新记录。
|
- 后台能查看档案更新记录。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 04. Agent 调用链接入权益、主题与成长档案
|
### 04. Agent 调用链接入权益、主题与近期实修回顾
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
- 改动大小:L
|
- 改动大小:L
|
||||||
- 影响范围:Agent 主链路、调试预览、用户端流式输出、检索日志
|
- 影响范围:Agent 主链路、调试预览、用户端流式输出、检索日志
|
||||||
- 依赖:权益版本体系、主题会话机制、成长档案
|
- 依赖:权益版本体系、主题会话机制、近期实修回顾
|
||||||
|
|
||||||
#### 当前现状
|
#### 当前现状
|
||||||
|
|
||||||
@@ -318,20 +303,20 @@ Agent 已经可以:
|
|||||||
- 使用上下文摘要;
|
- 使用上下文摘要;
|
||||||
- 记录检索日志;
|
- 记录检索日志;
|
||||||
- 支持后台预览。
|
- 支持后台预览。
|
||||||
- 用户端正式聊天已按权益决定是否注入成长档案;
|
- 用户端正式聊天已按权益决定是否注入近期实修回顾;
|
||||||
- 后台 Agent 预览已支持选择“模拟学员”,用于注入该学员权益和成长档案上下文。
|
- 后台 Agent 预览已支持选择“模拟学员”,用于注入该学员权益和近期实修回顾上下文。
|
||||||
|
|
||||||
但后续要让 Agent 区分:
|
但后续要让 Agent 区分:
|
||||||
|
|
||||||
- 当前用户是什么权益;
|
- 当前用户是什么权益;
|
||||||
- 当前问题属于哪个主题;
|
- 当前问题属于哪个主题;
|
||||||
- 是否可使用成长档案;
|
- 是否可使用近期实修回顾;
|
||||||
- 是否需要提醒找人工老师确认;
|
- 是否需要提醒找人工老师确认;
|
||||||
- 是否允许生成求助卡/分享稿。
|
- 是否允许生成求助卡/分享稿。
|
||||||
|
|
||||||
#### 目标
|
#### 目标
|
||||||
|
|
||||||
让 Agent 不只是“回答问题”,而是运行在“权益 + 主题 + 成长档案 + 知识库”的产品上下文中。
|
让 Agent 不只是“回答问题”,而是运行在“权益 + 主题 + 近期实修回顾 + 知识库”的产品上下文中。
|
||||||
|
|
||||||
#### 需要改的代码模块
|
#### 需要改的代码模块
|
||||||
|
|
||||||
@@ -349,16 +334,16 @@ Agent 已经可以:
|
|||||||
|
|
||||||
- 用户端和后台预览链路参数一致;
|
- 用户端和后台预览链路参数一致;
|
||||||
- Agent 能拿到当前主题摘要;
|
- Agent 能拿到当前主题摘要;
|
||||||
- 深度版用户可以使用成长档案;
|
- 深度版用户可以使用近期实修回顾;
|
||||||
- 基础版不会调用长期成长档案;
|
- 基础版不会调用近期实修回顾;
|
||||||
- 生成日志能看出本次用了哪些上下文。
|
- 生成日志能看出本次用了哪些上下文。
|
||||||
|
|
||||||
#### 开发进度
|
#### 开发进度
|
||||||
|
|
||||||
- 2026-07-31:已补齐后台预览的“模拟学员”入口;选择学员后,预览链路会加载该学员权益、主题额度使用量和长期成长档案,并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。
|
- 2026-07-31:已补齐后台预览的“模拟学员”入口;选择学员后,预览链路会加载该学员权益、主题额度使用量和实修回顾上下文,并在检索追踪中记录 `load_debug_user_context`。默认不选择学员时,保持原后台调试行为。
|
||||||
- 2026-07-31:后台预览已支持继续选择具体主题,加载主题摘要、滚动摘要和最近消息;正式聊天与后台预览均会按权益注入成长档案、求助卡和分享稿能力,并在追踪中记录模型路由与上下文来源。
|
- 2026-07-31:后台预览已支持继续选择具体主题,加载主题摘要、滚动摘要和最近消息;正式聊天与后台预览均会按权益注入实修回顾、求助卡和分享稿能力,并在追踪中记录模型路由与上下文来源。
|
||||||
- 2026-07-31:一期全链路已完成隔离生产编排、真实模型问答、流式响应、主题上下文、成长档案、求助卡、分享稿、异步报告和记录审计验收。详细结果见《一期全链路验收记录》。
|
- 2026-07-31:一期全链路已完成隔离生产编排、真实模型问答、流式响应、主题上下文、实修回顾、求助卡、分享稿、异步报告和记录审计验收。详细结果见《一期全链路验收记录》。
|
||||||
- 2026-08-03:主题结束已改为持久化异步沉淀。接口快速结束主题并创建唯一任务,后台独立生成摘要和成长档案;支持自动重试、服务重启恢复、用户端状态查询和后台手动重试,原同步模型调用不再阻塞用户操作。
|
- 2026-08-03:主题结束已改为持久化异步沉淀。接口快速结束主题并创建唯一任务,后台独立生成主题摘要和近期实修回顾;支持自动重试、服务重启恢复、用户端状态查询和后台手动重试,原同步模型调用不再阻塞用户操作。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -657,24 +642,23 @@ AI 日志增加:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 10. 用户端“我的实修档案”
|
### 10. 用户端“近期实修回顾”
|
||||||
|
|
||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
- 改动大小:M
|
- 改动大小: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
|
- 优先级:P1
|
||||||
- 改动大小:M
|
- 改动大小:M
|
||||||
- 影响范围:管理端用户详情、用户接口、统计接口
|
- 影响范围:管理端用户详情、用户接口、统计接口
|
||||||
- 依赖:权益版本体系、主题会话机制、成长档案
|
- 依赖:权益版本体系、主题会话机制、近期实修回顾
|
||||||
|
|
||||||
#### 当前现状
|
#### 当前现状
|
||||||
|
|
||||||
@@ -717,7 +702,7 @@ AI 日志增加:
|
|||||||
- 使用频率;
|
- 使用频率;
|
||||||
- Token/成本;
|
- Token/成本;
|
||||||
- 最近主题;
|
- 最近主题;
|
||||||
- 成长档案摘要;
|
- 近期实修回顾;
|
||||||
- 求助卡生成记录;
|
- 求助卡生成记录;
|
||||||
- 分享稿生成记录;
|
- 分享稿生成记录;
|
||||||
- 是否高频使用。
|
- 是否高频使用。
|
||||||
@@ -731,7 +716,7 @@ AI 日志增加:
|
|||||||
|
|
||||||
#### 开发进度
|
#### 开发进度
|
||||||
|
|
||||||
- 2026-07-31:后台用户管理新增“详情”抽屉,独立接口按需读取用户运营摘要;展示当前权益、权益有效期、本月主题数、近30天活跃天数、会话/消息/Token/成本、最近主题、成长档案摘要、老师求助卡和班级分享稿记录。列表接口仍只返回分页摘要,避免用户管理打开变慢。
|
- 2026-07-31:后台用户管理新增“详情”抽屉,独立接口按需读取用户运营摘要;展示当前权益、权益有效期、本月主题数、近30天活跃天数、会话/消息/Token/成本、最近主题、近期实修回顾、老师求助卡和班级分享稿记录。列表接口仍只返回分页摘要,避免用户管理打开变慢。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -739,12 +724,12 @@ AI 日志增加:
|
|||||||
|
|
||||||
- 优先级:P2
|
- 优先级:P2
|
||||||
- 改动大小:L
|
- 改动大小:L
|
||||||
- 影响范围:异步任务、模型调用、成长档案、用户端、后台
|
- 影响范围:异步任务、模型调用、近期实修回顾、用户端、后台
|
||||||
- 依赖:主题会话机制、成长档案、成本统计
|
- 依赖:主题会话机制、近期实修回顾、成本统计
|
||||||
|
|
||||||
#### 不建议过早做
|
#### 不建议过早做
|
||||||
|
|
||||||
报告依赖主题摘要和成长档案。如果先做,只能从原始聊天拼凑,成本高且质量不稳定。
|
报告依赖主题摘要和近期实修回顾。如果先做,只能从原始聊天拼凑,成本高且质量不稳定。
|
||||||
|
|
||||||
#### 目标
|
#### 目标
|
||||||
|
|
||||||
@@ -753,16 +738,14 @@ AI 日志增加:
|
|||||||
#### 报告类型
|
#### 报告类型
|
||||||
|
|
||||||
- 每周实修小结;
|
- 每周实修小结;
|
||||||
- 每月成长报告;
|
- 每月实修回顾;
|
||||||
- 五个月成长总结。
|
- 阶段实修回顾。
|
||||||
|
|
||||||
#### 报告内容建议
|
#### 报告内容建议
|
||||||
|
|
||||||
- 本周期主要议题;
|
- 本周期主要议题;
|
||||||
- 做过的功课;
|
- 近期关注;
|
||||||
- 重复出现的情绪/模式;
|
- 可以继续留意的问题;
|
||||||
- 有变化的地方;
|
|
||||||
- 仍需继续观察的方向;
|
|
||||||
- 可以带给老师确认的问题。
|
- 可以带给老师确认的问题。
|
||||||
|
|
||||||
#### 需要改的代码模块
|
#### 需要改的代码模块
|
||||||
@@ -792,6 +775,7 @@ AI 日志增加:
|
|||||||
- 2026-07-31:一期已新增周期报告数据表 `sys_periodic_report`,支持周报、月报、阶段总结三种类型;后台用户详情可手动生成/重新生成报告并查看状态、来源主题/摘要、失败原因;用户端“我的实修档案”可查看已生成报告。当前版本先做同步手动生成,后续再接定时/异步任务,聊天链路不会被报告生成阻塞。
|
- 2026-07-31:一期已新增周期报告数据表 `sys_periodic_report`,支持周报、月报、阶段总结三种类型;后台用户详情可手动生成/重新生成报告并查看状态、来源主题/摘要、失败原因;用户端“我的实修档案”可查看已生成报告。当前版本先做同步手动生成,后续再接定时/异步任务,聊天链路不会被报告生成阻塞。
|
||||||
- 2026-07-31:报告生成已改为数据库持久化异步任务。管理员点击生成后接口立即返回,后台 Worker 独立生成;支持等待、执行中、成功、空报告、失败状态,最多自动重试 3 次,服务重启后会恢复超时任务。同一用户、类型和周期由唯一约束保证幂等。
|
- 2026-07-31:报告生成已改为数据库持久化异步任务。管理员点击生成后接口立即返回,后台 Worker 独立生成;支持等待、执行中、成功、空报告、失败状态,最多自动重试 3 次,服务重启后会恢复超时任务。同一用户、类型和周期由唯一约束保证幂等。
|
||||||
- 2026-07-31:已接入自然周和自然月定时生成。默认按 `Asia/Shanghai` 在每周一 02:00 生成上一自然周周报、每月 1 日 03:00 生成上一自然月月报;只为权益开启周期报告且本周期存在成功主题摘要的有效用户入队。用户端只显示成功报告,后台可查看任务状态、执行次数、失败原因并手动重新生成。
|
- 2026-07-31:已接入自然周和自然月定时生成。默认按 `Asia/Shanghai` 在每周一 02:00 生成上一自然周周报、每月 1 日 03:00 生成上一自然月月报;只为权益开启周期报告且本周期存在成功主题摘要的有效用户入队。用户端只显示成功报告,后台可查看任务状态、执行次数、失败原因并手动重新生成。
|
||||||
|
- 2026-08-03:周期报告升级为 V2 实修回顾,只读取新版主题摘要,不再读取长期画像,也不生成情绪/身体/关系模式、功课效果、成长变化、评分或下一阶段目标;旧版报告默认隐藏,重新生成后升级为 V2。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -856,8 +840,8 @@ AI 日志增加:
|
|||||||
|
|
||||||
1. 权益版本体系;
|
1. 权益版本体系;
|
||||||
2. 主题会话机制;
|
2. 主题会话机制;
|
||||||
3. 主题摘要与成长档案;
|
3. 主题摘要与近期实修回顾;
|
||||||
4. Agent 调用链接入权益、主题与成长档案。
|
4. Agent 调用链接入权益、主题与近期实修回顾。
|
||||||
|
|
||||||
这一批改动最大,但越早做越少返工。
|
这一批改动最大,但越早做越少返工。
|
||||||
|
|
||||||
@@ -897,6 +881,6 @@ AI 日志增加:
|
|||||||
|
|
||||||
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
|
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
|
||||||
- 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环;
|
- 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环;
|
||||||
- 一期主要功能链路已验收通过;主题摘要和成长档案已改为持久化异步生成,原同步等待风险已经关闭;
|
- 一期主要功能链路已验收通过;主题摘要和近期实修回顾已改为持久化异步生成,原同步等待风险已经关闭;
|
||||||
- 问题洞察的知识库补充建议、人工合并和拆分暂缓;
|
- 问题洞察的知识库补充建议、人工合并和拆分暂缓;
|
||||||
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。
|
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。
|
||||||
|
|||||||
@@ -4,14 +4,16 @@
|
|||||||
|
|
||||||
- 验收日期:2026-07-31
|
- 验收日期:2026-07-31
|
||||||
- 验收分支:`codex/h5-qa-experience`
|
- 验收分支:`codex/h5-qa-experience`
|
||||||
- 验收范围:权益体系、用户聊天、后台 Agent 预览、主题上下文、成长档案、求助卡、分享稿、周期报告、记录审计、数据库迁移与生产部署链路
|
- 验收范围:权益体系、用户聊天、后台 Agent 预览、主题上下文、近期实修回顾、求助卡、分享稿、周期实修回顾、记录审计、数据库迁移与生产部署链路
|
||||||
- 验收方式:浏览器真实操作、接口调用、真实模型调用、自动化测试、隔离 MySQL 迁移、隔离生产编排及上线冒烟
|
- 验收方式:浏览器真实操作、接口调用、真实模型调用、自动化测试、隔离 MySQL 迁移、隔离生产编排及上线冒烟
|
||||||
|
|
||||||
## 2. 验收结论
|
## 2. 验收结论
|
||||||
|
|
||||||
一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。
|
一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。
|
||||||
|
|
||||||
2026-08-03 已完成主题沉淀异步化:结束主题接口只完成状态变更和任务入队,摘要、成长档案由持久化后台任务生成;重复点击返回同一任务,失败自动重试,服务重启能够恢复,最终失败可由管理员手动重试。原先约 42 秒的同步等待不再阻塞用户请求。
|
2026-08-03 已完成主题沉淀异步化:结束主题接口只完成状态变更和任务入队,主题摘要、近期实修回顾由持久化后台任务生成;重复点击返回同一任务,失败自动重试,服务重启能够恢复,最终失败可由管理员手动重试。原先约 42 秒的同步等待不再阻塞用户请求。
|
||||||
|
|
||||||
|
2026-08-03 已完成实修记录 V2 改造:不再生成、展示或注入常见情绪、身体/关系模式、做过或有效功课、近期变化等长期画像字段;仅保留最近 30 天、最多 10 个新版主题形成的近期回顾、近期关注和可以继续留意的问题。旧版记录保留在数据库中用于兼容和回滚,但不会进入用户端或 Agent 上下文。
|
||||||
|
|
||||||
## 3. 功能验收结果
|
## 3. 功能验收结果
|
||||||
|
|
||||||
@@ -22,27 +24,27 @@
|
|||||||
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
|
| 用户登录 | 通过 | 通过真实页面完成验证码登录并进入聊天页 |
|
||||||
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
|
| 用户端流式问答 | 通过 | 显示“思考中”,随后流式展示 Markdown 回答;发送后输入框立即清空 |
|
||||||
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
|
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
|
||||||
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和成长档案 |
|
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和近期实修回顾 |
|
||||||
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
|
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
|
||||||
| 主题沉淀 | 通过 | 结束主题快速入队;摘要与成长档案后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
|
| 主题沉淀 | 通过 | 结束主题快速入队;主题摘要与近期实修回顾后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
|
||||||
| 求助卡 | 通过 | 可生成非空内容并记录复制状态 |
|
| 求助卡 | 通过 | 可生成非空内容并记录复制状态 |
|
||||||
| 分享稿 | 通过 | 可生成非空内容并记录复制状态 |
|
| 分享稿 | 通过 | 可生成非空内容并记录复制状态 |
|
||||||
| 周期报告 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存报告 |
|
| 周期实修回顾 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存回顾;模型思考内容会在入库前剔除 |
|
||||||
| 用户成长档案 | 通过 | 用户端可读取档案、主题摘要和周期报告 |
|
| 近期实修回顾 | 通过 | 用户端只读取新版近期回顾、主题摘要和周期实修回顾,旧版画像不返回 |
|
||||||
| 后台用户详情 | 通过 | 可查看成长档案、求助卡、分享稿和周期报告 |
|
| 后台用户详情 | 通过 | 可查看近期实修回顾、求助卡、分享稿和周期实修回顾 |
|
||||||
| 记录审计 | 通过 | 聊天列表与按需加载详情均正常 |
|
| 记录审计 | 通过 | 聊天列表与按需加载详情均正常 |
|
||||||
|
|
||||||
## 4. 工程与部署验收
|
## 4. 工程与部署验收
|
||||||
|
|
||||||
| 验收项 | 结果 | 实际结果 |
|
| 验收项 | 结果 | 实际结果 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 后端自动化测试 | 通过 | 111 项测试全部通过,包含异步入队、幂等、重试、重启恢复及推理内容过滤 |
|
| 后端自动化测试 | 通过 | 114 项测试全部通过,包含 V2 数据隔离、异步入队、幂等、重试、重启恢复及推理内容过滤 |
|
||||||
| OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
|
| OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
|
||||||
| 管理后台构建 | 通过 | 生产构建成功 |
|
| 管理后台构建 | 通过 | 生产构建成功 |
|
||||||
| 用户端构建 | 通过 | 生产构建成功 |
|
| 用户端构建 | 通过 | 生产构建成功 |
|
||||||
| Alembic 离线 SQL | 通过 | 成功生成 1130 行迁移 SQL,并覆盖到 `0024_topic_settlement_jobs` |
|
| Alembic 离线 SQL | 通过 | 成功生成 1154 行迁移 SQL,并覆盖到 `0025_recent_practice_review` |
|
||||||
| 现有数据库迁移 | 通过 | 开发 MySQL 已从 `0023_simple_knowledge_route` 升级至 `0024_topic_settlement_jobs` |
|
| 现有数据库迁移 | 通过 | 开发 MySQL 已升级至 `0025_recent_practice_review`,数据库与 Redis 就绪 |
|
||||||
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0024_topic_settlement_jobs`,主题任务字段和索引均已生成,验收库随后清理 |
|
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0025_recent_practice_review`,V2 字段与默认值均已生成,验收库随后清理 |
|
||||||
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
|
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
|
||||||
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
|
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
|
||||||
| 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 |
|
| 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 |
|
||||||
@@ -53,7 +55,9 @@
|
|||||||
|
|
||||||
全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。
|
全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。
|
||||||
|
|
||||||
2026-08-03 浏览器验收时发现,个别模型可能把 `<think>` 推理过程和多个 JSON 草稿一起返回,旧解析逻辑可能将其降级为成长档案正文。现已在结构化解析前剔除推理块,并从后向前选择最后一个可完整解析的 JSON;已补回归测试,思考过程不会再写入成长档案。
|
2026-08-03 浏览器验收时发现,个别模型可能把 `<think>` 推理过程和多个 JSON 草稿一起返回,旧解析逻辑可能将其降级为回顾正文。现已在结构化解析前剔除推理块,并从后向前选择最后一个可完整解析的 JSON;已补回归测试,思考过程不会再写入近期或周期实修回顾。
|
||||||
|
|
||||||
|
2026-08-03 对真实模型完成主题沉淀和阶段回顾验收:新版主题只保存“摘要 / 本次关注 / 可以继续留意”,近期回顾只保存“回顾正文 / 近期关注”;后台页面未出现旧画像字段,周期回顾正文未出现 `<think>` 或“思考过程”。
|
||||||
|
|
||||||
## 6. 上线前风险与建议
|
## 6. 上线前风险与建议
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user