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

@@ -5,14 +5,18 @@ from collections.abc import AsyncIterator
from dataclasses import replace
from types import SimpleNamespace
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.user import User
from app.models.admin import Admin
from app.models.ai_config import ModelConfig
from app.schemas.admin import AgentDebugRequest
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.knowledge_agent_service import KnowledgeAgentService
from app.services.model_stream_service import ModelStreamService
@@ -25,17 +29,21 @@ from app.services.topic_session_service import TopicSessionService
class AgentDebugService:
@staticmethod
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
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(
db,
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,
preview_knowledge_ids=preview_knowledge_ids,
user_id=payload.userId,
@@ -43,6 +51,8 @@ class AgentDebugService:
prompt_override=payload.promptContent,
response_depth=payload.responseDepth,
growth_context=debug_context["growth_context"],
topic_context=debug_context["topic_context"],
product_context=debug_context["product_context"],
)
return RagResult(
question=rag_result.question,
@@ -56,13 +66,30 @@ class AgentDebugService:
)
@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:
return {"growth_context": None, "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": [],
}
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": [
{
"tool": "load_debug_user_context",
@@ -81,13 +108,55 @@ class AgentDebugService:
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
)
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 {
"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": [
{
"tool": "load_debug_user_context",
"order": 1,
"request": {"userId": user.id},
"request": {"userId": user.id, "topicSessionId": topic_session_id},
"status": "success",
"durationMs": 0,
"response": {
@@ -95,6 +164,16 @@ class AgentDebugService:
"userName": user.name,
"entitlement": entitlement_dict(entitlement),
"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.user import User
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.growth_profile_service import GrowthProfileService
from app.services.chat_context_service import ChatContextService
@@ -117,6 +117,7 @@ class ChatService:
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
@@ -130,6 +131,27 @@ class ChatService:
if growth_context:
context_trace = list(context_trace or [])
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传入历史 + 摘要)
rag_result = RagService.build_result(
@@ -139,6 +161,8 @@ class ChatService:
summary_up_to_message_id=session.summary_up_to_message_id,
context_trace=context_trace,
growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
)
completion = ModelClientService.complete(db, rag_result)
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.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService
from app.services.entitlement_service import EntitlementService
from app.services.entitlement_service import EntitlementService, entitlement_prompt_context
from app.services.external_errors import ExternalServiceError
from app.services.growth_profile_service import GrowthProfileService
from app.services.human_attention_service import HumanAttentionService
@@ -67,6 +67,7 @@ class ChatStreamService:
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
@@ -79,6 +80,14 @@ class ChatStreamService:
if growth_context:
context_trace = list(context_trace or [])
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()
rag_result = None
@@ -95,6 +104,8 @@ class ChatStreamService:
getattr(session, "summary_up_to_message_id", None),
context_trace,
growth_context,
topic_context,
product_context,
)
model_response = ModelStreamService.stream(db, rag_result)
for chunk in model_response.chunks:
@@ -239,6 +250,7 @@ class ChatStreamService:
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.topic_session_id == topic.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
@@ -251,6 +263,14 @@ class ChatStreamService:
if growth_context:
context_trace = list(context_trace or [])
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()
rag_result = None
@@ -268,6 +288,8 @@ class ChatStreamService:
session_id=session.id,
context_trace=context_trace,
growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
)
model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks:
@@ -485,6 +507,8 @@ def _build_rag_result(
summary_up_to_message_id: int | None,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:
@@ -497,5 +521,30 @@ def _build_rag_result(
summary_up_to_message_id=summary_up_to_message_id,
context_trace=context_trace,
growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
)
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(
plan: EntitlementPlan,
*,

View File

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

View File

@@ -20,6 +20,8 @@ class AsyncRagService:
session_id: int | None = None,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> RagResult:
return await KnowledgeAgentService.build_result(
db,
@@ -31,4 +33,6 @@ class AsyncRagService:
user_id=user.id,
context_trace=context_trace,
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,
context_trace: list[dict] | None = None,
growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> RagResult:
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
@@ -74,6 +76,8 @@ class RagService:
session_summary,
summary_up_to_message_id,
growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
)
prompt = PromptService.render_messages(messages)
return RagResult(
@@ -101,6 +105,8 @@ class PromptService:
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> str:
return cls.render_messages(
cls.build_messages(
@@ -111,6 +117,8 @@ class PromptService:
session_summary,
summary_up_to_message_id,
growth_context=growth_context,
topic_context=topic_context,
product_context=product_context,
)
)
@@ -126,6 +134,8 @@ class PromptService:
prompt_override: str | None = None,
response_depth: int | None = None,
growth_context: str | None = None,
topic_context: str | None = None,
product_context: str | None = None,
) -> list[dict[str, str]]:
prompt = prompt_override.strip() if prompt_override and prompt_override.strip() else cls._load_active_prompt(db)
@@ -157,6 +167,10 @@ class PromptService:
messages.append({"role": "system", "content": f"[历史对话摘要]\n{visible_summary}"})
if growth_context and 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:
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 app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary
from app.models.user import User
@@ -50,6 +51,12 @@ class TopicSessionService:
topic = TopicSessionService.active_for_session(db, user=user, session=session)
if topic is not None:
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(
user_id=user.id,
chat_session_id=session.id,
@@ -104,6 +111,43 @@ class TopicSessionService:
"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:
return datetime.now(UTC).replace(tzinfo=None)
@@ -112,3 +156,8 @@ def _now() -> datetime:
def _title_from_question(question: str) -> str:
title = question.strip().replace("\n", " ")
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() + ""