feat: 异步沉淀主题摘要和成长档案

This commit is contained in:
2026-08-03 11:35:58 +08:00
parent a3946013f5
commit d2e08ab36c
24 changed files with 825 additions and 58 deletions

View File

@@ -28,6 +28,10 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
# ============================================ # ============================================
# 内网穿透frpc sidecar # 内网穿透frpc sidecar

View File

@@ -47,6 +47,7 @@ const selectedUserDetail = ref<AdminUserDetail | null>(null);
const userDetailOpen = ref(false); const userDetailOpen = ref(false);
const userDetailLoading = ref(false); const userDetailLoading = ref(false);
const userReportGenerating = ref(""); const userReportGenerating = ref("");
const userSettlementRetrying = ref<number | null>(null);
const userKeyword = ref(""); const userKeyword = ref("");
const entitlementPlans = ref<EntitlementPlan[]>([]); const entitlementPlans = ref<EntitlementPlan[]>([]);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
@@ -375,11 +376,26 @@ async function refreshSelectedUserDetail() {
} }
} }
async function retryTopicSettlement(topicId: number) {
if (!selectedUserDetail.value || userSettlementRetrying.value !== null) return;
userSettlementRetrying.value = topicId;
try {
await api.retryTopicSettlement(selectedUserDetail.value.user.id, topicId);
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
ElMessage.success("主题沉淀任务已重新进入后台队列");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "主题沉淀重试失败");
} finally {
userSettlementRetrying.value = null;
}
}
function reportStatusLabel(status: string) { function reportStatusLabel(status: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: "等待生成", pending: "等待生成",
running: "生成中", running: "生成中",
success: "已完成", success: "已完成",
fallback: "已降级沉淀",
empty: "暂无沉淀", empty: "暂无沉淀",
failed: "生成失败", failed: "生成失败",
}; };
@@ -1714,13 +1730,28 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<span>更新{{ topic.updatedAt }}</span> <span>更新{{ topic.updatedAt }}</span>
</div> </div>
<template v-if="topic.summary"> <template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre> <div class="topic-summary-meta">
<div class="topic-summary-fields"> <span>沉淀状态{{ reportStatusLabel(topic.summary.status) }}</span>
<span>执行次数{{ topic.summary.attemptCount }}/{{ topic.summary.maxAttempts }}</span>
<span v-if="topic.summary.lastStartedAt">开始执行{{ topic.summary.lastStartedAt }}</span>
<span v-if="topic.summary.finishedAt">完成{{ topic.summary.finishedAt }}</span>
</div>
<p v-if="topic.summary.errorMessage" class="report-error">最近错误{{ topic.summary.errorMessage }}</p>
<pre v-if="topic.summary.status === 'success' || topic.summary.status === 'fallback'">{{ topic.summary.summary }}</pre>
<p v-else-if="topic.summary.status === 'pending' || topic.summary.status === 'running'" class="report-pending">主题已结束摘要和成长档案正在后台生成</p>
<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.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</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.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p> <p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
</div> </div>
<el-button
v-if="topic.summary.status === 'failed'"
size="small"
type="primary"
:loading="userSettlementRetrying === topic.id"
@click="retryTopicSettlement(topic.id)"
>重新沉淀</el-button>
</template> </template>
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" /> <el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
</section> </section>

View File

@@ -35,6 +35,7 @@ import type {
QuestionInsightRefreshResult, QuestionInsightRefreshResult,
QuestionInsightSummary, QuestionInsightSummary,
TopicSessionRecord, TopicSessionRecord,
TopicSummaryRecord,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -135,6 +136,8 @@ export const api = {
userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`), userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`),
generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) => generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) =>
request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }), request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }),
retryTopicSettlement: (userId: number, topicId: number) =>
request<TopicSummaryRecord>(`/admin/user/${userId}/topic/${topicId}/settlement/retry`, { method: "POST", body: "{}" }),
createUser: (payload: Record<string, unknown>) => createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }), request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) => importUsers: (students: Record<string, unknown>[]) =>

View File

@@ -500,6 +500,11 @@ export interface TopicSummaryRecord {
modelName?: string | null; modelName?: string | null;
status: string; status: string;
errorMessage?: string | null; errorMessage?: string | null;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string; generatedAt: string;
} }

View File

@@ -49,6 +49,10 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456 # 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_USERNAME=admin

View File

@@ -0,0 +1,59 @@
"""add durable topic settlement job fields
Revision ID: 0024_topic_settlement_jobs
Revises: 0023_simple_knowledge_route
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0024_topic_settlement_jobs"
down_revision = "0023_simple_knowledge_route"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_topic_summary",
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_topic_summary",
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
)
op.add_column("sys_topic_summary", sa.Column("next_run_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("locked_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("locked_by", sa.String(length=120), nullable=True))
op.add_column("sys_topic_summary", sa.Column("last_started_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("finished_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE sys_topic_summary "
"SET finished_at = generated_at "
"WHERE status IN ('success', 'fallback', 'failed')"
)
op.create_index(
"ix_topic_summary_job_due",
"sys_topic_summary",
["status", "next_run_at", "id"],
)
op.create_index(
"ix_topic_summary_job_stale",
"sys_topic_summary",
["status", "locked_at"],
)
def downgrade() -> None:
op.drop_index("ix_topic_summary_job_stale", table_name="sys_topic_summary")
op.drop_index("ix_topic_summary_job_due", table_name="sys_topic_summary")
op.drop_column("sys_topic_summary", "finished_at")
op.drop_column("sys_topic_summary", "last_started_at")
op.drop_column("sys_topic_summary", "locked_by")
op.drop_column("sys_topic_summary", "locked_at")
op.drop_column("sys_topic_summary", "next_run_at")
op.drop_column("sys_topic_summary", "max_attempts")
op.drop_column("sys_topic_summary", "attempt_count")

View File

@@ -349,6 +349,34 @@ def generate_user_report(
return api_success(periodic_report_dict(report), message="报告任务已进入后台队列") return api_success(periodic_report_dict(report), message="报告任务已进入后台队列")
@router.post("/user/{user_id}/topic/{topic_id}/settlement/retry")
def retry_topic_settlement(
user_id: int,
topic_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
topic = db.scalar(
select(TopicSession).where(TopicSession.id == topic_id, TopicSession.user_id == user.id)
)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="主题不存在")
if topic.status != "completed":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="只能重试已结束主题的沉淀任务")
summary = GrowthProfileService.retry_topic_settlement(db, user=user, topic=topic)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="topic_settlement",
action="retry",
target_id=summary.id,
)
db.commit()
db.refresh(summary)
return api_success(topic_summary_dict(summary), message="主题沉淀任务已重新进入队列")
@router.put("/user/{user_id}") @router.put("/user/{user_id}")
def update_user( def update_user(
user_id: int, user_id: int,

View File

@@ -98,6 +98,15 @@ def finish_topic(
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session)) return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
@router.get("/topic/settlement/{summary_id}")
def topic_settlement(
summary_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success(GrowthProfileService.topic_settlement_result(db, user=current_user, summary_id=summary_id))
@router.post("/session/{session_id}/help-card") @router.post("/session/{session_id}/help-card")
def generate_help_card( def generate_help_card(
session_id: int, session_id: int,

View File

@@ -48,6 +48,13 @@ def growth_profile(
"summary": item.summary, "summary": item.summary,
"recommendedHomework": item.recommended_homework, "recommendedHomework": item.recommended_homework,
"nextObservation": item.next_observation, "nextObservation": item.next_observation,
"status": item.status,
"errorMessage": item.error_message,
"attemptCount": item.attempt_count,
"maxAttempts": item.max_attempts,
"nextRunAt": item.next_run_at,
"lastStartedAt": item.last_started_at,
"finishedAt": item.finished_at,
"generatedAt": item.generated_at, "generatedAt": item.generated_at,
} }
for item in summaries for item in summaries

View File

@@ -70,6 +70,10 @@ class Settings(BaseSettings):
periodic_report_weekly_enabled: bool = True periodic_report_weekly_enabled: bool = True
periodic_report_monthly_enabled: bool = True periodic_report_monthly_enabled: bool = True
periodic_report_timezone: str = "Asia/Shanghai" periodic_report_timezone: str = "Asia/Shanghai"
topic_settlement_worker_enabled: bool = True
topic_settlement_poll_seconds: int = 2
topic_settlement_stale_minutes: int = 30
topic_settlement_max_attempts: int = 3
bootstrap_admin_username: str = "" bootstrap_admin_username: str = ""
bootstrap_admin_password: str = "" bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员" bootstrap_admin_name: str = "系统管理员"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
@@ -13,7 +14,7 @@ from app.core.observability import RequestObservabilityMiddleware, configure_log
from app.services.secret_service import SecretService from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService from app.services.maintenance_service import MaintenanceService
from app.services.periodic_report_worker import PeriodicReportWorker from app.services.periodic_report_worker import PeriodicReportWorker
import asyncio from app.services.topic_settlement_worker import TopicSettlementWorker
@asynccontextmanager @asynccontextmanager
@@ -24,12 +25,13 @@ async def lifespan(app: FastAPI):
create_tables() create_tables()
maintenance_task = asyncio.create_task(MaintenanceService.run_forever()) maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever()) periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever())
try: try:
yield yield
finally: finally:
for task in (maintenance_task, periodic_report_task): for task in (maintenance_task, periodic_report_task, topic_settlement_task):
task.cancel() task.cancel()
for task in (maintenance_task, periodic_report_task): for task in (maintenance_task, periodic_report_task, topic_settlement_task):
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -12,7 +12,11 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class TopicSummary(Base): class TopicSummary(Base):
__tablename__ = "sys_topic_summary" __tablename__ = "sys_topic_summary"
__table_args__ = (UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),) __table_args__ = (
UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),
Index("ix_topic_summary_job_due", "status", "next_run_at", "id"),
Index("ix_topic_summary_job_stale", "status", "locked_at"),
)
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)
@@ -28,6 +32,13 @@ class TopicSummary(Base):
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True) model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False) status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
locked_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
last_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
generated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) generated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -9,48 +9,153 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
from app.models.user import User from app.models.user import User
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.external_errors import ExternalServiceError from app.services.external_errors import ExternalServiceError
from app.services.tracked_generation_service import TrackedGenerationService from app.services.tracked_generation_service import TrackedGenerationService
from app.services.topic_session_service import TopicSessionService
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:
topic = TopicSessionService.active_for_session(db, user=user, session=session) topic = db.scalar(
select(TopicSession)
.where(
TopicSession.user_id == user.id,
TopicSession.chat_session_id == session.id,
TopicSession.status == "active",
)
.order_by(TopicSession.created_at.desc(), TopicSession.id.desc())
.with_for_update()
.limit(1)
)
if topic is None: if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前会话没有进行中的主题") # 重复点击或客户端超时重试时返回最近一次任务,不重复生成摘要。
summary = GrowthProfileService.generate_topic_summary(db, user=user, topic=topic, force=force) topic = db.scalar(
select(TopicSession)
.where(
TopicSession.user_id == user.id,
TopicSession.chat_session_id == session.id,
TopicSession.status == "completed",
)
.order_by(TopicSession.ended_at.desc(), TopicSession.id.desc())
.limit(1)
)
summary = (
db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id))
if topic is not None
else None
)
if topic is None or summary is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前会话没有进行中的主题")
entitlement = EntitlementService.active_entitlement(db, user)
return _settlement_result(db, topic=topic, summary=summary, user=user, growth_enabled=entitlement.enable_growth_profile)
has_messages = db.scalar(
select(ChatMessage.id)
.where(ChatMessage.topic_session_id == topic.id, ChatMessage.user_id == user.id)
.limit(1)
)
if has_messages is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容")
summary = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id))
is_new_summary = summary is None
if is_new_summary:
summary = TopicSummary(topic_session_id=topic.id, user_id=user.id, summary="")
if is_new_summary or force or summary.status not in {"pending", "running", "success", "fallback"}:
_reset_summary_job(summary)
summary.max_attempts = max(1, get_settings().topic_settlement_max_attempts)
db.add(summary)
topic.status = "completed" topic.status = "completed"
topic.ended_at = _now() topic.ended_at = _now()
topic.recommended_homework = summary.recommended_homework
db.add(topic) db.add(topic)
entitlement = EntitlementService.active_entitlement(db, user) entitlement = EntitlementService.active_entitlement(db, user)
profile = None
if entitlement.enable_growth_profile:
profile = GrowthProfileService.update_growth_profile(db, user=user, topic_summary=summary)
db.commit() db.commit()
db.refresh(topic) db.refresh(topic)
db.refresh(summary) db.refresh(summary)
if profile is not None: return _settlement_result(db, topic=topic, summary=summary, user=user, growth_enabled=entitlement.enable_growth_profile)
db.refresh(profile)
return {
"topic": topic_dict(topic),
"summary": topic_summary_dict(summary),
"profile": growth_profile_dict(profile) if profile is not None else None,
"growthProfileEnabled": entitlement.enable_growth_profile,
}
@staticmethod @staticmethod
def generate_topic_summary(db: Session, *, user: User, topic: TopicSession, force: bool = False) -> TopicSummary: def retry_topic_settlement(db: Session, *, user: User, topic: TopicSession) -> TopicSummary:
summary = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id).with_for_update())
if summary is None:
summary = TopicSummary(topic_session_id=topic.id, user_id=user.id, summary="")
if summary.status == "running":
return summary
_reset_summary_job(summary)
summary.max_attempts = max(1, get_settings().topic_settlement_max_attempts)
db.add(summary)
db.flush()
return summary
@staticmethod
def topic_settlement_result(db: Session, *, user: User, summary_id: int) -> dict:
summary = db.scalar(
select(TopicSummary).where(TopicSummary.id == summary_id, TopicSummary.user_id == user.id)
)
if summary is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="主题沉淀任务不存在")
topic = db.get(TopicSession, summary.topic_session_id)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="主题不存在")
entitlement = EntitlementService.active_entitlement(db, user)
return _settlement_result(
db,
topic=topic,
summary=summary,
user=user,
growth_enabled=entitlement.enable_growth_profile,
)
@staticmethod
def process_topic_settlement(db: Session, *, summary: TopicSummary) -> tuple[TopicSummary, UserGrowthProfile | None]:
topic = db.get(TopicSession, summary.topic_session_id)
# Redis 失效时允许多个进程依靠数据库继续消费;用户行锁保证同一学员的
# 多个主题不会并发覆盖长期成长档案。
user = db.scalar(select(User).where(User.id == summary.user_id).with_for_update())
if topic is None or user is None or user.is_deleted:
raise ValueError("主题或用户不存在")
generated = GrowthProfileService.generate_topic_summary(
db,
user=user,
topic=topic,
force=True,
process_job=True,
allow_fallback=False,
)
topic.recommended_homework = generated.recommended_homework
db.add(topic)
entitlement = EntitlementService.active_entitlement(db, user)
profile = None
if entitlement.enable_growth_profile:
profile = GrowthProfileService.update_growth_profile(db, user=user, topic_summary=generated)
return generated, profile
@staticmethod
def generate_topic_summary(
db: Session,
*,
user: User,
topic: TopicSession,
force: bool = False,
process_job: bool = False,
allow_fallback: bool = True,
) -> 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 not force: if existing is not None and not force:
return existing if existing.status in {"success", "fallback"}:
return existing
if existing.status in {"pending", "running"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="主题正在后台沉淀,请稍后再试")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="主题沉淀失败,请稍后重试")
if existing is not None and existing.status in {"pending", "running"} and not process_job:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="主题正在后台沉淀,请稍后再试")
messages = list( messages = list(
db.scalars( db.scalars(
select(ChatMessage) select(ChatMessage)
@@ -77,6 +182,8 @@ class GrowthProfileService:
status_value = "success" status_value = "success"
error_message = None error_message = None
except ExternalServiceError as exc: except ExternalServiceError as exc:
if not allow_fallback:
raise
data = _fallback_summary(topic, conversation, "") data = _fallback_summary(topic, conversation, "")
status_value = "fallback" status_value = "fallback"
error_message = str(exc) error_message = str(exc)
@@ -88,6 +195,10 @@ class GrowthProfileService:
existing.status = status_value existing.status = status_value
existing.error_message = error_message existing.error_message = error_message
existing.generated_at = _now() existing.generated_at = _now()
existing.finished_at = _now()
existing.next_run_at = None
existing.locked_at = None
existing.locked_by = None
db.add(existing) db.add(existing)
db.flush() db.flush()
return existing return existing
@@ -95,6 +206,8 @@ 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:
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:
profile = UserGrowthProfile(user_id=user.id, profile_text="") profile = UserGrowthProfile(user_id=user.id, profile_text="")
@@ -197,6 +310,11 @@ def topic_summary_dict(summary: TopicSummary) -> dict:
"modelName": summary.model_name, "modelName": summary.model_name,
"status": summary.status, "status": summary.status,
"errorMessage": summary.error_message, "errorMessage": summary.error_message,
"attemptCount": summary.attempt_count,
"maxAttempts": summary.max_attempts,
"nextRunAt": summary.next_run_at,
"lastStartedAt": summary.last_started_at,
"finishedAt": summary.finished_at,
"generatedAt": summary.generated_at, "generatedAt": summary.generated_at,
} }
@@ -303,17 +421,32 @@ def _parse_summary_json(raw: str) -> dict[str, Any] | None:
text = raw.strip() text = raw.strip()
if not text: if not text:
return None return None
if text.startswith("```"): # 部分模型会把推理过程和最终 JSON 一起放进 answer甚至在推理中先给出一份
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip() # 草稿 JSON。先移除推理块再从后向前选取可完整解析的对象避免把思考过程
text = re.sub(r"```$", "", text).strip() # 或多个 JSON 拼接后写入用户成长档案。
match = re.search(r"\{[\s\S]*\}", text) text = re.sub(r"<(?:think|analysis)>[\s\S]*?</(?:think|analysis)>", "", text, flags=re.IGNORECASE).strip()
if match: fenced = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
text = match.group(0) candidates = [*fenced, text]
try: for candidate in reversed(candidates):
parsed = json.loads(text) parsed = _last_json_object(candidate)
except json.JSONDecodeError: if parsed is not None:
return None return parsed
return parsed if isinstance(parsed, dict) else None return None
def _last_json_object(text: str) -> dict[str, Any] | None:
decoder = json.JSONDecoder()
parsed_objects: list[dict[str, Any]] = []
for index, character in enumerate(text):
if character != "{":
continue
try:
value, _ = decoder.raw_decode(text[index:])
except json.JSONDecodeError:
continue
if isinstance(value, dict):
parsed_objects.append(value)
return parsed_objects[-1] if parsed_objects else None
def _messages_text(messages: list[ChatMessage]) -> str: def _messages_text(messages: list[ChatMessage]) -> str:
@@ -341,3 +474,43 @@ def _limit(text: str, max_len: int) -> str:
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
def _reset_summary_job(summary: TopicSummary) -> None:
summary.summary = ""
summary.main_events = None
summary.emotions = None
summary.body_feelings = None
summary.beliefs = None
summary.recommended_homework = None
summary.insights = None
summary.next_observation = None
summary.model_name = None
summary.status = "pending"
summary.error_message = None
summary.attempt_count = 0
summary.next_run_at = _now()
summary.locked_at = None
summary.locked_by = None
summary.last_started_at = None
summary.finished_at = None
def _settlement_result(
db: Session,
*,
topic: TopicSession,
summary: TopicSummary,
user: User,
growth_enabled: bool,
) -> dict:
profile = None
if growth_enabled and summary.status in {"success", "fallback"}:
profile = GrowthProfileService.get_growth_profile(db, user.id)
return {
"topic": topic_dict(topic),
"summary": topic_summary_dict(summary),
"profile": growth_profile_dict(profile),
"growthProfileEnabled": growth_enabled,
"settlementStatus": summary.status,
}

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import asyncio
import logging
import os
import socket
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import SessionLocal
from app.models.chat import TopicSession
from app.models.growth import TopicSummary
from app.models.user import User
from app.services.growth_profile_service import GrowthProfileService
from app.services.redis_client import get_sync_redis_client
logger = logging.getLogger(__name__)
WORKER_LOCK_KEY = "topic-settlement:worker:lock"
WORKER_LOCK_TTL_SECONDS = 900
class TopicSettlementWorker:
"""持久化主题沉淀队列。
MySQL 保存任务、重试和执行状态Redis 仅限制多进程同时执行数量。
即使 Redis 暂时不可用或服务重启,已结束主题也不会丢失沉淀任务。
"""
@classmethod
async def run_forever(cls) -> None:
settings = get_settings()
if not settings.topic_settlement_worker_enabled:
logger.info("topic settlement worker disabled")
return
worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
poll_seconds = max(1, settings.topic_settlement_poll_seconds)
while True:
try:
processed = await asyncio.to_thread(cls.run_once, worker_id)
except Exception:
processed = False
logger.exception("topic settlement worker iteration failed")
await asyncio.sleep(0 if processed else poll_seconds)
@classmethod
def run_once(cls, worker_id: str) -> bool:
redis, acquired = _acquire_worker_lock(worker_id)
if not acquired:
return False
try:
now = _now()
with SessionLocal() as db:
cls.recover_stale_jobs(db, now=now)
db.commit()
with SessionLocal() as db:
summary_id = cls.claim_next(db, worker_id=worker_id, now=now)
if summary_id is None:
return False
with SessionLocal() as db:
cls.execute_claimed(db, summary_id=summary_id, worker_id=worker_id)
return True
finally:
_release_worker_lock(redis, worker_id)
@staticmethod
def claim_next(db: Session, *, worker_id: str, now: datetime | None = None) -> int | None:
current = now or _now()
summary = db.scalar(
select(TopicSummary)
.where(
TopicSummary.status == "pending",
TopicSummary.attempt_count < TopicSummary.max_attempts,
or_(TopicSummary.next_run_at.is_(None), TopicSummary.next_run_at <= current),
)
.order_by(TopicSummary.next_run_at.asc(), TopicSummary.id.asc())
.with_for_update(skip_locked=True)
.limit(1)
)
if summary is None:
db.rollback()
return None
summary.status = "running"
summary.attempt_count += 1
summary.locked_at = current
summary.locked_by = worker_id
summary.last_started_at = current
summary.finished_at = None
db.add(summary)
db.commit()
return summary.id
@staticmethod
def execute_claimed(db: Session, *, summary_id: int, worker_id: str) -> TopicSummary | None:
summary = db.get(TopicSummary, summary_id)
if summary is None or summary.status != "running" or summary.locked_by != worker_id:
return summary
topic = db.get(TopicSession, summary.topic_session_id)
user = db.get(User, summary.user_id)
if topic is None or user is None or user.is_deleted:
_finish_permanent_failure(summary, "主题或用户不存在")
db.commit()
return summary
try:
GrowthProfileService.process_topic_settlement(db, summary=summary)
now = _now()
summary.next_run_at = None
summary.finished_at = now
summary.locked_at = None
summary.locked_by = None
db.add(summary)
db.commit()
except Exception as exc:
# 撤销本轮可能已经写入的半成品摘要、档案和修订记录,再单独更新任务状态。
db.rollback()
summary = db.get(TopicSummary, summary_id)
if summary is None:
return None
summary.error_message = str(exc)[:2000]
now = _now()
if summary.attempt_count < summary.max_attempts:
retry_seconds = min(300, 30 * (2 ** max(0, summary.attempt_count - 1)))
summary.status = "pending"
summary.next_run_at = now + timedelta(seconds=retry_seconds)
summary.finished_at = None
else:
summary.status = "failed"
summary.next_run_at = None
summary.finished_at = now
summary.locked_at = None
summary.locked_by = None
db.add(summary)
db.commit()
db.refresh(summary)
return summary
@staticmethod
def recover_stale_jobs(db: Session, *, now: datetime | None = None) -> int:
current = now or _now()
stale_before = current - timedelta(minutes=max(5, get_settings().topic_settlement_stale_minutes))
summaries = list(
db.scalars(
select(TopicSummary)
.where(
TopicSummary.status == "running",
TopicSummary.locked_at.is_not(None),
TopicSummary.locked_at < stale_before,
)
.order_by(TopicSummary.id.asc())
.limit(100)
.with_for_update(skip_locked=True)
)
)
for summary in summaries:
if summary.attempt_count >= summary.max_attempts:
summary.status = "failed"
summary.finished_at = current
summary.next_run_at = None
else:
summary.status = "pending"
summary.next_run_at = current
summary.finished_at = None
summary.error_message = _append_error(summary.error_message, "任务执行进程中断,系统已自动恢复")
summary.locked_at = None
summary.locked_by = None
db.add(summary)
return len(summaries)
def _finish_permanent_failure(summary: TopicSummary, message: str) -> None:
summary.status = "failed"
summary.error_message = message
summary.next_run_at = None
summary.locked_at = None
summary.locked_by = None
summary.finished_at = _now()
def _append_error(current: str | None, message: str) -> str:
if not current:
return message
return f"{current}\n{message}"[-2000:]
def _acquire_worker_lock(worker_id: str):
redis = get_sync_redis_client()
if redis is None:
return None, True
try:
return redis, bool(redis.set(WORKER_LOCK_KEY, worker_id, nx=True, ex=WORKER_LOCK_TTL_SECONDS))
except Exception:
logger.warning("redis unavailable for topic settlement lock; falling back to database claim", exc_info=True)
return None, True
def _release_worker_lock(redis, worker_id: str) -> None:
if redis is None:
return
try:
redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
"return redis.call('del', KEYS[1]) else return 0 end",
1,
WORKER_LOCK_KEY,
worker_id,
)
except Exception:
logger.warning("failed to release topic settlement worker lock", exc_info=True)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,11 +10,12 @@ from app.models import Base
from app.models.ai_config import SystemConfig from app.models.ai_config import SystemConfig
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan from app.models.entitlement import EntitlementPlan
from app.models.growth import GrowthProfileRevision, UserGrowthProfile from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
from app.models.user import User from app.models.user import User
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService, _parse_summary_json
from app.services.rag_service import PromptService from app.services.rag_service import PromptService
from app.services.topic_settlement_worker import TopicSettlementWorker
def _db() -> Session: def _db() -> Session:
@@ -27,7 +28,7 @@ def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
def test_finish_topic_generates_summary_and_updates_growth_profile_for_enabled_plan(): def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_enabled_plan():
with _db() as db: with _db() as db:
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true")) db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, enable_growth_profile=1, status=1)) db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, enable_growth_profile=1, status=1))
@@ -58,13 +59,114 @@ def test_finish_topic_generates_summary_and_updates_growth_profile_for_enabled_p
result = GrowthProfileService.finish_active_topic(db, user=user, session=session) result = GrowthProfileService.finish_active_topic(db, user=user, session=session)
assert result["topic"]["status"] == "completed" assert result["topic"]["status"] == "completed"
assert result["summary"]["summary"] assert result["settlementStatus"] == "pending"
assert result["profile"] is not None assert result["summary"]["summary"] == ""
assert "最近主题" in result["profile"]["profileText"] assert result["profile"] is None
assert db.query(UserGrowthProfile).filter_by(user_id=1).count() == 0
summary_id = TopicSettlementWorker.claim_next(db, worker_id="test-worker", now=_now())
assert summary_id == result["summary"]["id"]
completed = TopicSettlementWorker.execute_claimed(db, summary_id=summary_id, worker_id="test-worker")
assert completed is not None
assert completed.status == "success"
assert completed.summary
profile = db.query(UserGrowthProfile).filter_by(user_id=1).one()
assert "最近主题" in profile.profile_text
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
def test_finish_topic_is_idempotent_and_does_not_call_model_in_request(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="active",
message_count=1,
started_at=now,
)
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我想沉淀一下", created_at=now)
db.add_all([user, session, topic, message])
db.commit()
monkeypatch.setattr(
"app.services.tracked_generation_service.TrackedGenerationService.generate",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("结束主题接口不应调用模型")),
)
first = GrowthProfileService.finish_active_topic(db, user=user, session=session)
second = GrowthProfileService.finish_active_topic(db, user=user, session=session)
assert first["summary"]["id"] == second["summary"]["id"]
assert first["settlementStatus"] == "pending"
assert second["settlementStatus"] == "pending"
assert db.query(TopicSummary).count() == 1
def test_failed_topic_settlement_is_retried(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="active", started_at=now)
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="测试", created_at=now)
db.add_all([user, session, topic, message])
db.commit()
result = GrowthProfileService.finish_active_topic(db, user=user, session=session)
monkeypatch.setattr(
GrowthProfileService,
"process_topic_settlement",
staticmethod(lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("模型暂时不可用"))),
)
summary_id = TopicSettlementWorker.claim_next(db, worker_id="retry-worker", now=_now())
retried = TopicSettlementWorker.execute_claimed(db, summary_id=summary_id, worker_id="retry-worker")
assert summary_id == result["summary"]["id"]
assert retried is not None
assert retried.status == "pending"
assert retried.attempt_count == 1
assert retried.next_run_at is not None
assert "模型暂时不可用" in (retried.error_message or "")
def test_stale_topic_settlement_is_recovered_after_restart():
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", started_at=now, ended_at=now)
summary = TopicSummary(
id=1,
topic_session_id=1,
user_id=1,
summary="",
status="running",
attempt_count=1,
max_attempts=3,
locked_by="stopped-worker",
locked_at=now - timedelta(minutes=31),
)
db.add_all([user, session, topic, summary])
db.commit()
recovered = TopicSettlementWorker.recover_stale_jobs(db, now=now)
db.commit()
db.refresh(summary)
assert recovered == 1
assert summary.status == "pending"
assert summary.locked_by is None
assert summary.next_run_at == now
assert "自动恢复" in (summary.error_message or "")
def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_context(): def test_prompt_can_include_growth_profile_context_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)
@@ -84,3 +186,27 @@ def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_c
assert "[长期成长档案]" in content assert "[长期成长档案]" in content
assert "表达障碍" in content assert "表达障碍" in content
assert "[本轮可靠知识上下文]" in content assert "[本轮可靠知识上下文]" in content
def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
raw = """
<think>
先分析一下,并给出一个未完成草稿:
```json
{"profileText": "不应采用的草稿"}
```
</think>
```json
{
"profileText": "只保留最终成长档案",
"commonEmotions": ["紧张"],
"recentProgress": ["开始观察身体感受"]
}
```
"""
parsed = _parse_summary_json(raw)
assert parsed is not None
assert parsed["profileText"] == "只保留最终成长档案"
assert "think" not in str(parsed).lower()

View File

@@ -43,6 +43,7 @@ const followingOutput = ref(true);
const messageList = ref<InstanceType<typeof MessageList> | null>(null); const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const activeAbortController = ref<AbortController | null>(null); const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null; let toastTimer: number | null = null;
let settlementPollVersion = 0;
onMounted(async () => { onMounted(async () => {
if (!getToken()) { if (!getToken()) {
@@ -62,6 +63,7 @@ onMounted(async () => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
settlementPollVersion += 1;
activeAbortController.value?.abort(); activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer); if (toastTimer) window.clearTimeout(toastTimer);
document.body.classList.remove("drawer-open"); document.body.classList.remove("drawer-open");
@@ -215,7 +217,12 @@ async function finishCurrentTopic() {
finishingTopic.value = true; finishingTopic.value = true;
try { try {
const result = await api.finishTopic(activeSessionId.value); const result = await api.finishTopic(activeSessionId.value);
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀"); if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀");
} else {
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
void watchTopicSettlement(result.summary.id);
}
await refreshSessionList(); await refreshSessionList();
await refreshProfile(); await refreshProfile();
} catch (error) { } catch (error) {
@@ -225,6 +232,38 @@ async function finishCurrentTopic() {
} }
} }
async function watchTopicSettlement(summaryId: number) {
const version = ++settlementPollVersion;
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 3000));
if (version !== settlementPollVersion || !user.value) return;
try {
const result = await api.topicSettlement(summaryId);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了成长档案" : "实修记录已沉淀完成");
await refreshProfile();
return;
}
if (result.settlementStatus === "failed") {
showToast("实修记录暂时沉淀失败,系统已保留对话,可稍后重试");
return;
}
} catch {
// 轮询失败不影响聊天;下一轮继续查询持久化任务状态。
}
}
}
function settlementStatusLabel(status: string) {
return {
pending: "等待沉淀",
running: "沉淀中",
success: "已完成",
fallback: "已降级完成",
failed: "沉淀失败",
}[status] || status;
}
async function generateHelpCard() { async function generateHelpCard() {
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return; if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
generatingHelpCard.value = true; generatingHelpCard.value = true;
@@ -490,8 +529,10 @@ async function copyText(text: string) {
<div v-if="growthProfile?.recentSummaries.length" class="recent-topic-summaries"> <div v-if="growthProfile?.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 growthProfile.recentSummaries" :key="item.id">
<time>{{ item.generatedAt }}</time> <time>{{ item.generatedAt }} · {{ settlementStatusLabel(item.status) }}</time>
<p>{{ 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>正在后台整理本主题的实修记录可以先关闭页面或继续聊天</p>
</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">

View File

@@ -89,6 +89,7 @@ export const api = {
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }), request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }), deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }),
finishTopic: (sessionId: number) => request<FinishTopicResult>(`/chat/session/${sessionId}/topic/finish`, { method: "POST", body: JSON.stringify({}) }), finishTopic: (sessionId: number) => request<FinishTopicResult>(`/chat/session/${sessionId}/topic/finish`, { method: "POST", body: JSON.stringify({}) }),
topicSettlement: (summaryId: number) => request<FinishTopicResult>(`/chat/topic/settlement/${summaryId}`),
generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(`/chat/session/${sessionId}/help-card`, { method: "POST", body: JSON.stringify({}) }), generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(`/chat/session/${sessionId}/help-card`, { method: "POST", body: JSON.stringify({}) }),
markHelpCardCopied: (cardId: number) => request<TeacherHelpCard>(`/chat/help-card/${cardId}/copied`, { method: "POST", body: JSON.stringify({}) }), markHelpCardCopied: (cardId: number) => request<TeacherHelpCard>(`/chat/help-card/${cardId}/copied`, { method: "POST", body: JSON.stringify({}) }),
helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`), helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`),

View File

@@ -38,6 +38,13 @@ export interface TopicSummary {
summary: string; summary: string;
recommendedHomework?: string | null; recommendedHomework?: string | null;
nextObservation?: string | null; nextObservation?: string | null;
status: "pending" | "running" | "success" | "fallback" | "failed" | string;
errorMessage?: string | null;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string; generatedAt: string;
} }
@@ -77,6 +84,7 @@ export interface FinishTopicResult {
summary: TopicSummary & Record<string, unknown>; summary: TopicSummary & Record<string, unknown>;
profile: GrowthProfile | null; profile: GrowthProfile | null;
growthProfileEnabled: boolean; growthProfileEnabled: boolean;
settlementStatus: string;
} }
export interface TeacherHelpCard { export interface TeacherHelpCard {

View File

@@ -163,6 +163,26 @@ PERIODIC_REPORT_STALE_MINUTES=30
PERIODIC_REPORT_MAX_ATTEMPTS=3 PERIODIC_REPORT_MAX_ATTEMPTS=3
``` ```
## 主题沉淀后台任务
用户点击“沉淀本主题”后,接口只结束当前主题并创建持久化任务,不再等待模型生成摘要和成长档案。用户可以立即继续聊天、切换会话或关闭页面。
任务状态保存在 `sys_topic_summary`
- `pending`:等待后台执行;
- `running`:正在生成摘要和成长档案;
- `success` / `fallback`:沉淀完成;
- `failed`:达到最大重试次数后仍失败,可在后台用户详情中手动重试。
多进程部署时 Redis 用于限制同一时间只有一个主题沉淀 Worker 执行MySQL 负责持久化任务、幂等和失败重试。服务中断超过配置时间后,任务会自动恢复,不需要用户重复结束主题。
```text
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
```
## 多模型分流 ## 多模型分流
模型管理现在区分两个概念: 模型管理现在区分两个概念:

View File

@@ -23,6 +23,8 @@
- [ ] 已确认权益版本、能力开关和学员当前权益配置正确。 - [ ] 已确认权益版本、能力开关和学员当前权益配置正确。
- [ ] 已验证后台 Agent 预览能够按模拟学员和具体主题加载上下文。 - [ ] 已验证后台 Agent 预览能够按模拟学员和具体主题加载上下文。
- [ ] 已确认“结束主题”在当前网关超时范围内完成;如已异步化,已验证任务状态、幂等和失败重试。 - [ ] 已确认“结束主题”在当前网关超时范围内完成;如已异步化,已验证任务状态、幂等和失败重试。
- [ ] 已确认主题沉淀 Worker 已启用,等待、执行中、成功和失败状态均可查询。
- [ ] 已验证主题沉淀任务在服务重启后自动恢复,后台可以手动重试最终失败任务。
## 发布中 ## 发布中
@@ -45,6 +47,7 @@
- [ ] 用户端可以提问并收到回答。 - [ ] 用户端可以提问并收到回答。
- [ ] 用户发送后输入框立即清空,思考状态与流式 Markdown 回答显示正常。 - [ ] 用户发送后输入框立即清空,思考状态与流式 Markdown 回答显示正常。
- [ ] 连续提问能够继续使用同一主题的上下文。 - [ ] 连续提问能够继续使用同一主题的上下文。
- [ ] 结束主题接口快速返回,用户可以立即新建主题或继续使用系统。
- [ ] 无命中问题返回固定兜底。 - [ ] 无命中问题返回固定兜底。
- [ ] 管理后台可以登录。 - [ ] 管理后台可以登录。
- [ ] 管理后台可以查看 Dashboard。 - [ ] 管理后台可以查看 Dashboard。

View File

@@ -53,6 +53,10 @@ services:
PERIODIC_REPORT_WEEKLY_ENABLED: "true" PERIODIC_REPORT_WEEKLY_ENABLED: "true"
PERIODIC_REPORT_MONTHLY_ENABLED: "true" PERIODIC_REPORT_MONTHLY_ENABLED: "true"
PERIODIC_REPORT_TIMEZONE: Asia/Shanghai PERIODIC_REPORT_TIMEZONE: Asia/Shanghai
TOPIC_SETTLEMENT_WORKER_ENABLED: "true"
TOPIC_SETTLEMENT_POLL_SECONDS: "2"
TOPIC_SETTLEMENT_STALE_MINUTES: "30"
TOPIC_SETTLEMENT_MAX_ATTEMPTS: "3"
JWT_SECRET_KEY: local-dev-secret-change-before-production JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true" MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456" MOCK_SMS_CODE: "123456"

View File

@@ -52,6 +52,10 @@ services:
PERIODIC_REPORT_WEEKLY_ENABLED: ${PERIODIC_REPORT_WEEKLY_ENABLED:-true} PERIODIC_REPORT_WEEKLY_ENABLED: ${PERIODIC_REPORT_WEEKLY_ENABLED:-true}
PERIODIC_REPORT_MONTHLY_ENABLED: ${PERIODIC_REPORT_MONTHLY_ENABLED:-true} PERIODIC_REPORT_MONTHLY_ENABLED: ${PERIODIC_REPORT_MONTHLY_ENABLED:-true}
PERIODIC_REPORT_TIMEZONE: ${PERIODIC_REPORT_TIMEZONE:-Asia/Shanghai} PERIODIC_REPORT_TIMEZONE: ${PERIODIC_REPORT_TIMEZONE:-Asia/Shanghai}
TOPIC_SETTLEMENT_WORKER_ENABLED: ${TOPIC_SETTLEMENT_WORKER_ENABLED:-true}
TOPIC_SETTLEMENT_POLL_SECONDS: ${TOPIC_SETTLEMENT_POLL_SECONDS:-2}
TOPIC_SETTLEMENT_STALE_MINUTES: ${TOPIC_SETTLEMENT_STALE_MINUTES:-30}
TOPIC_SETTLEMENT_MAX_ATTEMPTS: ${TOPIC_SETTLEMENT_MAX_ATTEMPTS:-3}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
MOCK_SMS_ENABLED: "false" MOCK_SMS_ENABLED: "false"

View File

@@ -357,7 +357,8 @@ 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主题结束已改为持久化异步沉淀。接口快速结束主题并创建唯一任务后台独立生成摘要和成长档案支持自动重试、服务重启恢复、用户端状态查询和后台手动重试原同步模型调用不再阻塞用户操作。
--- ---
@@ -896,6 +897,6 @@ AI 日志增加:
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系; - 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
- 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环; - 周报、月报的异步与定时生成已经完成,本计划中的连续陪伴主链路已完成一期闭环;
- 一期主要功能链路已验收通过;主题结束时同步生成摘要和成长档案的耗时仍是上线前必须关注的性能风险 - 一期主要功能链路已验收通过;主题摘要和成长档案已改为持久化异步生成,原同步等待风险已经关闭
- 问题洞察的知识库补充建议、人工合并和拆分暂缓; - 问题洞察的知识库补充建议、人工合并和拆分暂缓;
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。 - 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。

View File

@@ -11,7 +11,7 @@
一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。 一期功能主链路已打通,权益管理页面已完成布局重构,用户端和后台 Agent 预览均能够按学员、主题和权益运行。自动化测试、前后端构建、数据库迁移和隔离生产环境冒烟均通过。
当前仍有一项上线前性能风险:结束主题时会同步生成主题摘要成长档案,本次真实模型验收耗时约 42 秒。结果能够正确生成,但等待时间容易让用户误认为操作无响应,也会放大重复点击和网络超时风险。本项不影响数据正确性,但不应按“完全无风险”关闭 2026-08-03 已完成主题沉淀异步化:结束主题接口只完成状态变更和任务入队,摘要成长档案由持久化后台任务生成;重复点击返回同一任务,失败自动重试,服务重启能够恢复,最终失败可由管理员手动重试。原先约 42 秒的同步等待不再阻塞用户请求
## 3. 功能验收结果 ## 3. 功能验收结果
@@ -24,7 +24,7 @@
| 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 | | 用户端主题上下文 | 通过 | 连续消息均绑定同一主题,历史消息与主题上下文可继续使用 |
| 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和成长档案 | | 后台 Agent 预览 | 通过 | 可选择模拟学员和具体主题,能够加载权益、主题摘要、最近消息和成长档案 |
| Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 | | Agent 运行追踪 | 通过 | 能看到 `load_debug_user_context`、模型路由等追踪节点 |
| 主题沉淀 | 功能通过,性能待优化 | 主题摘要与成长档案均生成成功;真实模型调用约 42 秒 | | 主题沉淀 | 通过 | 结束主题快速入队;摘要与成长档案后台生成;支持幂等、自动重试、重启恢复和管理员手动重试 |
| 求助卡 | 通过 | 可生成非空内容并记录复制状态 | | 求助卡 | 通过 | 可生成非空内容并记录复制状态 |
| 分享稿 | 通过 | 可生成非空内容并记录复制状态 | | 分享稿 | 通过 | 可生成非空内容并记录复制状态 |
| 周期报告 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存报告 | | 周期报告 | 通过 | 入队接口约 7 毫秒返回,后台任务能够完成并保存报告 |
@@ -36,12 +36,13 @@
| 验收项 | 结果 | 实际结果 | | 验收项 | 结果 | 实际结果 |
| --- | --- | --- | | --- | --- | --- |
| 后端自动化测试 | 通过 | 107 项测试全部通过 | | 后端自动化测试 | 通过 | 111 项测试全部通过,包含异步入队、幂等、重试、重启恢复及推理内容过滤 |
| OpenAPI 检查 | 通过 | 成功生成并检查 93 个接口路径 | | OpenAPI 检查 | 通过 | 成功生成并检查 95 个接口路径 |
| 管理后台构建 | 通过 | 生产构建成功 | | 管理后台构建 | 通过 | 生产构建成功 |
| 用户端构建 | 通过 | 生产构建成功 | | 用户端构建 | 通过 | 生产构建成功 |
| Alembic 离线 SQL | 通过 | 成功生成 1106 行迁移 SQL并覆盖到 `0023_simple_knowledge_route` | | Alembic 离线 SQL | 通过 | 成功生成 1130 行迁移 SQL并覆盖到 `0024_topic_settlement_jobs` |
| 全新数据库迁移 | 通过 | 在隔离 MySQL 数据库从零升级至最新版本,共生成 39 张表 | | 现有数据库迁移 | 通过 | 开发 MySQL 已从 `0023_simple_knowledge_route` 升级至 `0024_topic_settlement_jobs` |
| 全新数据库迁移 | 通过 | 隔离 MySQL 从零升级到 `0024_topic_settlement_jobs`,主题任务字段和索引均已生成,验收库随后清理 |
| 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` | | 数据库与 Redis 就绪检查 | 通过 | `/api/ready` 返回数据库和 Redis 均已就绪,并返回 `X-Request-ID` |
| 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 | | 隔离生产编排 | 通过 | 使用生产镜像、独立数据库和 Redis 启动,所有容器健康 |
| 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 | | 生产冒烟 | 通过 | 用户端、管理后台、静态资源、健康接口和就绪接口均可访问 |
@@ -52,14 +53,15 @@
全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。 全链路验证脚本在生成 Alembic 离线 SQL 时,被部分历史迁移中的在线数据库检查阻断。已为相关迁移补充分离的离线模式逻辑,并为默认权益数据补充明确字段类型。在线迁移行为保持不变,且已通过全新 MySQL 数据库实际迁移验证。
2026-08-03 浏览器验收时发现,个别模型可能把 `<think>` 推理过程和多个 JSON 草稿一起返回,旧解析逻辑可能将其降级为成长档案正文。现已在结构化解析前剔除推理块,并从后向前选择最后一个可完整解析的 JSON已补回归测试思考过程不会再写入成长档案。
## 6. 上线前风险与建议 ## 6. 上线前风险与建议
### P1:结束主题接口等待时间过长 ### 已关闭:结束主题接口等待时间过长
- 现象:结束主题后同步执行主题摘要和成长档案两次模型处理,本次耗时约 42 秒 - 处理:主题结束与模型生成已拆分,持久化任务由独立 Worker 执行
- 影响:用户容易认为按钮没有生效;弱网或网关超时会使前端收到失败,但后端可能已经成功;重复点击可能产生重复任务 - 保护:数据库唯一约束和行锁保证重复请求只产生一条任务;最多自动重试 3 次;执行进程中断后自动恢复;后台支持手动重新入队
- 建议:把主题结束本身改为快速提交,摘要和成长档案转为后台任务;增加处理中状态、幂等键和失败重试;前端轮询或通过现有任务状态刷新结果 - 用户体验:主题结束后立即提示“实修记录正在后台沉淀”,用户可以继续聊天;页面停留期间会自动查询完成状态
- 上线判断:内部小范围试用可继续;正式扩大用户前建议完成该项。
### P2前端构建体积提示 ### P2前端构建体积提示