fix(backend): add missing storage, tasks, document, sandbox modules

The app.storage and app.tasks packages were never committed to git,
causing ModuleNotFoundError on Azure deployment. Also adds the
document and sandbox tool modules with their config fields.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-08 17:03:30 +08:00
co-authored by Claude Sonnet 4.6
parent 39aad16373
commit be60d9742e
8 changed files with 379 additions and 0 deletions
+14
View File
@@ -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
View File
+101
View File
@@ -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
View File
+110
View File
@@ -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
+4
View File
@@ -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],
}
+76
View File
@@ -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}"
+74
View File
@@ -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)