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:
co-authored by
Claude Sonnet 4.6
parent
39aad16373
commit
be60d9742e
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user