bamaer commented on code in PR #8784:
URL: https://github.com/apache/hop/pull/8784#discussion_r4210614772
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java:
##########
@@ -128,7 +483,10 @@ static AiAdvisorRequest toRequest(
request.setAttributes(copyAttributes(session.getAttributes()));
request.setInclusionSelections(copyInclusionSelections(session.getInclusionSelections()));
request.setFollowUp(hasSuccessfulPriorTurn(session));
-
request.setAppliedChangeSummaries(session.consumePendingAppliedSummaries());
+ request.setAppliedChangeSummaries(
+ consumeApplied
+ ? session.consumePendingAppliedSummaries()
Review Comment:
Fixed. The request now gets a copy of the applied changes. They are taken
off the session only when an answer is recorded, and only the ones that
question was sent with, so changes applied while it waits stay for the next
question.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java:
##########
@@ -947,58 +1501,101 @@ private void send() {
}
HopAiConfig config = HopAiConfigSingleton.getConfig();
if (!config.isAiEnabled()) {
- wlStatus.setText(BaseMessages.getString(PKG,
"AiAdvisor.Status.Disabled"));
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
return;
}
String prompt = wPrompt.getText();
- if (Utils.isEmpty(prompt)) {
+ if (Utils.isEmpty(prompt) || prompt.isBlank()) {
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.NoQuestion"));
+ wPrompt.setFocus();
return;
}
IAiAdvisor advisor = loadSelectedAdvisor();
if (advisor == null) {
- wlStatus.setText(BaseMessages.getString(PKG,
"AiAdvisor.Status.NoAdvisor"));
+ setStatus(noAdvisorMessage());
+ return;
+ }
+ if (!providerExists(wProvider.getText())) {
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ wProvider.getItemCount() == 0
+ ? "AiAdvisor.Status.NoProviderDefined"
+ : "AiAdvisor.Status.NoProviderSelected"));
return;
}
session.setAdvisorPluginId(selectedAdvisorId());
session.setScenarioId(selectedScenarioId());
session.setProviderName(wProvider.getText());
+ includeLogAfterARun();
for (Map.Entry<String, Button> entry : inclusionButtons.entrySet()) {
- session.getInclusions().put(entry.getKey(),
entry.getValue().getSelection());
+ // A blocked option keeps the user's choice for when the configuration
allows it again.
+ if (!isBlockedByConfig(entry.getKey())) {
+ session.getInclusions().put(entry.getKey(),
entry.getValue().getSelection());
+ }
}
AiAdvisorTurn turn = new AiAdvisorTurn();
turn.setUserPrompt(prompt);
+ turn.setStartedAtMillis(System.currentTimeMillis());
session.addTurn(turn);
session.setCancelled(false);
session.setWorking(true);
- session.setStatusMessage(BaseMessages.getString(PKG,
"AiAdvisor.Status.Working"));
+ // The transcript shows a live waiting line; the status line is for
problems.
+ session.setStatusMessage("");
wPrompt.setText("");
updateSendButton();
- wlStatus.setText(session.getStatusMessage());
+ setStatus(session.getStatusMessage());
transcript.showSession(session, this::reviewProposalsForTurn);
store.fireChanged();
AiAdvisorSession target = session;
- // Log widgets are SWT; read them on this UI thread before the background
worker starts.
- final String logExcerpt =
- target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+ sentFromHere.add(target);
+ // Build the question here on the UI thread: the log widgets are SWT, and
the pipeline or
+ // workflow may be edited while the model works. Only the model call runs
in the background.
+ AiAdvisorEngine.Prepared prepared;
+ try {
+ String logExcerpt = target.getLogSupplier() != null ?
target.getLogSupplier().get() : null;
+ prepared =
+ AiAdvisorEngine.prepare(
+ target, advisor, host.getVariables(),
host.getMetadataProvider(), logExcerpt);
+ } catch (Exception e) {
+ completeTurn(target, turn, null, e);
+ return;
+ }
+ turn.setEstimatedPromptTokens(prepared.estimatedTokens());
+ turn.setProviderLabel(prepared.provider().getName());
+ startWorkingLine(target);
+ IVariables variables = host.getVariables();
+ Display display = getDisplay();
Thread worker =
BackgroundThreadFacade.start(
() -> {
+ AiAdvisorResponse response = null;
+ Throwable error = null;
try {
- AiAdvisorResponse response =
- AiAdvisorEngine.advise(
- target,
- advisor,
- host.getVariables(),
- host.getMetadataProvider(),
- logExcerpt);
- host.asyncExec(() -> completeTurn(target, turn, response,
null));
+ response = AiAdvisorEngine.execute(target, advisor, variables,
prepared);
} catch (Throwable t) {
if (t instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
- host.asyncExec(() -> completeTurn(target, turn, null, t));
+ error = t;
+ }
+ AiAdvisorResponse result = response;
+ Throwable failure = error;
+ Thread self = Thread.currentThread();
+ // Record the answer even when this window was closed in the
meantime (unless the
+ // configuration cancels it on close): the session is shared
with the other views
+ // and would otherwise stay "working" for good.
+ if (!display.isDisposed()) {
Review Comment:
Agreed the option promised too much, but recording from the worker can't
help here: `HopGui.main` calls `System.exit` as soon as `open()` returns, so
the worker is killed. I dropped "or Hop GUI" from the tooltip and docs, which
now say exiting always stops a waiting question. The session store marks that
question as stopped before the final save on exit, so after a restart it says
so instead of sitting unanswered.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java:
##########
@@ -177,43 +222,75 @@ private static AiProposalValidation
validateAddPipelineHop(
String fromName = proposal.parameter("fromTransform");
String toName = proposal.parameter("toTransform");
if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
- return blocked(proposal, "fromTransform and toTransform are required");
+ return blocked(
+ proposal, BaseMessages.getString(PKG,
"PipelineAiProposalValidator.HopEndsRequired"));
}
if (!transformExists(pipelineMeta, fromName, reservedNames)) {
- return blocked(proposal, "From transform not found: " + fromName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(
+ PKG, "PipelineAiProposalValidator.FromTransformNotFound",
fromName));
}
if (!transformExists(pipelineMeta, toName, reservedNames)) {
- return blocked(proposal, "To transform not found: " + toName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(PKG,
"PipelineAiProposalValidator.ToTransformNotFound", toName));
}
TransformMeta from = pipelineMeta.findTransform(fromName);
TransformMeta to = pipelineMeta.findTransform(toName);
if (fromName.trim().equals(toName.trim())) {
- return blocked(proposal, "Hop cannot connect a transform to itself");
- }
+ return blocked(
+ proposal, BaseMessages.getString(PKG,
"PipelineAiProposalValidator.HopToItself"));
+ }
+ // A pipeline cannot loop: the reverse hop, already there or proposed
earlier, would. Not when
+ // the batch deletes it.
+ if ((from != null
Review Comment:
Fixed. The validator follows the hops as they will be once the batch is
applied: existing hops minus the deleted ones, with the batch's renames and
transform deletes, plus the hops added earlier in the batch. It blocks a loop
of any length and names the path in the message, for example `C -> A -> B ->
C`. Names are trimmed. Tests cover a loop through three transforms and one
through a renamed transform.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java:
##########
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.engine;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.util.ReflectionUtil;
+
+/**
+ * The settings of a transform or action, as compact JSON for a prompt.
+ *
+ * <p>Reads the {@code @HopMetadataProperty} fields: SQL, file names,
connection names, field lists
+ * and so on, which is what a model needs to explain what a pipeline or
workflow does. Password
+ * fields are skipped. Empty values, long texts and long lists are left out or
shortened so one
+ * transform cannot take over the prompt.
+ */
+public final class AiNodeSettings {
+
+ static final int MAX_DEPTH = 3;
+ static final int MAX_LIST_ITEMS = 25;
+ static final int MAX_TEXT_CHARS = 400;
+ static final int MAX_NODE_CHARS = 3_000;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiNodeSettings() {}
+
+ /**
+ * @param node the transform or action meta
+ * @return a JSON object, {@code {"truncated":true}} when the settings are
too large, or null when
+ * the plugin exposes no settings this way
+ */
+ public static String toJson(Object node) {
+ if (node == null) {
+ return null;
+ }
+ Object settings = read(node, 0);
+ if (!(settings instanceof Map<?, ?> map) || map.isEmpty()) {
+ return null;
+ }
+ try {
+ String json = MAPPER.writeValueAsString(settings);
+ if (json.length() > MAX_NODE_CHARS) {
+ return "{\"truncated\":true}";
+ }
+ return AiTextUtil.redactSecrets(json);
+ } catch (JsonProcessingException e) {
+ return null;
+ }
+ }
+
+ static Object read(Object value, int depth) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ return text.length() > MAX_TEXT_CHARS ? text.substring(0,
MAX_TEXT_CHARS) + "…" : text;
+ }
+ if (value instanceof Number || value instanceof Boolean) {
+ return value;
+ }
+ if (value instanceof Enum<?> e) {
+ return e.name();
+ }
+ if (depth >= MAX_DEPTH) {
+ return null;
+ }
+ if (value instanceof Collection<?> collection) {
+ return readList(collection, depth);
+ }
+ if (value.getClass().isArray() && value instanceof Object[] array) {
+ return readList(List.of(array), depth);
+ }
+ return readObject(value, depth);
+ }
+
+ private static List<Object> readList(Collection<?> collection, int depth) {
+ List<Object> list = new ArrayList<>();
+ int count = 0;
+ for (Object item : collection) {
+ if (count == MAX_LIST_ITEMS) {
+ list.add("… " + (collection.size() - MAX_LIST_ITEMS) + " more");
+ break;
+ }
+ Object read = read(item, depth + 1);
+ if (read != null) {
+ list.add(read);
+ }
+ count++;
+ }
+ return list.isEmpty() ? null : list;
+ }
+
+ private static Map<String, Object> readObject(Object object, int depth) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (Field field : ReflectionUtil.findAllFields(object.getClass())) {
+ HopMetadataProperty property =
field.getAnnotation(HopMetadataProperty.class);
+ if (property == null || property.password()) {
+ continue;
+ }
+ Object fieldValue;
+ try {
+ fieldValue =
+ ReflectionUtil.getFieldValue(object, field.getName(),
isBoolean(field.getType()));
+ } catch (Exception e) {
+ continue;
+ }
+ Object read = read(fieldValue, depth + 1);
+ if (read == null || Boolean.FALSE.equals(read)) {
Review Comment:
Fixed. `false` is kept; only null and empty values are skipped.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.session;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.ai.advisor.AiAdvisorMetadataSelection;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.engine.AiMetadataBackup;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+
+/**
+ * Keeps the AI Assistant sessions of a project between Hop GUI runs, in the
audit folder ({@code
+ * HOP_AUDIT_FOLDER}), where Hop GUI also remembers the open files of each
project.
+ *
+ * <p>Questions, answers, proposals and the session options are kept. The
context that was sent with
+ * a question is not: it is gathered again from the open file. The pipeline or
workflow itself is
+ * found again by its file name when it is opened.
+ */
+public final class AiAdvisorSessionArchive {
+
+ static final String AUDIT_TYPE = "ai-assistant";
+ static final String AUDIT_NAME = "sessions";
+ static final String DEFAULT_GROUP = "hop-gui";
+
+ /** The newest sessions per project that are kept; older ones are dropped
when saving. */
+ static final int MAX_SESSIONS = 50;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiAdvisorSessionArchive() {}
+
+ public static void save(String scope, List<AiAdvisorSession> sessions)
throws HopException {
+ List<AiAdvisorSession> kept =
+ sessions.size() > MAX_SESSIONS
+ ? sessions.subList(sessions.size() - MAX_SESSIONS, sessions.size())
+ : sessions;
+ List<Map<String, Object>> list = new ArrayList<>();
+ for (AiAdvisorSession session : kept) {
+ list.add(toMap(session));
+ }
+ Map<String, Object> state = new LinkedHashMap<>();
+ try {
+ state.put("json", MAPPER.writeValueAsString(list));
+ } catch (Exception e) {
+ throw new HopException("Unable to write the AI Assistant sessions", e);
+ }
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new AuditState(AUDIT_NAME,
state));
+ }
+
+ public static List<AiAdvisorSession> load(String scope) throws HopException {
+ List<AiAdvisorSession> sessions = new ArrayList<>();
+ AuditState state = AuditManager.getActive().retrieveState(group(scope),
AUDIT_TYPE, AUDIT_NAME);
+ if (state == null || state.getStateMap() == null) {
+ return sessions;
+ }
+ Object json = state.getStateMap().get("json");
+ if (!(json instanceof String text) || Utils.isEmpty(text)) {
+ return sessions;
+ }
+ try {
+ List<Map<String, Object>> list =
+ MAPPER.readValue(text, new TypeReference<List<Map<String,
Object>>>() {});
+ for (Map<String, Object> map : list) {
+ AiAdvisorSession session = fromMap(map);
+ session.setScope(scope);
+ sessions.add(session);
+ }
+ } catch (Exception e) {
+ throw new HopException("Unable to read the saved AI Assistant sessions",
e);
+ }
+ return sessions;
+ }
+
+ static final String LAST_PROVIDER_NAME = "last-provider";
+
+ /**
+ * Remember the provider picked last in a project, for new sessions when no
default provider is
+ * configured. Kept even when conversations are not: it holds only a name.
+ */
+ public static void saveLastProvider(String scope, String providerName)
throws HopException {
+ Map<String, Object> state = new LinkedHashMap<>();
+ state.put("name", providerName == null ? "" : providerName);
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new
AuditState(LAST_PROVIDER_NAME, state));
+ }
+
+ /** The provider picked last in a project, or null when none was remembered.
*/
+ public static String loadLastProvider(String scope) throws HopException {
+ AuditState state =
+ AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE,
LAST_PROVIDER_NAME);
+ if (state == null
+ || state.getStateMap() == null
+ || !(state.getStateMap().get("name") instanceof String name)
+ || Utils.isEmpty(name)) {
+ return null;
+ }
+ return name;
+ }
+
+ static String group(String scope) {
+ return Utils.isEmpty(scope) ? DEFAULT_GROUP : scope;
+ }
+
+ static Map<String, Object> toMap(AiAdvisorSession session) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("title", session.getTitle());
+ map.put("advisorPluginId", session.getAdvisorPluginId());
+ map.put("scenarioId", session.getScenarioId());
+ map.put("providerName", session.getProviderName());
+ map.put("location", session.getLocation());
+ map.put("areaLabel", session.getAreaLabel());
+ map.put("artifactName", session.getArtifactName());
+ map.put("artifactKind", session.getArtifactKind());
+ map.put("artifactFilename", session.getArtifactFilename());
+ map.put("focusNodeName", session.getFocusNodeName());
+ map.put("inclusions", new LinkedHashMap<>(session.getInclusions()));
+ map.put("userChosenInclusions", new
ArrayList<>(session.getUserChosenInclusions()));
+ List<Map<String, String>> selections = new ArrayList<>();
+ for (AiAdvisorMetadataSelection selection :
session.getMetadataSelections()) {
+ Map<String, String> entry = new LinkedHashMap<>();
+ entry.put("typeKey", selection.getTypeKey());
+ entry.put("name", selection.getName());
+ selections.add(entry);
+ }
+ map.put("metadataSelections", selections);
+ map.put("inclusionSelections", new
LinkedHashMap<>(session.getInclusionSelections()));
+ List<Map<String, Object>> turns = new ArrayList<>();
+ for (AiAdvisorTurn turn : session.getTurns()) {
+ turns.add(toMap(turn));
+ }
+ map.put("turns", turns);
+ return map;
Review Comment:
Fixed. The pending list is saved with the session and restored on load, and
the next question uses it in the same way.
##########
plugins/tech/ai/src/main/resources/org/apache/hop/ai/config/messages/messages_en_US.properties:
##########
@@ -17,15 +17,22 @@
HopAiConfig.Tab.Name=AI Assistant
HopAiConfigOptionPlugin.GitWarning.Label=Use Git (or similar version control)
and create a feature branch before applying AI-driven changes.\nYou can then
revert the whole project, or selected files, if things do not go as intended.
HopAiConfigOptionPlugin.GitWarning.Tooltip=Graph Undo only reverses some
canvas edits. Metadata saves and pasted transforms or actions are not undone
that way. A feature branch is the reliable rollback.
-HopAiConfigOptionPlugin.AiEnabled.Label=Enable AI advisory
-HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, AI Help is hidden and
no prompts are sent. Default is off. Before applying AI changes, work on a Git
feature branch so you can revert the project if needed.
+HopAiConfigOptionPlugin.AiEnabled.Label=Enable the AI Assistant
+HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, the AI Assistant is
hidden and no prompts are sent. Default is off. Before applying AI changes,
work on a Git feature branch so you can revert the project if needed.
HopAiConfigOptionPlugin.DefaultProvider.Label=Default AI provider
-HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata
used by GUI advisors. Pipelines can still pick a different provider.
+HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata
used by the AI Assistant. Pipelines can still pick a different provider.
HopAiConfigOptionPlugin.AllowFullXml.Label=Allow sending full XML
HopAiConfigOptionPlugin.AllowFullXml.Tooltip=Second gate for the session
"Include full XML" checkbox. Off by default. Full XML is large and can contain
SQL, filenames and notes. Leave this off unless you need it, and only after
secrets live in environment variables or a keystore.
HopAiConfigOptionPlugin.ExtraContext.Label=Extra context
HopAiConfigOptionPlugin.ExtraContext.Tooltip=Standing notes sent with every AI
Assistant question. The default reminds the model that metadata names are
case-sensitive (`local` is not `Local`), that pipeline and workflow run
configurations are different types, and that Hop is not Kettle. Add project
conventions here.
HopAiConfigOptionPlugin.ExtraContextFiles.Label=Context files
-HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files
that exist are appended to the system prompt; missing files are skipped.
Default is '${PROJECT_HOME}/AGENTS.md'. Variables are resolved.
+HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files
that exist are appended to the system prompt as instructions the assistant
follows; missing files are skipped. Variables are resolved. Empty by default.
Only list files you wrote for the AI Assistant, for example
'${PROJECT_HOME}/ai-assistant.md': an AGENTS.md is usually written for coding
agents, and a file from someone else's project can steer the assistant.
Review Comment:
Fixed, along with the generated config docs partials.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]