From d7913fddaa3de74bf67b6b305fc3d4c15daa77a4 Mon Sep 17 00:00:00 2001 From: malice Date: Fri, 17 Jul 2026 14:06:15 -0600 Subject: [PATCH] feat(dashboard): serve real recommendations in Nodes & Health; give mesh_intelligence one home (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(mesh_reporter): expose recommendations as list[str] Add recommendations_list(scope, scope_value) as the canonical source of recommendation text. build_recommendations() now just joins that list with the historical "OPTIMIZATION RECOMMENDATIONS:" header/bullet format it always used. router.py:1145 injects build_recommendations()'s output into the LLM system prompt on the live mesh-DM path — verified byte-identical before/after across mesh/region/node/missing/empty scopes via a synthetic fixture, and pinned with a literal-string regression test so a future refactor can't silently change that prompt text. This unblocks wiring recommendations into the dashboard, which previously only reached mesh DMs. * feat(dashboard): serve real recommendations from /api/health mesh_reporter was never on app.state — add it alongside health_engine etc. in server.py's existing pattern. mesh_routes.py's health endpoint now returns mesh_reporter.recommendations_list("mesh") instead of a hardcoded [] TODO stub. Add recommendations_available: bool alongside recommendations: string[] so "the engine ran and found nothing" (healthy mesh) is never indistinguishable from "the engine couldn't run" (unwired reporter, or an exception — logged via logger.exception and swallowed so the rest of the health response still serves). A crashed recommendations engine must not read as "mesh is healthy" to an operator. Also delete main.py's phantom `getattr(mh, "recommendations", [])` on the websocket health_update push: nothing ever set .recommendations on the mesh_health object (always []), and no frontend consumer reads it — the REST endpoint above is the supported path. * feat(dashboard-frontend): render recommendations in Nodes & Health Meshtastic → Nodes & Health → Health tab used to be a duplicate mesh_intelligence config editor (the same MeshIntelligenceSection also edited from Settings → Intelligence, a stale-tab-overwrite hazard). Replace it with a real operational view: fetch /api/health and render its recommendations list. Three states, matching visual conventions from Dashboard.tsx's alerts list and MeshCoreRouting.tsx's cross-link note: - recommendations present → Lightbulb list, one card per item - empty + recommendations_available → "No recommendations — mesh is healthy" (CheckCircle) - recommendations_available === false → amber warning, not the healthy state (AlertTriangle) — a crashed backend must not look healthy mesh_intelligence config now has exactly one home: Settings. The Health tab points there via the existing /config?section= deep-link pattern instead of duplicating the editor. Config.tsx itself is untouched. MeshHealth.recommendations/.recommendations_available are optional in the type: the websocket health_update push doesn't include them (REST-only). --------- Co-authored-by: Matt Johnson --- work/dashboard-frontend/src/lib/api.ts | 8 +- .../src/pages/MeshtasticNodes.tsx | 159 ++++++--------- work/meshai/dashboard/api/mesh_routes.py | 21 +- work/meshai/dashboard/server.py | 1 + work/meshai/main.py | 1 - work/meshai/mesh_reporter.py | 20 +- .../test_mesh_health_recommendations_api.py | 136 +++++++++++++ .../test_mesh_reporter_recommendations.py | 188 ++++++++++++++++++ 8 files changed, 432 insertions(+), 102 deletions(-) create mode 100644 work/tests/test_mesh_health_recommendations_api.py create mode 100644 work/tests/test_mesh_reporter_recommendations.py diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index f053979..777d8e3 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -32,7 +32,13 @@ export interface MeshHealth { total_regions: number unlocated_count: number last_computed: string - recommendations: string[] + // Only populated by the REST /api/health response; the websocket + // health_update push includes neither of these (see mesh_routes.py). + recommendations?: string[] + // false when the recommendations engine couldn't run (unwired reporter or + // an exception) — distinct from a genuinely empty `recommendations` list. + // Must NOT be treated the same as "mesh is healthy". + recommendations_available?: boolean } export interface NodeInfo { diff --git a/work/dashboard-frontend/src/pages/MeshtasticNodes.tsx b/work/dashboard-frontend/src/pages/MeshtasticNodes.tsx index 29a972f..8d5e6ee 100644 --- a/work/dashboard-frontend/src/pages/MeshtasticNodes.tsx +++ b/work/dashboard-frontend/src/pages/MeshtasticNodes.tsx @@ -1,11 +1,9 @@ import { useState, useEffect, useCallback } from 'react' -import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' +import { Link } from 'react-router-dom' +import { RefreshCw, Lightbulb, CheckCircle, AlertTriangle, ExternalLink } from 'lucide-react' import Mesh from './Mesh' import MeshtasticSources from './MeshtasticSources' -import { MeshIntelligenceSection, type MeshIntelligenceConfig } from './Config' -import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' -import { useDirty } from '@/context/DirtyContext' -import { notifyRestartRequired } from '@/components/RestartBanner' +import { fetchHealth, type MeshHealth } from '@/lib/api' const TABS = [ { key: 'nodes', label: 'Nodes' }, @@ -19,14 +17,9 @@ export default function MeshtasticNodes() { const [activeTab, setActiveTab] = useState('nodes') // Health tab state - const { setDirty } = useDirty() - const [intelligence, setIntelligence] = useState(null) - const [originalIntelligence, setOriginalIntelligence] = useState(null) + const [health, setHealth] = useState(null) const [loading, setLoading] = useState(false) - const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) - const [hasChanges, setHasChanges] = useState(false) useEffect(() => { document.title = 'Nodes & Health - MeshAI' @@ -36,12 +29,10 @@ export default function MeshtasticNodes() { setLoading(true) setError(null) try { - const data = (await apiFetchConfig('mesh_intelligence')) as MeshIntelligenceConfig - setIntelligence(data) - setOriginalIntelligence(JSON.parse(JSON.stringify(data))) - setHasChanges(false) + const data = await fetchHealth() + setHealth(data) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load mesh intelligence config') + setError(err instanceof Error ? err.message : 'Failed to load mesh health') } finally { setLoading(false) } @@ -49,46 +40,10 @@ export default function MeshtasticNodes() { // Load when Health tab becomes active useEffect(() => { - if (activeTab === 'health' && intelligence === null && !loading) { + if (activeTab === 'health' && health === null && !loading) { fetchData() } - }, [activeTab, intelligence, loading, fetchData]) - - useEffect(() => { - if (intelligence && originalIntelligence) { - setHasChanges(JSON.stringify(intelligence) !== JSON.stringify(originalIntelligence)) - } - }, [intelligence, originalIntelligence]) - - useEffect(() => { - setDirty(hasChanges) - return () => setDirty(false) - }, [hasChanges, setDirty]) - - const saveConfig = async () => { - if (!intelligence) return - setSaving(true) - setError(null) - setSuccess(null) - try { - const result = await apiUpdateConfig('mesh_intelligence', intelligence) - setOriginalIntelligence(JSON.parse(JSON.stringify(intelligence))) - setHasChanges(false) - setDirty(false) - setSuccess('Mesh intelligence saved successfully') - if (result.restart_required) notifyRestartRequired([]) - setTimeout(() => setSuccess(null), 3000) - } catch (err) { - setError(err instanceof Error ? err.message : 'Save failed') - } finally { - setSaving(false) - } - } - - const discardChanges = () => { - if (originalIntelligence) setIntelligence(JSON.parse(JSON.stringify(originalIntelligence))) - setHasChanges(false) - } + }, [activeTab, health, loading, fetchData]) return (
@@ -113,64 +68,78 @@ export default function MeshtasticNodes() { {activeTab === 'nodes' && } {activeTab === 'sources' && } {activeTab === 'health' && ( -
- {/* Save bar */} +
+ {/* Header */}
-
-

- Mesh health scoring, region management, and automated alerting. -

-
-
- - - -
+

+ Optimization recommendations generated from current mesh health. +

+
{/* Status messages */} {error && (
{error}
)} - {success && ( -
- {success} -
- )} {loading ? (
Loading...
- ) : intelligence ? ( -
- + ) : health ? ( +
+

+ Optimization Recommendations +

+ {health.recommendations && health.recommendations.length > 0 ? ( +
+ {health.recommendations.map((rec, i) => ( +
+ + {rec} +
+ ))} +
+ ) : health.recommendations_available === false ? ( +
+ + + Couldn't compute recommendations right now. Check server logs. + +
+ ) : ( +
+ + No recommendations — mesh is healthy. +
+ )}
) : (
-
Failed to load config
+
Failed to load mesh health
)} + + {/* Cross-link note */} +
+ +
+ Health scoring, region management, and alerting thresholds are configured on{' '} + + Settings → Intelligence + + . +
+
)}
diff --git a/work/meshai/dashboard/api/mesh_routes.py b/work/meshai/dashboard/api/mesh_routes.py index 7e3c2c9..9a64b34 100644 --- a/work/meshai/dashboard/api/mesh_routes.py +++ b/work/meshai/dashboard/api/mesh_routes.py @@ -1,11 +1,13 @@ """Mesh health and node API routes.""" +import logging from datetime import datetime from typing import Optional from fastapi import APIRouter, HTTPException, Request router = APIRouter(tags=["mesh"]) +logger = logging.getLogger(__name__) def _serialize_health_score(score) -> dict: @@ -68,6 +70,22 @@ async def get_health(request: Request): health = health_engine.mesh_health score = health.score + # `recommendations_available` distinguishes "the engine ran and found + # nothing" (empty list, mesh is genuinely healthy) from "the engine + # couldn't run" (unwired reporter or an exception) — the two must not + # look identical to the operator. See mesh_reporter.recommendations_list(). + mesh_reporter = getattr(request.app.state, "mesh_reporter", None) + recommendations: list[str] = [] + recommendations_available = True + if mesh_reporter: + try: + recommendations = mesh_reporter.recommendations_list("mesh") + except Exception: + logger.exception("mesh_reporter.recommendations_list failed") + recommendations_available = False + else: + recommendations_available = False + return { "score": round(score.composite, 1), "tier": score.tier, @@ -90,7 +108,8 @@ async def get_health(request: Request): "total_regions": health.total_regions, "unlocated_count": len(health.unlocated_nodes), "last_computed": _format_timestamp(health.last_computed), - "recommendations": [], # TODO: Add recommendations + "recommendations": recommendations, + "recommendations_available": recommendations_available, } diff --git a/work/meshai/dashboard/server.py b/work/meshai/dashboard/server.py index d6b8aa7..0a929e0 100644 --- a/work/meshai/dashboard/server.py +++ b/work/meshai/dashboard/server.py @@ -132,6 +132,7 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster: app.state.config_path = meshai_instance.config._config_path app.state.data_store = meshai_instance.data_store app.state.health_engine = meshai_instance.health_engine + app.state.mesh_reporter = getattr(meshai_instance, "mesh_reporter", None) app.state.alert_engine = getattr(meshai_instance, "alert_engine", None) app.state.env_store = getattr(meshai_instance, "env_store", None) app.state.notification_router = getattr(meshai_instance, "notification_router", None) diff --git a/work/meshai/main.py b/work/meshai/main.py index c3b85bc..6f30961 100644 --- a/work/meshai/main.py +++ b/work/meshai/main.py @@ -183,7 +183,6 @@ class MeshAI: "total_regions": mh.total_regions, "unlocated_count": getattr(mh, "unlocated_count", 0), "last_computed": mh.last_computed, - "recommendations": getattr(mh, "recommendations", []), } await self.broadcaster.broadcast("health_update", health_dict) except Exception as e: diff --git a/work/meshai/mesh_reporter.py b/work/meshai/mesh_reporter.py index 38f58fc..ace7b84 100644 --- a/work/meshai/mesh_reporter.py +++ b/work/meshai/mesh_reporter.py @@ -1324,11 +1324,17 @@ class MeshReporter: return recs - def build_recommendations(self, scope: str, scope_value: str = None) -> str: - """Generate actionable optimization recommendations.""" + def recommendations_list(self, scope: str, scope_value: str = None) -> list[str]: + """Generate actionable optimization recommendations as a plain list. + + This is the canonical source of recommendation text, consumed both + by build_recommendations() (LLM prompt injection, formatted as a + string) and by API/UI callers that want a list[str] directly (e.g. + the dashboard health endpoint). + """ health = self.health_engine.mesh_health if not health: - return "" + return [] recs = [] @@ -1345,11 +1351,17 @@ class MeshReporter: else: # mesh scope recs.extend(self._mesh_recommendations(health)) + return recs[:10] + + def build_recommendations(self, scope: str, scope_value: str = None) -> str: + """Generate actionable optimization recommendations.""" + recs = self.recommendations_list(scope, scope_value) + if not recs: return "" lines = ["OPTIMIZATION RECOMMENDATIONS:"] - for rec in recs[:10]: + for rec in recs: lines.append(f" - {rec}") return "\n".join(lines) diff --git a/work/tests/test_mesh_health_recommendations_api.py b/work/tests/test_mesh_health_recommendations_api.py new file mode 100644 index 0000000..2d29c19 --- /dev/null +++ b/work/tests/test_mesh_health_recommendations_api.py @@ -0,0 +1,136 @@ +"""API tests for GET /api/health's `recommendations` field. + +Covers the dashboard-recommendations wiring: mesh_reporter is exposed on +app.state (mirroring the existing health_engine/data_store/etc. pattern in +dashboard/server.py) and mesh_routes.py's health endpoint now returns real +recommendations from MeshReporter.recommendations_list("mesh") instead of +the old hardcoded `[]`. +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from meshai.dashboard.api.mesh_routes import router +from meshai.mesh_health import HealthScore, MeshHealth + + +def _client(health_engine, mesh_reporter=None): + app = FastAPI() + app.include_router(router, prefix="/api") + app.state.health_engine = health_engine + app.state.mesh_reporter = mesh_reporter + return TestClient(app) + + +def _health_engine(mesh_health): + engine = MagicMock() + engine.mesh_health = mesh_health + return engine + + +def test_health_endpoint_returns_recommendations(): + """recommendations_list("mesh") output reaches the REST /api/health body, + flagged as `recommendations_available: True` (engine ran successfully).""" + mesh_health = MeshHealth(score=HealthScore()) + engine = _health_engine(mesh_health) + + reporter = MagicMock() + reporter.recommendations_list.return_value = [ + "Coverage gap in TestRegion: 3 nodes only reach 1 gateway.", + "No MQTT uplinks in TestRegion. Enable on at least one infrastructure node.", + ] + + client = _client(engine, mesh_reporter=reporter) + r = client.get("/api/health") + + assert r.status_code == 200 + body = r.json() + assert body["recommendations"] == [ + "Coverage gap in TestRegion: 3 nodes only reach 1 gateway.", + "No MQTT uplinks in TestRegion. Enable on at least one infrastructure node.", + ] + assert body["recommendations_available"] is True + reporter.recommendations_list.assert_called_once_with("mesh") + + +def test_health_endpoint_empty_recommendations_is_marked_available(): + """A genuinely healthy mesh: empty list AND recommendations_available=True. + + This is the "healthy" state — it must be distinguishable from the + error/unwired states below, which also produce an empty list but with + recommendations_available=False. + """ + mesh_health = MeshHealth(score=HealthScore()) + engine = _health_engine(mesh_health) + + reporter = MagicMock() + reporter.recommendations_list.return_value = [] + + client = _client(engine, mesh_reporter=reporter) + r = client.get("/api/health") + + assert r.status_code == 200 + body = r.json() + assert body["recommendations"] == [] + assert body["recommendations_available"] is True + + +def test_health_endpoint_no_mesh_reporter_configured(): + """mesh_reporter can be None (e.g. Meshtastic not configured) — no crash, + but this must NOT be indistinguishable from "healthy": empty list with + recommendations_available=False, not True. + """ + mesh_health = MeshHealth(score=HealthScore()) + engine = _health_engine(mesh_health) + + client = _client(engine, mesh_reporter=None) + r = client.get("/api/health") + + assert r.status_code == 200 + body = r.json() + assert body["recommendations"] == [] + assert body["recommendations_available"] is False + + +def test_health_endpoint_recommendations_error_is_swallowed_but_flagged(caplog): + """A raising mesh_reporter must not break the health endpoint (the other + fields are still useful), but the failure must be (a) logged, so it's + traceable, and (b) surfaced via recommendations_available=False, so the + UI never renders a crashed engine as "mesh is healthy".""" + mesh_health = MeshHealth(score=HealthScore()) + engine = _health_engine(mesh_health) + + reporter = MagicMock() + reporter.recommendations_list.side_effect = RuntimeError("boom") + + client = _client(engine, mesh_reporter=reporter) + with caplog.at_level("ERROR"): + r = client.get("/api/health") + + assert r.status_code == 200 + body = r.json() + assert body["recommendations"] == [] + assert body["recommendations_available"] is False + # The other fields on the response are unaffected by the recommendations + # failure — a 500 must not take down the whole health endpoint. + assert body["score"] == round(HealthScore().composite, 1) + assert body["tier"] == HealthScore().tier + assert any("recommendations_list failed" in rec.message for rec in caplog.records) + + +def test_health_endpoint_no_health_data_yet(): + """health_engine.mesh_health is None (not computed yet) — unaffected by recommendations wiring.""" + engine = _health_engine(None) + reporter = MagicMock() + + client = _client(engine, mesh_reporter=reporter) + r = client.get("/api/health") + + assert r.status_code == 200 + body = r.json() + assert body["message"] == "Health engine not ready" + reporter.recommendations_list.assert_not_called() diff --git a/work/tests/test_mesh_reporter_recommendations.py b/work/tests/test_mesh_reporter_recommendations.py new file mode 100644 index 0000000..8799829 --- /dev/null +++ b/work/tests/test_mesh_reporter_recommendations.py @@ -0,0 +1,188 @@ +"""Tests for MeshReporter's recommendations engine. + +recommendations_list() is the canonical source of recommendation text +(list[str]); build_recommendations() formats that same list into the +"OPTIMIZATION RECOMMENDATIONS:\n - ..." string consumed by router.py's LLM +prompt injection (a live production path — see router.py ~line 1145). This +file locks in that build_recommendations() is exactly +recommendations_list() joined with the historical header/bullet format, so +future refactors can't silently change the LLM-facing string. +""" +from __future__ import annotations + +import time + +import pytest + +from meshai.mesh_health import HealthScore, MeshHealth, RegionHealth +from meshai.mesh_models import UnifiedNode +from meshai.mesh_reporter import MeshReporter + + +def _node(node_num, **kw): + defaults = dict( + node_num=node_num, + node_id_hex=f"!{node_num:08x}", + short_name=f"N{node_num}", + long_name=f"Node {node_num}", + last_heard=time.time(), + is_online=True, + ) + defaults.update(kw) + return UnifiedNode(**defaults) + + +class _FakeHealthEngine: + def __init__(self, mesh_health, packet_threshold=500): + self.mesh_health = mesh_health + self.packet_threshold = packet_threshold + self._nodes = mesh_health.nodes if mesh_health else {} + + def get_node(self, identifier): + for n in self._nodes.values(): + if str(n.node_num) == str(identifier) or n.node_id_hex == identifier or n.short_name == identifier: + return n + return None + + +class _FakeDataStore: + def __init__(self, avg_gateways=1.5): + self._avg_gateways = avg_gateways + + def get_mesh_deliverability(self): + return {"avg_gateways": self._avg_gateways} + + +@pytest.fixture +def reporter_with_data(): + """A MeshReporter wired to a small synthetic mesh with several + recommendation-triggering conditions across node/region/mesh scopes.""" + n1 = _node( + 1, + packets_by_type={"POSITION_APP": 500}, # aggressive interval trigger + channel_utilization=40, + air_util_tx=15, + battery_percent=10, + battery_trend="declining", + predicted_depletion_hours=20, + is_infrastructure=True, + uplink_enabled=False, + ) + n2 = _node(2, is_online=False, last_heard=time.time() - 7200, is_infrastructure=True, uplink_enabled=True) + n3 = _node(3, avg_gateways=1.0, packets_sent_24h=1000, text_messages_24h=0) + n4 = _node(4, avg_gateways=1.0, packets_sent_24h=1000, text_messages_24h=0) + n5 = _node(5, avg_gateways=1.0, packets_sent_24h=1000, text_messages_24h=0) + n6 = _node(6, battery_percent=5, battery_trend="declining") + n7 = _node(7, channel_utilization=20) + + nodes = {n.node_num: n for n in [n1, n2, n3, n4, n5, n6, n7]} + + region = RegionHealth( + name="TestRegion", + node_ids=[str(i) for i in range(1, 8)], + score=HealthScore(util_percent=30, infra_total=2, infra_online=1), + ) + + mesh_health = MeshHealth(regions=[region], nodes=nodes) + engine = _FakeHealthEngine(mesh_health) + return MeshReporter(engine, data_store=_FakeDataStore()) + + +@pytest.mark.parametrize( + "scope,scope_value", + [ + ("mesh", None), + ("region", "TestRegion"), + ("node", "1"), + ("node", "2"), + ("node", "999"), # missing node + ("region", "Nowhere"), # missing region + ], +) +def test_build_recommendations_matches_recommendations_list(reporter_with_data, scope, scope_value): + """build_recommendations() must be exactly recommendations_list() formatted + with the historical header + bullet convention (byte-identical LLM prompt + text is the whole point of this refactor).""" + recs = reporter_with_data.recommendations_list(scope, scope_value) + text = reporter_with_data.build_recommendations(scope, scope_value) + + if not recs: + assert text == "" + else: + expected_lines = ["OPTIMIZATION RECOMMENDATIONS:"] + [f" - {r}" for r in recs] + assert text == "\n".join(expected_lines) + + +def test_build_recommendations_llm_string_pinned(reporter_with_data): + """Pins the exact LLM-facing string router.py:1145 injects into the + system prompt (a live production path) against a literal, hardcoded + expectation — independent of the implementation, unlike the + recommendations_list()-derived check above. Captured against + origin/main before the recommendations_list() refactor via a synthetic + fixture identical to reporter_with_data's, and confirmed byte-identical + after. If this test ever needs to change, the LLM prompt text changed + and that must be a deliberate, reviewed decision — not a refactor + side-effect. + """ + assert reporter_with_data.build_recommendations("mesh") == ( + "OPTIMIZATION RECOMMENDATIONS:\n" + " - Coverage gap in TestRegion: 3 nodes only reach 1 gateway. " + "A new MQTT feeder in this area would add monitoring redundancy.\n" + " - Node 6 (N6) at 5% battery and declining. Likely offline soon.\n" + " - High channel utilization on Node 1 (N1), Node 7 (N7). " + "Check for aggressive broadcast intervals or nearby interference.\n" + " - Mesh-wide average is 1.5 gateways per packet. " + "Adding MQTT feeders would improve monitoring reliability across the mesh." + ) + + assert reporter_with_data.build_recommendations("region", "TestRegion") == ( + "OPTIMIZATION RECOMMENDATIONS:\n" + " - Channel utilization at 30%. Consider spreading nodes across " + "frequencies or reducing telemetry intervals.\n" + " - 1 infrastructure node(s) offline. Check power and connectivity.\n" + " - High-traffic nodes (Node 3 (N3), Node 4 (N4), Node 5 (N5)) " + "impacting channel. Review their telemetry settings.\n" + " - Nodes with frequent position broadcasts (Node 1 (N1)). " + "Recommend 900s interval." + ) + + assert reporter_with_data.build_recommendations("node", "2") == ( + "OPTIMIZATION RECOMMENDATIONS:\n" + " - Node offline since 2h ago. Check power and connectivity." + ) + + assert reporter_with_data.build_recommendations("node", "999") == "" + assert reporter_with_data.build_recommendations("region", "Nowhere") == "" + + +def test_recommendations_list_caps_at_ten(reporter_with_data): + recs = reporter_with_data.recommendations_list("node", "1") + assert len(recs) <= 10 + + +def test_recommendations_list_empty_when_no_health_data(): + engine = _FakeHealthEngine(None) + reporter = MeshReporter(engine, data_store=_FakeDataStore()) + + assert reporter.recommendations_list("mesh") == [] + assert reporter.build_recommendations("mesh") == "" + + +def test_recommendations_list_empty_mesh_no_triggers(): + """An empty mesh with no nodes/regions produces no recommendations.""" + mesh_health = MeshHealth() + engine = _FakeHealthEngine(mesh_health) + reporter = MeshReporter(engine, data_store=_FakeDataStore(avg_gateways=3.0)) + + assert reporter.recommendations_list("mesh") == [] + assert reporter.build_recommendations("mesh") == "" + + +def test_recommendations_list_returns_plain_strings(reporter_with_data): + recs = reporter_with_data.recommendations_list("mesh") + assert isinstance(recs, list) + assert all(isinstance(r, str) for r in recs) + # Plain recommendation text, not pre-formatted with the LLM-prompt header + # or bullet markers — that formatting belongs to build_recommendations(). + assert all(not r.startswith("OPTIMIZATION RECOMMENDATIONS") for r in recs) + assert all(not r.startswith(" - ") for r in recs)