mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 12:13:38 +00:00
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:
1 parent
3e7f1f91b4
commit
574f96341e
984 files changed
+9847
-9682
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
Reference in new issue
Block a user