feat: 异步沉淀主题摘要和成长档案
This commit is contained in:
@@ -47,6 +47,7 @@ const selectedUserDetail = ref<AdminUserDetail | null>(null);
|
||||
const userDetailOpen = ref(false);
|
||||
const userDetailLoading = ref(false);
|
||||
const userReportGenerating = ref("");
|
||||
const userSettlementRetrying = ref<number | null>(null);
|
||||
const userKeyword = ref("");
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
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) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "等待生成",
|
||||
running: "生成中",
|
||||
success: "已完成",
|
||||
fallback: "已降级沉淀",
|
||||
empty: "暂无沉淀",
|
||||
failed: "生成失败",
|
||||
};
|
||||
@@ -1714,13 +1730,28 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
<span>更新:{{ topic.updatedAt }}</span>
|
||||
</div>
|
||||
<template v-if="topic.summary">
|
||||
<pre>{{ topic.summary.summary }}</pre>
|
||||
<div class="topic-summary-fields">
|
||||
<div class="topic-summary-meta">
|
||||
<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.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>
|
||||
<el-button
|
||||
v-if="topic.summary.status === 'failed'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="userSettlementRetrying === topic.id"
|
||||
@click="retryTopicSettlement(topic.id)"
|
||||
>重新沉淀</el-button>
|
||||
</template>
|
||||
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
|
||||
</section>
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
QuestionInsightRefreshResult,
|
||||
QuestionInsightSummary,
|
||||
TopicSessionRecord,
|
||||
TopicSummaryRecord,
|
||||
} from "../types/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 })}`),
|
||||
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) }),
|
||||
retryTopicSettlement: (userId: number, topicId: number) =>
|
||||
request<TopicSummaryRecord>(`/admin/user/${userId}/topic/${topicId}/settlement/retry`, { method: "POST", body: "{}" }),
|
||||
createUser: (payload: Record<string, unknown>) =>
|
||||
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importUsers: (students: Record<string, unknown>[]) =>
|
||||
|
||||
@@ -500,6 +500,11 @@ export interface TopicSummaryRecord {
|
||||
modelName?: string | null;
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
attemptCount: number;
|
||||
maxAttempts: number;
|
||||
nextRunAt?: string | null;
|
||||
lastStartedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
|
||||
PERIODIC_REPORT_WEEKLY_ENABLED=true
|
||||
PERIODIC_REPORT_MONTHLY_ENABLED=true
|
||||
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
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
|
||||
@@ -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")
|
||||
@@ -349,6 +349,34 @@ def generate_user_report(
|
||||
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}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -98,6 +98,15 @@ def finish_topic(
|
||||
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")
|
||||
def generate_help_card(
|
||||
session_id: int,
|
||||
|
||||
@@ -48,6 +48,13 @@ def growth_profile(
|
||||
"summary": item.summary,
|
||||
"recommendedHomework": item.recommended_homework,
|
||||
"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,
|
||||
}
|
||||
for item in summaries
|
||||
|
||||
@@ -70,6 +70,10 @@ class Settings(BaseSettings):
|
||||
periodic_report_weekly_enabled: bool = True
|
||||
periodic_report_monthly_enabled: bool = True
|
||||
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_password: str = ""
|
||||
bootstrap_admin_name: str = "系统管理员"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
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.maintenance_service import MaintenanceService
|
||||
from app.services.periodic_report_worker import PeriodicReportWorker
|
||||
import asyncio
|
||||
from app.services.topic_settlement_worker import TopicSettlementWorker
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,12 +25,13 @@ async def lifespan(app: FastAPI):
|
||||
create_tables()
|
||||
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
|
||||
topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (maintenance_task, periodic_report_task):
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task):
|
||||
task.cancel()
|
||||
for task in (maintenance_task, periodic_report_task):
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -12,7 +12,11 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
class TopicSummary(Base):
|
||||
__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)
|
||||
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)
|
||||
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -9,48 +9,153 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
|
||||
from app.models.user import User
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
class GrowthProfileService:
|
||||
@staticmethod
|
||||
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:
|
||||
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.ended_at = _now()
|
||||
topic.recommended_homework = summary.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=summary)
|
||||
db.commit()
|
||||
db.refresh(topic)
|
||||
db.refresh(summary)
|
||||
if profile is not None:
|
||||
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,
|
||||
}
|
||||
return _settlement_result(db, topic=topic, summary=summary, user=user, growth_enabled=entitlement.enable_growth_profile)
|
||||
|
||||
@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))
|
||||
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(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
@@ -77,6 +182,8 @@ class GrowthProfileService:
|
||||
status_value = "success"
|
||||
error_message = None
|
||||
except ExternalServiceError as exc:
|
||||
if not allow_fallback:
|
||||
raise
|
||||
data = _fallback_summary(topic, conversation, "")
|
||||
status_value = "fallback"
|
||||
error_message = str(exc)
|
||||
@@ -88,6 +195,10 @@ class GrowthProfileService:
|
||||
existing.status = status_value
|
||||
existing.error_message = error_message
|
||||
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.flush()
|
||||
return existing
|
||||
@@ -95,6 +206,8 @@ class GrowthProfileService:
|
||||
@staticmethod
|
||||
def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile:
|
||||
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
|
||||
if profile is not None and profile.last_topic_summary_id == topic_summary.id:
|
||||
return profile
|
||||
before = growth_profile_dict(profile) if profile is not None else None
|
||||
if profile is None:
|
||||
profile = UserGrowthProfile(user_id=user.id, profile_text="")
|
||||
@@ -197,6 +310,11 @@ def topic_summary_dict(summary: TopicSummary) -> dict:
|
||||
"modelName": summary.model_name,
|
||||
"status": summary.status,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -303,17 +421,32 @@ def _parse_summary_json(raw: str) -> dict[str, Any] | None:
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"```$", "", text).strip()
|
||||
match = re.search(r"\{[\s\S]*\}", text)
|
||||
if match:
|
||||
text = match.group(0)
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
# 部分模型会把推理过程和最终 JSON 一起放进 answer,甚至在推理中先给出一份
|
||||
# 草稿 JSON。先移除推理块,再从后向前选取可完整解析的对象,避免把思考过程
|
||||
# 或多个 JSON 拼接后写入用户成长档案。
|
||||
text = re.sub(r"<(?:think|analysis)>[\s\S]*?</(?:think|analysis)>", "", text, flags=re.IGNORECASE).strip()
|
||||
fenced = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
|
||||
candidates = [*fenced, text]
|
||||
for candidate in reversed(candidates):
|
||||
parsed = _last_json_object(candidate)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
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:
|
||||
@@ -341,3 +474,43 @@ def _limit(text: str, max_len: int) -> str:
|
||||
|
||||
def _now() -> datetime:
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,11 +10,12 @@ from app.models import Base
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
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.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.topic_settlement_worker import TopicSettlementWorker
|
||||
|
||||
|
||||
def _db() -> Session:
|
||||
@@ -27,7 +28,7 @@ def _now() -> datetime:
|
||||
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:
|
||||
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))
|
||||
@@ -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)
|
||||
|
||||
assert result["topic"]["status"] == "completed"
|
||||
assert result["summary"]["summary"]
|
||||
assert result["profile"] is not None
|
||||
assert "最近主题" in result["profile"]["profileText"]
|
||||
assert result["settlementStatus"] == "pending"
|
||||
assert result["summary"]["summary"] == ""
|
||||
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(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():
|
||||
with _db() as db:
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -43,6 +43,7 @@ const followingOutput = ref(true);
|
||||
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
|
||||
const activeAbortController = ref<AbortController | null>(null);
|
||||
let toastTimer: number | null = null;
|
||||
let settlementPollVersion = 0;
|
||||
|
||||
onMounted(async () => {
|
||||
if (!getToken()) {
|
||||
@@ -62,6 +63,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
settlementPollVersion += 1;
|
||||
activeAbortController.value?.abort();
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
document.body.classList.remove("drawer-open");
|
||||
@@ -215,7 +217,12 @@ async function finishCurrentTopic() {
|
||||
finishingTopic.value = true;
|
||||
try {
|
||||
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 refreshProfile();
|
||||
} 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() {
|
||||
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
|
||||
generatingHelpCard.value = true;
|
||||
@@ -490,8 +529,10 @@ async function copyText(text: string) {
|
||||
<div v-if="growthProfile?.recentSummaries.length" class="recent-topic-summaries">
|
||||
<h3>最近主题沉淀</h3>
|
||||
<article v-for="item in growthProfile.recentSummaries" :key="item.id">
|
||||
<time>{{ item.generatedAt }}</time>
|
||||
<p>{{ item.summary }}</p>
|
||||
<time>{{ item.generatedAt }} · {{ settlementStatusLabel(item.status) }}</time>
|
||||
<p v-if="item.status === 'success' || item.status === 'fallback'">{{ item.summary }}</p>
|
||||
<p v-else-if="item.status === 'failed'">沉淀暂时失败,对话内容仍已保留。</p>
|
||||
<p v-else>正在后台整理本主题的实修记录,可以先关闭页面或继续聊天。</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="reportHistory.length" class="recent-topic-summaries periodic-report-list">
|
||||
|
||||
@@ -89,6 +89,7 @@ export const api = {
|
||||
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
|
||||
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({}) }),
|
||||
topicSettlement: (summaryId: number) => request<FinishTopicResult>(`/chat/topic/settlement/${summaryId}`),
|
||||
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({}) }),
|
||||
helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`),
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface TopicSummary {
|
||||
summary: string;
|
||||
recommendedHomework?: 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;
|
||||
}
|
||||
|
||||
@@ -77,6 +84,7 @@ export interface FinishTopicResult {
|
||||
summary: TopicSummary & Record<string, unknown>;
|
||||
profile: GrowthProfile | null;
|
||||
growthProfileEnabled: boolean;
|
||||
settlementStatus: string;
|
||||
}
|
||||
|
||||
export interface TeacherHelpCard {
|
||||
|
||||
Reference in New Issue
Block a user