From d8fde93b69ffa265d5e59bcaeceb2f97c3938ec5 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 15:53:47 +0800 Subject: [PATCH] feat: add teacher help cards --- .../apps/admin-web/src/App.vue | 19 +++ .../apps/admin-web/src/types/api.ts | 13 ++ .../versions/0016_teacher_help_cards.py | 51 ++++++++ .../apps/backend/app/api/admin_records.py | 14 +- .../apps/backend/app/api/chat.py | 30 +++++ .../apps/backend/app/models/__init__.py | 3 +- .../apps/backend/app/models/growth.py | 20 ++- .../backend/app/services/help_card_service.py | 123 ++++++++++++++++++ .../apps/backend/tests/test_help_cards.py | 64 +++++++++ .../apps/user-client/src/App.vue | 70 +++++++++- .../src/components/SessionQuota.vue | 11 ++ .../apps/user-client/src/services/api.ts | 4 + .../apps/user-client/src/styles.css | 33 +++++ .../apps/user-client/src/types/api.ts | 12 ++ .../docs/qianwen_product_todo.md | 4 + 15 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0016_teacher_help_cards.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/help_card_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index eb9596a..b1eb82e 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -1603,6 +1603,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { +

老师求助卡

+ + +
+
+ 生成:{{ card.createdAt }} + 复制:{{ card.copiedAt || '-' }} + 来源:{{ card.source }} +
+
{{ card.content }}
+
+
+
+ +

完整对话

; aiLogs: AiLogRecord[]; topics?: TopicSessionRecord[]; + helpCards?: TeacherHelpCardRecord[]; +} + +export interface TeacherHelpCardRecord { + id: number; + userId: number; + topicSessionId: number; + summaryId?: number | null; + content: string; + source: string; + copied: boolean; + copiedAt?: string | null; + createdAt: string; } export interface TopicSummaryRecord { diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0016_teacher_help_cards.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0016_teacher_help_cards.py new file mode 100644 index 0000000..f025722 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0016_teacher_help_cards.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py index 8cc92c3..a9b3630 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py @@ -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], } ) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py index 96a1c3f..2293c0c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -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, diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index c82e7b8..f5335ca 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -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", diff --git a/ai_knowledge_base_v2/apps/backend/app/models/growth.py b/ai_knowledge_base_v2/apps/backend/app/models/growth.py index 3eb38e2..3149c80 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/growth.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/growth.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/help_card_service.py b/ai_knowledge_base_v2/apps/backend/app/services/help_card_service.py new file mode 100644 index 0000000..56cf855 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/help_card_service.py @@ -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) + diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py b/ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py new file mode 100644 index 0000000..14f6265 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py @@ -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 + diff --git a/ai_knowledge_base_v2/apps/user-client/src/App.vue b/ai_knowledge_base_v2/apps/user-client/src/App.vue index 65e76bb..eda53ff 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/App.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/App.vue @@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue"; import SessionDrawer from "./components/SessionDrawer.vue"; import SessionQuota from "./components/SessionQuota.vue"; import { ApiError, api, clearToken, getToken, streamChat } from "./services/api"; -import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, UserProfile } from "./types/api"; +import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, TeacherHelpCard, UserProfile } from "./types/api"; const user = ref(null); const sessions = ref([]); @@ -23,9 +23,13 @@ const loadingSessions = ref(false); const sessionOperationPending = ref(false); const logoutDialogOpen = ref(false); const profileDialogOpen = ref(false); +const helpCardDialogOpen = ref(false); const growthProfile = ref(null); +const helpCard = ref(null); +const helpCardContent = ref(""); const profileLoading = ref(false); const finishingTopic = ref(false); +const generatingHelpCard = ref(false); const statusText = ref("连接后端中"); const toastText = ref(""); const followingOutput = ref(true); @@ -214,6 +218,39 @@ async function finishCurrentTopic() { } } +async function generateHelpCard() { + if (!activeSessionId.value || sending.value || generatingHelpCard.value) return; + generatingHelpCard.value = true; + try { + const result = await api.generateHelpCard(activeSessionId.value); + helpCard.value = result; + helpCardContent.value = result.content; + helpCardDialogOpen.value = true; + showToast("求助卡已生成,可编辑后复制给老师"); + await refreshProfile(); + } catch (error) { + handleError(error, "求助卡生成失败"); + } finally { + generatingHelpCard.value = false; + } +} + +async function copyHelpCard() { + if (!helpCardContent.value.trim()) { + showToast("求助卡内容为空"); + return; + } + try { + await copyText(helpCardContent.value); + if (helpCard.value) { + helpCard.value = await api.markHelpCardCopied(helpCard.value.id); + } + showToast("已复制,可粘贴给老师或班级群"); + } catch (error) { + handleError(error, "复制失败,请手动选择文本复制"); + } +} + async function openGrowthProfile() { profileDialogOpen.value = true; profileLoading.value = true; @@ -326,6 +363,22 @@ function showToast(message: string) { toastTimer = null; }, 3200); } + +async function copyText(text: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +}