From 0de9bede3136fc47d09ea458cf6182d821ab7960 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Thu, 30 Jul 2026 14:00:58 +0800 Subject: [PATCH] feat: [PPT-2596] Add ai support agent models --- ...7050000000_add_ai_support_agent_tables.sql | 346 ++++++++++++++++++ spec/ai_incident_spec.cr | 223 +++++++++++ src/placeos-models/ai_incident.cr | 90 +++++ src/placeos-models/ai_support/ai_agent_run.cr | 21 ++ .../ai_support/ai_approval_request.cr | 25 ++ .../ai_support/ai_correlation_finding.cr | 28 ++ .../ai_support/ai_escalation_record.cr | 26 ++ .../ai_support/ai_incident_event.cr | 19 + .../ai_support/ai_incident_feedback.cr | 18 + .../ai_support/ai_incident_report.cr | 23 ++ .../ai_support/ai_maintenance_run.cr | 22 ++ .../ai_support/ai_report_delivery.cr | 19 + .../ai_support/ai_trend_report.cr | 19 + .../ai_support/ai_verification_run.cr | 24 ++ 14 files changed, 903 insertions(+) create mode 100644 migration/db/migrations/202607050000000_add_ai_support_agent_tables.sql create mode 100644 spec/ai_incident_spec.cr create mode 100644 src/placeos-models/ai_incident.cr create mode 100644 src/placeos-models/ai_support/ai_agent_run.cr create mode 100644 src/placeos-models/ai_support/ai_approval_request.cr create mode 100644 src/placeos-models/ai_support/ai_correlation_finding.cr create mode 100644 src/placeos-models/ai_support/ai_escalation_record.cr create mode 100644 src/placeos-models/ai_support/ai_incident_event.cr create mode 100644 src/placeos-models/ai_support/ai_incident_feedback.cr create mode 100644 src/placeos-models/ai_support/ai_incident_report.cr create mode 100644 src/placeos-models/ai_support/ai_maintenance_run.cr create mode 100644 src/placeos-models/ai_support/ai_report_delivery.cr create mode 100644 src/placeos-models/ai_support/ai_trend_report.cr create mode 100644 src/placeos-models/ai_support/ai_verification_run.cr diff --git a/migration/db/migrations/202607050000000_add_ai_support_agent_tables.sql b/migration/db/migrations/202607050000000_add_ai_support_agent_tables.sql new file mode 100644 index 00000000..30c23566 --- /dev/null +++ b/migration/db/migrations/202607050000000_add_ai_support_agent_tables.sql @@ -0,0 +1,346 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +CREATE TABLE IF NOT EXISTS "ai_incidents" ( + id TEXT NOT NULL PRIMARY KEY, + status TEXT NOT NULL, + severity TEXT NOT NULL, + source TEXT NOT NULL, + classification TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, + summary TEXT NOT NULL DEFAULT '', + correlation_key TEXT NOT NULL, + report_schema_version TEXT NOT NULL DEFAULT 'incident-report.v1', + tenant_id TEXT, + system_id TEXT, + module_id TEXT, + module_name TEXT, + module_index INTEGER, + duplicate_count INTEGER NOT NULL DEFAULT 0, + last_seen_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + claim_token TEXT, + claim_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT ai_incidents_claim_state_check CHECK ( + (status = 'investigating' AND claim_token IS NOT NULL AND claim_expires_at IS NOT NULL) + OR + (status <> 'investigating' AND claim_token IS NULL AND claim_expires_at IS NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ai_incidents_correlation_key_index ON "ai_incidents" USING BTREE (correlation_key); +CREATE UNIQUE INDEX IF NOT EXISTS ai_incidents_active_correlation_key_index ON "ai_incidents" USING BTREE (correlation_key) WHERE resolved_at IS NULL; +CREATE INDEX IF NOT EXISTS ai_incidents_status_index ON "ai_incidents" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_incidents_tenant_id_index ON "ai_incidents" USING BTREE (tenant_id); +CREATE INDEX IF NOT EXISTS ai_incidents_system_id_index ON "ai_incidents" USING BTREE (system_id); +CREATE INDEX IF NOT EXISTS ai_incidents_module_id_index ON "ai_incidents" USING BTREE (module_id); +CREATE INDEX IF NOT EXISTS ai_incidents_created_at_index ON "ai_incidents" USING BTREE (created_at); +CREATE INDEX IF NOT EXISTS ai_incidents_last_seen_at_index ON "ai_incidents" USING BTREE (last_seen_at); +CREATE INDEX IF NOT EXISTS ai_incidents_resolved_at_index ON "ai_incidents" USING BTREE (resolved_at); +CREATE INDEX IF NOT EXISTS ai_incidents_claim_expires_at_index ON "ai_incidents" USING BTREE (claim_expires_at) WHERE status = 'investigating'; + +CREATE TABLE IF NOT EXISTS "ai_incident_events" ( + id bigint PRIMARY KEY, + incident_id TEXT NOT NULL, + source TEXT NOT NULL, + severity TEXT NOT NULL, + correlation_key TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + received_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS public.ai_incident_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.ai_incident_events_id_seq OWNED BY "ai_incident_events".id; +ALTER TABLE ONLY "ai_incident_events" ALTER COLUMN id SET DEFAULT nextval('public.ai_incident_events_id_seq'::regclass); + +CREATE INDEX IF NOT EXISTS ai_incident_events_incident_id_index ON "ai_incident_events" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_incident_events_correlation_key_index ON "ai_incident_events" USING BTREE (correlation_key); +CREATE INDEX IF NOT EXISTS ai_incident_events_received_at_index ON "ai_incident_events" USING BTREE (received_at); +CREATE INDEX IF NOT EXISTS ai_incident_events_payload_index ON "ai_incident_events" USING GIN (payload jsonb_path_ops); + +CREATE TABLE IF NOT EXISTS "ai_incident_reports" ( + id bigint PRIMARY KEY, + incident_id TEXT NOT NULL, + report_schema_version TEXT NOT NULL DEFAULT 'incident-report.v1', + status TEXT NOT NULL, + classification TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, + report_json JSONB NOT NULL DEFAULT '{}'::jsonb, + evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb, + investigation_json JSONB NOT NULL DEFAULT '[]'::jsonb, + decision_json JSONB, + markdown TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS public.ai_incident_reports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.ai_incident_reports_id_seq OWNED BY "ai_incident_reports".id; +ALTER TABLE ONLY "ai_incident_reports" ALTER COLUMN id SET DEFAULT nextval('public.ai_incident_reports_id_seq'::regclass); + +CREATE INDEX IF NOT EXISTS ai_incident_reports_incident_id_index ON "ai_incident_reports" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_incident_reports_status_index ON "ai_incident_reports" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_incident_reports_classification_index ON "ai_incident_reports" USING BTREE (classification); +CREATE INDEX IF NOT EXISTS ai_incident_reports_created_at_index ON "ai_incident_reports" USING BTREE (created_at); +CREATE INDEX IF NOT EXISTS ai_incident_reports_report_json_index ON "ai_incident_reports" USING GIN (report_json jsonb_path_ops); + +CREATE TABLE IF NOT EXISTS "ai_agent_runs" ( + id bigint PRIMARY KEY, + incident_id TEXT NOT NULL, + correlation_key TEXT NOT NULL, + classification TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, + plan_json JSONB, + investigation_json JSONB NOT NULL DEFAULT '[]'::jsonb, + decision_json JSONB, + remediation_proposal_json JSONB, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS public.ai_agent_runs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.ai_agent_runs_id_seq OWNED BY "ai_agent_runs".id; +ALTER TABLE ONLY "ai_agent_runs" ALTER COLUMN id SET DEFAULT nextval('public.ai_agent_runs_id_seq'::regclass); + +CREATE INDEX IF NOT EXISTS ai_agent_runs_incident_id_index ON "ai_agent_runs" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_agent_runs_correlation_key_index ON "ai_agent_runs" USING BTREE (correlation_key); +CREATE INDEX IF NOT EXISTS ai_agent_runs_classification_index ON "ai_agent_runs" USING BTREE (classification); + +CREATE TABLE IF NOT EXISTS "ai_approval_requests" ( + id TEXT NOT NULL PRIMARY KEY, + incident_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + requested_by TEXT NOT NULL, + request_note TEXT, + decided_by TEXT, + decision_note TEXT, + proposal_json JSONB NOT NULL DEFAULT '{}'::jsonb, + execution_mode TEXT NOT NULL DEFAULT 'approval_only', + decided_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS ai_approval_requests_incident_id_index ON "ai_approval_requests" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_approval_requests_status_index ON "ai_approval_requests" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_approval_requests_requested_by_index ON "ai_approval_requests" USING BTREE (requested_by); +CREATE INDEX IF NOT EXISTS ai_approval_requests_created_at_index ON "ai_approval_requests" USING BTREE (created_at); +CREATE INDEX IF NOT EXISTS ai_approval_requests_proposal_json_index ON "ai_approval_requests" USING GIN (proposal_json jsonb_path_ops); + +CREATE TABLE IF NOT EXISTS "ai_report_deliveries" ( + id bigint PRIMARY KEY, + incident_id TEXT NOT NULL, + status TEXT NOT NULL, + destination TEXT NOT NULL, + attempted_at TIMESTAMPTZ NOT NULL, + response_status INTEGER, + error TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS public.ai_report_deliveries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.ai_report_deliveries_id_seq OWNED BY "ai_report_deliveries".id; +ALTER TABLE ONLY "ai_report_deliveries" ALTER COLUMN id SET DEFAULT nextval('public.ai_report_deliveries_id_seq'::regclass); + +CREATE INDEX IF NOT EXISTS ai_report_deliveries_incident_id_index ON "ai_report_deliveries" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_report_deliveries_status_index ON "ai_report_deliveries" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_report_deliveries_destination_index ON "ai_report_deliveries" USING BTREE (destination); +CREATE INDEX IF NOT EXISTS ai_report_deliveries_attempted_at_index ON "ai_report_deliveries" USING BTREE (attempted_at); + +CREATE TABLE IF NOT EXISTS "ai_verification_runs" ( + id TEXT NOT NULL PRIMARY KEY, + incident_id TEXT NOT NULL, + procedure_id TEXT NOT NULL, + procedure_version INTEGER NOT NULL, + procedure_hash TEXT NOT NULL, + attempt INTEGER NOT NULL, + status TEXT NOT NULL, + checks_json JSONB NOT NULL DEFAULT '[]'::jsonb, + evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NOT NULL, + next_retry_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS ai_verification_runs_incident_id_index ON "ai_verification_runs" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_verification_runs_status_index ON "ai_verification_runs" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_verification_runs_procedure_id_index ON "ai_verification_runs" USING BTREE (procedure_id); +CREATE INDEX IF NOT EXISTS ai_verification_runs_started_at_index ON "ai_verification_runs" USING BTREE (started_at); +CREATE INDEX IF NOT EXISTS ai_verification_runs_next_retry_at_index ON "ai_verification_runs" USING BTREE (next_retry_at); + +CREATE TABLE IF NOT EXISTS "ai_escalation_records" ( + id TEXT NOT NULL PRIMARY KEY, + incident_id TEXT NOT NULL, + procedure_id TEXT NOT NULL, + procedure_version INTEGER NOT NULL, + procedure_hash TEXT NOT NULL, + owner_queue TEXT NOT NULL, + response_sla_minutes INTEGER NOT NULL, + reason TEXT NOT NULL, + required_artefacts_json JSONB NOT NULL DEFAULT '[]'::jsonb, + delivery_status TEXT NOT NULL, + delivery_destination TEXT NOT NULL, + delivery_error TEXT, + response_due_at TIMESTAMPTZ NOT NULL, + escalated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS ai_escalation_records_incident_id_index ON "ai_escalation_records" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_escalation_records_owner_queue_index ON "ai_escalation_records" USING BTREE (owner_queue); +CREATE INDEX IF NOT EXISTS ai_escalation_records_delivery_status_index ON "ai_escalation_records" USING BTREE (delivery_status); +CREATE INDEX IF NOT EXISTS ai_escalation_records_response_due_at_index ON "ai_escalation_records" USING BTREE (response_due_at); + +CREATE TABLE IF NOT EXISTS "ai_maintenance_runs" ( + id TEXT NOT NULL PRIMARY KEY, + procedure_id TEXT NOT NULL, + procedure_version INTEGER NOT NULL, + procedure_hash TEXT NOT NULL, + schedule_bucket BIGINT NOT NULL, + status TEXT NOT NULL, + target_count INTEGER NOT NULL DEFAULT 0, + incident_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb, + classification_counts_json JSONB NOT NULL DEFAULT '{}'::jsonb, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NOT NULL, + error TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS ai_maintenance_runs_window_index ON "ai_maintenance_runs" USING BTREE (procedure_id, procedure_version, schedule_bucket); +CREATE INDEX IF NOT EXISTS ai_maintenance_runs_status_index ON "ai_maintenance_runs" USING BTREE (status); +CREATE INDEX IF NOT EXISTS ai_maintenance_runs_started_at_index ON "ai_maintenance_runs" USING BTREE (started_at); + +CREATE TABLE IF NOT EXISTS "ai_correlation_findings" ( + id TEXT NOT NULL PRIMARY KEY, + deduplication_key TEXT NOT NULL, + kind TEXT NOT NULL, + policy_id TEXT NOT NULL, + policy_version INTEGER NOT NULL, + policy_hash TEXT NOT NULL, + scope_key TEXT NOT NULL, + tenant_id TEXT, + system_id TEXT, + module_id TEXT, + classification TEXT NOT NULL, + incident_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb, + observation_count INTEGER NOT NULL, + transition_count INTEGER NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + window_end TIMESTAMPTZ NOT NULL, + summary TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS ai_correlation_findings_deduplication_key_index ON "ai_correlation_findings" USING BTREE (deduplication_key); +CREATE INDEX IF NOT EXISTS ai_correlation_findings_kind_index ON "ai_correlation_findings" USING BTREE (kind); +CREATE INDEX IF NOT EXISTS ai_correlation_findings_scope_key_index ON "ai_correlation_findings" USING BTREE (scope_key); +CREATE INDEX IF NOT EXISTS ai_correlation_findings_window_start_index ON "ai_correlation_findings" USING BTREE (window_start); + +CREATE TABLE IF NOT EXISTS "ai_incident_feedback" ( + id TEXT NOT NULL PRIMARY KEY, + incident_id TEXT NOT NULL, + rating TEXT NOT NULL, + submitted_by TEXT NOT NULL, + comment TEXT, + submitted_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS ai_incident_feedback_incident_id_index ON "ai_incident_feedback" USING BTREE (incident_id); +CREATE INDEX IF NOT EXISTS ai_incident_feedback_rating_index ON "ai_incident_feedback" USING BTREE (rating); +CREATE INDEX IF NOT EXISTS ai_incident_feedback_submitted_at_index ON "ai_incident_feedback" USING BTREE (submitted_at); + +CREATE TABLE IF NOT EXISTS "ai_trend_reports" ( + id TEXT NOT NULL PRIMARY KEY, + policy_id TEXT NOT NULL, + policy_version INTEGER NOT NULL, + policy_hash TEXT NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + window_end TIMESTAMPTZ NOT NULL, + summary_json JSONB NOT NULL DEFAULT '{}'::jsonb, + markdown TEXT NOT NULL, + generated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS ai_trend_reports_generated_at_index ON "ai_trend_reports" USING BTREE (generated_at); +CREATE INDEX IF NOT EXISTS ai_trend_reports_policy_id_index ON "ai_trend_reports" USING BTREE (policy_id); + +ALTER TABLE ONLY "ai_incident_events" + DROP CONSTRAINT IF EXISTS ai_incident_events_incident_id_fkey; +ALTER TABLE ONLY "ai_incident_events" + ADD CONSTRAINT ai_incident_events_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_incident_reports" + DROP CONSTRAINT IF EXISTS ai_incident_reports_incident_id_fkey; +ALTER TABLE ONLY "ai_incident_reports" + ADD CONSTRAINT ai_incident_reports_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_agent_runs" + DROP CONSTRAINT IF EXISTS ai_agent_runs_incident_id_fkey; +ALTER TABLE ONLY "ai_agent_runs" + ADD CONSTRAINT ai_agent_runs_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_approval_requests" + DROP CONSTRAINT IF EXISTS ai_approval_requests_incident_id_fkey; +ALTER TABLE ONLY "ai_approval_requests" + ADD CONSTRAINT ai_approval_requests_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_report_deliveries" + DROP CONSTRAINT IF EXISTS ai_report_deliveries_incident_id_fkey; +ALTER TABLE ONLY "ai_report_deliveries" + ADD CONSTRAINT ai_report_deliveries_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_verification_runs" + DROP CONSTRAINT IF EXISTS ai_verification_runs_incident_id_fkey; +ALTER TABLE ONLY "ai_verification_runs" + ADD CONSTRAINT ai_verification_runs_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_escalation_records" + DROP CONSTRAINT IF EXISTS ai_escalation_records_incident_id_fkey; +ALTER TABLE ONLY "ai_escalation_records" + ADD CONSTRAINT ai_escalation_records_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +ALTER TABLE ONLY "ai_incident_feedback" + DROP CONSTRAINT IF EXISTS ai_incident_feedback_incident_id_fkey; +ALTER TABLE ONLY "ai_incident_feedback" + ADD CONSTRAINT ai_incident_feedback_incident_id_fkey FOREIGN KEY (incident_id) REFERENCES "ai_incidents"(id) ON DELETE CASCADE; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE IF EXISTS "ai_trend_reports"; +DROP TABLE IF EXISTS "ai_incident_feedback"; +DROP TABLE IF EXISTS "ai_correlation_findings"; +DROP TABLE IF EXISTS "ai_maintenance_runs"; +DROP TABLE IF EXISTS "ai_escalation_records"; +DROP TABLE IF EXISTS "ai_verification_runs"; +DROP TABLE IF EXISTS "ai_report_deliveries"; +DROP TABLE IF EXISTS "ai_approval_requests"; +DROP TABLE IF EXISTS "ai_agent_runs"; +DROP TABLE IF EXISTS "ai_incident_reports"; +DROP TABLE IF EXISTS "ai_incident_events"; +DROP TABLE IF EXISTS "ai_incidents"; diff --git a/spec/ai_incident_spec.cr b/spec/ai_incident_spec.cr new file mode 100644 index 00000000..6ae965bc --- /dev/null +++ b/spec/ai_incident_spec.cr @@ -0,0 +1,223 @@ +require "./helper" + +module PlaceOS::Model + describe AiIncident do + it "provides report lifecycle defaults and validates required fields" do + incident = AiIncident.new + + incident.status.should eq "open" + incident.severity.should eq "info" + incident.source.should eq "webhook" + incident.classification.should eq "unknown" + incident.confidence.should eq 0.0 + incident.summary.should eq "" + incident.report_schema_version.should eq "incident-report.v1" + incident.duplicate_count.should eq 0 + incident.valid?.should be_false + incident.errors.map(&.field).should contain(:correlation_key) + end + + it "persists incident state and report artefacts" do + incident = AiIncident.new + incident.status = "diagnosed" + incident.severity = "warning" + incident.source = "grafana" + incident.classification = "module_runtime_error" + incident.confidence = 0.87 + incident.summary = "The module reported a runtime error" + incident.correlation_key = "module:mod-1234" + incident.tenant_id = "tenant-1234" + incident.system_id = "sys-1234" + incident.module_id = "mod-1234" + incident.module_name = "Display" + incident.module_index = 1 + incident.duplicate_count = 2 + incident.last_seen_at = Time.utc + incident.save! + + event = AiIncidentEvent.new + event.incident_id = incident.id.as(String) + event.source = "grafana" + event.severity = "warning" + event.correlation_key = incident.correlation_key + event.payload = JSON.parse(%({"message":"runtime error"})) + event.received_at = Time.utc + event.save! + + report = AiIncidentReport.new + report.incident_id = incident.id.as(String) + report.status = "diagnosed" + report.classification = incident.classification + report.confidence = incident.confidence + report.report_json = JSON.parse(%({"summary":"The module reported a runtime error"})) + report.evidence_json = JSON.parse(%([{"source":"grafana"}])) + report.investigation_json = JSON.parse(%([{"tool":"module_state"}])) + report.decision_json = JSON.parse(%({"outcome":"report_only"})) + report.markdown = "# Incident report" + report.save! + + run = AiAgentRun.new + run.incident_id = incident.id.as(String) + run.correlation_key = incident.correlation_key + run.classification = incident.classification + run.confidence = incident.confidence + run.plan_json = JSON.parse(%({"steps":["module_state"]})) + run.investigation_json = report.investigation_json + run.decision_json = report.decision_json + run.remediation_proposal_json = JSON.parse(%({"execution_mode":"proposal_only"})) + run.save! + + persisted = AiIncident.find!(incident.id.as(String)) + persisted.status.should eq "diagnosed" + persisted.module_id.should eq "mod-1234" + persisted.duplicate_count.should eq 2 + persisted.events.map(&.id).should contain(event.id) + persisted.reports.map(&.id).should contain(report.id) + persisted.agent_runs.map(&.id).should contain(run.id) + + AiIncidentReport.find!(report.id.as(Int64)).report_json.should eq report.report_json + AiAgentRun.find!(run.id.as(Int64)).remediation_proposal_json.should eq run.remediation_proposal_json + ensure + incident.try &.delete + end + + it "persists incident workflow audit records and associations" do + incident = AiIncident.new + incident.correlation_key = "module:mod-audit" + incident.save! + incident_id = incident.id.as(String) + now = Time.utc + + approval = AiApprovalRequest.new + approval.id = "approval-1" + approval.incident_id = incident_id + approval.requested_by = "operator@example.com" + approval.request_note = "Review this proposal" + approval.proposal_json = JSON.parse(%({"action":"restart_module"})) + approval.save! + + delivery = AiReportDelivery.new + delivery.incident_id = incident_id + delivery.status = "delivered" + delivery.destination = "https://example.test/incidents" + delivery.attempted_at = now + delivery.response_status = 202 + delivery.save! + + verification = AiVerificationRun.new + verification.id = "verification-1" + verification.incident_id = incident_id + verification.procedure_id = "module-runtime-recovery" + verification.procedure_version = 1 + verification.procedure_hash = "verification-hash" + verification.attempt = 1 + verification.status = "passed" + verification.checks_json = JSON.parse(%([{"check":"runtime_error_cleared"}])) + verification.evidence_json = JSON.parse(%([{"value":false}])) + verification.started_at = now + verification.completed_at = now + verification.save! + + escalation = AiEscalationRecord.new + escalation.id = "escalation-1" + escalation.incident_id = incident_id + escalation.procedure_id = "operations-escalation" + escalation.procedure_version = 1 + escalation.procedure_hash = "escalation-hash" + escalation.owner_queue = "building-operations" + escalation.response_sla_minutes = 30 + escalation.reason = "Operator review is required" + escalation.required_artefacts_json = JSON.parse(%(["incident_report"])) + escalation.delivery_status = "delivered" + escalation.delivery_destination = "support@example.com" + escalation.response_due_at = now + 30.minutes + escalation.escalated_at = now + escalation.save! + + feedback = AiIncidentFeedback.new + feedback.id = "feedback-1" + feedback.incident_id = incident_id + feedback.rating = "helpful" + feedback.submitted_by = "operator@example.com" + feedback.comment = "Correct diagnosis" + feedback.submitted_at = now + feedback.save! + + persisted = AiIncident.find!(incident_id) + persisted.approval_requests.map(&.id).should contain(approval.id) + persisted.report_deliveries.map(&.id).should contain(delivery.id) + persisted.verification_runs.map(&.id).should contain(verification.id) + persisted.escalation_records.map(&.id).should contain(escalation.id) + persisted.feedback.map(&.id).should contain(feedback.id) + + incident.delete + + AiApprovalRequest.find?(approval.id.as(String)).should be_nil + AiReportDelivery.find?(delivery.id.as(Int64)).should be_nil + AiVerificationRun.find?(verification.id.as(String)).should be_nil + AiEscalationRecord.find?(escalation.id.as(String)).should be_nil + AiIncidentFeedback.find?(feedback.id.as(String)).should be_nil + ensure + incident.try &.delete + end + end + + describe "AI support operational records" do + it "persists maintenance, correlation, and trend records" do + now = Time.utc + + maintenance = AiMaintenanceRun.new + maintenance.id = "maintenance-1" + maintenance.procedure_id = "daily-runtime-review" + maintenance.procedure_version = 1 + maintenance.procedure_hash = "maintenance-hash" + maintenance.schedule_bucket = 20260730_i64 + maintenance.status = "completed" + maintenance.target_count = 1 + maintenance.incident_ids_json = JSON.parse(%(["incident-1"])) + maintenance.classification_counts_json = JSON.parse(%({"module_runtime_error":1})) + maintenance.started_at = now + maintenance.completed_at = now + maintenance.save! + + finding = AiCorrelationFinding.new + finding.id = "finding-1" + finding.deduplication_key = "runtime-errors:sys-1234" + finding.kind = "recurring_failure" + finding.policy_id = "recurring-runtime-errors" + finding.policy_version = 1 + finding.policy_hash = "correlation-hash" + finding.scope_key = "system:sys-1234" + finding.system_id = "sys-1234" + finding.classification = "module_runtime_error" + finding.incident_ids_json = JSON.parse(%(["incident-1"])) + finding.observation_count = 3 + finding.transition_count = 2 + finding.window_start = now - 1.hour + finding.window_end = now + finding.summary = "Three related runtime errors were observed" + finding.detected_at = now + finding.save! + + trend = AiTrendReport.new + trend.id = "trend-1" + trend.policy_id = "weekly-support-trends" + trend.policy_version = 1 + trend.policy_hash = "trend-hash" + trend.window_start = now - 7.days + trend.window_end = now + trend.summary_json = JSON.parse(%({"incident_count":3})) + trend.markdown = "# Weekly support trends" + trend.generated_at = now + trend.save! + + AiMaintenanceRun.find!("maintenance-1").incident_ids_json.should eq maintenance.incident_ids_json + AiCorrelationFinding.find!("finding-1").deduplication_key.should eq finding.deduplication_key + AiTrendReport.find!("trend-1").summary_json.should eq trend.summary_json + ensure + maintenance.try &.delete + finding.try &.delete + trend.try &.delete + end + end +end diff --git a/src/placeos-models/ai_incident.cr b/src/placeos-models/ai_incident.cr new file mode 100644 index 00000000..82d6e9db --- /dev/null +++ b/src/placeos-models/ai_incident.cr @@ -0,0 +1,90 @@ +require "json" +require "./base/model" +require "./ai_support/**" + +module PlaceOS::Model + class AiIncident < ModelBase + include PlaceOS::Model::Timestamps + + table :ai_incidents + + attribute status : String = "open", sanitize: :text, es_subfield: "keyword" + attribute severity : String = "info", sanitize: :text, es_subfield: "keyword" + attribute source : String = "webhook", sanitize: :text, es_subfield: "keyword" + attribute classification : String = "unknown", sanitize: :text, es_subfield: "keyword" + attribute confidence : Float64 = 0.0 + attribute summary : String = "", sanitize: :common + attribute correlation_key : String, sanitize: :text, es_subfield: "keyword" + attribute report_schema_version : String = "incident-report.v1", sanitize: :text, es_subfield: "keyword" + + attribute tenant_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute system_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute module_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute module_name : String? = nil, sanitize: :common + attribute module_index : Int32? = nil + + attribute duplicate_count : Int32 = 0 + attribute last_seen_at : Time? = nil + attribute resolved_at : Time? = nil + attribute claim_token : String? = nil, sanitize: :text + attribute claim_expires_at : Time? = nil + + has_many( + child_class: AiIncidentEvent, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :events + ) + + has_many( + child_class: AiIncidentReport, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :reports + ) + + has_many( + child_class: AiAgentRun, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :agent_runs + ) + + has_many( + child_class: AiApprovalRequest, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :approval_requests + ) + + has_many( + child_class: AiReportDelivery, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :report_deliveries + ) + + has_many( + child_class: AiVerificationRun, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :verification_runs + ) + + has_many( + child_class: AiEscalationRecord, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :escalation_records + ) + + has_many( + child_class: AiIncidentFeedback, + dependent: :destroy, + foreign_key: "incident_id", + collection_name: :feedback + ) + + validates :status, :severity, :source, :classification, :correlation_key, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_agent_run.cr b/src/placeos-models/ai_support/ai_agent_run.cr new file mode 100644 index 00000000..7664f5f7 --- /dev/null +++ b/src/placeos-models/ai_support/ai_agent_run.cr @@ -0,0 +1,21 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiAgentRun < ModelWithAutoKey + table :ai_agent_runs + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute correlation_key : String, sanitize: :text, es_subfield: "keyword" + attribute classification : String, sanitize: :text, es_subfield: "keyword" + attribute confidence : Float64 = 0.0 + attribute plan_json : JSON::Any? = nil, sanitize: :common + attribute investigation_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute decision_json : JSON::Any? = nil, sanitize: :common + attribute remediation_proposal_json : JSON::Any? = nil, sanitize: :common + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :correlation_key, :classification, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_approval_request.cr b/src/placeos-models/ai_support/ai_approval_request.cr new file mode 100644 index 00000000..49b467d7 --- /dev/null +++ b/src/placeos-models/ai_support/ai_approval_request.cr @@ -0,0 +1,25 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiApprovalRequest < ModelBase + include PlaceOS::Model::Timestamps + + table :ai_approval_requests + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute status : String = "pending", sanitize: :text, es_subfield: "keyword" + attribute requested_by : String, sanitize: :text, es_subfield: "keyword" + attribute request_note : String? = nil, sanitize: :common + attribute decided_by : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute decision_note : String? = nil, sanitize: :common + attribute proposal_json : JSON::Any = JSON::Any.new({} of String => JSON::Any), sanitize: :common + attribute execution_mode : String = "approval_only", sanitize: :text, es_subfield: "keyword" + attribute decided_at : Time? = nil + attribute executed_at : Time? = nil + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :status, :requested_by, :execution_mode, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_correlation_finding.cr b/src/placeos-models/ai_support/ai_correlation_finding.cr new file mode 100644 index 00000000..7e2ca5d2 --- /dev/null +++ b/src/placeos-models/ai_support/ai_correlation_finding.cr @@ -0,0 +1,28 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiCorrelationFinding < ModelBase + table :ai_correlation_findings + + attribute deduplication_key : String, sanitize: :text, es_subfield: "keyword" + attribute kind : String, sanitize: :text, es_subfield: "keyword" + attribute policy_id : String, sanitize: :text, es_subfield: "keyword" + attribute policy_version : Int32 + attribute policy_hash : String, sanitize: :text, es_subfield: "keyword" + attribute scope_key : String, sanitize: :text, es_subfield: "keyword" + attribute tenant_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute system_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute module_id : String? = nil, sanitize: :text, es_subfield: "keyword" + attribute classification : String, sanitize: :text, es_subfield: "keyword" + attribute incident_ids_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute observation_count : Int32 + attribute transition_count : Int32 + attribute window_start : Time + attribute window_end : Time + attribute summary : String, sanitize: :common + attribute detected_at : Time + + validates :deduplication_key, :kind, :policy_id, :policy_hash, :scope_key, :classification, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_escalation_record.cr b/src/placeos-models/ai_support/ai_escalation_record.cr new file mode 100644 index 00000000..ac9bdb5f --- /dev/null +++ b/src/placeos-models/ai_support/ai_escalation_record.cr @@ -0,0 +1,26 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiEscalationRecord < ModelBase + table :ai_escalation_records + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute procedure_id : String, sanitize: :text, es_subfield: "keyword" + attribute procedure_version : Int32 + attribute procedure_hash : String, sanitize: :text, es_subfield: "keyword" + attribute owner_queue : String, sanitize: :text, es_subfield: "keyword" + attribute response_sla_minutes : Int32 + attribute reason : String, sanitize: :common + attribute required_artefacts_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute delivery_status : String, sanitize: :text, es_subfield: "keyword" + attribute delivery_destination : String, sanitize: :text, es_subfield: "keyword" + attribute delivery_error : String? = nil, sanitize: :common + attribute response_due_at : Time + attribute escalated_at : Time + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :procedure_id, :procedure_hash, :owner_queue, :delivery_status, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_incident_event.cr b/src/placeos-models/ai_support/ai_incident_event.cr new file mode 100644 index 00000000..72197fb6 --- /dev/null +++ b/src/placeos-models/ai_support/ai_incident_event.cr @@ -0,0 +1,19 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiIncidentEvent < ModelWithAutoKey + table :ai_incident_events + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute source : String, sanitize: :text, es_subfield: "keyword" + attribute severity : String, sanitize: :text, es_subfield: "keyword" + attribute correlation_key : String, sanitize: :text, es_subfield: "keyword" + attribute payload : JSON::Any = JSON::Any.new({} of String => JSON::Any), sanitize: :common + attribute received_at : Time? = nil + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :source, :severity, :correlation_key, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_incident_feedback.cr b/src/placeos-models/ai_support/ai_incident_feedback.cr new file mode 100644 index 00000000..0665729f --- /dev/null +++ b/src/placeos-models/ai_support/ai_incident_feedback.cr @@ -0,0 +1,18 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiIncidentFeedback < ModelBase + table :ai_incident_feedback + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute rating : String, sanitize: :text, es_subfield: "keyword" + attribute submitted_by : String, sanitize: :text, es_subfield: "keyword" + attribute comment : String? = nil, sanitize: :common + attribute submitted_at : Time + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :rating, :submitted_by, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_incident_report.cr b/src/placeos-models/ai_support/ai_incident_report.cr new file mode 100644 index 00000000..acabce9a --- /dev/null +++ b/src/placeos-models/ai_support/ai_incident_report.cr @@ -0,0 +1,23 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiIncidentReport < ModelWithAutoKey + table :ai_incident_reports + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute report_schema_version : String = "incident-report.v1", sanitize: :text, es_subfield: "keyword" + attribute status : String, sanitize: :text, es_subfield: "keyword" + attribute classification : String, sanitize: :text, es_subfield: "keyword" + attribute confidence : Float64 = 0.0 + attribute report_json : JSON::Any = JSON::Any.new({} of String => JSON::Any), sanitize: :common + attribute evidence_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute investigation_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute decision_json : JSON::Any? = nil, sanitize: :common + attribute markdown : String? = nil, sanitize: :common + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :report_schema_version, :status, :classification, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_maintenance_run.cr b/src/placeos-models/ai_support/ai_maintenance_run.cr new file mode 100644 index 00000000..449d78f7 --- /dev/null +++ b/src/placeos-models/ai_support/ai_maintenance_run.cr @@ -0,0 +1,22 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiMaintenanceRun < ModelBase + table :ai_maintenance_runs + + attribute procedure_id : String, sanitize: :text, es_subfield: "keyword" + attribute procedure_version : Int32 + attribute procedure_hash : String, sanitize: :text, es_subfield: "keyword" + attribute schedule_bucket : Int64 + attribute status : String, sanitize: :text, es_subfield: "keyword" + attribute target_count : Int32 = 0 + attribute incident_ids_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute classification_counts_json : JSON::Any = JSON::Any.new({} of String => JSON::Any), sanitize: :common + attribute started_at : Time + attribute completed_at : Time + attribute error : String? = nil, sanitize: :common + + validates :procedure_id, :procedure_hash, :status, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_report_delivery.cr b/src/placeos-models/ai_support/ai_report_delivery.cr new file mode 100644 index 00000000..96207a3a --- /dev/null +++ b/src/placeos-models/ai_support/ai_report_delivery.cr @@ -0,0 +1,19 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiReportDelivery < ModelWithAutoKey + table :ai_report_deliveries + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute status : String, sanitize: :text, es_subfield: "keyword" + attribute destination : String, sanitize: :text, es_subfield: "keyword" + attribute attempted_at : Time + attribute response_status : Int32? = nil + attribute error : String? = nil, sanitize: :common + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :status, :destination, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_trend_report.cr b/src/placeos-models/ai_support/ai_trend_report.cr new file mode 100644 index 00000000..9e5bddd6 --- /dev/null +++ b/src/placeos-models/ai_support/ai_trend_report.cr @@ -0,0 +1,19 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiTrendReport < ModelBase + table :ai_trend_reports + + attribute policy_id : String, sanitize: :text, es_subfield: "keyword" + attribute policy_version : Int32 + attribute policy_hash : String, sanitize: :text, es_subfield: "keyword" + attribute window_start : Time + attribute window_end : Time + attribute summary_json : JSON::Any = JSON::Any.new({} of String => JSON::Any), sanitize: :common + attribute markdown : String, sanitize: :common + attribute generated_at : Time + + validates :policy_id, :policy_hash, presence: true + end +end diff --git a/src/placeos-models/ai_support/ai_verification_run.cr b/src/placeos-models/ai_support/ai_verification_run.cr new file mode 100644 index 00000000..d684922e --- /dev/null +++ b/src/placeos-models/ai_support/ai_verification_run.cr @@ -0,0 +1,24 @@ +require "json" +require "../base/model" + +module PlaceOS::Model + class AiVerificationRun < ModelBase + table :ai_verification_runs + + attribute incident_id : String, sanitize: :text, es_subfield: "keyword" + attribute procedure_id : String, sanitize: :text, es_subfield: "keyword" + attribute procedure_version : Int32 + attribute procedure_hash : String, sanitize: :text, es_subfield: "keyword" + attribute attempt : Int32 + attribute status : String, sanitize: :text, es_subfield: "keyword" + attribute checks_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute evidence_json : JSON::Any = JSON::Any.new([] of JSON::Any), sanitize: :common + attribute started_at : Time + attribute completed_at : Time + attribute next_retry_at : Time? = nil + + belongs_to AiIncident, foreign_key: "incident_id" + + validates :incident_id, :procedure_id, :procedure_hash, :status, presence: true + end +end