feat: add doc creator agent and clean stale records

Add a document generation agent for PPT, Word, and table outputs, and clean up deleted agent rows when pods are no longer present so list and metrics endpoints stop surfacing stale agents.

Made-with: Cursor
This commit is contained in:
zhanggangyong
2026-03-10 06:32:16 +00:00
parent ba7e4f3a30
commit ee73763c89
4 changed files with 1142 additions and 73 deletions
@@ -0,0 +1,225 @@
# 文档生成智能体 (Doc Creator Agent)
根据自然语言 prompt 生成 **PPT、Word、表格(Excel/CSV)**。
生成文件可上传至 Azure Blob Storage,返回可访问 URL;也可本地落盘后通过接口下载。
## 基本信息
| 项目 | 值 |
|------|------|
| 镜像 | `agnettaiji.azurecr.io/ai-agents/doc-creator-agent:latest` |
| 端口 | `8000` |
| 模板名 | `doc_creator_agent` |
| 框架 | API (FastAPI) + MCP |
## 认证
写操作需在请求头提供 API Key:
- `api-key: <key>`
- `Authorization: Bearer <key>`
若部署时配置了 `LLM_API_KEY`,可省略请求头。
## 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `LLM_API_KEY` | 调用 LLM 的 API Key | 必填或请求头传入 |
| `LLM_BASE_URL` | LLM 服务 Base URL | 已内置 LiteLLM |
| `DEFAULT_LLM_MODEL` | 默认模型 | `taiji/gpt-4o-mini` |
| `AZURE_STORAGE_CONNECTION_STRING` | Azure Blob 连接字符串 | 可选,不配置则本地 /tmp |
| `AZURE_BLOB_CONTAINER` | Blob 容器名 | `doc-creator` |
| `AZURE_BLOB_SAS_TOKEN` | 下载 URL 的 SAS Token | 可选 |
---
## 功能概览
- **统一生成**:`POST /api/v1/generate`,通过 `prompt` + `output_type`(ppt / word / table)生成对应文件。
- **分类型接口**:`/api/v1/generate-ppt`、`/api/v1/generate-word`、`/api/v1/generate-table`。
- **智能对话**:`POST /chat`,根据用户一句话自动判断生成 PPT/Word/表格并调用生成。
- **文件管理**:`GET /api/v1/files/{filename}` 下载,`GET /api/v1/list-files` 列出已生成文件。
---
## 1. 统一生成 — POST /api/v1/generate
根据 `prompt` 和 `output_type` 一次生成 PPT、Word 或表格。
### 请求体
```json
{
"prompt": "做一份产品发布会的 5 页 PPT,主题是智能手表",
"output_type": "ppt",
"title": "可选标题,不填则由模型推断"
}
```
- `output_type`:`ppt` | `word` | `table`
- `title`:可选
### 响应示例
```json
{
"success": true,
"filename": "doc_ppt_20250308_120000.pptx",
"url": "https://xxx.blob.core.windows.net/doc-creator/doc_ppt_xxx.pptx?xxx",
"output_type": "ppt"
}
```
无 Blob 时 `url` 为相对路径 `/api/v1/files/{filename}`,可通过同服务下载。
---
## 2. 生成 PPT — POST /api/v1/generate-ppt
### 请求体
```json
{
"prompt": "季度总结:Q1 销售、市场、产品规划",
"title": "2025 Q1 总结",
"num_slides": 5
}
```
### 响应
同统一生成,固定为 `.pptx` 文件及 `url`。
---
## 3. 生成 Word — POST /api/v1/generate-word
### 请求体
```json
{
"prompt": "写一份项目周报,包含本周完成、下周计划、风险与问题",
"title": "项目周报"
}
```
### 响应
返回 `.docx` 的 `filename` 与 `url`。
---
## 4. 生成表格 — POST /api/v1/generate-table
### 请求体
```json
{
"prompt": "做一个销售数据表:区域、销售额、环比,5 行示例数据",
"title": "销售数据",
"format": "xlsx"
}
```
- `format`:`xlsx`(默认)或 `csv`
### 响应
返回对应扩展名文件及 `url`,多一个字段 `"format": "xlsx"` 或 `"csv"`。
---
## 5. 智能对话 — POST /chat
用户用自然语言描述需求,Agent 自动判断生成 PPT / Word / 表格并调用生成接口。
### 请求体
```json
{
"message": "帮我做一份年终总结的 PPT"
}
```
### 响应示例
```json
{
"response": "已根据您的需求生成 ppt 文档。",
"generated": {
"success": true,
"filename": "doc_ppt_xxx.pptx",
"url": "https://...",
"output_type": "ppt"
},
"timestamp": "2025-03-08T12:00:00.000Z"
}
```
---
## 6. 文件下载与列表
- **下载**:`GET /api/v1/files/{filename}`
- 配置了 Blob 时 302 到 Blob URL;否则从 `/tmp` 返回文件。
- **列表**:`GET /api/v1/list-files`
- 返回当前已生成文件列表(Blob 时有效)。
---
## MCP 工具
Agent 同时通过 MCP 暴露以下工具,供 MCP 客户端发现与调用:
| 工具名 | 说明 |
|--------|------|
| `generate_document` | 根据 prompt + output_type 生成 ppt/word/table |
| `generate_ppt` | 根据描述生成 PPT |
| `generate_word` | 根据描述生成 Word |
| `generate_table` | 根据描述生成表格(xlsx/csv) |
MCP 端点:`POST /mcp`(JSON-RPC 2.0)。
---
## 构建、推送与动态注册
在仓库根目录下构建镜像:
```bash
docker build -f agent_templates/agents/doc_creator_agent/doc_creator_agent.Dockerfile -t agnettaiji.azurecr.io/ai-agents/doc-creator-agent:latest .
```
推送镜像:
```bash
docker push agnettaiji.azurecr.io/ai-agents/doc-creator-agent:latest
```
推送完成后,通过 Agent Manager 的模板创建接口动态注册模板,而不是直接改 `template_manager.py`:
```bash
curl -X POST "http://<agent-manager-host>:8000/templates/create" \
-H "Content-Type: application/json" \
-d '{
"name": "doc_creator_agent",
"display_name": "Doc Creator Agent",
"description": "根据 prompt 生成 PPT、Word、表格(Excel/CSV)",
"agent_type": "platform",
"agent_framework": "api",
"image": "agnettaiji.azurecr.io/ai-agents/doc-creator-agent:latest",
"port": 8000,
"env_requirements": {
"optional": {
"LLM_API_KEY": "LLM API 密钥(或请求头传入)",
"LLM_BASE_URL": "LLM 服务地址",
"AZURE_STORAGE_CONNECTION_STRING": "Azure Blob 连接字符串(可选,不配则本地存储)",
"AZURE_BLOB_CONTAINER": "Blob 容器名(可选)",
"AZURE_BLOB_SAS_TOKEN": "Blob 读 SAS(可选)"
}
}
}'
```
之后在 Agent Manager 中创建 Agent 时,模板名填写:`doc_creator_agent`。
@@ -0,0 +1,31 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir \
fastapi==0.109.0 \
uvicorn[standard]==0.27.0 \
pydantic==2.5.3 \
aiohttp>=3.9.0 \
python-pptx>=0.6.21 \
python-docx>=1.1.0 \
openpyxl>=3.1.2 \
azure-storage-blob>=12.19.0
COPY agent_templates/common/agent_callback_utils.py /app/common/
RUN touch /app/common/__init__.py
COPY agent_templates/agents/doc_creator_agent/doc_creator_agent.py /app/
ENV PYTHONUNBUFFERED=1
ENV SERVICE_HOST=0.0.0.0
ENV SERVICE_PORT=8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
EXPOSE 8000
CMD ["python3", "-u", "doc_creator_agent.py"]
@@ -0,0 +1,708 @@
"""
Doc Creator Agent - 根据 prompt 生成 PPT、Word、表格
支持:PPT (python-pptx)、Word (python-docx)、Excel/表格 (openpyxl)
生成文件可上传至 Azure Blob Storage,返回可访问 URL
"""
import os
import sys
import json
import uuid
import logging
import aiohttp
from typing import Optional, Dict
from datetime import datetime
from pathlib import Path
from io import BytesIO, StringIO
from fastapi import FastAPI, HTTPException, Header, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
import uvicorn
from azure.storage.blob import BlobServiceClient, ContentSettings
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
CALLBACK_ENABLED = True
except ImportError:
CALLBACK_ENABLED = False
AgentCallbackHandler = None
CallbackContextManager = None
# python-pptx, python-docx, openpyxl
from pptx import Presentation
from pptx.util import Inches, Pt
from docx import Document
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# ==================== 环境变量 ====================
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
POD_NAME = os.getenv("POD_NAME", "doc-creator-agent")
USER_ID = os.getenv("USER_ID", "")
LLM_BASE_URL = os.getenv(
"LLM_BASE_URL",
"https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1",
)
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
DEFAULT_LLM_MODEL = os.getenv("DEFAULT_LLM_MODEL", "taiji/gpt-4o-mini")
AZURE_STORAGE_CONNECTION_STRING = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
AZURE_BLOB_CONTAINER = os.getenv("AZURE_BLOB_CONTAINER", "doc-creator")
AZURE_BLOB_SAS_TOKEN = os.getenv("AZURE_BLOB_SAS_TOKEN", "")
# ==================== Azure Blob ====================
class BlobStorage:
def __init__(self):
self._client = None
if AZURE_STORAGE_CONNECTION_STRING:
try:
self._client = BlobServiceClient.from_connection_string(AZURE_STORAGE_CONNECTION_STRING)
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
if not container_client.exists():
container_client.create_container()
logger.info(f"Blob Storage 已连接: container={AZURE_BLOB_CONTAINER}")
except Exception as e:
logger.error(f"Blob 连接失败: {e}")
self._client = None
@property
def enabled(self) -> bool:
return self._client is not None
def _content_type(self, filename: str) -> str:
ext = filename.rsplit(".", 1)[-1].lower()
return {
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"csv": "text/csv",
}.get(ext, "application/octet-stream")
def upload(self, data: bytes, blob_name: str) -> str:
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
container_client.upload_blob(
name=blob_name,
data=data,
overwrite=True,
content_settings=ContentSettings(content_type=self._content_type(blob_name)),
)
base_url = f"https://{self._client.account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{blob_name}"
if AZURE_BLOB_SAS_TOKEN:
return f"{base_url}?{AZURE_BLOB_SAS_TOKEN}"
return base_url
def list_blobs(self, prefix: str = None):
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
for blob in container_client.list_blobs(name_starts_with=prefix or ""):
base_url = f"https://{self._client.account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{blob.name}"
url = f"{base_url}?{AZURE_BLOB_SAS_TOKEN}" if AZURE_BLOB_SAS_TOKEN else base_url
yield {"filename": blob.name, "url": url, "size_bytes": blob.size}
blob_storage = BlobStorage()
# ==================== 请求模型 ====================
class GenerateRequest(BaseModel):
"""统一生成请求:根据 prompt 和类型生成文件"""
prompt: str = Field(..., description="描述要生成的内容,例如:做一个产品发布会的5页PPT / 写一份项目周报 / 做一个销售数据表")
output_type: str = Field("ppt", description="输出类型: ppt, word, table")
title: Optional[str] = Field(None, description="文档标题(可选,不填则由 LLM 根据 prompt 推断)")
user_id: Optional[str] = None
class GeneratePptRequest(BaseModel):
prompt: str = Field(..., description="PPT 内容描述,例如:产品介绍、季度总结、培训大纲")
title: Optional[str] = None
num_slides: Optional[int] = Field(5, description="页数建议")
user_id: Optional[str] = None
class GenerateWordRequest(BaseModel):
prompt: str = Field(..., description="文档内容描述,例如:项目周报、会议纪要、说明文档")
title: Optional[str] = None
user_id: Optional[str] = None
class GenerateTableRequest(BaseModel):
prompt: str = Field(..., description="表格内容描述,例如:销售数据、人员名单、预算表")
title: Optional[str] = None
format: Optional[str] = Field("xlsx", description="xlsx 或 csv")
user_id: Optional[str] = None
class ChatRequest(BaseModel):
message: str = Field(..., description="用户消息,例如:帮我做一份年终总结的PPT")
user_id: Optional[str] = None
# ==================== LLM 调用 ====================
async def call_llm_json(
system_prompt: str,
user_content: str,
api_key: str,
max_tokens: int = 2000,
) -> dict:
"""调用 LLM 并解析为 JSON"""
payload = {
"model": DEFAULT_LLM_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"max_tokens": max_tokens,
"temperature": 0.3,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
url = f"{LLM_BASE_URL.rstrip('/')}/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status != 200:
text = await resp.text()
raise HTTPException(status_code=502, detail=f"LLM error {resp.status}: {text[:300]}")
data = await resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
cleaned = content.strip()
for mark in ("```json", "```"):
if cleaned.startswith(mark):
cleaned = cleaned.split("\n", 1)[-1] if "\n" in cleaned else ""
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0].strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
logger.warning(f"LLM 返回非 JSON,尝试提取: {e}")
raise HTTPException(status_code=502, detail="LLM 返回格式无法解析为 JSON")
# ==================== 生成逻辑 ====================
def _build_ppt(data: dict) -> bytes:
"""从结构化数据生成 PPTX 字节"""
prs = Presentation()
prs.slide_width = Inches(10)
prs.slide_height = Inches(7.5)
title_slide_layout = prs.slide_layouts[0]
content_layout = prs.slide_layouts[6] # blank
# 标题页
slide = prs.slides.add_slide(title_slide_layout)
title = data.get("title", "未命名演示")
slide.shapes.title.text = title
if slide.placeholders[1]:
slide.placeholders[1].text = data.get("subtitle", "")
# 内容页
slides_data = data.get("slides", [])
for s in slides_data:
slide = prs.slides.add_slide(content_layout)
slide_title = s.get("title", "")
bullets = s.get("bullets", s.get("content", []))
if isinstance(bullets, str):
bullets = [bullets]
left = Inches(0.5)
top = Inches(0.8)
w, h = Inches(9), Inches(1.2)
tx = slide.shapes.add_textbox(left, top, w, h)
tf = tx.text_frame
p = tf.paragraphs[0]
p.text = slide_title
p.font.size = Pt(28)
p.font.bold = True
for b in bullets:
top += Inches(0.9)
tx = slide.shapes.add_textbox(left, top, w, Inches(1.5))
tf = tx.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = b if isinstance(b, str) else str(b)
p.font.size = Pt(18)
buf = BytesIO()
prs.save(buf)
buf.seek(0)
return buf.read()
def _build_word(data: dict) -> bytes:
"""从结构化数据生成 DOCX 字节"""
doc = Document()
title = data.get("title", "未命名文档")
doc.add_heading(title, 0)
for sec in data.get("sections", data.get("paragraphs", [])):
if isinstance(sec, str):
doc.add_paragraph(sec)
continue
heading = sec.get("heading", sec.get("title", ""))
if heading:
doc.add_heading(heading, level=1)
for p in sec.get("paragraphs", sec.get("content", [])):
if p:
doc.add_paragraph(p if isinstance(p, str) else str(p))
buf = BytesIO()
doc.save(buf)
buf.seek(0)
return buf.read()
def _build_table(data: dict, fmt: str = "xlsx") -> bytes:
"""从结构化数据生成表格(xlsx 或 csv)"""
headers = data.get("headers", [])
rows = data.get("rows", [])
if not headers and rows:
headers = [f"列{i+1}" for i in range(len(rows[0]))]
if fmt == "csv":
import csv
buf = StringIO(newline="")
writer = csv.writer(buf)
writer.writerow(headers)
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
wb = Workbook()
ws = wb.active
ws.title = data.get("sheet_name", "Sheet1")
thin = Side(style="thin")
for c, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=c, value=h)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for r, row in enumerate(rows, 2):
for c, val in enumerate(row, 1):
ws.cell(row=r, column=c, value=val)
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf.read()
# ==================== FastAPI ====================
app = FastAPI(
title="Doc Creator Agent",
description="根据 prompt 生成 PPT、Word、表格(Excel/CSV)",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def get_api_key(
api_key: Optional[str] = Header(None, alias="api-key"),
authorization: Optional[str] = Header(None),
) -> str:
if api_key and api_key.strip():
return api_key.strip()
if authorization and (authorization.startswith("Bearer ") or authorization.strip()):
key = authorization[7:].strip() if authorization.startswith("Bearer ") else authorization.strip()
if key:
return key
if LLM_API_KEY:
return LLM_API_KEY
raise HTTPException(status_code=401, detail="请在请求头提供 api-key 或 Authorization Bearer token")
# ==================== 统一生成 ====================
PPT_JSON_SCHEMA = """{
"title": "演示文稿主标题",
"subtitle": "可选副标题",
"slides": [
{ "title": "每页标题", "bullets": ["要点1", "要点2", "要点3"] }
]
}"""
WORD_JSON_SCHEMA = """{
"title": "文档标题",
"sections": [
{ "heading": "章节标题", "paragraphs": ["段落1内容", "段落2内容"] }
]
}"""
TABLE_JSON_SCHEMA = """{
"headers": ["列名1", "列名2", "列名3"],
"rows": [
["行1值1", "行1值2", "行1值3"],
["行2值1", "行2值2", "行2值3"]
],
"sheet_name": "Sheet1"
}"""
@app.post("/api/v1/generate")
async def api_generate(request: GenerateRequest, api_key: str = Depends(get_api_key)):
"""根据 prompt 和 output_type 生成文件(ppt / word / table)"""
prompt = request.prompt
output_type = (request.output_type or "ppt").strip().lower()
if output_type not in ("ppt", "word", "table"):
raise HTTPException(status_code=400, detail="output_type 只能是 ppt, word, table")
if output_type == "ppt":
system_prompt = f"""你是一个专业的演示文稿策划。根据用户的描述,生成一份 PPT 大纲。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下(可增加 slides 数量):
{PPT_JSON_SCHEMA}
bullets 为每页的要点列表。"""
user_content = f"用户需求:{prompt}"
if request.title:
user_content += f"\n主标题请使用:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_ppt(data)
ext = "pptx"
elif output_type == "word":
system_prompt = f"""你是一个专业的文档撰写助手。根据用户的描述,生成文档结构。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下:
{WORD_JSON_SCHEMA}
sections 可多条,paragraphs 为每段的文字。"""
user_content = f"用户需求:{prompt}"
if request.title:
user_content += f"\n文档标题请使用:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_word(data)
ext = "docx"
else:
system_prompt = f"""你是一个专业的数据表设计助手。根据用户的描述,生成表格数据。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下(rows 为二维数组):
{TABLE_JSON_SCHEMA}
headers 和 rows 的列数要一致。"""
user_content = f"用户需求:{prompt}"
if request.title:
user_content += f"\n表头或第一行标题可体现:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_table(data, "xlsx")
ext = "xlsx"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"doc_{output_type}_{ts}.{ext}"
if blob_storage.enabled:
url = blob_storage.upload(raw, filename)
return {"success": True, "filename": filename, "url": url, "output_type": output_type}
out_path = Path("/tmp") / filename
out_path.write_bytes(raw)
return {
"success": True,
"filename": filename,
"url": f"/api/v1/files/{filename}",
"output_type": output_type,
}
@app.post("/api/v1/generate-ppt")
async def api_generate_ppt(request: GeneratePptRequest, api_key: str = Depends(get_api_key)):
"""根据 prompt 生成 PPT"""
system_prompt = f"""你是一个专业的演示文稿策划。根据用户的描述,生成 PPT 大纲。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下:
{PPT_JSON_SCHEMA}
slides 数量建议 {request.num_slides or 5} 页左右。"""
user_content = f"用户需求:{request.prompt}"
if request.title:
user_content += f"\n主标题请使用:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_ppt(data)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"doc_ppt_{ts}.pptx"
if blob_storage.enabled:
url = blob_storage.upload(raw, filename)
return {"success": True, "filename": filename, "url": url}
(Path("/tmp") / filename).write_bytes(raw)
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}"}
@app.post("/api/v1/generate-word")
async def api_generate_word(request: GenerateWordRequest, api_key: str = Depends(get_api_key)):
"""根据 prompt 生成 Word 文档"""
system_prompt = f"""你是一个专业的文档撰写助手。根据用户的描述,生成文档结构。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下:
{WORD_JSON_SCHEMA}"""
user_content = f"用户需求:{request.prompt}"
if request.title:
user_content += f"\n文档标题请使用:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_word(data)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"doc_word_{ts}.docx"
if blob_storage.enabled:
url = blob_storage.upload(raw, filename)
return {"success": True, "filename": filename, "url": url}
(Path("/tmp") / filename).write_bytes(raw)
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}"}
@app.post("/api/v1/generate-table")
async def api_generate_table(request: GenerateTableRequest, api_key: str = Depends(get_api_key)):
"""根据 prompt 生成表格(Excel 或 CSV)"""
fmt = (request.format or "xlsx").strip().lower()
if fmt not in ("xlsx", "csv"):
fmt = "xlsx"
system_prompt = f"""你是一个专业的数据表设计助手。根据用户的描述,生成表格数据。
必须只返回一个 JSON 对象,不要其他文字。格式严格如下:
{TABLE_JSON_SCHEMA}"""
user_content = f"用户需求:{request.prompt}"
if request.title:
user_content += f"\n表头或标题可体现:{request.title}"
data = await call_llm_json(system_prompt, user_content, api_key)
raw = _build_table(data, fmt)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
ext = "xlsx" if fmt == "xlsx" else "csv"
filename = f"doc_table_{ts}.{ext}"
if blob_storage.enabled:
url = blob_storage.upload(raw, filename)
return {"success": True, "filename": filename, "url": url, "format": fmt}
(Path("/tmp") / filename).write_bytes(raw)
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}", "format": fmt}
# ==================== 文件与健康 ====================
@app.get("/")
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"service": "Doc Creator Agent",
"pod_name": POD_NAME,
"storage": "azure_blob" if blob_storage.enabled else "local",
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/api/v1/files/{filename}")
async def download_file(filename: str):
if blob_storage.enabled:
base_url = f"https://{blob_storage._client.account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{filename}"
url = f"{base_url}?{AZURE_BLOB_SAS_TOKEN}" if AZURE_BLOB_SAS_TOKEN else base_url
return RedirectResponse(url=url)
path = Path("/tmp") / filename
if not path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
ext = filename.rsplit(".", 1)[-1].lower()
media = {
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"csv": "text/csv",
}.get(ext, "application/octet-stream")
return FileResponse(path, media_type=media, filename=filename)
@app.get("/api/v1/list-files")
async def list_files():
if not blob_storage.enabled:
return {"files": []}
files = list(blob_storage.list_blobs(prefix="doc_"))
return {"files": files}
# ==================== 智能对话(根据意图调用生成)====================
@app.post("/chat")
async def chat(request: ChatRequest, api_key: str = Depends(get_api_key)):
"""根据用户消息意图,自动选择生成 PPT / Word / 表格"""
system_prompt = """你根据用户消息判断用户想生成什么类型的文档。只返回一个 JSON:
{"intent": "ppt" | "word" | "table", "title": "可选标题", "prompt_for_generate": "给生成接口用的详细内容描述(一段话)"}
若无法判断则 intent 用 "word",prompt_for_generate 用用户原话。不要返回其他内容。"""
data = await call_llm_json(
system_prompt,
f"用户说:{request.message}",
api_key,
max_tokens=500,
)
intent = (data.get("intent") or "word").strip().lower()
if intent not in ("ppt", "word", "table"):
intent = "word"
prompt_for_generate = data.get("prompt_for_generate", request.message)
title = data.get("title")
gen_req = GenerateRequest(prompt=prompt_for_generate, output_type=intent, title=title)
result = await api_generate(gen_req, api_key)
return {
"response": f"已根据您的需求生成{intent}文档。",
"generated": result,
"timestamp": datetime.utcnow().isoformat(),
}
# ==================== MCP 工具列表(供 MCP 协议发现)====================
MCP_TOOL_LIST = [
{
"name": "generate_document",
"description": "根据 prompt 生成文档。支持类型:ppt(演示文稿)、word(Word 文档)、table(Excel/表格)。返回文件 URL 或下载链接。",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "描述要生成的内容,如:做一份产品发布会的5页PPT、写一份项目周报、做销售数据表"},
"output_type": {"type": "string", "description": "输出类型: ppt, word, table"},
"title": {"type": "string", "description": "可选文档标题"},
},
"required": ["prompt"],
},
},
{
"name": "generate_ppt",
"description": "根据描述生成 PPT 演示文稿。",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "PPT 内容描述"},
"title": {"type": "string", "description": "可选标题"},
"num_slides": {"type": "integer", "description": "建议页数"},
},
"required": ["prompt"],
},
},
{
"name": "generate_word",
"description": "根据描述生成 Word 文档。",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "文档内容描述"},
"title": {"type": "string", "description": "可选标题"},
},
"required": ["prompt"],
},
},
{
"name": "generate_table",
"description": "根据描述生成表格(Excel 或 CSV)。",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "表格内容描述"},
"title": {"type": "string", "description": "可选标题"},
"format": {"type": "string", "description": "xlsx 或 csv"},
},
"required": ["prompt"],
},
},
]
_MCP_HANDLERS = {}
def _register_mcp(name: str):
def deco(f):
_MCP_HANDLERS[name] = f
return f
return deco
@_register_mcp("generate_document")
async def _mcp_generate_document(api_key: str, **kwargs) -> str:
req = GenerateRequest(
prompt=kwargs["prompt"],
output_type=kwargs.get("output_type", "ppt"),
title=kwargs.get("title"),
)
result = await api_generate(req, api_key)
return json.dumps(result, ensure_ascii=False, indent=2)
@_register_mcp("generate_ppt")
async def _mcp_generate_ppt(api_key: str, **kwargs) -> str:
req = GeneratePptRequest(prompt=kwargs["prompt"], title=kwargs.get("title"), num_slides=kwargs.get("num_slides"))
result = await api_generate_ppt(req, api_key)
return json.dumps(result, ensure_ascii=False, indent=2)
@_register_mcp("generate_word")
async def _mcp_generate_word(api_key: str, **kwargs) -> str:
req = GenerateWordRequest(prompt=kwargs["prompt"], title=kwargs.get("title"))
result = await api_generate_word(req, api_key)
return json.dumps(result, ensure_ascii=False, indent=2)
@_register_mcp("generate_table")
async def _mcp_generate_table(api_key: str, **kwargs) -> str:
req = GenerateTableRequest(prompt=kwargs["prompt"], title=kwargs.get("title"), format=kwargs.get("format"))
result = await api_generate_table(req, api_key)
return json.dumps(result, ensure_ascii=False, indent=2)
sessions: Dict[str, Dict] = {}
def _get_api_key_from_request(request: Request) -> Optional[str]:
key = request.headers.get("api-key") or request.headers.get("api_key")
if key:
return key.strip()
auth = request.headers.get("Authorization")
if auth:
return auth[7:].strip() if auth.startswith("Bearer ") else auth.strip()
return LLM_API_KEY or None
async def _handle_mcp_request(data: dict, session_id: str = None, api_key: str = None) -> dict:
method = data.get("method")
params = data.get("params", {})
req_id = data.get("id")
if method == "tools/call" and not api_key:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32001, "message": "缺少 API Key"}}
try:
if method == "initialize":
session_id = session_id or str(uuid.uuid4())
sessions[str(session_id)] = {"initialized": True}
return {
"jsonrpc": "2.0", "id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "Doc Creator Agent", "version": "1.0.0"},
},
}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": MCP_TOOL_LIST}}
if method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
handler = _MCP_HANDLERS.get(tool_name)
if not handler:
raise ValueError(f"Unknown tool: {tool_name}")
result = await handler(api_key=api_key, **args)
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": result}]}}
if method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
raise ValueError(f"Unknown method: {method}")
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": str(e)}}
@app.post("/mcp")
async def mcp_endpoint(request: Request):
body = await request.json()
session_id = request.headers.get("x-mcp-session-id")
api_key = _get_api_key_from_request(request)
response = await _handle_mcp_request(body, session_id, api_key)
return JSONResponse(content=response, headers={"x-mcp-session-id": session_id or ""})
# ==================== 主入口 ====================
def main():
logger.info("启动 Doc Creator Agent - %s", POD_NAME)
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
if __name__ == "__main__":
main()
+178 -73
View File
@@ -8,11 +8,12 @@ from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from datetime import datetime
import logging
from kubernetes.client.rest import ApiException
from k8s_manager import K8sManager, sanitize_k8s_name
from database import (
get_db, Template, Agent, Quota, AgentMetric,
AgentType, AgentStatus, parse_resource_string
AgentType, AgentStatus, parse_resource_string, SessionLocal
)
from template_manager import template_manager
from tool_generator_api import router as tool_generator_router
@@ -43,6 +44,89 @@ KUBECONFIG_PATH = os.getenv("KUBECONFIG_PATH", None) # 可选:指定kubeconfi
k8s_manager = K8sManager(namespace=NAMESPACE, kubeconfig_path=KUBECONFIG_PATH)
def _delete_stale_agent_record(db: Session, db_agent: Optional[Agent], reason: str) -> None:
"""删除数据库中的失效 Agent 记录。"""
if not db_agent:
return
agent_name = db_agent.name
try:
db.delete(db_agent)
db.commit()
logger.info(f"🧹 已清理失效 Agent 记录: {agent_name}, reason={reason}")
except Exception as e:
db.rollback()
logger.error(f"清理失效 Agent 记录失败: {agent_name}, error={e}")
def _discover_agent_namespace(agent_name: str) -> Optional[str]:
"""尝试在 K8s 中发现 Agent 所在命名空间。"""
try:
namespaces = k8s_manager.v1.list_namespace(
label_selector=f"agent-name={agent_name}"
)
if namespaces.items:
return namespaces.items[0].metadata.name
except Exception as e:
logger.warning(f"按标签查找命名空间失败: {agent_name}, error={e}")
for ns_pattern in [f"agent-{agent_name}", f"agent-test-{agent_name}"]:
try:
k8s_manager.v1.read_namespace(name=ns_pattern)
return ns_pattern
except ApiException as e:
if e.status != 404:
logger.warning(f"检查命名空间失败: {ns_pattern}, error={e}")
except Exception as e:
logger.warning(f"检查命名空间异常: {ns_pattern}, error={e}")
return None
def _find_agent_pod(
agent_name: str,
db: Session,
db_agent: Optional[Agent] = None,
cleanup_stale: bool = False,
):
"""查找 Agent 对应的 Pod,必要时同步 namespace 或清理失效数据库记录。"""
namespaces_to_try: List[str] = []
if db_agent and db_agent.namespace:
namespaces_to_try.append(db_agent.namespace)
discovered_namespace = _discover_agent_namespace(agent_name)
if discovered_namespace and discovered_namespace not in namespaces_to_try:
namespaces_to_try.append(discovered_namespace)
for namespace in namespaces_to_try:
try:
temp_manager = K8sManager(namespace=namespace, kubeconfig_path=KUBECONFIG_PATH)
pod = temp_manager.v1.read_namespaced_pod(
name=agent_name,
namespace=namespace
)
if db_agent and db_agent.namespace != namespace:
db_agent.namespace = namespace
try:
db.commit()
except Exception as e:
db.rollback()
logger.warning(f"同步 Agent namespace 失败: {agent_name}, error={e}")
return pod, namespace
except ApiException as e:
if e.status == 404:
continue
raise
if cleanup_stale and db_agent:
_delete_stale_agent_record(db, db_agent, "pod_or_namespace_not_found")
return None, discovered_namespace
# ==================== 请求/响应模型 ====================
# Template Management Models
@@ -218,6 +302,8 @@ class CreateAgentRequest(BaseModel):
class AgentResponse(BaseModel):
"""Agent响应"""
name: str
displayName: Optional[str] = None
description: Optional[str] = None
namespace: str
status: str
framework: Optional[str] = None
@@ -251,6 +337,8 @@ class ResourceInfo(BaseModel):
class PodStatusResponse(BaseModel):
"""Pod状态响应"""
name: str
displayName: Optional[str] = None
description: Optional[str] = None
namespace: str
status: str
health_status: Optional[str] = None # 新增:健康状态 (healthy, unhealthy, degraded)
@@ -523,10 +611,14 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db
domain_url = access_info.get("domain_url")
recommended_url = access_info.get("recommended", domain_url or ip_url)
# 查找模板以关联 template_id
db_template = db.query(Template).filter(Template.name == request.template).first()
# 创建Agent记录
db_agent = Agent(
name=request.name,
display_name=request.name,
display_name=db_template.display_name if db_template else request.name,
template_id=db_template.id if db_template else None,
owner_id=user_id,
agent_type=AgentType.PLATFORM, # 默认为平台类型
status=AgentStatus.RUNNING,
@@ -559,6 +651,12 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db
if "status" not in result:
result["status"] = "Pending"
# 添加 displayName 和 description(从模板获取)
tpl_info = template_manager.get_template(request.template)
if tpl_info:
result["displayName"] = tpl_info.get("display_name", request.template)
result["description"] = tpl_info.get("description")
# 添加外部工具信息
if attached_tools:
result["tools_attached"] = len(attached_tools)
@@ -698,41 +796,24 @@ async def get_agent_status(agent_name: str, db: Session = Depends(get_db)):
try:
logger.info(f"获取Agent状态: {agent_name}")
# 先从数据库获取Agent的namespace
db_agent = db.query(Agent).filter(Agent.name == agent_name).first()
# 确定Agent所在的namespace
agent_namespace = None
if db_agent and db_agent.namespace:
agent_namespace = db_agent.namespace
else:
# 如果数据库中没有,尝试查找以 agent-{agent_name} 开头的命名空间
try:
namespaces = k8s_manager.v1.list_namespace(
label_selector=f"agent-name={agent_name}"
)
if namespaces.items:
agent_namespace = namespaces.items[0].metadata.name
else:
# 尝试常见的命名空间格式
for ns_pattern in [f"agent-{agent_name}", f"agent-test-{agent_name}"]:
try:
k8s_manager.v1.read_namespace(name=ns_pattern)
agent_namespace = ns_pattern
break
except:
continue
except Exception as e:
logger.warning(f"查找命名空间失败: {e}")
_, agent_namespace = _find_agent_pod(
agent_name=agent_name,
db=db,
db_agent=db_agent,
cleanup_stale=True,
)
if not agent_namespace:
raise HTTPException(status_code=404, detail=f"Agent {agent_name} 的命名空间未找到")
raise HTTPException(status_code=404, detail=f"Agent {agent_name} 不存在或已被删除")
# 使用正确的namespace获取Pod状态
temp_manager = K8sManager(namespace=agent_namespace, kubeconfig_path=KUBECONFIG_PATH)
result = temp_manager.get_pod_status(pod_name=agent_name)
if result.get("status") == "not_found":
_delete_stale_agent_record(db, db_agent, "status_pod_not_found")
raise HTTPException(status_code=404, detail=result.get("message"))
# 添加数据库中的信息
@@ -740,6 +821,20 @@ async def get_agent_status(agent_name: str, db: Session = Depends(get_db)):
# 添加框架类型
result["framework"] = db_agent.agent_framework.upper() if db_agent.agent_framework else "API"
# 添加 displayName 和 description(从模板获取)
if db_agent.template_id and db_agent.template:
result["displayName"] = db_agent.template.display_name
result["description"] = db_agent.template.description
else:
tpl_name = result.get("template")
if tpl_name:
tpl_info = template_manager.get_template(tpl_name)
if tpl_info:
result["displayName"] = tpl_info.get("display_name")
result["description"] = tpl_info.get("description")
if not result.get("displayName"):
result["displayName"] = db_agent.display_name
# 添加访问信息
access_info = {}
@@ -794,35 +889,17 @@ async def get_agent_metrics(agent_name: str, db: Session = Depends(get_db)):
try:
logger.info(f"获取Agent资源信息: {agent_name}")
# 先从数据库获取Agent的namespace
db_agent = db.query(Agent).filter(Agent.name == agent_name).first()
# 确定Agent所在的namespace
agent_namespace = None
if db_agent and db_agent.namespace:
agent_namespace = db_agent.namespace
else:
# 如果数据库中没有,尝试查找以 agent-{agent_name} 开头的命名空间
try:
namespaces = k8s_manager.v1.list_namespace(
label_selector=f"agent-name={agent_name}"
)
if namespaces.items:
agent_namespace = namespaces.items[0].metadata.name
else:
# 尝试常见的命名空间格式
for ns_pattern in [f"agent-{agent_name}", f"agent-test-{agent_name}"]:
try:
k8s_manager.v1.read_namespace(name=ns_pattern)
agent_namespace = ns_pattern
break
except:
continue
except Exception as e:
logger.warning(f"查找命名空间失败: {e}")
if not agent_namespace:
raise HTTPException(status_code=404, detail=f"Agent {agent_name} 的命名空间未找到")
pod, agent_namespace = _find_agent_pod(
agent_name=agent_name,
db=db,
db_agent=db_agent,
cleanup_stale=True,
)
if not pod or not agent_namespace:
raise HTTPException(status_code=404, detail=f"Agent {agent_name} 不存在或已被删除")
# 使用正确的namespace获取Pod指标
temp_manager = K8sManager(namespace=agent_namespace, kubeconfig_path=KUBECONFIG_PATH)
@@ -859,26 +936,49 @@ async def list_agents(template: Optional[str] = None, db: Session = Depends(get_
db_agents = query.all()
for db_agent in db_agents:
# 尝试从K8s获取Pod状态
pod_status = "Unknown"
pod_ip = None
try:
if db_agent.namespace:
temp_manager = K8sManager(namespace=db_agent.namespace, kubeconfig_path=KUBECONFIG_PATH)
pod = temp_manager.v1.read_namespaced_pod(
name=db_agent.name,
namespace=db_agent.namespace
)
pod_status = pod.status.phase
pod_ip = pod.status.pod_ip
except Exception:
pod_status = "NotFound"
pod, agent_namespace = _find_agent_pod(
agent_name=db_agent.name,
db=db,
db_agent=db_agent,
cleanup_stale=True,
)
except Exception as e:
logger.warning(f"查询 Agent Pod 失败: {db_agent.name}, error={e}")
pod = None
agent_namespace = db_agent.namespace
if not pod:
continue
pod_status = pod.status.phase
pod_ip = pod.status.pod_ip
template_name = pod.metadata.labels.get("template")
# 获取模板的 displayName 和 description
tpl_display_name = db_agent.display_name
tpl_description = None
tpl_name = template_name or "unknown"
if db_agent.template_id and db_agent.template:
tpl_display_name = db_agent.template.display_name
tpl_description = db_agent.template.description
tpl_name = db_agent.template.name
elif template_name:
tpl_info = template_manager.get_template(template_name)
if tpl_info:
tpl_display_name = tpl_info.get("display_name", template_name)
tpl_description = tpl_info.get("description")
tpl_name = template_name
agent_info = {
"name": db_agent.name,
"namespace": db_agent.namespace,
"displayName": tpl_display_name,
"description": tpl_description,
"namespace": agent_namespace,
"status": pod_status,
"template": db_agent.agent_framework or "unknown",
"template": tpl_name,
"framework": db_agent.agent_framework or "api",
"created_at": db_agent.created_at.isoformat() if db_agent.created_at else None,
"pod_ip": pod_ip,
"external_ip": db_agent.external_ip,
@@ -922,11 +1022,16 @@ async def list_agents(template: Optional[str] = None, db: Session = Depends(get_
for pod in pods.items:
if pod.metadata.name not in known_agents:
k8s_tpl_name = pod.metadata.labels.get("template", "unknown")
k8s_tpl = template_manager.get_template(k8s_tpl_name)
agent_info = {
"name": pod.metadata.name,
"displayName": k8s_tpl.get("display_name", k8s_tpl_name) if k8s_tpl else k8s_tpl_name,
"description": k8s_tpl.get("description") if k8s_tpl else None,
"namespace": ns_name,
"status": pod.status.phase,
"template": pod.metadata.labels.get("template", "unknown"),
"template": k8s_tpl_name,
"framework": pod.metadata.labels.get("framework", "api"),
"created_at": pod.metadata.creation_timestamp.isoformat() if pod.metadata.creation_timestamp else None,
"pod_ip": pod.status.pod_ip
}