mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 14:12:58 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""Tests for docsgpt/api/answer/services/stream_processor.py.
|
|
|
|
The previous suite was tightly coupled to Mongo (mock_mongo_db fixture,
|
|
bson.ObjectId, bson.DBRef, find_one, etc.) which no longer exist after the
|
|
Mongo -> Postgres cutover. Rewriting these ~18 tests against the new
|
|
repositories (AgentsRepository / PromptsRepository / ConversationsRepository)
|
|
requires meaningful setup that is best done alongside the migration of the
|
|
StreamProcessor internals themselves.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
# A static 24-hex-char string that is a valid ObjectId hex representation.
|
|
_STATIC_OID = "507f1f77bcf86cd799439011"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetPromptFunction:
|
|
pass
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorInitialization:
|
|
pass
|
|
|
|
def test_initializes_with_decoded_token(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
conv_id = _STATIC_OID
|
|
request_data = {
|
|
"question": "What is Python?",
|
|
"conversation_id": conv_id,
|
|
}
|
|
decoded_token = {"sub": "user_123", "email": "test@example.com"}
|
|
|
|
processor = StreamProcessor(request_data, decoded_token)
|
|
|
|
assert processor.data == request_data
|
|
assert processor.decoded_token == decoded_token
|
|
assert processor.initial_user_id == "user_123"
|
|
assert processor.conversation_id == request_data["conversation_id"]
|
|
|
|
def test_initializes_without_token(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Test question"}
|
|
|
|
processor = StreamProcessor(request_data, None)
|
|
|
|
assert processor.decoded_token is None
|
|
assert processor.initial_user_id is None
|
|
assert processor.data == request_data
|
|
|
|
def test_initializes_default_attributes(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor({"question": "Test"}, {"sub": "user_123"})
|
|
|
|
assert processor.source == {}
|
|
assert processor.all_sources == []
|
|
assert processor.attachments == []
|
|
assert processor.history == []
|
|
assert processor.agent_config == {}
|
|
assert processor.retriever_config == {}
|
|
assert processor.is_shared_usage is False
|
|
assert processor.shared_token is None
|
|
|
|
def test_extracts_conversation_id_from_request(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
conv_id = _STATIC_OID
|
|
request_data = {"question": "Test", "conversation_id": conv_id}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.conversation_id == conv_id
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorHistoryLoading:
|
|
pass
|
|
|
|
def test_uses_request_history_when_no_conversation_id(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "What is Python?",
|
|
"history": [{"prompt": "Hello", "response": "Hi there!"}],
|
|
}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.conversation_id is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorAgentConfiguration:
|
|
pass
|
|
|
|
def test_uses_default_config_without_api_key(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Test"}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert isinstance(processor.agent_config, dict)
|
|
assert processor.agent_id is None
|
|
|
|
def test_embedded_workflow_without_saved_id(self):
|
|
"""A preview run with no saved workflow id carries no ``workflow_id``."""
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "Test",
|
|
"workflow": {"nodes": [], "edges": []},
|
|
}
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert processor.agent_config["agent_type"] == "workflow"
|
|
assert processor.agent_config["workflow"] == {"nodes": [], "edges": []}
|
|
assert "workflow_id" not in processor.agent_config
|
|
|
|
def test_embedded_workflow_with_saved_id_persists_run(self):
|
|
"""A saved workflow id alongside the embedded graph is captured so the
|
|
run can persist a ``workflow_runs`` row for artifact listing."""
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "Test",
|
|
"workflow": {"nodes": [], "edges": []},
|
|
"workflow_id": "11111111-1111-1111-1111-111111111111",
|
|
}
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert processor.agent_config["agent_type"] == "workflow"
|
|
assert (
|
|
processor.agent_config["workflow_id"]
|
|
== "11111111-1111-1111-1111-111111111111"
|
|
)
|
|
assert processor.agent_config["workflow_owner"] == "user_123"
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorDocPrefetch:
|
|
pass
|
|
|
|
def test_prefetch_skipped_when_no_active_docs(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor(
|
|
{"question": "Hi there"},
|
|
{"sub": "user_123"},
|
|
)
|
|
processor.initialize()
|
|
processor.create_retriever = MagicMock()
|
|
|
|
docs_together, docs = processor.pre_fetch_docs("Hi there")
|
|
|
|
processor.create_retriever.assert_not_called()
|
|
assert docs_together is None
|
|
assert docs is None
|
|
|
|
def test_prefetch_skipped_when_active_docs_is_default(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor(
|
|
{"question": "Hi", "active_docs": "default"},
|
|
{"sub": "user_123"},
|
|
)
|
|
processor.initialize()
|
|
processor.create_retriever = MagicMock()
|
|
|
|
docs_together, docs = processor.pre_fetch_docs("Hi")
|
|
|
|
processor.create_retriever.assert_not_called()
|
|
assert docs_together is None
|
|
assert docs is None
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorAttachments:
|
|
pass
|
|
|
|
def test_handles_empty_attachments(self):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Simple question"}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.attachments == []
|
|
assert (
|
|
"attachments" not in processor.data
|
|
or processor.data.get("attachments") is None
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestToolPreFetch:
|
|
"""Tests for tool pre-fetching with saved parameter values."""
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestBuildContinuationFromMessages:
|
|
"""Stateless tool continuation rebuilt from the resent messages array.
|
|
|
|
OpenAI-compatible clients (opencode, etc.) resend the full conversation but
|
|
carry no conversation_id, so the agent + pending tool calls are reconstructed
|
|
directly from the request instead of from server-side ``pending_tool_state``.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_processor():
|
|
from unittest.mock import MagicMock
|
|
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor({"question": ""}, {"sub": "user_123"})
|
|
fake_agent = MagicMock()
|
|
fake_agent.tool_executor.get_tools.return_value = {"search": object()}
|
|
# build_agent touches the DB / config; stub it for this unit test.
|
|
processor.build_agent = MagicMock(return_value=fake_agent)
|
|
return processor, fake_agent
|
|
|
|
def _messages_with_tool_call(self, arguments):
|
|
return [
|
|
{"role": "system", "content": "You are a bot"},
|
|
{"role": "user", "content": "search the docs"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": arguments},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "found it"},
|
|
]
|
|
|
|
def test_rebuilds_pending_tool_calls_and_prior_messages(self):
|
|
processor, fake_agent = self._make_processor()
|
|
messages = self._messages_with_tool_call('{"q": "docs"}')
|
|
tool_actions = [{"call_id": "call_1", "result": "found it"}]
|
|
|
|
(
|
|
agent,
|
|
prior_messages,
|
|
tools_dict,
|
|
pending_tool_calls,
|
|
returned_actions,
|
|
reasoning,
|
|
) = processor.build_continuation_from_messages(messages, tool_actions)
|
|
|
|
assert agent is fake_agent
|
|
assert tools_dict == fake_agent.tool_executor.get_tools.return_value
|
|
assert returned_actions is tool_actions
|
|
assert reasoning == ""
|
|
# prior_messages stop before the assistant-with-tool_calls message.
|
|
assert prior_messages == messages[:2]
|
|
assert len(pending_tool_calls) == 1
|
|
call = pending_tool_calls[0]
|
|
assert call["call_id"] == "call_1"
|
|
assert call["name"] == "search"
|
|
assert call["tool_name"] == "search"
|
|
assert call["action_name"] == "search"
|
|
assert call["arguments"] == {"q": "docs"}
|
|
processor.build_agent.assert_called_once_with("")
|
|
|
|
def test_invalid_json_arguments_default_to_empty_dict(self):
|
|
processor, _ = self._make_processor()
|
|
messages = self._messages_with_tool_call("not-json")
|
|
|
|
_, _, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["arguments"] == {}
|
|
|
|
def test_dict_arguments_passed_through(self):
|
|
processor, _ = self._make_processor()
|
|
messages = self._messages_with_tool_call({"already": "parsed"})
|
|
|
|
_, _, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["arguments"] == {"already": "parsed"}
|
|
|
|
def test_uses_last_assistant_tool_call_message(self):
|
|
processor, _ = self._make_processor()
|
|
messages = [
|
|
{"role": "user", "content": "first"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "old",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "old", "content": "r1"},
|
|
{"role": "user", "content": "second"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "new",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "new", "content": "r2"},
|
|
]
|
|
|
|
_, prior_messages, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["call_id"] == "new"
|
|
# Everything up to (not including) the last assistant tool_calls message.
|
|
assert prior_messages == messages[:4]
|
|
|
|
def test_raises_when_no_assistant_tool_calls(self):
|
|
processor, _ = self._make_processor()
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": "no tools here"},
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="No assistant message with tool_calls"):
|
|
processor.build_continuation_from_messages(messages, [])
|
|
|
|
|