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

@@ -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()