Skip to content

Commit d4cab26

Browse files
Backlog/v12 llm node (#2538)
* feat[backend](soar): pin llm_enrich output contract via injected system prompt * feat[frontend](soar): single text-area prompt editor for LLM nodes * feat[backend](soar): guarantee llm_enrich output shape via backend normalization * feat[frontend](soar): expose llm_enrich result as a static field in child editors
1 parent 6d3ca69 commit d4cab26

12 files changed

Lines changed: 277 additions & 34 deletions

File tree

backend/modules/soar/executor/llm.go

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ type LLMStreamer interface {
2323

2424
// LLM is one implementation backing two node types:
2525
// - llm_enrich (kind=enrichment): drives the SOC-AI agent with a prompt and
26-
// returns the final message parsed as JSON — becomes ancestor context for
27-
// downstream nodes.
26+
// returns its final message normalized to {"result": ...} — becomes
27+
// ancestor context for downstream nodes.
2828
// - llm_action (kind=executor): drives the SOC-AI agent with a prompt so it
2929
// can use its own tools (list hosts, run commands, page oncall, etc.) and
3030
// succeeds when the stream ends on a `final` event.
@@ -33,13 +33,26 @@ type LLM struct {
3333
typ string
3434
}
3535

36-
// NewLLMEnrich registers a node type that expects a JSON `final` payload.
36+
// NewLLMEnrich registers a node type that normalizes its output to {"result": ...}.
3737
func NewLLMEnrich(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_enrich"} }
3838

3939
// NewLLMAction registers a node type that treats the `final` payload as free
4040
// text and only cares whether the stream ended cleanly.
4141
func NewLLMAction(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_action"} }
4242

43+
// enrichSystemPrompt is appended to the task of every llm_enrich execution.
44+
// It pins the output shape; the backend then enforces it in
45+
// normalizeEnrichmentOutput, so downstream nodes may always rely on
46+
// $(<nodeId>.result). Keep it in English regardless of the flow's lang —
47+
// models follow a contract more reliably in their training language.
48+
const enrichSystemPrompt = `OUTPUT CONTRACT (mandatory, overrides any conflicting instruction above):
49+
Respond with EXACTLY ONE JSON object and nothing else - no prose before or after it, no markdown fences.
50+
The object MUST contain a "result" property holding your complete finding:
51+
{"result": ...}
52+
- "result" may be a string, a JSON object, or a JSON array.
53+
- You may add a few sibling properties (e.g. "confidence").
54+
Downstream automation resolves <this-node-id>.result from your object verbatim.`
55+
4356
func (l *LLM) Type() string { return l.typ }
4457

4558
type llmParams struct {
@@ -68,8 +81,18 @@ func (l *LLM) Execute(ctx context.Context, exec *domain.SoarExecution) (json.Raw
6881
return nil, errors.New("soar llm: prompt is required")
6982
}
7083

84+
// The SOC-AI client takes a single task body, so the enrichment output
85+
// contract travels inside the task. It is mandatory for this node type:
86+
// downstream nodes resolve $(<nodeId>.result) against the normalized
87+
// output. llm_action leaves the task untouched — it only cares that the
88+
// agent finished cleanly.
89+
task := p.Prompt
90+
if exec.Kind == domain.NodeKindEnrichment {
91+
task = p.Prompt + "\n\n" + enrichSystemPrompt
92+
}
93+
7194
body, err := json.Marshal(map[string]any{
72-
"task": p.Prompt,
95+
"task": task,
7396
"page": defaultString(p.Page, "soar"),
7497
"lang": defaultString(p.Lang, "en"),
7598
"history": p.History,
@@ -106,11 +129,7 @@ func (l *LLM) Execute(ctx context.Context, exec *domain.SoarExecution) (json.Raw
106129
// structured to hand downstream.
107130
return nil, nil
108131
}
109-
output, err := extractJSONOutput(finalRaw)
110-
if err != nil {
111-
return nil, fmt.Errorf("soar llm enrichment: final is not JSON: %w", err)
112-
}
113-
return output, nil
132+
return normalizeEnrichmentOutput(finalRaw)
114133
}
115134

116135
// drainSSE walks a text/event-stream body and returns the concatenated event
@@ -182,34 +201,96 @@ func parseSSEFrame(frame []byte) (event string, data string) {
182201
return event, dataBuf.String()
183202
}
184203

185-
// extractJSONOutput accepts a few final-message shapes the SOC-AI agent tends
186-
// to produce: bare JSON, a `content` field inside a JSON envelope, or a JSON
187-
// blob wrapped in a ```json fence.
188-
func extractJSONOutput(finalData string) (json.RawMessage, error) {
189-
trimmed := strings.TrimSpace(finalData)
204+
// normalizeEnrichmentOutput is the backend-side half of the enrichment
205+
// contract: whatever the model returns, the node output is ALWAYS a JSON
206+
// object whose "result" property carries the finding, so downstream nodes can
207+
// unconditionally reference $(<nodeId>.result).
208+
//
209+
// - JSON object already carrying "result" -> passed through unchanged
210+
// (siblings such as "confidence" survive).
211+
// - JSON object without "result" -> encapsulated: the whole
212+
// object becomes the "result" value.
213+
// - JSON array or JSON scalar -> encapsulated as "result".
214+
// - JSON hidden in a {"content": "..."} envelope
215+
// or a ``` fence (model/transport quirk) -> unwrapped first, then
216+
// re-run through the same rules.
217+
// - plain text (contract ignored) -> {"result": "<text>"}.
218+
func normalizeEnrichmentOutput(finalRaw string) (json.RawMessage, error) {
219+
trimmed := strings.TrimSpace(finalRaw)
190220
if trimmed == "" {
191-
return nil, errors.New("empty final message")
221+
return nil, errors.New("soar llm enrichment: empty final message")
192222
}
223+
193224
if raw, ok := tryJSON(trimmed); ok {
194-
// Envelope { "content": "..." } — unwrap and retry.
195-
var env struct {
196-
Content string `json:"content"`
197-
}
198-
if err := json.Unmarshal(raw, &env); err == nil && strings.TrimSpace(env.Content) != "" {
199-
if inner, ok := tryJSON(strings.TrimSpace(env.Content)); ok {
200-
return inner, nil
201-
}
202-
if fenced, ok := stripJSONFence(env.Content); ok {
203-
return fenced, nil
204-
}
205-
return nil, fmt.Errorf("content is not JSON: %s", truncate(env.Content, 200))
206-
}
207-
return raw, nil
225+
return finishFromJSON(unwrapEnvelope(raw))
208226
}
209227
if fenced, ok := stripJSONFence(trimmed); ok {
210-
return fenced, nil
228+
return finishFromJSON(unwrapEnvelope(fenced))
229+
}
230+
// No JSON at all: the model answered in prose. Still succeed — the whole
231+
// text becomes "result". The contract prompt exists to avoid this branch.
232+
quoted, err := json.Marshal(trimmed)
233+
if err != nil {
234+
return nil, fmt.Errorf("soar llm enrichment: encapsulate result: %w", err)
235+
}
236+
return appendJSONValue(quoted), nil
237+
}
238+
239+
// finishFromJSON passes a JSON value through when it is already an object
240+
// carrying "result"; otherwise it encapsulates the value under "result".
241+
func finishFromJSON(raw json.RawMessage) (json.RawMessage, error) {
242+
var m map[string]json.RawMessage
243+
if err := json.Unmarshal(raw, &m); err == nil {
244+
if _, has := m["result"]; has {
245+
return raw, nil
246+
}
247+
}
248+
return appendJSONValue(raw), nil
249+
}
250+
251+
// unwrapEnvelope resolves a JSON value to its payload: a {"content": "..."}
252+
// envelope whose content is JSON (possibly fenced) is unwrapped to that inner
253+
// value; content that is plain prose becomes a JSON string. The model
254+
// sometimes wraps its answer in the chat-message envelope instead of sending
255+
// the object directly.
256+
func unwrapEnvelope(raw json.RawMessage) json.RawMessage {
257+
var m map[string]json.RawMessage
258+
if err := json.Unmarshal(raw, &m); err != nil {
259+
return raw // array or scalar — nothing to unwrap
260+
}
261+
c, ok := m["content"]
262+
if !ok {
263+
return raw
264+
}
265+
var cs string
266+
if err := json.Unmarshal(c, &cs); err != nil {
267+
return raw // content is not a string — leave the envelope as-is
211268
}
212-
return nil, fmt.Errorf("not JSON: %s", truncate(trimmed, 200))
269+
cs = strings.TrimSpace(cs)
270+
if cs == "" {
271+
return raw
272+
}
273+
if inner, isJSON := tryJSON(cs); isJSON {
274+
return inner
275+
}
276+
if fenced, isJSON := stripJSONFence(cs); isJSON {
277+
return fenced
278+
}
279+
quoted, err := json.Marshal(cs)
280+
if err != nil {
281+
return raw
282+
}
283+
return quoted
284+
}
285+
286+
// appendJSONValue wraps a JSON value under "result", keeping objects, arrays
287+
// and scalars as real JSON values (not escaped strings).
288+
func appendJSONValue(raw json.RawMessage) json.RawMessage {
289+
out := make([]byte, 0, len(raw)+12)
290+
out = append(out, `{"result":`...)
291+
out = append(out, raw...)
292+
out = append(out, '}')
293+
return out
213294
}
214295

215296
func tryJSON(s string) (json.RawMessage, bool) {
@@ -231,7 +312,7 @@ func stripJSONFence(s string) (json.RawMessage, bool) {
231312
return tryJSON(strings.TrimSpace(trimmed))
232313
}
233314

234-
func defaultString(s, fallback string) string {
315+
func defaultString(s string, fallback string) string {
235316
if s == "" {
236317
return fallback
237318
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import type { FlowNode } from '../types/soar.types'
4+
import { InsertFieldMenu } from './InsertFieldMenu'
5+
6+
interface Props {
7+
nodeId: string
8+
nodes: Record<string, FlowNode>
9+
params: unknown
10+
readOnly?: boolean
11+
/** executor: 'llm_enrich' | 'llm_action' — the hint differs per kind. */
12+
executor: string
13+
onChange: (params: { prompt?: string }) => void
14+
}
15+
16+
// llm_enrich / llm_action params hold a single free-text prompt. This editor
17+
// replaces the raw JSON textarea for those nodes — users see one text box,
18+
// never `{"prompt": ...}`. For llm_enrich the backend injects the mandatory
19+
// output contract (a JSON object with a `result` property) into the task
20+
// itself, so nothing about the return shape is configured here; the hint
21+
// just tells the user how children will read it.
22+
export function LLMParamsEditor({ nodeId, nodes, params, readOnly, executor, onChange }: Props) {
23+
const { t } = useTranslation()
24+
const promptRef = useRef<HTMLTextAreaElement>(null)
25+
const [prompt, setPrompt] = useState(() => extractPrompt(params))
26+
27+
useEffect(() => {
28+
setPrompt(extractPrompt(params))
29+
}, [params, nodeId])
30+
31+
const isEnrich = executor === 'llm_enrich'
32+
33+
const commit = () => {
34+
const trimmed = prompt.trim()
35+
onChange({ prompt: trimmed })
36+
}
37+
38+
const insertIntoPrompt = (token: string) => {
39+
const el = promptRef.current
40+
const cur = prompt
41+
const start = el?.selectionStart ?? cur.length
42+
const end = el?.selectionEnd ?? cur.length
43+
const next = cur.slice(0, start) + token + cur.slice(end)
44+
setPrompt(next)
45+
requestAnimationFrame(() => {
46+
const el2 = promptRef.current
47+
if (!el2) return
48+
el2.focus()
49+
const pos = start + token.length
50+
el2.setSelectionRange(pos, pos)
51+
})
52+
}
53+
54+
return (
55+
<div className="space-y-1">
56+
<div className="flex flex-wrap items-center gap-1.5">
57+
<label className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
58+
{t('soar.editor.canvas.llm.prompt')}
59+
</label>
60+
{!readOnly && (
61+
<InsertFieldMenu nodes={nodes} currentNodeId={nodeId} onInsert={insertIntoPrompt} />
62+
)}
63+
</div>
64+
<textarea
65+
ref={promptRef}
66+
value={prompt}
67+
readOnly={readOnly}
68+
onChange={(e) => setPrompt(e.target.value)}
69+
onBlur={commit}
70+
rows={8}
71+
placeholder={
72+
isEnrich
73+
? 'Analyze this alert and classify it: $(alert.name)'
74+
: 'Investigate $(alert.target.host) and page on-call if needed'
75+
}
76+
className="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
77+
/>
78+
{isEnrich && (
79+
<p className="text-[10px] leading-snug text-muted-foreground">
80+
{t('soar.editor.canvas.llm.hint', { nodeId })}
81+
</p>
82+
)}
83+
</div>
84+
)
85+
}
86+
87+
function extractPrompt(params: unknown): string {
88+
if (!params || typeof params !== 'object') return ''
89+
const p = (params as { prompt?: unknown }).prompt
90+
return typeof p === 'string' ? p : ''
91+
}

frontend/src/features/soar/components/NodeInspector.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { HttpParamsEditor } from './HttpParamsEditor'
1111
import { IncidentParamsEditor } from './IncidentParamsEditor'
1212
import { InsertFieldMenu } from './InsertFieldMenu'
1313
import { MailParamsEditor } from './MailParamsEditor'
14+
import { LLMParamsEditor } from './LLMParamsEditor'
1415

1516
interface Props {
1617
nodeId: string
@@ -243,7 +244,18 @@ export function NodeInspector({ nodeId, node, nodes, readOnly, onRename, onChang
243244
/>
244245
)}
245246

246-
{node.executor !== 'shell' && node.executor !== 'conditional' && node.executor !== 'http' && node.executor !== 'incident' && node.executor !== 'mail' && (
247+
{(node.executor === 'llm_enrich' || node.executor === 'llm_action') && (
248+
<LLMParamsEditor
249+
nodeId={nodeId}
250+
nodes={nodes}
251+
params={node.params}
252+
readOnly={readOnly}
253+
executor={node.executor}
254+
onChange={(next) => onChange({ params: next })}
255+
/>
256+
)}
257+
258+
{node.executor !== 'shell' && node.executor !== 'conditional' && node.executor !== 'http' && node.executor !== 'incident' && node.executor !== 'mail' && node.executor !== 'llm_enrich' && node.executor !== 'llm_action' && (
247259
<Field label={t('soar.editor.canvas.paramsJson')}>
248260
{!readOnly && (
249261
<div className="mb-1 flex flex-wrap items-center gap-1.5">
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { enrichmentAncestors } from './ancestors'
3+
import type { FlowNode } from '../types/soar.types'
4+
5+
const enrich = (executor: string): FlowNode => ({
6+
kind: 'enrichment',
7+
executor,
8+
onSuccess: ['child'],
9+
})
10+
11+
describe('enrichmentAncestors static fields', () => {
12+
const nodes: Record<string, FlowNode> = {
13+
llm1: enrich('llm_enrich'),
14+
geo: enrich('http'),
15+
child: { kind: 'executor', executor: 'shell', command: 'echo' },
16+
}
17+
18+
it('advertises result for llm_enrich parents (backend-guaranteed shape)', () => {
19+
const out = enrichmentAncestors(nodes, 'child')
20+
expect(out.find((a) => a.nodeId === 'llm1')?.fields).toEqual(['result'])
21+
})
22+
23+
it('leaves http parents runtime-dependent (no static fields)', () => {
24+
const out = enrichmentAncestors(nodes, 'child')
25+
expect(out.find((a) => a.nodeId === 'geo')?.fields).toEqual([])
26+
})
27+
})

frontend/src/features/soar/lib/ancestors.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ export function enrichmentAncestors(nodes: Record<string, FlowNode>, target: str
2626
const n = nodes[id]
2727
if (!n) continue
2828
if (n.kind === 'enrichment') {
29-
out.push({ nodeId: id, executor: n.executor, fields: [] })
29+
// llm_enrich output is always normalized to {"result": ...} by the
30+
// backend, so "result" is statically known; other executors' output
31+
// shapes stay runtime-dependent (empty fields = user types the path).
32+
const fields = n.executor === 'llm_enrich' ? ['result'] : []
33+
out.push({ nodeId: id, executor: n.executor, fields })
3034
}
3135
for (const parent of reverse.get(id) ?? []) queue.push(parent)
3236
}

frontend/src/shared/i18n/locales/de.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4795,6 +4795,10 @@
47954795
"description": "Beschreibung",
47964796
"descriptionPlaceholder": "Optionaler Kontext für Bearbeiter. Vorlagen wie $(alert.name) werden interpoliert."
47974797
},
4798+
"llm": {
4799+
"prompt": "Prompt",
4800+
"hint": "Das Modell ist angewiesen, immer mit einem einzigen JSON-Objekt mit der Eigenschaft \"result\" zu antworten; die Backend garantiert dieses Format, selbst wenn das Modell abweicht. Kindknoten greifen per $({{nodeId}}.result) oder konkreten Feldern zu."
4801+
},
47984802
"mail": {
47994803
"to": "An (kommagetrennt)",
48004804
"cc": "CC (kommagetrennt, optional)",

frontend/src/shared/i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5181,6 +5181,10 @@
51815181
"description": "Description",
51825182
"descriptionPlaceholder": "Optional context for responders. Templates like $(alert.name) are interpolated."
51835183
},
5184+
"llm": {
5185+
"prompt": "Prompt",
5186+
"hint": "The model is instructed to always answer with a single JSON object carrying a \"result\" property. The backend guarantees this shape even if the model deviates. Child nodes reference it via $({{nodeId}}.result) or specific fields."
5187+
},
51845188
"mail": {
51855189
"to": "To (comma-separated)",
51865190
"cc": "CC (comma-separated, optional)",

frontend/src/shared/i18n/locales/es.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4917,6 +4917,10 @@
49174917
"description": "Descripción",
49184918
"descriptionPlaceholder": "Contexto opcional para los responsables. Plantillas como $(alert.name) se interpolan."
49194919
},
4920+
"llm": {
4921+
"prompt": "Prompt",
4922+
"hint": "El modelo siempre responde con un único objeto JSON que incluye la propiedad \"result\"; el backend garantiza ese formato aunque el modelo se desvíe. Los nodos hijos la consultan con $({{nodeId}}.result) o campos específicos."
4923+
},
49204924
"mail": {
49214925
"to": "Para (separados por coma)",
49224926
"cc": "CC (separados por coma, opcional)",

0 commit comments

Comments
 (0)