feat: align agent preview with learner topics

This commit is contained in:
2026-07-31 18:21:36 +08:00
parent ab2c945f0b
commit b6aff78aac
18 changed files with 656 additions and 44 deletions

View File

@@ -3,7 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue"; import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { api, streamDebugAgent } from "../services/api"; import { api, streamDebugAgent } from "../services/api";
import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api"; import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem, TopicSessionRecord } from "../types/api";
import AgentGenerationParameters from "./AgentGenerationParameters.vue"; import AgentGenerationParameters from "./AgentGenerationParameters.vue";
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue"; import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
@@ -27,6 +27,8 @@ const models = ref<ModelItem[]>([]);
const knowledge = ref<KnowledgeItem[]>([]); const knowledge = ref<KnowledgeItem[]>([]);
const debugUsers = ref<AdminUser[]>([]); const debugUsers = ref<AdminUser[]>([]);
const debugUserLoading = ref(false); const debugUserLoading = ref(false);
const debugTopics = ref<TopicSessionRecord[]>([]);
const debugTopicLoading = ref(false);
const history = ref<PromptHistoryItem[]>([]); const history = ref<PromptHistoryItem[]>([]);
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 }); const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
const historyDetailOpen = ref(false); const historyDetailOpen = ref(false);
@@ -47,6 +49,7 @@ const agentPreviewMessages = ref<{
const agentForm = reactive({ const agentForm = reactive({
modelId: undefined as number | undefined, modelId: undefined as number | undefined,
userId: undefined as number | undefined, userId: undefined as number | undefined,
topicSessionId: undefined as number | undefined,
knowledgeIds: [] as number[], knowledgeIds: [] as number[],
temperature: 0.2 as number | null, temperature: 0.2 as number | null,
topP: null as number | null, topP: null as number | null,
@@ -89,6 +92,11 @@ const selectedDebugUserLabel = computed(() => {
const user = debugUsers.value.find((item) => item.id === agentForm.userId); const user = debugUsers.value.find((item) => item.id === agentForm.userId);
return user ? `${user.name || user.nickname || user.phone} · #${user.id}` : `学员 #${agentForm.userId}`; return user ? `${user.name || user.nickname || user.phone} · #${user.id}` : `学员 #${agentForm.userId}`;
}); });
const selectedDebugTopicLabel = computed(() => {
if (!agentForm.topicSessionId) return "未指定主题";
const topic = debugTopics.value.find((item) => item.id === agentForm.topicSessionId);
return topic ? topic.title : `主题 #${agentForm.topicSessionId}`;
});
onMounted(load); onMounted(load);
@@ -135,6 +143,55 @@ async function searchDebugUsers(keyword: string) {
} }
} }
async function handleDebugUserChange(userId?: number) {
agentForm.topicSessionId = undefined;
debugTopics.value = [];
clearAgentPreview("模拟学员已切换,可以选择其中一个主题继续调试。");
if (!userId) return;
debugTopicLoading.value = true;
try {
await loadDebugTopics(userId);
} catch (error) {
ElMessage.error(errorMessage(error, "学员主题加载失败"));
} finally {
debugTopicLoading.value = false;
}
}
async function loadDebugTopics(userId: number, keyword = "") {
const selected = debugTopics.value.find((item) => item.id === agentForm.topicSessionId);
const result = await api.userTopics(userId, { keyword, page: 1, pageSize: 100 });
debugTopics.value = selected && !result.items.some((item) => item.id === selected.id)
? [selected, ...result.items]
: result.items;
}
async function searchDebugTopics(keyword: string) {
if (!agentForm.userId) return;
debugTopicLoading.value = true;
try {
await loadDebugTopics(agentForm.userId, keyword);
} catch (error) {
ElMessage.error(errorMessage(error, "学员主题搜索失败"));
} finally {
debugTopicLoading.value = false;
}
}
function handleDebugTopicChange() {
clearAgentPreview(
agentForm.topicSessionId
? "模拟主题已切换,下一条调试会加载该主题的摘要和最近对话。"
: "已恢复通用学员模拟,不加载具体主题对话。",
);
}
function debugTopicOptionLabel(topic: TopicSessionRecord) {
const statusLabel = topic.status === "active" ? "进行中" : topic.status === "completed" ? "已完成" : topic.status;
const summaryLabel = topic.summaryAvailable ? "有摘要" : "无摘要";
return `${topic.title} · ${statusLabel} · ${topic.messageCount} 条消息 · ${summaryLabel}`;
}
function applyRuntimeConfig(value: AgentRuntimeConfig) { function applyRuntimeConfig(value: AgentRuntimeConfig) {
runtimeConfig.value = value; runtimeConfig.value = value;
Object.assign(runtimeForm, { Object.assign(runtimeForm, {
@@ -325,6 +382,7 @@ async function debugAgent() {
promptContent: promptContent.value, promptContent: promptContent.value,
modelId: agentForm.modelId, modelId: agentForm.modelId,
userId: agentForm.userId || null, userId: agentForm.userId || null,
topicSessionId: agentForm.topicSessionId || null,
knowledgeIds: agentForm.knowledgeIds, knowledgeIds: agentForm.knowledgeIds,
question, question,
history: conversationHistory, history: conversationHistory,
@@ -385,8 +443,8 @@ function scrollAgentPreview() {
}); });
} }
function clearAgentPreview() { function clearAgentPreview(message = "预览已清空,可以继续发起新的 Agent 调试。") {
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }]; agentPreviewMessages.value = [{ role: "assistant", content: message }];
agentDebugTrace.value = []; agentDebugTrace.value = [];
lastDebugRoute.value = null; lastDebugRoute.value = null;
} }
@@ -501,6 +559,7 @@ function errorMessage(error: unknown, fallback: string) {
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" /> <el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<div class="agent-debug-context-grid">
<el-form-item label="模拟学员(可选)"> <el-form-item label="模拟学员(可选)">
<el-select <el-select
v-model="agentForm.userId" v-model="agentForm.userId"
@@ -509,7 +568,9 @@ function errorMessage(error: unknown, fallback: string) {
remote remote
:remote-method="searchDebugUsers" :remote-method="searchDebugUsers"
:loading="debugUserLoading" :loading="debugUserLoading"
:disabled="agentDebugging"
placeholder="不选择则不注入学员权益和成长档案" placeholder="不选择则不注入学员权益和成长档案"
@change="handleDebugUserChange"
> >
<el-option <el-option
v-for="user in debugUsers" v-for="user in debugUsers"
@@ -518,8 +579,30 @@ function errorMessage(error: unknown, fallback: string) {
:value="user.id" :value="user.id"
/> />
</el-select> </el-select>
<div class="agent-form-help">用于让后台预览加载该学员的权益主题额度和长期成长档案不会消耗额度也不会影响正式会话</div> <div class="agent-form-help">加载该学员的权益和长期成长档案不会消耗额度或改动正式会话</div>
</el-form-item> </el-form-item>
<el-form-item label="模拟主题(可选)">
<el-select
v-model="agentForm.topicSessionId"
clearable
filterable
remote
:remote-method="searchDebugTopics"
:loading="debugTopicLoading"
:disabled="!agentForm.userId || agentDebugging"
placeholder="选择学员后可加载具体主题"
@change="handleDebugTopicChange"
>
<el-option
v-for="topic in debugTopics"
:key="topic.id"
:label="debugTopicOptionLabel(topic)"
:value="topic.id"
/>
</el-select>
<div class="agent-form-help">选择后会加载该主题摘要和最近对话用于验证追问效果</div>
</el-form-item>
</div>
<el-form-item label="调试知识库"> <el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库"> <el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" /> <el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
@@ -593,7 +676,7 @@ function errorMessage(error: unknown, fallback: string) {
<aside class="agent-preview-panel"> <aside class="agent-preview-panel">
<div class="agent-preview-head"> <div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }} · {{ selectedDebugUserLabel }}</small></div> <div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }} · {{ selectedDebugUserLabel }} · {{ selectedDebugTopicLabel }}</small></div>
<div class="agent-preview-head-actions"><span :title="lastDebugRoute?.routeReason || ''">{{ lastDebugRoute?.modelName || selectedModelName }}</span><el-button link :disabled="agentDebugging" @click="clearAgentPreview">清空</el-button></div> <div class="agent-preview-head-actions"><span :title="lastDebugRoute?.routeReason || ''">{{ lastDebugRoute?.modelName || selectedModelName }}</span><el-button link :disabled="agentDebugging" @click="clearAgentPreview">清空</el-button></div>
</div> </div>
<div ref="agentPreviewChat" class="agent-preview-chat"> <div ref="agentPreviewChat" class="agent-preview-chat">

View File

@@ -1,6 +1,7 @@
import { import {
ElAlert, ElAlert,
ElButton, ElButton,
ElCheckbox,
ElCollapse, ElCollapse,
ElCollapseItem, ElCollapseItem,
ElDatePicker, ElDatePicker,
@@ -32,6 +33,7 @@ import {
import "element-plus/theme-chalk/base.css"; import "element-plus/theme-chalk/base.css";
import "element-plus/theme-chalk/el-alert.css"; import "element-plus/theme-chalk/el-alert.css";
import "element-plus/theme-chalk/el-button.css"; import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-checkbox.css";
import "element-plus/theme-chalk/el-collapse.css"; import "element-plus/theme-chalk/el-collapse.css";
import "element-plus/theme-chalk/el-date-picker.css"; import "element-plus/theme-chalk/el-date-picker.css";
import "element-plus/theme-chalk/el-divider.css"; import "element-plus/theme-chalk/el-divider.css";
@@ -70,6 +72,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
[ [
ElAlert, ElAlert,
ElButton, ElButton,
ElCheckbox,
ElCollapse, ElCollapse,
ElCollapseItem, ElCollapseItem,
ElDatePicker, ElDatePicker,

View File

@@ -34,6 +34,7 @@ import type {
PromptHistoryItem, PromptHistoryItem,
QuestionInsightRefreshResult, QuestionInsightRefreshResult,
QuestionInsightSummary, QuestionInsightSummary,
TopicSessionRecord,
} from "../types/api"; } 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";
@@ -129,6 +130,8 @@ export const api = {
}, },
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`), users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`), userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<TopicSessionRecord>>(`/admin/user/${id}/topics${queryString(query)}`),
userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`), userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`),
generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) => generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) =>
request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }), request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }),

View File

@@ -1379,6 +1379,16 @@ textarea {
line-height: 1.6; line-height: 1.6;
} }
.agent-debug-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.agent-debug-context-grid .el-form-item {
min-width: 0;
}
.debug-knowledge-select { width: 100%; min-width: 0; } .debug-knowledge-select { width: 100%; min-width: 0; }
.debug-knowledge-select .el-select__wrapper { height: 40px; min-height: 40px; overflow: hidden; } .debug-knowledge-select .el-select__wrapper { height: 40px; min-height: 40px; overflow: hidden; }
.debug-knowledge-select .el-select__selection { flex-wrap: nowrap; overflow: hidden; } .debug-knowledge-select .el-select__selection { flex-wrap: nowrap; overflow: hidden; }
@@ -2385,6 +2395,10 @@ textarea {
.agent-page-head { .agent-page-head {
flex-direction: column; flex-direction: column;
} }
.agent-debug-context-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {

View File

@@ -562,6 +562,7 @@ export interface TopicSessionRecord {
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
summary?: TopicSummaryRecord | null; summary?: TopicSummaryRecord | null;
summaryAvailable?: boolean;
} }
export interface QuestionInsightSummary { export interface QuestionInsightSummary {

View File

@@ -9,7 +9,7 @@ from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import extract, func, select from sqlalchemy import extract, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -267,11 +267,49 @@ def user_operation_detail(
"recentTopics": _recent_topics(db, user.id), "recentTopics": _recent_topics(db, user.id),
"recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)], "recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)],
"recentShareDrafts": [share_draft_dict(item) for item in _recent_share_drafts(db, user.id)], "recentShareDrafts": [share_draft_dict(item) for item in _recent_share_drafts(db, user.id)],
"recentReports": [periodic_report_dict(item) for item in PeriodicReportService.list_user_reports(db, user_id=user.id, limit=10)], "recentReports": [
periodic_report_dict(item)
for item in PeriodicReportService.list_user_reports(db, user_id=user.id, limit=10)
],
} }
) )
@router.get("/user/{user_id}/topics")
def user_topic_options(
user_id: int,
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Lightweight topic selector data for Agent preview; no message bodies are returned."""
user = _get_user(db, user_id)
conditions = [TopicSession.user_id == user.id]
normalized_keyword = keyword.strip()
if normalized_keyword:
like = f"%{normalized_keyword}%"
conditions.append(or_(TopicSession.title.like(like), TopicSession.core_question.like(like)))
total = db.scalar(select(func.count(TopicSession.id)).where(*conditions)) or 0
rows = db.execute(
select(TopicSession, TopicSummary.id)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(*conditions)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [
{
**topic_dict(topic),
"summaryAvailable": summary_id is not None,
}
for topic, summary_id in rows
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/user/{user_id}/reports") @router.get("/user/{user_id}/reports")
def user_reports( def user_reports(
user_id: int, user_id: int,

View File

@@ -126,6 +126,7 @@ class AgentDebugRequest(BaseModel):
promptContent: str = Field(min_length=1) promptContent: str = Field(min_length=1)
modelId: int = Field(gt=0) modelId: int = Field(gt=0)
userId: int | None = Field(default=None, gt=0) userId: int | None = Field(default=None, gt=0)
topicSessionId: int | None = Field(default=None, gt=0)
knowledgeIds: list[int] = Field(default_factory=list) knowledgeIds: list[int] = Field(default_factory=list)
knowledgeVersions: dict[int, int] = Field(default_factory=dict) knowledgeVersions: dict[int, int] = Field(default_factory=dict)
question: str = Field(min_length=1, max_length=2000) question: str = Field(min_length=1, max_length=2000)

View File

@@ -5,14 +5,18 @@ from collections.abc import AsyncIterator
from dataclasses import replace from dataclasses import replace
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.user import User from app.models.user import User
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import ModelConfig from app.models.ai_config import ModelConfig
from app.schemas.admin import AgentDebugRequest from app.schemas.admin import AgentDebugRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict from app.services.chat_context_service import ChatContextService
from app.services.entitlement_service import EntitlementService, entitlement_dict, entitlement_prompt_context
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService
from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.model_stream_service import ModelStreamService from app.services.model_stream_service import ModelStreamService
@@ -25,17 +29,21 @@ from app.services.topic_session_service import TopicSessionService
class AgentDebugService: class AgentDebugService:
@staticmethod @staticmethod
async def build_result(db: Session, payload: AgentDebugRequest) -> RagResult: async def build_result(db: Session, payload: AgentDebugRequest) -> RagResult:
history = [
SimpleNamespace(id=index + 1, role=item.role, content=item.content)
for index, item in enumerate(payload.history)
]
preview_knowledge_ids = payload.knowledgeIds or None preview_knowledge_ids = payload.knowledgeIds or None
version_overrides = payload.knowledgeVersions or None version_overrides = payload.knowledgeVersions or None
debug_context = AgentDebugService._debug_user_context(db, payload.userId) debug_context = AgentDebugService._debug_user_context(db, payload.userId, payload.topicSessionId)
persisted_history = debug_context["history"]
max_history_id = max((int(item.id) for item in persisted_history), default=0)
preview_history = [
SimpleNamespace(id=max_history_id + index + 1, role=item.role, content=item.content)
for index, item in enumerate(payload.history)
]
rag_result = await KnowledgeAgentService.build_result( rag_result = await KnowledgeAgentService.build_result(
db, db,
question=payload.question, question=payload.question,
history=history, history=[*persisted_history, *preview_history],
session_summary=debug_context["session_summary"],
summary_up_to_message_id=debug_context["summary_up_to_message_id"],
version_overrides=version_overrides, version_overrides=version_overrides,
preview_knowledge_ids=preview_knowledge_ids, preview_knowledge_ids=preview_knowledge_ids,
user_id=payload.userId, user_id=payload.userId,
@@ -43,6 +51,8 @@ class AgentDebugService:
prompt_override=payload.promptContent, prompt_override=payload.promptContent,
response_depth=payload.responseDepth, response_depth=payload.responseDepth,
growth_context=debug_context["growth_context"], growth_context=debug_context["growth_context"],
topic_context=debug_context["topic_context"],
product_context=debug_context["product_context"],
) )
return RagResult( return RagResult(
question=rag_result.question, question=rag_result.question,
@@ -56,13 +66,30 @@ class AgentDebugService:
) )
@staticmethod @staticmethod
def _debug_user_context(db: Session, user_id: int | None) -> dict: def _debug_user_context(db: Session, user_id: int | None, topic_session_id: int | None) -> dict:
if user_id is None: if user_id is None:
return {"growth_context": None, "trace": []} if topic_session_id is not None:
user = db.get(User, user_id) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="选择主题前请先选择模拟学员")
if user is None or user.is_deleted:
return { return {
"growth_context": None, "growth_context": None,
"topic_context": None,
"product_context": None,
"session_summary": None,
"summary_up_to_message_id": None,
"history": [],
"trace": [],
}
user = db.get(User, user_id)
if user is None or user.is_deleted:
if topic_session_id is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="模拟学员不存在或已删除")
return {
"growth_context": None,
"topic_context": None,
"product_context": None,
"session_summary": None,
"summary_up_to_message_id": None,
"history": [],
"trace": [ "trace": [
{ {
"tool": "load_debug_user_context", "tool": "load_debug_user_context",
@@ -81,13 +108,55 @@ class AgentDebugService:
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id), monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
) )
growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None growth_context = GrowthProfileService.prompt_context(db, user) if entitlement.enable_growth_profile else None
product_context = entitlement_prompt_context(entitlement)
topic = None
topic_context = None
topic_summary_used = False
session_summary = None
summary_up_to_message_id = None
history: list[ChatMessage] = []
if topic_session_id is not None:
topic = db.scalar(
select(TopicSession).where(
TopicSession.id == topic_session_id,
TopicSession.user_id == user.id,
)
)
if topic is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="所选主题不属于当前模拟学员")
history_limit = ChatContextService.message_limit(db)
if history_limit > 0:
latest_history = list(
db.scalars(
select(ChatMessage)
.where(
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id,
ChatMessage.role.in_(("user", "assistant")),
)
.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc())
.limit(history_limit)
)
)
history = list(reversed(latest_history))
if topic.status == "active":
chat_session = db.get(ChatSession, topic.chat_session_id)
if chat_session is not None and chat_session.user_id == user.id:
session_summary = chat_session.summary
summary_up_to_message_id = chat_session.summary_up_to_message_id
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
return { return {
"growth_context": growth_context, "growth_context": growth_context,
"topic_context": topic_context,
"product_context": product_context,
"session_summary": session_summary,
"summary_up_to_message_id": summary_up_to_message_id,
"history": history,
"trace": [ "trace": [
{ {
"tool": "load_debug_user_context", "tool": "load_debug_user_context",
"order": 1, "order": 1,
"request": {"userId": user.id}, "request": {"userId": user.id, "topicSessionId": topic_session_id},
"status": "success", "status": "success",
"durationMs": 0, "durationMs": 0,
"response": { "response": {
@@ -95,6 +164,16 @@ class AgentDebugService:
"userName": user.name, "userName": user.name,
"entitlement": entitlement_dict(entitlement), "entitlement": entitlement_dict(entitlement),
"growthProfileUsed": bool(growth_context), "growthProfileUsed": bool(growth_context),
"productContextUsed": True,
"topic": {
"id": topic.id,
"title": topic.title,
"status": topic.status,
"messageCount": topic.message_count,
"loadedHistoryCount": len(history),
"summaryUsed": topic_summary_used,
"rollingSummaryUsed": bool(session_summary),
} if topic is not None else None,
}, },
} }
], ],

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.user import User 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, entitlement_prompt_context
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.growth_profile_service import GrowthProfileService
from app.services.chat_context_service import ChatContextService from app.services.chat_context_service import ChatContextService
@@ -117,6 +117,7 @@ class ChatService:
select(ChatMessage) select(ChatMessage)
.where( .where(
ChatMessage.session_id == session.id, ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id, ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id, ChatMessage.id < user_message.id,
) )
@@ -130,6 +131,27 @@ class ChatService:
if growth_context: if growth_context:
context_trace = list(context_trace or []) context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = list(context_trace or [])
context_trace.extend(
[
{
"tool": "load_topic_context",
"status": "success",
"response": {"topicSessionId": topic.id, "summaryUsed": topic_summary_used},
},
{
"tool": "load_product_entitlement",
"status": "success",
"response": {
"planId": entitlement.plan_id,
"allowHelpCard": entitlement.allow_help_card,
"allowShareDraft": entitlement.allow_share_draft,
},
},
]
)
# 构建 prompt传入历史 + 摘要) # 构建 prompt传入历史 + 摘要)
rag_result = RagService.build_result( rag_result = RagService.build_result(
@@ -139,6 +161,8 @@ class ChatService:
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, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )
completion = ModelClientService.complete(db, rag_result) completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc: except ExternalServiceError as exc:

View File

@@ -16,7 +16,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.chat_service import ChatService, _title_from_question 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, entitlement_prompt_context
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.growth_profile_service import GrowthProfileService
from app.services.human_attention_service import HumanAttentionService from app.services.human_attention_service import HumanAttentionService
@@ -67,6 +67,7 @@ class ChatStreamService:
select(ChatMessage) select(ChatMessage)
.where( .where(
ChatMessage.session_id == session.id, ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id, ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id, ChatMessage.id < user_message.id,
) )
@@ -79,6 +80,14 @@ class ChatStreamService:
if growth_context: if growth_context:
context_trace = list(context_trace or []) context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = _append_runtime_context_trace(
context_trace,
topic=topic,
topic_summary_used=topic_summary_used,
entitlement=entitlement,
)
started_at = perf_counter() started_at = perf_counter()
rag_result = None rag_result = None
@@ -95,6 +104,8 @@ class ChatStreamService:
getattr(session, "summary_up_to_message_id", None), getattr(session, "summary_up_to_message_id", None),
context_trace, context_trace,
growth_context, growth_context,
topic_context,
product_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:
@@ -239,6 +250,7 @@ class ChatStreamService:
select(ChatMessage) select(ChatMessage)
.where( .where(
ChatMessage.session_id == session.id, ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id, ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id, ChatMessage.id < user_message.id,
) )
@@ -251,6 +263,14 @@ class ChatStreamService:
if growth_context: if growth_context:
context_trace = list(context_trace or []) context_trace = list(context_trace or [])
context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1}) context_trace.append({"tool": "load_growth_profile", "status": "success", "count": 1})
topic_context, topic_summary_used = TopicSessionService.prompt_context(db, topic)
product_context = entitlement_prompt_context(entitlement)
context_trace = _append_runtime_context_trace(
context_trace,
topic=topic,
topic_summary_used=topic_summary_used,
entitlement=entitlement,
)
started_at = perf_counter() started_at = perf_counter()
rag_result = None rag_result = None
@@ -268,6 +288,8 @@ class ChatStreamService:
session_id=session.id, session_id=session.id,
context_trace=context_trace, context_trace=context_trace,
growth_context=growth_context, growth_context=growth_context,
topic_context=topic_context,
product_context=product_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:
@@ -485,6 +507,8 @@ def _build_rag_result(
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, growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
): ):
parameters = signature(RagService.build_result).parameters parameters = signature(RagService.build_result).parameters
if "history" in parameters: if "history" in parameters:
@@ -497,5 +521,30 @@ def _build_rag_result(
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, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )
return RagService.build_result(db, user, question) return RagService.build_result(db, user, question)
def _append_runtime_context_trace(context_trace, *, topic, topic_summary_used: bool, entitlement) -> list[dict]:
result = list(context_trace or [])
result.extend(
[
{
"tool": "load_topic_context",
"status": "success",
"response": {"topicSessionId": topic.id, "summaryUsed": topic_summary_used},
},
{
"tool": "load_product_entitlement",
"status": "success",
"response": {
"planId": entitlement.plan_id,
"allowHelpCard": entitlement.allow_help_card,
"allowShareDraft": entitlement.allow_share_draft,
},
},
]
)
return result

View File

@@ -214,6 +214,19 @@ def entitlement_dict(view: EntitlementView) -> dict:
} }
def entitlement_prompt_context(view: EntitlementView) -> str:
"""Describe product capabilities without letting the model pretend to execute them."""
help_card = "可由学员主动生成" if view.allow_help_card else "当前权益不可生成"
share_draft = "可由学员主动生成" if view.allow_share_draft else "当前权益不可生成"
return (
"[当前产品权益]\n"
f"权益名称:{view.name};权益类型:{view.plan_type}\n"
f"老师求助卡:{help_card};班级分享稿:{share_draft}\n"
"这些能力由页面按钮和专用接口真实执行。不得声称已自动转人工、已联系老师、已生成或已发送卡片;"
"只有学员明确询问相关能力时,才说明当前是否可以由学员主动生成。"
)
def view_from_plan( def view_from_plan(
plan: EntitlementPlan, plan: EntitlementPlan,
*, *,

View File

@@ -86,6 +86,8 @@ class KnowledgeAgentService:
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, growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> RagResult: ) -> RagResult:
started = perf_counter() started = perf_counter()
catalog = cls.get_knowledge_catalog( catalog = cls.get_knowledge_catalog(
@@ -211,6 +213,8 @@ class KnowledgeAgentService:
prompt_override=prompt_override, prompt_override=prompt_override,
response_depth=response_depth, response_depth=response_depth,
growth_context=growth_context, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )
return RagResult( return RagResult(
question=question, question=question,

View File

@@ -20,6 +20,8 @@ class AsyncRagService:
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, growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> RagResult: ) -> RagResult:
return await KnowledgeAgentService.build_result( return await KnowledgeAgentService.build_result(
db, db,
@@ -31,4 +33,6 @@ class AsyncRagService:
user_id=user.id, user_id=user.id,
context_trace=context_trace, context_trace=context_trace,
growth_context=growth_context, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )

View File

@@ -63,6 +63,8 @@ class RagService:
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, growth_context: str | None = None,
topic_context: str | None = None,
product_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)
@@ -74,6 +76,8 @@ class RagService:
session_summary, session_summary,
summary_up_to_message_id, summary_up_to_message_id,
growth_context=growth_context, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )
prompt = PromptService.render_messages(messages) prompt = PromptService.render_messages(messages)
return RagResult( return RagResult(
@@ -101,6 +105,8 @@ class PromptService:
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, growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> str: ) -> str:
return cls.render_messages( return cls.render_messages(
cls.build_messages( cls.build_messages(
@@ -111,6 +117,8 @@ class PromptService:
session_summary, session_summary,
summary_up_to_message_id, summary_up_to_message_id,
growth_context=growth_context, growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
) )
) )
@@ -126,6 +134,8 @@ class PromptService:
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, growth_context: str | None = None,
topic_context: str | None = None,
product_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)
@@ -157,6 +167,10 @@ class PromptService:
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(): if growth_context and growth_context.strip():
messages.append({"role": "system", "content": growth_context.strip()}) messages.append({"role": "system", "content": growth_context.strip()})
if topic_context and topic_context.strip():
messages.append({"role": "system", "content": topic_context.strip()})
if product_context and product_context.strip():
messages.append({"role": "system", "content": product_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)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import extract, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary
from app.models.user import User from app.models.user import User
@@ -50,6 +51,12 @@ class TopicSessionService:
topic = TopicSessionService.active_for_session(db, user=user, session=session) topic = TopicSessionService.active_for_session(db, user=user, session=session)
if topic is not None: if topic is not None:
return topic return topic
# A finished topic is a real memory boundary. Its durable summary lives in
# TopicSummary / growth profile; the rolling ChatSession summary must start
# clean for the next topic in the same chat window.
session.summary = None
session.summary_up_to_message_id = None
db.add(session)
topic = TopicSession( topic = TopicSession(
user_id=user.id, user_id=user.id,
chat_session_id=session.id, chat_session_id=session.id,
@@ -104,6 +111,43 @@ class TopicSessionService:
"updatedAt": topic.updated_at, "updatedAt": topic.updated_at,
} }
@staticmethod
def prompt_context(db: Session, topic: TopicSession) -> tuple[str, bool]:
"""Build bounded topic context shared by formal chat and admin preview."""
summary = db.scalar(
select(TopicSummary)
.where(TopicSummary.topic_session_id == topic.id)
.order_by(TopicSummary.generated_at.desc(), TopicSummary.id.desc())
.limit(1)
)
lines = [
"[当前对话主题]",
"以下信息用于延续当前主题,不能替代本轮可靠知识,也不得据此臆造课程内容。",
f"主题:{_limit(topic.title, 240)}",
f"核心问题:{_limit(topic.core_question, 1200)}",
f"主题状态:{topic.status}",
]
if topic.recommended_homework:
lines.append(f"已记录的建议功课:{_limit(topic.recommended_homework, 1000)}")
if summary is not None and summary.status in {"success", "fallback"}:
lines.extend(
[
"\n[所选主题摘要]",
_limit(summary.summary, 2400),
]
)
details = [
("主要事件", summary.main_events),
("情绪", summary.emotions),
("身体感受", summary.body_feelings),
("信念", summary.beliefs),
("建议功课", summary.recommended_homework),
("下一步观察", summary.next_observation),
]
lines.extend(f"{label}{_limit(value, 800)}" for label, value in details if value)
return "\n".join(lines), True
return "\n".join(lines), False
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
@@ -112,3 +156,8 @@ def _now() -> datetime:
def _title_from_question(question: str) -> str: def _title_from_question(question: str) -> str:
title = question.strip().replace("\n", " ") title = question.strip().replace("\n", " ")
return title[:40] if title else "新主题" return title[:40] if title else "新主题"
def _limit(value: str, limit: int) -> str:
text = value.strip()
return text if len(text) <= limit else text[:limit].rstrip() + ""

View File

@@ -6,9 +6,10 @@ from types import SimpleNamespace
from app.api.admin_agent_records import attention_list, retrieval_logs from app.api.admin_agent_records import attention_list, retrieval_logs
from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights, refresh_question_insights from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights, refresh_question_insights
from app.api.admin_users import list_users from app.api.admin_users import list_users, user_operation_detail, user_topic_options
from app.models import Base from app.models import Base
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary
from app.models.insight import QuestionInsightCleanedQuestion from app.models.insight import QuestionInsightCleanedQuestion
from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog
from app.models.logs import AiRequestLog from app.models.logs import AiRequestLog
@@ -34,6 +35,44 @@ def test_user_list_uses_database_pagination():
assert len(response["data"]["items"]) == 10 assert len(response["data"]["items"]) == 10
def test_agent_preview_topic_options_are_lightweight_and_paginated():
with _database() as db:
user = User(id=1, phone="13800000000", name="学员", daily_chat_limit=10)
db.add(user)
topics = [
TopicSession(
id=index + 1,
user_id=1,
chat_session_id=100 + index,
title=f"主题{index + 1}",
core_question=f"核心问题{index + 1}",
message_count=index,
)
for index in range(12)
]
db.add_all(topics)
db.flush()
db.add(TopicSummary(topic_session_id=topics[-1].id, user_id=1, summary="只用于判断有无摘要", status="success"))
db.commit()
response = user_topic_options(1, keyword="", page=2, pageSize=10, db=db, current_admin=object())
data = response["data"]
assert data["total"] == 12
assert data["page"] == 2
assert len(data["items"]) == 2
assert all("summary" not in item for item in data["items"])
assert all("summaryAvailable" in item for item in data["items"])
detail = user_operation_detail(1, db=db, current_admin=object())["data"]
assert detail["user"]["id"] == 1
assert detail["metrics"]["totalTopics"] == 12
searched = user_topic_options(1, keyword="核心问题12", page=1, pageSize=10, db=db, current_admin=object())["data"]
assert searched["total"] == 1
assert searched["items"][0]["title"] == "主题12"
def test_ai_log_page_does_not_load_large_detail_fields(): def test_ai_log_page_does_not_load_large_detail_fields():
with _database() as db: with _database() as db:
db.add_all([ db.add_all([

View File

@@ -2,13 +2,16 @@ from __future__ import annotations
import asyncio import asyncio
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.models import Base from app.models import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan from app.models.entitlement import EntitlementPlan
from app.models.growth import UserGrowthProfile from app.models.growth import TopicSummary, UserGrowthProfile
from app.models.user import User from app.models.user import User
from app.schemas.admin import AgentDebugRequest from app.schemas.admin import AgentDebugRequest
from app.services.agent_debug_service import AgentDebugService from app.services.agent_debug_service import AgentDebugService
@@ -54,4 +57,118 @@ def test_agent_debug_can_simulate_user_growth_profile_context():
assert "表达障碍" in rendered assert "表达障碍" in rendered
assert result.tool_trace[0]["tool"] == "load_debug_user_context" assert result.tool_trace[0]["tool"] == "load_debug_user_context"
assert result.tool_trace[0]["response"]["growthProfileUsed"] is True assert result.tool_trace[0]["response"]["growthProfileUsed"] is True
assert "[当前产品权益]" in rendered
def test_agent_debug_loads_selected_topic_history_summary_and_permissions():
with _db() as db:
user = User(id=1, phone="13800000001", name="测试学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=20, user_id=1, title="表达障碍", message_count=2, is_deleted=0)
topic = TopicSession(
id=30,
user_id=1,
chat_session_id=20,
title="表达障碍练习",
core_question="我在面对领导时不敢表达",
status="completed",
message_count=2,
)
db.add_all(
[
user,
session,
topic,
EntitlementPlan(
id=10,
name="深度陪伴版",
plan_type="deep",
monthly_topic_limit=90,
enable_growth_profile=1,
allow_help_card=1,
allow_share_draft=0,
status=1,
),
UserGrowthProfile(user_id=1, profile_text="用户在表达时容易身体紧绷。"),
ChatMessage(
id=100,
session_id=20,
topic_session_id=30,
user_id=1,
role="user",
content="我又不敢说话了",
),
ChatMessage(
id=101,
session_id=20,
topic_session_id=30,
user_id=1,
role="assistant",
content="先观察当下的身体感受。",
),
TopicSummary(
topic_session_id=30,
user_id=1,
summary="学员正在观察面对权威时的紧绷。",
emotions="害怕",
body_feelings="心口紧",
status="success",
),
]
)
db.commit()
result = asyncio.run(
AgentDebugService.build_result(
db,
AgentDebugRequest(
promptContent="你是测试 Agent",
modelId=1,
userId=1,
topicSessionId=30,
question="那我现在怎么继续?",
history=[{"role": "user", "content": "我想继续刚才的主题"}],
),
)
)
rendered = "\n".join(item["content"] for item in result.messages)
assert "[当前对话主题]" in rendered
assert "[所选主题摘要]" in rendered
assert "我又不敢说话了" in rendered
assert "我想继续刚才的主题" in rendered
assert "老师求助卡:可由学员主动生成" in rendered
assert "班级分享稿:当前权益不可生成" in rendered
context_response = result.tool_trace[0]["response"]
assert context_response["topic"]["id"] == 30
assert context_response["topic"]["loadedHistoryCount"] == 2
assert context_response["topic"]["summaryUsed"] is True
def test_agent_debug_rejects_topic_from_another_user():
with _db() as db:
db.add_all(
[
User(id=1, phone="13800000001", name="学员A", daily_chat_limit=100, daily_chat_used=0),
User(id=2, phone="13800000002", name="学员B", daily_chat_limit=100, daily_chat_used=0),
ChatSession(id=20, user_id=2, title="B的会话", message_count=0, is_deleted=0),
TopicSession(id=30, user_id=2, chat_session_id=20, title="B的主题", core_question="B的问题"),
]
)
db.commit()
with pytest.raises(HTTPException) as exc:
asyncio.run(
AgentDebugService.build_result(
db,
AgentDebugRequest(
promptContent="你是测试 Agent",
modelId=1,
userId=1,
topicSessionId=30,
question="继续这个主题",
),
)
)
assert exc.value.status_code == 400
assert exc.value.detail == "所选主题不属于当前模拟学员"

View File

@@ -4,16 +4,17 @@ from datetime import UTC, datetime
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.models import Base from app.models import Base
from app.models.chat import ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan from app.models.entitlement import EntitlementPlan
from app.models.user import User from app.models.user import User
from app.services.chat_service import ChatService from app.services.chat_service import ChatService
from app.services.entitlement_service import EntitlementService from app.services.entitlement_service import EntitlementService
from app.services.rag_service import RagResult, RagService
from app.services.topic_session_service import TopicSessionService from app.services.topic_session_service import TopicSessionService
@@ -142,3 +143,79 @@ def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic():
db.flush() db.flush()
ChatService._ensure_topic_quota(db, user, session, entitlement) ChatService._ensure_topic_quota(db, user, session, entitlement)
def test_formal_chat_passes_topic_and_product_context_to_rag(monkeypatch):
with _db() as db:
message_sequence = iter(range(1000, 1010))
def assign_sqlite_message_id(_session, _flush_context, _instances):
for instance in _session.new:
if isinstance(instance, ChatMessage) and instance.id is None:
instance.id = next(message_sequence)
event.listen(db, "before_flush", assign_sqlite_message_id)
user, session = _seed_user_session(db)
db.add(
EntitlementPlan(
id=10,
name="基础陪伴版",
plan_type="basic",
monthly_topic_limit=30,
allow_help_card=1,
allow_share_draft=0,
status=1,
)
)
db.commit()
captured = {}
def fake_build_result(_db, _user, question, **kwargs):
captured.update(kwargs)
return RagResult(
question=question,
knowledge_scopes=[],
chunks=[],
prompt="测试 prompt",
allow_general_knowledge=True,
)
monkeypatch.setattr(RagService, "build_result", fake_build_result)
ChatService.create_answer(db, user, session.id, "我想继续这个问题")
assert "[当前对话主题]" in captured["topic_context"]
assert "我想继续这个问题" in captured["topic_context"]
assert "[当前产品权益]" in captured["product_context"]
assert "班级分享稿:当前权益不可生成" in captured["product_context"]
def test_new_topic_resets_previous_topic_rolling_summary():
with _db() as db:
user, session = _seed_user_session(db)
session.summary = "上一个主题的滚动摘要"
session.summary_up_to_message_id = 88
db.add(
TopicSession(
id=90,
user_id=user.id,
chat_session_id=session.id,
title="已完成主题",
core_question="上一个问题",
status="completed",
)
)
db.commit()
topic = TopicSessionService.get_or_create_active(
db,
user=user,
session=session,
question="这是一个新主题",
deduct_quota=True,
)
assert topic.id != 90
assert topic.status == "active"
assert session.summary is None
assert session.summary_up_to_message_id is None