diff --git a/src/backend/app/agent/__init__.py b/src/backend/app/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/chat/__init__.py b/src/backend/app/agent/chat/__init__.py new file mode 100644 index 00000000..74d52246 --- /dev/null +++ b/src/backend/app/agent/chat/__init__.py @@ -0,0 +1,5 @@ +"""Chat completion and message-adjacent agent types (shared API + runner).""" + +from app.agent.chat.completion_params import ChatCompletionParams + +__all__ = ["ChatCompletionParams"] diff --git a/src/backend/app/agent/chat/completion_params.py b/src/backend/app/agent/chat/completion_params.py new file mode 100644 index 00000000..966baf5d --- /dev/null +++ b/src/backend/app/agent/chat/completion_params.py @@ -0,0 +1,36 @@ +"""LLM call parameters for chat turns (REST and executor share this model).""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ChatCompletionParams(BaseModel): + """ + Overrides for a single assistant generation. + Omitted fields fall back to `AgentConfig` / provider defaults. + """ + + model_config = ConfigDict(extra="ignore") + + provider: str | None = Field( + default=None, + description="Registered provider name, e.g. openai", + ) + model: str | None = Field( + default=None, + description="Provider model id, e.g. gpt-4o-mini", + ) + temperature: float | None = Field(default=None, ge=0.0, le=2.0) + max_tokens: int | None = Field(default=None, ge=1, le=128_000) + top_p: float | None = Field(default=None, ge=0.0, le=1.0) + frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + stop: list[str] | None = Field(default=None, max_length=8) + + def provider_create_kwargs(self) -> dict: + """Keyword args for `LLMFactory.create` / ChatOpenAI (excluding provider+model).""" + return self.model_dump( + exclude={"provider", "model"}, + exclude_none=True, + ) diff --git a/src/backend/app/agent/config.py b/src/backend/app/agent/config.py new file mode 100644 index 00000000..e5982af0 --- /dev/null +++ b/src/backend/app/agent/config.py @@ -0,0 +1,33 @@ +import os +from functools import lru_cache +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AgentConfig(BaseSettings): + # LLM defaults + default_provider: str = "openai" + default_model: str = "gpt-4o" + openai_api_key: str = "" + + # Agent behavior + max_iterations: int = Field(default=10, ge=1, le=1000) + max_total_tokens: int = Field(default=128_000, ge=1024) + + # VectorLink + vectorlink_url: str = "http://localhost:8080" + + model_config = SettingsConfigDict( + env_prefix="AGENT_", + env_file=os.environ.get("ENV_FILE", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + +@lru_cache() +def get_agent_settings() -> AgentConfig: + return AgentConfig() + + +settings = get_agent_settings() diff --git a/src/backend/app/agent/context/__init__.py b/src/backend/app/agent/context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/context/context_builder.py b/src/backend/app/agent/context/context_builder.py new file mode 100644 index 00000000..9cb51ecc --- /dev/null +++ b/src/backend/app/agent/context/context_builder.py @@ -0,0 +1,57 @@ +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.context.vectorlink_client import VectorLinkClient +from app.agent.context.token_tracker import TokenTracker + + +class ContextBUilder: + """Assemble prompt context from multiple sources within a token budget.""" + + def __init__(self, + graph: GraphTraversal, + vectorlink: VectorLinkClient, + budget: TokenTracker): + self.graph = graph + self.vectorlink = vectorlink + self.budget = budget + + async def build_context( + self, + *, + node_id: str | None = None, + query: str | None = None, + include_code: bool = False, + traversal_direction: str = "down", + traversal_depth: int = 2, + vector_top_k: int = 5, + ): + """ + Build context by: + 1. Graph traversal from node_id (if provided) + 2. Vector search for query (if provided) + 3. Merge, deduplicate, rank by relevance + 4. Truncate to fit token budget + 5. Optionally attach code content + """ + context_items = [] + + # Step 1: Graph traversal + if node_id: + if traversal_direction == "down": + nodes = await self.graph.traverse_down(node_id, traversal_depth) + else: + nodes = await self.graph.traverse_up(node_id, traversal_depth) + context_items.extend(nodes) + + # Step 2: Vector search + if query: + results = await self.vectorlink.search( + db="...", # from ProjectUoW + query=query, + top_k=vector_top_k, + ) + context_items.extend(results) + + # Step 3-5: Deduplicate, budget-check, enrich + ... + + return context_items diff --git a/src/backend/app/agent/context/graph_traversal.py b/src/backend/app/agent/context/graph_traversal.py new file mode 100644 index 00000000..c4784544 --- /dev/null +++ b/src/backend/app/agent/context/graph_traversal.py @@ -0,0 +1,189 @@ +from app.db.context import ProjectUoW +from app.db.async_terminus_client import WOQLQuery as WQ +from app.core.builder.tree_builder import TreeBuilder +from app.core.services.code_element_service import CodeElementService + + +class GraphTraversal: + """Walk and shape project graph data for workflow execution.""" + + EDGE_FIELDS = ( + "folder_children", + "file_children", + "class_children", + "function_children", + "call_children", + "code_element_group", + "call_group", + "structure_group", + ) + EDGE_PATTERN = "(" + "|".join(EDGE_FIELDS) + ")" + + def __init__(self, uow: ProjectUoW): + self.uow = uow + self.repos = uow.get_project_repos() + self.code_service = CodeElementService(uow) + + def _extract_children(self, doc: dict) -> list[str]: + children: list[str] = [] + for edge in self.EDGE_FIELDS: + raw = doc.get(edge) + if raw is None: + continue + if isinstance(raw, (list, set, tuple)): + children.extend([str(item) for item in raw if item]) + else: + children.append(str(raw)) + return list(set(children)) + + @staticmethod + def _normalize_type_name(type_name: str | None) -> str: + if not type_name: + return "" + return type_name.replace("Schema", "") + + def _normalize_doc(self, doc: dict) -> dict: + normalized = dict(doc) + normalized["id"] = normalized.get("@id") + normalized["type"] = normalized.get("@type") + normalized["children"] = self._extract_children(doc) + return normalized + + def _dedupe_nodes(self, nodes: list[dict]) -> list[dict]: + unique: dict[str, dict] = {} + for node in nodes: + node_id = node.get("id") or node.get("@id") + if not node_id: + continue + unique[node_id] = node + return list(unique.values()) + + # TODO: Make imporve type filtering + async def traverse_down( + self, + node_id: str | None, + max_depth: int = 5, + node_types: list[str] | None = None, + ) -> list[dict]: + """ + Get all descendants from node_id and include the start node. + Returns full node docs with normalized `id`, `type`, and `children`. + """ + if not node_id: + all_nodes, _ = await self.repos.project_repo.get_children( + exclude_types=[]) + normalized_nodes = [ + self._normalize_doc(node.model_dump()) + for node in all_nodes + ] + return self._dedupe_nodes(normalized_nodes) + + pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}" + query = ( + WQ() + .eq("v:start", node_id) + .path("v:start", f"{self.EDGE_PATTERN}{pattern}", "v:child") + .read_document("v:child", "v:child_doc") + ) + + allowed_types = None + if node_types: + allowed_types = { + self._normalize_type_name(node_type) for node_type in node_types + } + + nodes: list[dict] = [] + if self.repos.client: + result = await self.repos.client.query(query) + for row in result.get("bindings", []): + doc = row.get("child_doc", {}) + if allowed_types: + doc_type = self._normalize_type_name(doc.get("@type")) + if doc_type not in allowed_types: + continue + nodes.append(self._normalize_doc(doc)) + + start_result = await self.repos.client.get_document(node_id) + if start_result: + nodes.append(self._normalize_doc(start_result)) + + return self._dedupe_nodes(nodes) + + async def traverse_up( + self, + node_id: str, + max_depth: int = 5, + ) -> list[dict]: + """ + Get all ancestors from node_id and include the start node. + Returns full node docs with normalized `id`, `type`, and `children`. + """ + pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}" + query = ( + WQ() + .eq("v:start", node_id) + .path("v:start", f"<{self.EDGE_PATTERN}{pattern}", "v:parent") + .read_document("v:parent", "v:parent_doc") + ) + + nodes: list[dict] = [] + if self.repos.client: + result = await self.repos.client.query(query) + for row in result.get("bindings", []): + doc = row.get("parent_doc", {}) + nodes.append(self._normalize_doc(doc)) + + start_result = await self.repos.client.get_document(node_id) + if start_result: + nodes.append(self._normalize_doc(start_result)) + + return self._dedupe_nodes(nodes) + + async def build_tree(self, node_id: str | None, node_types: list[str] | None = None, max_depth: int = 5): + """Build nested tree nodes for subtree rooted at `node_id`.""" + nodes = await self.traverse_down(node_id=node_id, node_types=node_types, max_depth=max_depth) + tree = TreeBuilder(base_nodes=nodes).build() + return tree + + async def get_siblings(self, node_id: str) -> list[dict]: + """Get nodes at the same level (same parent).""" + parents = await self.traverse_up(node_id, max_depth=1) + if not parents: + return [] + + parent_id = parents[0]["id"] if parents[0]["id"] != node_id else ( + parents[1]["id"] if len(parents) > 1 else None + ) + if not parent_id: + return [] + + children = await self.traverse_down(parent_id, max_depth=1) + return [c for c in children if c["id"] not in {node_id, parent_id}] + + async def get_node_with_code(self, node_id: str) -> dict: + """Fetch node and hydrate code via CodeElementService.get_code.""" + if not self.repos.client: + return {} + + doc = await self.repos.client.get_document(node_id) + if not doc: + return {} + + try: + code_payload = await self.code_service.get_code(node_id) + if code_payload and code_payload.get("code"): + doc["code_content_data"] = code_payload["code"] + except Exception: + # Keep workflow robust for nodes that don't have code ranges. + doc["code_content_data"] = "" + return doc + + async def get_code_content(self, node_id: str) -> str: + """Fetch only code content for a node without hydrating full doc.""" + try: + code_payload = await self.code_service.get_code(node_id) + if code_payload and code_payload.get("code"): + return code_payload["code"] + except Exception: + return "" + return "" diff --git a/src/backend/app/agent/context/token_tracker.py b/src/backend/app/agent/context/token_tracker.py new file mode 100644 index 00000000..4a8fb7ec --- /dev/null +++ b/src/backend/app/agent/context/token_tracker.py @@ -0,0 +1,38 @@ +from langchain_core.callbacks import BaseCallbackHandler +from pydantic import BaseModel +from typing import Optional + + +class TokenUsage(BaseModel): + """Accumulated token usage for a single run.""" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + model: Optional[str] = None + + +class TokenTracker(BaseCallbackHandler): + """LangChain callback handler that accumulates token usage across calls.""" + + def __init__(self, max_total_tokens: int = 128_000): + self.max_total_tokens = max_total_tokens + self.usage = TokenUsage() + + def on_llm_end(self, response, **kwargs): + """Called after each LLM invocation — accumulates usage.""" + if hasattr(response, "llm_output") and response.llm_output: + usage = response.llm_output.get("token_usage", {}) + self.usage.prompt_tokens += usage.get("prompt_tokens", 0) + self.usage.completion_tokens += usage.get("completion_tokens", 0) + self.usage.total_tokens += usage.get("total_tokens", 0) + + @property + def remaining(self) -> int: + return self.max_total_tokens - self.usage.total_tokens + + @property + def over_budget(self) -> bool: + return self.usage.total_tokens >= self.max_total_tokens + + def get_usage(self) -> TokenUsage: + return self.usage.model_copy() diff --git a/src/backend/app/agent/context/vectorlink_client.py b/src/backend/app/agent/context/vectorlink_client.py new file mode 100644 index 00000000..1275728c --- /dev/null +++ b/src/backend/app/agent/context/vectorlink_client.py @@ -0,0 +1,60 @@ +import json +import httpx +from typing import Optional + + +class VectorLinkClient: + """Async HTTP client for the VectorLink semantic indexer.""" + + def __init__(self, base_url: str = "http://localhost:8080"): + self.base_url = base_url + self.headers = { + "Content-Type": "application/json", + "VECTORLINK_EMBEDDING_API_KEY": 'openai secreate', + } + self._client = httpx.AsyncClient( + base_url=base_url, headers=self.headers, timeout=30.0) + + async def index_document( + self, + db: str, + commit_id: str, + branch: str = "main", + ) -> str: + """Trigger vectorlink to start indexing and return the task id.""" + + try: + response = await self._client.get( + f"/api/index", + params={"domain": f"admin/{db}", + "commit": commit_id}, + ) + task_id = response.text + return task_id + except httpx.HTTPStatusError as e: + raise RuntimeError(f"Failed to index document: {e}") from e + + async def search( + self, + db: str, + commit_id: str, + query: str, + branch: str = "main", + ) -> list[dict]: + """Search the vectorlink index and return the results.""" + + try: + response = await self._client.post( + f"/api/search", + params={"domain": f"admin/{db}", + "commit": commit_id}, + json={"search": query}, + ) + if response.status_code != 200: + raise RuntimeError(f"Failed to search: {response.text}") + + return json.loads(response.text) + except httpx.HTTPStatusError as e: + raise RuntimeError(f"Failed to search: {e}") from e + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse search response: {e}") from e diff --git a/src/backend/app/agent/conversation_store.py b/src/backend/app/agent/conversation_store.py new file mode 100644 index 00000000..0767eadb --- /dev/null +++ b/src/backend/app/agent/conversation_store.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Protocol + +from app.core.model.conversation_domain import ( + Conversation, + ConversationMessage, + ConversationSummary, + TaskPart, +) +from app.core.repository.conversation import ( + ConversationRepo, + terminus_ids_match, +) + + +class ConversationStore(Protocol): + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + ... + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> str | None: + ... + + async def get_conversation( + self, conversation_id: str + ) -> Conversation | None: + ... + + async def get_conversation_metadata( + self, conversation_id: str + ) -> ConversationSummary | None: + ... + + async def list_conversations( + self, limit: int = 50, cursor: str | None = None + ) -> list[ConversationSummary]: + ... + + async def list_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + ... + + async def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + ... + + +class TerminusConversationStore: + """Conversation persistence backed by TerminusDB via `ConversationRepo`.""" + + def __init__(self, repo: ConversationRepo): + self._repo = repo + + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + cid = await self._repo.create_conversation( + title, description, metadata + ) + if cid is None: + raise RuntimeError( + "Failed to create conversation in TerminusDB" + ) + return cid + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> str | None: + mid = await self._repo.add_message(conversation_id, message) + if mid is None: + raise ValueError( + f"Failed to add message to conversation {conversation_id!r}" + ) + return mid + + async def get_conversation( + self, conversation_id: str + ) -> Conversation | None: + return await self._repo.get_conversation(conversation_id) + + async def get_conversation_metadata( + self, conversation_id: str + ) -> ConversationSummary | None: + return await self._repo.get_conversation_summary(conversation_id) + + async def list_conversations( + self, limit: int = 50, cursor: str | None = None + ) -> list[ConversationSummary]: + return await self._repo.list_conversations(limit=limit, cursor=cursor) + + async def list_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + return await self._repo.get_messages( + conversation_id, cursor=cursor, limit=limit + ) + + async def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + conv = await self._repo.get_conversation(conversation_id) + if conv is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + for message in reversed(conv.messages): + for idx, part in enumerate(message.parts): + if isinstance(part, TaskPart) and terminus_ids_match( + part.task_id, task_part.task_id + ): + new_parts = list(message.parts) + new_parts[idx] = task_part + updated = message.model_copy(update={"parts": new_parts}) + ok = await self._repo.update_message( + conversation_id, updated + ) + if not ok: + raise ValueError( + f"Failed to update message {message.id} " + f"for task part" + ) + return + + raise ValueError( + f"TaskPart not found for task_id={task_part.task_id} " + f"in conversation={conversation_id}" + ) diff --git a/src/backend/app/agent/graph/__init__.py b/src/backend/app/agent/graph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/graph/builder.py b/src/backend/app/agent/graph/builder.py new file mode 100644 index 00000000..72730d8e --- /dev/null +++ b/src/backend/app/agent/graph/builder.py @@ -0,0 +1,30 @@ + +from langgraph.graph import StateGraph, END +from .state import AgentState +from .nodes import planner, executor, reflector, responder +from .edges import after_planner, after_reflector + + +def build_agent_graph() -> StateGraph: + graph = StateGraph(AgentState) + + graph.add_node("planner", planner) + graph.add_node("executor", executor) + graph.add_node("reflector", reflector) + graph.add_node("responder", responder) + + graph.set_entry_point("planner") + + graph.add_conditional_edges("planner", after_planner, { + "executor": "executor", + "responder": "responder", + "end": END, + }) + graph.add_edge("executor", "reflector") + graph.add_conditional_edges("reflector", after_reflector, { + "responder": "responder", + "planner": "planner", + }) + graph.add_edge("responder", END) + + return graph.compile() diff --git a/src/backend/app/agent/graph/edges.py b/src/backend/app/agent/graph/edges.py new file mode 100644 index 00000000..d4b5d7cd --- /dev/null +++ b/src/backend/app/agent/graph/edges.py @@ -0,0 +1,15 @@ +from app.agent.graph.state import AgentState + + +def after_planner(state: AgentState) -> str: + if state["should_finish"]: + return "end" + if state["selected_tool"]: + return "executor" + return "responder" + + +def after_reflector(state: AgentState) -> str: + if state["should_finish"] or state["iteration_count"] >= state["max_iterations"]: + return "responder" + return "planner" diff --git a/src/backend/app/agent/graph/nodes.py b/src/backend/app/agent/graph/nodes.py new file mode 100644 index 00000000..f7642f2b --- /dev/null +++ b/src/backend/app/agent/graph/nodes.py @@ -0,0 +1,37 @@ +from app.agent.graph.state import AgentState +from app.agent.llm import factory + + +async def planner(state: AgentState) -> AgentState: + """ + Given messages + context, produce a plan. + Decides: use a tool, answer directly, or give up. + Populates: plan, selected_tool, tool_input. + """ + llm = factory.create() + response = await llm.invoke(state["messages"]) + + +async def executor(state: AgentState) -> AgentState: + """ + Look up selected_tool in ToolRegistry, call execute(). + Populates: tool_results (appends). + """ + ... + + +async def reflector(state: AgentState) -> AgentState: + """ + Review tool results. Decide if we have enough context to answer, + or if we need another tool call. + Populates: should_finish, context_docs. + """ + ... + + +async def responder(state: AgentState) -> AgentState: + """ + Generate the final response from accumulated context. + Appends an assistant message to messages. + """ + ... diff --git a/src/backend/app/agent/graph/state.py b/src/backend/app/agent/graph/state.py new file mode 100644 index 00000000..a25212e6 --- /dev/null +++ b/src/backend/app/agent/graph/state.py @@ -0,0 +1,40 @@ +from typing import TypedDict, Annotated, Sequence, Optional +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages + + +class ToolResult(TypedDict): + tool_name: str + tool_input: dict + output: str # serialized result + error: Optional[str] + + +class AgentState(TypedDict): + """Shared state flowing through the LangGraph.""" + messages: Annotated[Sequence[BaseMessage], add_messages] + + # Planning + plan: Optional[str] # high-level plan text + current_step: int # index in plan steps + + # Tool execution + selected_tool: Optional[str] + tool_input: Optional[dict] + tool_results: list[ToolResult] + + # Context + # retrieved graph nodes / vector results + context_docs: list[dict] + token_budget_remaining: int + + # Control + iteration_count: int + max_iterations: int + should_finish: bool + + # Workflow-specific (used by subgraphs) + target_node_id: Optional[str] # node being documented + traversal_direction: Optional[str] # "up" | "down" + # "description" | "documentation" | "both" + generation_mode: Optional[str] diff --git a/src/backend/app/agent/llm/factory.py b/src/backend/app/agent/llm/factory.py new file mode 100644 index 00000000..12a5622e --- /dev/null +++ b/src/backend/app/agent/llm/factory.py @@ -0,0 +1,42 @@ +from app.agent.llm.provider import LLMProvider +from app.agent.config import AgentConfig + + +class LLMFactory: + """Create LLM provider instances based on configuration.""" + + def __init__(self, config: AgentConfig): + self.config = config + self._providers: dict[str, type[LLMProvider]] = {} + self._provider_defaults: dict[str, dict] = {} + + def register_provider( + self, + name: str, + provider_cls: type[LLMProvider], + **defaults, + ): + self._providers[name] = provider_cls + if defaults: + self._provider_defaults[name] = defaults + + def create( + self, + *, + provider: str | None = None, + model: str | None = None, + **kwargs, + ) -> LLMProvider: + provider = provider or self.config.default_provider # e.g. "openai" + model = model or self.config.default_model # e.g. "gpt-4o" + provider_cls = self._providers[provider] + merged = {**self._provider_defaults.get(provider, {}), **kwargs} + + return provider_cls(model=model, **merged) + + def list_available(self) -> list[dict]: + """List registered providers and their supported models.""" + return [ + {"provider": name, "models": cls.supported_models()} + for name, cls in self._providers.items() + ] diff --git a/src/backend/app/agent/llm/gateway.py b/src/backend/app/agent/llm/gateway.py new file mode 100644 index 00000000..dee59c2b --- /dev/null +++ b/src/backend/app/agent/llm/gateway.py @@ -0,0 +1,47 @@ +# agent/llm/gateway.py + +from dataclasses import dataclass +from app.agent.chat.completion_params import ChatCompletionParams +from app.agent.config import settings + + +@dataclass +class ResolvedLLM: + provider: object # the LangChain-compatible provider + model: str + provider_name: str + + +class LLMGateway: + """Single responsibility: resolve params → usable LLM instance.""" + + def __init__(self, llm_factory): + self._factory = llm_factory + + @property + def factory(self): + """Underlying LLMFactory (for workflow construction).""" + return self._factory + + def resolve( + self, params: ChatCompletionParams | None = None + ) -> ResolvedLLM: + params = params or ChatCompletionParams() + model = params.model or settings.default_model + provider_name = params.provider or settings.default_provider + extra = params.provider_create_kwargs() + + mt = extra.get("max_tokens") + if mt is not None and mt > settings.max_total_tokens: + extra["max_tokens"] = settings.max_total_tokens + + llm = self._factory.create( + provider=provider_name, model=model, **extra + ) + return ResolvedLLM( + provider=llm, model=model, provider_name=provider_name + ) + + def create_mini(self) -> object: + """Cheap model for metadata generation.""" + return self._factory.create(model="gpt-4o-mini") diff --git a/src/backend/app/agent/llm/provider.py b/src/backend/app/agent/llm/provider.py new file mode 100644 index 00000000..df082e4e --- /dev/null +++ b/src/backend/app/agent/llm/provider.py @@ -0,0 +1,38 @@ +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator +from langchain_core.messages import BaseMessage + + +class LLMProvider(ABC): + """Abstract interface for an LLM provider.""" + + name: str # e.g. "openai", "anthropic", "local" + model: str # e.g. "gpt-4o", "claude-3-sonnet" + + @abstractmethod + async def invoke( + self, + messages: list[BaseMessage], + **kwargs, + ) -> BaseMessage: + """Single-shot invocation. Returns one complete message.""" + ... + + @abstractmethod + async def stream( + self, + messages: list[BaseMessage], + **kwargs, + ) -> AsyncIterator[str]: + """Streaming invocation. Yields token chunks.""" + ... + + @abstractmethod + def supports_tools(self) -> bool: + """Does this provider support native tool/function calling?""" + ... + + @abstractmethod + def max_context_tokens(self) -> int: + """Maximum context window size for this model.""" + ... diff --git a/src/backend/app/agent/llm/providers/openai_provider.py b/src/backend/app/agent/llm/providers/openai_provider.py new file mode 100644 index 00000000..da251f99 --- /dev/null +++ b/src/backend/app/agent/llm/providers/openai_provider.py @@ -0,0 +1,45 @@ +from langchain_openai import ChatOpenAI +from app.agent.llm.provider import LLMProvider +from typing import AsyncIterator +from langchain_core.messages import BaseMessage + + +class OpenAIProvider(LLMProvider): + name = "openai" + + MODEL_CONTEXTS = { + "gpt-4o": 128_000, + "gpt-4o-mini": 128_000, + "gpt-4-turbo": 128_000, + "gpt-3.5-turbo": 16_385, + } + + def __init__(self, model: str = "gpt-4o-mini", **kwargs): + self.model = model + self._llm = ChatOpenAI(model=model, **kwargs) + + async def invoke( + self, + messages: list[BaseMessage], + **kwargs, + ) -> BaseMessage: + return await self._llm.ainvoke(messages, **kwargs) + + async def stream( + self, + messages: list[BaseMessage], + **kwargs, + ): + async for chunk in self._llm.astream(messages, **kwargs): + if chunk.content: + yield chunk.content + + def supports_tools(self) -> bool: + return True + + def max_context_tokens(self) -> int: + return self.MODEL_CONTEXTS.get(self.model, 128_000) + + @classmethod + def supported_models(cls) -> list[str]: + return list(cls.MODEL_CONTEXTS.keys()) diff --git a/src/backend/app/agent/runner/__init__.py b/src/backend/app/agent/runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/runner/patch_builder.py b/src/backend/app/agent/runner/patch_builder.py new file mode 100644 index 00000000..e2116cb2 --- /dev/null +++ b/src/backend/app/agent/runner/patch_builder.py @@ -0,0 +1,59 @@ +"""RFC 6902 JSON Patch fragments for client conversation state.""" + +from __future__ import annotations + +from typing import Any + + +class ConversationPatchBuilder: + """Builds JSON Patch arrays for conversation state mutations.""" + + def __init__(self) -> None: + self._patches: list[dict[str, Any]] = [] + + def add(self, path: str, value: Any) -> ConversationPatchBuilder: + self._patches.append({"op": "add", "path": path, "value": value}) + return self + + def replace(self, path: str, value: Any) -> ConversationPatchBuilder: + self._patches.append({"op": "replace", "path": path, "value": value}) + return self + + def remove(self, path: str) -> ConversationPatchBuilder: + self._patches.append({"op": "remove", "path": path}) + return self + + def build(self) -> list[dict[str, Any]]: + return list(self._patches) + + def task_progress( + self, task_id: str, progress: float, message: str = "" + ) -> ConversationPatchBuilder: + self.replace(f"/tasks/{task_id}/progress", progress) + if message: + self.replace(f"/tasks/{task_id}/progress_message", message) + return self + + def add_message_wire(self, message: dict) -> ConversationPatchBuilder: + self.add("/messages/-", message) + return self + + def finalize_assistant_text_part( + self, + message_index: int, + text: str, + *, + message_id: str | None = None, + sequence: int | None = None, + ) -> ConversationPatchBuilder: + base = f"/messages/{message_index}" + self.replace(f"{base}/parts/0/text", text) + if message_id is not None: + self.replace(f"{base}/id", message_id) + if sequence is not None: + self.replace(f"{base}/sequence", sequence) + return self + + def message_count(self, n: int) -> ConversationPatchBuilder: + self.replace("/message_count", n) + return self diff --git a/src/backend/app/agent/runner/stream_buffer.py b/src/backend/app/agent/runner/stream_buffer.py new file mode 100644 index 00000000..18218028 --- /dev/null +++ b/src/backend/app/agent/runner/stream_buffer.py @@ -0,0 +1,84 @@ +"""In-memory LLM stream chunks with replay for WebSocket resume.""" + +from __future__ import annotations + +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +class StreamBuffer: + """Accumulates LLM token chunks with sequence numbers.""" + + def __init__(self, stream_id: str, conversation_id: str): + self.stream_id = stream_id + self.conversation_id = conversation_id + self._chunks: list[str] = [] + self._finished = False + self._final_text: str | None = None + self._message_id: str | None = None + + @property + def next_seq(self) -> int: + return len(self._chunks) + + def append(self, delta: str) -> int: + seq = self.next_seq + self._chunks.append(delta) + return seq + + def get_chunks_since(self, from_seq: int) -> list[tuple[int, str]]: + start = max(0, int(from_seq)) + return [(i, self._chunks[i]) for i in range(start, len(self._chunks))] + + def finish(self) -> str: + self._finished = True + self._final_text = "".join(self._chunks) + return self._final_text + + def set_message_id(self, message_id: str) -> None: + self._message_id = message_id + + @property + def message_id(self) -> str | None: + return self._message_id + + @property + def is_finished(self) -> bool: + return self._finished + + +class StreamRegistry: + """Tracks active streams; keeps finished buffers briefly for replay.""" + + def __init__(self, replay_ttl_s: float = 60.0): + self._active: dict[str, StreamBuffer] = {} + self.replay_ttl_s = replay_ttl_s + + def create(self, stream_id: str, conversation_id: str) -> StreamBuffer: + buf = StreamBuffer(stream_id, conversation_id) + self._active[stream_id] = buf + return buf + + def get(self, stream_id: str) -> StreamBuffer | None: + return self._active.get(stream_id) + + def remove(self, stream_id: str) -> None: + self._active.pop(stream_id, None) + + def schedule_remove(self, stream_id: str) -> None: + ttl = self.replay_ttl_s + + async def _delayed() -> None: + try: + await asyncio.sleep(ttl) + except asyncio.CancelledError: + return + self.remove(stream_id) + + try: + asyncio.create_task(_delayed()) + except RuntimeError: + logger.warning("Could not schedule stream buffer cleanup (no loop)") + diff --git a/src/backend/app/agent/runner/task_context.py b/src/backend/app/agent/runner/task_context.py new file mode 100644 index 00000000..ffa02d3f --- /dev/null +++ b/src/backend/app/agent/runner/task_context.py @@ -0,0 +1,131 @@ +# app/agent/runner/task_context.py + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from app.core.model.conversation_enums import SubTaskState, TaskState +from app.core.model.conversation_nodes import SubTask, Task + + +class SubtaskHandle: + """Lightweight handle a workflow uses to report on one subtask.""" + + def __init__(self, subtask: SubTask, parent: TaskContext): + self._sub = subtask + self._parent = parent + + @property + def id(self) -> str: + return self._sub.id + + def start(self, message: str = "") -> None: + self._sub.state = SubTaskState.RUNNING + self._sub.started_at = datetime.now(timezone.utc) + if message: + self._sub.description = message + self._parent._recompute_progress() + + def update(self, message: str = "") -> None: + if message: + self._sub.description = message + + def complete(self, message: str = "") -> None: + self._sub.state = SubTaskState.COMPLETED + self._sub.finished_at = datetime.now(timezone.utc) + if message: + self._sub.description = message + self._parent._recompute_progress() + + def fail(self, error: str) -> None: + self._sub.state = SubTaskState.FAILED + self._sub.error = error + self._sub.finished_at = datetime.now(timezone.utc) + self._parent._recompute_progress() + + +class TaskContext: + """ + The ONLY interface workflows use to report progress. + + Tracks subtasks in memory. The WorkflowService reads + snapshots for frontend pushes and final DB flush. + """ + + def __init__(self, task: Task | None = None): + self._task: Task | None = task + self._subtasks: list[SubTask] = [] + self._seq = 0 + + def bind(self, task: Task) -> None: + """Bind to the in-memory Task created by TaskManager.""" + self._task = task + + # -- progress --------------------------------------------------------- + + def update_progress( + self, progress: float, message: str = "" + ) -> None: + if self._task: + self._task.progress = max(0.0, min(1.0, progress)) + if message: + self._task.progress_message = message + + def _recompute_progress(self) -> None: + if not self._task or not self._subtasks: + return + done = sum( + 1 + for s in self._subtasks + if s.state + in {SubTaskState.COMPLETED, SubTaskState.FAILED} + ) + self._task.progress = done / len(self._subtasks) + self._task.sub_task_count = len(self._subtasks) + + # -- subtask management ----------------------------------------------- + + def subtask( + self, + name: str, + subtask_id: str | None = None, + touched_node_ids: list[str] | None = None, + ) -> SubtaskHandle: + import json + + sid = subtask_id or str(uuid.uuid4()) + now = datetime.now(timezone.utc) + st = SubTask( + id=sid, + task_id=self._task.id if self._task else "", + name=name, + state=SubTaskState.PENDING, + sequence=self._seq, + touched_node_ids_json=json.dumps( + touched_node_ids or [] + ), + created_at=now, + updated_at=now, + ) + self._seq += 1 + self._subtasks.append(st) + self._recompute_progress() + return SubtaskHandle(st, self) + + # -- snapshots for external consumers --------------------------------- + + @property + def subtask_snapshots(self) -> list[SubTask]: + return [s.model_copy(deep=True) for s in self._subtasks] + + @property + def subtask_count(self) -> int: + return len(self._subtasks) + + # -- noop for tests / optional ---------------------------------------- + + @classmethod + def noop(cls) -> TaskContext: + return cls(task=None) diff --git a/src/backend/app/agent/runner/task_manager.py b/src/backend/app/agent/runner/task_manager.py new file mode 100644 index 00000000..1146ea69 --- /dev/null +++ b/src/backend/app/agent/runner/task_manager.py @@ -0,0 +1,125 @@ +# app/agent/runner/task_manager.py + +import asyncio +import inspect +import json +import uuid +import logging +from datetime import datetime, timezone +from typing import Any, Callable, Optional, Union + +from app.core.model.conversation_enums import TaskState +from app.core.model.conversation_nodes import Task + +logger = logging.getLogger(__name__) + +_STATUS_INTERVAL_S = 0.5 + + +class TaskManager: + + def __init__(self): + self._tasks: dict[str, Task] = {} + self._asyncio_tasks: dict[str, asyncio.Task] = {} + + def submit( + self, + name: str, + coro_factory: Callable[..., Any], + on_status_update: Optional[ + Callable[[Task], Union[None, Any]] + ] = None, + *, + task_id: Optional[str] = None, + **kwargs, + ) -> str: + tid = task_id if task_id is not None else str(uuid.uuid4()) + if tid in self._tasks: + raise ValueError(f"task_id already in use: {tid!r}") + task_id = tid + now = datetime.now(timezone.utc) + status = Task( + id=task_id, + name=name, + state=TaskState.PENDING, + created_at=now, + updated_at=now, + ) + self._tasks[task_id] = status + + async def _emit(): + if not on_status_update: + return + try: + out = on_status_update(status.model_copy(deep=True)) + if inspect.isawaitable(out): + await out + except Exception: + logger.debug( + "status callback error", exc_info=True + ) + + async def _wrapper(): + status.state = TaskState.RUNNING + status.started_at = datetime.now(timezone.utc) + await _emit() + + reporter: asyncio.Task | None = None + if on_status_update: + + async def _reporter(): + while status.state == TaskState.RUNNING: + await _emit() + await asyncio.sleep(_STATUS_INTERVAL_S) + + reporter = asyncio.create_task(_reporter()) + + try: + run_kwargs = dict(kwargs) + # TaskManager injects task_status; task_context + # is passed through from the caller's kwargs. + run_kwargs.setdefault("task_status", status) + result = await coro_factory(**run_kwargs) + status.state = TaskState.COMPLETED + status.result_json = ( + json.dumps(result, default=str) + if result is not None + else None + ) + except asyncio.CancelledError: + status.state = TaskState.CANCELLED + except Exception as e: + status.state = TaskState.FAILED + status.error = str(e) + logger.exception("Task %s failed", task_id) + finally: + status.finished_at = datetime.now(timezone.utc) + if reporter: + reporter.cancel() + try: + await reporter + except asyncio.CancelledError: + pass + await _emit() + + atask = asyncio.get_running_loop().create_task(_wrapper()) + self._asyncio_tasks[task_id] = atask + return task_id + + def get_status(self, task_id: str) -> Optional[Task]: + return self._tasks.get(task_id) + + async def join(self, task_id: str) -> None: + atask = self._asyncio_tasks.get(task_id) + if atask: + await atask + + def cancel(self, task_id: str) -> bool: + atask = self._asyncio_tasks.get(task_id) + if atask and not atask.done(): + atask.cancel() + return True + return False + + def list_tasks(self) -> list[Task]: + return list(self._tasks.values()) diff --git a/src/backend/app/agent/runner/task_persistence.py b/src/backend/app/agent/runner/task_persistence.py new file mode 100644 index 00000000..795b915b --- /dev/null +++ b/src/backend/app/agent/runner/task_persistence.py @@ -0,0 +1,140 @@ +# app/agent/runner/task_persistence.py + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +from app.core.model.conversation_enums import TaskState +from app.core.model.conversation_nodes import SubTask, Task +from app.db.async_terminus_client import WOQLQuery as WQ + +try: + from terminusdb_client.woqlquery.woql_query import Doc +except ImportError: # pragma: no cover + Doc = dict # type: ignore + +from app.core.model.schemas.conversation_schema import ( + SubTaskSchema, + TaskSchema, +) + +logger = logging.getLogger(__name__) + + +class TaskPersistence: + """ + Encapsulates all DB operations for Task and SubTask documents. + + Workflows never touch the DB directly for task tracking. + """ + + def __init__(self, client: Any): + self._client = client + + # -- Task CRUD -------------------------------------------------------- + + async def create_task(self, task: Task) -> str: + schema = TaskSchema.from_pydantic(task) + + await self._client.insert_document( + schema, + commit_msg=f"Create task {task.id}", + ) + + return task.id + + async def update_task_state( + self, + task_id: str, + *, + state: TaskState, + progress: float = 0.0, + progress_message: str = "", + error: str | None = None, + result_json: str | None = None, + started_at: datetime | None = None, + finished_at: datetime | None = None, + sub_task_count: int = 0, + ) -> None: + now = datetime.now(timezone.utc) + updates: list = [] + + field_map = { + "state": state.value, + "progress": progress, + "progress_message": progress_message, + "sub_task_count": sub_task_count, + "updated_at": now, + } + if error is not None: + field_map["error"] = error + if result_json is not None: + field_map["result_json"] = result_json + if started_at is not None: + field_map["started_at"] = started_at + if finished_at is not None: + field_map["finished_at"] = finished_at + + for field, value in field_map.items(): + var = f"v:old_{field}" + updates.extend( + [ + WQ().opt( + WQ() + .triple(task_id, field, var) + .delete_triple(task_id, field, var) + ), + WQ().add_triple( + task_id, + field, + self._woql_value(value), + ), + ] + ) + + if updates: + await self._client.query( + WQ().woql_and(*updates), + commit_msg=f"Update task {task_id} → {state.value}", + ) + + # -- SubTask batch upsert --------------------------------------------- + + async def flush_subtasks( + self, task_id: str, subtasks: list[SubTask] + ) -> None: + if not subtasks: + return + + documents = [] + for st in subtasks: + schema = SubTaskSchema.from_pydantic( + st.model_copy(update={"task_id": task_id}) + ) + raw = schema._obj_to_dict()[0] + documents.append(raw) + + await self._client.insert_document( + documents, + commit_msg=( + f"Flush {len(subtasks)} subtasks for task {task_id}" + ), + ) + # -- helpers ----------------------------------------------------------- + + @staticmethod + def _woql_value(value: Any): + if isinstance(value, str): + return WQ().string(value) + if isinstance(value, bool): + return WQ().boolean(value) + if isinstance(value, int): + return WQ().iri(str(value)) # or integer helper + if isinstance(value, float): + return WQ().string(str(value)) + if isinstance(value, datetime): + return value + return WQ().string(str(value)) diff --git a/src/backend/app/agent/service/chat_service.py b/src/backend/app/agent/service/chat_service.py new file mode 100644 index 00000000..23b9da44 --- /dev/null +++ b/src/backend/app/agent/service/chat_service.py @@ -0,0 +1,189 @@ +import uuid +import asyncio +import logging + +from langchain_core.messages import AIMessage, HumanMessage + +from app.agent.conversation_store import ConversationStore +from app.agent.llm.gateway import LLMGateway +from app.agent.streaming.manager import StreamManager, StreamHandle +from app.agent.streaming import ( + conversation_message_to_wire, + emit_conversation_patch, +) +from app.agent.runner.patch_builder import ConversationPatchBuilder +from app.agent.runner.task_manager import TaskManager +from app.agent.chat.completion_params import ChatCompletionParams +from app.core.model.conversation_domain import ( + ConversationMessage, + TextPart, +) +from app.core.model.conversation_enums import MessageRole + +logger = logging.getLogger(__name__) + + +class ChatService: + def __init__( + self, + task_manager: TaskManager, + llm_gateway: LLMGateway, + stream_manager: StreamManager, + ): + self._tasks = task_manager + self._llm = llm_gateway + self._streams = stream_manager + + async def send_message( + self, + conversation_id: str, + user_message: ConversationMessage, + *, + store: ConversationStore, + completion_params: ChatCompletionParams | None = None, + client_ref: str | None = None, + ) -> dict: + # 1. Persist user message + user_mid = await store.add_message( + conversation_id, user_message + ) + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + # 2. Broadcast user message patch + await self._emit_user_patch( + conversation_id, user_message, user_mid, meta + ) + + # 3. Open stream + submit background generation + handle = self._streams.open(conversation_id) + task_id = self._tasks.submit( + name="chat:response", + coro_factory=self._generate, + store=store, + conversation_id=conversation_id, + handle=handle, + completion_params=completion_params, + client_ref=client_ref, + ) + + return { + "conversation_id": conversation_id, + "message_id": user_mid, + "task_id": task_id, + "stream_id": handle.stream_id, + "client_ref": client_ref, + } + + async def _generate( + self, + *, + store: ConversationStore, + conversation_id: str, + handle: StreamHandle, + completion_params: ChatCompletionParams | None = None, + client_ref: str | None = None, + task_status=None, + ) -> None: + resolved = self._llm.resolve(completion_params) + + await self._streams.emit_start( + handle, + model=resolved.model, + provider=resolved.provider_name, + task_id=getattr(task_status, "id", None), + client_ref=client_ref, + ) + try: + history = await store.list_messages( + conversation_id, cursor=0, limit=500 + ) + lc_messages = self._to_langchain(history) or [ + HumanMessage(content="") + ] + + async for delta in resolved.provider.stream(lc_messages): + if delta: + await self._streams.push_chunk(handle, delta) + + full_text = self._streams.finish(handle) + final_id = await self._persist_assistant( + store, conversation_id, full_text, resolved.model + ) + self._streams.set_message_id(handle, final_id) + + await self._emit_assistant_patch( + store, conversation_id, full_text, final_id + ) + await self._streams.emit_end(handle, final_id) + + except asyncio.CancelledError: + await self._streams.emit_error(handle, "cancelled") + raise + except Exception as e: + logger.exception("chat:response failed") + await self._streams.emit_error(handle, str(e)) + raise + + async def _emit_user_patch( + self, cid, msg, mid, meta + ) -> None: + wire = conversation_message_to_wire( + msg.model_copy( + update={ + "id": mid or msg.id, + "sequence": meta.message_count - 1, + } + ) + ) + patches = ( + ConversationPatchBuilder() + .add_message_wire(wire) + .message_count(meta.message_count) + .build() + ) + await emit_conversation_patch(cid, patches) + + async def _persist_assistant( + self, store, cid, text, model + ) -> str: + aid = str(uuid.uuid4()) + msg = ConversationMessage( + id=aid, + role=MessageRole.ASSISTANT, + parts=[TextPart(text=text)], + model=model, + ) + saved = await store.add_message(cid, msg) + return saved or aid + + async def _emit_assistant_patch( + self, store, cid, text, final_id + ) -> None: + meta = await store.get_conversation_metadata(cid) + if meta is None: + return + idx = meta.message_count - 1 + patches = ( + ConversationPatchBuilder() + .finalize_assistant_text_part( + idx, text, message_id=final_id, sequence=idx + ) + .message_count(meta.message_count) + .build() + ) + await emit_conversation_patch(cid, patches) + + @staticmethod + def _to_langchain(messages): + out = [] + for m in messages: + text = "\n".join( + p.text for p in m.parts if isinstance(p, TextPart) + ) + if m.role == MessageRole.USER: + out.append(HumanMessage(content=text)) + elif m.role == MessageRole.ASSISTANT: + out.append(AIMessage(content=text)) + return out diff --git a/src/backend/app/agent/service/title_generator.py b/src/backend/app/agent/service/title_generator.py new file mode 100644 index 00000000..cc17065f --- /dev/null +++ b/src/backend/app/agent/service/title_generator.py @@ -0,0 +1,73 @@ +# agent/services/title_generator.py + +from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel, Field +from app.agent.llm.gateway import LLMGateway + + +class TitleOutput(BaseModel): + title: str = Field(min_length=3, max_length=80) + description: str = Field(min_length=8, max_length=220) + + +async def generate_conversation_title( + llm: LLMGateway, workflow, params: dict +) -> tuple[str, str]: + name = getattr(workflow, "name", "workflow") + fallback_title = name.replace("_", " ").strip().title() + " Run" + fallback_desc = f"Run `{name}` with the provided parameters." + + try: + mini = llm.create_mini() + base = getattr(mini, "_llm", None) + if base is None: + return fallback_title, fallback_desc + + preview = ", ".join( + f"{k}={repr(v)[:77]}" for k, v in list(params.items())[:8] + ) or "None" + + result = await base.with_structured_output(TitleOutput).ainvoke([ + SystemMessage(content=( + "Generate concise conversation metadata for a " + "backend workflow run. No markdown or quotes." + )), + HumanMessage(content=( + f"Workflow: {name}\n" + f"Description: {getattr(workflow, 'description', '')}\n" + f"Params: {preview}\n\n" + "Return title (3-8 words) and description (one sentence)." + )), + ]) + return ( + result.title.strip().strip("\"'") or fallback_title, + result.description.strip().strip("\"'") or fallback_desc, + ) + except Exception: + return fallback_title, fallback_desc + + +class _BatchWorkflowMeta: + """Minimal stand-in for `generate_conversation_title` when starting batch runs.""" + + name = "workflow_batch" + description = ( + "Runs multiple agent workflows sequentially in one conversation." + ) + + +async def generate_batch_conversation_title( + llm: LLMGateway, + steps: list[dict], +) -> tuple[str, str]: + """ + Same LLM-based naming as single-workflow runs, using step names as context. + """ + preview_names = [str(s.get("workflow_name", "?")) for s in steps[:12]] + params = { + "step_count": len(steps), + "workflows": ", ".join(preview_names) or "none", + } + return await generate_conversation_title( + llm, _BatchWorkflowMeta(), params + ) diff --git a/src/backend/app/agent/service/workflow_service.py b/src/backend/app/agent/service/workflow_service.py new file mode 100644 index 00000000..edbe565f --- /dev/null +++ b/src/backend/app/agent/service/workflow_service.py @@ -0,0 +1,468 @@ +# app/agent/service/workflow_service.py + +from __future__ import annotations + +import uuid +import logging +from typing import Any + +from app.agent.conversation_store import ConversationStore +from app.agent.llm.gateway import LLMGateway +from app.agent.runner.task_context import TaskContext +from app.agent.runner.task_manager import TaskManager +from app.agent.runner.task_persistence import TaskPersistence +from app.agent.service.title_generator import ( + generate_batch_conversation_title, + generate_conversation_title, +) +from app.agent.workflows.base import BaseWorkflow +from app.core.repository.conversation._common import new_doc_id +from app.core.model.conversation_domain import ( + ConversationMessage, + TaskPart, + TextPart, +) +from app.core.model.conversation_enums import ( + MessageRole, + TaskState, + TaskState as ConversationTaskState, +) +from app.core.model.conversation_nodes import Task + +logger = logging.getLogger(__name__) + +_TERMINAL_STATES = { + TaskState.COMPLETED, + TaskState.FAILED, + TaskState.CANCELLED, +} + + +class WorkflowService: + def __init__( + self, + task_manager: TaskManager, + llm_gateway: LLMGateway, + db_client: Any = None, + ): + self._tasks = task_manager + self._llm = llm_gateway + self._db_client = db_client + self._task_part_cache: dict[str, TaskPart] = {} + # Keep reference to TaskContext per task_id so the + # status callback can read subtask snapshots. + self._task_contexts: dict[str, TaskContext] = {} + + @property + def llm_factory(self): + return self._llm.factory + + async def join_task(self, task_id: str) -> None: + await self._tasks.join(task_id) + + # -- single workflow -------------------------------------------------- + + async def run( + self, + workflow: BaseWorkflow, + *, + store: ConversationStore, + conversation_id: str | None = None, + **params, + ) -> tuple[str, str]: + workflow_params = dict(params) + # API fields are misnamed: these label the workflow run (Task), not the chat. + task_label = (workflow_params.pop( + "conversation_title", None) or "").strip() + task_summary = ( + workflow_params.pop("conversation_description", None) or "" + ).strip() + + # 1. Ensure conversation (LLM metadata for the thread, same idea as batch) + if conversation_id is None: + gen_title, gen_desc = await generate_conversation_title( + self._llm, workflow, workflow_params + ) + conversation_id = await store.create_conversation( + gen_title, gen_desc + ) + + # 2. Timeline: text + TaskPart ref (task_id == Task document @id) + message_id = str(uuid.uuid4()) + task_id = new_doc_id("TaskSchema") + task_part = TaskPart(task_id=task_id) + await store.add_message( + conversation_id, + ConversationMessage( + id=message_id, + role=MessageRole.ASSISTANT, + parts=[ + TextPart( + text=f"Starting {workflow.name}..." + ), + task_part, + ], + ), + ) + + task_display_name = task_label or f"workflow:{workflow.name}" + task_display_desc = task_summary + + await self._create_task_in_db( + task_id=task_id, + conversation_id=conversation_id, + message_id=message_id, + workflow=workflow, + workflow_params=workflow_params, + name=task_display_name, + description=task_display_desc, + ) + + # 3. Create TaskContext (WorkflowService owns it) + ctx = TaskContext() + task_id_holder: list[str] = [] + + async def _on_status(status: Task) -> None: + if not task_id_holder: + return + task_id = task_id_holder[0] + subtask_snapshots = ctx.subtask_snapshots + await self._sync_task_part( + store, + conversation_id, + task_id, + status, + subtask_snapshots, + ) + # On terminal state → flush to DB + if status.state in _TERMINAL_STATES: + await self._flush_to_db( + task_id=task_id, + conversation_id=conversation_id, + message_id=message_id, + status=status, + ctx=ctx, + workflow=workflow, + workflow_params=workflow_params, + ) + + # 4. Submit — TaskManager runs workflow.run() in background + # Do not let client params override the runner task_id. + submit_kw = { + k: v for k, v in workflow_params.items() if k != "task_id" + } + self._tasks.submit( + name=f"workflow:{workflow.name}", + coro_factory=workflow.run, + on_status_update=_on_status, + task_context=ctx, + task_id=task_id, + **submit_kw, + ) + task_id_holder.append(task_id) + self._task_contexts[task_id] = ctx + + # In-memory / stream: keep human task label; persisted message is ref-only. + self._task_part_cache[task_id] = TaskPart( + task_id=task_id, + title=task_display_name, + workflow_name=workflow.name, + ) + if not self._db_client: + await store.upsert_task_part( + conversation_id, self._task_part_cache[task_id] + ) + + # 7. Push initial state + initial = self._tasks.get_status(task_id) + if initial: + await self._sync_task_part( + store, conversation_id, task_id, initial, [] + ) + + return conversation_id, task_id + + # -- batch (non-blocking) --------------------------------------------- + + async def run_batch( + self, + steps: list[dict[str, Any]], + *, + workflow_factory: Any, + store: ConversationStore, + conversation_id: str | None = None, + conversation_title: str | None = None, + conversation_description: str | None = None, + ) -> tuple[str, str]: + """ + Submit an entire batch as ONE background task. + Steps run sequentially inside the background task. + Returns immediately with (conversation_id, parent_task_id). + + ``conversation_title`` / ``conversation_description`` (API name) set the + **batch task** label in Terminus, not the chat thread; thread metadata + is always LLM-generated when creating a new conversation. + """ + if conversation_id is None: + gen_title, gen_desc = await generate_batch_conversation_title( + self._llm, steps + ) + conversation_id = await store.create_conversation( + gen_title, gen_desc + ) + + task_label = (conversation_title or "").strip() + task_summary = (conversation_description or "").strip() + task_display_name = task_label or "Batch workflow" + task_display_desc = task_summary + + message_id = str(uuid.uuid4()) + task_id = new_doc_id("TaskSchema") + task_part = TaskPart(task_id=task_id) + await store.add_message( + conversation_id, + ConversationMessage( + id=message_id, + role=MessageRole.ASSISTANT, + parts=[ + TextPart(text="Starting batch workflow..."), + task_part, + ], + ), + ) + + await self._create_task_in_db( + task_id=task_id, + conversation_id=conversation_id, + message_id=message_id, + workflow=None, + workflow_params={"step_count": len(steps)}, + name=task_display_name, + description=task_display_desc, + ) + + ctx = TaskContext() + task_id_holder: list[str] = [] + + async def _on_status(status: Task) -> None: + if not task_id_holder: + return + tid = task_id_holder[0] + await self._sync_task_part( + store, + conversation_id, + tid, + status, + ctx.subtask_snapshots, + ) + if status.state in _TERMINAL_STATES: + await self._flush_to_db( + task_id=tid, + conversation_id=conversation_id, + message_id=message_id, + status=status, + ctx=ctx, + workflow=None, + workflow_params={ + "step_count": len(steps) + }, + ) + + async def _batch_runner( + task_status: Task | None = None, + task_context: TaskContext | None = None, + **_kw, + ): + effective_ctx = task_context or TaskContext.noop() + if task_status: + effective_ctx.bind(task_status) + + results = [] + total = len(steps) + for i, step in enumerate(steps): + wf = workflow_factory(step) + st = effective_ctx.subtask( + name=f"{wf.name}", + subtask_id=f"step-{i}-{wf.name}", + ) + st.start( + f"Running {wf.name} ({i + 1}/{total})" + ) + try: + # Each step gets its own nested context + step_ctx = TaskContext.noop() + result = await wf.execute( + step_ctx, **step["params"] + ) + results.append(result) + st.complete(f"Completed {wf.name}") + except Exception as exc: + st.fail(str(exc)) + raise + + return {"batch_results": results} + + self._tasks.submit( + name="workflow:batch", + coro_factory=_batch_runner, + on_status_update=_on_status, + task_context=ctx, + task_id=task_id, + ) + task_id_holder.append(task_id) + self._task_contexts[task_id] = ctx + + self._task_part_cache[task_id] = TaskPart( + task_id=task_id, + title=task_display_name, + workflow_name="batch", + ) + if not self._db_client: + await store.upsert_task_part( + conversation_id, self._task_part_cache[task_id] + ) + + return conversation_id, task_id + + # -- DB persistence --------------------------------------------------- + + async def _create_task_in_db( + self, + *, + task_id: str, + conversation_id: str, + message_id: str, + workflow: BaseWorkflow | None, + workflow_params: dict, + name: str | None = None, + description: str = "", + ) -> None: + if not self._db_client: + return + import json + + persistence = TaskPersistence(self._db_client) + default_name = ( + f"workflow:{workflow.name}" if workflow else "workflow:batch" + ) + task_doc = Task( + id=task_id, + name=(name or default_name).strip() or default_name, + description=description, + conversation_id=conversation_id, + message_id=message_id, + state=TaskState.PENDING, + workflow_name=workflow.name if workflow else "batch", + workflow_params_json=json.dumps( + workflow_params, default=str + ), + ) + + try: + await persistence.create_task(task_doc) + except Exception: + logger.exception( + "Failed to persist task %s to DB", task_id + ) + + async def _flush_to_db( + self, + *, + task_id: str, + conversation_id: str, + message_id: str, + status: Task, + ctx: TaskContext, + workflow: BaseWorkflow | None, + workflow_params: dict, + ) -> None: + if not self._db_client: + return + + persistence = TaskPersistence(self._db_client) + + try: + await persistence.update_task_state( + task_id, + state=status.state, + progress=status.progress, + progress_message=status.progress_message, + error=status.error, + result_json=status.result_json, + started_at=status.started_at, + finished_at=status.finished_at, + sub_task_count=ctx.subtask_count, + ) + except Exception: + logger.exception( + "Failed to update task %s in DB", task_id + ) + + try: + await persistence.flush_subtasks( + task_id, ctx.subtask_snapshots + ) + except Exception: + logger.exception( + "Failed to flush subtasks for task %s", task_id + ) + + # Cleanup + self._task_contexts.pop(task_id, None) + self._task_part_cache.pop(task_id, None) + + # -- TaskPart sync (frontend updates) --------------------------------- + + async def _sync_task_part( + self, + store: ConversationStore, + conversation_id: str, + task_id: str, + status: Task | None, + subtask_snapshots: list | None = None, + ) -> None: + if status is None: + return + base = self._task_part_cache.get( + task_id, + TaskPart(task_id=task_id), + ) + snaps = list(subtask_snapshots or []) + st_count = int(getattr(status, "sub_task_count", 0) or 0) + updated = base.model_copy( + update={ + "title": base.title or status.name, + "state": ConversationTaskState( + status.state.value + ), + "progress": status.progress, + "description": status.progress_message or "", + "started_at": status.started_at, + "finished_at": status.finished_at, + "sub_tasks": snaps, + "sub_task_count": max(len(snaps), st_count), + } + ) + self._task_part_cache[task_id] = updated + + if self._db_client: + persistence = TaskPersistence(self._db_client) + try: + await persistence.update_task_state( + task_id, + state=status.state, + progress=status.progress, + progress_message=status.progress_message, + error=status.error, + result_json=status.result_json, + started_at=status.started_at, + finished_at=status.finished_at, + sub_task_count=max(len(snaps), st_count), + ) + except Exception: + logger.exception( + "Failed to persist live task state for %s", task_id + ) + return + + await store.upsert_task_part(conversation_id, updated) diff --git a/src/backend/app/agent/streaming/__init__.py b/src/backend/app/agent/streaming/__init__.py new file mode 100644 index 00000000..e8199ee2 --- /dev/null +++ b/src/backend/app/agent/streaming/__init__.py @@ -0,0 +1,15 @@ +"""WebSocket payloads and domain → client wire shapes for conversations.""" + +from app.agent.streaming.conversation_events import ( + conversation_room, + emit_conversation_patch, + emit_to_conversation, +) +from app.agent.streaming.wire import conversation_message_to_wire + +__all__ = [ + "conversation_message_to_wire", + "conversation_room", + "emit_conversation_patch", + "emit_to_conversation", +] diff --git a/src/backend/app/agent/streaming/conversation_events.py b/src/backend/app/agent/streaming/conversation_events.py new file mode 100644 index 00000000..011ad51d --- /dev/null +++ b/src/backend/app/agent/streaming/conversation_events.py @@ -0,0 +1,38 @@ +"""Socket.IO helpers for conversation rooms and patch envelopes.""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.core.socket.manager import get_socket_manager + +logger = logging.getLogger(__name__) + + +def conversation_room(conversation_id: str) -> str: + return f"conv:{conversation_id}" + + +async def emit_to_conversation( + conversation_id: str, + event: str, + data: dict[str, Any], +) -> None: + mgr = get_socket_manager() + room = conversation_room(conversation_id) + try: + await mgr.server.emit(event, data, room=room) + except Exception as e: + logger.error("emit_to_conversation failed: %s", e) + + +async def emit_conversation_patch( + conversation_id: str, + patches: list[dict[str, Any]], +) -> None: + await emit_to_conversation( + conversation_id, + "conversation:patch", + {"conversation_id": conversation_id, "patches": patches}, + ) diff --git a/src/backend/app/agent/streaming/manager.py b/src/backend/app/agent/streaming/manager.py new file mode 100644 index 00000000..00d186fa --- /dev/null +++ b/src/backend/app/agent/streaming/manager.py @@ -0,0 +1,111 @@ +# agent/streaming/manager.py + +import uuid +import logging +from dataclasses import dataclass + +from app.agent.streaming import emit_to_conversation +from app.agent.runner.stream_buffer import StreamRegistry + +logger = logging.getLogger(__name__) + + +@dataclass +class StreamHandle: + stream_id: str + conversation_id: str + + +class StreamManager: + def __init__(self, registry: StreamRegistry | None = None): + self._registry = registry or StreamRegistry() + + def open(self, conversation_id: str) -> StreamHandle: + stream_id = str(uuid.uuid4()) + self._registry.create(stream_id, conversation_id) + return StreamHandle( + stream_id=stream_id, + conversation_id=conversation_id, + ) + + async def emit_start( + self, + handle: StreamHandle, + *, + model: str, + provider: str, + task_id: str | None = None, + client_ref: str | None = None, + ) -> None: + payload = { + "stream_id": handle.stream_id, + "conversation_id": handle.conversation_id, + "model": model, + "provider": provider, + } + if task_id: + payload["task_id"] = task_id + if client_ref: + payload["client_ref"] = client_ref + await emit_to_conversation( + handle.conversation_id, "stream:start", payload + ) + + async def push_chunk( + self, handle: StreamHandle, delta: str + ) -> int | None: + buf = self._registry.get(handle.stream_id) + if buf is None: + return None + seq = buf.append(delta) + await emit_to_conversation( + handle.conversation_id, + "stream:chunk", + { + "stream_id": handle.stream_id, + "seq": seq, + "delta": delta, + }, + ) + return seq + + def finish(self, handle: StreamHandle) -> str: + buf = self._registry.get(handle.stream_id) + if buf is None: + return "" + return buf.finish() + + def set_message_id( + self, handle: StreamHandle, message_id: str + ) -> None: + buf = self._registry.get(handle.stream_id) + if buf: + buf.set_message_id(message_id) + + def next_seq(self, handle: StreamHandle) -> int: + buf = self._registry.get(handle.stream_id) + return buf.next_seq if buf else 0 + + async def emit_end( + self, handle: StreamHandle, message_id: str + ) -> None: + await emit_to_conversation( + handle.conversation_id, + "stream:end", + { + "stream_id": handle.stream_id, + "message_id": message_id, + "total_seq": self.next_seq(handle), + }, + ) + self._registry.schedule_remove(handle.stream_id) + + async def emit_error( + self, handle: StreamHandle, error: str + ) -> None: + await emit_to_conversation( + handle.conversation_id, + "stream:error", + {"stream_id": handle.stream_id, "error": error}, + ) + self._registry.schedule_remove(handle.stream_id) diff --git a/src/backend/app/agent/streaming/wire.py b/src/backend/app/agent/streaming/wire.py new file mode 100644 index 00000000..a07e5fc9 --- /dev/null +++ b/src/backend/app/agent/streaming/wire.py @@ -0,0 +1,68 @@ +"""Map domain models to the JSON shape the client keeps for patches / API responses.""" + +from __future__ import annotations + +from typing import Any + +from app.core.model.conversation_domain import ( + ConversationMessage, + ConversationSummary, + MessagePart, + TaskPart, + TextPart, + ToolCallPart, +) + + +def _part_to_wire(part: MessagePart) -> dict[str, Any]: + if isinstance(part, TextPart): + return {"type": "text", "text": part.text} + if isinstance(part, TaskPart): + return { + "type": "task", + "task_id": part.task_id, + "title": part.title, + "description": part.description, + "state": part.state.value, + "progress": part.progress, + "sub_tasks": [st.model_dump(mode="json") for st in part.sub_tasks], + "sub_task_count": max( + int(part.sub_task_count or 0), + len(part.sub_tasks), + ), + "workflow_name": part.workflow_name, + } + if isinstance(part, ToolCallPart): + d: dict[str, Any] = { + "type": "tool_call", + "tool_name": part.tool_name, + "tool_input": part.tool_input, + } + if part.tool_output is not None: + d["tool_output"] = part.tool_output + return d + return part.model_dump(mode="json") # event / future parts + + +def conversation_message_to_wire(msg: ConversationMessage) -> dict[str, Any]: + return { + "id": msg.id, + "role": msg.role.value if hasattr(msg.role, "value") else str(msg.role), + "sequence": msg.sequence, + "parts": [_part_to_wire(p) for p in msg.parts], + "created_at": msg.created_at.isoformat(), + "token_count": msg.token_count, + "model": msg.model, + } + + +def conversation_summary_to_wire(s: ConversationSummary) -> dict[str, Any]: + return { + "id": s.id, + "title": s.title, + "description": s.description, + "message_count": s.message_count, + "has_active_task": s.has_active_task, + "created_at": s.created_at.isoformat(), + "updated_at": s.updated_at.isoformat(), + } diff --git a/src/backend/app/agent/tools/__init__.py b/src/backend/app/agent/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/tools/base.py b/src/backend/app/agent/tools/base.py new file mode 100644 index 00000000..98306b59 --- /dev/null +++ b/src/backend/app/agent/tools/base.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from typing import Any +from app.agent.tools.tool_card import ToolCard + + +class BaseTool(ABC): + """Abstract base class for all agent tools.""" + + @abstractmethod + def get_card(self) -> ToolCard: + """Return the tool's self-describing metadata.""" + ... + + @abstractmethod + async def execute(self, **kwargs) -> Any: + """Run the tool with validated inputs. Returns structured output.""" + ... + + def validate_inputs(self, **kwargs) -> dict: + """Optional input validation hook. Override to add custom checks.""" + return kwargs diff --git a/src/backend/app/agent/tools/tool_card.py b/src/backend/app/agent/tools/tool_card.py new file mode 100644 index 00000000..f28c52a8 --- /dev/null +++ b/src/backend/app/agent/tools/tool_card.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Optional + + +class ToolCard(BaseModel): + """Self-describing metadata for a tool (OctoTools-style tool card).""" + name: str # e.g. "graph_search" + description: str # Human-readable purpose + version: str = "1.0.0" + + # Schema for inputs / outputs + input_schema: dict # JSON-Schema dict for execute() kwargs + output_type: str # Human-readable output type description + + # Usage hints (fed to the planner LLM) + demo_commands: list[dict] = [] # Example invocations + limitations: Optional[str] = None + best_practice: Optional[str] = None + + # Runtime flags + requires_llm: bool = False # Does this tool need an LLM engine internally? + requires_vectorlink: bool = False # Needs VectorLink client? + requires_db: bool = False # Needs TerminusDB client? diff --git a/src/backend/app/agent/tools/tool_registry.py b/src/backend/app/agent/tools/tool_registry.py new file mode 100644 index 00000000..41704d89 --- /dev/null +++ b/src/backend/app/agent/tools/tool_registry.py @@ -0,0 +1,24 @@ +from app.agent.tools.base import BaseTool +from app.agent.tools.tool_card import ToolCard + + +class ToolRegistry: + """Discover, register, enable/disable tools at runtime.""" + + def __init__(self): + self._tools: dict[str, BaseTool] = {} + + def register(self, tool: BaseTool) -> None: + card = tool.get_card() + self._tools[card.name] = tool + + def get(self, name: str) -> BaseTool: + return self._tools[name] + + def list_cards(self, enabled_only: bool = True) -> list[ToolCard]: + """Return tool cards (useful for feeding to the LLM planner).""" + return [t.get_card() for t in self._tools.values()] + + def auto_discover(self, package_path: str = "app.agent.tools") -> None: + """Walk subpackages, import any BaseTool subclass, register it.""" + ... diff --git a/src/backend/app/agent/workflows/__init__.py b/src/backend/app/agent/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/workflows/base.py b/src/backend/app/agent/workflows/base.py new file mode 100644 index 00000000..f6a9e12a --- /dev/null +++ b/src/backend/app/agent/workflows/base.py @@ -0,0 +1,43 @@ +# app/agent/workflows/base.py + +from abc import ABC, abstractmethod +from typing import Any + +from app.core.model.conversation_nodes import Task +from app.agent.runner.task_context import TaskContext + + +class BaseWorkflow(ABC): + """ + Template Method base for all workflows. + + Subclasses implement `validate()` and `execute()`. + The framework calls `run()`. + """ + + name: str + description: str + + async def validate(self, **kwargs) -> None: + """Override to reject invalid params before execution.""" + + @abstractmethod + async def execute(self, ctx: TaskContext, **kwargs) -> Any: + """Implement workflow logic here.""" + ... + + async def run( + self, + task_status: Task | None = None, + task_context: TaskContext | None = None, + **kwargs, + ) -> Any: + """ + Template method — called by TaskManager. + Do NOT override in subclasses. + """ + ctx = task_context or TaskContext.noop() + if task_status: + ctx.bind(task_status) + await self.validate(**kwargs) + return await self.execute(ctx, **kwargs) diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py new file mode 100644 index 00000000..a56f554f --- /dev/null +++ b/src/backend/app/agent/workflows/description_gen.py @@ -0,0 +1,236 @@ +# app/agent/workflows/description_gen.py + +from __future__ import annotations + +from typing import Any + +from langchain_core.messages import HumanMessage + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.runner.task_context import TaskContext +from app.agent.workflows.base import BaseWorkflow +from app.agent.workflows.node_persistence import NodePersistence +from app.agent.workflows.traversal_helpers import ordered_nodes + +_NODE_TYPES = [ + "FileSchema", + "FunctionSchema", + "ClassSchema", + "FolderSchema", +] + + +class DescriptionGeneratorWorkflow(BaseWorkflow): + name = "description_generator" + description = "Generate descriptions recursively from a tree" + + def __init__( + self, + graph: GraphTraversal | None = None, + llm_factory=None, + ): + self.graph = graph + self.llm_factory = llm_factory + + async def validate(self, **kwargs) -> None: + if self.graph is None: + raise ValueError( + "GraphTraversal is required for description workflow." + ) + if self.llm_factory is None: + raise ValueError( + "LLM factory is required for description workflow." + ) + direction = kwargs.get("direction", "down") + if direction not in {"up", "down"}: + raise ValueError(f"Invalid direction: {direction}") + + async def execute(self, ctx: TaskContext, **kwargs) -> dict: + self._read_llm_options(kwargs) + node_id = kwargs.get("node_id") + direction = kwargs.get("direction", "down") + max_depth = kwargs.get("max_depth", 5) + description_mode = kwargs.pop("description_mode", "always") + + ctx.update_progress(0.0, "Building tree...") + + roots = await self.graph.build_tree( + node_id=node_id, + node_types=_NODE_TYPES, + max_depth=max_depth, + ) + nodes = ordered_nodes(roots, direction) + + if description_mode == "skip_if_present": + nodes = [ + n + for n in nodes + if not ( + getattr(n, "description", None) or "" + ).strip() + ] + + if not nodes: + return {"processed": 0, "results": {}} + + generated: dict[str, str] = {} + node_updates: dict[str, Any] = {} + + for index, tree_node in enumerate(nodes): + nid = getattr(tree_node, "id", None) + if not nid: + continue + + node_doc = self._tree_node_to_prompt_doc(tree_node) + node_doc["code_content_data"] = ( + await self.graph.get_code_content(nid) + ) + + node_name = node_doc.get("name", nid) + + # -- register subtask -- + st = ctx.subtask( + name=node_name, + subtask_id=nid, + touched_node_ids=[nid], + ) + st.start(f"Generating description for {node_name}") + + child_descs = self._gather_child_values( + tree_node, generated, attr="description" + ) + prompt = self._build_description_prompt( + node_doc=node_doc, + child_descriptions=child_descs, + ) + + try: + text = await self._invoke_llm(prompt) + + generated[nid] = text + node_updates[nid] = tree_node.model_copy( + update={"description": text} + ) + st.complete(f"Done: {node_name}") + except Exception as exc: + st.fail(str(exc)) + raise + + persistence = NodePersistence(self.graph) + await persistence.flush_descriptions(node_updates) + + return { + "processed": len(generated), + "direction": direction, + "description_results": generated, + } + + # -- shared helpers --------------------------------------------------- + + def _read_llm_options(self, kwargs: dict) -> None: + self._invoke_model = ( + kwargs.pop("model", None) or "gpt-4o-mini" + ) + self._invoke_provider = kwargs.pop("provider", None) + + @staticmethod + def _tree_node_to_prompt_doc(tree_node: Any) -> dict: + node_type = tree_node.__class__.__name__.replace( + "TreeNode", "Schema" + ) + return { + "@id": getattr(tree_node, "id", None), + "@type": node_type, + "name": getattr(tree_node, "name", ""), + "description": getattr(tree_node, "description", ""), + } + + @staticmethod + def _gather_child_values( + tree_node: Any, + generated_values: dict[str, str], + attr: str = "description", + ) -> list[str]: + """ + Collect child values, preferring freshly generated + values over the tree node's original attribute. + """ + values: list[str] = [] + for child in getattr(tree_node, "children", []) or []: + child_id = getattr(child, "id", None) + if not child_id: + continue + # Freshly generated wins + val = generated_values.get(child_id) + if not val: + val = getattr(child, attr, None) + if val and isinstance(val, str) and val.strip(): + values.append(val) + return values + + @staticmethod + def _extract_code_context(node_doc: dict) -> str: + node_type = ( + (node_doc.get("@type") or "").replace("Schema", "") + ) + if node_type not in {"File", "Function", "Class"}: + return "" + code = node_doc.get("code_content_data") + if isinstance(code, str) and code.strip(): + return code + return "" + + def _build_description_prompt( + self, + *, + node_doc: dict, + child_descriptions: list[str], + ) -> str: + child_ctx = ( + "\n".join(f"- {d}" for d in child_descriptions) + if child_descriptions + else "None" + ) + code_ctx = ( + self._extract_code_context(node_doc) + or "No direct code content found." + ) + return ( + "Task: description\n" + "Write a concise technical description of this " + "node in 1-5 sentences.\n" + "Use child descriptions for context and avoid " + "repetition.\n\n" + f"Node id: {node_doc.get('@id')}\n" + f"Node type: {node_doc.get('@type')}\n" + f"Node name: {node_doc.get('name')}\n" + f"Current description: " + f"{node_doc.get('description', '')}\n\n" + f"Code context:\n{code_ctx}\n\n" + f"Child descriptions:\n{child_ctx}\n" + ) + + async def _invoke_llm(self, prompt: str) -> str: + + model = ( + getattr(self, "_invoke_model", None) or "gpt-4o-mini" + ) + provider = getattr(self, "_invoke_provider", None) + llm = self.llm_factory.create( + provider=provider, model=model + ) + response = await llm.invoke( + [HumanMessage(content=prompt)] + ) + content = getattr(response, "content", "") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if text: + parts.append(text) + return "\n".join(parts).strip() + return str(content).strip() diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py new file mode 100644 index 00000000..d865777b --- /dev/null +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -0,0 +1,174 @@ +# app/agent/workflows/documentation_gen.py + +from __future__ import annotations + +from typing import Any + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.runner.task_context import TaskContext +from app.agent.workflows.description_gen import ( + DescriptionGeneratorWorkflow, + _NODE_TYPES, +) +from app.agent.workflows.node_persistence import NodePersistence +from app.agent.workflows.traversal_helpers import ordered_nodes + + +class DocumentationGeneratorWorkflow(DescriptionGeneratorWorkflow): + """Recursive doc generation; persisted as markdown with empty ``data`` (see ``NodePersistence``).""" + + name = "documentation_generator" + description = ( + "Generate documentation recursively from a tree" + ) + + def __init__( + self, + graph: GraphTraversal | None = None, + llm_factory=None, + ): + super().__init__(graph=graph, llm_factory=llm_factory) + + async def execute(self, ctx: TaskContext, **kwargs) -> dict: + self._read_llm_options(kwargs) + kwargs.pop("description_mode", None) + documentation_mode = kwargs.pop( + "documentation_mode", "upsert" + ) + + node_id = kwargs.get("node_id") + direction = kwargs.get("direction", "down") + max_depth = kwargs.get("max_depth", 5) + + ctx.update_progress(0.0, "Building tree...") + + roots = await self.graph.build_tree( + node_id=node_id, + node_types=_NODE_TYPES, + max_depth=max_depth, + ) + nodes = ordered_nodes(roots, direction) + + if not nodes: + return { + "processed": 0, + "documentation_results": {}, + "upserted_document_ids": [], + } + + persistence = NodePersistence(self.graph) + + if documentation_mode == "insert_only": + filtered = [] + for n in nodes: + nid = getattr(n, "id", None) + if nid and not await persistence.check_document_exists( + nid + ): + filtered.append(n) + nodes = filtered + + if not nodes: + return { + "processed": 0, + "documentation_results": {}, + "upserted_document_ids": [], + } + + doc_values: dict[str, str] = {} + processed_nodes: dict[str, Any] = {} + + for index, tree_node in enumerate(nodes): + nid = getattr(tree_node, "id", None) + if not nid: + continue + + node_doc = self._tree_node_to_prompt_doc(tree_node) + node_doc["code_content_data"] = ( + await self.graph.get_code_content(nid) + ) + node_name = node_doc.get("name", nid) + + st = ctx.subtask( + name=node_name, + subtask_id=nid, + touched_node_ids=[nid], + ) + st.start(f"Generating documentation for {node_name}") + + # Use freshly generated docs for child context + child_docs = self._gather_child_values( + tree_node, doc_values, attr="description" + ) + # Use tree descriptions for child descriptions + child_descs = self._gather_child_values( + tree_node, {}, attr="description" + ) + + prompt = self._build_documentation_prompt( + node_doc=node_doc, + node_description=( + getattr(tree_node, "description", "") or "" + ), + child_documentations=child_docs, + child_descriptions=child_descs, + ) + + try: + text = await self._invoke_llm(prompt) + doc_values[nid] = text + processed_nodes[nid] = tree_node + st.complete(f"Done: {node_name}") + except Exception as exc: + st.fail(str(exc)) + raise + + upserted_ids = ( + await persistence.flush_documentation_batch( + doc_values, processed_nodes + ) + ) + + return { + "processed": len(doc_values), + "direction": direction, + "documentation_results": doc_values, + "upserted_document_ids": upserted_ids, + } + + def _build_documentation_prompt( + self, + *, + node_doc: dict, + node_description: str, + child_documentations: list[str], + child_descriptions: list[str], + ) -> str: + child_doc_ctx = ( + "\n".join(f"- {d}" for d in child_documentations) + if child_documentations + else "None" + ) + child_desc_ctx = ( + "\n".join(f"- {d}" for d in child_descriptions) + if child_descriptions + else "None" + ) + code_ctx = ( + self._extract_code_context(node_doc) + or "No direct code content found." + ) + return ( + "Task: documentation\n" + "Write practical technical documentation for this " + "node.\n" + "Use node description and child outputs to keep " + "hierarchy-consistent docs.\n\n" + f"Node id: {node_doc.get('@id')}\n" + f"Node type: {node_doc.get('@type')}\n" + f"Node name: {node_doc.get('name')}\n" + f"Node description: {node_description}\n\n" + f"Code context:\n{code_ctx}\n\n" + f"Child documentations:\n{child_doc_ctx}\n\n" + f"Child descriptions:\n{child_desc_ctx}\n" + ) diff --git a/src/backend/app/agent/workflows/node_persistence.py b/src/backend/app/agent/workflows/node_persistence.py new file mode 100644 index 00000000..c24df22d --- /dev/null +++ b/src/backend/app/agent/workflows/node_persistence.py @@ -0,0 +1,193 @@ +# app/agent/workflows/node_persistence.py + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from app.agent.context.graph_traversal import GraphTraversal +from app.core.model.schemas import DocumentSchema +from app.db.async_terminus_client import WOQLQuery as WQ + +try: + from terminusdb_client.woqlquery.woql_query import Doc +except ImportError: # pragma: no cover + Doc = dict # type: ignore + +logger = logging.getLogger(__name__) + + +class NodePersistence: + """ + All DB write operations for workflows. + + Keeps workflow classes focused on orchestration + LLM calls. + """ + + def __init__(self, graph: GraphTraversal): + self._graph = graph + + @property + def _client(self): + return self._graph.repos.client + + # -- descriptions ----------------------------------------------------- + + async def flush_descriptions( + self, + node_updates: dict[str, Any], + ) -> None: + if not self._client or not node_updates: + return + + for node in node_updates.values(): + node_id = getattr(node, "id", None) + if not node_id: + continue + + queries = [] + if hasattr(node, "description"): + queries.extend( + [ + WQ().opt( + WQ() + .triple( + node_id, + "description", + "v:old_description", + ) + .delete_triple( + node_id, + "description", + "v:old_description", + ) + ), + WQ().add_triple( + node_id, + "description", + WQ().string( + getattr(node, "description", "") or "" + ), + ), + ] + ) + + if hasattr(node, "documents"): + for doc_id in set(getattr(node, "documents") or set()): + queries.append( + WQ().opt( + WQ().add_triple( + node_id, "documents", doc_id + ) + ) + ) + + if queries: + await self._client.query( + WQ().woql_and(*queries), + commit_msg=f"Workflow: update node {node_id}", + ) + + # -- documentation documents ------------------------------------------ + + @staticmethod + def documentation_doc_id(node_id: str) -> str: + safe = node_id.replace("/", "_").replace(":", "_") + return f"DocumentSchema/{safe}_workflow_documentation" + + async def flush_documentation_batch( + self, + documentation_values: dict[str, str], + processed_nodes: dict[str, Any], + ) -> list[str]: + if not self._client or not documentation_values: + return [] + + now = datetime.now(timezone.utc) + doc_ids = [ + self.documentation_doc_id(nid) + for nid in documentation_values + ] + + existing_docs: dict[str, dict] = {} + try: + existing = await self._client.get_documents(doc_ids) + existing_docs = { + doc.get("@id"): doc for doc in existing + } + except Exception: + existing_docs = {} + + document_queries = [] + link_queries = [] + + for node_id, content in documentation_values.items(): + doc_id = self.documentation_doc_id(node_id) + created_at = existing_docs.get(doc_id, {}).get( + "created_at", now + ) + + # Store generated text as markdown only; leave data empty so the + # editor loads from markdown (BlockNote) instead of JSON blocks. + schema = DocumentSchema( + _id=doc_id, + name="Overview ", + description="Generated by documentation workflow.", + data="", + markdown=content, + created_at=created_at, + updated_at=now, + ) + document_queries.append( + WQ().insert_document(Doc(schema._obj_to_dict()[0])) + ) + + tree_node = processed_nodes.get(node_id) + tree_node_id = ( + getattr(tree_node, "id", None) + if tree_node + else None + ) + if tree_node_id: + link_queries.append( + WQ().opt( + WQ().add_triple( + tree_node_id, "documents", doc_id + ) + ) + ) + + if document_queries: + await self._client.query( + WQ().woql_and(*document_queries), + commit_msg=( + f"Workflow: upsert {len(document_queries)} " + "generated documents" + ), + ) + if link_queries: + await self._client.query( + WQ().woql_and(*link_queries), + commit_msg=( + f"Workflow: link {len(link_queries)} " + "documents to nodes" + ), + ) + + return doc_ids + + async def check_document_exists(self, node_id: str) -> bool: + if not self._client: + return False + doc_id = self.documentation_doc_id(node_id) + try: + existing = await self._client.get_documents([doc_id]) + if not existing: + return False + row = existing[0] + has_data = bool((row.get("data") or "").strip()) + has_md = bool((row.get("markdown") or "").strip()) + return has_data or has_md + except Exception: + return False diff --git a/src/backend/app/agent/workflows/traversal_helpers.py b/src/backend/app/agent/workflows/traversal_helpers.py new file mode 100644 index 00000000..4504dcdb --- /dev/null +++ b/src/backend/app/agent/workflows/traversal_helpers.py @@ -0,0 +1,35 @@ +# app/agent/workflows/traversal_helpers.py + +from collections import deque +from typing import Any + + +def collect_levels(roots: list[Any]) -> list[list[Any]]: + if not roots: + return [] + levels: list[list[Any]] = [] + queue: deque[tuple[Any, int]] = deque() + visited: set[str] = set() + for root in roots: + rid = getattr(root, "id", None) + if rid and rid not in visited: + visited.add(rid) + queue.append((root, 0)) + while queue: + node, depth = queue.popleft() + while len(levels) <= depth: + levels.append([]) + levels[depth].append(node) + for child in getattr(node, "children", []) or []: + cid = getattr(child, "id", None) + if cid and cid not in visited: + visited.add(cid) + queue.append((child, depth + 1)) + return levels + + +def ordered_nodes(roots: list[Any], direction: str) -> list[Any]: + levels = collect_levels(roots) + if direction == "up": + levels = list(reversed(levels)) + return [n for level in levels for n in level] diff --git a/src/backend/app/api/dependencies.py b/src/backend/app/api/dependencies.py index 2d58d3aa..e19aa74c 100644 --- a/src/backend/app/api/dependencies.py +++ b/src/backend/app/api/dependencies.py @@ -1,5 +1,7 @@ +from app.agent.llm.gateway import LLMGateway from typing import Optional -from fastapi import Depends, Header, HTTPException, Query, status +from fastapi import Depends, Header, HTTPException, Query, Request, status +from app.agent.conversation_store import TerminusConversationStore from app.core.services.project_service import ProjectService from app.core.services.code_element_service import CodeElementService @@ -9,10 +11,13 @@ from app.core.services.document_service import DocumentService from app.core.services.test_service import TestService from app.core.services.play_ground_service import PlayGroundService +from app.core.repository.conversation import ConversationRepo from app.db.client import get_terminus_client from app.db.async_terminus_client import AsyncClient from app.core.model.nodes import ProjectNode from app.db.context import RequestDbContext, ProjectUoW +from app.agent.service.chat_service import ChatService +from app.agent.service.workflow_service import WorkflowService def get_project_service( @@ -111,3 +116,46 @@ def get_play_ground_service( uow: ProjectUoW = Depends(get_project_uow), ) -> PlayGroundService: return PlayGroundService(uow) + + +def get_project_conversation_repo( + uow: ProjectUoW = Depends(get_project_uow), +) -> ConversationRepo: + """Conversation/task documents in the project Terminus DB (branch/ref from request context).""" + return uow.get_project_repos().conversation_repo + + +def get_project_conversation_store( + uow: ProjectUoW = Depends(get_project_uow), +) -> TerminusConversationStore: + repo = uow.get_project_repos().conversation_repo + return TerminusConversationStore(repo) + + +def get_llm_gateway(request: Request) -> LLMGateway: + factory = getattr(request.app.state, "llm_factory", None) + if factory is None: + raise RuntimeError("llm_factory not initialized on app.state") + return LLMGateway(llm_factory=factory) + + +def get_chat_service(request: Request) -> ChatService: + tm = getattr(request.app.state, "task_manager", None) + sm = getattr(request.app.state, "stream_manager", None) + if tm is None or sm is None: + raise RuntimeError("task_manager or stream_manager not on app.state") + return ChatService( + task_manager=tm, + llm_gateway=get_llm_gateway(request), + stream_manager=sm, + ) + + +def get_workflow_service(request: Request) -> WorkflowService: + tm = getattr(request.app.state, "task_manager", None) + if tm is None: + raise RuntimeError("task_manager not initialized on app.state") + return WorkflowService( + task_manager=tm, + llm_gateway=get_llm_gateway(request), + ) diff --git a/src/backend/app/api/root.py b/src/backend/app/api/root.py index 97c24da8..2a82a9dd 100755 --- a/src/backend/app/api/root.py +++ b/src/backend/app/api/root.py @@ -9,6 +9,13 @@ from .v1.versioning import router as versioning_router # from .v1 import call_routes from .v1 import group_routes +from .v1.conversations import chat_router as conversations_chat_router +from .v1.conversations import conversation_router as conversations_router +from .v1.conversations import ( + conversation_tasks_router as conversation_scoped_tasks_router, +) +from .v1.conversations import task_router as conversation_tasks_router +from .v1.conversations import workflow_router as agent_workflows_router router = APIRouter() @@ -48,3 +55,9 @@ def get_root(): # router.include_router(call_routes.router, prefix="/calls", tags=["calls"]) router.include_router(group_routes.router, prefix="/groups", tags=["groups"]) + +router.include_router(conversations_router) +router.include_router(conversations_chat_router) +router.include_router(conversation_scoped_tasks_router) +router.include_router(conversation_tasks_router) +router.include_router(agent_workflows_router, prefix="/agent") diff --git a/src/backend/app/api/v1/conversations/__init__.py b/src/backend/app/api/v1/conversations/__init__.py new file mode 100644 index 00000000..48f9e0b7 --- /dev/null +++ b/src/backend/app/api/v1/conversations/__init__.py @@ -0,0 +1,15 @@ +from app.api.v1.conversations.routes import ( + chat_router, + conversation_router, + conversation_tasks_router, + task_router, + workflow_router, +) + +__all__ = [ + "chat_router", + "conversation_router", + "conversation_tasks_router", + "task_router", + "workflow_router", +] diff --git a/src/backend/app/api/v1/conversations/deps.py b/src/backend/app/api/v1/conversations/deps.py new file mode 100644 index 00000000..425fe2a0 --- /dev/null +++ b/src/backend/app/api/v1/conversations/deps.py @@ -0,0 +1,35 @@ +"""FastAPI dependencies for conversation + task APIs.""" + +from __future__ import annotations + +from fastapi import Depends, Request + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.runner.task_manager import TaskManager +from app.api.dependencies import ( + get_project_conversation_repo as get_conversation_repo, + get_project_conversation_store as get_conversation_store, + get_project_uow, +) +from app.db.context import ProjectUoW + +__all__ = [ + "get_conversation_repo", + "get_conversation_store", + "get_graph_traversal", + "get_task_manager", +] + + +def get_task_manager(request: Request) -> TaskManager: + tm = getattr(request.app.state, "task_manager", None) + if tm is None: + raise RuntimeError("task_manager not initialized on app.state") + return tm + + +def get_graph_traversal( + uow: ProjectUoW = Depends(get_project_uow), +) -> GraphTraversal: + """Graph access scoped to the resolved project, branch, and ref.""" + return GraphTraversal(uow) diff --git a/src/backend/app/api/v1/conversations/mappers.py b/src/backend/app/api/v1/conversations/mappers.py new file mode 100644 index 00000000..8330122e --- /dev/null +++ b/src/backend/app/api/v1/conversations/mappers.py @@ -0,0 +1,21 @@ +"""Map validated API DTOs to domain models.""" + +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.api.v1.conversations.schemas.parts import TextPartIn +from app.core.model.conversation_domain import MessagePart, TextPart + + +def message_parts_to_domain(parts: list) -> list[MessagePart]: + out: list[MessagePart] = [] + for p in parts: + if isinstance(p, TextPartIn): + out.append(TextPart(text=p.text)) + else: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported message part: {type(p).__name__}", + ) + return out diff --git a/src/backend/app/api/v1/conversations/params.py b/src/backend/app/api/v1/conversations/params.py new file mode 100644 index 00000000..d1eb4e54 --- /dev/null +++ b/src/backend/app/api/v1/conversations/params.py @@ -0,0 +1,27 @@ +"""Reusable FastAPI Query declarations for TerminusDB document ids (may contain `/`).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Query + +ConversationIdQuery = Annotated[ + str, + Query( + ..., + min_length=1, + max_length=512, + description="Full TerminusDB id (e.g. ConversationSchema/uuid). URL-encode `/` as %2F.", + ), +] + +TaskIdQuery = Annotated[ + str, + Query( + ..., + min_length=1, + max_length=512, + description="Full task document id or in-memory task id. URL-encode `/` as %2F.", + ), +] diff --git a/src/backend/app/api/v1/conversations/routes/__init__.py b/src/backend/app/api/v1/conversations/routes/__init__.py new file mode 100644 index 00000000..eba30835 --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes/__init__.py @@ -0,0 +1,17 @@ +from app.api.v1.conversations.routes.chat import router as chat_router +from app.api.v1.conversations.routes.conversation import ( + router as conversation_router, +) +from app.api.v1.conversations.routes.task import ( + conversation_tasks_router, + router as task_router, +) +from app.api.v1.conversations.routes.workflows import router as workflow_router + +__all__ = [ + "chat_router", + "conversation_router", + "conversation_tasks_router", + "task_router", + "workflow_router", +] diff --git a/src/backend/app/api/v1/conversations/routes/chat.py b/src/backend/app/api/v1/conversations/routes/chat.py new file mode 100644 index 00000000..6624be7d --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes/chat.py @@ -0,0 +1,85 @@ +"""Conversation messages and agent chat HTTP API.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.agent.conversation_store import ConversationStore +from app.agent.streaming.wire import conversation_message_to_wire +from app.agent.service.chat_service import ChatService +from app.api.dependencies import get_chat_service +from app.api.v1.conversations.deps import get_conversation_store +from app.api.v1.conversations.mappers import message_parts_to_domain +from app.api.v1.conversations.params import ConversationIdQuery +from app.api.v1.conversations.schemas import ( + PaginatedItems, + PostMessageResponse, + SendConversationMessageRequest, +) +from app.core.model.conversation_domain import ConversationMessage +from app.core.model.conversation_enums import MessageRole + +router = APIRouter(prefix="/conversations", tags=["chat"]) + + +@router.get("/messages") +async def list_messages( + conversation_id: ConversationIdQuery, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + rows = await store.list_messages( + conversation_id, cursor=cursor, limit=limit + 1 + ) + has_more = len(rows) > limit + page = rows[:limit] + next_c: int | None = None + if has_more and page: + next_c = page[-1].sequence + 1 + return PaginatedItems( + items=[conversation_message_to_wire(m) for m in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@router.post( + "/messages", + status_code=status.HTTP_202_ACCEPTED, +) +async def post_message( + body: SendConversationMessageRequest, + chat: ChatService = Depends(get_chat_service), + store: ConversationStore = Depends(get_conversation_store), +) -> PostMessageResponse: + cid = body.conversation_id + meta = await store.get_conversation_metadata(cid) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + + domain_parts = message_parts_to_domain(body.parts) + user_msg = ConversationMessage( + id=str(uuid.uuid4()), + role=MessageRole.USER, + parts=domain_parts, + ) + out = await chat.send_message( + cid, + user_msg, + store=store, + completion_params=body.generation, + client_ref=body.client_ref, + ) + return PostMessageResponse( + message_id=out.get("message_id"), + task_id=out["task_id"], + conversation_id=out["conversation_id"], + stream_id=out["stream_id"], + client_ref=body.client_ref, + ) diff --git a/src/backend/app/api/v1/conversations/routes/conversation.py b/src/backend/app/api/v1/conversations/routes/conversation.py new file mode 100644 index 00000000..ffc8ae4d --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes/conversation.py @@ -0,0 +1,105 @@ +"""Conversation metadata HTTP API (Phase 4). + +TerminusDB @id values often contain `/` (e.g. ConversationSchema/uuid), which breaks +path segments. Resource ids are passed as query parameters instead. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.agent.conversation_store import ConversationStore +from app.api.v1.conversations.deps import get_conversation_store +from app.api.v1.conversations.params import ConversationIdQuery +from app.api.v1.conversations.schemas import ( + ConversationMetaResponse, + CreateConversationRequest, + PaginatedItems, +) + +router = APIRouter(prefix="/conversations", tags=["conversations"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_conversation( + body: CreateConversationRequest, + store: ConversationStore = Depends(get_conversation_store), +) -> ConversationMetaResponse: + cid = await store.create_conversation(body.title, body.description) + meta = await store.get_conversation_metadata(cid) + if meta is None: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Conversation created but not readable", + ) + return ConversationMetaResponse( + id=meta.id, + title=meta.title, + description=meta.description, + message_count=meta.message_count, + has_active_task=meta.has_active_task, + created_at=meta.created_at, + updated_at=meta.updated_at, + metadata={}, + ) + + +@router.get("") +async def list_conversations( + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = None, + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + items = await store.list_conversations(limit=limit + 1, cursor=cursor) + has_more = len(items) > limit + page = items[:limit] + next_cursor = page[-1].id if has_more and page else None + return PaginatedItems( + items=[ + ConversationMetaResponse( + id=s.id, + title=s.title, + description=s.description, + message_count=s.message_count, + has_active_task=s.has_active_task, + created_at=s.created_at, + updated_at=s.updated_at, + metadata={}, + ) + for s in page + ], + next_cursor=next_cursor, + has_more=has_more, + ) + + +@router.get("/meta") +async def get_conversation( + conversation_id: ConversationIdQuery, + store: ConversationStore = Depends(get_conversation_store), +) -> ConversationMetaResponse: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + full = await store.get_conversation(conversation_id) + md = full.metadata if full else {} + return ConversationMetaResponse( + id=meta.id, + title=meta.title, + description=meta.description, + message_count=meta.message_count, + has_active_task=meta.has_active_task, + created_at=meta.created_at, + updated_at=meta.updated_at, + metadata=md, + ) + + +@router.delete("") +async def delete_conversation( + conversation_id: ConversationIdQuery, +) -> None: + raise HTTPException( + status.HTTP_501_NOT_IMPLEMENTED, + detail="Not implemented for this storage backend", + ) diff --git a/src/backend/app/api/v1/conversations/routes/task.py b/src/backend/app/api/v1/conversations/routes/task.py new file mode 100644 index 00000000..3f37b69d --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes/task.py @@ -0,0 +1,106 @@ +"""Agent task and subtask HTTP API.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from app.agent.conversation_store import ConversationStore +from app.agent.runner.task_manager import TaskManager +from app.api.v1.conversations.deps import ( + get_conversation_repo, + get_conversation_store, + get_task_manager, +) +from app.api.v1.conversations.params import ConversationIdQuery, TaskIdQuery +from app.api.v1.conversations.schemas import ( + PaginatedItems, + subtask_to_wire, + task_to_wire, +) +from app.core.repository.conversation._common import task_document_lookup_ids + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + +conversation_tasks_router = APIRouter(prefix="/conversations", tags=["tasks"]) + + +@conversation_tasks_router.get("/tasks") +async def list_conversation_tasks( + conversation_id: ConversationIdQuery, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + repo=Depends(get_conversation_repo), + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + tasks = await repo.list_tasks_for_conversation( + conversation_id, limit=limit + 1, cursor=cursor + ) + has_more = len(tasks) > limit + page = tasks[:limit] + next_c = cursor + len(page) if has_more else None + return PaginatedItems( + items=[task_to_wire(t) for t in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@router.get("/detail") +async def get_task( + task_id: TaskIdQuery, + repo=Depends(get_conversation_repo), + tm: TaskManager = Depends(get_task_manager), +): + t = await repo.get_task(task_id) + if t is not None: + return task_to_wire(t) + mem = tm.get_status(task_id) + if mem is not None: + return task_to_wire(mem) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + + +@router.get("/subtasks") +async def list_subtasks( + task_id: TaskIdQuery, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + repo=Depends(get_conversation_repo), + tm: TaskManager = Depends(get_task_manager), +) -> PaginatedItems: + t = await repo.get_task(task_id) + if t is None: + in_memory = any( + tm.get_status(cand) is not None + for cand in task_document_lookup_ids(task_id) + ) + if not in_memory: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + return PaginatedItems(items=[], next_cursor=None, has_more=False) + rows = await repo.get_subtasks(t.id, cursor=cursor, limit=limit + 1) + has_more = len(rows) > limit + page = rows[:limit] + next_c = page[-1].sequence + 1 if has_more and page else None + return PaginatedItems( + items=[subtask_to_wire(s) for s in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@router.post("/cancel", status_code=status.HTTP_204_NO_CONTENT) +async def cancel_task( + task_id: TaskIdQuery, + tm: TaskManager = Depends(get_task_manager), +) -> Response: + if not tm.cancel(task_id): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="Task not running or unknown", + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/app/api/v1/conversations/routes/workflows.py b/src/backend/app/api/v1/conversations/routes/workflows.py new file mode 100644 index 00000000..84b81f64 --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes/workflows.py @@ -0,0 +1,242 @@ +# app/api/v1/agent/workflows/router.py + +from __future__ import annotations + +from typing import Any, Optional, Type + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.conversation_store import ConversationStore +from app.agent.service.workflow_service import WorkflowService +from app.agent.workflows.base import BaseWorkflow +from app.agent.workflows.description_gen import ( + DescriptionGeneratorWorkflow, +) +from app.agent.workflows.documentation_gen import ( + DocumentationGeneratorWorkflow, +) +from app.api.dependencies import ( + get_project_conversation_store, + get_workflow_service, +) +from app.api.v1.conversations.deps import get_graph_traversal + +router = APIRouter( + prefix="/workflows", tags=["Agent Workflows"] +) + +_WORKFLOW_CLASSES: dict[str, Type[BaseWorkflow]] = { + "documentation_generator": DocumentationGeneratorWorkflow, + "description_generator": DescriptionGeneratorWorkflow, +} + + +class RunWorkflowRequest(BaseModel): + workflow_name: str + params: dict[str, Any] + conversation_id: Optional[str] = None + # Persisted on the Task row (display name / notes), not the conversation thread. + conversation_title: Optional[str] = None + conversation_description: Optional[str] = None + + +class RunWorkflowResponse(BaseModel): + conversation_id: str + task_id: str + status: str + + +class WorkflowBatchStep(BaseModel): + workflow_name: str + params: dict[str, Any] + + +class RunWorkflowBatchRequest(BaseModel): + steps: list[WorkflowBatchStep] + conversation_id: Optional[str] = None + # Labels the batch parent Task; chat title/description are always LLM-generated. + conversation_title: Optional[str] = None + conversation_description: Optional[str] = None + + +class RunWorkflowBatchResponse(BaseModel): + conversation_id: str + task_id: str # single parent task + status: str + + +@router.post( + "/run", + response_model=RunWorkflowResponse, + status_code=202, +) +async def run_workflow( + req: RunWorkflowRequest, + workflow_service: WorkflowService = Depends(get_workflow_service), + graph: GraphTraversal = Depends(get_graph_traversal), + store: ConversationStore = Depends(get_project_conversation_store), +): + workflow_cls = _WORKFLOW_CLASSES.get(req.workflow_name) + if not workflow_cls: + raise HTTPException( + status_code=400, + detail=f"Unknown workflow: {req.workflow_name}", + ) + + workflow = workflow_cls( + graph=graph, + llm_factory=workflow_service.llm_factory, + ) + + try: + params = dict(req.params or {}) + # Remove mode if present (legacy cleanup) + params.pop("mode", None) + + if req.conversation_title is not None: + params["conversation_title"] = req.conversation_title + if req.conversation_description is not None: + params["conversation_description"] = req.conversation_description + + conv_id, task_id = await workflow_service.run( + workflow, + store=store, + conversation_id=req.conversation_id, + **params, + ) + + return RunWorkflowResponse( + conversation_id=conv_id, + task_id=task_id, + status="accepted_and_running", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + # logger.exception("Workflow run failed") + raise HTTPException( + status_code=500, + detail=f"Failed to start workflow: {e}", + ) from e + + +@router.post( + "/run-batch", + response_model=RunWorkflowBatchResponse, + status_code=202, +) +async def run_workflow_batch( + req: RunWorkflowBatchRequest, + workflow_service: WorkflowService = Depends(get_workflow_service), + graph: GraphTraversal = Depends(get_graph_traversal), + store: ConversationStore = Depends(get_project_conversation_store), +): + """ + Run multiple workflows sequentially as a single background task. + + Returns immediately (202 Accepted) with a task_id. The workflows + run in the background. Do NOT await completion here. + """ + if not req.steps: + raise HTTPException( + status_code=400, + detail="steps must not be empty" + ) + + # Validate all workflow names upfront to fail fast + for step in req.steps: + if step.workflow_name not in _WORKFLOW_CLASSES: + raise HTTPException( + status_code=400, + detail=f"Unknown workflow: {step.workflow_name}", + ) + + def _make_workflow(step: dict) -> BaseWorkflow: + """Factory function to instantiate workflows.""" + cls = _WORKFLOW_CLASSES[step["workflow_name"]] + return cls( + graph=graph, + llm_factory=workflow_service.llm_factory, + ) + + try: + # Prepare steps with cleaned params + steps = [] + for step in req.steps: + params = dict(step.params or {}) + params.pop("mode", None) # legacy cleanup + steps.append({ + "workflow_name": step.workflow_name, + "params": params, + }) + + # Submit batch - returns immediately, runs in background + conv_id, task_id = await workflow_service.run_batch( + steps=steps, + workflow_factory=_make_workflow, + store=store, + conversation_id=req.conversation_id, + conversation_title=req.conversation_title, + conversation_description=req.conversation_description, + ) + + return RunWorkflowBatchResponse( + conversation_id=conv_id, + task_id=task_id, + status="accepted_and_running", + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception("Batch workflow submission failed") + raise HTTPException( + status_code=500, + detail=f"Failed to start workflow batch: {e}", + ) from e + + +# Optional: Endpoint to check batch progress or wait (for clients that want to poll) +@router.get("/{task_id}/status") +async def get_task_status( + task_id: str, + workflow_service: WorkflowService = Depends(get_workflow_service), +): + """ + Get current status of a workflow task including subtasks. + """ + status = workflow_service.get_task_status(task_id) + if not status: + raise HTTPException(status_code=404, detail="Task not found") + + return { + "task_id": status.id, + "state": status.state.value, + "progress": status.progress, + "progress_message": status.progress_message, + "sub_task_count": status.sub_task_count, + "started_at": status.started_at, + "finished_at": status.finished_at, + "error": status.error, + } + + +@router.post("/{task_id}/cancel") +async def cancel_task( + task_id: str, + workflow_service: WorkflowService = Depends(get_workflow_service), +): + """ + Cancel a running workflow task. + """ + cancelled = workflow_service.cancel_task(task_id) + if not cancelled: + raise HTTPException( + status_code=400, + detail="Task not found or already completed" + ) + return {"task_id": task_id, "status": "cancelled"} diff --git a/src/backend/app/api/v1/conversations/schemas/__init__.py b/src/backend/app/api/v1/conversations/schemas/__init__.py new file mode 100644 index 00000000..2d2c8114 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/__init__.py @@ -0,0 +1,25 @@ +"""Conversation API DTOs (pagination, parts, requests, responses).""" + +from app.api.v1.conversations.schemas.pagination import PaginatedItems +from app.api.v1.conversations.schemas.parts import MessagePartIn, TextPartIn +from app.api.v1.conversations.schemas.requests import ( + CreateConversationRequest, + SendConversationMessageRequest, +) +from app.api.v1.conversations.schemas.responses import ( + ConversationMetaResponse, + PostMessageResponse, +) +from app.api.v1.conversations.schemas.tasks import subtask_to_wire, task_to_wire + +__all__ = [ + "ConversationMetaResponse", + "CreateConversationRequest", + "MessagePartIn", + "PaginatedItems", + "PostMessageResponse", + "SendConversationMessageRequest", + "TextPartIn", + "subtask_to_wire", + "task_to_wire", +] diff --git a/src/backend/app/api/v1/conversations/schemas/pagination.py b/src/backend/app/api/v1/conversations/schemas/pagination.py new file mode 100644 index 00000000..cd039c34 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/pagination.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class PaginatedItems(BaseModel): + items: list[Any] = Field(default_factory=list) + next_cursor: str | int | None = None + has_more: bool = False diff --git a/src/backend/app/api/v1/conversations/schemas/parts.py b/src/backend/app/api/v1/conversations/schemas/parts.py new file mode 100644 index 00000000..4298ee2d --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/parts.py @@ -0,0 +1,20 @@ +""" +API message parts. + +Use a discriminated union when more than one `type` exists (see Pydantic docs). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class TextPartIn(BaseModel): + type: Literal["text"] = "text" + text: str = Field(..., min_length=1, max_length=1_000_000) + + +# Alias so request models and OpenAPI stay stable when new part types ship. +MessagePartIn = TextPartIn diff --git a/src/backend/app/api/v1/conversations/schemas/requests.py b/src/backend/app/api/v1/conversations/schemas/requests.py new file mode 100644 index 00000000..70ce4907 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/requests.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from app.agent.chat.completion_params import ChatCompletionParams +from app.api.v1.conversations.schemas.parts import TextPartIn + + +class CreateConversationRequest(BaseModel): + title: str = Field(default="New conversation", min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + + +class SendConversationMessageRequest(BaseModel): + """ + Submit a user turn and schedule streamed assistant generation. + + `conversation_id` is the full TerminusDB document id (may contain `/`); + clients should URL-encode it when passed as a query parameter elsewhere. + """ + + conversation_id: str = Field( + ..., + min_length=1, + max_length=512, + description="Full conversation document id, e.g. ConversationSchema/", + ) + role: Literal["user"] = "user" + parts: list[TextPartIn] = Field( + ..., + min_length=1, + max_length=64, + description="Ordered segments; schema uses `type` for forward-compatible unions.", + ) + generation: ChatCompletionParams | None = Field( + default=None, + description="Optional per-request LLM overrides.", + ) + client_ref: str | None = Field( + default=None, + max_length=128, + description="Optional idempotency or correlation key for the client.", + ) + metadata: dict[str, Any] | None = Field( + default=None, + description="Opaque envelope; not interpreted by the runner today.", + ) diff --git a/src/backend/app/api/v1/conversations/schemas/responses.py b/src/backend/app/api/v1/conversations/schemas/responses.py new file mode 100644 index 00000000..7b938c20 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/responses.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class PostMessageResponse(BaseModel): + message_id: str | None + task_id: str + conversation_id: str + stream_id: str + client_ref: str | None = None + + +class ConversationMetaResponse(BaseModel): + id: str + title: str + description: str + message_count: int + has_active_task: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/backend/app/api/v1/conversations/schemas/tasks.py b/src/backend/app/api/v1/conversations/schemas/tasks.py new file mode 100644 index 00000000..fcd86ee3 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/tasks.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + + +def task_to_wire(t: Any) -> dict[str, Any]: + return t.model_dump(mode="json") + + +def subtask_to_wire(st: Any) -> dict[str, Any]: + return st.model_dump(mode="json") diff --git a/src/backend/app/api/v1/document_routes.py b/src/backend/app/api/v1/document_routes.py index 1cef3979..8c359aaf 100644 --- a/src/backend/app/api/v1/document_routes.py +++ b/src/backend/app/api/v1/document_routes.py @@ -26,6 +26,7 @@ class UpdateDocumentRequest(BaseModel): name: Optional[str] = Field(None, min_length=1) description: Optional[str] = Field(None, min_length=1) data: Optional[str] = None + markdown: Optional[str] = None @router.post( @@ -73,6 +74,8 @@ async def update_document( existing.description = request.description if request.data is not None: existing.data = request.data + if request.markdown is not None: + existing.markdown = request.markdown response = await document_service.update(existing) @@ -131,8 +134,9 @@ async def get_documents_for_node( for compare_doc in compare_by_id.values(): merged_documents.append( DocumentResponse( - **compare_doc.model_dump(exclude={"data"}), + **compare_doc.model_dump(exclude={"data", "markdown"}), data="", + markdown="", status="removed", compare_to=compare_doc, ) diff --git a/src/backend/app/api/v1/play_ground_routes.py b/src/backend/app/api/v1/play_ground_routes.py index ea8ca26e..3bd3deea 100644 --- a/src/backend/app/api/v1/play_ground_routes.py +++ b/src/backend/app/api/v1/play_ground_routes.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from app.api.dependencies import get_play_ground_service @@ -18,8 +18,6 @@ class PlayGroundResponse(BaseModel): relative_path: str code: str executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None owner_function: Optional[str] = None owner_class: Optional[str] = None @@ -35,8 +33,6 @@ class CreatePlayGroundRequest(BaseModel): relative_path: str = Field(..., min_length=1) code: str = Field(..., min_length=1) executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None owner_function: Optional[str] = None owner_class: Optional[str] = None @@ -50,8 +46,6 @@ class UpdatePlayGroundRequest(BaseModel): relative_path: Optional[str] = Field(default=None, min_length=1) code: Optional[str] = Field(default=None, min_length=1) executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None @@ -82,8 +76,6 @@ def _to_response(raw: dict) -> PlayGroundResponse: relative_path=raw.get("relative_path", ""), code=raw.get("code", ""), executable_path=raw.get("executable_path"), - examples_path=raw.get("examples_path"), - command_prefix=raw.get("command_prefix"), filename=raw.get("filename"), owner_function=raw.get("owner_function"), owner_class=raw.get("owner_class"), @@ -125,8 +117,6 @@ async def create_playground( relative_path=request.relative_path, code=request.code, executable_path=request.executable_path, - examples_path=request.examples_path, - command_prefix=request.command_prefix, filename=request.filename, owner_function=request.owner_function, owner_class=request.owner_class, @@ -173,8 +163,6 @@ async def update_playground( and request.relative_path is None and request.code is None and request.executable_path is None - and request.examples_path is None - and request.command_prefix is None and request.filename is None ): raise HTTPException( @@ -189,8 +177,6 @@ async def update_playground( relative_path=request.relative_path, code=request.code, executable_path=request.executable_path, - examples_path=request.examples_path, - command_prefix=request.command_prefix, filename=request.filename, ) if not updated: @@ -216,52 +202,20 @@ async def delete_playground( @router.get( - "/owners/function/{owner_function_id}", + "/owners", response_model=list[PlayGroundResponse], ) -async def get_playgrounds_by_owner_function_id( - owner_function_id: str, +async def get_playgrounds_by_owner_node_id( + node_id: str = Query(..., min_length=1), play_ground_service: PlayGroundService = Depends(get_play_ground_service), ) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_function_id( - owner_function_id - ) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/class/{owner_class_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_class_id( - owner_class_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_class_id(owner_class_id) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/file/{owner_file_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_file_id( - owner_file_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_file_id(owner_file_id) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/folder/{owner_folder_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_folder_id( - owner_folder_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_folder_id(owner_folder_id) + try: + items = await play_ground_service.get_by_owner_node_id(node_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc return [_to_response(item) for item in items] diff --git a/src/backend/app/api/v1/project_routes.py b/src/backend/app/api/v1/project_routes.py index 69d962b0..af8a223d 100644 --- a/src/backend/app/api/v1/project_routes.py +++ b/src/backend/app/api/v1/project_routes.py @@ -83,7 +83,7 @@ async def create_project( raise project_service.uow = uow - children = await project_service.get_children() + children, _ = await project_service.get_children() tree_builder = TreeBuilder(children) tree = tree_builder.build() diff --git a/src/backend/app/config/settings.py b/src/backend/app/config/settings.py index cf2a459a..bc60c4d5 100755 --- a/src/backend/app/config/settings.py +++ b/src/backend/app/config/settings.py @@ -5,7 +5,7 @@ class Settings(BaseSettings): - APP_ENV: str + APP_ENV: str = "development" TERMINUS_HOST: str TERMINUS_USER: str @@ -16,7 +16,7 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" model_config = SettingsConfigDict( - # Pydantic-Settings will automatically use the ENV_FILE env var if it exists. + # Pydantic-Settings will automatically use ENV_FILE when present. # Otherwise, it will fall back to ".env". env_file=os.environ.get("ENV_FILE", ".env"), env_file_encoding="utf-8", @@ -38,7 +38,8 @@ def get_settings() -> Settings: """ Returns a cached, singleton instance of the Settings. This function will only create the Settings object once. - It also ensures that the test environment variables are loaded if APP_ENV is set to 'test'. + It also ensures test environment variables are loaded + if APP_ENV is set to 'test'. """ env_file = os.environ.get("ENV_FILE", ".env") if os.environ.get("APP_ENV") == "test": diff --git a/src/backend/app/core/builder/tree_builder.py b/src/backend/app/core/builder/tree_builder.py index 7c17f5ff..928662c2 100644 --- a/src/backend/app/core/builder/tree_builder.py +++ b/src/backend/app/core/builder/tree_builder.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional, Set from pydantic import BaseModel @@ -13,7 +14,10 @@ ProjectTreeNode, ) -# Schema @type or Node class -> tree model (nodes have children as string IDs; tree nodes have nested objects) +logger = logging.getLogger(__name__) + +# Schema @type or Node class -> tree model. +# Nodes use children as string IDs; tree nodes use nested objects. SCHEMA_TO_TREE = { "ProjectSchema": ProjectTreeNode, "FolderSchema": FolderTreeNode, @@ -220,7 +224,10 @@ def _inject_added_nodes( return list(merged.values()) def _propagate_statuses(self, nodes_map: Dict[str, AnyTreeNode]) -> None: - """Bubble up changes: if child is added/removed/modified/moved, parent becomes modified.""" + """ + Bubble up changes: + if child is added/removed/modified/moved, parent becomes modified. + """ changed_ids = { nid for nid, status in self.status_map.items() if status in ("added", "removed", "modified", "moved") @@ -282,7 +289,10 @@ def build(self) -> List[AnyTreeNode]: return result - def _build_tree_from_dicts(self, node_dicts: List[Dict[str, Any]]) -> List[AnyTreeNode]: + def _build_tree_from_dicts( + self, + node_dicts: List[Dict[str, Any]], + ) -> List[AnyTreeNode]: """Build tree from prepared node dictionaries.""" if not node_dicts: return [] diff --git a/src/backend/app/core/model/conversation_domain.py b/src/backend/app/core/model/conversation_domain.py new file mode 100644 index 00000000..062cf502 --- /dev/null +++ b/src/backend/app/core/model/conversation_domain.py @@ -0,0 +1,115 @@ +"""Conversation UI/message models (parts, messages, aggregates).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional, Union + +from pydantic import BaseModel, Field + +from app.core.model.conversation_enums import MessageRole, TaskState +from app.core.model.conversation_nodes import ConversationNode, SubTask + + +class TextPart(BaseModel): + type: Literal["text"] = "text" + text: str + + +class ToolCallPart(BaseModel): + """Agent-side tool call record (not shown to user directly).""" + type: Literal["tool_call"] = "tool_call" + tool_name: str + tool_input: dict + tool_output: Optional[str] = None + + +class EventPart(BaseModel): + """Replay event (mirrors frontend ReplayEvent).""" + type: Literal["event"] = "event" + at: int + event_type: str # "wait" | "click" | "focus" + payload: dict = Field(default_factory=dict) + + +class TaskPart(BaseModel): + """ + Marker in the message timeline for a workflow run. + + Persist a minimal row: ``task_id`` must match the Task document ``@id`` in + Terminus (e.g. ``TaskSchema/``). Title, state, and progress are filled + when serving messages by loading the linked ``Task`` (see repository hydrate). + """ + + type: Literal["task"] = "task" + task_id: str + title: str = "" + description: str = "" + state: TaskState = TaskState.PENDING + created_at: datetime = Field(default_factory=datetime.utcnow) + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + progress: float = 0.0 + sub_tasks: list[SubTask] = Field(default_factory=list) + # Mirrors Task.sub_task_count when known; used when sub_tasks are not embedded. + sub_task_count: int = 0 + touched_node_ids: list[str] = Field(default_factory=list) + workflow_name: Optional[str] = None + workflow_params: Optional[dict] = None + + +MessagePart = Union[TextPart, ToolCallPart, EventPart, TaskPart] + + +class ConversationMessage(BaseModel): + id: str + role: MessageRole + parts: list[MessagePart] + sequence: int = 0 + created_at: datetime = Field(default_factory=datetime.utcnow) + token_count: Optional[int] = None + model: Optional[str] = None + + +class ConversationSummary(BaseModel): + id: str + title: str + description: str = "" + created_at: datetime + updated_at: datetime + message_count: int = 0 + has_active_task: bool = False + + @classmethod + def from_conversation_node( + cls, node: ConversationNode + ) -> "ConversationSummary": + return cls( + id=node.id, + title=node.name, + description=node.description, + created_at=node.created_at, + updated_at=node.updated_at, + message_count=node.message_count, + has_active_task=node.has_active_task, + ) + + +class Conversation(ConversationSummary): + messages: list[ConversationMessage] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + @classmethod + def from_conversation_node( + cls, + node: ConversationNode, + *, + messages: list[ConversationMessage], + metadata: dict, + ) -> "Conversation": + summary = ConversationSummary.from_conversation_node(node) + return cls( + **summary.model_dump(), + messages=messages, + metadata=metadata, + ) diff --git a/src/backend/app/core/model/conversation_enums.py b/src/backend/app/core/model/conversation_enums.py new file mode 100644 index 00000000..4798c915 --- /dev/null +++ b/src/backend/app/core/model/conversation_enums.py @@ -0,0 +1,25 @@ +"""Enums shared by conversation, message, and task models.""" + +from enum import Enum + + +class MessageRole(str, Enum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class SubTaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +class TaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/src/backend/app/core/model/conversation_nodes.py b/src/backend/app/core/model/conversation_nodes.py new file mode 100644 index 00000000..f53421cf --- /dev/null +++ b/src/backend/app/core/model/conversation_nodes.py @@ -0,0 +1,206 @@ +"""Pydantic documents for agent data persisted in TerminusDB (single source types).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +import json + +from pydantic import BaseModel, Field, model_validator + +from app.core.model.conversation_enums import SubTaskState, TaskState + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _coerce_task_state(value: Any) -> TaskState: + if isinstance(value, TaskState): + return value + try: + return TaskState(str(value)) + except ValueError: + return TaskState.PENDING + + +def _coerce_subtask_state(value: Any) -> SubTaskState: + if isinstance(value, SubTaskState): + return value + try: + return SubTaskState(str(value)) + except ValueError: + return SubTaskState.PENDING + + +class ConversationNode(BaseModel): + id: str + name: str + description: str = "" + metadata_json: str = "{}" + message_count: int = 0 + has_active_task: bool = False + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "ConversationNode": + return ConversationNode( + id=raw_dict["@id"], + name=raw_dict["name"], + description=raw_dict.get("description") or "", + metadata_json=raw_dict.get("metadata_json") or "{}", + message_count=int(raw_dict.get("message_count") or 0), + has_active_task=_raw_bool(raw_dict.get("has_active_task")), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class MessageNode(BaseModel): + id: str + conversation_id: str + role: str + parts_json: str + token_count: Optional[int] = None + model_name: Optional[str] = None + sequence: int = 0 + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "MessageNode": + conv = raw_dict.get("conversation") + conv_id = ( + conv if isinstance(conv, str) else (conv or {}).get("@id", "") + ) + return MessageNode( + id=raw_dict["@id"], + conversation_id=conv_id or "", + role=raw_dict["role"], + parts_json=raw_dict.get("parts_json") or "[]", + token_count=raw_dict.get("token_count"), + model_name=raw_dict.get("model_name"), + sequence=int(raw_dict.get("sequence") or 0), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class TaskDocumentBase(BaseModel): + """Shared scalar fields for `Task` (persisted workflow run).""" + + id: str = "" + name: str = "" + description: str = "" + conversation_id: str = "" + message_id: str = "" + progress: float = 0.0 + progress_message: str = "" + workflow_name: Optional[str] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + sub_task_count: int = 0 + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + +class Task(TaskDocumentBase): + state: TaskState = TaskState.PENDING + workflow_params_json: Optional[str] = None + result_json: Optional[str] = None + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "Task": + conv = raw_dict.get("conversation") + msg = raw_dict.get("message") + conv_id = ( + conv if isinstance(conv, str) else (conv or {}).get("@id", "") + ) + msg_id = msg if isinstance(msg, str) else (msg or {}).get("@id", "") + return Task( + id=raw_dict["@id"], + name=raw_dict["name"], + description=raw_dict.get("description") or "", + conversation_id=conv_id or "", + message_id=msg_id or "", + state=_coerce_task_state(raw_dict.get("state")), + progress=float(raw_dict.get("progress") or 0.0), + progress_message=raw_dict.get("progress_message") or "", + workflow_name=raw_dict.get("workflow_name"), + workflow_params_json=raw_dict.get("workflow_params_json"), + started_at=raw_dict.get("started_at"), + finished_at=raw_dict.get("finished_at"), + error=raw_dict.get("error"), + result_json=raw_dict.get("result_json"), + sub_task_count=int(raw_dict.get("sub_task_count") or 0), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class SubTaskDocumentBase(BaseModel): + """Shared scalar fields for `SubTask` (workflow step under a task).""" + + id: str = "" + name: str + description: str = "" + sequence: int = 0 + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + +class SubTask(SubTaskDocumentBase): + task_id: str = "" + state: SubTaskState = SubTaskState.PENDING + touched_node_ids_json: str = "[]" + + @model_validator(mode="before") + @classmethod + def _legacy_touched_node_ids(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + if "touched_node_ids" in data: + data = dict(data) + data["touched_node_ids_json"] = json.dumps( + data.pop("touched_node_ids", []) + ) + return data + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "SubTask": + parent = raw_dict.get("task") + task_id = ( + parent + if isinstance(parent, str) + else (parent or {}).get("@id", "") + ) + return SubTask( + id=raw_dict["@id"], + task_id=task_id or "", + name=raw_dict["name"], + description=raw_dict.get("description") or "", + state=_coerce_subtask_state(raw_dict.get("state")), + sequence=int(raw_dict.get("sequence") or 0), + started_at=raw_dict.get("started_at"), + finished_at=raw_dict.get("finished_at"), + error=raw_dict.get("error"), + touched_node_ids_json=( + raw_dict.get("touched_node_ids_json") or "[]" + ), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +def _raw_bool(value: Any) -> bool: + if value is True or value is False: + return value + if isinstance(value, str): + return value.lower() == "true" + return bool(value) diff --git a/src/backend/app/core/model/nodes.py b/src/backend/app/core/model/nodes.py index 208cba3a..b29f8d99 100644 --- a/src/backend/app/core/model/nodes.py +++ b/src/backend/app/core/model/nodes.py @@ -64,6 +64,10 @@ def from_raw_dict(raw_dict): class DocumentNode(BaseNode): data: str = Field(..., description="The data of the document.") + markdown: str = Field( + default="", + description="Markdown representation (e.g. for AI); may duplicate structured data until optimized.", + ) @staticmethod def from_raw_dict(raw_dict): @@ -71,6 +75,7 @@ def from_raw_dict(raw_dict): return DocumentNode( **base.model_dump(), data=raw_dict["data"], + markdown=raw_dict.get("markdown", "") or "", ) diff --git a/src/backend/app/core/model/schemas/__init__.py b/src/backend/app/core/model/schemas/__init__.py index f73ac127..5230a819 100644 --- a/src/backend/app/core/model/schemas/__init__.py +++ b/src/backend/app/core/model/schemas/__init__.py @@ -22,6 +22,12 @@ CodeContentSchema, ) from .test_schema import TestConfigSchema, TestCaseSchema, TestLinkSchema +from .conversation_schema import ( + ConversationSchema, + MessageSchema, + TaskSchema, + SubTaskSchema, +) __all__ = [ "BaseSchema", @@ -46,6 +52,10 @@ "TestConfigSchema", "TestCaseSchema", "TestLinkSchema", + "ConversationSchema", + "MessageSchema", + "TaskSchema", + "SubTaskSchema", ] @@ -88,6 +98,11 @@ async def ensure_schema( schema_obj.add_obj(TestCaseSchema.__name__, TestCaseSchema) schema_obj.add_obj(TestLinkSchema.__name__, TestLinkSchema) + schema_obj.add_obj(ConversationSchema.__name__, ConversationSchema) + schema_obj.add_obj(MessageSchema.__name__, MessageSchema) + schema_obj.add_obj(TaskSchema.__name__, TaskSchema) + schema_obj.add_obj(SubTaskSchema.__name__, SubTaskSchema) + await schema_obj.commit( client, f"Initialize schema for {title}", diff --git a/src/backend/app/core/model/schemas/code_element_schema.py b/src/backend/app/core/model/schemas/code_element_schema.py index 08cbf8c8..a006e613 100644 --- a/src/backend/app/core/model/schemas/code_element_schema.py +++ b/src/backend/app/core/model/schemas/code_element_schema.py @@ -12,8 +12,6 @@ class PlayGroundSchema(BaseSchema): description: str relative_path: str executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None code: str owner_function: Optional[str] = None diff --git a/src/backend/app/core/model/schemas/conversation_schema.py b/src/backend/app/core/model/schemas/conversation_schema.py new file mode 100644 index 00000000..99d70aab --- /dev/null +++ b/src/backend/app/core/model/schemas/conversation_schema.py @@ -0,0 +1,193 @@ + +from datetime import datetime +from typing import Optional + +from app.core.model.conversation_nodes import ( + ConversationNode, + MessageNode, + SubTask, + Task, + _coerce_subtask_state, + _coerce_task_state, +) +from .base import BaseSchema, TerminusBase + + +class ConversationSchema(BaseSchema): + """Root conversation document; messages point here via `conversation` edge.""" + + metadata_json: str + message_count: int + has_active_task: bool + + @staticmethod + def from_pydantic(node: ConversationNode) -> "ConversationSchema": + return ConversationSchema( + _id=node.id, + name=node.name, + description=node.description, + metadata_json=node.metadata_json, + message_count=node.message_count, + has_active_task=node.has_active_task, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> ConversationNode: + return ConversationNode( + id=self._id, + name=self.name, + description=self.description, + metadata_json=self.metadata_json or "{}", + message_count=int(self.message_count or 0), + has_active_task=bool(self.has_active_task), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class MessageSchema(TerminusBase): + conversation: "ConversationSchema" + role: str + parts_json: str + token_count: Optional[int] + model_name: Optional[str] + sequence: int + + @staticmethod + def from_pydantic(node: MessageNode) -> "MessageSchema": + return MessageSchema( + _id=node.id, + conversation=node.conversation_id, + role=node.role, + parts_json=node.parts_json, + token_count=node.token_count, + model_name=node.model_name, + sequence=node.sequence, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> MessageNode: + conv = self.conversation + conv_id = conv if isinstance(conv, str) else getattr(conv, "_id", "") + return MessageNode( + id=self._id, + conversation_id=conv_id, + role=self.role, + parts_json=self.parts_json or "[]", + token_count=self.token_count, + model_name=self.model_name, + sequence=int(self.sequence or 0), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class TaskSchema(BaseSchema): + conversation: Optional[ConversationSchema] + message: Optional[MessageSchema] + state: str + progress: float + progress_message: str + workflow_name: Optional[str] + workflow_params_json: Optional[str] + started_at: Optional[datetime] + finished_at: Optional[datetime] + error: Optional[str] + result_json: Optional[str] + sub_task_count: int + + @staticmethod + def from_pydantic(node: Task) -> "TaskSchema": + return TaskSchema( + _id=node.id, + name=node.name, + description=node.description, + conversation=node.conversation_id, + message=f"MessageSchema/{node.message_id}" if node.message_id else None, + state=node.state.value, + progress=node.progress, + progress_message=node.progress_message, + workflow_name=node.workflow_name, + workflow_params_json=node.workflow_params_json, + started_at=node.started_at, + finished_at=node.finished_at, + error=node.error, + result_json=node.result_json, + sub_task_count=node.sub_task_count, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> Task: + conv = self.conversation + msg = self.message + return Task( + id=self._id, + name=self.name, + description=self.description, + conversation_id=conv if isinstance( + conv, str) else getattr(conv, "_id", ""), + message_id=msg if isinstance( + msg, str) else getattr(msg, "_id", ""), + state=_coerce_task_state(self.state), + progress=float(self.progress or 0.0), + progress_message=self.progress_message or "", + workflow_name=self.workflow_name, + workflow_params_json=self.workflow_params_json, + started_at=self.started_at, + finished_at=self.finished_at, + error=self.error, + result_json=self.result_json, + sub_task_count=int(self.sub_task_count or 0), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class SubTaskSchema(TerminusBase): + task: Optional[TaskSchema] + name: str + description: str + state: str + sequence: int + started_at: Optional[datetime] + finished_at: Optional[datetime] + error: Optional[str] + touched_node_ids_json: str + + @staticmethod + def from_pydantic(node: SubTask) -> "SubTaskSchema": + return SubTaskSchema( + _id=node.id, + task=node.task_id, + name=node.name, + description=node.description, + state=node.state.value, + sequence=node.sequence, + started_at=node.started_at, + finished_at=node.finished_at, + error=node.error, + touched_node_ids_json=node.touched_node_ids_json, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> SubTask: + parent = self.task + return SubTask( + id=self._id, + task_id=parent if isinstance( + parent, str) else getattr(parent, "_id", ""), + name=self.name, + description=self.description, + state=_coerce_subtask_state(self.state), + sequence=int(self.sequence or 0), + started_at=self.started_at, + finished_at=self.finished_at, + error=self.error, + touched_node_ids_json=self.touched_node_ids_json or "[]", + created_at=self.created_at, + updated_at=self.updated_at, + ) diff --git a/src/backend/app/core/model/schemas/metadata.py b/src/backend/app/core/model/schemas/metadata.py index 4afa7c4c..d1d0d345 100644 --- a/src/backend/app/core/model/schemas/metadata.py +++ b/src/backend/app/core/model/schemas/metadata.py @@ -96,6 +96,7 @@ class DocumentSchema(DocumentTemplate): name: str description: str data: str + markdown: str created_at: datetime updated_at: datetime @@ -106,6 +107,7 @@ def from_pydantic(document: DocumentNode): name=document.name, description=document.description, data=document.data, + markdown=document.markdown, created_at=document.created_at, updated_at=document.updated_at, ) @@ -116,6 +118,7 @@ def to_pydantic(self): name=self.name, description=self.description, data=self.data, + markdown=self.markdown, created_at=self.created_at, updated_at=self.updated_at, ) diff --git a/src/backend/app/core/repository/__init__.py b/src/backend/app/core/repository/__init__.py index 83b320c2..87bed048 100644 --- a/src/backend/app/core/repository/__init__.py +++ b/src/backend/app/core/repository/__init__.py @@ -15,6 +15,7 @@ from .code_elements.code_element_repo import CodeElementRepo from .code_elements.test_repo import TestRepo from .code_elements.play_ground_repo import PlayGroundRepo +from .conversation import ConversationRepo class Repositories: @@ -37,3 +38,4 @@ def __init__(self, client: AsyncClient): self.document_repo = DocumentRepo(client) self.test_repo = TestRepo(client) self.play_ground_repo = PlayGroundRepo(client) + self.conversation_repo = ConversationRepo(client) diff --git a/src/backend/app/core/repository/code_elements/play_ground_repo.py b/src/backend/app/core/repository/code_elements/play_ground_repo.py index bcc1229a..f01098f5 100644 --- a/src/backend/app/core/repository/code_elements/play_ground_repo.py +++ b/src/backend/app/core/repository/code_elements/play_ground_repo.py @@ -67,17 +67,3 @@ async def get_by_owner_field( return [] return [row["playground_doc"] for row in result.get("bindings", [])] - - async def get_by_owner_function_id( - self, owner_function_id: str - ) -> list[dict]: - return await self.get_by_owner_field("owner_function", owner_function_id) - - async def get_by_owner_class_id(self, owner_class_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_class", owner_class_id) - - async def get_by_owner_file_id(self, owner_file_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_file", owner_file_id) - - async def get_by_owner_folder_id(self, owner_folder_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_folder", owner_folder_id) diff --git a/src/backend/app/core/repository/conversation/__init__.py b/src/backend/app/core/repository/conversation/__init__.py new file mode 100644 index 00000000..c8075222 --- /dev/null +++ b/src/backend/app/core/repository/conversation/__init__.py @@ -0,0 +1,8 @@ +from ._common import terminus_doc_id_tail, terminus_ids_match +from .repo import ConversationRepo + +__all__ = [ + "ConversationRepo", + "terminus_doc_id_tail", + "terminus_ids_match", +] diff --git a/src/backend/app/core/repository/conversation/_common.py b/src/backend/app/core/repository/conversation/_common.py new file mode 100644 index 00000000..161195b7 --- /dev/null +++ b/src/backend/app/core/repository/conversation/_common.py @@ -0,0 +1,86 @@ +"""Shared helpers and constants for conversation persistence.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from pydantic import TypeAdapter + +from app.core.model.conversation_domain import MessagePart +from app.core.model.conversation_enums import TaskState + +MESSAGE_PARTS_ADAPTER = TypeAdapter(list[MessagePart]) + +TERMINAL_TASK_STATES = frozenset( + { + TaskState.COMPLETED, + TaskState.FAILED, + TaskState.CANCELLED, + } +) + + +def new_doc_id(class_name: str) -> str: + return f"{class_name}/{uuid.uuid4()}" + + +def terminus_doc_id_tail(doc_id: str) -> str: + """Segment after the last `/`, or the full string (bare ids, URLs).""" + s = (doc_id or "").strip() + if not s: + return s + return s.rsplit("/", 1)[-1] + + +def task_document_lookup_ids(task_id: str) -> list[str]: + """ + Terminus task document ids may be stored as `TaskSchema/` while clients + sometimes pass a bare UUID. Return candidates to try with get_document / WOQL. + """ + s = (task_id or "").strip() + if not s: + return [] + out: list[str] = [] + if "/" in s: + out.append(s) + tail = terminus_doc_id_tail(s) + prefixed = f"TaskSchema/{tail}" if tail else "" + if prefixed and prefixed not in out: + out.append(prefixed) + else: + out.append(f"TaskSchema/{s}") + out.append(s) + seen: set[str] = set() + ordered: list[str] = [] + for x in out: + if x not in seen: + seen.add(x) + ordered.append(x) + return ordered + + +def terminus_ids_match(a: str, b: str) -> bool: + """ + True if two Terminus document ids refer to the same document. + + TerminusDB uses `ClassName/` while in-memory code often holds + bare UUIDs; message parts and API payloads may use either form. + """ + if a == b: + return True + if not a or not b: + return False + return terminus_doc_id_tail(a) == terminus_doc_id_tail(b) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def parts_to_json(parts: list[MessagePart]) -> str: + return MESSAGE_PARTS_ADAPTER.dump_json(parts).decode("utf-8") + + +def parts_from_json(parts_json: str) -> list[MessagePart]: + return MESSAGE_PARTS_ADAPTER.validate_json(parts_json.encode("utf-8")) diff --git a/src/backend/app/core/repository/conversation/conversations.py b/src/backend/app/core/repository/conversation/conversations.py new file mode 100644 index 00000000..cc5c6632 --- /dev/null +++ b/src/backend/app/core/repository/conversation/conversations.py @@ -0,0 +1,107 @@ +"""Conversation document CRUD and listing.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +from app.core.model.conversation_domain import Conversation, ConversationSummary +from app.core.model.conversation_nodes import ConversationNode +from app.core.model.schemas.conversation_schema import ConversationSchema + +from ._common import new_doc_id, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + +logger = logging.getLogger(__name__) + + +class ConversationsMixin: + client: "AsyncClient" + + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str | None: + now = utcnow() + conv_id = new_doc_id("ConversationSchema") + node = ConversationNode( + id=conv_id, + name=title, + description=description, + metadata_json=json.dumps(metadata or {}), + message_count=0, + has_active_task=False, + created_at=now, + updated_at=now, + ) + try: + await self.client.insert_document( + ConversationSchema.from_pydantic(node), + commit_msg=f"Creating conversation {title!r}", + ) + except Exception: + logger.exception("TerminusDB insert_document failed for conversation") + return None + return conv_id + + async def get_conversation(self, conversation_id: str) -> Conversation | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + messages = await self.get_messages( + conversation_id, cursor=0, limit=10_000 + ) + meta: dict[str, Any] = {} + try: + meta = json.loads(conv.metadata_json or "{}") + except json.JSONDecodeError: + meta = {} + return Conversation.from_conversation_node( + conv, messages=messages, metadata=meta + ) + + async def _get_conversation_node( + self, conversation_id: str + ) -> ConversationNode | None: + try: + raw = await self.client.get_document(conversation_id) + except Exception as exc: + print(exc) + return None + if not raw or "ConversationSchema" not in str(raw.get("@type", "")): + return None + return ConversationNode.from_raw_dict(raw) + + async def list_conversations( + self, + limit: int = 50, + cursor: str | None = None, + ) -> list[ConversationSummary]: + try: + items_raw = await self.client.get_all_documents(doc_type="ConversationSchema") + except Exception as exc: + print(exc) + return [] + nodes = [ConversationNode.from_raw_dict(r) for r in items_raw] + nodes.sort(key=lambda n: n.updated_at, reverse=True) + if cursor: + idx = next((i for i, n in enumerate(nodes) if n.id == cursor), None) + if idx is not None: + nodes = nodes[idx + 1:] + cap = max(1, limit) + return [ + ConversationSummary.from_conversation_node(n) for n in nodes[:cap] + ] + + async def get_conversation_summary( + self, conversation_id: str + ) -> ConversationSummary | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + return ConversationSummary.from_conversation_node(conv) diff --git a/src/backend/app/core/repository/conversation/messages.py b/src/backend/app/core/repository/conversation/messages.py new file mode 100644 index 00000000..75527dd8 --- /dev/null +++ b/src/backend/app/core/repository/conversation/messages.py @@ -0,0 +1,228 @@ +"""Message documents: append and paginated read.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.core.model.conversation_domain import ( + ConversationMessage, + TaskPart, +) +from app.core.model.conversation_enums import MessageRole +from app.core.model.conversation_nodes import MessageNode +from app.core.model.schemas.conversation_schema import ( + ConversationSchema, + MessageSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import ( + new_doc_id, + parts_from_json, + parts_to_json, + terminus_ids_match, + utcnow, +) + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class MessagesMixin: + client: "AsyncClient" + + async def _hydrate_task_parts( + self, messages: list[ConversationMessage] + ) -> None: + """Merge Task document fields into ``TaskPart`` rows (ref-in-message pattern).""" + for mi, msg in enumerate(messages): + new_parts: list = [] + changed = False + for p in msg.parts: + if isinstance(p, TaskPart): + doc = await self.get_task(p.task_id) + if doc is not None: + desc = ( + (doc.progress_message or "").strip() + or (doc.description or "").strip() + or p.description + ) + new_parts.append( + p.model_copy( + update={ + "title": doc.name or p.title, + "description": desc, + "state": doc.state, + "progress": doc.progress, + "started_at": doc.started_at or p.started_at, + "finished_at": doc.finished_at or p.finished_at, + "sub_task_count": doc.sub_task_count, + "workflow_name": doc.workflow_name or p.workflow_name, + } + ) + ) + changed = True + else: + new_parts.append(p) + else: + new_parts.append(p) + if changed: + messages[mi] = msg.model_copy(update={"parts": new_parts}) + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = utcnow() + seq = conv.message_count + msg_id = message.id or new_doc_id("MessageSchema") + if isinstance(message.role, MessageRole): + role_val = message.role.value + else: + role_val = str(message.role) + msg_node = MessageNode( + id=msg_id, + conversation_id=conversation_id, + role=role_val, + parts_json=parts_to_json(message.parts), + token_count=message.token_count, + model_name=message.model, + sequence=seq, + created_at=message.created_at or now, + updated_at=now, + ) + schmea = MessageSchema.from_pydantic(msg_node) + try: + await self.client.insert_document( + schmea, + commit_msg=f"Message in {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.message_count = seq + 1 + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Bump message_count {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return msg_id + + async def get_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:msg", "conversation", conversation_id), + WQ().triple("v:msg", "rdf:type", "@schema:MessageSchema"), + WQ().triple("v:msg", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:msg_doc").woql_and( + ordered, + WQ().read_document("v:msg", "v:msg_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[ConversationMessage] = [] + for row in result.get("bindings", []): + raw = row.get("msg_doc") + if not raw: + continue + node = MessageNode.from_raw_dict(raw) + parts = parts_from_json(node.parts_json) + out.append( + ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) + ) + await self._hydrate_task_parts(out) + return out + + async def get_message(self, message_id: str) -> ConversationMessage | None: + try: + raw = await self.client.get_document(message_id) + except Exception as exc: + print(exc) + return None + if not raw or "MessageSchema" not in (raw.get("@type") or ""): + return None + node = MessageNode.from_raw_dict(raw) + parts = parts_from_json(node.parts_json) + msg = ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) + buf = [msg] + await self._hydrate_task_parts(buf) + return buf[0] + + async def update_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> bool: + try: + raw = await self.client.get_document(message.id) + except Exception as exc: + print(exc) + return False + if not raw or "MessageSchema" not in str(raw.get("@type", "")): + return False + node = MessageNode.from_raw_dict(raw) + if not terminus_ids_match(node.conversation_id, conversation_id): + return False + now = utcnow() + if isinstance(message.role, MessageRole): + role_val = message.role.value + else: + role_val = str(message.role) + updated = MessageNode( + id=message.id, + conversation_id=conversation_id, + role=role_val, + parts_json=parts_to_json(message.parts), + token_count=message.token_count, + model_name=message.model, + sequence=node.sequence, + created_at=node.created_at, + updated_at=now, + ) + try: + await self.client.update_document( + MessageSchema.from_pydantic(updated), + commit_msg=f"Update message {message.id}", + ) + except Exception as exc: + print(exc) + return False + return True diff --git a/src/backend/app/core/repository/conversation/repo.py b/src/backend/app/core/repository/conversation/repo.py new file mode 100644 index 00000000..8b6f5d37 --- /dev/null +++ b/src/backend/app/core/repository/conversation/repo.py @@ -0,0 +1,22 @@ +"""Composed repository for conversations, messages, tasks, and subtasks.""" + +from __future__ import annotations + +from app.db.async_terminus_client import AsyncClient + +from .conversations import ConversationsMixin +from .messages import MessagesMixin +from .subtasks import SubtasksMixin +from .tasks import TasksMixin + + +class ConversationRepo( + ConversationsMixin, + MessagesMixin, + TasksMixin, + SubtasksMixin, +): + """TerminusDB: conversations, messages, tasks, and subtasks.""" + + def __init__(self, client: AsyncClient): + self.client = client diff --git a/src/backend/app/core/repository/conversation/subtasks.py b/src/backend/app/core/repository/conversation/subtasks.py new file mode 100644 index 00000000..b27dab4f --- /dev/null +++ b/src/backend/app/core/repository/conversation/subtasks.py @@ -0,0 +1,139 @@ +"""SubTask documents under a task.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from app.core.model.conversation_nodes import SubTask, Task, _coerce_subtask_state +from app.core.model.schemas.conversation_schema import ( + SubTaskSchema, + TaskSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import new_doc_id, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class SubtasksMixin: + client: "AsyncClient" + + async def append_subtask( + self, task_id: str, subtask: SubTask + ) -> str | None: + try: + task_raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return None + if not task_raw: + return None + task_node = Task.from_raw_dict(task_raw) + now = utcnow() + seq = task_node.sub_task_count + sub_id = subtask.id or new_doc_id("SubTaskSchema") + sub_node = subtask.model_copy( + update={ + "id": sub_id, + "task_id": task_id, + "sequence": seq, + "created_at": now, + "updated_at": now, + } + ) + + try: + await self.client.insert_document( + SubTaskSchema.from_pydantic(sub_node), + commit_msg=f"Subtask on {task_id}", + ) + except Exception as exc: + print(exc) + return None + + task_node.sub_task_count = seq + 1 + task_node.updated_at = now + try: + await self.client.update_document( + TaskSchema.from_pydantic(task_node), + commit_msg=f"Bump sub_task_count {task_id}", + ) + except Exception as exc: + print(exc) + return None + return sub_id + + async def update_subtask(self, subtask_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(subtask_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = SubTask.from_raw_dict(raw) + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + node.state = _coerce_subtask_state(fields["state"]) + if "sequence" in fields: + node.sequence = int(fields["sequence"]) + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "touched_node_ids" in fields: + node.touched_node_ids_json = json.dumps(fields["touched_node_ids"]) + + node.updated_at = utcnow() + + try: + await self.client.update_document( + SubTaskSchema.from_pydantic(node), + commit_msg=f"Update subtask {subtask_id}", + ) + except Exception as exc: + print(exc) + return False + return True + + async def get_subtasks( + self, + task_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[SubTask]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:st", "task", task_id), + WQ().triple("v:st", "rdf:type", "@schema:SubTaskSchema"), + WQ().triple("v:st", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:st_doc").woql_and( + ordered, + WQ().read_document("v:st", "v:st_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[SubTask] = [] + for row in result.get("bindings", []): + raw = row.get("st_doc") + if not raw: + continue + out.append(SubTask.from_raw_dict(raw)) + return out diff --git a/src/backend/app/core/repository/conversation/tasks.py b/src/backend/app/core/repository/conversation/tasks.py new file mode 100644 index 00000000..480c4415 --- /dev/null +++ b/src/backend/app/core/repository/conversation/tasks.py @@ -0,0 +1,224 @@ +"""Task documents and conversation active-task flag.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from app.core.model.conversation_enums import TaskState +from app.core.model.conversation_nodes import Task, _coerce_task_state +from app.core.model.schemas.conversation_schema import ( + ConversationSchema, + TaskSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import ( + TERMINAL_TASK_STATES, + new_doc_id, + task_document_lookup_ids, + utcnow, +) + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class TasksMixin: + client: "AsyncClient" + + async def create_task( + self, + conversation_id: str, + message_id: str, + task: Task, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = utcnow() + task_id = task.id or new_doc_id("TaskSchema") + node = task.model_copy( + update={ + "id": task_id, + "conversation_id": conversation_id, + "message_id": message_id, + "created_at": task.created_at or now, + "updated_at": now, + } + ) + try: + await self.client.insert_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Task for conversation {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.has_active_task = True + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Mark active task {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return task_id + + async def update_task(self, task_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = Task.from_raw_dict(raw) + conv_id = node.conversation_id + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + node.state = _coerce_task_state(fields["state"]) + if "progress" in fields: + node.progress = float(fields["progress"]) + if "progress_message" in fields: + node.progress_message = fields["progress_message"] + if "workflow_name" in fields: + node.workflow_name = fields["workflow_name"] + if "workflow_params" in fields: + wp = fields["workflow_params"] + node.workflow_params_json = ( + json.dumps(wp) if wp is not None else None + ) + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "result" in fields: + r = fields["result"] + node.result_json = json.dumps(r) if r is not None else None + if "sub_task_count" in fields: + node.sub_task_count = int(fields["sub_task_count"]) + + node.updated_at = utcnow() + + try: + await self.client.update_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Update task {task_id}", + ) + except Exception as exc: + print(exc) + return False + + if node.state in TERMINAL_TASK_STATES and conv_id: + await self._maybe_clear_active_task(conv_id) + return True + + async def _maybe_clear_active_task(self, conversation_id: str) -> None: + conv = await self._get_conversation_node(conversation_id) + if conv is None or not conv.has_active_task: + return + try: + open_tasks = await self._query_tasks_for_conversation( + conversation_id, + states=[ + TaskState.PENDING.value, + TaskState.RUNNING.value, + ], + ) + except Exception as exc: + print(exc) + return + if open_tasks: + return + conv.has_active_task = False + conv.updated_at = utcnow() + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Clear active task flag {conversation_id}", + ) + except Exception as exc: + print(exc) + + async def _query_tasks_for_conversation( + self, + conversation_id: str, + states: list[str], + ) -> list[Task]: + if not states: + return [] + query = ( + WQ() + .select("v:task_doc") + .woql_and( + WQ().triple("v:task", "conversation", conversation_id), + WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), + WQ().triple("v:task", "state", "v:state"), + WQ().member("v:state", [WQ().string(s) for s in states]), + WQ().read_document("v:task", "v:task_doc"), + ) + ) + try: + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + out: list[Task] = [] + for row in result.get("bindings", []): + raw = row.get("task_doc") + if raw: + out.append(Task.from_raw_dict(raw)) + return out + + async def get_task(self, task_id: str) -> Task | None: + for doc_id in task_document_lookup_ids(task_id): + try: + raw = await self.client.get_document(doc_id) + except Exception as exc: + print(exc) + continue + if raw: + return Task.from_raw_dict(raw) + return None + + async def list_tasks_for_conversation( + self, + conversation_id: str, + *, + limit: int = 50, + cursor: int = 0, + ) -> list[Task]: + query = ( + WQ() + .select("v:task_doc") + .woql_and( + WQ().triple("v:task", "conversation", conversation_id), + WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), + WQ().read_document("v:task", "v:task_doc"), + ) + ) + try: + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + out: list[Task] = [] + for row in result.get("bindings", []): + raw = row.get("task_doc") + if raw: + out.append(Task.from_raw_dict(raw)) + out.sort(key=lambda t: t.created_at, reverse=True) + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + slice_start = cursor + return out[slice_start : slice_start + cap] diff --git a/src/backend/app/core/repository/project_repo.py b/src/backend/app/core/repository/project_repo.py index c963e014..2d2461b1 100644 --- a/src/backend/app/core/repository/project_repo.py +++ b/src/backend/app/core/repository/project_repo.py @@ -158,7 +158,7 @@ async def get_children(self, exclude_types: list[str] = [], include_commit_id: b if include_commit_id: return children, version - return children + return children, None except Exception as e: print(e) - return [] + return [], None diff --git a/src/backend/app/core/services/document_service.py b/src/backend/app/core/services/document_service.py index f4109cd1..b9e455e2 100644 --- a/src/backend/app/core/services/document_service.py +++ b/src/backend/app/core/services/document_service.py @@ -41,6 +41,7 @@ async def create(self, name=name, description=description, data="", + markdown="", ) created = await repos.document_repo.create_nodes(document, singular_name="document", plural_name="documents") diff --git a/src/backend/app/core/services/play_ground_service.py b/src/backend/app/core/services/play_ground_service.py index 40a8dcef..9d5bedd7 100644 --- a/src/backend/app/core/services/play_ground_service.py +++ b/src/backend/app/core/services/play_ground_service.py @@ -9,6 +9,13 @@ class PlayGroundService: + OWNER_FIELD_BY_PREFIX = { + "FunctionSchema": "owner_function", + "ClassSchema": "owner_class", + "FileSchema": "owner_file", + "FolderSchema": "owner_folder", + } + def __init__(self, uow: ProjectUoW): self.uow = uow self.repos = self.uow.get_project_repos() @@ -35,8 +42,6 @@ async def create_playground( relative_path: str, code: str, executable_path: Optional[str] = None, - examples_path: Optional[str] = None, - command_prefix: Optional[str] = None, filename: Optional[str] = None, owner_function: Optional[str] = None, owner_class: Optional[str] = None, @@ -62,8 +67,6 @@ async def create_playground( relative_path=relative_path, code=code, executable_path=executable_path, - examples_path=examples_path, - command_prefix=command_prefix, filename=filename, owner_function=owner_function, owner_class=owner_class, @@ -88,8 +91,6 @@ async def update_playground( relative_path: Optional[str] = None, code: Optional[str] = None, executable_path: Optional[str] = None, - examples_path: Optional[str] = None, - command_prefix: Optional[str] = None, filename: Optional[str] = None, ) -> Optional[dict]: existing = await self.repos.play_ground_repo.get_by_id(playground_id) @@ -110,12 +111,6 @@ async def update_playground( executable_path=existing.get("executable_path") if executable_path is None else executable_path, - examples_path=existing.get("examples_path") - if examples_path is None - else examples_path, - command_prefix=existing.get("command_prefix") - if command_prefix is None - else command_prefix, filename=existing.get("filename") if filename is None else filename, owner_function=existing.get("owner_function"), owner_class=existing.get("owner_class"), @@ -132,17 +127,22 @@ async def update_playground( async def delete_playground(self, playground_id: str) -> bool: return await self.repos.play_ground_repo.delete(playground_id) - async def get_by_owner_function_id(self, owner_function_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_function_id(owner_function_id) - - async def get_by_owner_class_id(self, owner_class_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_class_id(owner_class_id) - - async def get_by_owner_file_id(self, owner_file_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_file_id(owner_file_id) + @classmethod + def _owner_field_from_node_id(cls, node_id: str) -> str: + prefix = node_id.split("/", 1)[0] + owner_field = cls.OWNER_FIELD_BY_PREFIX.get(prefix) + if owner_field is None: + supported = ", ".join(sorted(cls.OWNER_FIELD_BY_PREFIX.keys())) + raise ValueError( + f"Unsupported node id prefix '{prefix}'. Supported prefixes: {supported}" + ) + return owner_field - async def get_by_owner_folder_id(self, owner_folder_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_folder_id(owner_folder_id) + async def get_by_owner_node_id(self, node_id: str) -> list[dict]: + owner_field = self._owner_field_from_node_id(node_id) + return await self.repos.play_ground_repo.get_by_owner_field( + owner_field, node_id + ) async def run_code(self, playground_id: str) -> CodeResponse: playground = await self.repos.play_ground_repo.get_by_id(playground_id) @@ -161,7 +161,5 @@ async def run_code(self, playground_id: str) -> CodeResponse: project_root_path=project_path, python_executable=playground.get("executable_path"), code=playground.get("code", ""), - examples_path=playground.get("examples_path"), - command_prefix=playground.get("command_prefix"), filename=playground.get("filename"), ) diff --git a/src/backend/app/core/socket/manager.py b/src/backend/app/core/socket/manager.py index 79491343..2020fece 100644 --- a/src/backend/app/core/socket/manager.py +++ b/src/backend/app/core/socket/manager.py @@ -103,8 +103,13 @@ def __init__(self): # Prevent re-initialization if not hasattr(self, "initialized"): self.initialized = True + self._stream_registry = None self._setup_handlers() + def bind_stream_registry(self, registry) -> None: + """Process-wide stream buffers for `stream:resume` replay.""" + self._stream_registry = registry + def _setup_handlers(self): @self.server.event async def connect(sid, environ): @@ -151,6 +156,69 @@ async def leave_project(sid, project_id: str): ) await self.server.leave_room(sid, normalized_id) + @self.server.event + async def join_conversation(sid, conversation_id: str): + room = f"conv:{conversation_id}" + await self.server.enter_room(sid, room) + logger.info( + "Client %s... joined conversation room %s", + sid[:8], + conversation_id[:16], + ) + + @self.server.event + async def leave_conversation(sid, conversation_id: str): + room = f"conv:{conversation_id}" + await self.server.leave_room(sid, room) + + @self.server.on("stream:resume") + async def stream_resume(sid, data): + await self._handle_stream_resume(sid, data) + + async def _handle_stream_resume(self, sid, data: Any) -> None: + if not isinstance(data, dict): + await self.server.emit( + "stream:error", + {"stream_id": None, "error": "invalid_payload"}, + to=sid, + ) + return + stream_id = data.get("stream_id") + last_seq = data.get("last_seq", -1) + reg = self._stream_registry + if reg is None: + await self.server.emit( + "stream:error", + {"stream_id": stream_id, "error": "server_misconfigured"}, + to=sid, + ) + return + buf = reg.get(stream_id) if stream_id else None + if buf is None: + await self.server.emit( + "stream:error", + {"stream_id": stream_id, "error": "stream_expired"}, + to=sid, + ) + return + start = int(last_seq) + 1 + for seq, delta in buf.get_chunks_since(start): + await self.server.emit( + "stream:chunk", + {"stream_id": stream_id, "seq": seq, "delta": delta}, + to=sid, + ) + if buf.is_finished and buf.message_id: + await self.server.emit( + "stream:end", + { + "stream_id": stream_id, + "message_id": buf.message_id, + "total_seq": buf.next_seq, + }, + to=sid, + ) + def _normalize_project_id(self, project_id: str) -> str: """Normalize project_id to key format (remove nodes/ prefix).""" # Extract key part if project_id has nodes/ prefix diff --git a/src/backend/app/db/client.py b/src/backend/app/db/client.py index f26f92e3..79e4443c 100755 --- a/src/backend/app/db/client.py +++ b/src/backend/app/db/client.py @@ -7,7 +7,16 @@ from .async_terminus_client import AsyncClient from ..config.settings import get_settings -from app.core.model.schemas import ProjectSchema, BaseSchema, TerminusBase, ThemeConfigSchema +from app.core.model.schemas import ( + BaseSchema, + ConversationSchema, + MessageSchema, + ProjectSchema, + SubTaskSchema, + TaskSchema, + TerminusBase, + ThemeConfigSchema, +) from app.db.woqlschema import * _client: AsyncClient | None = None @@ -23,6 +32,11 @@ async def migrate_base(client): schema_obj.add_obj(BaseSchema.__name__, BaseSchema) schema_obj.add_obj(ThemeConfigSchema.__name__, ThemeConfigSchema) schema_obj.add_obj(ProjectSchema.__name__, ProjectSchema) + # Hub DB includes conversation types for schema compatibility; live data uses project DBs. + schema_obj.add_obj(ConversationSchema.__name__, ConversationSchema) + schema_obj.add_obj(MessageSchema.__name__, MessageSchema) + schema_obj.add_obj(TaskSchema.__name__, TaskSchema) + schema_obj.add_obj(SubTaskSchema.__name__, SubTaskSchema) await schema_obj.commit(client, "Add ProjectSchema to schema", full_replace=True) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 5bf3f0c7..adbbd9db 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -4,6 +4,12 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.socket.manager import get_socket_manager +from app.agent.config import settings as agent_settings +from app.agent.runner.task_manager import TaskManager +from app.agent.llm.factory import LLMFactory +from app.agent.llm.providers.openai_provider import OpenAIProvider +from app.agent.runner.stream_buffer import StreamRegistry +from app.agent.streaming.manager import StreamManager from .api import root from .db.client import get_terminus_client, close_db_client @@ -29,10 +35,27 @@ async def lifespan(app: FastAPI): watcher_service.set_event_loop( asyncio.get_running_loop() ) + + # 2. Initialize LLM Factory + llm_factory = LLMFactory(agent_settings) + if agent_settings.openai_api_key: + llm_factory.register_provider( + "openai", + OpenAIProvider, + api_key=agent_settings.openai_api_key, + ) + task_manager = TaskManager() + stream_registry = StreamRegistry() + stream_manager = StreamManager(stream_registry) + + app.state.llm_factory = llm_factory + app.state.task_manager = task_manager + app.state.stream_manager = stream_manager app.state.watcher_service = watcher_service # Init Socket Manager (creates the server instance) - _ = get_socket_manager() + socket_manager = get_socket_manager() + socket_manager.bind_stream_registry(stream_registry) print("🔌 Socket.IO server initialized and ready") yield diff --git a/src/backend/docker-compose.yml b/src/backend/docker-compose.yml index ff76b6f5..04799fa5 100755 --- a/src/backend/docker-compose.yml +++ b/src/backend/docker-compose.yml @@ -4,7 +4,6 @@ services: terminusdb: image: terminusdb/terminusdb-server:latest container_name: terminusdb-server - pull_policy: always ports: - "6363:6363" environment: @@ -15,21 +14,21 @@ services: volumes: - terminusdb_storage:/app/terminusdb/storage healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6363/ok"] + test: ["CMD", "curl", "-f", "http://localhost:6363/api/ok"] interval: 10s timeout: 5s retries: 5 semantic_indexer: - image: terminusdb/vectorlink:latest + image: terminusdb/vectorlink container_name: terminusdb-semantic-indexer ports: - "8080:8080" - depends_on: - terminusdb: - condition: service_healthy + # depends_on: + # terminusdb: + # condition: service_healthy environment: - - TERMINUSDB_CONTENT_ENDPOINT=http://terminusdb:6363 + - TERMINUSDB_CONTENT_ENDPOINT=http://terminusdb:6363/api/index - TERMINUSDB_USER_FORWARD_HEADER=X-User-Forward - OPENAI_KEY=${OPENAI_KEY} volumes: diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 62c721c3..41b36cfd 100755 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "trio>=0.32.0", "python-slugify>=8.0.4", "coverage>=7.13.4", + "langgraph>=1.1.2", + "langchain>=1.2.12", + "langchain-openai>=1.1.11", ] [project.optional-dependencies] diff --git a/src/backend/tests/e2e/conftest.py b/src/backend/tests/e2e/conftest.py index 7f5fd01d..a8ea6164 100644 --- a/src/backend/tests/e2e/conftest.py +++ b/src/backend/tests/e2e/conftest.py @@ -8,6 +8,7 @@ from app.db.client import get_terminus_client from app.core.services.project_service import ProjectService from app.db.async_terminus_client import AsyncClient as TerminusClient +from app.db.context import ProjectUoW, RequestDbContext from app.core.parser.graph_builder.orchestrator import GraphBuilderOrchestrator @@ -42,24 +43,26 @@ def sample_project_path(tmp_path): @pytest_asyncio.fixture -async def built_sample_project(sample_project_path, create_repos, terminusdb_client): +async def built_sample_project(sample_project_path, terminusdb_client): """Creates a project and runs GraphBuilder to populate structure (no API).""" - project_service = ProjectService(create_repos) - print(f"Creating sample project at: {create_repos.client.db}") + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) + print(f"Creating sample project at: {terminusdb_client.db}") project_node = await project_service.create( "sample_project", "A sample project for E2E tests", sample_project_path, ) - clone_db = terminusdb_client.clone() + project_uow = ProjectUoW(terminusdb_client, project_node, ctx) orchestrator = GraphBuilderOrchestrator( project_node=project_node, - db=clone_db, + uow=project_uow, ignore_file_name=".gitignore", ) await orchestrator.resync() - yield project_node, create_repos + yield project_node, project_uow await project_service.delete(project_node.id) @@ -77,8 +80,10 @@ async def sample_project_node(empty_project_uow): @pytest_asyncio.fixture -async def created_sample_project(create_repos): - project_service = ProjectService(create_repos) +async def created_sample_project(terminusdb_client): + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) return await project_service.create( "sample_project", "A sample project for E2E tests", diff --git a/src/backend/tests/unit/service/agent/conftest.py b/src/backend/tests/unit/service/agent/conftest.py new file mode 100644 index 00000000..13905cf6 --- /dev/null +++ b/src/backend/tests/unit/service/agent/conftest.py @@ -0,0 +1,41 @@ +import pytest +import shutil +from pathlib import Path +from app.db.context import RequestDbContext, ProjectUoW +from app.core.services.project_service import ProjectService +from app.core.parser.graph_builder.orchestrator import GraphBuilderOrchestrator +import pytest_asyncio + + +@pytest.fixture +def sample_project_path(tmp_path): + """Returns the path to a temporary copy of the sample project directory for E2E tests.""" + source_path = Path(__file__).parent / "sample_project" + project_path = tmp_path / "sample_project" + shutil.copytree(source_path, project_path) + return str(project_path) + + +@pytest_asyncio.fixture +async def built_sample_project(sample_project_path, terminusdb_client): + """Creates a project and runs GraphBuilder to populate structure (no API).""" + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) + print(f"Creating sample project at: {terminusdb_client.db}") + project_node = await project_service.create( + "sample_project", + "A sample project for E2E tests", + sample_project_path, + ) + + project_uow = ProjectUoW(terminusdb_client, project_node, ctx) + orchestrator = GraphBuilderOrchestrator( + project_node=project_node, + uow=project_uow, + ignore_file_name=".gitignore", + ) + await orchestrator.resync() + yield project_node, project_uow + + await project_service.delete(project_node.id) diff --git a/src/backend/tests/unit/service/agent/sample_project/core/model/child.py b/src/backend/tests/unit/service/agent/sample_project/core/model/child.py new file mode 100644 index 00000000..12165bf7 --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/model/child.py @@ -0,0 +1,8 @@ +from .parent import Parent + +class Child(Parent): + """ ID: e278aa7a-d358-4a4c-aa8e-d67668aefa96 """ + + def __init__(self, name: str): + """ ID: 709db811-a10e-441b-8159-dc8427f60cdd """ + super().__init__(name) \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py b/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py new file mode 100644 index 00000000..5b683a8c --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py @@ -0,0 +1,10 @@ +class Parent: + """ ID: eafcb450-3ccf-4fd7-98e7-10c0abb7f429 """ + + def __init__(self, name: str): + """ ID: fbea723d-0b37-4320-82b7-f5086aafb9c8 """ + self.name = name + + def get_name(self): + """ ID: 86f5a508-7ccc-4009-b62a-6a3989800e93 """ + return self.name \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py b/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py new file mode 100644 index 00000000..7955e73e --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py @@ -0,0 +1,5 @@ +from ..model.child import Child + +def create_child(): + """ ID: e9887730-19a8-45da-b40c-c63368d69571 """ + return Child() \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/main.py b/src/backend/tests/unit/service/agent/sample_project/main.py new file mode 100644 index 00000000..3c860f50 --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/main.py @@ -0,0 +1,11 @@ +from .core.utils.helper import create_child + + +def main(): + """ ID: 64df7ee4-1a99-47ac-8134-0b1d6dffaef6 """ + child = create_child() + print(child.get_name()) + + +if __name__ == '__main__': + main() diff --git a/src/backend/tests/unit/service/agent/test_generation_workflows.py b/src/backend/tests/unit/service/agent/test_generation_workflows.py new file mode 100644 index 00000000..be2977a3 --- /dev/null +++ b/src/backend/tests/unit/service/agent/test_generation_workflows.py @@ -0,0 +1,302 @@ +"""Integration tests for WorkflowService using the sample project graph and a fake LLM.""" + +from __future__ import annotations + +import pytest + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.conversation_store import TerminusConversationStore +from app.agent.llm.gateway import LLMGateway +from app.agent.runner.task_manager import TaskManager +from app.agent.service.title_generator import TitleOutput +from app.agent.service.workflow_service import WorkflowService +from app.agent.workflows.description_gen import DescriptionGeneratorWorkflow +from app.agent.workflows.documentation_gen import DocumentationGeneratorWorkflow +from app.core.model.conversation_domain import TaskPart +from app.core.model.conversation_enums import TaskState +from app.core.repository.conversation import ConversationRepo +from app.db.context import ProjectUoW, RequestDbContext + + +# --- Fake LLM: workflow prompts (invoke) + title generator (structured output) --- + + +class _FakeResponse: + def __init__(self, content: str): + self.content = content + + +class _FakeStructuredRunnable: + """Feeds ``generate_conversation_title`` / ``generate_batch_conversation_title``.""" + + async def ainvoke(self, messages): + return TitleOutput( + title="Structured Test Chat Title", + description=( + "One sentence describing the structured test conversation." + ), + ) + + +class _FakeInnerLLM: + def with_structured_output(self, schema): + return _FakeStructuredRunnable() + + +class _FakeLLMProvider: + def __init__(self) -> None: + self._llm = _FakeInnerLLM() + + async def invoke(self, messages, **kwargs): + prompt = messages[-1].content if messages else "" + node_name = "unknown" + for line in prompt.splitlines(): + if line.startswith("Node name: "): + node_name = line.replace("Node name: ", "").strip() + break + + if "Task: documentation" in prompt: + return _FakeResponse(f"DOC::{node_name}") + return _FakeResponse(f"DESC::{node_name}") + + +class FakeAgentLLMFactory: + """Matches ``LLMGateway.create_mini()`` and workflow ``llm_factory.create(...)``.""" + + def create(self, **kwargs): + return _FakeLLMProvider() + + +def _doc_id_for_node(node_id: str) -> str: + safe = node_id.replace("/", "_").replace(":", "_") + return f"DocumentSchema/{safe}_workflow_documentation" + + +async def _get_main_file_node(uow: ProjectUoW): + repos = uow.get_project_repos() + file_nodes = await repos.structure_repo.get_by_qnames( + ["sample_project.main"], + "FileSchema", + ) + return file_nodes["sample_project.main"] + + +def _workflow_params_for_main(main_node, **extra): + return { + "node_id": main_node.id, + "direction": "up", + "max_depth": 1, + **extra, + } + + +@pytest.mark.asyncio +async def test_workflow_service_run_description_workflow( + built_sample_project, + terminusdb_client, +): + _, uow = built_sample_project + graph = GraphTraversal(uow) + llm_factory = FakeAgentLLMFactory() + gateway = LLMGateway(llm_factory) + workflow = DescriptionGeneratorWorkflow( + graph=graph, + llm_factory=llm_factory, + ) + repo = ConversationRepo(terminusdb_client) + store = TerminusConversationStore(repo) + svc = WorkflowService( + TaskManager(), + gateway, + db_client=terminusdb_client, + ) + + main_node = await _get_main_file_node(uow) + conv_id, task_id = await svc.run( + workflow, + store=store, + **_workflow_params_for_main(main_node), + ) + await svc.join_task(task_id) + + meta = await store.get_conversation_metadata(conv_id) + assert meta is not None + assert meta.title == "Structured Test Chat Title" + assert "structured test conversation" in meta.description.lower() + + conv = await store.get_conversation(conv_id) + assert conv is not None + task_parts = [ + p + for m in conv.messages + for p in m.parts + if isinstance(p, TaskPart) + ] + assert len(task_parts) == 1 + assert task_parts[0].task_id == task_id + assert task_id.startswith("TaskSchema/") + + task_doc = await repo.get_task(task_id) + assert task_doc is not None + assert task_doc.state == TaskState.COMPLETED + assert task_doc.message_id + assert task_doc.name.startswith("workflow:") + + sub_task = await repo.get_subtasks(task_id) + + assert len(sub_task) == 2 + + repos = uow.get_project_repos() + raw_main = await repos.client.get_document(main_node.id) + + assert raw_main["description"].startswith("DESC::") + + +@pytest.mark.asyncio +async def test_workflow_service_run_documentation_workflow( + built_sample_project, + terminusdb_client, +): + _, uow = built_sample_project + graph = GraphTraversal(uow) + llm_factory = FakeAgentLLMFactory() + gateway = LLMGateway(llm_factory) + workflow = DocumentationGeneratorWorkflow( + graph=graph, + llm_factory=llm_factory, + ) + repo = ConversationRepo(terminusdb_client) + store = TerminusConversationStore(repo) + svc = WorkflowService( + TaskManager(), + gateway, + db_client=terminusdb_client, + ) + + main_node = await _get_main_file_node(uow) + conv_id, task_id = await svc.run( + workflow, + store=store, + **_workflow_params_for_main(main_node), + ) + await svc.join_task(task_id) + + task_doc = await repo.get_task(task_id) + assert task_doc is not None + assert task_doc.state == TaskState.COMPLETED + + repos = uow.get_project_repos() + raw_main = await repos.client.get_document(main_node.id) + expected_doc_id = _doc_id_for_node(main_node.id) + assert expected_doc_id in set(raw_main.get("documents", [])) + generated = await repos.client.get_document(expected_doc_id) + assert generated["data"].startswith("DOC::") + + +@pytest.mark.asyncio +async def test_workflow_service_task_label_params_map_to_task_row( + built_sample_project, + terminusdb_client, +): + _, uow = built_sample_project + graph = GraphTraversal(uow) + llm_factory = FakeAgentLLMFactory() + gateway = LLMGateway(llm_factory) + workflow = DescriptionGeneratorWorkflow( + graph=graph, + llm_factory=llm_factory, + ) + repo = ConversationRepo(terminusdb_client) + store = TerminusConversationStore(repo) + svc = WorkflowService( + TaskManager(), + gateway, + db_client=terminusdb_client, + ) + + main_node = await _get_main_file_node(uow) + _, task_id = await svc.run( + workflow, + store=store, + conversation_title="Custom run label", + conversation_description="Notes for this workflow run", + **_workflow_params_for_main(main_node), + ) + await svc.join_task(task_id) + + task_doc = await repo.get_task(task_id) + assert task_doc is not None + assert task_doc.name == "Custom run label" + assert task_doc.description == "Notes for this workflow run" + + +@pytest.mark.asyncio +async def test_workflow_service_batch_description_then_documentation( + built_sample_project, + terminusdb_client, +): + _, uow = built_sample_project + graph = GraphTraversal(uow) + llm_factory = FakeAgentLLMFactory() + gateway = LLMGateway(llm_factory) + main_node = await _get_main_file_node(uow) + base_params = _workflow_params_for_main(main_node) + + def workflow_factory(step: dict): + name = step["workflow_name"] + if name == "description_generator": + return DescriptionGeneratorWorkflow( + graph=graph, + llm_factory=llm_factory, + ) + if name == "documentation_generator": + return DocumentationGeneratorWorkflow( + graph=graph, + llm_factory=llm_factory, + ) + raise AssertionError(f"unexpected workflow {name!r}") + + repo = ConversationRepo(terminusdb_client) + store = TerminusConversationStore(repo) + svc = WorkflowService( + TaskManager(), + gateway, + db_client=terminusdb_client, + ) + + steps = [ + { + "workflow_name": "description_generator", + "params": dict(base_params), + }, + { + "workflow_name": "documentation_generator", + "params": dict(base_params), + }, + ] + + conv_id, task_id = await svc.run_batch( + steps, + workflow_factory=workflow_factory, + store=store, + conversation_title="Batch parent label", + conversation_description="Two-step batch test", + ) + await svc.join_task(task_id) + + meta = await store.get_conversation_metadata(conv_id) + assert meta is not None + assert meta.title == "Structured Test Chat Title" + + task_doc = await repo.get_task(task_id) + assert task_doc is not None + assert task_doc.state == TaskState.COMPLETED + assert task_doc.name == "Batch parent label" + assert task_doc.description == "Two-step batch test" + assert task_doc.workflow_name == "batch" + + repos = uow.get_project_repos() + raw_main = await repos.client.get_document(main_node.id) + assert raw_main["description"].startswith("DESC::") + expected_doc_id = _doc_id_for_node(main_node.id) + assert expected_doc_id in set(raw_main.get("documents", [])) diff --git a/src/backend/tree.json b/src/backend/tree.json deleted file mode 100644 index 4995490b..00000000 --- a/src/backend/tree.json +++ /dev/null @@ -1 +0,0 @@ -[{"id": "FolderSchema/3d22733e-1c90-456b-ba1e-23b25e3773f1", "name": "examples", "description": "Folder examples", "created_at": "2026-03-02T13:26:09.779352Z", "updated_at": "2026-03-02T13:26:09.779353Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/examples", "qname": "sample_project2.examples", "children": [{"id": "FileSchema/54bc4038-6004-40fc-988c-3c863914e98d", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.907007Z", "updated_at": "2026-03-02T13:26:09.907007Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/examples/__init__.py", "qname": "sample_project2.examples.__init__", "documents": [], "theme_config": null, "hash": "80d9c527e809cc99728abc469c1cf6d2d34bdf579d7034002d3d30a141b31312", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/54bc4038-6004-40fc-988c-3c863914e98d"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/76e5c73d-e1bf-47b8-9d05-814dc171f10b", "name": "core", "description": "Folder core", "created_at": "2026-03-02T13:26:09.779106Z", "updated_at": "2026-03-02T13:26:09.779107Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core", "qname": "sample_project2.core", "children": [{"id": "FolderSchema/e14f9027-d43a-4701-aca9-442f61d5dc0d", "name": "utils", "description": "Folder utils", "created_at": "2026-03-02T13:26:09.779272Z", "updated_at": "2026-03-02T13:26:09.779272Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils", "qname": "sample_project2.core.utils", "children": [{"id": "FileSchema/8d48173b-d3c6-4264-9a45-a8bcabc8a17d", "name": "helper", "description": "File helper", "created_at": "2026-03-02T13:26:09.906969Z", "updated_at": "2026-03-02T13:26:09.906969Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/helper.py", "qname": "sample_project2.core.utils.helper", "documents": [], "theme_config": null, "hash": "6fd86108098a2fc3d29460d8667fa7f9f24e0dfaebb881a700a1dbaaf9484091", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "FunctionSchema/6e445d46-a576-41e8-a8ee-223eadd6808b"], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/2dee774d-27b7-404f-958f-18963182abb3", "name": "Child", "description": "call::model.child.Child", "created_at": "2026-03-02T13:26:12.529052Z", "updated_at": "2026-03-02T13:26:12.529101Z", "qname": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/c021ef6f-92ac-44a1-b4a3-bb76fce8a74d"], "call_group": []}, "children": [{"id": "CallSchema/c021ef6f-92ac-44a1-b4a3-bb76fce8a74d", "name": "__init__", "description": "call::model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.529120Z", "updated_at": "2026-03-02T13:26:12.529120Z", "qname": "CallSchema/2dee774d-27b7-404f-958f-18963182abb3::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/6e445d46-a576-41e8-a8ee-223eadd6808b", "name": "gg", "description": "Function gg", "created_at": "2026-03-02T13:26:10.428986Z", "updated_at": "2026-03-02T13:26:10.428986Z", "qname": "sample_project2.core.utils.helper.gg", "code_position": {"line_no": 18, "col_offset": 0, "end_line_no": 21, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "FolderSchema/058cc172-12ef-41c8-80b8-247ef2337813", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779317Z", "updated_at": "2026-03-02T13:26:09.779317Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__pycache__", "qname": "sample_project2.core.utils.__pycache__", "children": [{"id": "FileSchema/10a1e587-1157-4b8f-b191-fbd1b50f14d6", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906929Z", "updated_at": "2026-03-02T13:26:09.906929Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__pycache__/__init__.py", "qname": "sample_project2.core.utils.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "ab7503b84b86a9f713beb5313850ca98b29b3be9cf8c8d0c361928de69ab76f2", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/10a1e587-1157-4b8f-b191-fbd1b50f14d6"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/435e26f0-b71c-44f3-aa2b-79696098d670", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906886Z", "updated_at": "2026-03-02T13:26:09.906887Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__init__.py", "qname": "sample_project2.core.utils.__init__", "documents": [], "theme_config": null, "hash": "1fddc5d2e9a38472a7742045cd7ee27f260c12692f29a48320f1ba0b39241f71", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/058cc172-12ef-41c8-80b8-247ef2337813"], "file_children": ["FileSchema/8d48173b-d3c6-4264-9a45-a8bcabc8a17d", "FileSchema/435e26f0-b71c-44f3-aa2b-79696098d670"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/cbf0fded-9500-460a-9054-15124baebf48", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906626Z", "updated_at": "2026-03-02T13:26:09.906626Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__init__.py", "qname": "sample_project2.core.__init__", "documents": [], "theme_config": null, "hash": "6c509d059d23cdfdd2f3461bdba74025dd3e077b97fd61083fcc402ffa5be893", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FolderSchema/1ff2573f-25df-4952-8067-a5bd3544cb06", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779151Z", "updated_at": "2026-03-02T13:26:09.779151Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__pycache__", "qname": "sample_project2.core.__pycache__", "children": [{"id": "FileSchema/1760100c-b635-49c7-8617-9c2edceff84a", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906672Z", "updated_at": "2026-03-02T13:26:09.906672Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__pycache__/__init__.py", "qname": "sample_project2.core.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "94f2066efc299954374d47b58983d7b5c20b34bb05730908df65444bd2c87e77", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/1760100c-b635-49c7-8617-9c2edceff84a"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/918c5c9e-e649-4866-aa7d-507947231cf6", "name": "model", "description": "Folder model", "created_at": "2026-03-02T13:26:09.779191Z", "updated_at": "2026-03-02T13:26:09.779192Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model", "qname": "sample_project2.core.model", "children": [{"id": "FileSchema/a65973ab-fdae-448b-a241-eed927d0a84e", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906716Z", "updated_at": "2026-03-02T13:26:09.906716Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__init__.py", "qname": "sample_project2.core.model.__init__", "documents": [], "theme_config": null, "hash": "7676e96e7ac4f4cd04247c8a7d4b5d64deccd1ca9037d53dd1e4a1dc1305a3a6", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FolderSchema/01530536-7659-4cc0-92e7-c5a4dc7d9cf3", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779234Z", "updated_at": "2026-03-02T13:26:09.779234Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__pycache__", "qname": "sample_project2.core.model.__pycache__", "children": [{"id": "FileSchema/21159768-34c4-48a3-ad1b-a38b1805a0df", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906763Z", "updated_at": "2026-03-02T13:26:09.906763Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__pycache__/__init__.py", "qname": "sample_project2.core.model.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "a1778df505e49d9619897713ed11c980f370c132ddb4668df26cc68829d22672", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/21159768-34c4-48a3-ad1b-a38b1805a0df"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/4ea5dc9c-650e-4936-ace4-97d8dd11c1c6", "name": "parent", "description": "File parent", "created_at": "2026-03-02T13:26:09.906846Z", "updated_at": "2026-03-02T13:26:09.906847Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/parent.py", "qname": "sample_project2.core.model.parent", "documents": [], "theme_config": null, "hash": "c3a597a53d21e8aac2f963afa247e06925abecf1cb0f43644a496725347f5b87", "children_by_type": {"class_children": ["ClassSchema/e82dc2ba-b511-4096-91c2-c7f97f312c45", "ClassSchema/e61d4fc3-681d-4b64-9576-deb169ce1ba1", "ClassSchema/0aef4dcd-59eb-4b0a-82c1-dc3b71e55d22"], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "ClassSchema/e82dc2ba-b511-4096-91c2-c7f97f312c45", "name": "Uncle", "description": "Class Uncle", "created_at": "2026-03-02T13:26:10.458623Z", "updated_at": "2026-03-02T13:26:10.458623Z", "qname": "sample_project2.core.model.parent.Uncle", "code_position": {"line_no": 24, "col_offset": 0, "end_line_no": 41, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/2c33578a-b0ea-4c32-a707-ef8ee4be0fed", "FunctionSchema/6f0276d2-c919-4e74-9f0f-16b1c008ae28", "FunctionSchema/fab8038a-5741-42ce-8410-217dc4a1afc6"], "code_element_group": []}, "children": [{"id": "FunctionSchema/2c33578a-b0ea-4c32-a707-ef8ee4be0fed", "name": "run", "description": "Function run", "created_at": "2026-03-02T13:26:10.458639Z", "updated_at": "2026-03-02T13:26:10.458639Z", "qname": "sample_project2.core.model.parent.Uncle.run", "code_position": {"line_no": 38, "col_offset": 4, "end_line_no": 41, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/fab8038a-5741-42ce-8410-217dc4a1afc6", "name": "walk", "description": "Function walk", "created_at": "2026-03-02T13:26:10.458634Z", "updated_at": "2026-03-02T13:26:10.458634Z", "qname": "sample_project2.core.model.parent.Uncle.walk", "code_position": {"line_no": 33, "col_offset": 4, "end_line_no": 36, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/6f0276d2-c919-4e74-9f0f-16b1c008ae28", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458629Z", "updated_at": "2026-03-02T13:26:10.458629Z", "qname": "sample_project2.core.model.parent.Uncle.get_name", "code_position": {"line_no": 28, "col_offset": 4, "end_line_no": 31, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.parent.Uncle"], "theme_config": null, "node_type": "class"}, {"id": "ClassSchema/e61d4fc3-681d-4b64-9576-deb169ce1ba1", "name": "Parent", "description": "Class Parent", "created_at": "2026-03-02T13:26:10.458644Z", "updated_at": "2026-03-02T13:26:10.458644Z", "qname": "sample_project2.core.model.parent.Parent", "code_position": {"line_no": 43, "col_offset": 0, "end_line_no": 55, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/a2755ca0-36f8-4766-a0f2-65578edeb4ba", "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46"], "code_element_group": []}, "children": [{"id": "FunctionSchema/a2755ca0-36f8-4766-a0f2-65578edeb4ba", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458653Z", "updated_at": "2026-03-02T13:26:10.458653Z", "qname": "sample_project2.core.model.parent.Parent.get_name", "code_position": {"line_no": 52, "col_offset": 4, "end_line_no": 55, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.458648Z", "updated_at": "2026-03-02T13:26:10.458649Z", "qname": "sample_project2.core.model.parent.Parent.__init__", "code_position": {"line_no": 47, "col_offset": 4, "end_line_no": 50, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.parent.Parent"], "theme_config": null, "node_type": "class"}, {"id": "ClassSchema/0aef4dcd-59eb-4b0a-82c1-dc3b71e55d22", "name": "GrandParent", "description": "Class GrandParent", "created_at": "2026-03-02T13:26:10.458598Z", "updated_at": "2026-03-02T13:26:10.458599Z", "qname": "sample_project2.core.model.parent.GrandParent", "code_position": {"line_no": 5, "col_offset": 0, "end_line_no": 22, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f524bf69-3e1f-4d6d-906e-f8cdf912292b", "FunctionSchema/e3a37c00-22fd-490c-b94d-dd39e6d94859", "FunctionSchema/dfbbacc3-b91d-4e51-8e6c-72c6f9d93f66"], "code_element_group": []}, "children": [{"id": "FunctionSchema/f524bf69-3e1f-4d6d-906e-f8cdf912292b", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458605Z", "updated_at": "2026-03-02T13:26:10.458606Z", "qname": "sample_project2.core.model.parent.GrandParent.get_name", "code_position": {"line_no": 9, "col_offset": 4, "end_line_no": 12, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/e3a37c00-22fd-490c-b94d-dd39e6d94859", "name": "walk", "description": "Function walk", "created_at": "2026-03-02T13:26:10.458611Z", "updated_at": "2026-03-02T13:26:10.458612Z", "qname": "sample_project2.core.model.parent.GrandParent.walk", "code_position": {"line_no": 14, "col_offset": 4, "end_line_no": 17, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/dfbbacc3-b91d-4e51-8e6c-72c6f9d93f66", "name": "sleep", "description": "Function sleep", "created_at": "2026-03-02T13:26:10.458617Z", "updated_at": "2026-03-02T13:26:10.458617Z", "qname": "sample_project2.core.model.parent.GrandParent.sleep", "code_position": {"line_no": 19, "col_offset": 4, "end_line_no": 22, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent"], "theme_config": null, "node_type": "class"}], "node_type": "file"}, {"id": "FileSchema/e1f8d40d-0e99-45e4-ad7d-5d9140215da0", "name": "child", "description": "File child", "created_at": "2026-03-02T13:26:09.906805Z", "updated_at": "2026-03-02T13:26:09.906806Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/child.py", "qname": "sample_project2.core.model.child", "documents": [], "theme_config": null, "hash": "64ec72e2ee88770c87ff55c2ac475e3bc5b15a56a31c99051be701567d0e5ad2", "children_by_type": {"class_children": ["ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0"], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [{"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/5e424edb-664d-486e-93ff-676d16d52682", "name": "__init__", "description": "call::parent.Parent.__init__", "created_at": "2026-03-02T13:26:12.530042Z", "updated_at": "2026-03-02T13:26:12.530042Z", "qname": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326::FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "target_function": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.458648Z", "updated_at": "2026-03-02T13:26:10.458649Z", "qname": "sample_project2.core.model.parent.Parent.__init__", "code_position": {"line_no": 47, "col_offset": 4, "end_line_no": 50, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "name": "set_name", "description": "Function set_name", "created_at": "2026-03-02T13:26:10.443308Z", "updated_at": "2026-03-02T13:26:10.443309Z", "qname": "sample_project2.core.model.child.Child.set_name", "code_position": {"line_no": 21, "col_offset": 4, "end_line_no": 24, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2", "name": "fly", "description": "Function fly", "created_at": "2026-03-02T13:26:10.443313Z", "updated_at": "2026-03-02T13:26:10.443314Z", "qname": "sample_project2.core.model.child.Child.fly", "code_position": {"line_no": 25, "col_offset": 4, "end_line_no": 28, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "class"}], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/01530536-7659-4cc0-92e7-c5a4dc7d9cf3"], "file_children": ["FileSchema/4ea5dc9c-650e-4936-ace4-97d8dd11c1c6", "FileSchema/a65973ab-fdae-448b-a241-eed927d0a84e", "FileSchema/e1f8d40d-0e99-45e4-ad7d-5d9140215da0"], "structure_group": []}, "theme_config": null, "node_type": "folder"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/e14f9027-d43a-4701-aca9-442f61d5dc0d", "FolderSchema/1ff2573f-25df-4952-8067-a5bd3544cb06", "FolderSchema/918c5c9e-e649-4866-aa7d-507947231cf6"], "file_children": ["FileSchema/cbf0fded-9500-460a-9054-15124baebf48"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/f82bc8a6-37d5-426c-9113-b93b7d35d60b", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779050Z", "updated_at": "2026-03-02T13:26:09.779054Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__pycache__", "qname": "sample_project2.__pycache__", "children": [{"id": "FileSchema/e45bcf03-780a-4fa9-8f27-a8f47649f8f8", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906580Z", "updated_at": "2026-03-02T13:26:09.906581Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__pycache__/__init__.py", "qname": "sample_project2.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "b172fe08b6a8f13192e597ae8a330cad4c64894da282e026521b71a5268ff8df", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/e45bcf03-780a-4fa9-8f27-a8f47649f8f8"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1", "name": "main", "description": "File main", "created_at": "2026-03-02T13:26:09.907105Z", "updated_at": "2026-03-02T13:48:03.774381Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/main.py", "qname": "sample_project2.main", "documents": [], "theme_config": null, "hash": "6427c103950b47425509a6866acce7a74b06d3943886614004d65a9e19413f95", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "FunctionSchema/fb04a14a-2746-4212-8bdd-cb70779c416c"], "code_element_group": [], "call_children": ["CallSchema/921155ba-86ca-43f9-95e4-489f233dac16", "CallSchema/65834d2b-1f81-4c13-85e0-5f4416baba36"], "call_group": []}, "children": [{"id": "FunctionSchema/fb04a14a-2746-4212-8bdd-cb70779c416c", "name": "runner", "description": "Function runner", "created_at": "2026-03-02T13:26:10.466360Z", "updated_at": "2026-03-02T13:26:10.466360Z", "qname": "sample_project2.main.runner", "code_position": {"line_no": 16, "col_offset": 0, "end_line_no": 20, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16", "name": "main", "description": "call::main.main", "created_at": "2026-03-02T13:26:12.529728Z", "updated_at": "2026-03-02T13:26:12.529729Z", "qname": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1::FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "target_function": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "children_by_type": {"call_children": ["CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2", "CallSchema/5ec80657-2dc9-47ae-b4c6-fa65074992cb"], "call_group": []}, "children": [{"id": "CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2", "name": "create_child", "description": "call::core.utils.helper.create_child", "created_at": "2026-03-02T13:26:12.529748Z", "updated_at": "2026-03-02T13:26:12.529748Z", "qname": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16::FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "target_function": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "children_by_type": {"call_children": ["CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89"], "call_group": []}, "children": [{"id": "CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89", "name": "Child", "description": "call::sample_project2.core.model.child.Child", "created_at": "2026-03-02T13:48:04.296251Z", "updated_at": "2026-03-02T13:48:04.296252Z", "qname": "CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/40f90d8c-e69f-42df-b250-9a00a0ff09f1"], "call_group": []}, "children": [{"id": "CallSchema/40f90d8c-e69f-42df-b250-9a00a0ff09f1", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:48:04.296267Z", "updated_at": "2026-03-02T13:48:04.296268Z", "qname": "CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/5ec80657-2dc9-47ae-b4c6-fa65074992cb", "name": "get_name", "description": "call::core.model.child.Child.get_name", "created_at": "2026-03-02T13:26:12.529741Z", "updated_at": "2026-03-02T13:26:12.529742Z", "qname": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16::FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "target_function": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "name": "main", "description": "Function main", "created_at": "2026-03-02T13:26:10.466354Z", "updated_at": "2026-03-02T13:26:10.466354Z", "qname": "sample_project2.main.main", "code_position": {"line_no": 8, "col_offset": 0, "end_line_no": 13, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/65834d2b-1f81-4c13-85e0-5f4416baba36", "name": "dd", "description": "call::sample_project2.main.dd", "created_at": "2026-03-02T13:48:04.296215Z", "updated_at": "2026-03-02T13:48:04.296219Z", "qname": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1::FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "target_function": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "name": "dd", "description": "Function dd", "created_at": "2026-03-02T13:26:10.466365Z", "updated_at": "2026-03-02T13:26:10.466365Z", "qname": "sample_project2.main.dd", "code_position": {"line_no": 22, "col_offset": 0, "end_line_no": 26, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "name": "dd", "description": "Function dd", "created_at": "2026-03-02T13:26:10.466365Z", "updated_at": "2026-03-02T13:26:10.466365Z", "qname": "sample_project2.main.dd", "code_position": {"line_no": 22, "col_offset": 0, "end_line_no": 26, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "name": "main", "description": "Function main", "created_at": "2026-03-02T13:26:10.466354Z", "updated_at": "2026-03-02T13:26:10.466354Z", "qname": "sample_project2.main.main", "code_position": {"line_no": 8, "col_offset": 0, "end_line_no": 13, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/99e22f7e-4efe-4457-8b1b-fbb20002788d", "name": "get_name", "description": "call::core.model.child.Child.get_name", "created_at": "2026-03-02T13:26:12.642096Z", "updated_at": "2026-03-02T13:26:12.642098Z", "qname": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87::FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "target_function": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/74062b63-0b55-40f5-a107-060e7aa546c2", "name": "create_child", "description": "call::core.utils.helper.create_child", "created_at": "2026-03-02T13:26:12.642113Z", "updated_at": "2026-03-02T13:26:12.642113Z", "qname": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87::FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "target_function": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "children_by_type": {"call_children": ["CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786"], "call_group": []}, "children": [{"id": "CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786", "name": "Child", "description": "call::sample_project2.core.model.child.Child", "created_at": "2026-03-02T13:48:04.318589Z", "updated_at": "2026-03-02T13:48:04.318593Z", "qname": "CallSchema/74062b63-0b55-40f5-a107-060e7aa546c2::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/398eb955-92b5-4c98-9e37-6863a7cf277b"], "call_group": []}, "children": [{"id": "CallSchema/398eb955-92b5-4c98-9e37-6863a7cf277b", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:48:04.318610Z", "updated_at": "2026-03-02T13:48:04.318611Z", "qname": "CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "FileSchema/bfeb3280-f24b-4556-ad17-d23e05102f12", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906428Z", "updated_at": "2026-03-02T13:26:09.906432Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__init__.py", "qname": "sample_project2.__init__", "documents": [], "theme_config": null, "hash": "e91a9e76a195d5a82f1b3f1abc9cb9729e1ac72de1ab898e5ecd37a0df9ca0ba", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FileSchema/cb66b194-9ab0-4d9c-a126-66477c033786", "name": "hello", "description": "File hello", "created_at": "2026-03-02T13:26:09.907042Z", "updated_at": "2026-03-02T13:26:09.907042Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/hello.py", "qname": "sample_project2.hello", "documents": [], "theme_config": null, "hash": "746e294781781eee487d13a8ae6a73545e93c86b0eeea0b63a8497ff344a030c", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f6c92d63-9951-4ddd-953e-755dfdc174f2"], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "FunctionSchema/f6c92d63-9951-4ddd-953e-755dfdc174f2", "name": "runn", "description": "Function runn", "created_at": "2026-03-02T13:26:10.464700Z", "updated_at": "2026-03-02T13:26:10.464701Z", "qname": "sample_project2.hello.runn", "code_position": {"line_no": 4, "col_offset": 0, "end_line_no": 7, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "CallSchema/b7b895bd-8b2f-4e44-b05d-6100a9a695a7", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.529767Z", "updated_at": "2026-03-02T13:26:12.529768Z", "qname": "CallSchema/723f0443-2b2d-4747-84bf-1e442c0b3279::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/d5c05556-2c9b-4326-af6a-7f5cce7827b1", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.642137Z", "updated_at": "2026-03-02T13:26:12.642137Z", "qname": "CallSchema/5fbf36be-473b-4908-86dc-ef2a149f7e7a::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/5f446258-19a6-4b82-99e5-b2e3e2ba2580", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:33.926408Z", "updated_at": "2026-03-02T13:26:33.926409Z", "qname": "CallSchema/2d60eff6-5131-4e1d-8682-ce4cb0b1822f::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/c88620b5-2933-48cb-94b1-8791b41b2244", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:33.958871Z", "updated_at": "2026-03-02T13:26:33.958871Z", "qname": "CallSchema/c7a257ab-4625-415c-a1b1-5b5e55bd72ac::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/005eea45-e496-4593-8d1f-c445997a391e", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:29:43.791330Z", "updated_at": "2026-03-02T13:29:43.791331Z", "qname": "CallSchema/1366431e-7ec4-4118-9062-e98abd17a8f2::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/99ecf862-f66f-455e-b3b2-a08d52744a15", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:29:43.820557Z", "updated_at": "2026-03-02T13:29:43.820558Z", "qname": "CallSchema/2c271965-fdba-4981-866b-38a3d5149a7e::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/69e51916-6574-4100-92c1-f3addda5ae15", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:35:45.815735Z", "updated_at": "2026-03-02T13:35:45.815736Z", "qname": "CallSchema/2054d62e-90be-4dd6-a2bf-55f7bdee4e7b::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/fdca1add-d35d-4d05-a832-3c179ec08c27", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:35:45.860600Z", "updated_at": "2026-03-02T13:35:45.860604Z", "qname": "CallSchema/9ce6620d-a759-4d3b-8488-88236fbeffbd::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/bb61ff1e-d6d6-47a8-9c47-6931ec49683c", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:40:32.132279Z", "updated_at": "2026-03-02T13:40:32.132281Z", "qname": "CallSchema/6b4447de-bc24-4c4a-a24f-91e8fb4828fb::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/fbde7964-0d78-476d-b504-df3afb8cb5da", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:40:32.108887Z", "updated_at": "2026-03-02T13:40:32.108887Z", "qname": "CallSchema/41155d11-02c5-48c9-a412-8ad9d6dddd42::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/3813285e-f473-41b1-81a6-760ddc72a912", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:43:24.922822Z", "updated_at": "2026-03-02T13:43:24.922822Z", "qname": "CallSchema/f6149e92-cc6f-4f1b-82e1-3d37653328c8::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/d762eb1b-74da-44f6-9749-f38305313cfb", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:43:24.946721Z", "updated_at": "2026-03-02T13:43:24.946722Z", "qname": "CallSchema/ca3e33ea-3966-45e9-9364-513b5f8750fd::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/1a3c1ef4-e944-4ff8-82cc-36fac4d49a94", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:46:03.848060Z", "updated_at": "2026-03-02T13:46:03.848060Z", "qname": "CallSchema/2e431f06-2c91-4920-b1e0-674f965cd423::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/ca9f853f-0393-4ef3-b67e-46002012f6bb", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:46:03.810823Z", "updated_at": "2026-03-02T13:46:03.810824Z", "qname": "CallSchema/63fef3ab-ebb2-440b-8fcd-21a46cf69f44::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}] \ No newline at end of file diff --git a/src/backend/vector_storage/admin%2Fsir.vecs b/src/backend/vector_storage/admin%2Fsir.vecs new file mode 100644 index 00000000..e1339607 Binary files /dev/null and b/src/backend/vector_storage/admin%2Fsir.vecs differ diff --git a/src/backend/vector_storage/admin%2Fsir@1dagck3uovy24coiasr81il63rdrrq3.hnsw b/src/backend/vector_storage/admin%2Fsir@1dagck3uovy24coiasr81il63rdrrq3.hnsw new file mode 100644 index 00000000..3b3bc476 --- /dev/null +++ b/src/backend/vector_storage/admin%2Fsir@1dagck3uovy24coiasr81il63rdrrq3.hnsw @@ -0,0 +1 @@ +{"metric":null,"zero":[[1,2,3,4,5,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615],[0,2,3,4,5,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615],[1,0,3,4,5,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615],[2,0,1,4,5,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615],[0,1,2,3,5,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615],[0,1,2,4,3,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615,18446744073709551615]],"features":[{"id":"terminusdb:///data/File/001","index":0},{"id":"terminusdb:///data/File/002","index":1},{"id":"terminusdb:///data/File/003","index":2},{"id":"terminusdb:///data/File/004","index":3},{"id":"terminusdb:///data/File/71","index":4},{"id":"terminusdb:///data/File/utils","index":5}],"layers":[],"prng":{"state":46084779743337481210624775610908566648,"increment":1},"params":{"ef_construction":400}} \ No newline at end of file diff --git a/src/backend/vector_storage/api%2Fdb%2Fadmin%2Fsir.vecs b/src/backend/vector_storage/api%2Fdb%2Fadmin%2Fsir.vecs new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir%2Flocal%2Fbranch%2Fmain.vecs b/src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir%2Flocal%2Fbranch%2Fmain.vecs new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir.vecs b/src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir.vecs new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/package.json b/src/frontend/package.json index 17bb0126..e74f901e 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -54,20 +54,23 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "driver.js": "^1.4.0", + "fast-json-patch": "^3.1.1", "immer": "^10.1.1", "lucide-react": "^0.532.0", "mermaid": "^11.9.0", "next-themes": "^0.4.6", "postcss": "^8.5.6", - "react": "^19.1.0", + "react": "^19.2.0", "react-day-picker": "^9.8.1", - "react-dom": "^19.1.0", + "react-dom": "^19.2.0", "react-hook-form": "^7.61.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.3", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", "react-virtual": "^2.10.4", + "remark-gfm": "^4.0.1", "remeda": "^2.32.0", "socket.io-client": "^4.8.1", "sonner": "^2.0.6", diff --git a/src/frontend/src/features/Dashboard/components/NodeContextMenu.tsx b/src/frontend/src/features/Dashboard/components/NodeContextMenu.tsx index ec16c2ba..3c180d98 100644 --- a/src/frontend/src/features/Dashboard/components/NodeContextMenu.tsx +++ b/src/frontend/src/features/Dashboard/components/NodeContextMenu.tsx @@ -5,7 +5,15 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Separator } from "@/components/ui/separator"; -import { Crosshair, Expand, Group, Link, Trash, FileCode } from "lucide-react"; +import { + Crosshair, + Expand, + Group, + Link, + Trash, + FileCode, + Workflow, +} from "lucide-react"; interface NodeContextMenuProps { children: React.ReactNode; @@ -41,6 +49,10 @@ export const NodeContextMenu = ({ Build Prompt + onAction("start-workflow")}> + + Start workflow + {["function", "class", "call", "file"].includes(nodeType) && ( onAction("add-call")}> diff --git a/src/frontend/src/features/Dashboard/components/SidebarDialogs.tsx b/src/frontend/src/features/Dashboard/components/SidebarDialogs.tsx index 06d416c1..b75786d4 100644 --- a/src/frontend/src/features/Dashboard/components/SidebarDialogs.tsx +++ b/src/frontend/src/features/Dashboard/components/SidebarDialogs.tsx @@ -1,4 +1,5 @@ import { useSidebarModalStore } from "@/features/Dashboard/store/useSidebarModalStore"; +import { StartWorkflowDialog } from "@/features/Dashboard/features/Agent/components/StartWorkflowDialog"; import GroupDialog from "./GroupDialog"; import SelectNodeDialog from "./SelectNodeDialog"; import PromptBuilder from "@/components/PromptBuilder/PromptBuilder"; @@ -71,6 +72,13 @@ export function SidebarDialogs() { rootNode={targetNode as ContainerNodeTree} projectId={projectData?.id ?? ""} /> + + !open && closeModal()} + targetNode={targetNode} + projectId={projectData?.id ?? ""} + /> ); } diff --git a/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx b/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx index dba7563f..3a1ecee4 100644 --- a/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx +++ b/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; import { SendHorizontal } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useAgentChatSend } from "../hooks/useAgentChatSend"; interface AgentChatInputProps { className?: string; @@ -10,10 +12,20 @@ interface AgentChatInputProps { export function AgentChatInput({ className }: AgentChatInputProps) { const [value, setValue] = useState(""); + const sendMessage = useAgentChatSend(); const handleSubmit = () => { - // Input UI only for now; sending logic is intentionally out of scope. - setValue(""); + const text = value.trim(); + if (!text) return; + + sendMessage.mutate(text, { + onSuccess: () => setValue(""), + onError: (e) => { + toast.error("Failed to send message", { + description: e instanceof Error ? e.message : String(e), + }); + }, + }); }; return ( @@ -21,14 +33,20 @@ export function AgentChatInput({ className }: AgentChatInputProps) { setValue(event.target.value)} - placeholder="Ask AI about this code..." + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + placeholder="Message…" className="h-9 text-xs" /> + +
setSearchTerm(event.target.value)} - placeholder="Search chats..." + placeholder="Search…" className="h-8 text-xs" />
+
- {filteredHistory.length > 0 ? ( - filteredHistory.map((item) => ( + {serverQuery.isError ? ( +

+ Could not load conversations. Is the API running? +

+ ) : serverQuery.isPending ? ( +

+ Loading… +

+ ) : filteredServer.length > 0 ? ( + filteredServer.map((item) => ( )) ) : ( -

- No chat history found. +

+ No conversations yet. Start a new chat and send a message.

)}
diff --git a/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx b/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx index 133fe01f..cb7afe7f 100644 --- a/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx +++ b/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx @@ -1,24 +1,61 @@ +import { useQuery } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; -import { useConversationStore } from "../store/useConversationStore"; -import { selectMessageText } from "../store/selectors/conversationSelectors"; -import { useShallow } from "zustand/react/shallow"; +import { useVersioningStore } from "@/features/Dashboard/features/Versioning/store/useVersioningStore"; +import useProjectStore from "@/features/Dashboard/store/useProjectStore"; +import { agentConversationHydrationQueryOptions } from "@/services/agent"; +import { isAgentPreviewConversationId } from "../constants/agentPreviewConversation"; +import { useAgentUiStore } from "../store/useAgentUiStore"; +import { useAgentLiveStore } from "../live/store/useAgentLiveStore"; import { AgentChatInput } from "./AgentChatInput"; -import { WalkthroughView } from "./WalkthroughView/WalkthroughView"; +import { ChatContainer } from "./chat"; +import { WalkthroughPlaybackBar } from "../walkthrough/components/WalkthroughPlaybackBar"; +import { useWalkthroughStore } from "../walkthrough/store/useWalkthroughStore"; interface AgentSidebarProps { className?: string; } export function AgentSidebar({ className }: AgentSidebarProps) { - const [viewMode, setViewMode, currentConversation] = useConversationStore( - useShallow((state) => [ - state.viewMode, - state.setViewMode, - state.currentConversation, - ]), + const playbackDetached = useWalkthroughStore((s) => s.playbackDetached); + const backendConversationId = useAgentUiStore((s) => s.backendConversationId); + const projectId = useProjectStore((s) => s.projectData?.id ?? ""); + const branch = useVersioningStore((s) => s.branch); + const ref = useVersioningStore((s) => s.checkedOutCommitId); + const compareTo = useVersioningStore((s) => s.compareToCommitId); + + const wire = useAgentLiveStore((s) => s.wire); + const activeStreams = useAgentLiveStore((s) => s.activeStreams); + + const hydrationQuery = useQuery( + agentConversationHydrationQueryOptions( + projectId, + backendConversationId, + branch, + ref, + compareTo, + ), + ); + /** Disabled queries (local preview id) stay `isPending` with no fetch — do not treat as loading. */ + const isPreview = isAgentPreviewConversationId(backendConversationId); + const isLiveLoading = + Boolean(backendConversationId) && + !isPreview && + hydrationQuery.isPending; + + const isLive = + Boolean(backendConversationId) && wire?.id === backendConversationId; + + const streamingPlaceholderIds = new Set( + [...activeStreams].map((sid) => `stream:${sid}`), ); - const messages = currentConversation?.messages; + const listMessages = isLive && wire ? wire.messages : []; + + const title = !backendConversationId + ? "New chat" + : isLiveLoading + ? "Loading…" + : (wire?.title ?? "Conversation"); return ( ); diff --git a/src/frontend/src/features/Dashboard/features/Agent/components/StartWorkflowDialog.tsx b/src/frontend/src/features/Dashboard/features/Agent/components/StartWorkflowDialog.tsx new file mode 100644 index 00000000..2e288842 --- /dev/null +++ b/src/frontend/src/features/Dashboard/features/Agent/components/StartWorkflowDialog.tsx @@ -0,0 +1,413 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { useAgentUiStore } from "@/features/Dashboard/features/Agent/store/useAgentUiStore"; +import { useAgentOverlayStore } from "@/features/Dashboard/features/Agent/store/useAgentOverlayStore"; +import { useVersioningStore } from "@/features/Dashboard/features/Versioning/store/useVersioningStore"; +import queryKeys from "@/lib/queryKeys"; +import { AgentHttpError } from "@/lib/agentFetch"; +import { + agentConversationHydrationQueryOptions, + agentWorkflowsApi, +} from "@/services/agent"; +import type { AnyNodeTree } from "@/types/project"; +import type { + DescriptionWorkflowMode, + DocumentationWorkflowMode, + RunWorkflowRequest, + StartAgentWorkflowsPayload, + WorkflowBatchStepWire, + WorkflowModelId, +} from "@/types/agent/workflows"; +import { WORKFLOW_MODEL_OPTIONS } from "@/types/agent/workflows"; + +type WorkflowKind = "description" | "documentation"; + +function buildWorkflowSteps(args: { + kind: WorkflowKind; + nodeId: string; + descriptionModel: WorkflowModelId; + documentationModel: WorkflowModelId; + descriptionMode: DescriptionWorkflowMode; + documentationMode: DocumentationWorkflowMode; + direction: "up" | "down"; + maxDepth: number; +}): WorkflowBatchStepWire[] { + const base = { + node_id: args.nodeId, + direction: args.direction, + max_depth: args.maxDepth, + }; + if (args.kind === "description") { + return [ + { + workflow_name: "description_generator", + params: { + ...base, + model: args.descriptionModel, + description_mode: args.descriptionMode, + }, + }, + ]; + } + return [ + { + workflow_name: "documentation_generator", + params: { + ...base, + model: args.documentationModel, + documentation_mode: args.documentationMode, + }, + }, + ]; +} + +interface StartWorkflowDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + targetNode: AnyNodeTree; + projectId: string; +} + +export function StartWorkflowDialog({ + open, + onOpenChange, + targetNode, + projectId, +}: StartWorkflowDialogProps) { + const queryClient = useQueryClient(); + const branch = useVersioningStore((s) => s.branch); + const ref = useVersioningStore((s) => s.checkedOutCommitId); + const compareTo = useVersioningStore((s) => s.compareToCommitId); + const setAgentOpen = useAgentOverlayStore((s) => s.setOpen); + const setBackendConversationId = useAgentUiStore( + (s) => s.setBackendConversationId, + ); + + const [workflowKind, setWorkflowKind] = + useState("description"); + const [conversationTitle, setConversationTitle] = useState(""); + const [conversationDescription, setConversationDescription] = useState(""); + const [descriptionModel, setDescriptionModel] = + useState("gpt-4o-mini"); + const [documentationModel, setDocumentationModel] = + useState("gpt-4o-mini"); + const [descriptionMode, setDescriptionMode] = + useState("always"); + const [documentationMode, setDocumentationMode] = + useState("upsert"); + const [direction, setDirection] = useState<"up" | "down">("down"); + const [maxDepth, setMaxDepth] = useState(5); + + const nodeId = targetNode.id; + + const resetForm = () => { + setWorkflowKind("description"); + setConversationTitle(""); + setConversationDescription(""); + setDescriptionModel("gpt-4o-mini"); + setDocumentationModel("gpt-4o-mini"); + setDescriptionMode("always"); + setDocumentationMode("upsert"); + setDirection("down"); + setMaxDepth(5); + }; + + const startWorkflowsMutation = useMutation({ + mutationFn: async (body: StartAgentWorkflowsPayload) => { + const step = body.steps[0]; + if (!step) { + throw new Error("No workflow step"); + } + const title = (body.conversation_title ?? "").trim(); + const description = (body.conversation_description ?? "").trim(); + const req: RunWorkflowRequest = { + workflow_name: step.workflow_name, + params: step.params, + }; + if (title) req.conversation_title = title; + if (description) req.conversation_description = description; + return agentWorkflowsApi.run(projectId, req); + }, + onSuccess: async (data) => { + toast.success("Workflow started"); + const cid = data.conversation_id; + setBackendConversationId(cid); + setAgentOpen(true); + void queryClient.prefetchQuery( + agentConversationHydrationQueryOptions( + projectId, + cid, + branch, + ref, + compareTo, + 200, + ), + ); + void queryClient.invalidateQueries({ + queryKey: queryKeys.agent.conversations.all(), + }); + onOpenChange(false); + resetForm(); + }, + onError: (err: unknown) => { + const msg = + err instanceof AgentHttpError + ? JSON.stringify(err.body) || err.message + : err instanceof Error + ? err.message + : "Failed to start workflow"; + toast.error(msg); + }, + }); + + const payload = useMemo((): StartAgentWorkflowsPayload | null => { + if (!nodeId) return null; + const steps = buildWorkflowSteps({ + kind: workflowKind, + nodeId, + descriptionModel, + documentationModel, + descriptionMode, + documentationMode, + direction, + maxDepth, + }); + const body: StartAgentWorkflowsPayload = { steps }; + const t = conversationTitle.trim(); + const d = conversationDescription.trim(); + if (t) body.conversation_title = t; + if (d) body.conversation_description = d; + return body; + }, [ + nodeId, + workflowKind, + descriptionModel, + documentationModel, + descriptionMode, + documentationMode, + direction, + maxDepth, + conversationTitle, + conversationDescription, + ]); + + const handleSubmit = () => { + if (!payload?.steps.length) { + toast.error("Could not build workflow request"); + return; + } + startWorkflowsMutation.mutate(payload); + }; + + return ( + { + onOpenChange(next); + if (!next) resetForm(); + }} + > + + + Start workflow +

+ Root: {targetNode.name} +

+
+ +
+ setWorkflowKind(v as WorkflowKind)} + className="gap-4" + > + + + Descriptions + + + Documentation + + + + +
+ + +
+
+ + + setDescriptionMode(v as DescriptionWorkflowMode) + } + className="flex flex-col gap-2" + > + + + +
+
+ + +
+ + +
+
+ + + setDocumentationMode(v as DocumentationWorkflowMode) + } + className="flex flex-col gap-2" + > + + + +
+
+
+ +
+
+ + +
+
+ + + setMaxDepth(Math.max(1, Number(e.target.value) || 1)) + } + /> +
+
+ +
+

Conversation (optional)

+
+ + setConversationTitle(e.target.value)} + /> +
+
+ +