File size: 10,120 Bytes
801b1af
0336d0f
801b1af
0336d0f
801b1af
 
 
 
8cafc70
31de28c
0336d0f
801b1af
 
 
 
 
 
0336d0f
 
8cafc70
 
801b1af
 
31de28c
801b1af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cafc70
 
801b1af
 
 
 
 
0336d0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801b1af
 
 
0336d0f
 
 
 
 
 
 
 
31de28c
 
 
 
 
 
 
 
 
 
 
 
801b1af
31de28c
0336d0f
801b1af
0336d0f
 
 
 
 
 
 
 
 
 
 
 
 
 
31de28c
 
 
 
 
 
 
 
 
 
 
801b1af
 
31de28c
 
801b1af
 
 
 
4905c30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import os
import json
import pickle
import re
import tempfile
from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
from faster_whisper import WhisperModel
import anthropic
from supabase import create_client

load_dotenv()

app = Flask(__name__)
CORS(app)

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_KEY"])

print("Loading Whisper model (base)…")
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
print("Whisper ready.")

claude = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

SYSTEM_PROMPT = """You are a concise diagnostic assistant for HP Metal Jet S100 industrial 3D metal printers.
You receive real-time sensor data and answer operator questions in 1-3 short sentences.
Be direct and technical. Always respond in English."""


@app.route("/api/transcribe", methods=["POST"])
def transcribe():
    if "audio" not in request.files:
        return jsonify({"error": "no audio file"}), 400

    audio = request.files["audio"]

    with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp:
        audio.save(tmp.name)
        tmp_path = tmp.name

    try:
        segments, _ = whisper_model.transcribe(tmp_path, language="en", task="transcribe")
        text = " ".join(seg.text for seg in segments).strip()
        print(f"\n[TRANSCRIPTION] {text}\n")
        return jsonify({"text": text})
    finally:
        os.unlink(tmp_path)

#END STUFF

_SQL_SYSTEM = """You are a read-only PostgreSQL query generator for a fleet of HP Metal Jet S100 industrial 3D metal printers.
Your only job is to output a single valid SQL SELECT statement that fetches exactly the data needed to answer the operator's question.

DATABASE SCHEMA
===============
printers(
  id           CHAR(20) PRIMARY KEY,
  last_repair  TIMESTAMP
)

snapshots(
  id                  CHAR(20) REFERENCES printers(id),  -- printer identifier
  time_step_id        INT,                                -- monotonically increasing step counter
  recoater_blade      FLOAT,   -- Recoating System   (0.0 = failed, 1.0 = perfect)
  nozzle_plate        FLOAT,   -- Printhead Array
  heating_elements    FLOAT,   -- Thermal Control
  temperature_sensors FLOAT,   -- Thermal Control
  insulation_panels   FLOAT,   -- Thermal Control
  firing_resistors    FLOAT,   -- Printhead Array
  cleaning_interface  FLOAT,   -- Printhead Array
  recoater_motor      FLOAT,   -- Recoating System
  linear_rail         FLOAT,   -- Recoating System
  PRIMARY KEY (id, time_step_id)
)

conditions(
  id                         CHAR(20) REFERENCES printers(id),
  timestamp                  TIMESTAMP,
  ambient_temperature_c      FLOAT,   -- degrees Celsius
  build_chamber_temp_c       FLOAT,   -- degrees Celsius
  ambient_humidity_pct       FLOAT,   -- percentage 0-100
  powder_contamination_level FLOAT,   -- fraction 0-1
  build_volume_cm3           FLOAT,   -- cm3
  recoating_speed_mm_s       FLOAT,   -- mm/s
  maintenance_level          FLOAT,   -- fraction 0-1, higher = better maintained
  PRIMARY KEY (id, timestamp)
)

SUBSYSTEM GROUPINGS
===================
Thermal Control  : heating_elements, temperature_sensors, insulation_panels
Printhead Array  : nozzle_plate, firing_resistors, cleaning_interface
Recoating System : recoater_blade, recoater_motor, linear_rail

HEALTH THRESHOLDS (all snapshot columns use the same scale)
===========================================================
>= 0.60 = healthy
0.30 to 0.59 = warning
< 0.30 = critical

QUERY RULES
===========
1. Output ONLY the raw SQL β€” no markdown fences, no explanations, no semicolons.
2. Always filter snapshots and conditions by id = '<printer_id>' unless the question explicitly asks for fleet-wide comparison.
3. Current state  β†’ SELECT all 9 health columns FROM snapshots WHERE id = '<printer_id>' ORDER BY time_step_id DESC LIMIT 1
4. Trend / rate   β†’ fetch the last N rows ORDER BY time_step_id ASC (use 50-100 rows for rate, 200+ for long-term trend).
5. Anomaly        β†’ compute AVG and STDDEV per component over a window, compare to latest value.
6. Fleet queries  β†’ omit the id filter, GROUP BY id, rank by computed health score.
7. Maintenance    β†’ join printers on id to access last_repair; join conditions on id for environmental context.
8. Never use INSERT, UPDATE, DELETE, DROP, CREATE, TRUNCATE, or any DDL/DML.
9. Default LIMIT 100; for full history queries up to LIMIT 500.
10. When unsure which columns are relevant, select all 9 health columns β€” the answering model will filter.
11. Use readable aliases: recoater_blade AS "Recoater Blade", etc."""

_ANSWER_SYSTEM = """You are the digital co-pilot for HP Metal Jet S100 industrial 3D metal printers.
You receive raw data fetched from a live sensor database and answer operator questions accurately and directly.

TONE AND LENGTH
===============
- Floor operators need fast, actionable answers. Be direct and technical.
- Simple state questions: 1-2 sentences.
- Trend, anomaly, or multi-component questions: up to a short paragraph.
- Never pad with filler phrases like "Based on the data provided..." β€” start with the answer.
- Always respond in English.

SEVERITY TAG (mandatory β€” always open your response with one)
=============================================================
[CRITICAL] β€” any component below 0.30, imminent failure predicted, or immediate action required
[WARNING]  β€” any component between 0.30 and 0.59, accelerating degradation, or anomaly detected
[INFO]     β€” all components healthy, routine query, or informational answer

COMPONENT AND SUBSYSTEM REFERENCE
==================================
Thermal Control  : heating_elements, temperature_sensors, insulation_panels
Printhead Array  : nozzle_plate, firing_resistors, cleaning_interface
Recoating System : recoater_blade, recoater_motor, linear_rail

Health scale: 0.0 = completely failed, 1.0 = perfect condition
Thresholds  : >= 0.60 healthy, 0.30-0.59 warning, < 0.30 critical

GROUNDING RULES (non-negotiable)
=================================
1. Answer only from the data rows provided. Never use training knowledge to fill gaps.
2. Cite the specific value and time_step_id or timestamp for every number you state.
3. If the data is empty or NULL, say explicitly: "No data available for [component] β€” cannot answer."
4. Never extrapolate predictions beyond the available data window.
5. If the question is outside the scope of the telemetry, say so clearly.

RESPONSE PATTERNS BY QUESTION TYPE
====================================
Current state   β†’ state the health value(s), their severity, and what it means operationally.
Trend           β†’ state direction (improving/degrading), magnitude (e.g. dropped 0.12 over 50 steps), and whether the rate is accelerating.
Anomaly         β†’ name the component, the step where the drop occurred, the before/after values, and whether it is isolated or correlated with others.
Prediction      β†’ state how many steps remain at current rate before crossing 0.30; do not guess beyond the data window.
Root cause      β†’ trace the sequence: which component moved first, which followed, and whether conditions (temperature, contamination) correlate.
Fleet           β†’ rank printers by overall health score; name the worst component across the fleet.
Action/priority β†’ rank by (lowest health + fastest degradation rate); name the single most urgent repair first.
Maintenance     β†’ state the last_repair timestamp and how many steps have elapsed since then.
Aggregation     β†’ provide the exact computed values (avg, min, max, stddev) with the time window they cover.
No data         β†’ "No data available for [X]. Check that the printer ID is correct and that snapshots exist for this time range."
"""


def _extract_sql(text: str) -> str:
    text = text.strip()
    # strip markdown code fences if the model adds them anyway
    text = re.sub(r"^```[a-z]*\n?", "", text, flags=re.IGNORECASE)
    text = re.sub(r"\n?```$", "", text)
    return text.strip().rstrip(";")


@app.route("/api/chat", methods=["POST"])
def chat():
    data = request.json or {}
    prompt = data.get("prompt", "").strip()
    printer_id = data.get("printer_id", "")

    if not prompt:
        return jsonify({"error": "prompt is required"}), 400

    # ── Step 1: ask LLM to generate a SQL query for the needed data ──────────
    sql_resp = claude.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=400,
        system=_SQL_SYSTEM,
        messages=[{
            "role": "user",
            "content": (
                f"Database schema:\n{_SQL_SYSTEM}\n\n"
                f"printer_id: {printer_id}\n"
                f"User question: {prompt}"
            ),
        }],
    )
    sql = _extract_sql(sql_resp.content[0].text)
    print(f"\n[SQL] {sql}\n")

    # ── Step 2: run the query against Supabase ───────────────────────────────
    try:
        rpc_result = supabase.rpc("run_readonly_query", {"query_text": sql}).execute()
        db_data = rpc_result.data
        if isinstance(db_data, str):
            db_data = json.loads(db_data)
        db_text = json.dumps(db_data, indent=2)
    except Exception as exc:
        print(f"[DB ERROR] {exc}")
        db_text = f"Query failed: {exc}"

    print(f"[DB RESULT] {db_text[:500]}\n")

    # ── Step 3: ask LLM to answer using the retrieved data ───────────────────
    answer_resp = claude.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=300,
        system=_ANSWER_SYSTEM,
        messages=[{
            "role": "user",
            "content": (
                f"Data fetched from database:\n{db_text}\n\n"
                f"Operator question: {prompt}"
            ),
        }],
    )

    answer = answer_resp.content[0].text
    print(f"[CLAUDE ANSWER] {answer}\n")
    return jsonify({"text": answer})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=7860, debug=False)