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 1eceeae..eb9596a 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -1571,6 +1571,38 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
消息数{{ chatDetail.session.messageCount }}
+

主题会话

+ + +
+
+ 核心问题:{{ topic.coreQuestion }} + 消息数:{{ topic.messageCount }} + Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }} + 额度:{{ topic.quotaDeducted ? '已计入' : '不计入' }} + 开始:{{ topic.startedAt }} + 结束:{{ topic.endedAt || '-' }} +
+ + +
+
+
+ +

完整对话

; aiLogs: AiLogRecord[]; + topics?: TopicSessionRecord[]; +} + +export interface TopicSummaryRecord { + id: number; + topicSessionId: number; + userId: number; + summary: string; + mainEvents?: string | null; + emotions?: string | null; + bodyFeelings?: string | null; + beliefs?: string | null; + recommendedHomework?: string | null; + insights?: string | null; + nextObservation?: string | null; + modelName?: string | null; + status: string; + errorMessage?: string | null; + generatedAt: string; +} + +export interface TopicSessionRecord { + id: number; + userId: number; + chatSessionId: number; + title: string; + coreQuestion: string; + status: string; + messageCount: number; + tokenInput: number; + tokenOutput: number; + quotaDeducted: boolean; + recommendedHomework?: string | null; + startedAt: string; + endedAt?: string | null; + summary?: TopicSummaryRecord | null; } export interface QuestionInsightSummary { diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0015_growth_profiles.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0015_growth_profiles.py new file mode 100644 index 0000000..27908cd --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0015_growth_profiles.py @@ -0,0 +1,102 @@ +"""add topic summaries and growth profiles + +Revision ID: 0015_growth_profiles +Revises: 0014_entitlements_topics +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0015_growth_profiles" +down_revision = "0014_entitlements_topics" +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_topic_summary" not in tables: + op.create_table( + "sys_topic_summary", + sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True), + sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("main_events", sa.Text(), nullable=True), + sa.Column("emotions", sa.Text(), nullable=True), + sa.Column("body_feelings", sa.Text(), nullable=True), + sa.Column("beliefs", sa.Text(), nullable=True), + sa.Column("recommended_homework", sa.Text(), nullable=True), + sa.Column("insights", sa.Text(), nullable=True), + sa.Column("next_observation", sa.Text(), nullable=True), + sa.Column("model_name", sa.String(100), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="success"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("generated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"), + ) + op.create_index("ix_sys_topic_summary_topic_session_id", "sys_topic_summary", ["topic_session_id"]) + op.create_index("ix_sys_topic_summary_user_id", "sys_topic_summary", ["user_id"]) + op.create_index("ix_sys_topic_summary_status", "sys_topic_summary", ["status"]) + + if "sys_user_growth_profile" not in tables: + op.create_table( + "sys_user_growth_profile", + 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("profile_text", sa.Text(), nullable=False), + sa.Column("recurring_topics", sa.Text(), nullable=True), + sa.Column("common_emotions", sa.Text(), nullable=True), + sa.Column("body_patterns", sa.Text(), nullable=True), + sa.Column("relation_patterns", sa.Text(), nullable=True), + sa.Column("homework_done", sa.Text(), nullable=True), + sa.Column("effective_homework", sa.Text(), nullable=True), + sa.Column("recent_progress", sa.Text(), nullable=True), + sa.Column("last_topic_summary_id", sa.BigInteger(), nullable=True), + sa.Column("updated_by", sa.String(30), nullable=False, server_default="system"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"), + ) + op.create_index("ix_sys_user_growth_profile_user_id", "sys_user_growth_profile", ["user_id"]) + + if "sys_growth_profile_revision" not in tables: + op.create_table( + "sys_growth_profile_revision", + 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("profile_id", sa.BigInteger(), sa.ForeignKey("sys_user_growth_profile.id"), nullable=False), + sa.Column("topic_summary_id", sa.BigInteger(), nullable=True), + sa.Column("before_json", sa.Text(), nullable=True), + sa.Column("after_json", sa.Text(), nullable=False), + sa.Column("reason", sa.String(50), nullable=False, server_default="topic_summary"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_sys_growth_profile_revision_user_id", "sys_growth_profile_revision", ["user_id"]) + op.create_index("ix_sys_growth_profile_revision_profile_id", "sys_growth_profile_revision", ["profile_id"]) + op.create_index("ix_sys_growth_profile_revision_topic_summary_id", "sys_growth_profile_revision", ["topic_summary_id"]) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "sys_growth_profile_revision" in tables: + op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision") + op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision") + op.drop_index("ix_sys_growth_profile_revision_user_id", table_name="sys_growth_profile_revision") + op.drop_table("sys_growth_profile_revision") + if "sys_user_growth_profile" in tables: + op.drop_index("ix_sys_user_growth_profile_user_id", table_name="sys_user_growth_profile") + op.drop_table("sys_user_growth_profile") + if "sys_topic_summary" in tables: + op.drop_index("ix_sys_topic_summary_status", table_name="sys_topic_summary") + op.drop_index("ix_sys_topic_summary_user_id", table_name="sys_topic_summary") + op.drop_index("ix_sys_topic_summary_topic_session_id", table_name="sys_topic_summary") + op.drop_table("sys_topic_summary") 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 f75ddf6..8cc92c3 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 @@ -15,11 +15,13 @@ from app.core.database import get_db 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 +from app.models.chat import ChatMessage, ChatSession, TopicSession +from app.models.growth import 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 router = APIRouter() @@ -135,6 +137,7 @@ def chat_detail( .where(AiRequestLog.session_id == session_id) .order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc()) ).all() + topics = _topic_rows(db, session_id) return api_success( { "session": _chat_row_dict(session, user), @@ -146,6 +149,7 @@ def chat_detail( page_size=messagePageSize, ), "aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs], + "topics": topics, } ) @@ -317,6 +321,22 @@ def _message_dict(message: ChatMessage) -> dict: } +def _topic_rows(db: Session, session_id: int) -> list[dict]: + rows = db.execute( + select(TopicSession, TopicSummary) + .join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True) + .where(TopicSession.chat_session_id == session_id) + .order_by(TopicSession.started_at.asc(), TopicSession.id.asc()) + ).all() + return [ + { + **topic_dict(topic), + "summary": topic_summary_dict(summary) if summary else None, + } + for topic, summary in rows + ] + + def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict: data = { "id": log.id, 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 92b5a86..96a1c3f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -30,6 +30,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.reasoning_policy_service import ReasoningPolicyService router = APIRouter() @@ -85,6 +86,16 @@ def delete_session( return api_success() +@router.post("/session/{session_id}/topic/finish") +def finish_topic( + 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) + return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session)) + + @router.post("/completions") def completions( payload: ChatCompletionRequest, diff --git a/ai_knowledge_base_v2/apps/backend/app/api/user.py b/ai_knowledge_base_v2/apps/backend/app/api/user.py index 5e5018f..cc3fb9a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/user.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/user.py @@ -9,6 +9,7 @@ from app.core.responses import api_success from app.models.user import User from app.schemas.user import UserProfile from app.services.entitlement_service import EntitlementService, entitlement_dict +from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict from app.services.topic_session_service import TopicSessionService router = APIRouter() @@ -27,3 +28,28 @@ def profile( ) data["entitlement"] = entitlement_dict(view) return api_success(data) + + +@router.get("/growth-profile") +def growth_profile( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + profile = GrowthProfileService.get_growth_profile(db, current_user.id) + summaries = GrowthProfileService.recent_topic_summaries(db, current_user.id, limit=10) + return api_success( + { + "profile": growth_profile_dict(profile), + "recentSummaries": [ + { + "id": item.id, + "topicSessionId": item.topic_session_id, + "summary": item.summary, + "recommendedHomework": item.recommended_homework, + "nextObservation": item.next_observation, + "generatedAt": item.generated_at, + } + for item in summaries + ], + } + ) 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 168421d..c82e7b8 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -3,6 +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.knowledge import ( HumanAttentionHistory, HumanAttentionRecord, @@ -30,6 +31,7 @@ __all__ = [ "ChatMessage", "ChatSession", "EntitlementPlan", + "GrowthProfileRevision", "Knowledge", "KnowledgeCard", "KnowledgeChunk", @@ -49,11 +51,13 @@ __all__ = [ "LogRetentionPolicy", "StorageSnapshot", "TopicSession", + "TopicSummary", "Prompt", "Role", "SystemConfig", "User", "UserEntitlement", "UserEntitlementLog", + "UserGrowthProfile", "UserKnowledgePermission", ] diff --git a/ai_knowledge_base_v2/apps/backend/app/models/growth.py b/ai_knowledge_base_v2/apps/backend/app/models/growth.py new file mode 100644 index 0000000..3eb38e2 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/growth.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + +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"),) + + 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) + user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False) + summary: Mapped[str] = mapped_column(Text, nullable=False) + main_events: Mapped[str | None] = mapped_column(Text, nullable=True) + emotions: Mapped[str | None] = mapped_column(Text, nullable=True) + body_feelings: Mapped[str | None] = mapped_column(Text, nullable=True) + beliefs: Mapped[str | None] = mapped_column(Text, nullable=True) + recommended_homework: Mapped[str | None] = mapped_column(Text, nullable=True) + insights: Mapped[str | None] = mapped_column(Text, nullable=True) + next_observation: Mapped[str | None] = mapped_column(Text, 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) + error_message: Mapped[str | None] = mapped_column(Text, 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) + + +class UserGrowthProfile(Base): + __tablename__ = "sys_user_growth_profile" + __table_args__ = (UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"),) + + 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) + profile_text: Mapped[str] = mapped_column(Text, default="", nullable=False) + recurring_topics: Mapped[str | None] = mapped_column(Text, nullable=True) + common_emotions: Mapped[str | None] = mapped_column(Text, nullable=True) + body_patterns: Mapped[str | None] = mapped_column(Text, nullable=True) + relation_patterns: Mapped[str | None] = mapped_column(Text, nullable=True) + homework_done: Mapped[str | None] = mapped_column(Text, nullable=True) + effective_homework: Mapped[str | None] = mapped_column(Text, nullable=True) + recent_progress: Mapped[str | None] = mapped_column(Text, nullable=True) + last_topic_summary_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + updated_by: Mapped[str] = mapped_column(String(30), default="system", 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) + + +class GrowthProfileRevision(Base): + __tablename__ = "sys_growth_profile_revision" + + 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) + profile_id: Mapped[int] = mapped_column(ForeignKey("sys_user_growth_profile.id"), index=True, nullable=False) + topic_summary_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) + before_json: Mapped[str | None] = mapped_column(Text, nullable=True) + 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) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py index 3a3761c..7d71a7c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py @@ -13,6 +13,7 @@ from app.models.user import User from app.services.ai_request_log_service import AiRequestLogService from app.services.entitlement_service import EntitlementService from app.services.external_errors import ExternalServiceError +from app.services.growth_profile_service import GrowthProfileService from app.services.chat_context_service import ChatContextService from app.services.model_service import ModelClientService from app.services.rag_service import RagService @@ -124,6 +125,10 @@ class ChatService: summary_result = ChatContextService.update_summary(db, session, history) context_trace = [summary_result.trace] if summary_result.trace else None + growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None + if growth_context: + context_trace = list(context_trace or []) + context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) # 构建 prompt(传入历史 + 摘要) rag_result = RagService.build_result( @@ -132,6 +137,7 @@ class ChatService: session_summary=session.summary, summary_up_to_message_id=session.summary_up_to_message_id, context_trace=context_trace, + growth_context=growth_context, ) completion = ModelClientService.complete(db, rag_result) except ExternalServiceError as exc: diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py index c718e2b..c72eb3a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py @@ -18,6 +18,7 @@ from app.services.chat_service import ChatService, _title_from_question from app.services.chat_context_service import ChatContextService from app.services.entitlement_service import EntitlementService from app.services.external_errors import ExternalServiceError +from app.services.growth_profile_service import GrowthProfileService from app.services.human_attention_service import HumanAttentionService from app.services.model_stream_service import ModelStreamService from app.services.rag_async_service import AsyncRagService @@ -73,6 +74,10 @@ class ChatStreamService: ) summary_result = ChatContextService.update_summary(db, session, history) context_trace = [summary_result.trace] if summary_result.trace else None + growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None + if growth_context: + context_trace = list(context_trace or []) + context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) started_at = perf_counter() rag_result = None @@ -88,6 +93,7 @@ class ChatStreamService: getattr(session, "summary", None), getattr(session, "summary_up_to_message_id", None), context_trace, + growth_context, ) model_response = ModelStreamService.stream(db, rag_result) for chunk in model_response.chunks: @@ -229,6 +235,10 @@ class ChatStreamService: ) summary_result = await ChatContextService.update_summary_async(db, session, history) context_trace = [summary_result.trace] if summary_result.trace else None + growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None + if growth_context: + context_trace = list(context_trace or []) + context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) started_at = perf_counter() rag_result = None @@ -245,6 +255,7 @@ class ChatStreamService: summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None), session_id=session.id, context_trace=context_trace, + growth_context=growth_context, ) model_response = ModelStreamService.stream_async(db, rag_result) async for chunk in model_response.chunks: @@ -425,6 +436,7 @@ def _build_rag_result( summary: str | None, summary_up_to_message_id: int | None, context_trace: list[dict] | None = None, + growth_context: str | None = None, ): parameters = signature(RagService.build_result).parameters if "history" in parameters: @@ -436,5 +448,6 @@ def _build_rag_result( session_summary=summary, summary_up_to_message_id=summary_up_to_message_id, context_trace=context_trace, + growth_context=growth_context, ) return RagService.build_result(db, user, question) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/growth_profile_service.py b/ai_knowledge_base_v2/apps/backend/app/services/growth_profile_service.py new file mode 100644 index 0000000..d6296a2 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/growth_profile_service.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import re +from datetime import UTC, datetime +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +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.model_service import ModelClientService +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) + 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.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, + } + + @staticmethod + def generate_topic_summary(db: Session, *, user: User, topic: TopicSession, force: bool = False) -> TopicSummary: + existing = db.scalar(select(TopicSummary).where(TopicSummary.topic_session_id == topic.id)) + if existing is not None and not force: + return existing + messages = list( + db.scalars( + select(ChatMessage) + .where(ChatMessage.topic_session_id == topic.id, ChatMessage.user_id == user.id) + .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc()) + ) + ) + if not messages: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前主题还没有可沉淀的对话内容") + conversation = _messages_text(messages) + prompt = _topic_summary_prompt(topic, conversation) + model_name = _enabled_model_name(db) + try: + raw = ModelClientService.generate_text_or_raise(db, prompt) + parsed = _parse_summary_json(raw) + data = parsed if parsed and "summary" in parsed else _fallback_summary(topic, conversation, raw) + status_value = "success" + error_message = None + except ExternalServiceError as exc: + data = _fallback_summary(topic, conversation, "") + status_value = "fallback" + error_message = str(exc) + + if existing is None: + existing = TopicSummary(topic_session_id=topic.id, user_id=user.id) + _apply_summary(existing, data) + existing.model_name = model_name + existing.status = status_value + existing.error_message = error_message + existing.generated_at = _now() + db.add(existing) + db.flush() + return existing + + @staticmethod + def update_growth_profile(db: Session, *, user: User, topic_summary: TopicSummary) -> UserGrowthProfile: + profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id)) + before = growth_profile_dict(profile) if profile is not None else None + if profile is None: + profile = UserGrowthProfile(user_id=user.id, profile_text="") + db.add(profile) + db.flush() + + prompt = _growth_profile_prompt(profile, topic_summary) + try: + raw = ModelClientService.generate_text_or_raise(db, prompt) + parsed = _parse_summary_json(raw) + data = parsed if parsed and "profileText" in parsed else _fallback_profile(profile, topic_summary, raw) + except ExternalServiceError: + data = _fallback_profile(profile, topic_summary, "") + + _apply_profile(profile, data) + profile.last_topic_summary_id = topic_summary.id + profile.updated_by = "system" + db.add(profile) + db.flush() + after = growth_profile_dict(profile) + db.add( + GrowthProfileRevision( + user_id=user.id, + profile_id=profile.id, + topic_summary_id=topic_summary.id, + before_json=json.dumps(before, ensure_ascii=False, default=str) if before else None, + after_json=json.dumps(after, ensure_ascii=False, default=str), + reason="topic_summary", + ) + ) + return profile + + @staticmethod + def get_growth_profile(db: Session, user_id: int) -> UserGrowthProfile | None: + return db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user_id)) + + @staticmethod + def recent_topic_summaries(db: Session, user_id: int, *, limit: int = 10) -> list[TopicSummary]: + return list( + db.scalars( + select(TopicSummary) + .where(TopicSummary.user_id == user_id) + .order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc()) + .limit(limit) + ) + ) + + @staticmethod + def prompt_context(db: Session, user: User) -> str | None: + profile = GrowthProfileService.get_growth_profile(db, user.id) + if profile is None or not profile.profile_text.strip(): + return None + return ( + "[长期成长档案]\n" + "以下是用户跨主题沉淀出的简要成长档案,只能用于理解用户长期模式和延续陪伴," + "不能替代本轮可靠知识,也不能臆造课程内容。\n" + f"{_limit(profile.profile_text, 1800)}" + ) + + +def topic_dict(topic: TopicSession) -> dict: + return { + "id": topic.id, + "userId": topic.user_id, + "chatSessionId": topic.chat_session_id, + "title": topic.title, + "coreQuestion": topic.core_question, + "status": topic.status, + "messageCount": topic.message_count, + "tokenInput": topic.token_input, + "tokenOutput": topic.token_output, + "quotaDeducted": bool(topic.quota_deducted), + "recommendedHomework": topic.recommended_homework, + "startedAt": topic.started_at, + "endedAt": topic.ended_at, + "createdAt": topic.created_at, + "updatedAt": topic.updated_at, + } + + +def topic_summary_dict(summary: TopicSummary) -> dict: + return { + "id": summary.id, + "topicSessionId": summary.topic_session_id, + "userId": summary.user_id, + "summary": summary.summary, + "mainEvents": summary.main_events, + "emotions": summary.emotions, + "bodyFeelings": summary.body_feelings, + "beliefs": summary.beliefs, + "recommendedHomework": summary.recommended_homework, + "insights": summary.insights, + "nextObservation": summary.next_observation, + "modelName": summary.model_name, + "status": summary.status, + "errorMessage": summary.error_message, + "generatedAt": summary.generated_at, + } + + +def growth_profile_dict(profile: UserGrowthProfile | None) -> dict | None: + if profile is None: + return None + return { + "id": profile.id, + "userId": profile.user_id, + "profileText": profile.profile_text, + "recurringTopics": profile.recurring_topics, + "commonEmotions": profile.common_emotions, + "bodyPatterns": profile.body_patterns, + "relationPatterns": profile.relation_patterns, + "homeworkDone": profile.homework_done, + "effectiveHomework": profile.effective_homework, + "recentProgress": profile.recent_progress, + "lastTopicSummaryId": profile.last_topic_summary_id, + "updatedBy": profile.updated_by, + "createdAt": profile.created_at, + "updatedAt": profile.updated_at, + } + + +def _apply_summary(summary: TopicSummary, data: dict[str, Any]) -> None: + summary.summary = _field(data, "summary", "本主题已沉淀。", 4000) + summary.main_events = _field(data, "mainEvents", "", 2000) or None + summary.emotions = _field(data, "emotions", "", 1000) or None + summary.body_feelings = _field(data, "bodyFeelings", "", 1000) or None + summary.beliefs = _field(data, "beliefs", "", 1000) or None + summary.recommended_homework = _field(data, "recommendedHomework", "", 1500) or None + summary.insights = _field(data, "insights", "", 1500) or None + summary.next_observation = _field(data, "nextObservation", "", 1500) or None + + +def _apply_profile(profile: UserGrowthProfile, data: dict[str, Any]) -> None: + profile.profile_text = _field(data, "profileText", profile.profile_text or "暂无成长档案。", 5000) + profile.recurring_topics = _field(data, "recurringTopics", "", 2000) or profile.recurring_topics + profile.common_emotions = _field(data, "commonEmotions", "", 1500) or profile.common_emotions + profile.body_patterns = _field(data, "bodyPatterns", "", 1500) or profile.body_patterns + profile.relation_patterns = _field(data, "relationPatterns", "", 1500) or profile.relation_patterns + profile.homework_done = _field(data, "homeworkDone", "", 1500) or profile.homework_done + profile.effective_homework = _field(data, "effectiveHomework", "", 1500) or profile.effective_homework + profile.recent_progress = _field(data, "recentProgress", "", 1500) or profile.recent_progress + + +def _topic_summary_prompt(topic: TopicSession, conversation: str) -> str: + return ( + "你是大本营千问千答的主题沉淀助手。请把一个主题会话总结为结构化 JSON," + "只依据对话内容,不要扩展课程知识,不要评价用户人格。输出字段:" + "summary, mainEvents, emotions, bodyFeelings, beliefs, recommendedHomework, insights, nextObservation。" + "内容要偏“回到当下、回到自身、觉察情绪和感受、如是释放”,不要给很多术层面的建议。\n\n" + f"主题标题:{topic.title}\n核心问题:{topic.core_question}\n\n对话:\n{_limit(conversation, 12000)}" + ) + + +def _growth_profile_prompt(profile: UserGrowthProfile, summary: TopicSummary) -> str: + current = profile.profile_text or "暂无" + return ( + "你是大本营千问千答的长期成长档案整理助手。请根据旧档案和最新主题摘要," + "滚动更新一份结构化 JSON。只保留稳定模式和最近进展,避免堆叠流水账。" + "输出字段:profileText, recurringTopics, commonEmotions, bodyPatterns, relationPatterns, " + "homeworkDone, effectiveHomework, recentProgress。语气客观、温和,不诊断、不贴标签。\n\n" + f"旧档案:\n{_limit(current, 5000)}\n\n最新主题摘要:\n{json.dumps(topic_summary_dict(summary), ensure_ascii=False, default=str)}" + ) + + +def _fallback_summary(topic: TopicSession, conversation: str, raw: str) -> dict[str, str]: + source = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else conversation + user_lines = [line for line in source.splitlines() if line.startswith("用户:")] + assistant_lines = [line for line in source.splitlines() if line.startswith("大本营答疑助手:")] + first_question = user_lines[0].removeprefix("用户:").strip() if user_lines else topic.core_question + last_answer = assistant_lines[-1].removeprefix("大本营答疑助手:").strip() if assistant_lines else "" + return { + "summary": _limit(f"用户围绕“{first_question}”进行了提问,本主题的主要回答和沉淀为:{last_answer}", 1800), + "mainEvents": _limit(first_question, 800), + "emotions": _join_markers(source, ("害怕", "委屈", "愤怒", "抗拒", "焦虑", "担心", "难受")), + "bodyFeelings": _join_markers(source, ("身体", "胸", "心口", "头", "胃", "麻", "胀", "紧", "堵")), + "beliefs": _join_markers(source, ("必须", "应该", "不能", "总是", "一定", "不配")), + "recommendedHomework": _join_markers(source, ("静心", "觉察", "功课", "练习", "释放", "内省")), + "insights": _limit(last_answer, 1000), + "nextObservation": "下次继续从当下最明显的身体感受、情绪和自动念头开始观察。", + } + + +def _fallback_profile(profile: UserGrowthProfile, summary: TopicSummary, raw: str) -> dict[str, str]: + previous = profile.profile_text.strip() + addition = raw.strip() if raw.strip() and not raw.strip().startswith("你是大本营") else summary.summary + text = "\n\n".join(part for part in [previous, f"最近主题:{addition}"] if part) + return { + "profileText": _limit(text or "暂无成长档案。", 4500), + "recurringTopics": summary.main_events or "", + "commonEmotions": summary.emotions or "", + "bodyPatterns": summary.body_feelings or "", + "relationPatterns": summary.beliefs or "", + "homeworkDone": summary.recommended_homework or "", + "effectiveHomework": summary.recommended_homework or "", + "recentProgress": summary.insights or summary.summary, + } + + +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 + + +def _messages_text(messages: list[ChatMessage]) -> str: + labels = {"user": "用户", "assistant": "大本营答疑助手"} + return "\n".join(f"{labels.get(message.role, message.role)}:{message.content}" for message in messages) + + +def _field(data: dict[str, Any], key: str, default: str, max_len: int) -> str: + value = data.get(key, default) + if isinstance(value, list): + value = ";".join(str(item) for item in value if str(item).strip()) + if not isinstance(value, str): + value = str(value) + return _limit(value.strip(), max_len) + + +def _join_markers(text: str, markers: tuple[str, ...]) -> str: + found = [marker for marker in markers if marker in text] + return ";".join(found) + + +def _limit(text: str, max_len: int) -> str: + return text[:max_len].strip() + + +def _enabled_model_name(db: Session) -> str | None: + model = ModelClientService._get_enabled_model(db) + return model.model_name if model is not None else None + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py index 8e954b7..27cdb73 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_agent_service.py @@ -85,6 +85,7 @@ class KnowledgeAgentService: context_trace: list[dict] | None = None, prompt_override: str | None = None, response_depth: int | None = None, + growth_context: str | None = None, ) -> RagResult: started = perf_counter() catalog = cls.get_knowledge_catalog( @@ -209,6 +210,7 @@ class KnowledgeAgentService: summary_up_to_message_id, prompt_override=prompt_override, response_depth=response_depth, + growth_context=growth_context, ) return RagResult( question=question, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index 0853224..5658dbd 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -73,6 +73,16 @@ class ModelClientService: raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model") return _generate_summary(model, messages_text, previous_summary) + @staticmethod + def generate_text_or_raise(db: Session, prompt: str) -> str: + model = ModelClientService._get_enabled_model(db) + if _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled): + return prompt.strip()[-1200:] + if model is None: + raise ExternalServiceError("未启用可用于生成文本的模型", provider="model") + rag_result = RagResult(question=prompt, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True) + return _call_configured_model(model, rag_result, allow_no_hit=True) + @staticmethod async def summarize_or_raise_async( db: Session, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/rag_async_service.py b/ai_knowledge_base_v2/apps/backend/app/services/rag_async_service.py index 8200729..618492e 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/rag_async_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/rag_async_service.py @@ -19,6 +19,7 @@ class AsyncRagService: summary_up_to_message_id: int | None = None, session_id: int | None = None, context_trace: list[dict] | None = None, + growth_context: str | None = None, ) -> RagResult: return await KnowledgeAgentService.build_result( db, @@ -29,4 +30,5 @@ class AsyncRagService: session_id=session_id, user_id=user.id, context_trace=context_trace, + growth_context=growth_context, ) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py index 6875e30..cd32113 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py @@ -62,6 +62,7 @@ class RagService: session_summary: str | None = None, summary_up_to_message_id: int | None = None, context_trace: list[dict] | None = None, + growth_context: str | None = None, ) -> RagResult: scopes = KnowledgeAccessService.get_allowed_knowledge(db, user) chunks = FeishuKnowledgeService.retrieve(question, scopes, db) @@ -72,6 +73,7 @@ class RagService: history, session_summary, summary_up_to_message_id, + growth_context=growth_context, ) prompt = PromptService.render_messages(messages) return RagResult( @@ -98,6 +100,7 @@ class PromptService: history: list[ChatMessage] | None = None, session_summary: str | None = None, summary_up_to_message_id: int | None = None, + growth_context: str | None = None, ) -> str: return cls.render_messages( cls.build_messages( @@ -107,6 +110,7 @@ class PromptService: history, session_summary, summary_up_to_message_id, + growth_context=growth_context, ) ) @@ -121,6 +125,7 @@ class PromptService: summary_up_to_message_id: int | None = None, prompt_override: str | None = None, response_depth: int | None = None, + growth_context: str | None = None, ) -> list[dict[str, str]]: prompt = prompt_override.strip() if prompt_override and prompt_override.strip() else cls._load_active_prompt(db) @@ -150,6 +155,8 @@ class PromptService: ) if visible_summary: messages.append({"role": "system", "content": f"[历史对话摘要]\n{visible_summary}"}) + if growth_context and growth_context.strip(): + messages.append({"role": "system", "content": growth_context.strip()}) for message in recent_history: content = cls._clean_history_content(message.content) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_growth_profiles.py b/ai_knowledge_base_v2/apps/backend/tests/test_growth_profiles.py new file mode 100644 index 0000000..bdd3f06 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_growth_profiles.py @@ -0,0 +1,86 @@ +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.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.user import User +from app.services.entitlement_service import EntitlementService +from app.services.growth_profile_service import GrowthProfileService +from app.services.rag_service import PromptService + + +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_finish_topic_generates_summary_and_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)) + 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=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, 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() + EntitlementService.assign_user_plan(db, user=user, plan_id=10, operated_by=1) + db.commit() + + 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 db.query(UserGrowthProfile).filter_by(user_id=1).count() == 1 + assert db.query(GrowthProfileRevision).filter_by(user_id=1).count() == 1 + + +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) + db.add(user) + db.add(UserGrowthProfile(user_id=1, profile_text="用户最近反复在表达障碍和身体紧绷之间观察。")) + db.commit() + + growth_context = GrowthProfileService.prompt_context(db, user) + messages = PromptService.build_messages( + db, + "我现在又表达不出来了怎么办", + [], + growth_context=growth_context, + ) + + content = "\n".join(item["content"] for item in messages) + assert "[长期成长档案]" in content + assert "表达障碍" in content + assert "[本轮可靠知识上下文]" in content 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 d5a5a2c..65e76bb 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, UserProfile } from "./types/api"; +import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, UserProfile } from "./types/api"; const user = ref(null); const sessions = ref([]); @@ -22,6 +22,10 @@ const loadingSession = ref(false); const loadingSessions = ref(false); const sessionOperationPending = ref(false); const logoutDialogOpen = ref(false); +const profileDialogOpen = ref(false); +const growthProfile = ref(null); +const profileLoading = ref(false); +const finishingTopic = ref(false); const statusText = ref("连接后端中"); const toastText = ref(""); const followingOutput = ref(true); @@ -195,6 +199,33 @@ async function stop() { } } +async function finishCurrentTopic() { + if (!activeSessionId.value || sending.value || finishingTopic.value) return; + finishingTopic.value = true; + try { + const result = await api.finishTopic(activeSessionId.value); + showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了你的成长档案" : "本主题已沉淀"); + await refreshSessionList(); + await refreshProfile(); + } catch (error) { + handleError(error, "主题沉淀失败"); + } finally { + finishingTopic.value = false; + } +} + +async function openGrowthProfile() { + profileDialogOpen.value = true; + profileLoading.value = true; + try { + growthProfile.value = await api.growthProfile(); + } catch (error) { + handleError(error, "成长档案加载失败"); + } finally { + profileLoading.value = false; + } +} + async function renameSession(sessionId: number, title: string, done: (success: boolean) => void) { if (sessionOperationPending.value) return; sessionOperationPending.value = true; @@ -304,7 +335,14 @@ function showToast(message: string) {