Spaces:
Runtime error
Runtime error
| """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 | |