From 561388a0b3b448363ba46ec82cb17c1308dd9870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 3 Sep 2026 19:34:11 -0600 Subject: [PATCH 1/4] feat[backend](soar): pin llm_enrich output contract via injected system prompt --- backend/modules/soar/executor/llm.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/modules/soar/executor/llm.go b/backend/modules/soar/executor/llm.go index 751fe7e49..19ae016f7 100644 --- a/backend/modules/soar/executor/llm.go +++ b/backend/modules/soar/executor/llm.go @@ -40,6 +40,18 @@ func NewLLMEnrich(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_enrich" // text and only cares whether the stream ended cleanly. func NewLLMAction(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_action"} } +// enrichSystemPrompt is appended to the task of every llm_enrich execution. It +// pins the output shape: the node's `final` message must be a single JSON +// object with at least a `result` property (any JSON value), which becomes the +// node's output. Keep it in English regardless of the flow's lang — models +// follow a contract more reliably in their training language. +const enrichSystemPrompt = `OUTPUT CONTRACT (mandatory, overrides any conflicting instruction above): +Respond with EXACTLY ONE JSON object and nothing else — no prose before or after it, no markdown fences. +The object MUST contain a "result" property: {"result": ...} +- "result" may be a string, a JSON object, or a JSON array, carrying your complete finding. +- You may add extra sibling properties (e.g. "confidence") that downstream nodes will use. +Downstream automation parses this object verbatim; any other format fails the enrichment node.` + func (l *LLM) Type() string { return l.typ } type llmParams struct { @@ -68,8 +80,19 @@ func (l *LLM) Execute(ctx context.Context, exec *domain.SoarExecution) (json.Raw return nil, errors.New("soar llm: prompt is required") } + // The SOC-AI client takes a single task body, so the enrichment output + // contract travels inside the task. It is mandatory for this node type: + // downstream nodes resolve $(.) against the returned JSON, + // so a missing or malformed `result` would leave them an empty bag. + // llm_action leaves the task untouched — it only cares that the agent + // finished cleanly. + task := p.Prompt + if exec.Kind == domain.NodeKindEnrichment { + task = p.Prompt + "\n\n" + enrichSystemPrompt + } + body, err := json.Marshal(map[string]any{ - "task": p.Prompt, + "task": task, "page": defaultString(p.Page, "soar"), "lang": defaultString(p.Lang, "en"), "history": p.History, From 100ca0ebb13b599a010919bd9f61cfda7b27d903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 3 Sep 2026 19:34:28 -0600 Subject: [PATCH 2/4] feat[frontend](soar): single text-area prompt editor for LLM nodes --- .../soar/components/LLMParamsEditor.tsx | 91 +++++++++++++++++++ .../soar/components/NodeInspector.tsx | 14 ++- frontend/src/shared/i18n/locales/de.json | 4 + frontend/src/shared/i18n/locales/en.json | 4 + frontend/src/shared/i18n/locales/es.json | 4 + frontend/src/shared/i18n/locales/fr.json | 4 + frontend/src/shared/i18n/locales/it.json | 4 + frontend/src/shared/i18n/locales/pt.json | 4 + frontend/src/shared/i18n/locales/ru.json | 4 + 9 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/soar/components/LLMParamsEditor.tsx diff --git a/frontend/src/features/soar/components/LLMParamsEditor.tsx b/frontend/src/features/soar/components/LLMParamsEditor.tsx new file mode 100644 index 000000000..a6e952795 --- /dev/null +++ b/frontend/src/features/soar/components/LLMParamsEditor.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { FlowNode } from '../types/soar.types' +import { InsertFieldMenu } from './InsertFieldMenu' + +interface Props { + nodeId: string + nodes: Record + params: unknown + readOnly?: boolean + /** executor: 'llm_enrich' | 'llm_action' — the hint differs per kind. */ + executor: string + onChange: (params: { prompt?: string }) => void +} + +// llm_enrich / llm_action params hold a single free-text prompt. This editor +// replaces the raw JSON textarea for those nodes — users see one text box, +// never `{"prompt": ...}`. For llm_enrich the backend injects the mandatory +// output contract (a JSON object with a `result` property) into the task +// itself, so nothing about the return shape is configured here; the hint +// just tells the user how children will read it. +export function LLMParamsEditor({ nodeId, nodes, params, readOnly, executor, onChange }: Props) { + const { t } = useTranslation() + const promptRef = useRef(null) + const [prompt, setPrompt] = useState(() => extractPrompt(params)) + + useEffect(() => { + setPrompt(extractPrompt(params)) + }, [params, nodeId]) + + const isEnrich = executor === 'llm_enrich' + + const commit = () => { + const trimmed = prompt.trim() + onChange({ prompt: trimmed }) + } + + const insertIntoPrompt = (token: string) => { + const el = promptRef.current + const cur = prompt + const start = el?.selectionStart ?? cur.length + const end = el?.selectionEnd ?? cur.length + const next = cur.slice(0, start) + token + cur.slice(end) + setPrompt(next) + requestAnimationFrame(() => { + const el2 = promptRef.current + if (!el2) return + el2.focus() + const pos = start + token.length + el2.setSelectionRange(pos, pos) + }) + } + + return ( +
+
+ + {!readOnly && ( + + )} +
+