feat: add teacher help cards

This commit is contained in:
2026-07-31 15:53:47 +08:00
parent 79094d0832
commit d8fde93b69
15 changed files with 467 additions and 4 deletions

View File

@@ -0,0 +1,51 @@
"""add teacher help cards
Revision ID: 0016_teacher_help_cards
Revises: 0015_growth_profiles
"""
from alembic import op
import sqlalchemy as sa
revision = "0016_teacher_help_cards"
down_revision = "0015_growth_profiles"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_teacher_help_card" in tables:
return
op.create_table(
"sys_teacher_help_card",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("summary_id", sa.BigInteger(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
sa.Column("copied_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_teacher_help_card_user_id", "sys_teacher_help_card", ["user_id"])
op.create_index("ix_sys_teacher_help_card_summary_id", "sys_teacher_help_card", ["summary_id"])
op.create_index("ix_sys_teacher_help_card_user_created", "sys_teacher_help_card", ["user_id", "created_at"])
op.create_index("ix_sys_teacher_help_card_topic_created", "sys_teacher_help_card", ["topic_session_id", "created_at"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_teacher_help_card" not in tables:
return
op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_summary_id", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_id", table_name="sys_teacher_help_card")
op.drop_table("sys_teacher_help_card")

View File

@@ -16,12 +16,13 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary
from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
from app.api.pagination import page_result
from app.services.question_insight_service import QuestionInsightService
from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
router = APIRouter()
@@ -138,6 +139,16 @@ def chat_detail(
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all()
topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics]
help_cards = []
if topic_ids:
help_cards = list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.topic_session_id.in_(topic_ids))
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
)
)
return api_success(
{
"session": _chat_row_dict(session, user),
@@ -150,6 +161,7 @@ def chat_detail(
),
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
"topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
}
)

View File

@@ -31,6 +31,7 @@ from app.services.chat_queue_runtime import (
from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService
from app.services.growth_profile_service import GrowthProfileService
from app.services.help_card_service import HelpCardService, help_card_dict
from app.services.reasoning_policy_service import ReasoningPolicyService
router = APIRouter()
@@ -96,6 +97,35 @@ def finish_topic(
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
@router.post("/session/{session_id}/help-card")
def generate_help_card(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
card = HelpCardService.generate_for_session(db, user=current_user, session=session)
return api_success(help_card_dict(card))
@router.get("/help-card/list")
def list_help_cards(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success([help_card_dict(card) for card in HelpCardService.list_user_cards(db, user=current_user, limit=limit)])
@router.post("/help-card/{card_id}/copied")
def mark_help_card_copied(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id)))
@router.post("/completions")
def completions(
payload: ChatCompletionRequest,

View File

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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
@@ -64,3 +64,21 @@ class GrowthProfileRevision(Base):
after_json: Mapped[str] = mapped_column(Text, nullable=False)
reason: Mapped[str] = mapped_column(String(50), default="topic_summary", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class TeacherHelpCard(Base):
__tablename__ = "sys_teacher_help_card"
__table_args__ = (
Index("ix_sys_teacher_help_card_user_created", "user_id", "created_at"),
Index("ix_sys_teacher_help_card_topic_created", "topic_session_id", "created_at"),
)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
topic_session_id: Mapped[int] = mapped_column(ForeignKey("sys_topic_session.id"), index=True, nullable=False)
summary_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(30), default="topic_summary", nullable=False)
copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession
from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
class HelpCardService:
@staticmethod
def generate_for_session(db: Session, *, user: User, session: ChatSession) -> TeacherHelpCard:
entitlement = EntitlementService.active_entitlement(db, user)
if not entitlement.allow_help_card:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前权益暂不支持生成老师求助卡")
topic = _latest_topic(db, user=user, session=session)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前会话还没有可生成求助卡的主题")
summary = GrowthProfileService.generate_topic_summary(db, user=user, topic=topic)
content = _render_help_card(user=user, topic=topic, summary=summary)
card = TeacherHelpCard(
user_id=user.id,
topic_session_id=topic.id,
summary_id=summary.id,
content=content,
source="topic_summary",
)
topic.help_card_generated = 1
db.add_all([topic, card])
db.commit()
db.refresh(card)
return card
@staticmethod
def list_user_cards(db: Session, *, user: User, limit: int = 20) -> list[TeacherHelpCard]:
return list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user.id)
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit)
)
)
@staticmethod
def mark_copied(db: Session, *, user: User, card_id: int) -> TeacherHelpCard:
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
card.copied = 1
card.copied_at = _now()
db.add(card)
db.commit()
db.refresh(card)
return card
def help_card_dict(card: TeacherHelpCard) -> dict:
return {
"id": card.id,
"userId": card.user_id,
"topicSessionId": card.topic_session_id,
"summaryId": card.summary_id,
"content": card.content,
"source": card.source,
"copied": bool(card.copied),
"copiedAt": card.copied_at,
"createdAt": card.created_at,
}
def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSession | None:
return db.scalar(
select(TopicSession)
.where(TopicSession.user_id == user.id, TopicSession.chat_session_id == session.id)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.limit(1)
)
def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary) -> str:
data = topic_summary_dict(summary)
return (
"【给老师的求助卡】\n"
"说明:这是我根据本次 AI 对话整理出的求助信息,请老师帮我确认方向。"
"我会按实际情况自行删改后再发送。\n\n"
f"学员:{user.name or user.nickname or user.phone}\n"
f"主题:{topic.title}\n"
f"主题时间:{_format_time(topic.started_at)} - {_format_time(topic.ended_at) if topic.ended_at else '进行中'}\n\n"
"1. 我遇到的问题\n"
f"{topic.core_question or '(请补充)'}\n\n"
"2. AI 已经帮我梳理出的重点\n"
f"{data.get('summary') or '(暂无摘要)'}\n\n"
"3. 我现在最明显的情绪 / 身体感受\n"
f"情绪:{data.get('emotions') or '(请补充)'}\n"
f"身体感受:{data.get('bodyFeelings') or '(请补充)'}\n\n"
"4. 我已经尝试过或被建议的功课\n"
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
"5. 我仍然卡住的地方\n"
f"{data.get('beliefs') or data.get('nextObservation') or '(请补充)'}\n\n"
"6. 我想请老师确认的问题\n"
"(请把最想确认的一两个问题写在这里)\n\n"
"备注:这张卡片不会自动发送给老师,也不代表已经转人工处理。"
)
def _format_time(value: datetime | None) -> str:
if value is None:
return "未知"
return value.strftime("%Y-%m-%d %H:%M")
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan
from app.models.growth import TeacherHelpCard
from app.models.user import User
from app.services.help_card_service import HelpCardService
def _db() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def test_generate_help_card_from_topic_summary_and_mark_copied():
with _db() as db:
user = User(id=1, phone="13800000001", name="测试学员", daily_chat_limit=100, daily_chat_used=0)
plan = EntitlementPlan(id=10, name="基础版", plan_type="basic", allow_help_card=1, status=1)
session = ChatSession(id=1, user_id=1, title="阴影人格", message_count=2, last_message_at=_now(), is_deleted=0)
topic = TopicSession(
id=1,
user_id=1,
chat_session_id=1,
title="阴影人格练习",
core_question="阴影人格练习步骤是否正确",
status="active",
message_count=2,
quota_deducted=1,
started_at=_now(),
)
db.add_all([user, plan, session, topic])
db.add_all(
[
ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我练习时身体抗拒。", created_at=_now()),
ChatMessage(id=2, session_id=1, topic_session_id=1, user_id=1, role="assistant", content="先回到身体感受,不急着分析。", created_at=_now()),
]
)
db.commit()
card = HelpCardService.generate_for_session(db, user=user, session=session)
assert "给老师的求助卡" in card.content
assert "不会自动发送给老师" in card.content
assert "阴影人格练习步骤是否正确" in card.content
assert db.get(TopicSession, 1).help_card_generated == 1
assert db.query(TeacherHelpCard).count() == 1
copied = HelpCardService.mark_copied(db, user=user, card_id=card.id)
assert copied.copied == 1
assert copied.copied_at is not None