diff --git a/backend/app/config.py b/backend/app/config.py index 5767507..5d47906 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -47,6 +47,20 @@ class Settings(BaseSettings): # Redis redis_url: str = "rediss://:bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=@oper.redis.cache.windows.net:6380" + # Doc Creator Agent + doc_agent_url: str = "http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com" + doc_agent_key: str = "" + + # Daytona Sandbox + daytona_api_key: str = "" + daytona_api_url: str = "https://app.daytona.io/api" + + # Azure Blob Storage + azure_storage_connection_string: str = "" + + # Azure Service Bus + azure_service_bus_connection_string: str = "" + # Server host: str = "0.0.0.0" port: int = 8000 diff --git a/backend/app/storage/__init__.py b/backend/app/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/storage/blob.py b/backend/app/storage/blob.py new file mode 100644 index 0000000..450add5 --- /dev/null +++ b/backend/app/storage/blob.py @@ -0,0 +1,101 @@ +"""Azure Blob Storage client for file uploads and downloads. + +Used to persist attachments, sandbox output, and generated documents. +""" + +from __future__ import annotations + +import logging +import uuid +from io import BytesIO + +from azure.storage.blob.aio import BlobServiceClient + +from app.config import settings + +logger = logging.getLogger(__name__) + +_client: BlobServiceClient | None = None +DEFAULT_CONTAINER = "soc-files" + + +async def _get_client() -> BlobServiceClient: + global _client + if _client is None: + _client = BlobServiceClient.from_connection_string( + settings.azure_storage_connection_string + ) + return _client + + +async def ensure_container(container: str = DEFAULT_CONTAINER) -> None: + """Create the container if it does not already exist.""" + client = await _get_client() + container_client = client.get_container_client(container) + try: + await container_client.get_container_properties() + except Exception: + await container_client.create_container() + logger.info("Created blob container: %s", container) + + +async def upload_blob( + data: bytes, + filename: str | None = None, + container: str = DEFAULT_CONTAINER, + content_type: str = "application/octet-stream", +) -> str: + """Upload bytes to Azure Blob Storage and return the blob URL. + + Args: + data: File content as bytes. + filename: Optional filename. A UUID is generated if not provided. + container: Blob container name. + content_type: MIME content type. + + Returns: + The public URL of the uploaded blob. + """ + client = await _get_client() + await ensure_container(container) + + blob_name = filename or f"{uuid.uuid4().hex}" + blob_client = client.get_blob_client(container=container, blob=blob_name) + + from azure.storage.blob import ContentSettings + + await blob_client.upload_blob( + data, + overwrite=True, + content_settings=ContentSettings(content_type=content_type), + ) + logger.info("Uploaded blob: %s/%s (%d bytes)", container, blob_name, len(data)) + return blob_client.url + + +async def download_blob( + blob_name: str, + container: str = DEFAULT_CONTAINER, +) -> bytes: + """Download a blob's content as bytes. + + Args: + blob_name: Name of the blob to download. + container: Blob container name. + + Returns: + The blob content as bytes. + """ + client = await _get_client() + blob_client = client.get_blob_client(container=container, blob=blob_name) + stream = await blob_client.download_blob() + data = await stream.readall() + return data + + +async def close_blob_client() -> None: + """Close the blob service client.""" + global _client + if _client is not None: + await _client.close() + _client = None diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/tasks/bus.py b/backend/app/tasks/bus.py new file mode 100644 index 0000000..c86be3f --- /dev/null +++ b/backend/app/tasks/bus.py @@ -0,0 +1,110 @@ +"""Azure Service Bus client for async task dispatch and consumption. + +Long-running tasks (document generation, deep search) are published +to a Service Bus queue so the main request can return immediately +with a "task accepted" response. A background consumer picks up +messages and processes them. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from datetime import datetime + +from azure.servicebus.aio import ServiceBusClient, ServiceBusSender, ServiceBusReceiver +from azure.servicebus import ServiceBusMessage + +from app.config import settings + +logger = logging.getLogger(__name__) + +_client: ServiceBusClient | None = None +QUEUE_NAME = "soc-tasks" + + +async def _get_client() -> ServiceBusClient: + global _client + if _client is None: + _client = ServiceBusClient.from_connection_string( + settings.azure_service_bus_connection_string + ) + return _client + + +async def send_task( + task_type: str, + payload: dict, + conversation_id: str | None = None, +) -> str: + """Publish an async task message to Service Bus. + + Args: + task_type: Type of task (e.g. "document_generate", "deep_search"). + payload: Task-specific data. + conversation_id: Optional conversation context. + + Returns: + A unique task_id for tracking. + """ + client = await _get_client() + task_id = uuid.uuid4().hex + + message_body = json.dumps({ + "task_id": task_id, + "task_type": task_type, + "conversation_id": conversation_id, + "payload": payload, + "created_at": datetime.utcnow().isoformat(), + }) + + sender: ServiceBusSender + async with client.get_queue_sender(queue_name=QUEUE_NAME) as sender: + await sender.send_messages(ServiceBusMessage(message_body)) + + logger.info("Sent task %s (type=%s) to Service Bus", task_id, task_type) + return task_id + + +async def receive_tasks(max_messages: int = 10, max_wait_time: int = 5) -> list[dict]: + """Receive pending task messages from Service Bus. + + Args: + max_messages: Maximum number of messages to receive. + max_wait_time: Maximum wait time in seconds. + + Returns: + List of task dicts. Each message is completed (removed from queue) + after being returned. + """ + client = await _get_client() + tasks = [] + + receiver: ServiceBusReceiver + async with client.get_queue_receiver( + queue_name=QUEUE_NAME, + max_wait_time=max_wait_time, + ) as receiver: + messages = await receiver.receive_messages( + max_message_count=max_messages, + max_wait_time=max_wait_time, + ) + for msg in messages: + try: + body = json.loads(str(msg)) + tasks.append(body) + await receiver.complete_message(msg) + except Exception: + logger.exception("Failed to process Service Bus message") + await receiver.dead_letter_message(msg, reason="parse_error") + + return tasks + + +async def close_service_bus() -> None: + """Close the Service Bus client.""" + global _client + if _client is not None: + await _client.close() + _client = None diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py index ac2abc7..e1c95d4 100644 --- a/backend/app/tools/__init__.py +++ b/backend/app/tools/__init__.py @@ -3,6 +3,8 @@ from app.tools.kb import kb_search from app.tools.tickets import ticket_list, ticket_detail from app.tools.search import web_search +from app.tools.document import generate_document +from app.tools.sandbox import sandbox_run # Mapping from frontend tool names to LangChain tool objects. # The frontend sends a list of tool *keys* (e.g. ["knowledge", "tickets"]); @@ -11,6 +13,8 @@ ALL_TOOLS: dict[str, list] = { "knowledge": [kb_search], "tickets": [ticket_list, ticket_detail], "search": [web_search], + "document": [generate_document], + "sandbox": [sandbox_run], } diff --git a/backend/app/tools/document.py b/backend/app/tools/document.py new file mode 100644 index 0000000..09d194f --- /dev/null +++ b/backend/app/tools/document.py @@ -0,0 +1,76 @@ +"""Document generation tool using Doc Creator Agent. + +Supports Word, PPT, and Excel document types. The tool detects +the desired format from the user's prompt and calls the external +Doc Creator Agent API to generate the document. +""" + +from __future__ import annotations + +import logging +import re + +import httpx +from langchain_core.tools import tool + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Keywords used to detect desired document type from the prompt +_PPT_KEYWORDS = re.compile(r"(ppt|pptx|幻灯片|演示|slides?|presentation)", re.IGNORECASE) +_TABLE_KEYWORDS = re.compile(r"(excel|xlsx?|表格|spreadsheet|csv|数据表)", re.IGNORECASE) + + +def _detect_doc_type(prompt: str) -> str: + """Detect document output type from the user prompt.""" + if _PPT_KEYWORDS.search(prompt): + return "ppt" + if _TABLE_KEYWORDS.search(prompt): + return "table" + return "word" + + +@tool +async def generate_document(prompt: str) -> str: + """Generate a Word, PPT, or Excel document based on the user's request. + + Use this tool when the user asks you to create, generate, or write a + document, presentation, spreadsheet, or report. The tool will produce + a downloadable file link. + + Args: + prompt: A description of the document to generate, including its + content requirements and desired format. + """ + output_type = _detect_doc_type(prompt) + logger.info("Generating document type=%s for prompt: %s", output_type, prompt[:100]) + + try: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + settings.doc_agent_url, + json={"prompt": prompt, "output_type": output_type}, + headers={ + "Authorization": f"Bearer {settings.doc_agent_key}", + "Content-Type": "application/json", + }, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPStatusError as exc: + logger.error("Doc Creator API error: %s %s", exc.response.status_code, exc.response.text) + return f"Document generation failed (HTTP {exc.response.status_code}). Please try again later." + except Exception: + logger.exception("Doc Creator Agent request failed") + return "Document generation failed due to a network error. Please try again later." + + title = data.get("title", "Document") + file_url = data.get("file_url", "") + + if not file_url: + return "Document was generated but no download link was returned." + + type_labels = {"ppt": "PPT", "table": "Excel", "word": "Word"} + label = type_labels.get(output_type, "Document") + return f"[{label}] {title}\nDownload: {file_url}" diff --git a/backend/app/tools/sandbox.py b/backend/app/tools/sandbox.py new file mode 100644 index 0000000..99d6296 --- /dev/null +++ b/backend/app/tools/sandbox.py @@ -0,0 +1,74 @@ +"""Sandboxed code execution tool using Daytona SDK. + +Creates an ephemeral Daytona sandbox, runs user code, captures +stdout/stderr, and destroys the sandbox on completion. +""" + +from __future__ import annotations + +import logging + +from langchain_core.tools import tool + +from app.config import settings + +logger = logging.getLogger(__name__) + + +@tool +async def sandbox_run(code: str, language: str = "python") -> str: + """Execute code in a secure sandbox and return the output. + + Use this tool when the user asks you to run, execute, or test code. + The sandbox is isolated and ephemeral — it is destroyed after execution. + + Supported languages: python, javascript, typescript, bash/shell. + + Args: + code: The source code to execute. + language: Programming language (default: python). + """ + # Lazy import so the app starts even if daytona is not installed + try: + from daytona import Daytona, DaytonaConfig, DaytonaError + except ImportError: + return "Sandbox execution is not available: the daytona SDK is not installed." + + logger.info("Sandbox run: language=%s, code length=%d", language, len(code)) + + config = DaytonaConfig( + api_key=settings.daytona_api_key, + api_url=settings.daytona_api_url, + target="us", + ) + daytona = Daytona(config) + sandbox = None + + try: + sandbox = daytona.create() + + if language in ("bash", "shell", "sh"): + response = sandbox.process.exec(code, timeout=30) + else: + response = sandbox.process.code_run(code, timeout=30) + + exit_code = getattr(response, "exit_code", None) + result = getattr(response, "result", str(response)) + + # Truncate very long output + if len(result) > 10000: + result = result[:10000] + "\n... (output truncated)" + + if exit_code and exit_code != 0: + return f"[Exit code: {exit_code}]\n{result}" + return result or "(no output)" + + except Exception as exc: + logger.exception("Sandbox execution failed") + return f"Sandbox execution failed: {exc}" + finally: + if sandbox is not None: + try: + daytona.remove(sandbox) + except Exception: + logger.warning("Failed to remove sandbox", exc_info=True)