add relationship fields

This commit is contained in:
larssand
2026-07-02 15:07:15 +02:00
parent d48d34f96c
commit 7d0e66c239
13 changed files with 437 additions and 31 deletions

View File

@@ -93,6 +93,10 @@ def _sample_event(event: LogEvent, value: str = "") -> dict[str, object]:
}
def _relationship_key(left: str, right: str) -> str:
return f"relationship:{left.lower()}->{right.lower()}"
class BaselineStore:
"""Persistent five-minute behavior baseline, implemented with stdlib SQLite."""
@@ -230,6 +234,13 @@ class BaselineStore:
for detector in event_detector_categories(event):
detector_pending[(stream_id, entity, detector, bucket)] += 1
detector_temporal_pending[(stream_id, entity, detector, moment.weekday(), moment.hour, bucket)] += 1
for relation in getattr(profile, "relationship_fields", ()):
left = str(getattr(relation, "left", "")).lower()
right = str(getattr(relation, "right", "")).lower()
left_value = event.fields.get(left)
right_value = event.fields.get(right)
if left_value and right_value:
pending_values[(stream_id, left_value, _relationship_key(left, right), right_value)] += 1
for field in fields:
key = (stream_id, entity, str(field).lower(), bucket)
value = _number(event.fields.get(key[2])) if key[2] in numeric else 0
@@ -386,6 +397,63 @@ class BaselineStore:
samples = [_sample_event(event, detector) for event in matching[:5]]
if score >= MIN_REPORTED_DEVIATION_SCORE:
output[entity].append({"detector": f"{detector}_burst", "field": detector, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "confidence": confidence, "baseline_samples": len(rows), "baseline_age_days": round(baseline_age_days, 2), "baseline_scope": baseline_scope, "reason": f"{detector.replace('_', ' ')} burst above its {baseline_scope} baseline (z={z_score:.1f}, minimum={minimum})", "current": current_value, "baseline": round(mean(history), 2), "sample_values": [detector], "sample_events": samples})
# Detect custom behavior relationships, e.g. username -> srcip or host -> process.name.
relationship_counts: Counter[tuple[str, str, str, str, str]] = Counter()
relationship_samples: dict[tuple[str, str, str, str, str], list[LogEvent]] = defaultdict(list)
for event in events:
stream = event.fields.get("fgai_stream_id", "")
profile = profiles.get(stream)
if not profile:
continue
for relation in getattr(profile, "relationship_fields", ()):
left = str(getattr(relation, "left", "")).lower()
right = str(getattr(relation, "right", "")).lower()
left_value = event.fields.get(left)
right_value = event.fields.get(right)
if not left_value or not right_value:
continue
key = (stream, left_value, left, right, right_value)
relationship_counts[key] += 1
if len(relationship_samples[key]) < 5:
relationship_samples[key].append(event)
relationship_limit: Counter[tuple[str, str]] = Counter()
for (stream, left_value, left, right, right_value), count in relationship_counts.items():
field = _relationship_key(left, right)
known = connection.execute("select seen_count from profile_values where stream_id=? and entity=? and field=? and value=?", (stream, left_value, field, right_value)).fetchone()
known_total = connection.execute("select coalesce(sum(seen_count), 0) from profile_values where stream_id=? and entity=? and field=?", (stream, left_value, field)).fetchone()[0]
if known is not None or int(known_total or 0) < 12:
continue
if relationship_limit[(stream, left_value)] >= MAX_RARE_VALUES_PER_ENTITY:
continue
oldest = connection.execute("select min(bucket_start) from profile_buckets where stream_id=? and entity=?", (stream, left_value)).fetchone()[0]
if oldest is None:
oldest = connection.execute("select min(last_seen) from profile_values where stream_id=? and entity=? and field=?", (stream, left_value, field)).fetchone()[0]
baseline_age_days = _age_days(oldest, _event_epoch(relationship_samples[(stream, left_value, left, right, right_value)][0], int(time.time())))
if baseline_age_days < min_training_days:
continue
profile = profiles.get(stream)
base_score = 28
score, weight = _weighted_score(base_score, profile, field, "new_relationship")
if score < MIN_REPORTED_DEVIATION_SCORE:
continue
samples = relationship_samples[(stream, left_value, left, right, right_value)]
output[left_value].append({
"detector": "new_relationship",
"field": field,
"stream_id": stream,
"score": score,
"base_score": base_score,
"weight": weight,
"confidence": "medium",
"baseline_samples": int(known_total),
"baseline_age_days": round(baseline_age_days, 2),
"baseline_scope": "known field relationships",
"reason": f"new {right} value for {left}={left_value}",
"value": right_value,
"sample_values": [f"{left}={left_value}", f"{right}={right_value}"],
"sample_events": [_sample_event(item, f"{left}={left_value} {right}={right_value}") for item in samples],
})
relationship_limit[(stream, left_value)] += 1
# Detect selected categorical values that have not appeared for this entity in prior data.
rare_counts: Counter[tuple[str, str]] = Counter()
for event in events:

View File

@@ -257,13 +257,15 @@ def _stream_name(config: dict[str, object], stream_id: str) -> str:
def _profile_fields(profile: object | None) -> tuple[str, ...]:
if not profile:
return ()
return tuple(
field for field in (
str(getattr(profile, "entity_field", "")),
*tuple(str(item) for item in getattr(profile, "entity_fields", ())),
str(getattr(profile, "timestamp_field", "")),
return tuple(
field for field in (
str(getattr(profile, "entity_field", "")),
*tuple(str(item) for item in getattr(profile, "entity_fields", ())),
str(getattr(profile, "timestamp_field", "")),
*tuple(str(item) for item in getattr(profile, "categorical_fields", ())),
*tuple(str(item) for item in getattr(profile, "numeric_fields", ())),
*tuple(str(getattr(relation, "left", "")) for relation in getattr(profile, "relationship_fields", ())),
*tuple(str(getattr(relation, "right", "")) for relation in getattr(profile, "relationship_fields", ())),
)
if field
)

File diff suppressed because one or more lines are too long

View File

@@ -75,6 +75,42 @@ class FieldDiscoveryStore:
primary key (stream_id, field)
)"""
)
connection.execute(
"""create table if not exists field_catalog (
stream_id text not null, stream_name text not null, field text not null,
field_type text not null, properties text not null, first_seen integer not null, last_seen integer not null,
primary key (stream_id, field)
)"""
)
def ingest_catalog(self, stream_id: str, stream_name: str, fields: list[dict[str, object]], *, retention_days: int = 30) -> int:
now = int(time.time())
rows = []
for item in fields:
if not isinstance(item, dict):
continue
field = str(item.get("name") or item.get("field") or "").strip()
if not field or field in IGNORED_DISCOVERY_FIELDS:
continue
type_info = item.get("type", {})
field_type = str(type_info.get("type", "") if isinstance(type_info, dict) else type_info)
properties = type_info.get("properties", []) if isinstance(type_info, dict) else []
if not isinstance(properties, list):
properties = []
rows.append((stream_id, stream_name, field, field_type, json.dumps([str(prop) for prop in properties]), now, now))
with sqlite3.connect(self.path) as connection:
for row in rows:
connection.execute(
"""insert into field_catalog values (?, ?, ?, ?, ?, ?, ?)
on conflict(stream_id, field) do update set
stream_name=excluded.stream_name,
field_type=excluded.field_type,
properties=excluded.properties,
last_seen=excluded.last_seen""",
row,
)
connection.execute("delete from field_catalog where last_seen < ?", (now - retention_days * 86400,))
return len(rows)
def ingest(self, events: list[object], *, max_values: int = 20, retention_days: int = 30) -> int:
now = int(time.time())
@@ -130,6 +166,7 @@ class FieldDiscoveryStore:
events: list[LogEvent] = []
with sqlite3.connect(self.path) as connection:
rows = connection.execute("select stream_id, stream_name, field, seen_count, numeric_count, sample_values from field_discovery").fetchall()
catalog_rows = connection.execute("select stream_id, stream_name, field, field_type, properties from field_catalog").fetchall()
for stream_id, stream_name, field, seen_count, numeric_count, sample_values in rows:
try:
values = [str(item) for item in json.loads(str(sample_values))]
@@ -142,4 +179,20 @@ class FieldDiscoveryStore:
value = values[index % len(values)]
fields = {"fgai_stream_id": str(stream_id), "fgai_stream": str(stream_name), str(field): value}
events.append(LogEvent(raw=json.dumps(fields, sort_keys=True), fields=fields))
existing = {(event.fields.get("fgai_stream_id", ""), field) for event in events for field in event.fields if field not in {"fgai_stream_id", "fgai_stream"}}
for stream_id, stream_name, field, field_type, properties in catalog_rows:
if (str(stream_id), str(field)) in existing:
continue
try:
props = set(json.loads(str(properties)))
except (json.JSONDecodeError, TypeError):
props = set()
if "numeric" in props or str(field_type).lower() in {"long", "double", "integer", "int", "float"}:
value = "1"
elif "enumerable" in props:
value = "observed"
else:
value = "catalog"
fields = {"fgai_stream_id": str(stream_id), "fgai_stream": str(stream_name), str(field): value}
events.append(LogEvent(raw=json.dumps(fields, sort_keys=True), fields=fields))
return events

View File

@@ -109,22 +109,28 @@ def ollama_dashboard_assessment(analysis: dict[str, object], model: str | None =
)
def _compact_profile_suggestion(item: dict[str, object]) -> dict[str, object]:
discovery = item.get("discovery", {}) if isinstance(item.get("discovery"), dict) else {}
selected = discovery.get("selected_fields", {}) if isinstance(discovery.get("selected_fields"), dict) else {}
return {
"stream_id": item.get("stream_id"),
"stream_name": item.get("stream_name"),
"events": item.get("events"),
"detected_log_type": item.get("detected_log_type", ""),
"common_fields": item.get("common_fields", [])[:25],
"shared_fields": item.get("shared_fields", [])[:20],
"top_fields": discovery.get("top_fields", [])[:25],
"selected_fields": selected,
"rejected_fields": discovery.get("rejected_fields", [])[:10],
"heuristic_profile": item.get("profile", {}),
}
def ollama_profile_advice(suggestions: list[dict[str, object]], model: str | None = None, timeout: int | None = None) -> list[dict[str, object]]:
host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
selected_model = model or os.getenv("FGAI_PROFILE_ADVISOR_MODEL", "qwen3:8b")
selected_timeout = timeout or int(os.getenv("FGAI_PROFILE_ADVISOR_TIMEOUT", "120"))
compact = [
{
"stream_id": item.get("stream_id"),
"stream_name": item.get("stream_name"),
"events": item.get("events"),
"common_fields": item.get("common_fields", [])[:20],
"shared_fields": item.get("shared_fields", [])[:12],
"detected_log_type": item.get("detected_log_type", ""),
"heuristic_profile": item.get("profile", {}),
}
for item in suggestions[:10]
]
compact = [_compact_profile_suggestion(item) for item in suggestions[:10]]
body = json.dumps(
{
"model": selected_model,
@@ -136,9 +142,18 @@ def ollama_profile_advice(suggestions: list[dict[str, object]], model: str | Non
"Return only valid JSON with this schema: "
"{\"profiles\":[{\"stream_id\":\"...\",\"entity_fields\":[\"...\"],\"timestamp_field\":\"...\","
"\"categorical_fields\":[\"...\"],\"numeric_fields\":[\"...\"],\"detectors\":{\"auth_failure\":{\"enabled\":true,\"minimum\":5,\"z_threshold\":3}},"
"\"relationship_fields\":[{\"left\":\"username\",\"right\":\"srcip\",\"name\":\"user source IP\"}],"
"\"field_roles\":{\"field\":\"entity|identity|asset|session|status|action|resource|metric|context\"},"
"\"correlation_roles\":{\"entity\":[\"...\"],\"identity\":[\"...\"],\"asset\":[\"...\"],\"session\":[\"...\"],\"activity\":[\"...\"],\"resource\":[\"...\"],\"status\":[\"...\"]},"
"\"reason\":\"short reason\"}]}. "
"Use only field names present in common_fields, shared_fields, or heuristic_profile. Prefer fields that appear in shared_fields when they are useful categorical or numeric baseline fields. Do not include raw message/full_message fields. "
"Allowed detectors are auth_failure, dns_query, deny_action. Prefer canonical fields such as username, hostname, eventid, srcip, dstip when present. "
"Use only field names present in common_fields, shared_fields aliases, top_fields, selected_fields, or heuristic_profile. "
"Do not invent fields. Do not include raw message/full_message/raw/answer fields. "
"Treat shared_fields as deterministic evidence that a field or semantic group appears across streams. "
"For custom namespaces such as lcs_* infer roles from the real field names and observed stats, for example tenant/customer/request/session/correlation IDs. "
"Prefer profile fields that connect streams: source or client IPs, usernames/accounts, hostnames, event IDs, session/correlation/request IDs, status/result/action/type, service/app, URL/domain/resource, and stable numeric metrics. "
"Use categorical_fields for low-cardinality behavior dimensions, numeric_fields for counters/durations/sizes, and entity_fields for fields that identify a user, host, IP, account, device, or application actor. "
"Use relationship_fields for behavior pairs where a left identity should learn normal right-side values, such as username->srcip, username->hostname, host->process.name, srcip->dstip, or account->application. "
"Allowed detectors are auth_failure, dns_query, deny_action. Prefer canonical fields such as username, hostname, eventid, srcip, dstip when present, but keep useful stream-specific fields too. "
f"\n\nObserved streams:\n{json.dumps(compact, sort_keys=True)}"
),
}

View File

@@ -54,6 +54,21 @@ def _range_seconds(value: object) -> int:
return 300
def _graylog_fields_from_result(result: dict[str, object]) -> list[dict[str, object]]:
content = result.get("result", {}).get("content", []) if isinstance(result.get("result"), dict) else []
text = next((item.get("text", "") for item in content if isinstance(item, dict)), "")
if not text:
return []
try:
payload = json.loads(text)
except json.JSONDecodeError:
return []
fields = payload.get("fields", payload) if isinstance(payload, dict) else payload
if isinstance(fields, dict) and isinstance(fields.get("fields"), list):
fields = fields["fields"]
return [item for item in fields if isinstance(item, dict)] if isinstance(fields, list) else []
def _stream_coverage(runtime_values: dict[str, object], stream_profiles: dict[str, object], stream_status: dict[str, object], profile_readiness: list[dict[str, object]], stream_titles: dict[str, str]) -> list[dict[str, object]]:
configured = [
item for item in runtime_values.get("graylog_streams", [])
@@ -85,7 +100,7 @@ def _stream_coverage(runtime_values: dict[str, object], stream_profiles: dict[st
"profile": _profile_name(stream_id, stream_titles, profile) if profile else "",
"profile_ready": bool(profile),
"entity_field": ", ".join(getattr(profile, "entity_fields", ()) or (str(getattr(profile, "entity_field", "")),)) if profile else "",
"tracked_fields": len(getattr(profile, "categorical_fields", ())) + len(getattr(profile, "numeric_fields", ())) if profile else 0,
"tracked_fields": len(getattr(profile, "categorical_fields", ())) + len(getattr(profile, "numeric_fields", ())) + len(getattr(profile, "relationship_fields", ())) if profile else 0,
"ready_fields": ready_fields,
"total_fields": total_fields,
"readiness": f"{ready_fields}/{total_fields}" if total_fields else "0/0",
@@ -155,6 +170,8 @@ def build_status(
aggregate_events_total = 0
client = GraylogMcpClient(url, token, verify_tls=verify_tls)
probe_status = client.probe()
discovery_store = FieldDiscoveryStore(history_path) if history_path else None
catalog_fields_total = 0
for stream_config in stream_configs:
stream_id = str(stream_config["id"])
profile = stream_profiles.get(stream_id)
@@ -164,8 +181,15 @@ def build_status(
str(getattr(profile, "timestamp_field", "")),
*tuple(str(field) for field in getattr(profile, "categorical_fields", ())),
*tuple(str(field) for field in getattr(profile, "numeric_fields", ())),
*tuple(str(getattr(relation, "left", "")) for relation in getattr(profile, "relationship_fields", ())),
*tuple(str(getattr(relation, "right", "")) for relation in getattr(profile, "relationship_fields", ())),
) if profile else ()
stream_name = str(stream_config.get("title", "") or stream_titles.get(stream_id) or stream_id)
if discovery_store:
try:
catalog_fields_total += discovery_store.ingest_catalog(stream_id, stream_name, _graylog_fields_from_result(client.call_tool("list_fields", {"streams": [stream_id]})))
except RuntimeError:
pass
aggregate_status: dict[str, object] = {}
if use_aggregate:
aggregate_status = GraylogAggregateSource(client, stream_id, str(runtime_values.get("graylog_query", "*"))).fetch_count(range_seconds=range_seconds, probe_status=probe_status)
@@ -199,6 +223,7 @@ def build_status(
"sample_limited_streams": len(sample_limited_streams),
"truncated_streams": len(truncated_streams),
"aggregate_error_streams": len(aggregate_errors),
"catalog_fields": catalog_fields_total,
"coverage_status": "partial" if partial_streams else "truncated" if truncated_streams else "complete_window",
"coverage_warning": " ".join(warnings),
}
@@ -338,7 +363,7 @@ 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} 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]} for item in stream_profiles.values()],
"stream_coverage": stream_coverage,
"profile_suggestions": profile_suggestions,
"profile_readiness": profile_readiness,

View File

@@ -219,7 +219,46 @@ def _looks_like_time_field(field: str) -> bool:
def _semantic_field_group(field: str) -> str:
return ALIAS_CANONICAL_BY_FIELD.get(field.lower(), field.lower())
return _semantic_field_groups(field)[0]
def _semantic_field_groups(field: str) -> list[str]:
normalized = field.lower()
if normalized in ALIAS_CANONICAL_BY_FIELD:
return [ALIAS_CANONICAL_BY_FIELD[normalized]]
groups = []
if normalized.startswith("lcs_"):
groups.append("lcs_*")
compact = normalized.replace(".", "_").replace("-", "_")
parts = [part for part in compact.split("_") if part]
if not parts:
return [normalized]
if parts[-1] in {"ip", "addr", "address"}:
groups.append("*_ip")
if parts[-1] in {"user", "username", "account", "principal", "actor", "name"} and any(part in {"user", "account", "principal", "actor"} for part in parts):
groups.append("*_user")
if parts[-1] in {"host", "hostname", "computer", "node", "name"} and any(part in {"host", "hostname", "computer", "node"} for part in parts):
groups.append("*_host")
if parts[-1] in {"id", "uuid", "guid"}:
groups.append("*_id")
if parts[-1] in {"name", "label", "title"}:
groups.append("*_name")
if parts[-1] in {"status", "state", "result", "outcome", "disposition"}:
groups.append("*_status")
if parts[-1] in {"action", "operation", "event"}:
groups.append("*_action")
if parts[-1] in {"type", "category", "class", "kind"}:
groups.append("*_type")
if parts[-1] in {"code", "reason"}:
groups.append("*_code")
if parts[-1] in {"port"}:
groups.append("*_port")
if parts[-1] in {"domain", "fqdn"}:
groups.append("*_domain")
if parts[-1] in {"url", "uri", "path"}:
groups.append("*_url")
groups.append(normalized)
return list(dict.fromkeys(groups))
def _shared_field_kind(field: str, *, cardinality: int, total: int, numeric_ratio: float) -> str:
@@ -413,7 +452,9 @@ def _shared_profile_fields(
"candidate": candidate,
"semantic_group": _semantic_field_group(field),
})
if kind == "numeric":
if kind == "entity":
pass
elif kind == "numeric":
numeric.append(field)
elif kind == "categorical":
categorical.append(field)
@@ -443,6 +484,14 @@ def _shared_profile_fields(
else:
stats_by_key[group].setdefault("aliases", aliases[:10])
stats_by_key[group]["streams"] = max(int(stats_by_key[group].get("streams", 0) or 0), stream_count)
if kind == "numeric":
for field in present_aliases:
if field not in numeric:
numeric.append(field)
elif kind == "categorical":
for field in present_aliases:
if field not in categorical:
categorical.append(field)
stats = sorted(
stats_by_key.values(),
key=lambda item: (
@@ -458,6 +507,21 @@ def _shared_profile_fields(
def _allowed_fields(suggestion: dict[str, object]) -> set[str]:
profile = suggestion.get("profile", {}) if isinstance(suggestion.get("profile"), dict) else {}
fields = {str(item.get("field", "")) for item in suggestion.get("common_fields", []) if isinstance(item, dict)}
discovery = suggestion.get("discovery", {}) if isinstance(suggestion.get("discovery"), dict) else {}
for item in discovery.get("top_fields", []) if isinstance(discovery.get("top_fields"), list) else []:
if isinstance(item, dict):
fields.add(str(item.get("field", "")))
for item in suggestion.get("shared_fields", []) if isinstance(suggestion.get("shared_fields"), list) else []:
if not isinstance(item, dict):
continue
fields.add(str(item.get("field", "")))
aliases = item.get("aliases", [])
if isinstance(aliases, list):
fields.update(str(alias) for alias in aliases if alias)
selected = discovery.get("selected_fields", {}) if isinstance(discovery.get("selected_fields"), dict) else {}
for value in selected.values():
if isinstance(value, list):
fields.update(str(field) for field in value if field)
for key in ("entity_fields", "categorical_fields", "numeric_fields"):
value = profile.get(key, [])
if isinstance(value, list):
@@ -490,6 +554,19 @@ def apply_profile_advice(suggestions: list[dict[str, object]], advice: list[dict
timestamp = str(heuristic.get("timestamp_field", "timestamp"))
categorical = valid_fields("categorical_fields", list(heuristic.get("categorical_fields", [])) if isinstance(heuristic.get("categorical_fields"), list) else [])
numeric = valid_fields("numeric_fields", list(heuristic.get("numeric_fields", [])) if isinstance(heuristic.get("numeric_fields"), list) else [])
relationships = []
raw_relationships = advisor.get("relationship_fields", [])
if isinstance(raw_relationships, list):
for relation in raw_relationships:
if not isinstance(relation, dict):
continue
left = str(relation.get("left", "")).strip()
right = str(relation.get("right", "")).strip()
if left not in allowed_fields or right not in allowed_fields or left == right:
continue
relationships.append({"left": left, "right": right, "name": str(relation.get("name", "") or f"{left}->{right}")})
if not relationships and isinstance(heuristic.get("relationship_fields"), list):
relationships = [item for item in heuristic.get("relationship_fields", []) if isinstance(item, dict)]
detectors_raw = advisor.get("detectors", {})
detectors = {}
if isinstance(detectors_raw, dict):
@@ -503,6 +580,23 @@ def apply_profile_advice(suggestions: list[dict[str, object]], advice: list[dict
if not detectors:
detectors = dict(heuristic.get("detectors", {})) if isinstance(heuristic.get("detectors"), dict) else {}
if entity_fields:
raw_field_roles = advisor.get("field_roles", {})
field_roles = {}
if isinstance(raw_field_roles, dict):
field_roles = {
str(field): str(role)
for field, role in raw_field_roles.items()
if str(field) in allowed_fields and str(role)
}
raw_correlation_roles = advisor.get("correlation_roles", {})
correlation_roles = {}
if isinstance(raw_correlation_roles, dict):
for role, fields in raw_correlation_roles.items():
if not isinstance(fields, list):
continue
valid = [str(field) for field in fields if str(field) in allowed_fields]
if valid:
correlation_roles[str(role)] = list(dict.fromkeys(valid))
profile = {
**heuristic,
"entity_field": entity_fields[0],
@@ -511,9 +605,16 @@ def apply_profile_advice(suggestions: list[dict[str, object]], advice: list[dict
"categorical_fields": categorical,
"numeric_fields": numeric,
"detectors": detectors,
"relationship_fields": relationships,
}
item.update({"profile": profile, "entity_fields": entity_fields, "timestamp_field": timestamp, "categorical_fields": categorical, "numeric_fields": numeric, "detectors": detectors, "relationship_fields": relationships})
item["profile_advisor"] = {
"status": "ok",
"model": str(advisor.get("model", "")),
"reason": str(advisor.get("reason", "")),
"field_roles": field_roles,
"correlation_roles": correlation_roles,
}
item.update({"profile": profile, "entity_fields": entity_fields, "timestamp_field": timestamp, "categorical_fields": categorical, "numeric_fields": numeric, "detectors": detectors})
item["profile_advisor"] = {"status": "ok", "model": str(advisor.get("model", "")), "reason": str(advisor.get("reason", ""))}
else:
item["profile_advisor"] = {"status": "invalid", "reason": "advisor returned no valid entity fields"}
output.append(item)
@@ -539,10 +640,11 @@ def suggest_stream_profiles(events: list[LogEvent], *, existing_profiles: dict[s
if field not in IGNORED_DISCOVERY_FIELDS and _has_discovery_value(value)
}
stream_field_counts.update(fields)
semantic_groups = {_semantic_field_group(field) for field in fields}
semantic_groups = {group for field in fields for group in _semantic_field_groups(field)}
stream_semantic_counts.update(semantic_groups)
for field in fields:
stream_semantic_fields[_semantic_field_group(field)].add(field)
for group in _semantic_field_groups(field):
stream_semantic_fields[group].add(field)
suggestions: list[dict[str, object]] = []
for stream_id, stream_events in grouped.items():
@@ -673,6 +775,7 @@ def suggest_stream_profiles(events: list[LogEvent], *, existing_profiles: dict[s
"numeric_fields": numeric,
"detectors": detectors,
"field_weights": {},
"relationship_fields": [],
},
"reason": "selected common entity, time, categorical, and numeric fields from observed events; non-priority fields are included when coverage and cardinality look useful",
})

View File

@@ -3,6 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class RelationshipField:
left: str
right: str
name: str = ""
@dataclass(frozen=True)
class StreamProfile:
stream_id: str
@@ -14,6 +21,7 @@ class StreamProfile:
numeric_fields: tuple[str, ...] = ()
detectors: dict[str, dict[str, object]] = field(default_factory=dict)
field_weights: dict[str, object] = field(default_factory=dict)
relationship_fields: tuple[RelationshipField, ...] = ()
def _detectors(value: object) -> dict[str, dict[str, object]]:
@@ -54,6 +62,25 @@ def _field_weights(value: object) -> dict[str, object]:
return output
def _relationship_fields(value: object) -> tuple[RelationshipField, ...]:
output: list[RelationshipField] = []
seen: set[tuple[str, str]] = set()
for item in value if isinstance(value, list) else []:
if not isinstance(item, dict):
continue
left = str(item.get("left") or item.get("entity") or item.get("from") or "").strip()
right = str(item.get("right") or item.get("value") or item.get("to") or "").strip()
if not left or not right or left == right:
continue
key = (left.lower(), right.lower())
if key in seen:
continue
seen.add(key)
name = str(item.get("name") or f"{left}->{right}").strip()
output.append(RelationshipField(left=left, right=right, name=name))
return tuple(output)
def parse_profiles(value: object) -> dict[str, StreamProfile]:
profiles: dict[str, StreamProfile] = {}
for item in value if isinstance(value, list) else []:
@@ -69,11 +96,13 @@ def parse_profiles(value: object) -> dict[str, StreamProfile]:
continue
detectors = _detectors(item.get("detectors", {}))
field_weights = _field_weights(item.get("field_weights", {}))
relationship_fields = _relationship_fields(item.get("relationship_fields", []))
profiles[stream_id] = StreamProfile(
stream_id, str(item.get("name", "")).strip() or stream_id, entity, timestamp, entity_fields,
tuple(str(field) for field in item.get("categorical_fields", []) if field),
tuple(str(field) for field in item.get("numeric_fields", []) if field),
detectors,
field_weights,
relationship_fields,
)
return profiles