Files
aiagent/backend/app/services/agent_workspace_chat_log.py
renjianbo df4fab1e6e feat: Agent 批量测试、作业助手与上传预览;Windows 启动脚本与文档- 新增 run_agent_test_cases 与示例 JSON、(红头)agent测试用例文档
- 扩展 test_agent_execution(--homework、UTF-8 控制台)
- 后端:uploads 预览、file_read、工作流与对话落盘等
- 前端:AgentChatPreview 与设计器相关调整
- 忽略 redis二进制、agent_workspaces、uploads、tessdata 等本机产物

Made-with: Cursor
2026-04-13 20:17:18 +08:00

303 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
每个智能体在工作区根目录下对应一个 Markdown 对话文件(自动追加)。
与 builtin_tools._local_file_workspace_root 使用同一根目录,避免越权路径。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.execution import Execution
from app.models.agent import Agent
from app.services.builtin_tools import _local_file_workspace_root
logger = logging.getLogger(__name__)
_MAX_BODY_CHARS = 100_000
def _workspace_subdir() -> str:
s = (getattr(settings, "AGENT_WORKSPACE_CHAT_SUBDIR", None) or "agent_workspaces").strip()
return s.strip("/\\") or "agent_workspaces"
def _extract_user_text(input_data: Optional[Dict[str, Any]]) -> str:
if not input_data:
return ""
keys = ("query", "USER_INPUT", "message", "content", "text", "prompt", "user_message")
for k in keys:
v = input_data.get(k)
if v is None:
continue
if isinstance(v, str) and v.strip():
return v.strip()
if v is not None and not isinstance(v, (dict, list)):
s = str(v).strip()
if s:
return s
filtered = {k: v for k, v in input_data.items() if not str(k).startswith("__")}
try:
raw = json.dumps(filtered, ensure_ascii=False, default=str)
except Exception:
raw = str(filtered)
if len(raw) > _MAX_BODY_CHARS:
raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
return raw or ""
def _extract_assistant_text(result: Optional[Dict[str, Any]]) -> str:
if not result:
return ""
r = result.get("result")
if r is None:
try:
raw = json.dumps(result, ensure_ascii=False, default=str)
except Exception:
raw = str(result)
elif isinstance(r, str):
return r if len(r) <= _MAX_BODY_CHARS else r[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
else:
try:
raw = json.dumps(r, ensure_ascii=False, default=str)
except Exception:
raw = str(r)
if len(raw) > _MAX_BODY_CHARS:
raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
return raw
def _file_already_has_execution_id(md_path: Path, execution_id: str) -> bool:
"""同一执行只记一次,避免 Celery 与 API 补救重复写入。"""
if not md_path.exists():
return False
try:
text = md_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
needle = f"**执行 ID**`{execution_id}`"
return needle in text
def _user_id_from_input(input_data: Optional[Dict[str, Any]]) -> Optional[str]:
if not input_data:
return None
for k in ("user_id", "USER_ID", "userId"):
v = input_data.get(k)
if v is None:
continue
s = str(v).strip()
if s:
return s
return None
def fetch_agent_preview_chat_turns(
db: Session,
agent_id: str,
*,
preview_user_id: Optional[str] = None,
limit: int = 50,
max_scan: int = 500,
) -> List[Dict[str, Any]]:
"""
设计器预览侧恢复对话:从已完成执行解析用户/助手文本。
preview_user_id 与 input_data.user_id 一致时只返回该浏览器预览会话的记录。
"""
from app.models.execution import Execution
limit = max(1, min(int(limit), 200))
max_scan = max(limit, min(int(max_scan), 1000))
rows = (
db.query(Execution)
.filter(
Execution.agent_id == agent_id,
Execution.status == "completed",
)
.order_by(Execution.created_at.desc())
.limit(max_scan)
.all()
)
picked: List[Execution] = []
for ex in rows:
if preview_user_id:
inp = ex.input_data if isinstance(ex.input_data, dict) else None
uid = _user_id_from_input(inp)
if uid != preview_user_id:
continue
picked.append(ex)
if len(picked) >= limit:
break
picked.reverse()
out: List[Dict[str, Any]] = []
for ex in picked:
inp = ex.input_data if isinstance(ex.input_data, dict) else None
od = ex.output_data
wf: Dict[str, Any] = od if isinstance(od, dict) else {"result": od}
user_text = (_extract_user_text(inp) or "").strip()
agent_text = (_extract_assistant_text(wf) or "").strip()
if not user_text and not agent_text:
continue
out.append(
{
"execution_id": str(ex.id),
"created_at": ex.created_at,
"user_text": user_text or "(无文本)",
"agent_text": agent_text or "(无输出)",
}
)
return out
def append_agent_dialogue_md(
*,
db: Session,
execution: Execution,
input_data: Optional[Dict[str, Any]],
workflow_result: Dict[str, Any],
) -> Optional[Path]:
"""
在 agent_workspaces/<agent_id>/dialogue.md 末尾追加一轮对话。
仅当 execution.agent_id 存在且开关开启时写入。
"""
if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True):
return None
if not execution or not getattr(execution, "agent_id", None):
return None
agent_id = str(execution.agent_id)
ag = db.query(Agent).filter(Agent.id == execution.agent_id).first()
agent_name = (ag.name if ag and ag.name else agent_id).strip() or agent_id
root = _local_file_workspace_root()
sub = _workspace_subdir()
agent_dir = root / sub / agent_id
try:
agent_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning("创建智能体对话目录失败: %s", e)
return None
md_path = agent_dir / "dialogue.md"
eid = str(execution.id)
if _file_already_has_execution_id(md_path, eid):
logger.debug("对话 MD 已包含执行 %s,跳过追加", eid)
return md_path
user_text = _extract_user_text(input_data)
assistant_text = _extract_assistant_text(workflow_result)
uid = _user_id_from_input(input_data)
ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
rel_note = f"{sub}/{agent_id}/dialogue.md"
block_lines = [
f"### {ts}",
"",
f"- **执行 ID**`{eid}`",
]
if uid:
block_lines.append(f"- **用户 ID**`{uid}`")
block_lines.extend(
[
"",
"**用户**",
"",
user_text or "(无文本输入,详见 input_data",
"",
"**助手**",
"",
assistant_text or "(无输出)",
"",
"---",
"",
]
)
block = "\n".join(block_lines)
try:
if not md_path.exists():
header = "\n".join(
[
f"# 对话记录 · {agent_name}",
"",
f"- **Agent ID**`{agent_id}`",
f"- **文件路径**(相对工作区根):`{rel_note}`",
"- **说明**:每次关联本智能体的执行成功完成后自动追加。",
"",
"---",
"",
]
)
md_path.write_text(header + block, encoding="utf-8")
else:
with md_path.open("a", encoding="utf-8", newline="\n") as f:
f.write(block)
except OSError as e:
logger.warning("写入智能体对话 MD 失败: %s", e)
return None
logger.info("智能体对话已写入: %s", md_path)
return md_path
def ensure_agent_dialogue_logged_from_db_execution(
db: Session, execution: Execution
) -> Optional[Path]:
"""
根据已落库的执行记录补写对话 MD供 GET execution 等路径调用)。
仅 completed + 有 agent_id + 有 output_data 时写入;与 Celery 路径幂等。
"""
if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True):
return None
if not execution or execution.status != "completed":
return None
if not getattr(execution, "agent_id", None):
return None
od = execution.output_data
if od is None:
return None
wf: Dict[str, Any]
if isinstance(od, dict):
wf = od
else:
wf = {"result": od}
try:
return append_agent_dialogue_md(
db=db,
execution=execution,
input_data=execution.input_data if isinstance(execution.input_data, dict) else None,
workflow_result=wf,
)
except Exception as e:
logger.warning("从执行记录补写对话 MD 失败: %s", e)
return None
def try_append_agent_dialogue_after_success(
db: Session,
execution: Optional[Execution],
input_data: Optional[Dict[str, Any]],
workflow_result: Optional[Dict[str, Any]],
execution_logger: Any = None,
) -> None:
if not workflow_result:
return
try:
append_agent_dialogue_md(
db=db,
execution=execution,
input_data=input_data,
workflow_result=workflow_result,
)
except Exception as e:
msg = f"写入智能体对话 MD 异常: {e}"
logger.warning(msg)
if execution_logger:
execution_logger.warn(msg)