Files
2026-05-16 14:21:50 +02:00

700 lines
31 KiB
Java

/**
* 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;
}
}
}