Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions spec/ai_incident_spec.cr
Original file line number Diff line number Diff line change
@@ -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
90 changes: 90 additions & 0 deletions src/placeos-models/ai_incident.cr
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/placeos-models/ai_support/ai_agent_run.cr
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions src/placeos-models/ai_support/ai_approval_request.cr
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions src/placeos-models/ai_support/ai_correlation_finding.cr
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading