feat(mcp): add shared output-shaping helpers for the MCP toolkit

This commit is contained in:
RNL
2026-08-19 21:49:16 +10:00
parent fcd6561cbf
commit 8a498e933e
3 changed files with 278 additions and 0 deletions

143
bambu_run/mcp/shapes.py Normal file
View File

@@ -0,0 +1,143 @@
"""Shared output-shaping helpers enforcing the spec's data-correction rules.
Every rule here traces to a verified finding in "Bambu-Run MCP Tool Surface"
(the "Data Reality Findings" section) -- not a general defensive-programming
habit. Do not relax these without re-checking against real data.
"""
from __future__ import annotations
import statistics
STALE_THRESHOLD_SECONDS = 120
LOW_REMAINING_THRESHOLD = 20
def freshness(timestamp, now):
"""(data_age_seconds, stale) for a reading timestamp relative to `now`.
`now` is passed in rather than computed here so a caller building a
multi-field response reuses one `timezone.now()` call throughout,
keeping `generated_at` and every `data_age_seconds` mutually consistent.
"""
age_seconds = (now - timestamp).total_seconds()
return {
"data_age_seconds": int(age_seconds),
"stale": age_seconds > STALE_THRESHOLD_SECONDS,
}
def remaining_status(percent):
"""Classify one raw remaining-percent reading, honoring the -1 sentinel.
Returns (value, status): value is percent-or-None; status is one of
"unread" | "empty" | "low" | "ok". -1 and None both mean "no RFID
reading yet" -- the spec rules out -1 meaning "low" (jobs 62/63/70/77
show start=-1% followed by end=90-100%, which a genuinely low reading
could not do).
"""
if percent is None or percent == -1:
return None, "unread"
percent = int(round(percent))
if percent <= 0:
return 0, "empty"
if percent <= LOW_REMAINING_THRESHOLD:
return percent, "low"
return percent, "ok"
def smoothed_remaining_percent(readings):
"""Median of the 5 most recent remain_percent readings for one tray.
`readings` is most-recent-first. -1 sentinel readings are excluded
before the median, not treated as zero (spec: AMS remain_percent swings
22-35% mid-print on real RFID noise; median-of-5 rejects single-sample
spikes while still tracking genuine change).
"""
recent = readings[:5]
valid = [r for r in recent if r is not None and r != -1]
if not valid:
return remaining_status(-1 if recent else None)
return remaining_status(statistics.median(valid))
def classify_alerts(code_counts, total_rows):
"""Turn {(attr, code): occurrences} window counts into the alerts array.
Frequency heuristic (spec: HMS codes are stateful, not event-based --
the two most common codes alone accounted for over 80% of all alert
occurrences in the verified data, in long unbroken runs, not isolated
events): a code present in >20% of window rows is "persistent" (a
likely standing advisory, surfaced once with a count); <5% is
"transient" (possibly actionable). The 5-20% band is omitted rather
than guessed at. severity is always "unknown" -- nothing in Bambu-Run
decodes these codes yet; do not assert "fine" or "faulty" from them.
"""
alerts = []
for (attr, code), count in code_counts.items():
ratio = count / total_rows if total_rows else 0
if ratio > 0.20:
kind = "persistent"
elif ratio < 0.05:
kind = "transient"
else:
continue
alerts.append({
"attr": attr,
"code": code,
"kind": kind,
"occurrences": count,
"severity": "unknown",
})
return alerts
def display_name(design_title, plate_title, project_name):
"""design_title -> plate_title -> project_name fallback.
Spec finding: design_title is empty on 4 of 5 recent BambuCloudTask rows.
"""
return design_title or plate_title or project_name
def normalize_consumed(starting_percent, ending_percent, initial_weight_grams):
"""FilamentUsage consumption, honest about the -1 sentinel and RFID jitter.
Returns (consumed_percent, consumed_grams, data_quality). data_quality is
"ok" or "unreliable". A negative or sentinel-derived result becomes
(None, None, "unreliable") -- never a negative figure, never a
fabricated zero (spec: 24/269 usage rows go negative; a -1 sentinel
subtracted as a real value produces e.g. consumed=-101%).
"""
if starting_percent is None or starting_percent == -1 or ending_percent is None:
return None, None, "unreliable"
consumed_percent = starting_percent - ending_percent
if consumed_percent < 0:
return None, None, "unreliable"
consumed_grams = None
if initial_weight_grams:
consumed_grams = int(initial_weight_grams * (consumed_percent / 100.0))
return consumed_percent, consumed_grams, "ok"
def resolve_printer(printer):
"""Resolve an id, exact name, or None to a Printer queryset.
None returns all active printers. Raises Printer.DoesNotExist if a
specific id/name was given and nothing matched -- MCP tool wrappers let
this propagate as a clear tool error rather than returning empty data
that looks like "no printers exist".
"""
from bambu_run.models import Printer
if printer is None:
return Printer.objects.filter(is_active=True)
if isinstance(printer, int) or (isinstance(printer, str) and printer.isdigit()):
by_id = Printer.objects.filter(pk=int(printer))
if by_id.exists():
return by_id
by_name = Printer.objects.filter(name=printer)
if not by_name.exists():
raise Printer.DoesNotExist(f"No printer matching {printer!r}")
return by_name