Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
544 changes: 260 additions & 284 deletions src/backend/bisheng/api/v1/callback.py

Large diffs are not rendered by default.

47 changes: 27 additions & 20 deletions src/backend/bisheng/tool/domain/langchain/knowledge.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from typing import Any, Type, List, Optional
from typing import Any

from langchain_classic.chains.combine_documents import create_stuff_documents_chain
from langchain_core.documents import Document, BaseDocumentCompressor
from langchain_core.documents import BaseDocumentCompressor, Document
from langchain_core.language_models import BaseChatModel
from langchain_core.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate, ChatPromptTemplate
from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate, SystemMessagePromptTemplate
from langchain_core.retrievers import BaseRetriever
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -34,17 +34,17 @@ class ToolInputSchema(BaseModel):
class KnowledgeRetrieverTool(BaseTool):
name: str = "knowledge_retriever_tool"
description: str = "在知识库中检索与查询相关的文档内容。"
args_schema: Type[BaseModel] = ToolInputSchema
args_schema: type[BaseModel] = ToolInputSchema

vector_retriever: Optional[BaseRetriever] = None
elastic_retriever: Optional[BaseRetriever] = None
rerank: Optional[BaseDocumentCompressor] = None
vector_retriever: BaseRetriever | None = None
elastic_retriever: BaseRetriever | None = None
rerank: BaseDocumentCompressor | None = None
max_content: int = Field(default=15000, description="The max length of the combined document content.")
sort_by_source_and_index: bool = Field(default=False, description="Sort by document name & chunk index.")
rrf_weights: List[float] = Field(default=None)
rrf_weights: list[float] = Field(default=None)
rrf_remove_zero_score: bool = Field(default=False)

def _run(self, query: str, **kwargs: Any) -> List[Document]:
def _run(self, query: str, **kwargs: Any) -> list[Document]:
milvus_docs, es_docs = [], []
if self.vector_retriever:
milvus_docs = self.vector_retriever.invoke(query)
Expand All @@ -57,7 +57,7 @@ def _run(self, query: str, **kwargs: Any) -> List[Document]:
finally_docs = self.rerank.compress_documents(finally_docs, query)
return finally_docs

async def _arun(self, query: str, **kwargs: Any) -> List[Document]:
async def _arun(self, query: str, **kwargs: Any) -> list[Document]:
milvus_docs, es_docs = [], []
if self.vector_retriever:
milvus_docs = await self.vector_retriever.ainvoke(query)
Expand All @@ -70,7 +70,7 @@ async def _arun(self, query: str, **kwargs: Any) -> List[Document]:
finally_docs = await self.rerank.acompress_documents(finally_docs, query)
return finally_docs

def _rrf_rerank(self, milvus_docs: List[Document], es_docs: List[Document], query: str) -> List[Document]:
def _rrf_rerank(self, milvus_docs: list[Document], es_docs: list[Document], query: str) -> list[Document]:
if not milvus_docs and not es_docs:
return []
rrf_rerank = RRFRerank(
Expand Down Expand Up @@ -103,16 +103,16 @@ def _rrf_rerank(self, milvus_docs: List[Document], es_docs: List[Document], quer
class KnowledgeRagTool(BaseTool):
name: str
description: str
args_schema: Type[BaseModel] = ToolInputSchema
args_schema: type[BaseModel] = ToolInputSchema

llm: BaseChatModel
chat_prompt: Optional[ChatPromptTemplate] = CHAT_PROMPT
chat_prompt: ChatPromptTemplate | None = CHAT_PROMPT

vector_retriever: Optional[BaseRetriever] = None
elastic_retriever: Optional[BaseRetriever] = None
vector_retriever: BaseRetriever | None = None
elastic_retriever: BaseRetriever | None = None
max_content: int = Field(default=15000, description="The max length of the combined document content.")
sort_by_source_and_index: bool = Field(default=False, description="Sort by document name & chunk index.")
rrf_weights: List[float] = Field(default=None)
rrf_weights: list[float] = Field(default=None)
rrf_remove_zero_score: bool = Field(default=False)

knowledge_retriever_tool: KnowledgeRetrieverTool = None
Expand All @@ -121,7 +121,10 @@ class KnowledgeRagTool(BaseTool):
def init_knowledge_rag_tool(cls, name: str, description: str, **kwargs) -> BaseTool:
llm = kwargs.pop("llm")
chat_prompt = kwargs.pop("chat_prompt", CHAT_PROMPT)
# cancel assistant deep callback
# The retriever is an internal step of this tool, not a tool call of its
# own — it must stay invisible to the caller's callbacks. Dropping them
# here is not enough on its own (LangChain also inherits handlers from
# the ambient run context), so `_run`/`_arun` bypass `invoke` as well.
kwargs.pop("callbacks", None)
knowledge_retriever_tool = KnowledgeRetrieverTool(**kwargs)
return cls(
Expand All @@ -134,14 +137,18 @@ def init_knowledge_rag_tool(cls, name: str, description: str, **kwargs) -> BaseT
)

def _run(self, query: str) -> Any:
# 1. retrieve documents
retrieval_result = self.knowledge_retriever_tool.invoke({"query": query})
# 1. retrieve documents — called directly rather than through `invoke`,
# which would open a nested tool run. The chat then showed a second card
# per search, named after the retriever instead of the knowledge base
# ("知识库已被删除"), and it never closed because the retriever answers
# with Document objects that the run-log frame could not serialize.
retrieval_result = self.knowledge_retriever_tool._run(query)
llm_inputs = self._get_llm_inputs(query, retrieval_result)
qa_chain = create_stuff_documents_chain(llm=self.llm, prompt=self.chat_prompt)
return qa_chain.invoke(llm_inputs)

async def _arun(self, query: str) -> Any:
retrieval_result = await self.knowledge_retriever_tool.ainvoke({"query": query})
retrieval_result = await self.knowledge_retriever_tool._arun(query)
llm_inputs = self._get_llm_inputs(query, retrieval_result)
qa_chain = create_stuff_documents_chain(llm=self.llm, prompt=self.chat_prompt)
return await qa_chain.ainvoke(llm_inputs)
Expand Down
61 changes: 61 additions & 0 deletions src/backend/test/api/test_assistant_runlog_frames.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""A tool card in the chat closes only when its end frame arrives.

Two ways it never did. The knowledge tool ran its retriever through `invoke`,
which opened a *nested* tool run: a second card per search, named after the
retriever rather than the knowledge base ("知识库已被删除"), whose result is a
list of ``Document`` objects that plain ``json.dumps`` refuses — so the end frame
raised inside the callback and the card spun forever, past the end of the
session, with nothing persisted to recover it from.
"""

from __future__ import annotations

import inspect
import json

from langchain_core.documents import Document

from bisheng.api.v1.callback import AsyncGptsDebugCallbackHandler, _dump_run_log
from bisheng.tool.domain.langchain.knowledge import KnowledgeRagTool


def test_a_document_answer_still_serializes() -> None:
"""The retriever's own answer shape must not be able to suppress a frame."""

payload = {"tool_key": "4138", "output": [Document(page_content="hello")]}

decoded = json.loads(_dump_run_log(payload))

assert decoded["tool_key"] == "4138"
assert "hello" in decoded["output"][0]


def test_chinese_survives_the_frame() -> None:
# ensure_ascii stays off: the card shows this text verbatim.
assert "知识库" in _dump_run_log({"output": "知识库内容"})


def test_a_nameless_end_callback_is_not_fatal() -> None:
"""`on_tool_end` reads the name from kwargs, which is not always populated."""

assert AsyncGptsDebugCallbackHandler.parse_tool_category(None) == ("", "tool")
assert AsyncGptsDebugCallbackHandler.parse_tool_category("") == ("", "tool")


def test_a_knowledge_tool_is_still_recognised_by_its_id() -> None:
name, category = AsyncGptsDebugCallbackHandler.parse_tool_category("knowledge_4138")
assert (name, category) == ("4138", "knowledge")


def test_the_retriever_is_an_internal_step_not_a_tool_call() -> None:
"""Going through `invoke` re-opens the callback machinery for the inner tool.

That is what produced the duplicate, wrongly-named card; the direct call
keeps the retrieval invisible to whoever is watching the outer tool.
"""

for source in (inspect.getsource(KnowledgeRagTool._run), inspect.getsource(KnowledgeRagTool._arun)):
assert "knowledge_retriever_tool.invoke" not in source
assert "knowledge_retriever_tool.ainvoke" not in source
assert "knowledge_retriever_tool._run(query)" in inspect.getsource(KnowledgeRagTool._run)
assert "knowledge_retriever_tool._arun(query)" in inspect.getsource(KnowledgeRagTool._arun)
1 change: 1 addition & 0 deletions src/frontend/client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@
"com_runlog_done": "Done",
"com_runlog_flow_deleted": "The skill has been deleted and the name cannot be retrieved",
"com_runlog_knowledge_deleted": "The knowledge base has been deleted and the name cannot be retrieved",
"com_runlog_interrupted": "The tool call returned no result (the session ended)",
"com_runlog_offline": "Offline",
"com_runlog_searched": "Searched",
"com_runlog_searching": "Searching",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/client/src/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@
"com_runlog_done": "完了",
"com_runlog_flow_deleted": "スキルが削除されたため、スキル名を取得できません",
"com_runlog_knowledge_deleted": "ナレッジベースが削除されたため、名称を取得できません",
"com_runlog_interrupted": "ツール呼び出しは結果を返しませんでした(セッション終了)",
"com_runlog_offline": "オフライン",
"com_runlog_searched": "検索済み",
"com_runlog_searching": "検索中",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/client/src/locales/zh-Hans/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@
"com_runlog_done": "完成",
"com_runlog_flow_deleted": "技能已被删除,无法获取技能名",
"com_runlog_knowledge_deleted": "知识库已被删除,无法获取知识库名",
"com_runlog_interrupted": "工具调用未返回结果(会话已结束)",
"com_runlog_offline": "已下线",
"com_runlog_searched": "已搜索",
"com_runlog_searching": "正在搜索",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { formatDate } from "~/utils";

const runLogsTypes = ['tool', 'flow', 'knowledge']
export const runLogsTypes = ['tool', 'flow', 'knowledge']
// 兼容处理技能和助手
export const SkillMethod = {
/** 获取input发送参数 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default function MessageRunlog({ data }) {
}, [_chatsState, data])

const [title, lost] = useMemo(() => {
// Settled by the session closing rather than by its own end frame — say so
// instead of showing a success tick the call never earned.
if (data.interrupted) return [t('com_runlog_interrupted'), true]
let lost = false
let title = ''
const status = data.end ? t('com_runlog_used') : t('com_runlog_using')
Expand Down
16 changes: 15 additions & 1 deletion src/frontend/client/src/pages/appChat/useChatHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Chat } from "~/@types/chat"
import { baseMsgItem } from "~/api/apps"
import { formatDate, generateUUID } from "~/utils"
import { FLOW_TYPES } from "."
import { SkillMethod } from "./appUtils/skillMethod"
import { runLogsTypes, SkillMethod } from "./appUtils/skillMethod"
import { bishengConfState, chatApiVersionState, chatIdState, chatsState, currentChatState, currentRunningState, runningState } from "./store/atoms"
import { emitAreaTextEvent, EVENT_TYPE } from "./useAreaText"

Expand Down Expand Up @@ -341,6 +341,20 @@ export default function useChatHelpers() {
)
},
skillCloseMsg: () => {
// A tool card only closes when its `end` frame arrives. Lose one — a
// serialization failure, a dropped socket — and it spins forever with
// nothing to recover it: the round is over and nothing was persisted.
// Settle what is still open, marked interrupted rather than wearing a
// success tick it never earned.
setChats((prev) =>
updateChatMessages(prev, chatId, (messages) =>
messages.map((msg) =>
runLogsTypes.includes(msg.category) && !msg.end
? { ...msg, end: true, interrupted: true }
: msg
)
)
)
setRunningState((prev) => {
return {
...prev,
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/en-US/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,8 @@
"flowOffline": "{{name}} is offline",
"flowDeleted": "The skill has been deleted, its name is unavailable",
"toolDeleted": "The tool has been deleted, its name is unavailable",
"knowledgeDeleted": "The knowledge base has been deleted, its name is unavailable"
"knowledgeDeleted": "The knowledge base has been deleted, its name is unavailable",
"interrupted": "The tool call returned no result (the session ended)"
}
},
"importLinsight": {
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/ja/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,8 @@
"flowOffline": "{{name}} はオフラインです",
"flowDeleted": "スキルが削除されているため、スキル名を取得できません",
"toolDeleted": "ツールが削除されているため、ツール名を取得できません",
"knowledgeDeleted": "ナレッジベースが削除されているため、名称を取得できません"
"knowledgeDeleted": "ナレッジベースが削除されているため、名称を取得できません",
"interrupted": "ツール呼び出しは結果を返しませんでした(セッション終了)"
}
},
"model": {
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/zh-Hans/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,8 @@
"flowOffline": "{{name}} 已下线",
"flowDeleted": "技能已被删除,无法获取技能名",
"toolDeleted": "工具已被删除,无法获取工具名",
"knowledgeDeleted": "知识库已被删除,无法获取知识库名"
"knowledgeDeleted": "知识库已被删除,无法获取知识库名",
"interrupted": "工具调用未返回结果(会话已结束)"
}
},
"model": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function ChatInput({ clear, form, questions, inputForm, wsUrl, on

const { isLoading: audioOpening } = useAudioStore()

const { messages, hisMessages, chatId, createSendMsg, createWsMsg, updateCurrentMessage, destory, setShowGuideQuestion } = useMessageStore()
const { messages, hisMessages, chatId, createSendMsg, createWsMsg, updateCurrentMessage, closeDanglingRunLogs, destory, setShowGuideQuestion } = useMessageStore()
const currentChatIdRef = useRef(null)
const inputRef = useRef(null)
const continueRef = useRef(false)
Expand Down Expand Up @@ -256,6 +256,7 @@ export default function ChatInput({ clear, form, questions, inputForm, wsUrl, on

if (!msgClosedRef.current) msgClosedRef.current = true
} else if (data.type === "close") {
closeDanglingRunLogs()
setStop({ show: false, disable: false })
setInputLock((prev) => (prev.reason ? prev : { locked: false, reason: '' }))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default function RunLog({ data }) {
const assistantState = useAssistantStore(state => state.assistantState)

const [title, lost] = useMemo(() => {
// Settled by the session closing rather than by its own end frame — say so
// instead of showing a success tick the call never earned.
if (data.interrupted) return [t('chat.runLog.interrupted'), true]
let lost = false
let title = ''
const status = data.end ? t('chat.runLog.used') : t('chat.runLog.using')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Actions = {
insetSystemMsg: (text: string) => void;
insetBsMsg: (text: string) => void;
setShowGuideQuestion: (text: boolean) => void;
closeDanglingRunLogs: () => void;
clearMsgs: () => void;
}

Expand Down Expand Up @@ -152,6 +153,24 @@ export const useMessageStore = create<State & Actions>((set, get) => ({
set({ historyEnd: true })
}
},
/**
* A tool card only closes when its `end` frame arrives. Lose one — a
* serialization failure, a dropped socket — and the card spins forever, with
* nothing to recover it: the round is over and nothing was persisted. So on
* session close, settle whatever is still open and mark it interrupted
* rather than leaving a success tick it never earned.
*/
closeDanglingRunLogs() {
const messages = get().messages
if (!messages.some(msg => runLogsTypes.includes(msg.category) && !msg.end)) return
set({
messages: messages.map(msg =>
runLogsTypes.includes(msg.category) && !msg.end
? { ...msg, end: true, interrupted: true }
: msg
)
})
},
clearMsgs() {
setTimeout(() => {
set({ hisMessages: [], messages: [], historyEnd: true })
Expand Down
58 changes: 58 additions & 0 deletions src/frontend/platform/src/test/assistantRunLogClosing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useMessageStore } from "@/components/bs-comp/chatComponent/messageStore";
import { ChatMessageType } from "@/types/chat";
import { beforeEach, describe, expect, it } from "vitest";

type RunLogCard = ChatMessageType & { interrupted?: boolean };

const asMessages = (rows: unknown[]) => rows as ChatMessageType[];
const readMessages = () => useMessageStore.getState().messages as RunLogCard[];

/**
* A tool card closes only when its own end frame arrives. One got lost — the
* knowledge retriever answered with objects the frame could not serialize — and
* the card kept spinning after the session had ended, with nothing persisted to
* recover it from. Closing the session must settle whatever is still open.
*/
describe("dangling run-log cards at session close", () => {
const runLog = (id: string, end: boolean) => ({
id,
category: "knowledge",
end,
message: { tool_key: "4138" },
thought: "",
});

beforeEach(() => {
useMessageStore.setState({ messages: [], hisMessages: [] });
});

it("settles a tool card whose end frame never arrived", () => {
useMessageStore.setState({ messages: asMessages([runLog("a", true), runLog("b", false)]) });

useMessageStore.getState().closeDanglingRunLogs();

const [settled, interrupted] = readMessages();
expect(settled.interrupted).toBeUndefined();
// Marked, not silently ticked: it never earned a success icon.
expect(interrupted.end).toBe(true);
expect(interrupted.interrupted).toBe(true);
});

it("leaves the streaming answer alone", () => {
const answer = { id: "answer", category: "answer", end: false, message: "", thought: "" };
useMessageStore.setState({ messages: asMessages([answer]) });

useMessageStore.getState().closeDanglingRunLogs();

expect(readMessages()[0].end).toBe(false);
});

it("does not touch the list when every card is already closed", () => {
const messages = asMessages([runLog("a", true)]);
useMessageStore.setState({ messages });

useMessageStore.getState().closeDanglingRunLogs();

expect(useMessageStore.getState().messages).toBe(messages);
});
});
Loading