Skip to content

Commit 0b1534f

Browse files
authored
Merge pull request #97 from zestones/29-m47-memory-flex-scene
29 m47 memory flex scene
2 parents 40223d9 + ae9bb4d commit 0b1534f

7 files changed

Lines changed: 348 additions & 1 deletion

File tree

backend/agents/investigator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def _spawn_work_order_generator(work_order_id: int) -> None:
556556
instead of a throwaway stub.
557557
"""
558558
try:
559-
from agents.work_order_generator import ( # type: ignore[import-not-found]
559+
from agents.work_order_generator import ( # type: ignore[import-not-found]
560560
run_work_order_generator,
561561
)
562562
except ImportError:

backend/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class Settings(BaseSettings):
3535
# `model_for()` currently ignores this and routes per use case.
3636
aria_model: str = "sonnet" # "sonnet" | "opus"
3737

38+
# Mounts `modules.demo.router` when true — off by default so production
39+
# deployments do not expose `/api/v1/demo/trigger-memory-scene`. See #29.
40+
aria_demo_enabled: bool = False
41+
3842
@property
3943
def database_dsn(self) -> str:
4044
return (

backend/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ async def health() -> dict[str, str]:
9191
app.include_router(kb_router)
9292
app.include_router(events_router)
9393

94+
if settings.aria_demo_enabled:
95+
# Demo-only routes (#29 memory-flex scene). Mounted behind a flag
96+
# so production deployments do not expose the scene triggers.
97+
from modules.demo.router import router as demo_router
98+
99+
app.include_router(demo_router)
100+
log.info("Demo endpoints enabled at /api/v1/demo/*")
101+
94102
app.mount("/mcp", mcp_http_app)
95103

96104
return app

backend/modules/demo/__init__.py

Whitespace-only changes.

backend/modules/demo/router.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Demo-only routes — mounted in ``main.py`` only when ``ARIA_DEMO_ENABLED=true``.
2+
3+
Issue #29 (M4.7 — Memory flex scene). Provides an idempotent, re-playable
4+
endpoint that:
5+
6+
1. Clears any recent ``failure_history`` for the target cell (so the scene
7+
can be re-triggered during demo rehearsal without accumulating rows).
8+
2. Seeds one past failure dated 3 months ago with a ``signal_patterns``
9+
JSON matching the current anomaly the operator is about to inject.
10+
3. Cancels any still-open agent-generated work order on the cell (so
11+
Sentinel's per-WO debounce window does not swallow the fresh anomaly).
12+
4. Inserts a short burst of high-vibration readings into
13+
``process_signal_data`` so Sentinel's next 30-second tick opens a
14+
``work_order(status='detected')`` and spawns the Investigator.
15+
16+
The Investigator then loads ``get_failure_history(cell_id, limit=5)`` on
17+
startup (#25) and — if the current signal pattern matches the seeded past
18+
failure — cites it in ``submit_rca.similar_past_failure``. That chain is
19+
the "knowledge doesn't retire" demo scene.
20+
21+
Intentionally throwaway: this router is kept separate from production
22+
routers, guarded by an env flag, and carries no Pydantic schemas outside
23+
the inline response dict. Do not import from it in production code.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
import logging
30+
from typing import Any
31+
32+
import asyncpg
33+
from core.database import get_db
34+
from core.security import get_current_user
35+
from fastapi import APIRouter, Body, Depends, HTTPException
36+
37+
log = logging.getLogger("aria.demo")
38+
39+
router = APIRouter(
40+
prefix="/api/v1/demo",
41+
tags=["demo"],
42+
dependencies=[Depends(get_current_user)],
43+
)
44+
45+
46+
_PAST_FAILURE_PATTERN: dict[str, Any] = {"vibration_mm_s": {"peak": 5.4, "duration_min": 14}}
47+
# Short burst of readings — 5 x 30s apart, each above the 4.5 mm/s alert
48+
# threshold seeded in migration 007's P-02 KB. Sentinel's 5-minute window
49+
# captures all of them.
50+
_FRESH_READINGS = [5.05, 5.12, 5.18, 5.22, 5.15]
51+
52+
53+
@router.post("/trigger-memory-scene")
54+
async def trigger_memory_scene(
55+
cell_name: str = Body("P-02", embed=True),
56+
conn: asyncpg.Connection = Depends(get_db),
57+
) -> dict[str, Any]:
58+
"""Prime the "memory flex" demo scene — see module docstring.
59+
60+
Safe to call repeatedly: step 1 clears prior scene state and step 3
61+
cancels any open agent WO so Sentinel's debounce is reset.
62+
"""
63+
cell_row = await conn.fetchrow("SELECT id FROM cell WHERE name = $1", cell_name)
64+
if cell_row is None:
65+
raise HTTPException(status_code=404, detail=f"cell {cell_name!r} not found")
66+
cell_id: int = cell_row["id"]
67+
68+
# Find the signal def that maps to vibration_mm_s — must exist for the
69+
# Sentinel → Investigator chain to pick up the injected readings.
70+
sig_row = await conn.fetchrow(
71+
"""
72+
SELECT id FROM process_signal_definition
73+
WHERE cell_id = $1 AND kb_threshold_key = 'vibration_mm_s'
74+
LIMIT 1
75+
""",
76+
cell_id,
77+
)
78+
if sig_row is None:
79+
raise HTTPException(
80+
status_code=400,
81+
detail=(
82+
f"cell {cell_name!r} has no process_signal_definition with "
83+
"kb_threshold_key='vibration_mm_s'"
84+
),
85+
)
86+
signal_def_id: int = sig_row["id"]
87+
88+
async with conn.transaction():
89+
# 1. Clear recent failure_history so the seeded past-failure is the
90+
# only one the Investigator sees from the last week.
91+
await conn.execute(
92+
"DELETE FROM failure_history WHERE cell_id = $1 "
93+
"AND failure_time > NOW() - INTERVAL '7 days'",
94+
cell_id,
95+
)
96+
97+
# 2. Insert the past-failure row at t-3 months. `encode_fields` is
98+
# overkill here — raw jsonb literal is fine in a demo route.
99+
past_row = await conn.fetchrow(
100+
"""
101+
INSERT INTO failure_history (
102+
cell_id, failure_time, resolved_time,
103+
failure_mode, root_cause, signal_patterns
104+
) VALUES (
105+
$1,
106+
NOW() - INTERVAL '3 months',
107+
NOW() - INTERVAL '3 months' + INTERVAL '4 hours',
108+
'bearing_wear',
109+
'Discharge bearing wear near end-of-life — replaced under PM-2026-01-18.',
110+
$2::jsonb
111+
)
112+
RETURNING id
113+
""",
114+
cell_id,
115+
json.dumps(_PAST_FAILURE_PATTERN),
116+
)
117+
past_failure_id: int = past_row["id"] if past_row is not None else 0
118+
119+
# 3. Cancel any still-open agent-generated WO on this cell so the
120+
# Sentinel per-(cell, signal) debounce window does not swallow
121+
# the fresh anomaly. `detected` and `analyzed` are the statuses
122+
# that block detection per M4.2.
123+
cancelled = await conn.execute(
124+
"""
125+
UPDATE work_order
126+
SET status = 'cancelled', completed_at = NOW()
127+
WHERE cell_id = $1
128+
AND generated_by_agent = TRUE
129+
AND status IN ('detected', 'analyzed', 'open', 'in_progress')
130+
""",
131+
cell_id,
132+
)
133+
134+
# 4. Seed a burst of high-vibration readings backdated 1..5 minutes.
135+
# Sentinel's 5-minute look-back will pick them up on the next tick.
136+
for i, value in enumerate(_FRESH_READINGS):
137+
await conn.execute(
138+
"""
139+
INSERT INTO process_signal_data (time, cell_id, signal_def_id, raw_value)
140+
VALUES (NOW() - (INTERVAL '30 seconds' * $1), $2, $3, $4)
141+
ON CONFLICT (time, signal_def_id) DO NOTHING
142+
""",
143+
len(_FRESH_READINGS) - i,
144+
cell_id,
145+
signal_def_id,
146+
value,
147+
)
148+
149+
log.info(
150+
"memory-scene triggered cell=%s cell_id=%d past_failure_id=%d "
151+
"signal_def_id=%d readings=%d cancelled_wos=%s",
152+
cell_name,
153+
cell_id,
154+
past_failure_id,
155+
signal_def_id,
156+
len(_FRESH_READINGS),
157+
cancelled,
158+
)
159+
160+
return {
161+
"ok": True,
162+
"cell_id": cell_id,
163+
"cell_name": cell_name,
164+
"past_failure_id": past_failure_id,
165+
"signal_def_id": signal_def_id,
166+
"readings_inserted": len(_FRESH_READINGS),
167+
# Sentinel tick is 30s; worst-case latency = one full tick + DB roundtrip.
168+
"expect_anomaly_within_seconds": 35,
169+
}

backend/tests/unit/modules/demo/__init__.py

Whitespace-only changes.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for ``modules.demo.router`` (issue #29 / M4.7 memory flex scene).
2+
3+
Covers the handler's SQL orchestration without touching a live database:
4+
5+
- Returns 404 when the cell does not exist.
6+
- Returns 400 when the cell has no vibration signal_def mapped to
7+
``kb_threshold_key='vibration_mm_s'``.
8+
- Happy path inside a transaction: DELETE recent failure_history, INSERT
9+
one past failure, CANCEL open agent WOs, INSERT the burst of fresh
10+
readings, returns the expected envelope.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
import pytest
18+
from modules.demo import router as demo_router
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Fake asyncpg.Connection surface — only the methods the handler uses.
23+
# ---------------------------------------------------------------------------
24+
25+
26+
class _FakeTxn:
27+
async def __aenter__(self) -> "_FakeTxn":
28+
return self
29+
30+
async def __aexit__(self, *a: Any) -> None:
31+
return None
32+
33+
34+
class _FakeConn:
35+
def __init__(
36+
self,
37+
*,
38+
cell_row: dict[str, Any] | None,
39+
sig_row: dict[str, Any] | None,
40+
past_id: int = 77,
41+
) -> None:
42+
self._cell_row = cell_row
43+
self._sig_row = sig_row
44+
self._past_id = past_id
45+
self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = []
46+
self.execute_calls: list[tuple[str, tuple[Any, ...]]] = []
47+
48+
def transaction(self) -> _FakeTxn:
49+
return _FakeTxn()
50+
51+
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
52+
self.fetchrow_calls.append((query, args))
53+
q = " ".join(query.split())
54+
if "FROM cell WHERE name" in q:
55+
return self._cell_row
56+
if "FROM process_signal_definition" in q:
57+
return self._sig_row
58+
if "INSERT INTO failure_history" in q:
59+
return {"id": self._past_id}
60+
return None
61+
62+
async def execute(self, query: str, *args: Any) -> str:
63+
self.execute_calls.append((query, args))
64+
q = " ".join(query.split())
65+
if "DELETE FROM failure_history" in q:
66+
return "DELETE 0"
67+
if "UPDATE work_order" in q:
68+
return "UPDATE 1"
69+
if "INSERT INTO process_signal_data" in q:
70+
return "INSERT 0 1"
71+
return "OK"
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# Tests
76+
# ---------------------------------------------------------------------------
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_returns_404_when_cell_missing() -> None:
81+
conn = _FakeConn(cell_row=None, sig_row=None)
82+
with pytest.raises(Exception) as excinfo:
83+
await demo_router.trigger_memory_scene(
84+
cell_name="UNKNOWN-99", conn=conn # type: ignore[arg-type]
85+
)
86+
# FastAPI HTTPException — inspect via attribute.
87+
assert getattr(excinfo.value, "status_code", None) == 404
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_returns_400_when_signal_def_missing() -> None:
92+
conn = _FakeConn(cell_row={"id": 2}, sig_row=None)
93+
with pytest.raises(Exception) as excinfo:
94+
await demo_router.trigger_memory_scene(
95+
cell_name="P-02", conn=conn # type: ignore[arg-type]
96+
)
97+
assert getattr(excinfo.value, "status_code", None) == 400
98+
# Nothing written when signal lookup fails (we return before the txn).
99+
assert conn.execute_calls == []
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_happy_path_orchestrates_all_four_steps() -> None:
104+
conn = _FakeConn(cell_row={"id": 2}, sig_row={"id": 55}, past_id=99)
105+
106+
resp = await demo_router.trigger_memory_scene(
107+
cell_name="P-02", conn=conn # type: ignore[arg-type]
108+
)
109+
110+
assert resp["ok"] is True
111+
assert resp["cell_id"] == 2
112+
assert resp["cell_name"] == "P-02"
113+
assert resp["past_failure_id"] == 99
114+
assert resp["signal_def_id"] == 55
115+
# 5 readings seeded by the handler.
116+
assert resp["readings_inserted"] == 5
117+
assert resp["expect_anomaly_within_seconds"] == 35
118+
119+
# Step order inside the txn: DELETE, (fetchrow INSERT failure), UPDATE,
120+
# then 5 x INSERT process_signal_data.
121+
exec_queries = [" ".join(q.split()) for q, _ in conn.execute_calls]
122+
assert any(q.startswith("DELETE FROM failure_history") for q in exec_queries)
123+
assert any(q.startswith("UPDATE work_order") for q in exec_queries)
124+
readings_inserts = [q for q in exec_queries if "INSERT INTO process_signal_data" in q]
125+
assert len(readings_inserts) == 5
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_happy_path_past_failure_row_carries_signal_patterns_jsonb() -> None:
130+
conn = _FakeConn(cell_row={"id": 2}, sig_row={"id": 55})
131+
132+
await demo_router.trigger_memory_scene(
133+
cell_name="P-02", conn=conn # type: ignore[arg-type]
134+
)
135+
136+
insert_calls = [
137+
(q, args)
138+
for q, args in conn.fetchrow_calls
139+
if "INSERT INTO failure_history" in " ".join(q.split())
140+
]
141+
assert len(insert_calls) == 1
142+
_, args = insert_calls[0]
143+
# args[0] = cell_id, args[1] = jsonb string
144+
assert args[0] == 2
145+
import json as _json
146+
147+
patterns = _json.loads(args[1])
148+
assert "vibration_mm_s" in patterns
149+
assert patterns["vibration_mm_s"]["peak"] == pytest.approx(5.4)
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_readings_all_above_alert_threshold() -> None:
154+
conn = _FakeConn(cell_row={"id": 2}, sig_row={"id": 55})
155+
await demo_router.trigger_memory_scene(
156+
cell_name="P-02", conn=conn # type: ignore[arg-type]
157+
)
158+
# All seeded readings must exceed the P-02 vibration alert (4.5 mm/s)
159+
# otherwise Sentinel will not open a work_order and the scene dies.
160+
reading_values = [
161+
args[-1]
162+
for q, args in conn.execute_calls
163+
if "INSERT INTO process_signal_data" in " ".join(q.split())
164+
]
165+
assert reading_values # at least one
166+
assert all(v > 4.5 for v in reading_values)

0 commit comments

Comments
 (0)