mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 10:13:39 +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.
552 lines
16 KiB
Python
552 lines
16 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from docsgpt.agents.workflows.schemas import (
|
|
AgentNodeConfig,
|
|
AgentType,
|
|
ConditionCase,
|
|
ConditionNodeConfig,
|
|
ExecutionStatus,
|
|
NodeExecutionLog,
|
|
NodeType,
|
|
Position,
|
|
StateOperation,
|
|
Workflow,
|
|
WorkflowCreate,
|
|
WorkflowEdge,
|
|
WorkflowEdgeCreate,
|
|
WorkflowGraph,
|
|
WorkflowNode,
|
|
WorkflowNodeCreate,
|
|
WorkflowRun,
|
|
WorkflowRunCreate,
|
|
)
|
|
|
|
|
|
# ── Enum tests ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestNodeType:
|
|
@pytest.mark.unit
|
|
def test_values(self):
|
|
assert NodeType.START == "start"
|
|
assert NodeType.END == "end"
|
|
assert NodeType.AGENT == "agent"
|
|
assert NodeType.NOTE == "note"
|
|
assert NodeType.STATE == "state"
|
|
assert NodeType.CONDITION == "condition"
|
|
assert NodeType.CODE == "code"
|
|
|
|
@pytest.mark.unit
|
|
def test_all_members(self):
|
|
assert set(NodeType) == {
|
|
NodeType.START,
|
|
NodeType.END,
|
|
NodeType.AGENT,
|
|
NodeType.NOTE,
|
|
NodeType.STATE,
|
|
NodeType.CONDITION,
|
|
NodeType.CODE,
|
|
}
|
|
|
|
|
|
class TestAgentType:
|
|
@pytest.mark.unit
|
|
def test_values(self):
|
|
assert AgentType.CLASSIC == "classic"
|
|
assert AgentType.REACT == "react"
|
|
assert AgentType.AGENTIC == "agentic"
|
|
assert AgentType.RESEARCH == "research"
|
|
|
|
|
|
class TestExecutionStatus:
|
|
@pytest.mark.unit
|
|
def test_values(self):
|
|
assert ExecutionStatus.PENDING == "pending"
|
|
assert ExecutionStatus.RUNNING == "running"
|
|
assert ExecutionStatus.COMPLETED == "completed"
|
|
assert ExecutionStatus.FAILED == "failed"
|
|
|
|
|
|
# ── Position ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPosition:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
p = Position()
|
|
assert p.x == 0.0
|
|
assert p.y == 0.0
|
|
|
|
@pytest.mark.unit
|
|
def test_custom_values(self):
|
|
p = Position(x=10.5, y=-3.2)
|
|
assert p.x == 10.5
|
|
assert p.y == -3.2
|
|
|
|
@pytest.mark.unit
|
|
def test_extra_fields_forbidden(self):
|
|
with pytest.raises(ValidationError):
|
|
Position(x=0, y=0, z=1)
|
|
|
|
|
|
# ── AgentNodeConfig ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAgentNodeConfig:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
c = AgentNodeConfig()
|
|
assert c.agent_type == AgentType.CLASSIC
|
|
assert c.llm_name is None
|
|
assert c.system_prompt == "You are a helpful assistant."
|
|
assert c.prompt_template == ""
|
|
assert c.output_variable is None
|
|
assert c.stream_to_user is True
|
|
assert c.tools == []
|
|
assert c.sources == []
|
|
assert c.chunks == "2"
|
|
assert c.retriever == ""
|
|
assert c.model_id is None
|
|
assert c.json_schema is None
|
|
|
|
@pytest.mark.unit
|
|
def test_custom_values(self):
|
|
c = AgentNodeConfig(
|
|
agent_type=AgentType.REACT,
|
|
llm_name="gpt-4",
|
|
tools=["search"],
|
|
sources=["src1"],
|
|
chunks="5",
|
|
model_id="m1",
|
|
json_schema={"type": "object"},
|
|
)
|
|
assert c.agent_type == AgentType.REACT
|
|
assert c.llm_name == "gpt-4"
|
|
assert c.tools == ["search"]
|
|
assert c.sources == ["src1"]
|
|
assert c.chunks == "5"
|
|
assert c.model_id == "m1"
|
|
assert c.json_schema == {"type": "object"}
|
|
|
|
@pytest.mark.unit
|
|
def test_extra_fields_allowed(self):
|
|
c = AgentNodeConfig(custom_field="value")
|
|
assert c.custom_field == "value"
|
|
|
|
|
|
# ── ConditionCase / ConditionNodeConfig ──────────────────────────────────────
|
|
|
|
|
|
class TestConditionCase:
|
|
@pytest.mark.unit
|
|
def test_alias(self):
|
|
c = ConditionCase(expression="x > 1", sourceHandle="handle-1")
|
|
assert c.source_handle == "handle-1"
|
|
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
c = ConditionCase(sourceHandle="h")
|
|
assert c.name is None
|
|
assert c.expression == ""
|
|
|
|
@pytest.mark.unit
|
|
def test_extra_forbidden(self):
|
|
with pytest.raises(ValidationError):
|
|
ConditionCase(sourceHandle="h", extra="nope")
|
|
|
|
|
|
class TestConditionNodeConfig:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
c = ConditionNodeConfig()
|
|
assert c.mode == "simple"
|
|
assert c.cases == []
|
|
|
|
@pytest.mark.unit
|
|
def test_with_cases(self):
|
|
c = ConditionNodeConfig(
|
|
mode="advanced",
|
|
cases=[{"expression": "x > 1", "sourceHandle": "h1"}],
|
|
)
|
|
assert c.mode == "advanced"
|
|
assert len(c.cases) == 1
|
|
assert c.cases[0].source_handle == "h1"
|
|
|
|
|
|
# ── StateOperation ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestStateOperation:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
s = StateOperation()
|
|
assert s.expression == ""
|
|
assert s.target_variable == ""
|
|
|
|
@pytest.mark.unit
|
|
def test_extra_forbidden(self):
|
|
with pytest.raises(ValidationError):
|
|
StateOperation(expression="a", target_variable="b", extra="no")
|
|
|
|
|
|
# ── WorkflowEdgeCreate / WorkflowEdge ───────────────────────────────────────
|
|
|
|
|
|
class TestWorkflowEdgeCreate:
|
|
@pytest.mark.unit
|
|
def test_aliases(self):
|
|
e = WorkflowEdgeCreate(
|
|
id="e1",
|
|
workflow_id="w1",
|
|
source="n1",
|
|
target="n2",
|
|
sourceHandle="sh",
|
|
targetHandle="th",
|
|
)
|
|
assert e.source_id == "n1"
|
|
assert e.target_id == "n2"
|
|
assert e.source_handle == "sh"
|
|
assert e.target_handle == "th"
|
|
|
|
@pytest.mark.unit
|
|
def test_optional_handles_default_none(self):
|
|
e = WorkflowEdgeCreate(id="e1", workflow_id="w1", source="n1", target="n2")
|
|
assert e.source_handle is None
|
|
assert e.target_handle is None
|
|
|
|
|
|
class TestWorkflowEdge:
|
|
@pytest.mark.unit
|
|
def test_uuid_id(self):
|
|
oid = str(uuid.uuid4())
|
|
e = WorkflowEdge(
|
|
id=oid,
|
|
workflow_id="w1",
|
|
source="n1",
|
|
target="n2",
|
|
)
|
|
assert e.id == oid
|
|
|
|
@pytest.mark.unit
|
|
def test_string_id_passthrough(self):
|
|
e = WorkflowEdge(
|
|
id="string-id",
|
|
workflow_id="w1",
|
|
source="n1",
|
|
target="n2",
|
|
)
|
|
assert e.id == "string-id"
|
|
|
|
@pytest.mark.unit
|
|
def test_model_dump(self):
|
|
e = WorkflowEdge(
|
|
id="e1",
|
|
workflow_id="w1",
|
|
source="n1",
|
|
target="n2",
|
|
sourceHandle="sh",
|
|
targetHandle="th",
|
|
)
|
|
doc = e.model_dump()
|
|
assert doc == {
|
|
"id": "e1",
|
|
"workflow_id": "w1",
|
|
"source_id": "n1",
|
|
"target_id": "n2",
|
|
"source_handle": "sh",
|
|
"target_handle": "th",
|
|
}
|
|
|
|
|
|
# ── WorkflowNodeCreate / WorkflowNode ───────────────────────────────────────
|
|
|
|
|
|
class TestWorkflowNodeCreate:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
n = WorkflowNodeCreate(id="n1", workflow_id="w1", type=NodeType.AGENT)
|
|
assert n.title == "Node"
|
|
assert n.description is None
|
|
assert n.position.x == 0.0
|
|
assert n.position.y == 0.0
|
|
assert n.config == {}
|
|
|
|
@pytest.mark.unit
|
|
def test_position_from_dict(self):
|
|
n = WorkflowNodeCreate(
|
|
id="n1",
|
|
workflow_id="w1",
|
|
type=NodeType.START,
|
|
position={"x": 100, "y": 200},
|
|
)
|
|
assert isinstance(n.position, Position)
|
|
assert n.position.x == 100
|
|
assert n.position.y == 200
|
|
|
|
@pytest.mark.unit
|
|
def test_position_from_position_object(self):
|
|
pos = Position(x=5, y=10)
|
|
n = WorkflowNodeCreate(
|
|
id="n1", workflow_id="w1", type=NodeType.END, position=pos
|
|
)
|
|
assert n.position is pos
|
|
|
|
|
|
class TestWorkflowNode:
|
|
@pytest.mark.unit
|
|
def test_uuid_id(self):
|
|
oid = str(uuid.uuid4())
|
|
n = WorkflowNode(
|
|
id=oid, workflow_id="w1", type=NodeType.AGENT
|
|
)
|
|
assert n.id == oid
|
|
|
|
@pytest.mark.unit
|
|
def test_model_dump(self):
|
|
n = WorkflowNode(
|
|
id="n1",
|
|
workflow_id="w1",
|
|
type=NodeType.AGENT,
|
|
title="My Node",
|
|
description="desc",
|
|
position={"x": 10, "y": 20},
|
|
config={"key": "val"},
|
|
)
|
|
doc = n.model_dump()
|
|
assert doc == {
|
|
"id": "n1",
|
|
"workflow_id": "w1",
|
|
"type": NodeType.AGENT,
|
|
"title": "My Node",
|
|
"description": "desc",
|
|
"position": {"x": 10.0, "y": 20.0},
|
|
"config": {"key": "val"},
|
|
}
|
|
|
|
|
|
# ── WorkflowCreate / Workflow ───────────────────────────────────────────────
|
|
|
|
|
|
class TestWorkflowCreate:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
w = WorkflowCreate()
|
|
assert w.name == "New Workflow"
|
|
assert w.description is None
|
|
assert w.user is None
|
|
|
|
@pytest.mark.unit
|
|
def test_custom_values(self):
|
|
w = WorkflowCreate(name="Test", description="d", user="u1")
|
|
assert w.name == "Test"
|
|
assert w.description == "d"
|
|
assert w.user == "u1"
|
|
|
|
|
|
class TestWorkflow:
|
|
@pytest.mark.unit
|
|
def test_uuid_id(self):
|
|
oid = str(uuid.uuid4())
|
|
w = Workflow(id=oid)
|
|
assert w.id == oid
|
|
|
|
@pytest.mark.unit
|
|
def test_string_id(self):
|
|
w = Workflow(id="abc")
|
|
assert w.id == "abc"
|
|
|
|
@pytest.mark.unit
|
|
def test_none_id(self):
|
|
w = Workflow()
|
|
assert w.id is None
|
|
|
|
@pytest.mark.unit
|
|
def test_datetime_defaults(self):
|
|
before = datetime.now(timezone.utc)
|
|
w = Workflow()
|
|
after = datetime.now(timezone.utc)
|
|
assert before <= w.created_at <= after
|
|
assert before <= w.updated_at <= after
|
|
|
|
@pytest.mark.unit
|
|
def test_model_dump(self):
|
|
w = Workflow(name="W", description="d", user="u1")
|
|
doc = w.model_dump()
|
|
assert doc["name"] == "W"
|
|
assert doc["description"] == "d"
|
|
assert doc["user"] == "u1"
|
|
assert "created_at" in doc
|
|
assert "updated_at" in doc
|
|
|
|
|
|
# ── WorkflowGraph ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestWorkflowGraph:
|
|
@pytest.fixture
|
|
def graph(self):
|
|
workflow = Workflow(name="test")
|
|
nodes = [
|
|
WorkflowNode(id="start", workflow_id="w1", type=NodeType.START),
|
|
WorkflowNode(id="agent1", workflow_id="w1", type=NodeType.AGENT),
|
|
WorkflowNode(id="end", workflow_id="w1", type=NodeType.END),
|
|
]
|
|
edges = [
|
|
WorkflowEdge(
|
|
id="e1", workflow_id="w1", source="start", target="agent1"
|
|
),
|
|
WorkflowEdge(
|
|
id="e2", workflow_id="w1", source="agent1", target="end"
|
|
),
|
|
]
|
|
return WorkflowGraph(workflow=workflow, nodes=nodes, edges=edges)
|
|
|
|
@pytest.mark.unit
|
|
def test_get_node_by_id_found(self, graph):
|
|
node = graph.get_node_by_id("agent1")
|
|
assert node is not None
|
|
assert node.id == "agent1"
|
|
assert node.type == NodeType.AGENT
|
|
|
|
@pytest.mark.unit
|
|
def test_get_node_by_id_not_found(self, graph):
|
|
assert graph.get_node_by_id("nonexistent") is None
|
|
|
|
@pytest.mark.unit
|
|
def test_get_start_node(self, graph):
|
|
start = graph.get_start_node()
|
|
assert start is not None
|
|
assert start.id == "start"
|
|
assert start.type == NodeType.START
|
|
|
|
@pytest.mark.unit
|
|
def test_get_start_node_missing(self):
|
|
g = WorkflowGraph(
|
|
workflow=Workflow(),
|
|
nodes=[
|
|
WorkflowNode(id="a", workflow_id="w", type=NodeType.AGENT),
|
|
],
|
|
)
|
|
assert g.get_start_node() is None
|
|
|
|
@pytest.mark.unit
|
|
def test_get_outgoing_edges(self, graph):
|
|
edges = graph.get_outgoing_edges("start")
|
|
assert len(edges) == 1
|
|
assert edges[0].target_id == "agent1"
|
|
|
|
@pytest.mark.unit
|
|
def test_get_outgoing_edges_none(self, graph):
|
|
edges = graph.get_outgoing_edges("end")
|
|
assert edges == []
|
|
|
|
@pytest.mark.unit
|
|
def test_empty_graph(self):
|
|
g = WorkflowGraph(workflow=Workflow())
|
|
assert g.nodes == []
|
|
assert g.edges == []
|
|
assert g.get_start_node() is None
|
|
|
|
|
|
# ── NodeExecutionLog ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestNodeExecutionLog:
|
|
@pytest.mark.unit
|
|
def test_required_fields(self):
|
|
now = datetime.now(timezone.utc)
|
|
log = NodeExecutionLog(
|
|
node_id="n1",
|
|
node_type="agent",
|
|
status=ExecutionStatus.RUNNING,
|
|
started_at=now,
|
|
)
|
|
assert log.node_id == "n1"
|
|
assert log.completed_at is None
|
|
assert log.error is None
|
|
assert log.state_delta == {}
|
|
|
|
@pytest.mark.unit
|
|
def test_full_log(self):
|
|
started = datetime.now(timezone.utc)
|
|
completed = datetime.now(timezone.utc)
|
|
log = NodeExecutionLog(
|
|
node_id="n1",
|
|
node_type="agent",
|
|
status=ExecutionStatus.COMPLETED,
|
|
started_at=started,
|
|
completed_at=completed,
|
|
error=None,
|
|
state_delta={"key": "value"},
|
|
)
|
|
assert log.completed_at == completed
|
|
assert log.state_delta == {"key": "value"}
|
|
|
|
@pytest.mark.unit
|
|
def test_extra_forbidden(self):
|
|
with pytest.raises(ValidationError):
|
|
NodeExecutionLog(
|
|
node_id="n",
|
|
node_type="agent",
|
|
status=ExecutionStatus.PENDING,
|
|
started_at=datetime.now(timezone.utc),
|
|
extra="no",
|
|
)
|
|
|
|
|
|
# ── WorkflowRunCreate / WorkflowRun ─────────────────────────────────────────
|
|
|
|
|
|
class TestWorkflowRunCreate:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
r = WorkflowRunCreate(workflow_id="w1")
|
|
assert r.workflow_id == "w1"
|
|
assert r.inputs == {}
|
|
|
|
|
|
class TestWorkflowRun:
|
|
@pytest.mark.unit
|
|
def test_defaults(self):
|
|
r = WorkflowRun(workflow_id="w1")
|
|
assert r.status == ExecutionStatus.PENDING
|
|
assert r.inputs == {}
|
|
assert r.outputs == {}
|
|
assert r.steps == []
|
|
assert r.completed_at is None
|
|
|
|
@pytest.mark.unit
|
|
def test_uuid_id(self):
|
|
oid = str(uuid.uuid4())
|
|
r = WorkflowRun(id=oid, workflow_id="w1")
|
|
assert r.id == oid
|
|
|
|
@pytest.mark.unit
|
|
def test_model_dump(self):
|
|
now = datetime.now(timezone.utc)
|
|
log = NodeExecutionLog(
|
|
node_id="n1",
|
|
node_type="agent",
|
|
status=ExecutionStatus.COMPLETED,
|
|
started_at=now,
|
|
)
|
|
r = WorkflowRun(
|
|
workflow_id="w1",
|
|
status=ExecutionStatus.RUNNING,
|
|
inputs={"q": "hello"},
|
|
outputs={"a": "world"},
|
|
steps=[log],
|
|
)
|
|
doc = r.model_dump()
|
|
assert doc["workflow_id"] == "w1"
|
|
assert doc["status"] == ExecutionStatus.RUNNING
|
|
assert doc["inputs"] == {"q": "hello"}
|
|
assert doc["outputs"] == {"a": "world"}
|
|
assert len(doc["steps"]) == 1
|
|
assert doc["steps"][0]["node_id"] == "n1"
|
|
assert doc["completed_at"] is None
|