|
@@ -5,6 +5,7 @@ import com.zsjz.ai.common.context.CaseContextHolder;
|
|
|
import com.zsjz.ai.common.exception.ServerException;
|
|
import com.zsjz.ai.common.exception.ServerException;
|
|
|
import com.zsjz.ai.common.utils.FieldLabels;
|
|
import com.zsjz.ai.common.utils.FieldLabels;
|
|
|
import com.zsjz.ai.module.agent.config.ArtifactProperties;
|
|
import com.zsjz.ai.module.agent.config.ArtifactProperties;
|
|
|
|
|
+import io.agentscope.core.tool.Tool;
|
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.RequiredArgsConstructor;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.apache.commons.csv.CSVFormat;
|
|
import org.apache.commons.csv.CSVFormat;
|
|
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service;
|
|
|
import java.io.BufferedReader;
|
|
import java.io.BufferedReader;
|
|
|
import java.io.IOException;
|
|
import java.io.IOException;
|
|
|
import java.io.Reader;
|
|
import java.io.Reader;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Files;
|
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Path;
|
|
@@ -39,7 +41,7 @@ import java.util.stream.Stream;
|
|
|
* 回给模型与前端的只有 {@code artifact} 元数据(id/行数/大小/列名)。
|
|
* 回给模型与前端的只有 {@code artifact} 元数据(id/行数/大小/列名)。
|
|
|
*
|
|
*
|
|
|
* <h3>目录与安全</h3>
|
|
* <h3>目录与安全</h3>
|
|
|
- * 落盘位置 {@code QingJian/workspace/{caseId}/artifacts/{tool}_{ts}_{uuid8}.csv},
|
|
|
|
|
|
|
+ * 落盘位置 {@code QingJian/workspace/{caseId}/artifacts/{domain}_{tool}_{ts}_{uuid8}.csv},
|
|
|
* 与 Python 沙箱产物同级({@code workspace/{caseId}/py-out});路径解析统一走
|
|
* 与 Python 沙箱产物同级({@code workspace/{caseId}/py-out});路径解析统一走
|
|
|
* {@link #resolve(String)}:id 只允许安全字符,归一化后必须仍落在当前案件的
|
|
* {@link #resolve(String)}:id 只允许安全字符,归一化后必须仍落在当前案件的
|
|
|
* artifacts 根目录内,跨案件与 {@code ../} 穿越都按 404 处理
|
|
* artifacts 根目录内,跨案件与 {@code ../} 穿越都按 404 处理
|
|
@@ -365,25 +367,80 @@ public class ArtifactService {
|
|
|
private record Names(String id, String fileName) {
|
|
private record Names(String id, String fileName) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 文件名 = {工具}_{时间戳}_{8位随机}.csv */
|
|
|
|
|
|
|
+ /** 文件名 = {工具域}_{具体工具名}_{时间戳}_{8位随机}.csv */
|
|
|
private Names buildNames(String toolName) {
|
|
private Names buildNames(String toolName) {
|
|
|
|
|
+ String domain = safeToolPart(toolName, "query");
|
|
|
|
|
+ String specific = currentToolName();
|
|
|
|
|
+ String specificSafe = safeToolPart(specific, null);
|
|
|
|
|
+ // execute_sql / query_table_data 这类工具:域标识本身就是具体工具名,避免重复
|
|
|
|
|
+ boolean appendSpecific = specificSafe != null && !specificSafe.equalsIgnoreCase(domain);
|
|
|
|
|
+
|
|
|
|
|
+ String uuid = UUID.randomUUID().toString().replace("-", "");
|
|
|
|
|
+ String suffix = "_" + LocalDateTime.now().format(ID_TS) + "_"
|
|
|
|
|
+ + uuid.substring(0, 8) + ".csv";
|
|
|
|
|
+ String idBase = appendSpecific ? domain + "_" + specificSafe : domain;
|
|
|
|
|
+ String displayBase = artifactLabel(toolName);
|
|
|
|
|
+ if (appendSpecific) {
|
|
|
|
|
+ displayBase = displayBase + "_" + specificSafe;
|
|
|
|
|
+ }
|
|
|
|
|
+ return new Names(idBase + suffix, displayBase + suffix);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 当前正在执行的 {@code @Tool} 名称。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>CSV 是工具结果的一部分,文件名只带工具域(call_analysis / trans_analysis)时,
|
|
|
|
|
+ * 同一域下多个工具产出的文件无法区分。这里沿调用栈找最近的 {@code @Tool} 方法:
|
|
|
|
|
+ * {@code @Tool 方法 → ToolResultTable.store → ArtifactService.export → buildNames}。
|
|
|
|
|
+ * 找不到(例如单元测试直接调用 export)时返回 {@code null},文件名退回「域标识」口径。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String currentToolName() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
|
|
|
|
+ .walk(frames -> frames
|
|
|
|
|
+ .limit(16)
|
|
|
|
|
+ .map(ArtifactService::toolNameOf)
|
|
|
|
|
+ .filter(name -> name != null && !name.isBlank())
|
|
|
|
|
+ .findFirst()
|
|
|
|
|
+ .orElse(null));
|
|
|
|
|
+ } catch (Throwable e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从栈帧的「类 + 方法名」找到 @Tool 注解名;重载方法逐个匹配 */
|
|
|
|
|
+ private static String toolNameOf(StackWalker.StackFrame frame) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ for (Method method : frame.getDeclaringClass().getDeclaredMethods()) {
|
|
|
|
|
+ if (!method.getName().equals(frame.getMethodName())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Tool tool = method.getAnnotation(Tool.class);
|
|
|
|
|
+ if (tool != null) {
|
|
|
|
|
+ return tool.name();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Throwable ignored) {
|
|
|
|
|
+ // 反射不可用/类不可见时退回域标识
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 工具名清洗:只保留字母/数字/下划线/连字符,最长 40 字符;空时用 fallback */
|
|
|
|
|
+ private static String safeToolPart(String toolName, String fallback) {
|
|
|
|
|
+ if (toolName == null) {
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
StringBuilder safe = new StringBuilder();
|
|
StringBuilder safe = new StringBuilder();
|
|
|
- String name = toolName == null ? "query" : toolName.trim().toLowerCase(Locale.ROOT);
|
|
|
|
|
- for (int i = 0; i < name.length() && safe.length() < 40; i++) {
|
|
|
|
|
- char c = name.charAt(i);
|
|
|
|
|
|
|
+ for (int i = 0; i < toolName.length() && safe.length() < 40; i++) {
|
|
|
|
|
+ char c = toolName.charAt(i);
|
|
|
boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
|
|| (c >= '0' && c <= '9') || c == '_' || c == '-';
|
|
|| (c >= '0' && c <= '9') || c == '_' || c == '-';
|
|
|
if (ok) {
|
|
if (ok) {
|
|
|
safe.append(c);
|
|
safe.append(c);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- if (safe.isEmpty()) {
|
|
|
|
|
- safe.append("query");
|
|
|
|
|
- }
|
|
|
|
|
- String uuid = UUID.randomUUID().toString().replace("-", "");
|
|
|
|
|
- String suffix = "_" + LocalDateTime.now().format(ID_TS) + "_"
|
|
|
|
|
- + uuid.substring(0, 8) + ".csv";
|
|
|
|
|
- return new Names(safe + suffix, artifactLabel(toolName) + suffix);
|
|
|
|
|
|
|
+ return safe.isEmpty() ? fallback : safe.toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|