add rule generalization, sized LLM defaults, ops docs, and UI polish

Feedback rules can now be suggested for merging when a human has already
reviewed the same pattern multiple times (never guesses about new values).
LLM defaults sized for constrained VRAM instead of llama3.3/llama3.1.
Adds systemd units, a state backup script, and reverse-proxy auth guidance
for production deployment. Findings view is collapsible and sorts
suggested-review rows to the top for a quicker overview.
This commit is contained in:
larssand
2026-09-23 13:04:53 +02:00
parent c3e482f9ab
commit bf1109be81
14 changed files with 446 additions and 14 deletions

View File

@@ -186,7 +186,8 @@ stored under `graylog_stream_profiles` in `state/fgai-config.json`.
The Settings page also shows recommended stream profiles built from observed
field coverage and cardinality. These recommendations use deterministic
discovery first, then can optionally be refined by a local Ollama profile advisor
model such as `qwen3:8b` or `qwen3:14b`. Enable `Ollama profile advisor` and set
model such as `qwen2.5:3b-instruct` (default) or a larger model on more capable
hardware. Enable `Ollama profile advisor` and set
`Profile advisor model` in Settings. Advisor output must be valid JSON and is
validated against fields actually seen in the stream before it can be applied.
Unknown fields, raw message fields, internal `fgai_*` fields, and unknown
@@ -671,16 +672,28 @@ fgai suggest-blocks --logs logs/fg_syslog.jsonl --execute --expiry-minutes 60
Optional local LLM summary through Ollama:
```bash
ollama pull llama3.3
ollama pull qwen2.5:7b-instruct
fgai analyze-logs --logs logs/fg_syslog.jsonl --llm --llm-timeout 300
```
For slower machines or large models:
For slower machines, pick a smaller model instead of raising the timeout:
```bash
OLLAMA_MODEL=llama3.1 OLLAMA_TIMEOUT=300 fgai analyze-logs --logs logs/fg_syslog.jsonl --llm
OLLAMA_MODEL=qwen2.5:3b-instruct OLLAMA_TIMEOUT=300 fgai analyze-logs --logs logs/fg_syslog.jsonl --llm
```
**Sizing the model to your GPU**: the defaults (`qwen2.5:7b-instruct` for the
dashboard assessment, `qwen2.5:3b-instruct` for the profile advisor) target a
6-8 GB VRAM card at Q4 quantization with some headroom for context. On a
memory-constrained card (6 GB or less, e.g. a mobile/low-power GPU), drop both
to `qwen2.5:3b-instruct` or smaller — tight VRAM causes Ollama to spill onto
CPU, which shows up as slow or timed-out assessments rather than an obvious
out-of-memory error. On a workstation-class GPU with 16 GB+ VRAM, a larger
model such as `qwen3:14b` for the dashboard assessment will give noticeably
better analysis quality. The profile advisor only ever classifies a handful
of ambiguous fields per call (see the Graylog MCP section below), so it stays
small and fast even on constrained hardware without much quality loss.
## Optional FortiGate Input
## Synthetic Windows Test Input
@@ -723,7 +736,7 @@ end
- `FORTIGATE_VERIFY_TLS`: `true` or `false`, defaults to `true`.
- `FGAI_ALLOWLIST`: comma-separated IPs/CIDRs never to block.
- `OLLAMA_HOST`: defaults to `http://127.0.0.1:11434`.
- `OLLAMA_MODEL`: defaults to `llama3.1`.
- `OLLAMA_MODEL`: defaults to `qwen2.5:7b-instruct`. Size this to your GPU's VRAM — see the sizing note above.
- `OLLAMA_TIMEOUT`: Ollama request timeout in seconds, defaults to `180`.
- `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes.
- `FGAI_LLM_INTERVAL`: seconds between dashboard LLM notes, defaults to `300`.
@@ -743,3 +756,70 @@ The agent separates detection from enforcement:
- Source IPs must be globally routable and outside the allowlist.
- Blocking requires `--execute`.
- The FortiGate API call is limited to the quarantine/banned user monitor endpoint.
## Production Deployment
`start.sh` is convenient for a single machine you're logged into, but has no
supervision (a crashed process just stays dead) and no boot-time start.
### Running under systemd
Unit files for the monitor, dashboard, and optional local syslog listener are
in `systemd/`, with full setup and update instructions in
[`systemd/README.md`](systemd/README.md). They mirror `start.sh`'s exact
process arguments, add `Restart=on-failure`, and run under a dedicated
`signalscope` system user with `ProtectSystem=strict`/`ProtectHome=true`
sandboxing rather than as root.
### Backup and Restore
Everything SignalScope needs to restore — runtime configuration, feedback
rules, behavior baselines, incident state, and caches — lives under `state/`.
Nothing else needs backing up; logs and exports are reproducible/disposable.
```bash
scripts/backup_state.sh # writes to ./backups/signalscope-state-<timestamp>.tar.gz
scripts/backup_state.sh /path/to/backups # or a custom destination
```
SQLite databases (`fgai-baseline.sqlite3`, `signalscope-history.sqlite3`,
`signalscope-status-cache.sqlite3`) are backed up with `sqlite3 .backup`,
which takes a consistent snapshot even while the monitor is actively writing
to them, rather than a plain file copy that can capture a torn/inconsistent
write mid-transaction. JSON files (`fgai-config.json`,
`signalscope-feedback.json`, `signalscope-incidents.json`,
`threat-intel-cache.json`) are already written atomically by SignalScope
(temp file + rename) and are copied directly.
To restore: stop the monitor and dashboard, extract the archive's contents
over `state/`, then restart.
### Exposing the Dashboard Beyond Loopback
The dashboard has **no built-in authentication** — its entire access-control
model is "only accept connections from `127.0.0.1`" (`start.sh`'s and the
systemd unit's default `--host 127.0.0.1`). API routes that change state
(`POST /api/config`, `/api/feedback`, `/api/feedback/bulk`, incident actions,
and more) additionally check that the client's socket address is loopback,
regardless of what `Host` header a request carries.
If you need to reach the dashboard from another machine, do not change
`--host` to `0.0.0.0` directly — put an authenticating reverse proxy in front
of it instead and keep SignalScope itself bound to loopback. For example,
with Caddy:
```
signalscope.internal.example.com {
basicauth {
analyst $2a$14$... # generate with: caddy hash-password
}
reverse_proxy 127.0.0.1:8088
}
```
Or nginx with `auth_basic` / an `auth_request` subrequest to whatever SSO the
rest of your infrastructure already uses. Either way, terminate TLS at the
proxy and keep the token/credential prompts outside SignalScope's own process
— since state-changing routes only trust the *proxy's* loopback connection,
not the original client, the proxy is the only thing standing between a
public request and dashboard write access once `--host` is opened up.

46
scripts/backup_state.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Backs up everything SignalScope needs to restore its configuration,
# feedback rules, baselines, and incident history: the whole state/
# directory. SQLite databases are backed up live via `sqlite3 .backup`
# (a consistent snapshot even while the monitor is writing to them)
# instead of a plain file copy, which can capture a torn/inconsistent
# write. JSON files (config, feedback, incidents, caches) are already
# written atomically by SignalScope (temp file + rename), so a plain
# copy of those is safe.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STATE_DIR="${FGAI_STATE_DIR:-$ROOT_DIR/state}"
BACKUP_ROOT="${1:-$ROOT_DIR/backups}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST_DIR="$BACKUP_ROOT/signalscope-state-$STAMP"
if [ ! -d "$STATE_DIR" ]; then
printf 'State directory not found: %s\n' "$STATE_DIR" >&2
exit 1
fi
mkdir -p "$DEST_DIR"
shopt -s nullglob
for db in "$STATE_DIR"/*.sqlite3; do
name="$(basename "$db")"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$db" ".backup '$DEST_DIR/$name'"
else
printf 'Warning: sqlite3 CLI not found, falling back to plain copy for %s (may be inconsistent if the monitor is running)\n' "$name" >&2
cp "$db" "$DEST_DIR/$name"
fi
done
for json_file in "$STATE_DIR"/*.json; do
cp "$json_file" "$DEST_DIR/$(basename "$json_file")"
done
shopt -u nullglob
ARCHIVE="$BACKUP_ROOT/signalscope-state-$STAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$BACKUP_ROOT" "signalscope-state-$STAMP"
rm -rf "$DEST_DIR"
printf 'Backed up %s to %s\n' "$STATE_DIR" "$ARCHIVE"

View File

@@ -28,11 +28,12 @@ DEFAULT_CONFIG: dict[str, object] = {
"llm_model": "",
"llm_timeout": 180,
"profile_advisor_enabled": False,
"profile_advisor_model": "qwen3:8b",
"profile_advisor_model": "qwen2.5:3b-instruct",
"profile_advisor_timeout": 240,
"profile_advisor_interval_seconds": 300,
"field_confidence_threshold": 60,
"field_confidence_retention_days": 90,
"feedback_generalization_min_values": 3,
"auto_apply_profile_suggestions": False,
"threat_intel_enabled": False,
"threat_intel_provider": "auto",
@@ -89,6 +90,11 @@ class ConfigStore:
current[key] = min(100, max(0, int(value)))
except (TypeError, ValueError):
continue
elif key == "feedback_generalization_min_values":
try:
current[key] = min(20, max(2, int(value)))
except (TypeError, ValueError):
continue
elif key == "threat_intel_provider" and value in {"auto", "abuseipdb", "virustotal"}:
current[key] = value
elif key == "graylog_streams" and isinstance(value, list):

File diff suppressed because one or more lines are too long

View File

@@ -175,3 +175,36 @@ def feedback_matches(entry: dict[str, object], *, entity: str, stream_id: str, f
def select_match(candidates: list[dict[str, object]]) -> dict[str, object]:
return min(candidates, key=lambda item: SCOPE_PRIORITY.get(str(item.get("scope") or _resolve_scope(item)), 1))
def suggest_generalizations(
entries: list[dict[str, object]], *, min_values: int = 3, sample_limit: int = 5
) -> list[dict[str, object]]:
"""Find groups of narrow value-scope rules a human could merge into one field-scope rule.
Only ever looks at entries a human already created (value scope) — never
reasons about unseen values, so it can't pre-judge a new anomaly.
"""
groups: dict[tuple[str, str, str, str], list[dict[str, object]]] = {}
for entry in entries:
scope = str(entry.get("scope") or _resolve_scope(entry))
if scope != "value":
continue
status = str(entry.get("status", ""))
if status not in VALID_STATUSES:
continue
key = (str(entry.get("stream_id", "")), str(entry.get("entity", "")), str(entry.get("field", "")), status)
groups.setdefault(key, []).append(entry)
suggestions = []
for (stream_id, entity, field, status), group in groups.items():
distinct_values = sorted({str(item.get("value", "")) for item in group})
if len(distinct_values) < min_values:
continue
suggestions.append({
"stream_id": stream_id, "entity": entity, "field": field, "status": status,
"value_count": len(distinct_values), "sample_values": distinct_values[:sample_limit],
"entry_ids": [str(item.get("id", "")) for item in group],
})
suggestions.sort(key=lambda item: -item["value_count"])
return suggestions

View File

@@ -41,7 +41,7 @@ def ollama_summary(
timeout: int | None = None,
) -> str:
host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
selected_model = model or os.getenv("OLLAMA_MODEL", "llama3.1")
selected_model = model or os.getenv("OLLAMA_MODEL", "qwen2.5:7b-instruct")
selected_timeout = timeout or int(os.getenv("OLLAMA_TIMEOUT", "180"))
prompt = {
"analysis": analysis or {},
@@ -378,7 +378,7 @@ def ollama_profile_advice(
ambiguous_fields_by_stream: dict[str, list[str]] | 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_model = model or os.getenv("FGAI_PROFILE_ADVISOR_MODEL", "qwen2.5:3b-instruct")
selected_timeout = timeout or int(os.getenv("FGAI_PROFILE_ADVISOR_TIMEOUT", "240"))
scoped = bool(ambiguous_fields_by_stream)
compact = [

View File

@@ -12,7 +12,7 @@ from .baseline import BaselineStore
from .config import ConfigStore
from .correlation import correlate_source_ips
from .event_context import build_event_context
from .feedback import FeedbackStore, SUPPRESSING_STATUSES, feedback_matches, select_match
from .feedback import FeedbackStore, SUPPRESSING_STATUSES, feedback_matches, select_match, suggest_generalizations
from .graylog_aggregate import GraylogAggregateSource
from .graylog_mcp import GraylogMcpClient
from .graylog_source import GraylogStreamSource
@@ -524,6 +524,8 @@ def build_status(
deviation["feedback_rule_id"] = match.get("id", "")
if match["status"] in SUPPRESSING_STATUSES:
deviation["score"] = 0
generalization_min_values = int(runtime_values.get("feedback_generalization_min_values", 3) or 3)
feedback_generalization_suggestions = suggest_generalizations(feedback, min_values=generalization_min_values)
anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations)
baseline_events = baseline.ingest(events) if baseline else 0
profile_baseline_fields = (
@@ -691,6 +693,7 @@ def build_status(
"triage_queue": triage_queue,
"sequence_findings": sequence_findings,
"feedback": feedback,
"feedback_generalization_suggestions": feedback_generalization_suggestions,
"cross_source_correlations": correlations,
"incidents": incidents,
"data_quality": assess_data_quality(events, mcp_status),

68
systemd/README.md Normal file
View File

@@ -0,0 +1,68 @@
# Running SignalScope under systemd
These units replace `start.sh`/`stop.sh` with proper service supervision
(auto-restart, journald logging, boot-time start). They assume the repo lives
at `/opt/signalscope` and runs as a dedicated `signalscope` user — adjust the
paths and `User=`/`Group=` lines if you deploy elsewhere.
## One-time setup
```bash
sudo useradd --system --home-dir /opt/signalscope --shell /usr/sbin/nologin signalscope
sudo mkdir -p /opt/signalscope
sudo cp -r . /opt/signalscope/ # from a checkout of this repo
sudo chown -R signalscope:signalscope /opt/signalscope
# Create the venv and install dependencies as the service user
sudo -u signalscope python3 -m venv /opt/signalscope/.venv
sudo -u signalscope /opt/signalscope/.venv/bin/pip install --upgrade pip
sudo -u signalscope /opt/signalscope/.venv/bin/pip install -e /opt/signalscope
sudo cp systemd/*.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## Enable and start
```bash
sudo systemctl enable --now signalscope-monitor.service
sudo systemctl enable --now signalscope-dashboard.service
# Only if you use the local syslog input instead of Graylog MCP:
sudo systemctl enable --now signalscope-syslog.service
```
## Everyday operations
```bash
systemctl status signalscope-monitor signalscope-dashboard
journalctl -u signalscope-monitor -f
journalctl -u signalscope-dashboard -f
sudo systemctl restart signalscope-monitor signalscope-dashboard
```
Configure Graylog MCP, streams, and profiles the same way as always: open the
dashboard and use Settings — configuration lives in
`/opt/signalscope/state/fgai-config.json` regardless of how the process was
started.
## Updating
```bash
sudo systemctl stop signalscope-dashboard signalscope-monitor
sudo -u signalscope git -C /opt/signalscope pull
sudo -u signalscope /opt/signalscope/.venv/bin/pip install -e /opt/signalscope
sudo systemctl start signalscope-monitor signalscope-dashboard
```
## Notes
- `signalscope-dashboard.service` binds `127.0.0.1` by default, matching the
documented safety default in the main README. See "Exposing the dashboard
beyond loopback" there before changing `--host` to anything else.
- `state/` (config, feedback rules, baselines, incidents) is the only
directory that needs backing up — see `scripts/backup_state.sh` and the
"Backup and Restore" section in the main README.
- The monitor unit's `ExecStart` has a commented example for enabling the
Ollama dashboard assessment (`--llm ...`); the profile advisor is a
dashboard Settings toggle and needs no unit-file change.

View File

@@ -0,0 +1,29 @@
[Unit]
Description=SignalScope dashboard
After=network-online.target signalscope-monitor.service
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
# Binds to loopback only by default. Do not change --host to 0.0.0.0 without
# putting an authenticating reverse proxy in front — see the README section
# "Exposing the dashboard beyond loopback".
ExecStart=/opt/signalscope/.venv/bin/fgai dashboard \
--host 127.0.0.1 \
--port 8088 \
--status-file /opt/signalscope/state/fgai-status.json \
--config-file /opt/signalscope/state/fgai-config.json \
--image-dir /opt/signalscope/images
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/state
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,30 @@
[Unit]
Description=SignalScope continuous monitor
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
# To enable the local Ollama dashboard assessment, add to ExecStart below:
# --llm --llm-interval 300 --llm-timeout 180 --model qwen2.5:7b-instruct
ExecStart=/opt/signalscope/.venv/bin/fgai monitor \
--logs /opt/signalscope/logs/fg_syslog.jsonl \
--output /opt/signalscope/state/fgai-status.json \
--interval 10 \
--baseline-db /opt/signalscope/state/fgai-baseline.sqlite3 \
--history-db /opt/signalscope/state/signalscope-history.sqlite3 \
--status-cache-db /opt/signalscope/state/signalscope-status-cache.sqlite3 \
--config-file /opt/signalscope/state/fgai-config.json
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/state /opt/signalscope/logs /opt/signalscope/run
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,30 @@
[Unit]
Description=SignalScope local syslog listener (optional — only needed for the local_syslog log source, not for Graylog MCP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=signalscope
Group=signalscope
WorkingDirectory=/opt/signalscope
ExecStart=/opt/signalscope/.venv/bin/fgai listen-syslog \
--host 0.0.0.0 \
--port 5514 \
--output /opt/signalscope/logs/fg_syslog.jsonl \
--rotate-bytes 26214400 \
--rotate-count 14 \
--quiet
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/signalscope/logs
# To listen on the standard syslog port 514 instead of 5514, change --port
# above to 514 and uncomment the line below instead of running as root:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target

View File

@@ -82,6 +82,16 @@ class ConfigTests(unittest.TestCase):
public = store.update({"field_confidence_threshold": "not-a-number"})
self.assertEqual(public["field_confidence_threshold"], 70)
def test_feedback_generalization_min_values_is_numeric_and_clamped(self):
with tempfile.TemporaryDirectory() as directory:
store = ConfigStore(str(Path(directory) / "config.json"))
public = store.update({"feedback_generalization_min_values": "1"})
self.assertEqual(public["feedback_generalization_min_values"], 2)
public = store.update({"feedback_generalization_min_values": "500"})
self.assertEqual(public["feedback_generalization_min_values"], 20)
public = store.update({"feedback_generalization_min_values": "5"})
self.assertEqual(public["feedback_generalization_min_values"], 5)
def test_empty_stream_update_does_not_clear_existing_streams(self):
with tempfile.TemporaryDirectory() as directory:
store = ConfigStore(str(Path(directory) / "config.json"))

View File

@@ -94,6 +94,35 @@ class DashboardTests(unittest.TestCase):
self.assertIn("openDetailIds", HTML)
self.assertIn("addEventListener('toggle'", HTML)
def test_dashboard_field_deviations_are_collapsible_and_open_by_default(self):
start = HTML.index("Field Baseline Deviations")
preceding = HTML[max(0, start - 80):start]
self.assertIn('<details class="collapsible-section" open>', preceding)
self.assertIn('id="findingSummaryBadge"', HTML)
def test_dashboard_sorts_likely_worth_confirming_rows_first(self):
self.assertIn('const suggestPriority', HTML)
self.assertIn('suggestPriority(left) - suggestPriority(right)', HTML)
def test_dashboard_has_generalization_suggestion_panel(self):
self.assertIn('id="feedbackGeneralizationSuggestions"', HTML)
def test_dashboard_generalization_merge_button_confirms_before_widening_scope(self):
self.assertIn('merge-feedback-generalization', HTML)
self.assertIn('Merge into one field-wide rule', HTML)
self.assertIn('will be revoked', HTML)
def test_dashboard_generalization_merge_only_ever_posts_field_scope(self):
start = HTML.index("querySelectorAll('.merge-feedback-generalization')")
end = HTML.index('}));', start)
merge_handler = HTML[start:end]
self.assertIn("scope: 'field'", merge_handler)
self.assertNotIn("scope: 'entity'", merge_handler)
self.assertNotIn("scope: 'stream'", merge_handler)
def test_dashboard_exposes_feedback_generalization_setting(self):
self.assertIn('name="feedback_generalization_min_values"', HTML)
def test_dashboard_suggests_review_from_detector_history(self):
self.assertIn("label:'Suggested', render:suggestReview", HTML)
self.assertIn("likely false positive", HTML)

View File

@@ -3,7 +3,7 @@ import time
import unittest
from pathlib import Path
from fgai.feedback import FeedbackStore, feedback_matches, select_match
from fgai.feedback import FeedbackStore, feedback_matches, select_match, suggest_generalizations
class FeedbackTests(unittest.TestCase):
@@ -120,6 +120,43 @@ class FeedbackTests(unittest.TestCase):
self.assertEqual(select_match(candidates), value_rule)
self.assertEqual(select_match([stream_rule, entity_rule]), entity_rule)
def test_suggest_generalizations_groups_narrow_value_rules_by_stream_entity_field_status(self):
entries = [
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "bob", "id": "1"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "carol", "id": "2"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "windows", "field": "username", "value": "dave", "id": "3"},
{"scope": "value", "status": "confirmed", "entity": "alice", "stream_id": "windows", "field": "username", "value": "eve", "id": "4"},
]
suggestions = suggest_generalizations(entries, min_values=3)
self.assertEqual(len(suggestions), 1)
self.assertEqual(suggestions[0]["value_count"], 3)
self.assertEqual(suggestions[0]["status"], "false_positive")
self.assertEqual(set(suggestions[0]["entry_ids"]), {"1", "2", "3"})
def test_suggest_generalizations_respects_min_values_threshold(self):
entries = [
{"scope": "value", "status": "expected", "entity": "a", "stream_id": "s", "field": "f", "value": "x", "id": "1"},
{"scope": "value", "status": "expected", "entity": "a", "stream_id": "s", "field": "f", "value": "y", "id": "2"},
]
self.assertEqual(suggest_generalizations(entries, min_values=3), [])
def test_suggest_generalizations_ignores_broader_scope_entries(self):
entries = [
{"scope": "field", "status": "false_positive", "entity": "a", "stream_id": "s", "field": "f", "id": "1"},
{"scope": "entity", "status": "expected", "entity": "a", "id": "2"},
]
self.assertEqual(suggest_generalizations(entries, min_values=2), [])
def test_suggest_generalizations_keeps_different_entities_separate(self):
entries = [
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "s", "field": "f", "value": "1", "id": "1"},
{"scope": "value", "status": "false_positive", "entity": "alice", "stream_id": "s", "field": "f", "value": "2", "id": "2"},
{"scope": "value", "status": "false_positive", "entity": "bob", "stream_id": "s", "field": "f", "value": "3", "id": "3"},
]
self.assertEqual(suggest_generalizations(entries, min_values=2), [
{"stream_id": "s", "entity": "alice", "field": "f", "status": "false_positive", "value_count": 2, "sample_values": ["1", "2"], "entry_ids": ["1", "2"]}
])
if __name__ == "__main__":
unittest.main()