diff options
Diffstat (limited to 'source/net')
| -rw-r--r-- | source/net/yacy/ai/tools/ArtifactEnvelopeTool.java | 699 | ||||
| -rw-r--r-- | source/net/yacy/visualization/ArtifactPlotter.java | 584 |
2 files changed, 1283 insertions, 0 deletions
diff --git a/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java b/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java new file mode 100644 index 000000000..9842021fb --- /dev/null +++ b/source/net/yacy/ai/tools/ArtifactEnvelopeTool.java @@ -0,0 +1,699 @@ +/** + * ArtifactEnvelopeTool + * Copyright 2026 by Michael Peter Christen + * First released 11.02.2026 at https://yacy.net + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program in the file lgpl21.txt + * If not, see <http://www.gnu.org/licenses/>. + */ + +package net.yacy.ai.tools; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import net.yacy.ai.ToolHandler; + +/** + * Build a renderer-ready artifact envelope from structured context data. + * + * <h2>Introduction: idea and principle</h2> + * This tool bridges two worlds: + * <ul> + * <li><b>LLM/tool output</b>: plain structured JSON data extracted from conversation context.</li> + * <li><b>Frontend visualization</b>: a dedicated renderer that expects a normalized artifact envelope.</li> + * </ul> + * + * The central idea is to make visualization generation deterministic and transparent. + * Instead of asking the model to invent arbitrary UI markup, this tool converts known + * data into a stable envelope schema: + * <pre> + * { + * "kind": "artifact", + * "artifact_type": "chart|graph", + * "renderer": "...", + * "spec": { ... renderer-specific payload ... } + * } + * </pre> + * + * <h3>Design principles</h3> + * <ul> + * <li><b>Deterministic mapping</b>: same input structure yields same artifact shape.</li> + * <li><b>Schema-first output</b>: frontend can render without reinterpreting natural language.</li> + * <li><b>Heuristic, but explainable</b>: lightweight field inference for x/y/series or graph edges.</li> + * <li><b>Fail fast</b>: invalid or insufficient data returns explicit tool errors.</li> + * </ul> + * + * <h3>Transformation strategy</h3> + * <ol> + * <li>Normalize incoming {@code data} into a row array (JSON objects).</li> + * <li>Choose artifact mode: + * <ul> + * <li>Graph when input looks like an edge list or caller hints {@code graph}.</li> + * <li>Chart otherwise (default path).</li> + * </ul> + * </li> + * <li>Infer missing fields (x/y/series or source/target) from row content.</li> + * <li>Emit envelope with renderer-specific {@code spec}: + * <ul> + * <li>Vega-Lite for charts</li> + * <li>Cytoscape-style node/edge spec for graphs</li> + * </ul> + * </li> + * </ol> + */ +public class ArtifactEnvelopeTool implements ToolHandler { + + private static final String NAME = "artifact_envelope"; + private static final String VEGA_SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json"; + + @Override + public JSONObject definition() throws JSONException { + final JSONObject tool = new JSONObject(true); + tool.put("type", "function"); + final JSONObject fn = new JSONObject(true); + fn.put("name", NAME); + fn.put("description", "Transform structured context data into a graphical artifact envelope for chart or graph rendering."); + + final JSONObject params = new JSONObject(true); + params.put("type", "object"); + final JSONObject props = new JSONObject(true); + + props.put("data", new JSONObject(true) + .put("description", "Structured input data. Prefer array of row objects; object or JSON string also accepted.")); + props.put("artifact_hint", new JSONObject(true) + .put("type", "string") + .put("description", "Optional hint: auto, chart, or graph.")); + props.put("title", new JSONObject(true) + .put("type", "string") + .put("description", "Optional chart or graph title.")); + props.put("renderer", new JSONObject(true) + .put("type", "string") + .put("description", "Optional renderer override. Defaults: vega-lite for charts, cytoscape for graphs.")); + props.put("x_field", new JSONObject(true) + .put("type", "string") + .put("description", "Optional x axis field for chart artifacts.")); + props.put("y_field", new JSONObject(true) + .put("type", "string") + .put("description", "Optional y axis field for chart artifacts.")); + props.put("series_field", new JSONObject(true) + .put("type", "string") + .put("description", "Optional series/group field for chart color encoding.")); + props.put("source_field", new JSONObject(true) + .put("type", "string") + .put("description", "Optional source field for graph edge lists.")); + props.put("target_field", new JSONObject(true) + .put("type", "string") + .put("description", "Optional target field for graph edge lists.")); + props.put("chart_mark", new JSONObject(true) + .put("type", "string") + .put("description", "Optional Vega-Lite mark override: line, bar, point, area.")); + + params.put("properties", props); + params.put("required", new JSONArray().put("data")); + fn.put("parameters", params); + tool.put("function", fn); + return tool; + } + + @Override + public int maxCallsPerTurn() { + return 3; + } + + @Override + public String execute(final String arguments) { + // Parse tool-call arguments strictly once; all downstream logic works on this object. + final JSONObject args; + try { + args = (arguments == null || arguments.isEmpty()) ? new JSONObject(true) : new JSONObject(arguments); + } catch (final JSONException e) { + return ToolHandler.errorJson("Invalid arguments JSON"); + } + + // "data" is the only hard requirement: we cannot produce a visualization envelope without it. + final Object dataRaw = args.opt("data"); + if (dataRaw == null || dataRaw == JSONObject.NULL) return ToolHandler.errorJson("Missing data"); + + // Normalize arbitrary accepted inputs (array/object/stringified JSON) into row objects. + final JSONArray rows; + try { + rows = normalizeRows(dataRaw); + } catch (final JSONException e) { + return ToolHandler.errorJson("Failed to parse data: " + e.getMessage()); + } + if (rows.length() == 0) return ToolHandler.errorJson("No rows available after normalization"); + + // Optional hints allow caller control while preserving safe defaults. + final String artifactHint = normalizeHint(args.optString("artifact_hint", "auto")); + final String title = trimToNull(args.optString("title", null)); + final String rendererOverride = trimToNull(args.optString("renderer", null)); + + try { + // Artifact type choice: + // 1) explicit hint wins + // 2) otherwise detect graph-like edge-list structure + // 3) fallback is chart + if ("graph".equals(artifactHint) || isGraphLike(rows, args)) { + return buildGraphArtifact(rows, args, title, rendererOverride).toString(); + } + return buildChartArtifact(rows, args, title, rendererOverride).toString(); + } catch (final JSONException | IllegalArgumentException e) { + return ToolHandler.errorJson(e.getMessage() == null ? "Failed to build artifact envelope" : e.getMessage()); + } + } + + private static JSONObject buildChartArtifact(final JSONArray rows, final JSONObject args, + final String title, final String rendererOverride) throws JSONException { + // Clone rows so local transforms (such as adding synthetic index or grouping fallback) + // do not mutate caller-provided JSON structures. + JSONArray chartRows = cloneRows(rows); + + // Scan fields once to classify likely numeric/temporal/string roles. + final FieldProfile profile = inferFieldProfile(chartRows); + + // User-configured fields are respected only when they actually exist in data. + String xField = firstExistingField(args, chartRows, "x_field"); + String yField = firstExistingField(args, chartRows, "y_field"); + String seriesField = firstExistingField(args, chartRows, "series_field"); + + if (xField == null || yField == null) { + // Fill missing axes via deterministic heuristic (temporal->x, numeric->y, etc.). + final FieldChoice inferred = chooseChartFields(chartRows, profile, xField, yField); + if (xField == null) xField = inferred.xField; + if (yField == null) yField = inferred.yField; + } + + if (xField == null || yField == null) { + // Last-resort fallback: + // If we cannot derive meaningful x/y directly, aggregate category counts. + // This still produces a useful bar-chart-ready artifact for categorical data. + final String fallbackField = profile.firstStringField(); + if (fallbackField == null) throw new IllegalArgumentException("Could not infer chart axes from data"); + final JSONArray grouped = countByField(chartRows, fallbackField); + chartRows = grouped; + xField = "category"; + yField = "count"; + seriesField = null; + } + + if (seriesField == null) { + // Optional color grouping: only selected when a suitable low-cardinality string field exists. + seriesField = inferSeriesField(chartRows, xField, yField, profile); + } + + // Map internal field classification to Vega-Lite type system. + final String xType = vegaTypeForField(chartRows, xField, profile); + final String yType = vegaTypeForField(chartRows, yField, profile); + + // Select chart mark automatically unless caller overrides with a supported mark. + final String mark = chooseMark(args.optString("chart_mark", ""), xType, yType); + + // Build Vega-Lite encoding block. + final JSONObject encoding = new JSONObject(true); + encoding.put("x", axis(xField, xType)); + encoding.put("y", axis(yField, yType)); + if (seriesField != null && !seriesField.equals(xField) && !seriesField.equals(yField)) { + encoding.put("color", axis(seriesField, "nominal")); + } + + // Build renderer payload ("spec") and wrap it in the common artifact envelope. + final JSONObject spec = new JSONObject(true); + spec.put("$schema", VEGA_SCHEMA); + if (title != null) spec.put("title", title); + spec.put("mark", mark); + spec.put("encoding", encoding); + spec.put("data", new JSONObject(true).put("values", chartRows)); + + final JSONObject out = new JSONObject(true); + out.put("kind", "artifact"); + out.put("artifact_type", "chart"); + out.put("renderer", rendererOverride == null ? "vega-lite" : rendererOverride); + out.put("spec", spec); + return out; + } + + private static JSONObject buildGraphArtifact(final JSONArray rows, final JSONObject args, + final String title, final String rendererOverride) throws JSONException { + // Identify source/target edge fields from explicit arguments first, then common conventions. + final String sourceField = chooseEdgeField(args.optString("source_field", ""), rows, + new String[] { "source", "from", "src", "origin" }); + final String targetField = chooseEdgeField(args.optString("target_field", ""), rows, + new String[] { "target", "to", "dst", "destination" }); + if (sourceField == null || targetField == null) { + throw new IllegalArgumentException("Graph artifact requires source/target fields"); + } + + // Convert edge-list rows to a deduplicated node set + edge array. + // Output format is intentionally simple for frontend adapters. + final Set<String> seenNodes = new LinkedHashSet<>(); + final JSONArray nodes = new JSONArray(); + final JSONArray edges = new JSONArray(); + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + final String src = asNodeId(row.opt(sourceField)); + final String dst = asNodeId(row.opt(targetField)); + if (src == null || dst == null) continue; + if (seenNodes.add(src)) nodes.put(node(src)); + if (seenNodes.add(dst)) nodes.put(node(dst)); + final JSONObject edgeData = new JSONObject(true); + edgeData.put("id", "e" + i); + edgeData.put("source", src); + edgeData.put("target", dst); + if (row.has("weight")) edgeData.put("weight", row.opt("weight")); + edges.put(new JSONObject(true).put("data", edgeData)); + } + if (edges.length() == 0) throw new IllegalArgumentException("No valid edges found in data"); + + final JSONObject spec = new JSONObject(true); + if (title != null) spec.put("title", title); + spec.put("nodes", nodes); + spec.put("edges", edges); + + // Graph envelope payload; default renderer targets common graph frontend adapters. + final JSONObject out = new JSONObject(true); + out.put("kind", "artifact"); + out.put("artifact_type", "graph"); + out.put("renderer", rendererOverride == null ? "cytoscape" : rendererOverride); + out.put("spec", spec); + return out; + } + + private static JSONObject node(final String id) throws JSONException { + return new JSONObject(true).put("data", new JSONObject(true).put("id", id).put("label", id)); + } + + private static JSONObject axis(final String field, final String type) throws JSONException { + return new JSONObject(true).put("field", field).put("type", type); + } + + private static String chooseMark(final String override, final String xType, final String yType) { + // Respect explicit override when it is one of the marks we support safely. + final String normalized = trimToNull(override); + if (normalized != null) { + final String lower = normalized.toLowerCase(Locale.ROOT); + if ("line".equals(lower) || "bar".equals(lower) || "point".equals(lower) || "area".equals(lower)) return lower; + } + // Deterministic default heuristics by axis type. + if ("temporal".equals(xType) && "quantitative".equals(yType)) return "line"; + if ("nominal".equals(xType) && "quantitative".equals(yType)) return "bar"; + if ("quantitative".equals(xType) && "quantitative".equals(yType)) return "point"; + return "bar"; + } + + private static String normalizeHint(final String hint) { + final String value = trimToNull(hint); + if (value == null) return "auto"; + final String lower = value.toLowerCase(Locale.ROOT); + if ("chart".equals(lower) || "graph".equals(lower) || "auto".equals(lower)) return lower; + return "auto"; + } + + private static JSONArray normalizeRows(final Object dataRaw) throws JSONException { + // The tool accepts multiple shapes to be easy to call from other tools/LLM outputs: + // - array of objects + // - object containing values/rows arrays + // - plain object (single row) + // - JSON string representing any of the above + if (dataRaw instanceof JSONArray) return toRowObjects((JSONArray) dataRaw); + if (dataRaw instanceof JSONObject) return fromObject((JSONObject) dataRaw); + if (dataRaw instanceof String) { + final String text = ((String) dataRaw).trim(); + if (text.isEmpty()) return new JSONArray(); + final Object parsed = new JSONTokener(text).nextValue(); + if (parsed instanceof JSONArray) return toRowObjects((JSONArray) parsed); + if (parsed instanceof JSONObject) return fromObject((JSONObject) parsed); + throw new JSONException("String data is not a JSON object or array"); + } + return new JSONArray().put(new JSONObject(true).put("value", dataRaw)); + } + + private static JSONArray fromObject(final JSONObject obj) throws JSONException { + if (obj == null) return new JSONArray(); + final JSONArray values = obj.optJSONArray("values"); + if (values != null) return toRowObjects(values); + final JSONArray rows = obj.optJSONArray("rows"); + if (rows != null) return toRowObjects(rows); + return new JSONArray().put(obj); + } + + private static JSONArray toRowObjects(final JSONArray array) throws JSONException { + final JSONArray rows = new JSONArray(); + for (int i = 0; i < array.length(); i++) { + final Object v = array.opt(i); + if (v instanceof JSONObject) { + rows.put((JSONObject) v); + } else { + // Scalar array elements are wrapped to preserve position and value. + rows.put(new JSONObject(true).put("index", i).put("value", v)); + } + } + return rows; + } + + private static JSONArray cloneRows(final JSONArray in) throws JSONException { + final JSONArray out = new JSONArray(); + for (int i = 0; i < in.length(); i++) { + final JSONObject row = in.optJSONObject(i); + if (row == null) continue; + out.put(new JSONObject(row.toString())); + } + return out; + } + + private static boolean isGraphLike(final JSONArray rows, final JSONObject args) { + // Explicitly configured source+target implies graph intent. + final String configuredSource = trimToNull(args.optString("source_field", null)); + final String configuredTarget = trimToNull(args.optString("target_field", null)); + if (configuredSource != null && configuredTarget != null) return true; + + // Otherwise, treat data as graph when most rows look like edges. + int candidates = 0; + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + final String source = chooseEdgeField(configuredSource, row, new String[] { "source", "from", "src", "origin" }); + final String target = chooseEdgeField(configuredTarget, row, new String[] { "target", "to", "dst", "destination" }); + if (source != null && target != null) candidates++; + } + return candidates > 0 && candidates >= Math.max(1, rows.length() / 2); + } + + private static String chooseEdgeField(final String preferred, final JSONArray rows, final String[] fallbacks) { + if (preferred != null && preferred.length() > 0 && hasField(rows, preferred)) return preferred; + for (final String fallback : fallbacks) { + if (hasField(rows, fallback)) return fallback; + } + return null; + } + + private static String chooseEdgeField(final String preferred, final JSONObject row, final String[] fallbacks) { + if (row == null) return null; + if (preferred != null && preferred.length() > 0 && row.has(preferred)) return preferred; + for (final String fallback : fallbacks) { + if (row.has(fallback)) return fallback; + } + return null; + } + + private static boolean hasField(final JSONArray rows, final String field) { + if (field == null || field.isEmpty()) return false; + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row != null && row.has(field)) return true; + } + return false; + } + + private static String firstExistingField(final JSONObject args, final JSONArray rows, final String argumentName) { + final String field = trimToNull(args.optString(argumentName, null)); + if (field == null) return null; + return hasField(rows, field) ? field : null; + } + + private static String trimToNull(final String value) { + if (value == null) return null; + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String asNodeId(final Object value) { + if (value == null || value == JSONObject.NULL) return null; + final String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static FieldChoice chooseChartFields(final JSONArray rows, final FieldProfile profile, + final String configuredX, final String configuredY) throws JSONException { + String x = configuredX; + String y = configuredY; + + // Preferred order: + // x: temporal -> string -> numeric -> synthetic index + // y: numeric + if (x == null && !profile.temporalFields.isEmpty()) x = profile.temporalFields.get(0); + if (y == null) { + final String numeric = profile.firstNumericFieldExcluding(x); + if (numeric != null) y = numeric; + } + + if (x == null && y != null) { + final String candidate = profile.firstStringFieldExcluding(y); + if (candidate != null) x = candidate; + } + if (x == null) { + final String candidate = profile.firstNumericFieldExcluding(y); + if (candidate != null) x = candidate; + } + + if (x == null && y != null) { + // When only y can be found, add row index as synthetic x axis. + addSequentialIndex(rows); + x = "index"; + } + if (y == null && x != null) { + final String candidate = profile.firstNumericFieldExcluding(x); + if (candidate != null) y = candidate; + } + + return new FieldChoice(x, y); + } + + private static void addSequentialIndex(final JSONArray rows) throws JSONException { + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row != null && !row.has("index")) row.put("index", i); + } + } + + private static String inferSeriesField(final JSONArray rows, final String xField, + final String yField, final FieldProfile profile) { + final List<String> strings = profile.stringFields; + for (int i = 0; i < strings.size(); i++) { + final String candidate = strings.get(i); + if (candidate.equals(xField) || candidate.equals(yField)) continue; + // Keep chart readable by selecting only low-cardinality categories as series. + final int distinct = distinctValueCount(rows, candidate, 20); + if (distinct >= 2 && distinct <= 8) return candidate; + } + return null; + } + + private static String vegaTypeForField(final JSONArray rows, final String field, final FieldProfile profile) { + if (field == null) return "nominal"; + if ("index".equals(field)) return "quantitative"; + if (profile.temporalFields.contains(field)) return "temporal"; + if (profile.numericFields.contains(field)) return "quantitative"; + // Recheck values for late-added synthetic fields or uncertain classifications. + final int temporalVotes = countTemporalValues(rows, field); + final int numericVotes = countNumericValues(rows, field); + if (temporalVotes > 0 && temporalVotes >= numericVotes) return "temporal"; + if (numericVotes > 0) return "quantitative"; + return "nominal"; + } + + private static JSONArray countByField(final JSONArray rows, final String field) throws JSONException { + final Map<String, Integer> counts = new LinkedHashMap<>(); + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + final Object value = row.opt(field); + final String key = value == null || value == JSONObject.NULL ? "null" : String.valueOf(value); + final Integer current = counts.get(key); + counts.put(key, current == null ? 1 : current.intValue() + 1); + } + final JSONArray out = new JSONArray(); + for (final Map.Entry<String, Integer> entry : counts.entrySet()) { + out.put(new JSONObject(true).put("category", entry.getKey()).put("count", entry.getValue())); + } + return out; + } + + private static int distinctValueCount(final JSONArray rows, final String field, final int maxDistinct) { + final Set<String> distinct = new LinkedHashSet<>(); + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null || !row.has(field)) continue; + final Object v = row.opt(field); + if (v == null || v == JSONObject.NULL) continue; + distinct.add(String.valueOf(v)); + if (distinct.size() > maxDistinct) return distinct.size(); + } + return distinct.size(); + } + + private static int countTemporalValues(final JSONArray rows, final String field) { + int count = 0; + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + final Object value = row.opt(field); + if (looksTemporal(value)) count++; + } + return count; + } + + private static int countNumericValues(final JSONArray rows, final String field) { + int count = 0; + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + final Object value = row.opt(field); + if (asDouble(value) != null) count++; + } + return count; + } + + private static boolean looksTemporal(final Object value) { + if (value == null || value == JSONObject.NULL) return false; + if (value instanceof Number) { + // Numeric epoch-like values are treated as temporal candidates. + final double d = ((Number) value).doubleValue(); + return d > 1_000_000_000d && d < 9_999_999_999_999d; + } + final String s = String.valueOf(value).trim(); + if (s.isEmpty()) return false; + try { + Instant.parse(s); + return true; + } catch (final DateTimeParseException e) { + // next parser + } + try { + LocalDate.parse(s); + return true; + } catch (final DateTimeParseException e) { + return false; + } + } + + private static Double asDouble(final Object value) { + if (value == null || value == JSONObject.NULL) return null; + if (value instanceof Number) return Double.valueOf(((Number) value).doubleValue()); + final String text = String.valueOf(value).trim(); + if (text.isEmpty()) return null; + try { + return Double.valueOf(Double.parseDouble(text)); + } catch (final NumberFormatException e) { + return null; + } + } + + private static FieldProfile inferFieldProfile(final JSONArray rows) { + final Map<String, Integer> numericVotes = new LinkedHashMap<>(); + final Map<String, Integer> temporalVotes = new LinkedHashMap<>(); + final Map<String, Integer> stringVotes = new LinkedHashMap<>(); + final Map<String, Integer> presentVotes = new LinkedHashMap<>(); + + for (int i = 0; i < rows.length(); i++) { + final JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + for (final String key : row.keySet()) { + increment(presentVotes, key); + final Object value = row.opt(key); + if (value == null || value == JSONObject.NULL) continue; + if (looksTemporal(value)) { + increment(temporalVotes, key); + continue; + } + if (asDouble(value) != null) { + increment(numericVotes, key); + } else { + increment(stringVotes, key); + } + } + } + + final List<String> numeric = new ArrayList<>(); + final List<String> temporal = new ArrayList<>(); + final List<String> stringy = new ArrayList<>(); + + for (final Map.Entry<String, Integer> present : presentVotes.entrySet()) { + final String field = present.getKey(); + final int total = Math.max(1, present.getValue().intValue()); + final int temporalCount = temporalVotes.containsKey(field) ? temporalVotes.get(field).intValue() : 0; + final int numericCount = numericVotes.containsKey(field) ? numericVotes.get(field).intValue() : 0; + final int stringCount = stringVotes.containsKey(field) ? stringVotes.get(field).intValue() : 0; + if (temporalCount >= Math.max(1, (int) Math.ceil(total * 0.5d))) { + temporal.add(field); + } else if (numericCount >= Math.max(1, (int) Math.ceil(total * 0.5d))) { + numeric.add(field); + } else if (stringCount > 0) { + stringy.add(field); + } + } + return new FieldProfile(numeric, temporal, stringy); + } + + private static void increment(final Map<String, Integer> map, final String key) { + final Integer value = map.get(key); + map.put(key, value == null ? Integer.valueOf(1) : Integer.valueOf(value.intValue() + 1)); + } + + private static final class FieldChoice { + final String xField; + final String yField; + + FieldChoice(final String xField, final String yField) { + this.xField = xField; + this.yField = yField; + } + } + + private static final class FieldProfile { + final List<String> numericFields; + final List<String> temporalFields; + final List<String> stringFields; + + FieldProfile(final List<String> numericFields, final List<String> temporalFields, final List<String> stringFields) { + this.numericFields = numericFields == null ? new ArrayList<String>() : numericFields; + this.temporalFields = temporalFields == null ? new ArrayList<String>() : temporalFields; + this.stringFields = stringFields == null ? new ArrayList<String>() : stringFields; + } + + String firstNumericFieldExcluding(final String exclude) { + for (int i = 0; i < this.numericFields.size(); i++) { + final String field = this.numericFields.get(i); + if (exclude == null || !exclude.equals(field)) return field; + } + return null; + } + + String firstStringField() { + return this.stringFields.isEmpty() ? null : this.stringFields.get(0); + } + + String firstStringFieldExcluding(final String exclude) { + for (int i = 0; i < this.stringFields.size(); i++) { + final String field = this.stringFields.get(i); + if (exclude == null || !exclude.equals(field)) return field; + } + return null; + } + } +} diff --git a/source/net/yacy/visualization/ArtifactPlotter.java b/source/net/yacy/visualization/ArtifactPlotter.java new file mode 100644 index 000000000..c15cfe5db --- /dev/null +++ b/source/net/yacy/visualization/ArtifactPlotter.java @@ -0,0 +1,584 @@ +/** + * ArtifactPlotter + * Copyright 2026 by Michael Peter Christen + * First released 11.02.2026 at https://yacy.net + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program in the file lgpl21.txt + * If not, see <http://www.gnu.org/licenses/>. + */ + +package net.yacy.visualization; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +/** + * Renders artifact envelopes from ArtifactEnvelopeTool into YaCy raster images. + * <p> + * Supported envelope families: + * <ul> + * <li>chart artifacts (vega-lite style subset: mark, encoding, data.values)</li> + * <li>graph artifacts (spec.nodes/spec.edges using data.id/source/target)</li> + * </ul> + */ +public class ArtifactPlotter extends RasterPlotter { + + public static final int DEFAULT_WIDTH = 640; + public static final int DEFAULT_HEIGHT = 480; + + private static final long COLOR_BACKGROUND = 0xFFFFFFL; + private static final long COLOR_AXES = 0x202020L; + private static final long COLOR_GRID = 0xE6E6E6L; + private static final long COLOR_TEXT = 0x222222L; + private static final long COLOR_EDGE = 0x6C7A89L; + private static final long COLOR_NODE = 0x2878B5L; + private static final long COLOR_NODE_TEXT = 0x111111L; + + private static final long[] PALETTE = new long[] { + 0x1F77B4L, 0xFF7F0EL, 0x2CA02CL, 0xD62728L, 0x9467BDL, 0x8C564BL, + 0xE377C2L, 0x7F7F7FL, 0xBCBD22L, 0x17BECFL + }; + + private static final int MARGIN_LEFT = 64; + private static final int MARGIN_RIGHT = 24; + private static final int MARGIN_TOP = 48; + private static final int MARGIN_BOTTOM = 58; + + public ArtifactPlotter() { + this(DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public ArtifactPlotter(final int width, final int height) { + super(width, height, DrawMode.MODE_REPLACE, COLOR_BACKGROUND); + } + + public static ArtifactPlotter fromEnvelope(final String artifactEnvelopeJson) { + final ArtifactPlotter p = new ArtifactPlotter(); + p.paintEnvelope(artifactEnvelopeJson); + return p; + } + + public static ArtifactPlotter fromEnvelope(final JSONObject artifactEnvelope) { + final ArtifactPlotter p = new ArtifactPlotter(); + p.paintEnvelope(artifactEnvelope); + return p; + } + + public void paintEnvelope(final String artifactEnvelopeJson) { + if (artifactEnvelopeJson == null || artifactEnvelopeJson.trim().isEmpty()) { + drawError("empty artifact envelope"); + return; + } + try { + final Object parsed = new JSONTokener(artifactEnvelopeJson).nextValue(); + if (!(parsed instanceof JSONObject)) { + drawError("artifact envelope must be JSON object"); + return; + } + paintEnvelope((JSONObject) parsed); + } catch (final JSONException e) { + drawError("invalid JSON envelope"); + } + } + + public void paintEnvelope(final JSONObject artifactEnvelope) { + clear(); + if (artifactEnvelope == null) { + drawError("null artifact envelope"); + return; + } + final String kind = artifactEnvelope.optString("kind", ""); + if (!"artifact".equalsIgnoreCase(kind)) { + drawError("unsupported kind: " + kind); + return; + } + final String artifactType = artifactEnvelope.optString("artifact_type", "").toLowerCase(Locale.ROOT); + final JSONObject spec = artifactEnvelope.optJSONObject("spec"); + if (spec == null) { + drawError("missing spec"); + return; + } + + if ("chart".equals(artifactType)) { + paintChart(artifactEnvelope, spec); + return; + } + if ("graph".equals(artifactType)) { + paintGraph(artifactEnvelope, spec); + return; + } + drawError("unsupported artifact_type: " + artifactType); + } + + private void paintChart(final JSONObject artifact, final JSONObject spec) { + final JSONObject data = spec.optJSONObject("data"); + JSONArray values = data == null ? null : data.optJSONArray("values"); + if (values == null) values = spec.optJSONArray("values"); + if (values == null || values.length() == 0) { + drawError("chart has no data values"); + return; + } + + final JSONObject encoding = spec.optJSONObject("encoding"); + final JSONObject xEnc = encoding == null ? null : encoding.optJSONObject("x"); + final JSONObject yEnc = encoding == null ? null : encoding.optJSONObject("y"); + final JSONObject cEnc = encoding == null ? null : encoding.optJSONObject("color"); + + final String xField = xEnc == null ? null : trimToNull(xEnc.optString("field", null)); + final String yField = yEnc == null ? null : trimToNull(yEnc.optString("field", null)); + final String xType = normalizeType(xEnc == null ? null : xEnc.optString("type", "nominal")); + final String yType = normalizeType(yEnc == null ? null : yEnc.optString("type", "quantitative")); + final String colorField = cEnc == null ? null : trimToNull(cEnc.optString("field", null)); + final String mark = spec.optString("mark", "line").toLowerCase(Locale.ROOT); + + if (xField == null || yField == null) { + drawError("chart encoding requires x/y fields"); + return; + } + + final List<ChartPoint> points = new ArrayList<ChartPoint>(); + final Map<String, Integer> xCategories = new LinkedHashMap<String, Integer>(); + final Map<String, Integer> yCategories = new LinkedHashMap<String, Integer>(); + for (int i = 0; i < values.length(); i++) { + final JSONObject row = values.optJSONObject(i); + if (row == null) continue; + final ChartPoint p = pointFromRow(row, xField, xType, yField, yType, colorField, xCategories, yCategories); + if (p != null) points.add(p); + } + if (points.isEmpty()) { + drawError("chart data rows are not plottable"); + return; + } + + final int left = MARGIN_LEFT; + final int right = getWidth() - MARGIN_RIGHT; + final int top = MARGIN_TOP; + final int bottom = getHeight() - MARGIN_BOTTOM; + final int plotW = Math.max(1, right - left); + final int plotH = Math.max(1, bottom - top); + + final double minX = minX(points); + final double maxX = maxX(points); + final double minY = minY(points, "bar".equals(mark) || "area".equals(mark)); + final double maxY = maxY(points); + final double safeMaxY = (maxY <= minY) ? minY + 1.0d : maxY; + final double safeMaxX = (maxX <= minX) ? minX + 1.0d : maxX; + final int baselineY = mapY(0.0d < minY ? minY : 0.0d, minY, safeMaxY, top, bottom, plotH); + + drawChartScaffold(artifact, spec, xField, yField, left, right, top, bottom, minY, safeMaxY); + + final Map<String, Long> seriesColors = assignSeriesColors(points); + if ("line".equals(mark) || "area".equals(mark)) { + final Map<String, List<ChartPoint>> groups = groupBySeries(points); + for (final Map.Entry<String, List<ChartPoint>> entry : groups.entrySet()) { + final List<ChartPoint> series = entry.getValue(); + Collections.sort(series, new Comparator<ChartPoint>() { + @Override + public int compare(final ChartPoint a, final ChartPoint b) { + return Double.compare(a.x, b.x); + } + }); + final long color = seriesColors.get(entry.getKey()).longValue(); + for (int i = 1; i < series.size(); i++) { + final ChartPoint p0 = series.get(i - 1); + final ChartPoint p1 = series.get(i); + final int x0 = mapX(p0.x, minX, safeMaxX, left, plotW); + final int y0 = mapY(p0.y, minY, safeMaxY, top, bottom, plotH); + final int x1 = mapX(p1.x, minX, safeMaxX, left, plotW); + final int y1 = mapY(p1.y, minY, safeMaxY, top, bottom, plotH); + setColor(color); + line(x0, y0, x1, y1, 100); + if ("area".equals(mark)) { + line(x1, y1, x1, baselineY, 35); + } + } + for (int i = 0; i < series.size(); i++) { + final ChartPoint p = series.get(i); + final int x = mapX(p.x, minX, safeMaxX, left, plotW); + final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH); + setColor(color); + dot(x, y, 2, true, 100); + } + } + } else if ("point".equals(mark)) { + for (int i = 0; i < points.size(); i++) { + final ChartPoint p = points.get(i); + final int x = mapX(p.x, minX, safeMaxX, left, plotW); + final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH); + final long color = seriesColors.get(p.series).longValue(); + setColor(color); + dot(x, y, 3, true, 100); + } + } else { // bar as default fallback for unknown marks + final int count = Math.max(1, points.size()); + final int barWidth = Math.max(1, Math.min(22, (int) (plotW / (double) Math.max(2, count)))); + for (int i = 0; i < points.size(); i++) { + final ChartPoint p = points.get(i); + final int x = mapX(p.x, minX, safeMaxX, left, plotW); + final int y = mapY(p.y, minY, safeMaxY, top, bottom, plotH); + final long color = seriesColors.get(p.series).longValue(); + setColor(color); + for (int dx = -barWidth / 2; dx <= barWidth / 2; dx++) { + line(x + dx, baselineY, x + dx, y, 100); + } + } + } + + drawSeriesLegend(points, seriesColors, right, top + 6); + drawNominalTicks(xType, xCategories, left, top, bottom, plotW); + } + + private void paintGraph(final JSONObject artifact, final JSONObject spec) { + final JSONArray nodes = spec.optJSONArray("nodes"); + final JSONArray edges = spec.optJSONArray("edges"); + if (nodes == null || edges == null) { + drawError("graph requires nodes and edges"); + return; + } + + final Set<String> nodeIds = new LinkedHashSet<String>(); + for (int i = 0; i < nodes.length(); i++) { + final JSONObject node = nodes.optJSONObject(i); + if (node == null) continue; + final JSONObject data = node.optJSONObject("data"); + final String id = data == null ? null : trimToNull(data.optString("id", null)); + if (id != null) nodeIds.add(id); + } + for (int i = 0; i < edges.length(); i++) { + final JSONObject edge = edges.optJSONObject(i); + if (edge == null) continue; + final JSONObject data = edge.optJSONObject("data"); + if (data == null) continue; + final String source = trimToNull(data.optString("source", null)); + final String target = trimToNull(data.optString("target", null)); + if (source != null) nodeIds.add(source); + if (target != null) nodeIds.add(target); + } + if (nodeIds.isEmpty()) { + drawError("graph has no nodes"); + return; + } + + final int left = 30; + final int right = getWidth() - 30; + final int top = 40; + final int bottom = getHeight() - 24; + final int cx = (left + right) / 2; + final int cy = (top + bottom) / 2; + final int radius = Math.max(20, Math.min(right - left, bottom - top) / 2 - 26); + + final List<String> ordered = new ArrayList<String>(nodeIds); + Collections.sort(ordered); + final Map<String, NodePos> positions = new LinkedHashMap<String, NodePos>(); + for (int i = 0; i < ordered.size(); i++) { + final String id = ordered.get(i); + final double angle = (Math.PI * 2.0d * i) / Math.max(1, ordered.size()); + final int x = cx + (int) Math.round(Math.cos(angle) * radius); + final int y = cy + (int) Math.round(Math.sin(angle) * radius); + positions.put(id, new NodePos(x, y)); + } + + drawGraphHeader(artifact, spec); + setColor(COLOR_EDGE); + for (int i = 0; i < edges.length(); i++) { + final JSONObject edge = edges.optJSONObject(i); + if (edge == null) continue; + final JSONObject data = edge.optJSONObject("data"); + if (data == null) continue; + final String source = trimToNull(data.optString("source", null)); + final String target = trimToNull(data.optString("target", null)); + if (source == null || target == null) continue; + final NodePos from = positions.get(source); + final NodePos to = positions.get(target); + if (from == null || to == null) continue; + lineArrow(from.x, from.y, to.x, to.y, 6, 8, COLOR_EDGE, COLOR_EDGE); + } + + for (int i = 0; i < ordered.size(); i++) { + final String id = ordered.get(i); + final NodePos p = positions.get(id); + setColor(COLOR_NODE); + dot(p.x, p.y, 7, true, 100); + setColor(COLOR_NODE_TEXT); + PrintTool.print6(this, p.x, p.y + 16, 0, truncate(id, 14), 0, 90, false, false); + } + } + + private void drawChartScaffold(final JSONObject artifact, final JSONObject spec, final String xField, final String yField, + final int left, final int right, final int top, final int bottom, final double minY, final double maxY) { + final String title = trimToNull(spec.optString("title", null)); + setColor(COLOR_TEXT); + PrintTool.print6(this, getWidth() / 2, 14, 0, title == null ? "ARTIFACT CHART" : title.toUpperCase(Locale.ROOT), 0, 90, true, false); + + setColor(COLOR_GRID); + for (int i = 1; i < 6; i++) { + final int y = top + ((bottom - top) * i / 6); + line(left, y, right, y, 100); + } + + setColor(COLOR_AXES); + line(left, top, left, bottom, 100); + line(left, bottom, right, bottom, 100); + + setColor(COLOR_TEXT); + PrintTool.print6(this, left - 8, top - 2, 90, yField.toUpperCase(Locale.ROOT), 1, 80, false, false); + PrintTool.print6(this, (left + right) / 2, getHeight() - 12, 0, xField.toUpperCase(Locale.ROOT), 0, 80, false, false); + PrintTool.print6(this, left - 4, bottom + 12, 0, formatTick(minY), 1, 70, false, false); + PrintTool.print6(this, left - 4, top + 6, 0, formatTick(maxY), 1, 70, false, false); + } + + private void drawGraphHeader(final JSONObject artifact, final JSONObject spec) { + final String title = trimToNull(spec.optString("title", null)); + setColor(COLOR_TEXT); + PrintTool.print6(this, getWidth() / 2, 14, 0, title == null ? "ARTIFACT GRAPH" : title.toUpperCase(Locale.ROOT), 0, 90, true, false); + } + + private void drawSeriesLegend(final List<ChartPoint> points, final Map<String, Long> seriesColors, final int right, final int startY) { + if (seriesColors.size() <= 1) return; + int y = startY; + final List<String> keys = new ArrayList<String>(seriesColors.keySet()); + for (int i = 0; i < keys.size() && i < 8; i++) { + final String key = keys.get(i); + final long c = seriesColors.get(key).longValue(); + setColor(c); + dot(right - 6, y, 3, true, 100); + setColor(COLOR_TEXT); + PrintTool.print6(this, right - 12, y + 2, 0, truncate(key.toUpperCase(Locale.ROOT), 16), 1, 70, false, false); + y += 12; + } + } + + private void drawNominalTicks(final String xType, final Map<String, Integer> xCategories, + final int left, final int top, final int bottom, final int plotW) { + if (!"nominal".equals(xType) && !"ordinal".equals(xType)) return; + if (xCategories.isEmpty()) return; + final int n = xCategories.size(); + final int maxLabels = 8; + int i = 0; + for (final Map.Entry<String, Integer> entry : xCategories.entrySet()) { + if (n > maxLabels && i % Math.max(1, n / maxLabels) != 0) { + i++; + continue; + } + final double xValue = entry.getValue().doubleValue(); + final int x = left + (int) Math.round((xValue / Math.max(1.0d, n - 1.0d)) * plotW); + setColor(COLOR_AXES); + line(x, bottom, x, bottom + 4, 100); + setColor(COLOR_TEXT); + PrintTool.print6(this, x, bottom + 14, 0, truncate(entry.getKey().toUpperCase(Locale.ROOT), 8), 0, 60, false, false); + i++; + } + } + + private ChartPoint pointFromRow(final JSONObject row, final String xField, final String xType, final String yField, final String yType, + final String colorField, final Map<String, Integer> xCategories, final Map<String, Integer> yCategories) { + if (row == null) return null; + final Object xRaw = row.opt(xField); + final Object yRaw = row.opt(yField); + if (xRaw == null || xRaw == JSONObject.NULL || yRaw == null || yRaw == JSONObject.NULL) return null; + + final Double xValue = valueForType(xRaw, xType, xCategories); + final Double yValue = valueForType(yRaw, yType, yCategories); + if (xValue == null || yValue == null) return null; + + final String series; + if (colorField != null && row.has(colorField) && row.opt(colorField) != JSONObject.NULL) { + series = String.valueOf(row.opt(colorField)); + } else { + series = "_default"; + } + return new ChartPoint(xValue.doubleValue(), yValue.doubleValue(), series); + } + + private static Double valueForType(final Object raw, final String type, final Map<String, Integer> categories) { + if ("temporal".equals(type)) return parseTemporal(raw); + if ("quantitative".equals(type)) return parseDouble(raw); + if ("nominal".equals(type) || "ordinal".equals(type)) { + final String key = String.valueOf(raw); + Integer idx = categories.get(key); + if (idx == null) { + idx = Integer.valueOf(categories.size()); + categories.put(key, idx); + } + return Double.valueOf(idx.doubleValue()); + } + final Double d = parseDouble(raw); + if (d != null) return d; + return parseTemporal(raw); + } + + private static Double parseTemporal(final Object raw) { + if (raw == null || raw == JSONObject.NULL) return null; + if (raw instanceof Number) return Double.valueOf(((Number) raw).doubleValue()); + final String s = String.valueOf(raw).trim(); + if (s.isEmpty()) return null; + try { + return Double.valueOf(Instant.parse(s).toEpochMilli()); + } catch (final DateTimeParseException e) { + // continue + } + try { + return Double.valueOf(LocalDate.parse(s).toEpochDay()); + } catch (final DateTimeParseException e) { + return parseDouble(s); + } + } + + private static Double parseDouble(final Object raw) { + if (raw == null || raw == JSONObject.NULL) return null; + if (raw instanceof Number) return Double.valueOf(((Number) raw).doubleValue()); + final String s = String.valueOf(raw).trim(); + if (s.isEmpty()) return null; + try { + return Double.valueOf(Double.parseDouble(s)); + } catch (final NumberFormatException e) { + return null; + } + } + + private static String normalizeType(final String type) { + final String t = trimToNull(type); + if (t == null) return "nominal"; + final String lower = t.toLowerCase(Locale.ROOT); + if ("temporal".equals(lower) || "quantitative".equals(lower) || "nominal".equals(lower) || "ordinal".equals(lower)) return lower; + return "nominal"; + } + + private static String trimToNull(final String s) { + if (s == null) return null; + final String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static int mapX(final double value, final double minX, final double maxX, final int left, final int plotW) { + final double ratio = (value - minX) / Math.max(1e-12d, maxX - minX); + return left + (int) Math.round(ratio * plotW); + } + + private static int mapY(final double value, final double minY, final double maxY, final int top, final int bottom, final int plotH) { + final double ratio = (value - minY) / Math.max(1e-12d, maxY - minY); + return bottom - (int) Math.round(ratio * plotH); + } + + private static double minX(final List<ChartPoint> points) { + double min = Double.POSITIVE_INFINITY; + for (int i = 0; i < points.size(); i++) min = Math.min(min, points.get(i).x); + return min; + } + + private static double maxX(final List<ChartPoint> points) { + double max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < points.size(); i++) max = Math.max(max, points.get(i).x); + return max; + } + + private static double minY(final List<ChartPoint> points, final boolean includeZeroBaseline) { + double min = Double.POSITIVE_INFINITY; + for (int i = 0; i < points.size(); i++) min = Math.min(min, points.get(i).y); + if (includeZeroBaseline) min = Math.min(0.0d, min); + return min; + } + + private static double maxY(final List<ChartPoint> points) { + double max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < points.size(); i++) max = Math.max(max, points.get(i).y); + return max; + } + + private static Map<String, List<ChartPoint>> groupBySeries(final List<ChartPoint> points) { + final Map<String, List<ChartPoint>> grouped = new LinkedHashMap<String, List<ChartPoint>>(); + for (int i = 0; i < points.size(); i++) { + final ChartPoint p = points.get(i); + List<ChartPoint> list = grouped.get(p.series); + if (list == null) { + list = new ArrayList<ChartPoint>(); + grouped.put(p.series, list); + } + list.add(p); + } + return grouped; + } + + private static Map<String, Long> assignSeriesColors(final List<ChartPoint> points) { + final Map<String, Long> colors = new LinkedHashMap<String, Long>(); + int i = 0; + for (int p = 0; p < points.size(); p++) { + final String key = points.get(p).series; + if (!colors.containsKey(key)) { + colors.put(key, Long.valueOf(PALETTE[i % PALETTE.length])); + i++; + } + } + if (colors.isEmpty()) colors.put("_default", Long.valueOf(PALETTE[0])); + return colors; + } + + private void drawError(final String message) { + clear(); + setColor(0xBB2222L); + PrintTool.print6(this, getWidth() / 2, getHeight() / 2 - 6, 0, "ARTIFACT RENDER ERROR", 0, 100, true, false); + setColor(0x222222L); + PrintTool.print6(this, getWidth() / 2, getHeight() / 2 + 10, 0, truncate(message == null ? "unknown error" : message, 50).toUpperCase(Locale.ROOT), 0, 80, false, false); + } + + private static String truncate(final String s, final int maxLen) { + if (s == null) return ""; + if (s.length() <= maxLen) return s; + if (maxLen <= 3) return s.substring(0, Math.max(0, maxLen)); + return s.substring(0, maxLen - 3) + "..."; + } + + private static String formatTick(final double v) { + if (Math.abs(v) >= 1000.0d || Math.abs(v) < 0.01d) return String.format(Locale.ROOT, "%.2e", v); + if (Math.abs(v - Math.rint(v)) < 1e-9d) return String.valueOf((long) Math.rint(v)); + return String.format(Locale.ROOT, "%.2f", v); + } + + private static final class ChartPoint { + final double x; + final double y; + final String series; + + ChartPoint(final double x, final double y, final String series) { + this.x = x; + this.y = y; + this.series = series == null ? "_default" : series; + } + } + + private static final class NodePos { + final int x; + final int y; + + NodePos(final int x, final int y) { + this.x = x; + this.y = y; + } + } +} |
