add value-shape field classification and auto-apply

Field roles are now inferred from observed values instead of names,
with src/dst disambiguation, value-pattern detector hints, an Ollama
advisor scoped to ambiguous fields only, confidence-gated auto-apply
with manual-field protection, and drift flagging for review
This commit is contained in:
larssand
2026-09-07 21:58:25 +02:00
parent 7b94512abc
commit e35445da4f
22 changed files with 1133 additions and 93 deletions

View File

@@ -114,13 +114,141 @@ def _suggestion_field_set(suggestion: dict[str, object]) -> set[str]:
return {field for field in fields if field}
def _needs_profile_advisor(suggestion: dict[str, object], stream_profiles: dict[str, object]) -> bool:
if not suggestion.get("profile_exists"):
return True
def _ambiguous_fields(suggestion: dict[str, object], threshold: int) -> list[str]:
field_confidence = suggestion.get("field_confidence", {})
if not isinstance(field_confidence, dict):
return []
selected = _suggestion_field_set(suggestion)
# Only fields that were actually observed and shape-classified are "ambiguous" —
# a field with no field_confidence entry (e.g. a placeholder timestamp field name
# used when no time-like field was discovered at all) has nothing to disambiguate.
return [field for field in selected if field in field_confidence and int(field_confidence[field] or 0) < threshold]
def _needs_profile_advisor(suggestion: dict[str, object], stream_profiles: dict[str, object], *, confidence_threshold: int = 60) -> bool:
stream_id = str(suggestion.get("stream_id", ""))
suggested_fields = _suggestion_field_set(suggestion)
if not suggestion.get("profile_exists"):
return True
existing_fields = _profile_field_set(stream_profiles.get(stream_id))
return bool(suggested_fields - existing_fields)
if suggested_fields - existing_fields:
return True
return bool(_ambiguous_fields(suggestion, confidence_threshold))
def _suggestion_is_high_confidence(suggestion: dict[str, object], threshold: int) -> bool:
field_confidence = suggestion.get("field_confidence", {})
if not isinstance(field_confidence, dict):
return False
observed = [field for field in _suggestion_field_set(suggestion) if field in field_confidence]
if not observed:
return False
return all(int(field_confidence[field] or 0) >= threshold for field in observed)
def _profile_is_auto_apply_safe(existing: object) -> bool:
"""A profile with any manual-origin field is left alone entirely by auto-apply."""
if existing is None:
return True
field_origin = getattr(existing, "field_origin", None) or {}
return all(origin != "manual" for origin in field_origin.values())
def _auto_apply_profile_suggestions(
profile_suggestions: list[dict[str, object]],
stream_profiles: dict[str, object],
runtime_values: dict[str, object],
config_path: str | None,
) -> list[str]:
"""Write high-confidence, provenance-safe suggestions straight to config.
Mirrors the dashboard's own POST /api/config write path (ConfigStore.update),
just invoked here unattended each cycle instead of from a human clicking Apply.
"""
if not config_path or not runtime_values.get("auto_apply_profile_suggestions"):
return []
threshold = int(runtime_values.get("field_confidence_threshold", 60) or 60)
updated_profiles = [item for item in runtime_values.get("graylog_stream_profiles", []) if isinstance(item, dict)]
applied: list[str] = []
now = int(time.time())
for suggestion in profile_suggestions:
if not isinstance(suggestion, dict):
continue
stream_id = str(suggestion.get("stream_id", ""))
profile = suggestion.get("profile", {})
if not stream_id or not isinstance(profile, dict):
continue
if not _suggestion_is_high_confidence(suggestion, threshold):
continue
if not _profile_is_auto_apply_safe(stream_profiles.get(stream_id)):
continue
all_fields = {*profile.get("entity_fields", []), *profile.get("categorical_fields", []), *profile.get("numeric_fields", [])}
new_profile = dict(profile)
new_profile["field_origin"] = {str(field): "auto" for field in all_fields if field}
new_profile["updated_at"] = now
updated_profiles = [item for item in updated_profiles if item.get("stream_id") != stream_id]
updated_profiles.append(new_profile)
applied.append(stream_id)
if applied:
ConfigStore(config_path).update({"graylog_stream_profiles": updated_profiles})
return applied
DRIFT_CONFIDENCE_DROP_THRESHOLD = 20
def _detect_profile_drift(
profile_suggestions: list[dict[str, object]],
stream_profiles: dict[str, object],
discovery_store: object | None,
) -> list[dict[str, object]]:
"""Flag auto-applied fields whose classification has drifted since they were applied.
Flag-only: this never touches the live profile, only surfaces a diagnostic for a
human to review. A silently "self-corrected" auto-applied field could suppress or
misdirect real findings with nobody noticing, which is worse than a stale mapping
that gets flagged and reviewed.
"""
if discovery_store is None:
return []
drift: list[dict[str, object]] = []
for suggestion in profile_suggestions:
if not isinstance(suggestion, dict):
continue
stream_id = str(suggestion.get("stream_id", ""))
if not stream_id:
continue
existing = stream_profiles.get(stream_id)
field_origin = getattr(existing, "field_origin", None) or {}
auto_fields = [field for field, origin in field_origin.items() if origin == "auto"]
if not auto_fields:
continue
applied = discovery_store.applied_confidence(stream_id)
if not applied:
continue
field_confidence = suggestion.get("field_confidence", {})
field_shapes = suggestion.get("field_shapes", {})
if not isinstance(field_confidence, dict) or not isinstance(field_shapes, dict):
continue
for field in auto_fields:
baseline = applied.get(field)
if not baseline or field not in field_confidence:
continue
current_confidence = int(field_confidence.get(field, 0) or 0)
current_shape = str(field_shapes.get(field, ""))
confidence_drop = int(baseline["applied_confidence"]) - current_confidence
shape_changed = bool(current_shape) and current_shape != str(baseline["applied_shape"])
if confidence_drop > DRIFT_CONFIDENCE_DROP_THRESHOLD or shape_changed:
drift.append({
"stream_id": stream_id,
"field": field,
"applied_confidence": baseline["applied_confidence"],
"current_confidence": current_confidence,
"applied_shape": baseline["applied_shape"],
"current_shape": current_shape,
"applied_at": baseline["applied_at"],
})
return drift
def _stream_name(stream_id: str, stream_titles: dict[str, str], profile: object | None = None) -> str:
@@ -427,13 +555,19 @@ def build_status(
stream_coverage = _stream_coverage(runtime_values, stream_profiles, mcp_status, profile_readiness, stream_titles)
discovery_cache_events = []
discovered_profile_fields = 0
discovery_store = None
if history_path:
discovery_store = FieldDiscoveryStore(history_path)
discovered_profile_fields = discovery_store.ingest(events)
discovery_cache_events = discovery_store.synthetic_events()
profile_suggestions = suggest_stream_profiles([*discovery_cache_events, *events], existing_profiles=stream_profiles)
confidence_threshold = int(runtime_values.get("field_confidence_threshold", 60) or 60)
profile_advisor_status = {"enabled": bool(runtime_values.get("profile_advisor_enabled")), "status": "disabled"}
advisor_candidates = [item for item in profile_suggestions if _needs_profile_advisor(item, stream_profiles)]
advisor_candidates = [item for item in profile_suggestions if _needs_profile_advisor(item, stream_profiles, confidence_threshold=confidence_threshold)]
ambiguous_fields_by_stream = {
str(item.get("stream_id", "")): _ambiguous_fields(item, confidence_threshold)
for item in advisor_candidates
}
if runtime_values.get("profile_advisor_enabled") and not run_profile_advisor:
profile_advisor_status = {"enabled": True, "status": "pending" if advisor_candidates else "skipped_no_profile_changes", "candidates": len(advisor_candidates)}
elif runtime_values.get("profile_advisor_enabled") and profile_suggestions and not advisor_candidates:
@@ -444,6 +578,7 @@ def build_status(
advisor_candidates[:10],
model=str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b"),
timeout=int(runtime_values.get("profile_advisor_timeout", 240) or 240),
ambiguous_fields_by_stream=ambiguous_fields_by_stream,
)
profile_suggestions = apply_profile_advice(profile_suggestions, advice)
profile_advisor_status = {"enabled": True, "status": "ok" if advice else "empty", "profiles_returned": len(advice), "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")}
@@ -457,6 +592,28 @@ def build_status(
}
for suggestion in profile_suggestions:
suggestion.setdefault("profile_advisor", {"status": "heuristic", "error": str(exc)})
auto_applied_streams = _auto_apply_profile_suggestions(profile_suggestions, stream_profiles, runtime_values, config_path)
if auto_applied_streams:
if discovery_store:
suggestions_by_stream = {str(item.get("stream_id", "")): item for item in profile_suggestions if isinstance(item, dict)}
for stream_id in auto_applied_streams:
suggestion = suggestions_by_stream.get(stream_id)
if not suggestion:
continue
profile = suggestion.get("profile", {})
if not isinstance(profile, dict):
continue
applied_fields = [*profile.get("entity_fields", []), *profile.get("categorical_fields", []), *profile.get("numeric_fields", [])]
discovery_store.record_applied_confidence(
stream_id,
applied_fields,
suggestion.get("field_confidence", {}) if isinstance(suggestion.get("field_confidence"), dict) else {},
suggestion.get("field_shapes", {}) if isinstance(suggestion.get("field_shapes"), dict) else {},
)
if config_store:
stream_profiles = parse_profiles(config_store.read().get("graylog_stream_profiles", []))
confidence_history_maintenance = discovery_store.confidence_history_maintenance(retention_days=int(runtime_values.get("field_confidence_retention_days", 90) or 90)) if discovery_store else {}
profile_drift = _detect_profile_drift(profile_suggestions, stream_profiles, discovery_store)
intel_ips = sorted(
{
ip
@@ -512,7 +669,9 @@ def build_status(
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "training_days": baseline_training_days, "new_events_recorded": baseline_events, "profile_fields_recorded": profile_baseline_fields, "discovery_fields_recorded": discovered_profile_fields, "discovery_cache_events": len(discovery_cache_events), "maintenance": baseline_maintenance, "size_bytes": baseline_maintenance.get("size_bytes", 0) if isinstance(baseline_maintenance, dict) else 0},
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status, "profile_advisor": profile_advisor_status},
"configuration": runtime_config,
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "entity_fields": list(item.entity_fields), "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights, "relationship_fields": [{"left": relation.left, "right": relation.right, "name": relation.name} for relation in item.relationship_fields]} for item in stream_profiles.values()],
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "entity_fields": list(item.entity_fields), "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights, "relationship_fields": [{"left": relation.left, "right": relation.right, "name": relation.name} for relation in item.relationship_fields], "field_origin": item.field_origin, "updated_at": item.updated_at} for item in stream_profiles.values()],
"profile_auto_apply": {"applied_streams": auto_applied_streams, "confidence_history_maintenance": confidence_history_maintenance},
"profile_drift": profile_drift,
"stream_coverage": stream_coverage,
"profile_suggestions": profile_suggestions,
"profile_readiness": profile_readiness,
@@ -626,7 +785,12 @@ def add_profile_advisor(status: dict[str, object], runtime_values: dict[str, obj
if not isinstance(profile_suggestions, list):
return
stream_profiles = parse_profiles(runtime_values.get("graylog_stream_profiles", []))
advisor_candidates = [item for item in profile_suggestions if isinstance(item, dict) and _needs_profile_advisor(item, stream_profiles)]
confidence_threshold = int(runtime_values.get("field_confidence_threshold", 60) or 60)
advisor_candidates = [item for item in profile_suggestions if isinstance(item, dict) and _needs_profile_advisor(item, stream_profiles, confidence_threshold=confidence_threshold)]
ambiguous_fields_by_stream = {
str(item.get("stream_id", "")): _ambiguous_fields(item, confidence_threshold)
for item in advisor_candidates
}
capabilities = status.setdefault("capabilities", {})
if not isinstance(capabilities, dict):
return
@@ -641,6 +805,7 @@ def add_profile_advisor(status: dict[str, object], runtime_values: dict[str, obj
advisor_candidates[:10],
model=str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b"),
timeout=int(runtime_values.get("profile_advisor_timeout", 240) or 240),
ambiguous_fields_by_stream=ambiguous_fields_by_stream,
)
status["profile_suggestions"] = apply_profile_advice(profile_suggestions, advice)
capabilities["profile_advisor"] = {"enabled": True, "status": "ok" if advice else "empty", "profiles_returned": len(advice), "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")}
@@ -740,6 +905,7 @@ def monitor_loop(
print(f"Writing status to {output}", flush=True)
last_llm_at = 0
last_llm_text: str | None = None
last_advisor_at = 0
while True:
runtime = ConfigStore(config_path).read() if config_path and Path(config_path).exists() else {}
effective_llm = bool(runtime.get("llm_enabled")) if runtime else llm
@@ -804,16 +970,21 @@ def monitor_loop(
"generated_at": last_llm_at,
"text": last_llm_text or "",
}
if runtime.get("profile_advisor_enabled"):
advisor_interval = int(runtime.get("profile_advisor_interval_seconds", 300) or 300)
if runtime.get("profile_advisor_enabled") and now - last_advisor_at >= advisor_interval:
add_profile_advisor(status, runtime)
last_advisor_at = now
write_status(status, output)
else:
status["llm_assessment"] = {"enabled": False, "status": "disabled", "text": ""}
if runtime.get("profile_advisor_enabled"):
advisor_interval = int(runtime.get("profile_advisor_interval_seconds", 300) or 300)
now = int(time.time())
if runtime.get("profile_advisor_enabled") and now - last_advisor_at >= advisor_interval:
if status_cache_path and isinstance(mcp, dict) and mcp.get("status") not in {"error", "refreshing"}:
StatusSnapshotStore(status_cache_path).save("last_good", status)
write_status(status, output)
add_profile_advisor(status, runtime)
last_advisor_at = now
if status_cache_path and isinstance(mcp, dict) and mcp.get("status") not in {"error", "refreshing"}:
StatusSnapshotStore(status_cache_path).save("last_good", status)
write_status(status, output)