Adds MAC address matching as the primary matching phase:
- Normalizes MACs (strips colons/dots/dashes) from SNMP interface data
- Matches against gaiia_inventory_items.mac_address (synced from Gaiia)
- Phase 1: MAC → Phase 2: IP → Phase 3: Site
Flash now shows breakdown: "Auto-matched 47 devices (12 by MAC, 34 by
IP, 1 by site). 17 remaining."
A bare <select phx-change="..."> sends %{"value" => ...}, not the name
attribute. Wrapping in <form phx-change="..."> fixes this so the handler
receives %{"manufacturer_id" => value}.
- Handle empty manufacturer_id selection (select placeholder)
- Log and flash when model fetch fails or returns empty
- Add require Logger to reconciliation live view
Replaces auto-matching with explicit two-step selection:
1. Select manufacturer from Gaiia's inventory model library (dropdown)
2. Select model for that manufacturer (dropdown, filtered live)
3. Click Create
Manufacturers and models are fetched from Gaiia on demand and cached
in socket assigns. Only one device form is open at a time.
ngettext is a macro that already calls dngettext internally. The
previous code was calling dngettext a second time on the result string,
passing nil as the count — causing a FunctionClauseError.
Adds `Towerops.Gaiia.InventoryCreator` which:
- Matches a device's SNMP-discovered manufacturer/model against Gaiia's
inventory model library to find the right modelId
- Calls `startAddInventoryItemsJob` to create the item, assigned to the
correct Gaiia network site with MAC address from SNMP interfaces
A "Create in Gaiia" button now appears next to each untracked device on
the reconciliation page.
Two improvements to Gaiia reconciliation:
1. Auto-match now uses two-phase matching:
- Phase 1: IP match (high confidence, same as before)
- Phase 2: Site match — if a device is at a Towerops site linked to a
Gaiia network site, match it to unmapped inventory assigned to that site
2. Gaiia sync now automatically maps network sites to Towerops sites by
name during sync. When a Gaiia network site name matches a Towerops
site name, `site_id` is set on the `gaiia_network_sites` row. This
enables the site-based matching in phase 2.
Flash message now shows breakdown: "Auto-matched 47 devices (34 by IP,
13 by site). 17 remaining."
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory
items whose IP matches an untracked Towerops device and links them.
Button on the reconciliation page's Untracked tab runs the match
inline and shows a flash with matched/remaining counts.
Warning: 65°C → 80°C, Critical: 75°C → 90°C. Outdoor gear in Texas
routinely runs at 70-75°C ambient. The old thresholds generated useless
AI insights like "Verona to Altoga at 72°C may be overheating" — that's
normal for an enclosure in summer, not actionable.
The LLM can now copy device_id from the snapshot into its observation
output. The worker wires it into the insight's device_id FK so the UI
can link directly to the referenced device.
Adds a wireless section to the NetworkSnapshot with per-AP radio state
(channel, frequency, channel width, noise floor, RF/QoE scores), per-channel
occupancy (own-fleet + foreign neighbor counts), and APs with the worst
foreign interference. Updates the system prompt to describe this data and
removes device_id from the expected output schema — the LLM now references
devices by name.
deepseek-v4-flash is still a reasoning variant and burns tokens on
chain-of-thought before producing content. deepseek-chat produces output
directly — 82 tokens all went to content in testing, vs 85-102 tokens
that produced empty observations on flash.
Info-level logs for: org count at start, observation count per org,
successful persistence, and dedup. Makes the worker observable without
needing database access.
deepseek-v4-pro is a reasoning model that burns tokens on chain-of-thought
before producing content. The flash variant is faster, cheaper, and doesn't
starve the content output — a better fit for summarization and observation
generation tasks.
deepseek-v4-pro is a reasoning model that burns tokens on
reasoning_content before producing content. When max_tokens was too low
(400/1500), all tokens went to reasoning and content was empty.
- parse_response now falls back to reasoning_content when content is blank
- max_tokens raised to 20_000 across all call sites and default
- improved parse logging shows raw/cleaned byte sizes
The network insight prompt is large and max_tokens is 1500 — DeepSeek
takes longer than 30s. Also plumb opts[:receive_timeout] through so
workers can tune it.
The parse function silently returned {:ok, []} on any decode failure or
observation rejection. Log warnings for both cases so we can see what the
LLM is actually returning.
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.
New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents
Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
The Repo.transaction wrapping Repo.stream() held a checkout for the
entire iteration, including the LLM HTTP call. When the LLM took >15s,
Postgrex killed the connection, cascading to all subsequent orgs.
Collect org IDs first, then process each independently.
Adds a parallel LLM path that reviews the whole org snapshot — Preseem
APs, SNMP signals, backhaul, agents, and the insight types the rules
already covered — and emits novel observations as ai_observation
insights (source=ai). Existing rule-based insights and per-insight
enrichment continue unchanged.
- lib/towerops/llm/network_snapshot.ex builds the bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex builds the prompt and
parses the LLM's JSON observations array
- lib/towerops/workers/ai_network_insight_worker.ex runs nightly at
03:30 UTC (after the rule pass + per-insight enrichment) and on
the superuser regenerate button
- preseem/insight.ex extends @valid_types with "ai_observation" and
@valid_sources with "ai"
- insights_live/index.ex includes the new worker in the regen list
and adds an "AI" source badge style
Bumps the SnmpKit Manager timeout-options test threshold from 500ms
to 3000ms (was flaking on loaded dev machines), and scopes
list_unenriched_insights/1 assertion in insights_test:573 to the
test's org so cross-suite leaks don't fail the count.
- ToweropsWeb.AgentChannel now runs sensor readings through
Towerops.Snmp.SensorScale.normalize/2 in both resolve_sensor_value/2
and the GraphQL publisher. Without this, MikroTik mtxrHl* deci-degree
readings (e.g. Culleoka reporting 294 -> 29.4°C) were stored and
alerted on at 294°C. The DevicePollerWorker path already did this;
only the agent path was missing it.
- Demote "Received SNMP result from agent" and "Processing polling
result for X" from info to debug — they fire every poll cycle on
every device and were dominating prod logs.
- /insights now shows a "Regenerate insights" button for superusers
that enqueues all seven insight workers (Preseem baselines,
capacity, device-health, Gaiia, system, wireless, LLM enrichment).
Non-superusers neither see the button nor can trigger the event.
- Dialyzer is clean: added :tools to plt_add_apps, gave
Preseem.Insight a @type t, pinned a few unmatched returns, and
suppressed a known MapSet opaque false-positive in Towerops.Unused.
- e2e: NODE_OPTIONS=--disable-warning=DEP0205 silences the Node 25
deprecation noise from Playwright's internal ESM loader.
Token-usage doesn't belong on the per-insight row — every consumer of
Towerops.LLM (insight enrichment today; billing-anomaly summaries,
monitoring narratives, fleet weekly reports tomorrow) needs to record
into one queryable place. Per-insight columns also made it impossible
to track LLM calls that aren't tied to an insight.
Schema: `llm_usage` keyed (organization_id, day, model, purpose) with a
unique index. organization_id is nullable for system-level analyses.
Each row carries running totals plus a request_count — callers UPSERT
and increment atomically.
API: `Towerops.LLM.Usage.record/1` for write, `summarize/2` for
read-side rollups. The worker now calls `Usage.record(%{... purpose:
"insight_enrichment"})` after each successful enrichment.
Removed: `llm_prompt_tokens` / `llm_completion_tokens` from
preseem_insights — same migration drops the columns and creates
llm_usage in one go. `apply_llm_enrichment` reverts to /3 (no usage
arg).
If a rule keeps re-firing the underlying condition, the dedup query
suppresses duplicate inserts but `inserted_at` of the live row stays
old — so a real 6-month-old issue and a one-off blip both linger
forever. Auto-resolving any active insight older than 7 days clears
the dashboard. Still-real issues will be re-created by the next rule
pass (dedup only checks `status: "active"`), so the operator sees
fresh evidence rather than stale text.
- Towerops.Workers.InsightExpiryWorker (queue: :maintenance) flips
`status` to "resolved" on rows where `inserted_at < now - 7d`.
- Window is configurable via `:max_age_days` for ops who want a
different cadence per env.
- Oban cron: 04:00 UTC nightly, after the 02:00 rule pass and 03:00
LLM enrichment.
UI: the AI-generated footer dropped " · deepseek-v4-pro" — model
identity is irrelevant to operators reviewing the recommendation.
Parser: stop letting raw JSON leak into the summary text when the LLM
returns an almost-but-not-quite-valid response.
- Strip ```json fences (already done) PLUS try a balanced-brace
extraction so a "Here's the JSON: {…}" preamble doesn't break
Jason.decode
- When all decode paths fail, regex-extract `"summary"` and `"action"`
fields from the text rather than dumping the whole blob with braces
intact
- Last-ditch sanitiser strips remaining brace/quote noise so the
rendered summary never shows literal `"summary"` keys
Maintenance: `Towerops.Maintenance.RepairLlmSummaries.run()` clears
`llm_enriched_at` on already-stored rows whose summaries contain JSON
artifacts so the next worker cycle re-enriches them with the new
parser.
Test: insights_live_events_test now asserts the AI-generated label is
present *and* that the model name does not leak into the rendered HTML.
Mix tasks aren't usable from the prod release image — `/app/bin/towerops`
ships without `mix`, and `Mix.shell()` raises at runtime. Move the work
to `Towerops.Maintenance.FixMikrotikTemperatureScaling` (plain module
with `run/0`, IO.puts + Logger.info) and keep the Mix.Task as a thin
dev-only delegator.
Run in prod via:
/app/bin/towerops eval 'Towerops.Maintenance.FixMikrotikTemperatureScaling.run()'
The CSS `capitalize` class only capitalises the first letter, so the
source filter pills and per-card badges showed "Ai" and "Snmp". Switch
to a `source_label/1` helper that returns "AI" / "SNMP" verbatim and
falls back to `String.capitalize/1` for the rest (Preseem, Gaiia,
System).
Some MikroTik models report `mtxrGaugeValue` (mtxrGaugeUnit=1, °C) in
deci-degrees, while others — like the CRS317 in upstream test data —
return whole degrees. The MIB doesn't disclose which, so the YAML
profile can't pick one literal divisor. Result: a sensor that should
read 56.92 °C was being stored and alerted on as 5,692 °C.
- Towerops.Snmp.SensorScale.normalize/2: divide-by-10 a temperature
reading until it falls below 150 °C, capped at 3 iterations. Other
sensor types pass through untouched.
- DevicePollerWorker.poll_simple_sensor wires the normaliser in after
the YAML divisor is applied, so live polling stops storing bogus
values immediately.
- DeviceOverheating rule now bounds the query at 150 °C as defense in
depth so a future scaling glitch never produces a 5,000 °C alert.
- mix towerops.fix_mikrotik_temperature_scaling backfills any rows
already in the DB by re-running normalize/2 over them.
- routeros.yaml profile gets a comment pointing at the runtime
normaliser instead of a wrong unconditional divisor.
Adds an "ai" pill to the source-filter strip on /insights and a small
blue "AI" badge on every card whose `llm_enriched_at` is set. Selecting
the pill returns every LLM-enriched insight regardless of underlying
source (preseem / snmp / gaiia / system).
`source: "ai"` is a virtual filter — `list_insights/2` translates it to
`WHERE llm_enriched_at IS NOT NULL` rather than equality on the source
column, so existing real source semantics stay intact.
UI:
- Type-specific evidence cards (red/amber/orange/rose/purple/cyan/
indigo/blue) had `dark:text-{color}-300` labels and
`dark:bg-{color}-800 dark:text-{color}-100` badges that washed out on
the dark background. Bumped 31 label shades to -200 and switched the
three problem badges (Above manufacturer spec, Investigate upstream
first, own fleet, crit) to solid -600 with white text.
- upstream_degradation card got brighter device-link text and a
hover state in dark mode.
DeepSeek:
- Empty-string DEEPSEEK_MODEL / DEEPSEEK_BASE_URL env vars were not
treated as missing — `||` only short-circuits on nil/false — so a
blank secret would override the @default_model and the API would
reject `model: ""`. Both runtime.exs and the client itself now treat
blank values as missing.
Track DeepSeek token spend per enriched insight so cost can be rolled
up by SQL — `SELECT date_trunc('day', llm_enriched_at), organization_id,
SUM(llm_prompt_tokens), SUM(llm_completion_tokens) FROM preseem_insights
GROUP BY 1, 2`.
- Migration adds llm_prompt_tokens + llm_completion_tokens to
preseem_insights
- DeepSeek client surfaces the API's `usage` block (prompt/completion/
total tokens) on the success response
- apply_llm_enrichment/4 takes a usage map and persists the two int
columns
- Worker plumbs the usage from the response through to the context
Replaces the previous :requires_redis skip with a real test double so
the suite stays self-contained — no external Redis service needed in
CI, but the call paths through RedisHealthCheck and FourOhFourTracker
still get exercised end-to-end.
- Towerops.Redix.Fake: GenServer that speaks PING / SADD / SCARD /
SISMEMBER / SMEMBERS / EXPIRE / DEL / FLUSHDB. start_link refuses
anything other than localhost:6379 so failure-path tests still fail
the way they did against real Redix.
- Application supervisor starts the fake under name Towerops.Redix in
test env (instead of the dummy Agent stub).
- RedisHealthCheck and FourOhFourTracker now route Redix calls through
Application.get_env(:towerops, :redix_module, Redix). Test config
points it at the fake.
- Drops the test_helper :requires_redis exclusion and the @moduletag
on the two test files; FourOhFourTrackerTest setup just resets the
fake between runs.
The Forgejo CI test gate has no Redis service, so FourOhFourTracker
and RedisHealthCheck tests fail with Redix.ConnectionError. They've
always been broken in CI but the failures got noticed now.
Mirrors the existing :requires_gdal pattern: probe localhost:6379 in
test_helper.exs and add :requires_redis to extra_excludes when nothing
answers. Locally (where direnv runs Redis) the tag isn't excluded so
the tests still run.
When a user flips monitoring_enabled true → false on a device, any
unresolved alerts on that device are now resolved silently — the
operator has deliberately stopped monitoring it, so leaving stale
"device down" pages around is noise.
- Alerts.resolve_active_alerts_for_device/1: bulk-resolves via
resolve_alert_silent so PagerDuty/email don't fire
- Devices.handle_monitoring_changes hooks it on the disable transition
DeepSeek API spend isn't justified by minute-by-minute freshness when
ops review insights in the morning. Run rules at 02:00 UTC and the
enrichment pass at 03:00 UTC so summaries are ready by 9 AM local.
- RecommendationsRunWorker: hourly → 02:00 UTC daily
- InsightLlmEnrichmentWorker: every 5 min → 03:00 UTC daily
Performance:
- HealthController: honor `:timeout` from `:redis` config (was hardcoded
2s) so the 503-when-unreachable test exits immediately
- DevicePollerBranches: drop `collect_events` window 1500ms → 100ms
(PubSub broadcasts are synchronous from `run_perform`)
- Towerops.Unused: cache xref analysis in `:persistent_term` keyed by
BEAM mtimes so repeated `mix unused` invocations skip re-analysis
- DiscoveryWorker: make wait_loop interval configurable via
`:discovery_wait_interval_ms` (25ms in tests vs 500ms in prod)
- ProfileWatcher: configurable `:profile_reloader` so tests can stub
the YAML reload instead of paying the full disk-load cost
- PingExecutor: tag the loopback test `:network` (it shells out to
`ping`); coverage already provided by 4 other `:network`-tagged tests
Flake fix:
- FrequencyChange tests used hardcoded `~U[2026-05-09 12:00:00Z]`,
which now falls outside the rule's 24h lookback window. Switched to
`DateTime.utc_now()` so scans stay inside the window regardless of
clock progression.
The towerops-db secret in prod has both DATABASE_URL and individual
POSTGRES_* fields; towerops-redis uses HOST/PORT/PASSWORD instead of
a single REDIS_URL. Update the committed template to match so a fresh
copy of k8s/secrets.example.yaml mirrors what's actually in the
cluster.
Replace the single-secret towerops-llm template with one
multi-document YAML covering every Secret the Deployment references:
towerops-secrets, towerops-db, towerops-aws, towerops-redis,
towerops-billing, towerops-llm.
Workflow: copy k8s/secrets.example.yaml to k8s/secrets.yaml (which is
gitignored), fill in real values, kubectl apply. Real values never land
in git.
Fiber operators care a lot about optical Rx power because connectors
get dirty, splices degrade, and patch panels develop bend-radius
problems. The data is already in snmp_transceiver_readings — this
rule turns it into actionable insights.
- Towerops.Recommendations.Rules.OpticalRxLow — single query selects
the latest reading per transceiver (last hour), filters to those
with rx_power_dbm <= -28, joins back to the device. Critical at
<= -32 dBm. One insight per (device, port) via metadata.dedup_key.
- Insight.@valid_types extended with optical_rx_low.
- LLM prompt hint walks the operator through the standard ladder:
clean SC/LC connector with a one-click cleaner, inspect bend
radius, verify splices, then replace the transceiver.
- UI: cyan card with port, Rx power, Tx power, transceiver type, and
wavelength.
- Wired into RecommendationsRunWorker.
8 new tests.
When 50%+ of the access points at one site simultaneously have
Preseem QoE score <60, root cause is almost always a shared upstream
problem (backhaul, transit, DNS, DHCP) — not per-AP RF. This rule
emits one site-level insight that points the operator at the right
investigation layer instead of letting them chase per-AP RF
symptoms across many APs at the same site.
- Towerops.Recommendations.Rules.UpstreamDegradation — single query
pulls all matched Preseem APs grouped by site_id. Critical when
100% of APs at the site are degraded; warning otherwise. Lists
every degraded AP with its QoE score in metadata.
- Insight.@valid_types extended with upstream_degradation.
- LLM prompt hint tells the model to instruct the operator to
investigate upstream FIRST and to NOT chase per-AP RF symptoms.
- UI: indigo card with degraded/total AP counts, an "Investigate
upstream first" badge, and a clickable list of affected APs.
- Wired into RecommendationsRunWorker FIRST in the @rules list so
the upstream signal surfaces before per-AP RF rules.
Also fixes two flakes:
- device_live/show_test.exs is now async: false. It inserts to
InterfaceStat which is a TimescaleDB hypertable; concurrent inserts
deadlock on chunk-table locks under parallel SQL-sandbox tests
(ERROR 40P01 deadlock_detected in setup).
- Cleared an unused-variable warning in device_overheating_test.exs.
8 new tests for UpstreamDegradation.
Operators frequently misdiagnose thermal throttling as RF problems
and waste truck rolls. This rule reads the hottest temperature
sensor per device and, when the device is linked to a Preseem AP
with degraded QoE, makes the thermal-correlation explicit so the
fix is ventilation/shading rather than antenna alignment.
- Towerops.Recommendations.Rules.DeviceOverheating — single query
pulls the hottest temperature sensor per device above 65 °C; one
insight per device. Critical at 75 °C+. Includes the linked
Preseem qoe_score in metadata when present.
- Insight.@valid_types extended with device_overheating.
- LLM prompt hint tells the model to recommend ventilation/fan/
shading inspection BEFORE any RF action when QoE is also low.
- UI: red card with sensor name, temperature, linked QoE score,
and an "Above manufacturer spec" badge for >=75 °C.
Wired into RecommendationsRunWorker. 8 new tests.
Generic prompts produce generic advice. This commit adds per-type
expert hints appended to the base system prompt, listing the metadata
field names the rule provides plus short domain heuristics so the
LLM has the context it needs to write a tight, specific recommendation.
Hints cover: ap_frequency_change, own_fleet_channel_conflict,
sector_overload, cpe_realign, wireless_signal_weak, wireless_snr_low,
backhaul_over_capacity, backhaul_near_capacity, qoe_degradation,
capacity_saturation. Unknown types fall back to the base prompt.
No change to rules, schema, or worker — this lifts every existing
insight's enrichment quality on the next cron run.
Also fixes two recurring flaky tests:
- agent_live_test.exs:567 now mirrors the companion show_test.exs
pattern of accepting any of the valid update-flow flashes (Failed
/ Update command sent / No binary available). The previous
Req.Test stub-and-allow approach was itself flaky because both
test files stub the same module name and ownership tracking can
leak under full-suite parallelism.
- telemetry_test.exs is now async: false. Its handle_endpoint_stop
describe block mutates global Logger.level which races with other
async tests; running this single module sync removes the race
without sacrificing the per-test setup/restore pattern.
Same-channel conflicts at one site are not all equal. A pair of APs
sharing a channel in a clean RF environment is a planning issue;
the same pair in a contaminated environment is an outage waiting to
happen. This commit makes the rule reason across sources before
deciding urgency.
- Towerops.Snmp.RfHealth — cross-source helper that combines:
* Most recent noise-floor / noise sensor on the AP (dBm)
* Preseem rf_score and qoe_score from the linked AccessPoint row
* Average CPE SNR and client count from wireless_clients
Returns a snapshot map and an interference_severity/1 classifier
(:critical | :warning | :ok). Critical when noise floor >= -80 dBm
OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
active clients.
- OwnFleetChannelConflict now enriches each conflicting AP with its
noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a
per-AP rf_severity. Urgency promotes to critical when 3+ APs share
a channel OR any conflicting AP shows critical RF health.
- UI: amber card now renders an evidence table with width, noise,
rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
environment detected" badge when has_critical_rf is true.
Also strengthens the previous flake fix on agent_live_test.exs:567
by switching to Req.Test shared mode (the test is sync), so the
ReleaseChecker stub is visible to the LiveView process regardless of
when it spawns relative to setup. The earlier Req.Test.allow approach
relied on stub timing that didn't always hold under full-suite load.
11 new tests across RfHealth + the enriched rule. LLM enrichment
automatically picks up the richer metadata via the existing Phase 1
worker.
Adds a vendor-agnostic radio-state extractor that lets the frequency-
aware rules actually fire on production data without per-vendor
plumbing for each profile.
- Towerops.Snmp.RadioState — pure module that maps a frequency in MHz
to the corresponding 802.11 channel across 2.4 GHz, 5 GHz, and 6 GHz
bands; reads the freshest "frequency" sensor (already emitted by
Cambium/MikroTik/AirOS/Mimosa/Exalt/etc. profiles) and persists the
AP-level current_frequency_mhz, current_channel, last_radio_seen_at
fields onto snmp_devices.
- DevicePollerWorker now calls RadioState.update_from_sensors/1 after
each sensor poll so frequency-aware rules read a single canonical
source instead of joining sensors at query time.
- Towerops.Recommendations.Rules.OwnFleetChannelConflict — new rule
that needs no neighbor-scan collection: detects when 2+ of our own
APs at the same site share a channel (self-interference). One
insight per (site, channel) with a clickable list of conflicting
APs and their channel widths. Critical when 3+ APs share a channel.
- Insight.@valid_types extended with own_fleet_channel_conflict.
- UI: amber card showing channel, frequency, AP count, and the linked
list of conflicting devices.
Wired into the existing hourly RecommendationsRunWorker. LLM
enrichment automatically applies via the Phase 1 worker.
Two new multi-source rules using data we already collect, both wired
into the existing hourly RecommendationsRunWorker.
SectorOverload — fires when a Preseem-monitored AP has <25% free
airtime AND active subscribers. Critical urgency when free airtime
drops below 15%, OR when free airtime <25% AND QoE <50. Reuses the
airtime/subscriber/QoE data already pulled by PreseemSyncWorker so
no new collection is needed. UI: orange evidence card with free
airtime, subscriber count, QoE score, and AP model.
CpeRealign — fires when a wireless client has BOTH signal_strength
<=-78 dBm AND SNR <=18 dB seen in the last 2 hours. This is the
classic alignment / obstruction signature, distinct from the
existing single-metric wireless_signal_weak / wireless_snr_low
alerts produced by WirelessInsightWorker. One insight per (AP, CPE)
pair via a metadata.dedup_key. Critical when signal <=-88 OR SNR
<=10. UI: rose evidence card with signal, SNR, TX/RX rate, distance,
hostname.
Insights.insert_insight_if_new/1 dedup logic now prefers a
metadata.dedup_key over device_id when explicitly set, allowing
per-CPE insights without collapsing multiple CPEs on the same AP
into a single insight. Existing rules without dedup_key are
unaffected.
Insight.@valid_types extended with sector_overload and cpe_realign.
LLM enrichment automatically applies to both new types via the
existing Phase 1 worker — no extra wiring.
Also adds k8s/secrets.yaml to .gitignore so operators can drop a
local Secret manifest with real values, kubectl apply manually, and
never accidentally commit it. Documented in k8s/README.md.