refactor: rename the application package to docsgpt

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.
This commit is contained in:
Alex committed 2026-09-07 10:20:43 +01:00
1 parent 3e7f1f91b4
commit 574f96341e
984 files changed
+9847 -9682

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
import re
from typing import Any, Dict
import celpy
import celpy.celtypes
class CelEvaluationError(Exception):
pass
def _convert_value(value: Any) -> Any:
if isinstance(value, bool):
return celpy.celtypes.BoolType(value)
if isinstance(value, int):
return celpy.celtypes.IntType(value)
if isinstance(value, float):
return celpy.celtypes.DoubleType(value)
if isinstance(value, str):
return celpy.celtypes.StringType(value)
if isinstance(value, list):
return celpy.celtypes.ListType([_convert_value(item) for item in value])
if isinstance(value, dict):
return celpy.celtypes.MapType(
{celpy.celtypes.StringType(k): _convert_value(v) for k, v in value.items()}
)
if value is None:
return celpy.celtypes.BoolType(False)
return celpy.celtypes.StringType(str(value))
def build_activation(state: Dict[str, Any]) -> Dict[str, Any]:
return {k: _convert_value(v) for k, v in state.items()}
_MAX_ERROR_CHARS = 200
def _summarize_cel_error(exc: Exception) -> str:
"""Reduce a celpy error to a short, value-free description.
Two problems with the raw text. ``undeclared reference`` errors embed a
repr of the *entire* activation — every binding, thousands of characters,
including the user's own query. Others interpolate the offending **value**
(``invalid literal for int() with base 10: StringType('…')``). Both get
logged and, now that config errors skip ``sanitize_api_error``, shown. So
strip the activation dump, drop anything that looks like a quoted value,
and cap the result.
"""
text = " ".join(str(exc).split())
marker = " (in activation"
if marker in text:
text = text.split(marker, 1)[0]
text = text.strip().lstrip("(").strip()
# Redact quoted fragments by DEFAULT. celpy interpolates state values into
# messages (``StringType('SECRET')``), and a value that happens to look
# like an identifier is indistinguishable from a variable name — so allow
# only the two positions that are known to hold names, not data: the
# offending variable in "undeclared reference to 'x'", and the exception
# class in "<class 'ValueError'>".
def _redact(match: "re.Match[str]") -> str:
inner = match.group(1)
prefix = text[max(0, match.start() - 14):match.start()]
if prefix.endswith("reference to ") or prefix.endswith("<class "):
return f"'{inner}'"
return "'…'"
text = re.sub(r"'([^']*)'", _redact, text)
text = text.strip().strip('"').strip()
if len(text) > _MAX_ERROR_CHARS:
text = text[: _MAX_ERROR_CHARS - 1].rstrip() + "…"
return text
def _looks_like_template_syntax(expression: str) -> bool:
"""Whether the source contains a ``{{ … }}`` substitution.
Only consulted **after** compilation has already failed: ``{{`` is legal
CEL inside a string literal (``x == "{{y}}"``) and at the head of a nested
map literal (``{{"a": 1}: 2}``), both of which compile fine and must not be
rejected.
"""
return "{{" in expression and "}}" in expression
def _template_syntax_error(expression: str) -> "CelEvaluationError":
inner = expression.split("{{", 1)[1].split("}}", 1)[0].strip()
suggestion = inner or "query"
return CelEvaluationError(
"This field takes a CEL expression, not {{ }} template syntax. "
f"Write {suggestion} instead of {{{{{suggestion}}}}}."
)
def _compile(expression: str):
"""Compile, translating failures into actionable messages."""
try:
return celpy.Environment().compile(expression)
except Exception as exc:
# Agent ``prompt_template`` and end ``output_template`` fields *are*
# Jinja2, so ``{{query}}`` is correct one panel over. Here it fails to
# parse with a bare caret, which reads as "the product is broken"
# rather than "wrong syntax for this field".
if _looks_like_template_syntax(expression):
raise _template_syntax_error(expression) from exc
raise CelEvaluationError(f"CEL error: {_summarize_cel_error(exc)}") from exc
def validate_cel_expression(expression: str) -> None:
"""Compile-check an expression without running it.
Used at save time. Only syntax is knowable then — workflow state is built
during a run, so unresolved names are *not* an error here.
Args:
expression: The CEL source to check.
Raises:
CelEvaluationError: If the expression is empty or does not compile.
"""
if not expression or not expression.strip():
raise CelEvaluationError("Empty expression")
_compile(expression)
def evaluate_cel(expression: str, state: Dict[str, Any]) -> Any:
if not expression or not expression.strip():
raise CelEvaluationError("Empty expression")
ast = _compile(expression)
try:
env = celpy.Environment()
program = env.program(ast)
activation = build_activation(state)
result = program.evaluate(activation)
except celpy.CELEvalError as exc:
raise CelEvaluationError(
f"CEL evaluation error: {_summarize_cel_error(exc)}"
) from exc
except Exception as exc:
raise CelEvaluationError(f"CEL error: {_summarize_cel_error(exc)}") from exc
return cel_to_python(result)
def cel_to_python(value: Any) -> Any:
if isinstance(value, celpy.celtypes.BoolType):
return bool(value)
if isinstance(value, celpy.celtypes.IntType):
return int(value)
if isinstance(value, celpy.celtypes.DoubleType):
return float(value)
if isinstance(value, celpy.celtypes.StringType):
return str(value)
if isinstance(value, celpy.celtypes.ListType):
return [cel_to_python(item) for item in value]
if isinstance(value, celpy.celtypes.MapType):
return {str(k): cel_to_python(v) for k, v in value.items()}
return value
+81
View File
@@ -0,0 +1,81 @@
"""Workflow Node Agents - defines specialized agents for workflow nodes."""
from typing import Dict, List, Optional, Type
from docsgpt.agents.agentic_agent import AgenticAgent
from docsgpt.agents.base import BaseAgent
from docsgpt.agents.classic_agent import ClassicAgent
from docsgpt.agents.research_agent import ResearchAgent
from docsgpt.agents.workflows.schemas import AgentType
class _WorkflowNodeMixin:
"""Common __init__ for all workflow node agents."""
def __init__(
self,
endpoint: str,
llm_name: str,
model_id: str,
api_key: str,
tool_ids: Optional[List[str]] = None,
**kwargs,
):
super().__init__(
endpoint=endpoint,
llm_name=llm_name,
model_id=model_id,
api_key=api_key,
**kwargs,
)
# Scope the executor to exactly the node's configured tools. Agents
# fetch their toolset via ``tool_executor.get_tools()``, so the scope
# must live on the executor — it resolves builtin synthetic ids
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
# alike, and an empty list means the node's LLM gets no tools.
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
pass
class WorkflowNodeAgenticAgent(_WorkflowNodeMixin, AgenticAgent):
pass
class WorkflowNodeResearchAgent(_WorkflowNodeMixin, ResearchAgent):
pass
class WorkflowNodeAgentFactory:
_agents: Dict[AgentType, Type[BaseAgent]] = {
AgentType.CLASSIC: WorkflowNodeClassicAgent,
AgentType.REACT: WorkflowNodeClassicAgent, # backwards compat
AgentType.AGENTIC: WorkflowNodeAgenticAgent,
AgentType.RESEARCH: WorkflowNodeResearchAgent,
}
@classmethod
def create(
cls,
agent_type: AgentType,
endpoint: str,
llm_name: str,
model_id: str,
api_key: str,
tool_ids: Optional[List[str]] = None,
**kwargs,
) -> BaseAgent:
agent_class = cls._agents.get(agent_type)
if not agent_class:
raise ValueError(f"Unsupported agent type: {agent_type}")
return agent_class(
endpoint=endpoint,
llm_name=llm_name,
model_id=model_id,
api_key=api_key,
tool_ids=tool_ids,
**kwargs,
)
+193
View File
@@ -0,0 +1,193 @@
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field, field_validator
class NodeType(str, Enum):
START = "start"
END = "end"
AGENT = "agent"
NOTE = "note"
STATE = "state"
CONDITION = "condition"
CODE = "code"
class AgentType(str, Enum):
CLASSIC = "classic"
REACT = "react"
AGENTIC = "agentic"
RESEARCH = "research"
class ExecutionStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class Position(BaseModel):
model_config = ConfigDict(extra="forbid")
x: float = 0.0
y: float = 0.0
class AgentNodeConfig(BaseModel):
model_config = ConfigDict(extra="allow")
agent_type: AgentType = AgentType.CLASSIC
llm_name: Optional[str] = None
system_prompt: str = "You are a helpful assistant."
prompt_template: str = ""
output_variable: Optional[str] = None
stream_to_user: bool = True
tools: List[str] = Field(default_factory=list)
sources: List[str] = Field(default_factory=list)
chunks: str = "2"
retriever: str = ""
model_id: Optional[str] = None
json_schema: Optional[Dict[str, Any]] = None
# Run-scoped documents fed to this node's LLM. Entries are state-var names
# holding artifact refs (single dict or a list of dicts), raw artifact ids,
# short refs (``A1``), or the ``"*"``/``"input_documents"`` token meaning
# "every ref in ``state['input_documents']``".
input_documents: List[str] = Field(default_factory=list)
# How selected documents reach the model: ``auto`` (native when the model
# accepts the mime, else extract to text), ``native`` (force native; raise
# on an unsupported mime), or ``extract`` (always inline extracted text).
file_passing: Literal["auto", "native", "extract"] = "auto"
class CodeNodeConfig(BaseModel):
model_config = ConfigDict(extra="allow")
code: str = ""
inputs: List[str] = Field(default_factory=list)
output_variable: Optional[str] = None
timeout: Optional[int] = None
json_schema: Optional[Dict[str, Any]] = None
class ConditionCase(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
name: Optional[str] = None
expression: str = ""
source_handle: str = Field(..., alias="sourceHandle")
class ConditionNodeConfig(BaseModel):
model_config = ConfigDict(extra="allow")
mode: Literal["simple", "advanced"] = "simple"
cases: List[ConditionCase] = Field(default_factory=list)
class StateOperation(BaseModel):
model_config = ConfigDict(extra="forbid")
expression: str = ""
target_variable: str = ""
class WorkflowEdgeCreate(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
workflow_id: str
source_id: str = Field(..., alias="source")
target_id: str = Field(..., alias="target")
source_handle: Optional[str] = Field(None, alias="sourceHandle")
target_handle: Optional[str] = Field(None, alias="targetHandle")
class WorkflowEdge(WorkflowEdgeCreate):
pass
class WorkflowNodeCreate(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
workflow_id: str
type: NodeType
title: str = "Node"
description: Optional[str] = None
position: Position = Field(default_factory=Position)
config: Dict[str, Any] = Field(default_factory=dict)
@field_validator("position", mode="before")
@classmethod
def parse_position(cls, v: Union[Dict[str, float], Position]) -> Position:
if isinstance(v, dict):
return Position(**v)
return v
class WorkflowNode(WorkflowNodeCreate):
pass
class WorkflowCreate(BaseModel):
model_config = ConfigDict(extra="allow")
name: str = "New Workflow"
description: Optional[str] = None
user: Optional[str] = None
class Workflow(WorkflowCreate):
id: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class WorkflowGraph(BaseModel):
workflow: Workflow
nodes: List[WorkflowNode] = Field(default_factory=list)
edges: List[WorkflowEdge] = Field(default_factory=list)
def get_node_by_id(self, node_id: str) -> Optional[WorkflowNode]:
for node in self.nodes:
if node.id == node_id:
return node
return None
def get_start_node(self) -> Optional[WorkflowNode]:
for node in self.nodes:
if node.type == NodeType.START:
return node
return None
def get_outgoing_edges(self, node_id: str) -> List[WorkflowEdge]:
return [edge for edge in self.edges if edge.source_id == node_id]
class NodeExecutionLog(BaseModel):
model_config = ConfigDict(extra="forbid")
node_id: str
node_type: str
status: ExecutionStatus
started_at: datetime
completed_at: Optional[datetime] = None
duration_ms: Optional[int] = None
error: Optional[str] = None
# The node's state DELTA (keys it added or changed), not the full state:
# point-in-time state is the merge of deltas up to this step. Runs
# persisted before the rename carry this as ``state_snapshot``.
state_delta: Dict[str, Any] = Field(default_factory=dict)
# Compact per-node tool-call summary: [{tool_name, action_name, status}].
tool_calls: List[Dict[str, Any]] = Field(default_factory=list)
class WorkflowRunCreate(BaseModel):
workflow_id: str
inputs: Dict[str, str] = Field(default_factory=dict)
class WorkflowRun(BaseModel):
model_config = ConfigDict(extra="allow")
id: Optional[str] = None
workflow_id: str
user: Optional[str] = None
status: ExecutionStatus = ExecutionStatus.PENDING
inputs: Dict[str, str] = Field(default_factory=dict)
outputs: Dict[str, Any] = Field(default_factory=dict)
steps: List[NodeExecutionLog] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
completed_at: Optional[datetime] = None
File diff suppressed because it is too large. Load diff