microfactory-lab / ingest /distill.py
kylebrodeur's picture
Upload folder using huggingface_hub
1130366 verified
Raw
History Blame Contribute Delete
14.4 kB
"""Knowledge distiller — turn structured sources into Chief Engineer knowledge.
Two outputs, matching how the sources actually carry information:
1. **Reference facts** (material baselines from slicer/firmware configs) →
`data/references.jsonl`. Injected into the prompt as a "Material Reference"
block. These are NOT env-keyed precedent — they're hard parameters.
2. **Candidate lessons** (research distilled into env-keyed rows) → appended to
the ledger as `source="ingested"`, retrieved exactly like seed/earned lessons.
Deterministic + stdlib (configparser/regex). License-safe: we ingest *profile
data*, never slicer code. No OrcaSlicer/PrusaSlicer imports.
"""
from __future__ import annotations
import configparser
import json
import re
from pathlib import Path
from pydantic import BaseModel
from core.ledger import LedgerManager
from core.models import LessonEntry, MATERIALS
DATA = Path(__file__).resolve().parent.parent / "data"
REFERENCES_PATH = DATA / "references.jsonl"
class ReferenceFact(BaseModel):
material: str
param: str # "nozzle_temp" | "bed_temp" | "retraction_mm" | "max_temp" | ...
value: float
source: str # where it came from (filename / profile)
def _material_of(text: str) -> str | None:
up = text.upper()
for m in MATERIALS:
if m in up:
return m
if "ACETAL" in up or "DELRIN" in up:
return None
return None
# --- 3D-ADAM defect taxonomy (encoded from the paper's defect classes) ------
# RAG-ready risk knowledge keyed to the geometry/condition that triggers it.
DEFECT_TAXONOMY = {
"warping": {"risk": "warping", "trigger": "high-shrinkage material on a cool/draughty bed",
"note": "corners lift as lower layers cool and contract; raise bed temp, enclose, slow first layer."},
"under_extrusion": {"risk": "under_extrusion", "trigger": "too-low temp / too-fast flow / partial clog",
"note": "gaps and weak walls; raise temp or slow down, check for moisture and clogs."},
"stringing": {"risk": "stringing", "trigger": "wet filament or hot travel",
"note": "fine whiskers across gaps; dry filament first, then lower temp / tune retraction."},
"cracking": {"risk": "delamination", "trigger": "over-cooling on tall prints / poor layer bond",
"note": "layers split under stress; reduce fan, raise temp, enclose for ABS."},
}
def parse_prusa_ini(path: Path) -> list[ReferenceFact]:
"""Extract per-filament baselines from a PrusaSlicer-style INI (data only)."""
facts: list[ReferenceFact] = []
cp = configparser.ConfigParser(strict=False, interpolation=None)
try:
cp.read(path, encoding="utf-8")
except Exception:
return facts
for section in cp.sections():
mat = _material_of(section)
if not mat:
continue
src = f"{path.name}:[{section}]"
kv = cp[section]
def first_num(raw: str | None) -> float | None:
if not raw:
return None
m = re.search(r"-?\d+(?:\.\d+)?", raw.split(",")[0])
return float(m.group()) if m else None
for key, param in (
("temperature", "nozzle_temp"),
("first_layer_temperature", "nozzle_temp"),
("bed_temperature", "bed_temp"),
("retract_length", "retraction_mm"),
):
v = first_num(kv.get(key))
if v is not None:
facts.append(ReferenceFact(material=mat, param=param, value=v, source=src))
return facts
def parse_klipper_cfg(path: Path) -> list[ReferenceFact]:
"""Pull safety-relevant limits from a Klipper printer.cfg ([extruder] max_temp)."""
facts: list[ReferenceFact] = []
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return facts
for section, param in (("extruder", "max_temp"), ("heater_bed", "bed_max_temp")):
m = re.search(rf"\[{section}\][^\[]*?max_temp\s*[:=]\s*(\d+(?:\.\d+)?)", text, re.S | re.I)
if m:
facts.append(ReferenceFact(material="*", param=param, value=float(m.group(1)),
source=f"{path.name}:[{section}]"))
return facts
def parse_marlin_config(path: Path) -> list[ReferenceFact]:
"""Pull hotend/bed max temps from a Marlin Configuration.h (#define ... MAXTEMP)."""
facts: list[ReferenceFact] = []
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return facts
for define, param in (("HEATER_0_MAXTEMP", "max_temp"), ("BED_MAXTEMP", "bed_max_temp")):
m = re.search(rf"#define\s+{define}\s+(\d+)", text)
if m:
facts.append(ReferenceFact(material="*", param=param, value=float(m.group(1)),
source=f"{path.name}:{define}"))
return facts
def parse_prusa_config(path: Path) -> list[ReferenceFact]:
"""Parse a PrusaSlicer flat config — the `; key = value` block inside a `.3mf`
project (Metadata/*.config) or a standalone exported `.config`/`.ini`. Carries
REAL per-filament settings incl. fan (which the slicer/firmware configs above
don't). One filament_type per file; source = the profile id (curated)."""
try:
if path.suffix.lower() == ".3mf":
import zipfile
with zipfile.ZipFile(path) as z:
name = next((n for n in z.namelist() if n.lower().endswith(".config")), None)
if not name:
return []
text = z.read(name).decode("utf-8", "ignore")
else:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return []
def get(key: str) -> str | None:
m = re.search(rf"(?mi)^[;\s]*{re.escape(key)}\s*=\s*(.+)$", text)
return m.group(1).strip().strip('"') if m else None
def num(raw: str | None) -> float | None:
if not raw:
return None
m = re.search(r"-?\d+(?:\.\d+)?", raw.split(",")[0])
return float(m.group()) if m else None
mat = (get("filament_type") or "").upper()
if mat not in MATERIALS:
return []
src = (get("filament_settings_id") or path.stem) + (" (3mf)" if path.suffix.lower() == ".3mf" else "")
facts: list[ReferenceFact] = []
for key, param in (("temperature", "nozzle_temp"), ("first_layer_temperature", "nozzle_temp"),
("bed_temperature", "bed_temp"), ("retract_length", "retraction_mm"),
("max_fan_speed", "fan_pct")):
v = num(get(key))
if v is not None:
facts.append(ReferenceFact(material=mat, param=param, value=v, source=src))
return facts
def parse_octoprint_history(path: Path) -> list[dict]:
"""Extract Lane-B lesson candidates from an OctoPrint print-job-history CSV.
The `result` column is COMPLETION status, not print quality (a 'success' row can
read 'very stringy'), and fan/retraction/geometry/humidity aren't recorded — so
this yields *lessons from the Note field*, not calibration rows. Honesty gate:
only emit a lesson when the note names a real defect/outcome. Neutral env (the
history doesn't log room conditions)."""
import csv as _csv
KEYWORDS = { # note phrase → (outcome, geometry, what it teaches)
"string": ("failed_stringing", "stringing"),
"sag": ("failed_sag", "overhang"),
"droop": ("failed_sag", "overhang"),
"warp": ("failed_sag", "adhesion"),
"curl": ("failed_sag", "adhesion"),
"lift": ("failed_sag", "adhesion"),
"rough": ("success", "adhesion"), # finish note, not a hard failure
}
out: list[dict] = []
try:
rows = list(_csv.DictReader(path.open(encoding="utf-8")))
except Exception:
return out
for r in rows:
note = (r.get("Note") or "").strip()
if note in ("", "-"):
continue
mat = (r.get("Material") or "").upper().replace("_PLUS", "").replace("PLA_PLUS", "PLA")
if mat not in MATERIALS:
continue
m = re.search(r"_(\d{3})C_", r.get("File Name", ""))
noz = m.group(1) if m else "?"
low = note.lower()
hit = next(((o, g) for k, (o, g) in KEYWORDS.items() if k in low), None)
if not hit:
continue
outcome, geo = hit
out.append({
"job_id": f"octo-{r.get('Start Datetime [dd.mm.yyyy hh:mm]','')[:10].replace('.','')}",
"material": mat, "geometry_type": geo, "env_temp": 22.0, "env_humidity": 50.0,
"outcome": outcome,
"lesson": f"My own print ({mat} at ~{noz}°C nozzle): \"{note}\". ",
"source": "ingested", "_note": note, "_file": r.get("File Name", ""),
})
return out
def write_references(facts: list[ReferenceFact]) -> int:
DATA.mkdir(parents=True, exist_ok=True)
with REFERENCES_PATH.open("w", encoding="utf-8") as f:
for fact in facts:
f.write(fact.model_dump_json() + "\n")
return len(facts)
def load_references() -> list[ReferenceFact]:
if not REFERENCES_PATH.exists():
return []
out = []
for line in REFERENCES_PATH.read_text(encoding="utf-8").splitlines():
if line.strip():
try:
out.append(ReferenceFact(**json.loads(line)))
except Exception:
continue
return out
# Params injected as material baselines — restricted to ones we have HIGH-CONFIDENCE
# (curated config) values for. Deliberately excludes fan_pct/first_layer_fan_pct
# (only present from bulk 3DTime G-code headers where M106 was unparsed → defaulted
# to ~0, which is wrong and misleading) and the noisy modal-only advance params.
# Everything the ingestion collects still lives in data/references.jsonl as a dataset;
# this is just what's trustworthy enough to put in front of the model.
_PROMPT_PARAMS = ("nozzle_temp", "bed_temp", "retraction_mm", "fan_pct",
"max_temp", "bed_max_temp")
# Params we trust ONLY from curated configs, never from bulk modal/3DTime parses
# (fan was defaulted to ~0 by the G-code header extractor). So fan reaches the prompt
# only when a real slicer profile supplies it.
_CURATED_ONLY = {"fan_pct", "first_layer_fan_pct"}
# Sane physical ranges — drop parse-garbage (e.g. a G-code header that yields
# bed_temp=12 or nozzle_temp=120 from a misread M-code) before it reaches the model.
_SANE_RANGE = {
"nozzle_temp": (170, 320), "bed_temp": (40, 130), "max_temp": (200, 350),
"bed_max_temp": (60, 150), "retraction_mm": (0.2, 8.0), "fan_pct": (0, 100),
"first_layer_fan_pct": (0, 100), "pressure_advance": (0.0, 1.5),
"linear_advance_k": (0.0, 2.0), "shore_hardness": (0, 100),
}
_REF_ORDER = ("nozzle_temp", "bed_temp", "retraction_mm", "fan_pct", "max_temp", "bed_max_temp")
def material_facts(material: str, facts: list[ReferenceFact] | None = None,
params: tuple[str, ...] | None = None) -> dict[str, dict]:
"""On-demand reference lookup for ONE material: curated + range-filtered +
aggregated. Returns {param: {value(median), lo, hi, n, sources}}. The bulk
model-metadata params are excluded; out-of-range parse noise is dropped. This
is the single source the prompt block and any targeted fact lookup draw from —
facts are fetched per request, not bulk-injected."""
facts = facts if facts is not None else load_references()
want = set(params) if params else set(_PROMPT_PARAMS)
# split curated (slicer/firmware/profile configs — precise) from bulk
# (modal/3DTime/gcode-derived — high volume, lower precision). Prefer curated.
cur: dict[str, list[tuple[float, str]]] = {}
bulk: dict[str, list[tuple[float, str]]] = {}
for f in facts:
if f.material not in (material, "*") or f.param not in want:
continue
lo, hi = _SANE_RANGE.get(f.param, (float("-inf"), float("inf")))
if not (lo <= f.value <= hi):
continue
head = f.source.split(":")[0]
bucket = bulk if head.startswith(("modal", "ablam")) else cur
bucket.setdefault(f.param, []).append((f.value, head))
out: dict[str, dict] = {}
for p in want:
# authoritative first; bulk only if no curated AND the param is bulk-trustworthy
pairs = cur.get(p) or (None if p in _CURATED_ONLY else bulk.get(p))
if not pairs:
continue
vals = sorted(v for v, _ in pairs)
out[p] = {"value": vals[len(vals) // 2], "lo": vals[0], "hi": vals[-1],
"n": len(vals), "sources": sorted({s for _, s in pairs})[:3]}
return out
def lookup_fact(material: str, param: str, facts: list[ReferenceFact] | None = None) -> dict | None:
"""On-demand single-fact lookup (material+global), aggregated. None if unknown."""
return material_facts(material, facts, params=(param,)).get(param)
def reference_block(material: str, facts: list[ReferenceFact] | None = None,
max_lines: int = 8) -> list[str]:
"""Lean prompt lines for THIS material — one aggregated line per useful param
(median + observed range + source count), capped. Computed on demand, so the
1,300-row reference corpus never floods the prompt (was 500+ lines/material)."""
md = material_facts(material, facts)
lines: list[str] = []
for p in _REF_ORDER:
d = md.get(p)
if not d:
continue
rng = f" (range {d['lo']:g}{d['hi']:g}, n={d['n']})" if d["n"] > 1 else ""
src = f" [{', '.join(d['sources'])}]" if d["sources"] else ""
lines.append(f"{p}{d['value']:g}{rng}{src}")
if len(lines) >= max_lines:
break
return lines
def ingest_candidate_lessons(path: Path, ledger: LedgerManager) -> int:
"""Append research-distilled env-keyed rows to the ledger as source='ingested'."""
if not path.exists():
return 0
n = 0
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
row["source"] = "ingested"
ledger.append(LessonEntry(**row))
n += 1
except Exception:
continue
return n