← 3.1 Agent PAL core demos

[3.3.1] pal_core_01_detect.py (BINGO) 26.0411 (01 events / alerts)


drones


ME: so in pal_core_01_detect cmd_demo there is 
- A (py script) 
- S simulated (no ingress, just hard coded "sensor" data) 
- H run command line 
- no LLM (not used) 
- DB simulated (just JSON file)

Core Concept
Many small events may mean nothing individually.
But together they may indicate something important.
Example:
•	power spike 
•	camera offline 
•	truck delay 
At same site, within 30 minutes.
→ alert
________________________________________
Input Event Schema
{
  "site": "A",
  "type": "power_spike",
  "severity": 2,
  "timestamp": "2026-04-11T10:00:00Z"
}
Required Fields
•	site (string) 
•	type (string) 
•	severity (int) 
•	timestamp (ISO string) 
________________________________________
Stored Events
Keep list in:
pal_core_events.json
________________________________________
Rule Schema (hardcoded v1)
RULES = [
    {
        "name": "suspicious_activity",
        "site_types": ["power_spike", "camera_offline", "truck_delay"],
        "window_minutes": 30,
        "min_total_severity": 3
    }
]
________________________________________
Detection Logic
For each site:
1.	load recent events 
2.	check if required event types exist 
3.	check timestamps inside window 
4.	check severity threshold 
5.	generate alert 
________________________________________
Alert Output Schema
{
  "alert_type": "suspicious_activity",
  "site": "A",
  "matched_events": [
    "power_spike",
    "camera_offline",
    "truck_delay"
  ],
  "confidence": 0.82,
  "reason": "Matched required constellation within 30 minutes"
}
________________________________________
CLI Commands
Ingest
python pal_core_01_detect.py ingest '{"site":"A","type":"power_spike","severity":2}'
Scan
python pal_core_01_detect.py scan
Demo Seed Data
python pal_core_01_detect.py demo
________________________________________
OpenAI Role (optional later)
Not needed for v1 core logic.
Later use for:
•	explain alerts 
•	summarize risks 
•	convert natural language to rules 
________________________________________
Deterministic Role
Handles:
•	storage 
•	time windows 
•	rule matching 
•	severity scoring 
•	alert generation 
________________________________________
Why This Is Strong
This feels like real operational software:
•	streaming data 
•	weak signals 
•	event fusion 
•	actionable alert 
# pal_core_01_detect_ids.py (IDs)
#
# PAL Core 01 - Detect (with IDs)
#
# Commands:
#   1) ingest -> store one event
#   2) scan   -> scan stored events and generate alerts
#   3) demo   -> load demo events and scan
#
# Examples:
#   python pal_core_01_detect_ids.py ingest "{\"site\":\"A\",\"type\":\"power_spike\",\"severity\":2}"
#   python pal_core_01_detect_ids.py scan
#   python pal_core_01_detect_ids.py demo
#
# ID conventions:
# - Event notes begin with E00x
# - Rule names begin with R00x
# - Alert reasons begin with A00x

import json
import re
import sys
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional

# --------------------------------------------------
# 1 FILES / CONSTANTS
# --------------------------------------------------
EVENTS_FILE = Path("pal_core_events.json")

ALLOWED_TOP_KEYS = {
    "timestamp",
    "site",
    "type",
    "severity",
    "note",
}

REQUIRED_KEYS = {
    "site",
    "type",
    "severity",
}

RULES = [
    {
        "name": "R001 suspicious_activity",
        "site_types": ["power_spike", "camera_offline", "truck_delay"],
        "window_minutes": 30,
        "min_total_severity": 3,
    }
]

# --------------------------------------------------
# 2 HELPERS
# --------------------------------------------------
def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

def parse_iso_timestamp(ts: str) -> datetime:
    ts = ts.strip()
    if ts.endswith("Z"):
        ts = ts[:-1] + "+00:00"
    dt = datetime.fromisoformat(ts)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt

def load_events() -> List[Dict[str, Any]]:
    if not EVENTS_FILE.exists():
        return []
    try:
        data = json.loads(EVENTS_FILE.read_text(encoding="utf-8"))
        if not isinstance(data, list):
            raise ValueError("Events file must contain a JSON list.")
        return data
    except Exception as e:
        raise RuntimeError(f"Failed to load {EVENTS_FILE}: {e}")

def save_events(events: List[Dict[str, Any]]) -> None:
    EVENTS_FILE.write_text(
        json.dumps(events, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )

def strip_id_prefix(text: str) -> str:
    if not text:
        return ""
    return re.sub(r"^[AER]\d{3}\s+", "", text).strip()

def get_next_event_id(events: List[Dict[str, Any]]) -> str:
    max_n = 0
    for e in events:
        note = str(e.get("note", ""))
        m = re.match(r"^E(\d{3})\b", note)
        if m:
            max_n = max(max_n, int(m.group(1)))
    return f"E{max_n + 1:03d}"

# def format_event_note(event_id: str, note: str) -> str:
#     clean = strip_id_prefix(note)
#     return f"{event_id} {clean}".strip()

def format_event_note(event_id: str, note: str) -> str:
    clean = strip_id_prefix(note)
    if not clean:
        clean = "no_note"
    return f"{event_id} {clean}"

def validate_event(event: Dict[str, Any]) -> List[str]:
    errors: List[str] = []

    if not isinstance(event, dict):
        return ["Event must be a JSON object."]

    for key in REQUIRED_KEYS:
        if key not in event:
            errors.append(f"Missing required key: '{key}'.")

    for key in event.keys():
        if key not in ALLOWED_TOP_KEYS:
            errors.append(f"Unexpected key: '{key}'.")

    if "site" in event and not isinstance(event["site"], str):
        errors.append("'site' must be a string.")

    if "type" in event and not isinstance(event["type"], str):
        errors.append("'type' must be a string.")

    if "severity" in event:
        if not isinstance(event["severity"], int):
            errors.append("'severity' must be an integer.")
        elif event["severity"] < 0:
            errors.append("'severity' must be >= 0.")

    if "note" in event and not isinstance(event["note"], str):
        errors.append("'note' must be a string.")

    if "timestamp" in event:
        if not isinstance(event["timestamp"], str):
            errors.append("'timestamp' must be a string.")
        else:
            try:
                parse_iso_timestamp(event["timestamp"])
            except Exception:
                errors.append("'timestamp' must be valid ISO format.")

    return errors

def normalize_event(event: Dict[str, Any], existing_events: List[Dict[str, Any]]) -> Dict[str, Any]:
    out = dict(event)
    if "timestamp" not in out:
        out["timestamp"] = utc_now_iso()
    if "note" not in out:
        out["note"] = ""

    event_id = get_next_event_id(existing_events)
    out["note"] = format_event_note(event_id, out["note"])
    return out

def print_usage() -> None:
    print(
        "Usage:\n"
        "  python pal_core_01_detect_ids.py ingest '<json_event>'\n"
        "  python pal_core_01_detect_ids.py scan\n"
        "  python pal_core_01_detect_ids.py demo\n\n"
        "Examples:\n"
        '  python pal_core_01_detect_ids.py ingest "{\\"site\\":\\"A\\",\\"type\\":\\"power_spike\\",\\"severity\\":2}"\n'
        "  python pal_core_01_detect_ids.py scan\n"
        "  python pal_core_01_detect_ids.py demo"
    )

# --------------------------------------------------
# 3 COMMAND: INGEST
# --------------------------------------------------
def cmd_ingest(event_json_text: str) -> None:
    try:
        event = json.loads(event_json_text)
    except Exception as e:
        print("INGEST FAILED")
        print(f"Invalid JSON input: {e}")
        return

    errors = validate_event(event)
    if errors:
        print("INGEST FAILED")
        for err in errors:
            print(f"- {err}")
        return

    events = load_events()
    event = normalize_event(event, events)

    events.append(event)
    save_events(events)

    print("INGEST OK")
    print(f"Saved to: {EVENTS_FILE.resolve()}")
    print("Event:")
    print(json.dumps(event, indent=2, ensure_ascii=False))

# --------------------------------------------------
# 4 DETECTION ENGINE
# --------------------------------------------------
def group_events_by_site(events: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
    out: Dict[str, List[Dict[str, Any]]] = {}
    for e in events:
        site = e["site"]
        out.setdefault(site, []).append(e)
    return out

def compute_confidence(min_total_severity: int, actual_total_severity: int) -> float:
    if min_total_severity <= 0:
        return 0.5
    ratio = actual_total_severity / float(min_total_severity)
    return round(max(0.50, min(0.99, 0.50 + 0.20 * (ratio - 1.0))), 2)

def get_next_alert_id(existing_alert_count: int) -> str:
    return f"A{existing_alert_count + 1:03d}"

def detect_rule_for_site(
    site: str,
    site_events: List[Dict[str, Any]],
    rule: Dict[str, Any],
    alert_start_index: int,
) -> List[Dict[str, Any]]:
    alerts: List[Dict[str, Any]] = []

    required_types = list(rule["site_types"])
    window_minutes = int(rule["window_minutes"])
    min_total_severity = int(rule["min_total_severity"])

    sorted_events = sorted(site_events, key=lambda e: parse_iso_timestamp(e["timestamp"]))

    for anchor_event in sorted_events:
        anchor_time = parse_iso_timestamp(anchor_event["timestamp"])
        window_start = anchor_time - timedelta(minutes=window_minutes)

        window_events = [
            e for e in sorted_events
            if window_start <= parse_iso_timestamp(e["timestamp"]) <= anchor_time
        ]

        types_in_window = {e["type"] for e in window_events}
        if not all(req_type in types_in_window for req_type in required_types):
            continue

        matched_events: List[Dict[str, Any]] = []
        for req_type in required_types:
            candidates = [e for e in window_events if e["type"] == req_type]
            chosen = sorted(candidates, key=lambda e: parse_iso_timestamp(e["timestamp"]))[-1]
            matched_events.append(chosen)

        matched_events = sorted(matched_events, key=lambda e: parse_iso_timestamp(e["timestamp"]))
        total_severity = sum(int(e["severity"]) for e in matched_events)
        if total_severity < min_total_severity:
            continue

        first_time = parse_iso_timestamp(matched_events[0]["timestamp"])
        last_time = parse_iso_timestamp(matched_events[-1]["timestamp"])
        span_minutes = (last_time - first_time).total_seconds() / 60.0

        alert_id = get_next_alert_id(alert_start_index + len(alerts))
        alert = {
            "alert_id": alert_id,
            "alert_type": rule["name"],
            "site": site,
            "matched_events": [
                {
                    "type": e["type"],
                    "severity": e["severity"],
                    "timestamp": e["timestamp"],
                    "note": e.get("note", ""),
                }
                for e in matched_events
            ],
            "matched_event_ids": [
                str(e.get("note", "")).split(" ", 1)[0] if str(e.get("note", "")).startswith("E") else ""
                for e in matched_events
            ],
            "total_severity": total_severity,
            "confidence": compute_confidence(min_total_severity, total_severity),
            "reason": (
                f"{alert_id} Matched rule {rule['name']} within {window_minutes} minutes "
                f"(actual span: {round(span_minutes, 1)} minutes)."
            ),
        }
        alerts.append(alert)

    deduped: List[Dict[str, Any]] = []
    seen = set()
    for alert in alerts:
        key = (
            alert["alert_type"],
            alert["site"],
            tuple((e["type"], e["timestamp"]) for e in alert["matched_events"]),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(alert)

    return deduped

def detect_alerts(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    alerts: List[Dict[str, Any]] = []
    by_site = group_events_by_site(events)

    for site, site_events in by_site.items():
        for rule in RULES:
            site_alerts = detect_rule_for_site(site, site_events, rule, len(alerts))
            alerts.extend(site_alerts)

    return alerts

# --------------------------------------------------
# 5 COMMAND: SCAN
# --------------------------------------------------
def cmd_scan() -> None:
    events = load_events()

    if not events:
        print("SCAN FAILED")
        print("No events stored yet.")
        return

    print("=== STORED EVENTS ===")
    print(json.dumps(events, indent=2, ensure_ascii=False))

    alerts = detect_alerts(events)

    print("\n=== RULES ===")
    print(json.dumps(RULES, indent=2, ensure_ascii=False))

    print("\n=== ALERTS ===")
    if not alerts:
        print("[]")
        return

    print(json.dumps(alerts, indent=2, ensure_ascii=False))

# --------------------------------------------------
# 6 COMMAND: DEMO
# --------------------------------------------------
def demo_events() -> List[Dict[str, Any]]:
    base = datetime(2026, 4, 11, 10, 0, 0, tzinfo=timezone.utc)

    def ts(minutes: int) -> str:
        return (base + timedelta(minutes=minutes)).isoformat()

    raw = [
        {
            "site": "A",
            "type": "power_spike",
            "severity": 2,
            "note": "unexpected jump in electricity usage",
            "timestamp": ts(0),
        },
        {
            "site": "A",
            "type": "camera_offline",
            "severity": 1,
            "note": "camera feed lost",
            "timestamp": ts(8),
        },
        {
            "site": "A",
            "type": "truck_delay",
            "severity": 2,
            "note": "vehicle delay near site",
            "timestamp": ts(20),
        },
        {
            "site": "B",
            "type": "power_spike",
            "severity": 1,
            "note": "minor fluctuation",
            "timestamp": ts(3),
        },
        {
            "site": "B",
            "type": "truck_delay",
            "severity": 1,
            "note": "small traffic delay",
            "timestamp": ts(40),
        },
    ]

    events: List[Dict[str, Any]] = []
    for item in raw:
        events.append(normalize_event(item, events))
    return events

def cmd_demo() -> None:
    events = demo_events()
    save_events(events)

    print("DEMO OK")
    print(f"Saved demo events to: {EVENTS_FILE.resolve()}")
    print("\n=== STORED EVENTS ===")
    print(json.dumps(events, indent=2, ensure_ascii=False))

    alerts = detect_alerts(events)

    print("\n=== ALERTS ===")
    print(json.dumps(alerts, indent=2, ensure_ascii=False))

# --------------------------------------------------
# 7 MAIN
# --------------------------------------------------
def main() -> None:
    if len(sys.argv) < 2:
        print_usage()
        return

    command = sys.argv[1].strip().lower()

    if command == "ingest":
        if len(sys.argv) < 3:
            print("Missing JSON event for ingest.\n")
            print_usage()
            return
        cmd_ingest(sys.argv[2])

    elif command == "scan":
        cmd_scan()

    elif command == "demo":
        cmd_demo()

    else:
        print(f"Unknown command: {command}\n")
        print_usage()

if __name__ == "__main__":
    main()
#545 (1) result attached (seems ok)
(2) could create a modified code version that names events, rules, alerts?
  - for each event, add "E00x" to the beginning of the note
  - for each rule, add "R00x" to the beginning of name
  - for each alert, add "A00x" to the beginning of the note
x = 0,1,2,3....
i will run this again.
python pal_core_01_detect_ids.py demo
$ python pal_core_01_detect_ids.py demo
DEMO OK
Saved demo events to: C:\Users\terry\Downloads\d1_agent\pal_core_events.json

=== STORED EVENTS ===
[
  {
    "site": "A",
    "type": "power_spike",
    "severity": 2,
    "note": "E001 unexpected jump in electricity usage",
    "timestamp": "2026-04-11T10:00:00+00:00"
  },
  {
    "site": "A",
    "type": "camera_offline",
    "severity": 1,
    "note": "E002 camera feed lost",
    "timestamp": "2026-04-11T10:08:00+00:00"
  },
  {
    "site": "A",
    "type": "truck_delay",
    "severity": 2,
    "note": "E003 vehicle delay near site",
    "timestamp": "2026-04-11T10:20:00+00:00"
  },
  {
    "site": "B",
    "type": "power_spike",
    "severity": 1,
    "note": "E004 minor fluctuation",
    "timestamp": "2026-04-11T10:03:00+00:00"
  },
  {
    "site": "B",
    "type": "truck_delay",
    "severity": 1,
    "note": "E005 small traffic delay",
    "timestamp": "2026-04-11T10:40:00+00:00"
  }
]

=== ALERTS ===
[
  {
    "alert_id": "A001",
    "alert_type": "R001 suspicious_activity",
    "site": "A",
    "matched_events": [
      {
        "type": "power_spike",
        "severity": 2,
        "timestamp": "2026-04-11T10:00:00+00:00",
        "note": "E001 unexpected jump in electricity usage"
      },
      {
        "type": "camera_offline",
        "severity": 1,
        "timestamp": "2026-04-11T10:08:00+00:00",
        "note": "E002 camera feed lost"
      },
      {
        "type": "truck_delay",
        "severity": 2,
        "timestamp": "2026-04-11T10:20:00+00:00",
        "note": "E003 vehicle delay near site"
      }
    ],
    "matched_event_ids": [
      "E001",
      "E002",
      "E003"
    ],
    "total_severity": 5,
    "confidence": 0.63,
    "reason": "A001 Matched rule R001 suspicious_activity within 30 minutes (actual span: 20.0 minutes)."
  }
]
(venv) 
terry@LAPTOP-HKPDHF7M MINGW64 ~/Downloads/d1_agent (main)

python pal_core_01_detect_ids.py scan
$ python pal_core_01_detect_ids.py scan
=== STORED EVENTS ===
[
  {
    "site": "A",
    "type": "power_spike",
    "severity": 2,
    "note": "E001 unexpected jump in electricity usage",
    "timestamp": "2026-04-11T10:00:00+00:00"
  },
  {
    "site": "A",
    "type": "camera_offline",
    "severity": 1,
    "note": "E002 camera feed lost",
    "timestamp": "2026-04-11T10:08:00+00:00"
  },
  {
    "site": "A",
    "type": "truck_delay",
    "severity": 2,
    "note": "E003 vehicle delay near site",
    "timestamp": "2026-04-11T10:20:00+00:00"
  },
  {
    "site": "B",
    "type": "power_spike",
    "severity": 1,
    "note": "E004 minor fluctuation",
    "timestamp": "2026-04-11T10:03:00+00:00"
  },
  {
    "site": "B",
    "type": "truck_delay",
    "severity": 1,
    "note": "E005 small traffic delay",
    "timestamp": "2026-04-11T10:40:00+00:00"
  }
]

=== RULES ===
[
  {
    "name": "R001 suspicious_activity",
    "site_types": [
      "power_spike",
      "camera_offline",
      "truck_delay"
    ],
    "window_minutes": 30,
    "min_total_severity": 3
  }
]

=== ALERTS ===
[
  {
    "alert_id": "A001",
    "alert_type": "R001 suspicious_activity",
    "site": "A",
    "matched_events": [
      {
        "type": "power_spike",
        "severity": 2,
        "timestamp": "2026-04-11T10:00:00+00:00",
        "note": "E001 unexpected jump in electricity usage"
      },
      {
        "type": "camera_offline",
        "severity": 1,
        "timestamp": "2026-04-11T10:08:00+00:00",
        "note": "E002 camera feed lost"
      },
      {
        "type": "truck_delay",
        "severity": 2,
        "timestamp": "2026-04-11T10:20:00+00:00",
        "note": "E003 vehicle delay near site"
      }
    ],
    "matched_event_ids": [
      "E001",
      "E002",
      "E003"
    ],
    "total_severity": 5,
    "confidence": 0.63,
    "reason": "A001 Matched rule R001 suspicious_activity within 30 minutes (actual span: 20.0 minutes)."
  }
]
(venv) 
terry@LAPTOP-HKPDHF7M MINGW64 ~/Downloads/d1_agent (main)

python pal_core_01_detect_ids.py ingest "{\"site\":\"A\",\"type\":\"power_spike\",\"severity\":2}"
$ python pal_core_01_detect_ids.py ingest "{\"site\":\"A\",\"type\":\"power_spike\",\"severity\":2}"
INGEST OK
Saved to: C:\Users\terry\Downloads\d1_agent\pal_core_events.json
Event:
{
  "site": "A",
  "type": "power_spike",
  "severity": 2,
  "timestamp": "2026-04-11T20:38:50.999465+00:00",
  "note": "E006 no_note"
}
(venv) 
terry@LAPTOP-HKPDHF7M MINGW64 ~/Downloads/d1_agent (main)

python pal_core_01_detect_ids.py scan
$ python pal_core_01_detect_ids.py scan
=== STORED EVENTS ===
[
  {
    "site": "A",
    "type": "power_spike",
    "severity": 2,
    "note": "E001 unexpected jump in electricity usage",
    "timestamp": "2026-04-11T10:00:00+00:00"
  },
  {
    "site": "A",
    "type": "camera_offline",
    "severity": 1,
    "note": "E002 camera feed lost",
    "timestamp": "2026-04-11T10:08:00+00:00"
  },
  {
    "site": "A",
    "type": "truck_delay",
    "severity": 2,
    "note": "E003 vehicle delay near site",
    "timestamp": "2026-04-11T10:20:00+00:00"
  },
  {
    "site": "B",
    "type": "power_spike",
    "severity": 1,
    "note": "E004 minor fluctuation",
    "timestamp": "2026-04-11T10:03:00+00:00"
  },
  {
    "site": "B",
    "type": "truck_delay",
    "severity": 1,
    "note": "E005 small traffic delay",
    "timestamp": "2026-04-11T10:40:00+00:00"
  },
  {
    "site": "A",
    "type": "power_spike",
    "severity": 2,
    "timestamp": "2026-04-11T20:38:50.999465+00:00",
    "note": "E006 no_note"
  }
]

=== RULES ===
[
  {
    "name": "R001 suspicious_activity",
    "site_types": [
      "power_spike",
      "camera_offline",
      "truck_delay"
    ],
    "window_minutes": 30,
    "min_total_severity": 3
  }
]

=== ALERTS ===
[
  {
    "alert_id": "A001",
    "alert_type": "R001 suspicious_activity",
    "site": "A",
    "matched_events": [
      {
        "type": "power_spike",
        "severity": 2,
        "timestamp": "2026-04-11T10:00:00+00:00",
        "note": "E001 unexpected jump in electricity usage"
      },
      {
        "type": "camera_offline",
        "severity": 1,
        "timestamp": "2026-04-11T10:08:00+00:00",
        "note": "E002 camera feed lost"
      },
      {
        "type": "truck_delay",
        "severity": 2,
        "timestamp": "2026-04-11T10:20:00+00:00",
        "note": "E003 vehicle delay near site"
      }
    ],
    "matched_event_ids": [
      "E001",
      "E002",
      "E003"
    ],
    "total_severity": 5,
    "confidence": 0.63,
    "reason": "A001 Matched rule R001 suspicious_activity within 30 minutes (actual span: 20.0 minutes)."
  }
]
(venv) 
terry@LAPTOP-HKPDHF7M MINGW64 ~/Downloads/d1_agent (main)
#546 Looks clean now.
What you have is a solid v1:
•	E00x event labeling works 
•	R00x rule labeling works 
•	A00x alert labeling works 
•	matched_event_ids works 
•	empty-note ingest is now handled nicely 
One nice side effect: this makes the demo much easier to narrate:
Events E001, E002, and E003 satisfied rule R001, which generated alert A001.
That is exactly the kind of traceability you want for PAL-style demos.
My recommendation: freeze this as the working pal_core_01_detect_ids.py v1 and move next to either:
•	add a clear command, or 
•	start pal_core_02_predict.py 
I would lean toward pal_core_02_predict.py next.



26.0618