feat: add topic summaries and growth profiles
This commit is contained in:
@@ -1571,6 +1571,38 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<div><span>消息数</span><strong>{{ chatDetail.session.messageCount }}</strong></div>
|
<div><span>消息数</span><strong>{{ chatDetail.session.messageCount }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<h3 class="detail-title">主题会话</h3>
|
||||||
|
<el-collapse v-if="chatDetail.topics?.length" class="topic-summary-collapse">
|
||||||
|
<el-collapse-item
|
||||||
|
v-for="topic in chatDetail.topics"
|
||||||
|
:key="topic.id"
|
||||||
|
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
|
||||||
|
>
|
||||||
|
<section class="topic-summary-card">
|
||||||
|
<div class="topic-summary-meta">
|
||||||
|
<span>核心问题:{{ topic.coreQuestion }}</span>
|
||||||
|
<span>消息数:{{ topic.messageCount }}</span>
|
||||||
|
<span>Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span>
|
||||||
|
<span>额度:{{ topic.quotaDeducted ? '已计入' : '不计入' }}</span>
|
||||||
|
<span>开始:{{ topic.startedAt }}</span>
|
||||||
|
<span>结束:{{ topic.endedAt || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="topic.summary">
|
||||||
|
<pre>{{ topic.summary.summary }}</pre>
|
||||||
|
<div class="topic-summary-fields">
|
||||||
|
<p v-if="topic.summary.mainEvents"><strong>主要事件</strong>{{ topic.summary.mainEvents }}</p>
|
||||||
|
<p v-if="topic.summary.emotions"><strong>情绪</strong>{{ topic.summary.emotions }}</p>
|
||||||
|
<p v-if="topic.summary.bodyFeelings"><strong>身体感受</strong>{{ topic.summary.bodyFeelings }}</p>
|
||||||
|
<p v-if="topic.summary.recommendedHomework"><strong>推荐功课</strong>{{ topic.summary.recommendedHomework }}</p>
|
||||||
|
<p v-if="topic.summary.nextObservation"><strong>下一步观察</strong>{{ topic.summary.nextObservation }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else description="该主题尚未沉淀摘要" :image-size="60" />
|
||||||
|
</section>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
|
||||||
|
|
||||||
<h3 class="detail-title">完整对话</h3>
|
<h3 class="detail-title">完整对话</h3>
|
||||||
<AdminPagination
|
<AdminPagination
|
||||||
class="chat-detail-pagination top"
|
class="chat-detail-pagination top"
|
||||||
|
|||||||
@@ -2042,6 +2042,64 @@ textarea {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topic-summary-collapse {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
color: #667a73;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-meta span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-card pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #dfe8e5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fbfa;
|
||||||
|
color: #34453f;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-fields p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f6f8f7;
|
||||||
|
color: #53645e;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-summary-fields strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #203b32;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -396,6 +396,42 @@ export interface ChatDetail {
|
|||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
messagesPage?: PageResult<ChatMessageRecord>;
|
messagesPage?: PageResult<ChatMessageRecord>;
|
||||||
aiLogs: AiLogRecord[];
|
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 {
|
export interface QuestionInsightSummary {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -15,11 +15,13 @@ from app.core.database import get_db
|
|||||||
from app.core.dependencies import get_current_admin
|
from app.core.dependencies import get_current_admin
|
||||||
from app.core.responses import api_success
|
from app.core.responses import api_success
|
||||||
from app.models.admin import Admin
|
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.logs import AiRequestLog, OperationLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.api.pagination import page_result
|
from app.api.pagination import page_result
|
||||||
from app.services.question_insight_service import QuestionInsightService
|
from app.services.question_insight_service import QuestionInsightService
|
||||||
|
from app.services.growth_profile_service import topic_dict, topic_summary_dict
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ def chat_detail(
|
|||||||
.where(AiRequestLog.session_id == session_id)
|
.where(AiRequestLog.session_id == session_id)
|
||||||
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
|
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
|
||||||
).all()
|
).all()
|
||||||
|
topics = _topic_rows(db, session_id)
|
||||||
return api_success(
|
return api_success(
|
||||||
{
|
{
|
||||||
"session": _chat_row_dict(session, user),
|
"session": _chat_row_dict(session, user),
|
||||||
@@ -146,6 +149,7 @@ def chat_detail(
|
|||||||
page_size=messagePageSize,
|
page_size=messagePageSize,
|
||||||
),
|
),
|
||||||
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
|
"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:
|
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
|
||||||
data = {
|
data = {
|
||||||
"id": log.id,
|
"id": log.id,
|
||||||
|
|||||||
@@ -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_queue_service import load_chat_queue_config
|
||||||
from app.services.chat_stream_service import ChatStreamService
|
from app.services.chat_stream_service import ChatStreamService
|
||||||
|
from app.services.growth_profile_service import GrowthProfileService
|
||||||
from app.services.reasoning_policy_service import ReasoningPolicyService
|
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -85,6 +86,16 @@ def delete_session(
|
|||||||
return api_success()
|
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")
|
@router.post("/completions")
|
||||||
def completions(
|
def completions(
|
||||||
payload: ChatCompletionRequest,
|
payload: ChatCompletionRequest,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.core.responses import api_success
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserProfile
|
from app.schemas.user import UserProfile
|
||||||
from app.services.entitlement_service import EntitlementService, entitlement_dict
|
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
|
from app.services.topic_session_service import TopicSessionService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -27,3 +28,28 @@ def profile(
|
|||||||
)
|
)
|
||||||
data["entitlement"] = entitlement_dict(view)
|
data["entitlement"] = entitlement_dict(view)
|
||||||
return api_success(data)
|
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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
|||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||||
|
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
|
||||||
from app.models.knowledge import (
|
from app.models.knowledge import (
|
||||||
HumanAttentionHistory,
|
HumanAttentionHistory,
|
||||||
HumanAttentionRecord,
|
HumanAttentionRecord,
|
||||||
@@ -30,6 +31,7 @@ __all__ = [
|
|||||||
"ChatMessage",
|
"ChatMessage",
|
||||||
"ChatSession",
|
"ChatSession",
|
||||||
"EntitlementPlan",
|
"EntitlementPlan",
|
||||||
|
"GrowthProfileRevision",
|
||||||
"Knowledge",
|
"Knowledge",
|
||||||
"KnowledgeCard",
|
"KnowledgeCard",
|
||||||
"KnowledgeChunk",
|
"KnowledgeChunk",
|
||||||
@@ -49,11 +51,13 @@ __all__ = [
|
|||||||
"LogRetentionPolicy",
|
"LogRetentionPolicy",
|
||||||
"StorageSnapshot",
|
"StorageSnapshot",
|
||||||
"TopicSession",
|
"TopicSession",
|
||||||
|
"TopicSummary",
|
||||||
"Prompt",
|
"Prompt",
|
||||||
"Role",
|
"Role",
|
||||||
"SystemConfig",
|
"SystemConfig",
|
||||||
"User",
|
"User",
|
||||||
"UserEntitlement",
|
"UserEntitlement",
|
||||||
"UserEntitlementLog",
|
"UserEntitlementLog",
|
||||||
|
"UserGrowthProfile",
|
||||||
"UserKnowledgePermission",
|
"UserKnowledgePermission",
|
||||||
]
|
]
|
||||||
|
|||||||
66
ai_knowledge_base_v2/apps/backend/app/models/growth.py
Normal file
66
ai_knowledge_base_v2/apps/backend/app/models/growth.py
Normal file
@@ -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)
|
||||||
@@ -13,6 +13,7 @@ from app.models.user import User
|
|||||||
from app.services.ai_request_log_service import AiRequestLogService
|
from app.services.ai_request_log_service import AiRequestLogService
|
||||||
from app.services.entitlement_service import EntitlementService
|
from app.services.entitlement_service import EntitlementService
|
||||||
from app.services.external_errors import ExternalServiceError
|
from app.services.external_errors import ExternalServiceError
|
||||||
|
from app.services.growth_profile_service import GrowthProfileService
|
||||||
from app.services.chat_context_service import ChatContextService
|
from app.services.chat_context_service import ChatContextService
|
||||||
from app.services.model_service import ModelClientService
|
from app.services.model_service import ModelClientService
|
||||||
from app.services.rag_service import RagService
|
from app.services.rag_service import RagService
|
||||||
@@ -124,6 +125,10 @@ class ChatService:
|
|||||||
|
|
||||||
summary_result = ChatContextService.update_summary(db, session, history)
|
summary_result = ChatContextService.update_summary(db, session, history)
|
||||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
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(传入历史 + 摘要)
|
# 构建 prompt(传入历史 + 摘要)
|
||||||
rag_result = RagService.build_result(
|
rag_result = RagService.build_result(
|
||||||
@@ -132,6 +137,7 @@ class ChatService:
|
|||||||
session_summary=session.summary,
|
session_summary=session.summary,
|
||||||
summary_up_to_message_id=session.summary_up_to_message_id,
|
summary_up_to_message_id=session.summary_up_to_message_id,
|
||||||
context_trace=context_trace,
|
context_trace=context_trace,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
completion = ModelClientService.complete(db, rag_result)
|
completion = ModelClientService.complete(db, rag_result)
|
||||||
except ExternalServiceError as exc:
|
except ExternalServiceError as exc:
|
||||||
|
|||||||
@@ -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.chat_context_service import ChatContextService
|
||||||
from app.services.entitlement_service import EntitlementService
|
from app.services.entitlement_service import EntitlementService
|
||||||
from app.services.external_errors import ExternalServiceError
|
from app.services.external_errors import ExternalServiceError
|
||||||
|
from app.services.growth_profile_service import GrowthProfileService
|
||||||
from app.services.human_attention_service import HumanAttentionService
|
from app.services.human_attention_service import HumanAttentionService
|
||||||
from app.services.model_stream_service import ModelStreamService
|
from app.services.model_stream_service import ModelStreamService
|
||||||
from app.services.rag_async_service import AsyncRagService
|
from app.services.rag_async_service import AsyncRagService
|
||||||
@@ -73,6 +74,10 @@ class ChatStreamService:
|
|||||||
)
|
)
|
||||||
summary_result = ChatContextService.update_summary(db, session, history)
|
summary_result = ChatContextService.update_summary(db, session, history)
|
||||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
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()
|
started_at = perf_counter()
|
||||||
rag_result = None
|
rag_result = None
|
||||||
@@ -88,6 +93,7 @@ class ChatStreamService:
|
|||||||
getattr(session, "summary", None),
|
getattr(session, "summary", None),
|
||||||
getattr(session, "summary_up_to_message_id", None),
|
getattr(session, "summary_up_to_message_id", None),
|
||||||
context_trace,
|
context_trace,
|
||||||
|
growth_context,
|
||||||
)
|
)
|
||||||
model_response = ModelStreamService.stream(db, rag_result)
|
model_response = ModelStreamService.stream(db, rag_result)
|
||||||
for chunk in model_response.chunks:
|
for chunk in model_response.chunks:
|
||||||
@@ -229,6 +235,10 @@ class ChatStreamService:
|
|||||||
)
|
)
|
||||||
summary_result = await ChatContextService.update_summary_async(db, session, history)
|
summary_result = await ChatContextService.update_summary_async(db, session, history)
|
||||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
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()
|
started_at = perf_counter()
|
||||||
rag_result = None
|
rag_result = None
|
||||||
@@ -245,6 +255,7 @@ class ChatStreamService:
|
|||||||
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
|
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
context_trace=context_trace,
|
context_trace=context_trace,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
model_response = ModelStreamService.stream_async(db, rag_result)
|
model_response = ModelStreamService.stream_async(db, rag_result)
|
||||||
async for chunk in model_response.chunks:
|
async for chunk in model_response.chunks:
|
||||||
@@ -425,6 +436,7 @@ def _build_rag_result(
|
|||||||
summary: str | None,
|
summary: str | None,
|
||||||
summary_up_to_message_id: int | None,
|
summary_up_to_message_id: int | None,
|
||||||
context_trace: list[dict] | None = None,
|
context_trace: list[dict] | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
):
|
):
|
||||||
parameters = signature(RagService.build_result).parameters
|
parameters = signature(RagService.build_result).parameters
|
||||||
if "history" in parameters:
|
if "history" in parameters:
|
||||||
@@ -436,5 +448,6 @@ def _build_rag_result(
|
|||||||
session_summary=summary,
|
session_summary=summary,
|
||||||
summary_up_to_message_id=summary_up_to_message_id,
|
summary_up_to_message_id=summary_up_to_message_id,
|
||||||
context_trace=context_trace,
|
context_trace=context_trace,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
return RagService.build_result(db, user, question)
|
return RagService.build_result(db, user, question)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -85,6 +85,7 @@ class KnowledgeAgentService:
|
|||||||
context_trace: list[dict] | None = None,
|
context_trace: list[dict] | None = None,
|
||||||
prompt_override: str | None = None,
|
prompt_override: str | None = None,
|
||||||
response_depth: int | None = None,
|
response_depth: int | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
) -> RagResult:
|
) -> RagResult:
|
||||||
started = perf_counter()
|
started = perf_counter()
|
||||||
catalog = cls.get_knowledge_catalog(
|
catalog = cls.get_knowledge_catalog(
|
||||||
@@ -209,6 +210,7 @@ class KnowledgeAgentService:
|
|||||||
summary_up_to_message_id,
|
summary_up_to_message_id,
|
||||||
prompt_override=prompt_override,
|
prompt_override=prompt_override,
|
||||||
response_depth=response_depth,
|
response_depth=response_depth,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
return RagResult(
|
return RagResult(
|
||||||
question=question,
|
question=question,
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ class ModelClientService:
|
|||||||
raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model")
|
raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model")
|
||||||
return _generate_summary(model, messages_text, previous_summary)
|
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
|
@staticmethod
|
||||||
async def summarize_or_raise_async(
|
async def summarize_or_raise_async(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AsyncRagService:
|
|||||||
summary_up_to_message_id: int | None = None,
|
summary_up_to_message_id: int | None = None,
|
||||||
session_id: int | None = None,
|
session_id: int | None = None,
|
||||||
context_trace: list[dict] | None = None,
|
context_trace: list[dict] | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
) -> RagResult:
|
) -> RagResult:
|
||||||
return await KnowledgeAgentService.build_result(
|
return await KnowledgeAgentService.build_result(
|
||||||
db,
|
db,
|
||||||
@@ -29,4 +30,5 @@ class AsyncRagService:
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
context_trace=context_trace,
|
context_trace=context_trace,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class RagService:
|
|||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
summary_up_to_message_id: int | None = None,
|
summary_up_to_message_id: int | None = None,
|
||||||
context_trace: list[dict] | None = None,
|
context_trace: list[dict] | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
) -> RagResult:
|
) -> RagResult:
|
||||||
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
|
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
|
||||||
chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
|
chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
|
||||||
@@ -72,6 +73,7 @@ class RagService:
|
|||||||
history,
|
history,
|
||||||
session_summary,
|
session_summary,
|
||||||
summary_up_to_message_id,
|
summary_up_to_message_id,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
prompt = PromptService.render_messages(messages)
|
prompt = PromptService.render_messages(messages)
|
||||||
return RagResult(
|
return RagResult(
|
||||||
@@ -98,6 +100,7 @@ class PromptService:
|
|||||||
history: list[ChatMessage] | None = None,
|
history: list[ChatMessage] | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
summary_up_to_message_id: int | None = None,
|
summary_up_to_message_id: int | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return cls.render_messages(
|
return cls.render_messages(
|
||||||
cls.build_messages(
|
cls.build_messages(
|
||||||
@@ -107,6 +110,7 @@ class PromptService:
|
|||||||
history,
|
history,
|
||||||
session_summary,
|
session_summary,
|
||||||
summary_up_to_message_id,
|
summary_up_to_message_id,
|
||||||
|
growth_context=growth_context,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ class PromptService:
|
|||||||
summary_up_to_message_id: int | None = None,
|
summary_up_to_message_id: int | None = None,
|
||||||
prompt_override: str | None = None,
|
prompt_override: str | None = None,
|
||||||
response_depth: int | None = None,
|
response_depth: int | None = None,
|
||||||
|
growth_context: str | None = None,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
prompt = prompt_override.strip() if prompt_override and prompt_override.strip() else cls._load_active_prompt(db)
|
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:
|
if visible_summary:
|
||||||
messages.append({"role": "system", "content": f"[历史对话摘要]\n{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:
|
for message in recent_history:
|
||||||
content = cls._clean_history_content(message.content)
|
content = cls._clean_history_content(message.content)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
|||||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||||
import SessionQuota from "./components/SessionQuota.vue";
|
import SessionQuota from "./components/SessionQuota.vue";
|
||||||
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
|
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<UserProfile | null>(null);
|
const user = ref<UserProfile | null>(null);
|
||||||
const sessions = ref<ChatSession[]>([]);
|
const sessions = ref<ChatSession[]>([]);
|
||||||
@@ -22,6 +22,10 @@ const loadingSession = ref(false);
|
|||||||
const loadingSessions = ref(false);
|
const loadingSessions = ref(false);
|
||||||
const sessionOperationPending = ref(false);
|
const sessionOperationPending = ref(false);
|
||||||
const logoutDialogOpen = ref(false);
|
const logoutDialogOpen = ref(false);
|
||||||
|
const profileDialogOpen = ref(false);
|
||||||
|
const growthProfile = ref<GrowthProfileResult | null>(null);
|
||||||
|
const profileLoading = ref(false);
|
||||||
|
const finishingTopic = ref(false);
|
||||||
const statusText = ref("连接后端中");
|
const statusText = ref("连接后端中");
|
||||||
const toastText = ref("");
|
const toastText = ref("");
|
||||||
const followingOutput = ref(true);
|
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) {
|
async function renameSession(sessionId: number, title: string, done: (success: boolean) => void) {
|
||||||
if (sessionOperationPending.value) return;
|
if (sessionOperationPending.value) return;
|
||||||
sessionOperationPending.value = true;
|
sessionOperationPending.value = true;
|
||||||
@@ -304,7 +335,14 @@ function showToast(message: string) {
|
|||||||
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
|
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" />
|
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" />
|
||||||
<SessionQuota :used="user.todayUsed" :limit="user.dailyLimit" :entitlement="user.entitlement" />
|
<SessionQuota
|
||||||
|
:used="user.todayUsed"
|
||||||
|
:limit="user.dailyLimit"
|
||||||
|
:entitlement="user.entitlement"
|
||||||
|
:finishing="finishingTopic"
|
||||||
|
@finish-topic="finishCurrentTopic"
|
||||||
|
@open-profile="openGrowthProfile"
|
||||||
|
/>
|
||||||
<MessageList
|
<MessageList
|
||||||
ref="messageList"
|
ref="messageList"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
@@ -329,6 +367,30 @@ function showToast(message: string) {
|
|||||||
|
|
||||||
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
|
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
|
||||||
|
|
||||||
|
<AppDialog v-if="profileDialogOpen" title="我的实修档案" labelled-by="growth-profile-title" @close="profileDialogOpen = false">
|
||||||
|
<section class="growth-profile-dialog" :aria-busy="profileLoading">
|
||||||
|
<p v-if="profileLoading" class="profile-empty">正在加载成长档案...</p>
|
||||||
|
<template v-else-if="growthProfile?.profile">
|
||||||
|
<pre>{{ growthProfile.profile.profileText }}</pre>
|
||||||
|
<div class="growth-profile-fields">
|
||||||
|
<p v-if="growthProfile.profile.recurringTopics"><strong>反复议题</strong>{{ growthProfile.profile.recurringTopics }}</p>
|
||||||
|
<p v-if="growthProfile.profile.commonEmotions"><strong>常见情绪</strong>{{ growthProfile.profile.commonEmotions }}</p>
|
||||||
|
<p v-if="growthProfile.profile.bodyPatterns"><strong>身体感受</strong>{{ growthProfile.profile.bodyPatterns }}</p>
|
||||||
|
<p v-if="growthProfile.profile.homeworkDone"><strong>做过的功课</strong>{{ growthProfile.profile.homeworkDone }}</p>
|
||||||
|
<p v-if="growthProfile.profile.recentProgress"><strong>最近进展</strong>{{ growthProfile.profile.recentProgress }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="profile-empty">还没有成长档案。你可以在完成一次主题对话后点击“沉淀本主题”。</p>
|
||||||
|
<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>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
|
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
|
||||||
<p class="confirm-copy">确定退出当前账号吗?</p>
|
<p class="confirm-copy">确定退出当前账号吗?</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const props = defineProps<{
|
|||||||
used: number;
|
used: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
entitlement?: UserEntitlementSummary | null;
|
entitlement?: UserEntitlementSummary | null;
|
||||||
|
finishing?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
finishTopic: [];
|
||||||
|
openProfile: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
||||||
@@ -25,6 +31,19 @@ const limitText = computed(() => displayLimit.value === null ? "不限" : String
|
|||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
|
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
|
||||||
</div>
|
</div>
|
||||||
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
<div class="session-quota-actions">
|
||||||
|
<button
|
||||||
|
v-if="entitlement?.enableGrowthProfile"
|
||||||
|
type="button"
|
||||||
|
class="quota-link"
|
||||||
|
@click="$emit('openProfile')"
|
||||||
|
>
|
||||||
|
我的档案
|
||||||
|
</button>
|
||||||
|
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
||||||
|
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
||||||
|
</button>
|
||||||
|
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { ApiResponse, CaptchaResult, ChatMessage, ChatSession, LoginResult, UserProfile } from "../types/api";
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
CaptchaResult,
|
||||||
|
ChatMessage,
|
||||||
|
ChatSession,
|
||||||
|
FinishTopicResult,
|
||||||
|
GrowthProfileResult,
|
||||||
|
LoginResult,
|
||||||
|
UserProfile,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
const TOKEN_KEY = "ai-kb-user-token";
|
const TOKEN_KEY = "ai-kb-user-token";
|
||||||
@@ -76,6 +85,8 @@ export const api = {
|
|||||||
renameSession: (sessionId: number, title: string) =>
|
renameSession: (sessionId: number, title: string) =>
|
||||||
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
|
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
|
||||||
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }),
|
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }),
|
||||||
|
finishTopic: (sessionId: number) => request<FinishTopicResult>(`/chat/session/${sessionId}/topic/finish`, { method: "POST", body: JSON.stringify({}) }),
|
||||||
|
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
|
||||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1230,6 +1230,30 @@ textarea:focus-visible {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-quota-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-link {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(31, 107, 82, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #2f6d59;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-link:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
.session-quota svg,
|
.session-quota svg,
|
||||||
.session-quota strong {
|
.session-quota strong {
|
||||||
color: var(--chat-brand-dark);
|
color: var(--chat-brand-dark);
|
||||||
@@ -1672,6 +1696,72 @@ textarea:focus-visible {
|
|||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.growth-profile-dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-profile-dialog pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--chat-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--chat-brand-soft);
|
||||||
|
color: var(--chat-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-profile-fields,
|
||||||
|
.recent-topic-summaries {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-profile-fields p,
|
||||||
|
.profile-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--chat-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-profile-fields strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--chat-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-topic-summaries h3 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--chat-text);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-topic-summaries article {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid var(--chat-border);
|
||||||
|
border-radius: 13px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-topic-summaries time {
|
||||||
|
color: var(--chat-weak);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-topic-summaries p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--chat-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -1727,6 +1817,8 @@ body.drawer-open { overflow: hidden; }
|
|||||||
.user-summary { max-width: 72px; font-size: 9px; }
|
.user-summary { max-width: 72px; font-size: 9px; }
|
||||||
.header-logout-button { min-width: 48px; padding: 0 5px; }
|
.header-logout-button { min-width: 48px; padding: 0 5px; }
|
||||||
.session-quota { margin-right: 12px; margin-left: 12px; }
|
.session-quota { margin-right: 12px; margin-left: 12px; }
|
||||||
|
.session-quota-actions { gap: 5px; }
|
||||||
|
.quota-link { padding: 0 6px; font-size: 11px; }
|
||||||
.message-list { padding-right: 12px; padding-left: 12px; }
|
.message-list { padding-right: 12px; padding-left: 12px; }
|
||||||
.chat-composer { padding-right: 10px; padding-left: 10px; }
|
.chat-composer { padding-right: 10px; padding-left: 10px; }
|
||||||
.composer-send,
|
.composer-send,
|
||||||
|
|||||||
@@ -32,6 +32,41 @@ export interface UserEntitlementSummary {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopicSummary {
|
||||||
|
id: number;
|
||||||
|
topicSessionId: number;
|
||||||
|
summary: string;
|
||||||
|
recommendedHomework?: string | null;
|
||||||
|
nextObservation?: string | null;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrowthProfile {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
profileText: string;
|
||||||
|
recurringTopics?: string | null;
|
||||||
|
commonEmotions?: string | null;
|
||||||
|
bodyPatterns?: string | null;
|
||||||
|
relationPatterns?: string | null;
|
||||||
|
homeworkDone?: string | null;
|
||||||
|
effectiveHomework?: string | null;
|
||||||
|
recentProgress?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrowthProfileResult {
|
||||||
|
profile: GrowthProfile | null;
|
||||||
|
recentSummaries: TopicSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinishTopicResult {
|
||||||
|
topic: Record<string, unknown>;
|
||||||
|
summary: TopicSummary & Record<string, unknown>;
|
||||||
|
profile: GrowthProfile | null;
|
||||||
|
growthProfileEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string;
|
token: string;
|
||||||
expiredAt: string;
|
expiredAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user