""" Chromeleon Analytics — FastAPI Backend Hugging Face Spaces Edition (port 7860) ========================================= AI via GROQ (OpenAI-compatible, free tier available at console.groq.com) Set GROQ_API_KEY as a Secret in your HF Space Settings to enable AI features. """ import gzip import json import math import os import re import time from collections import Counter, defaultdict from pathlib import Path from typing import Optional, List from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel # --------------------------------------------------------------------------- # !! CRITICAL FIX !! # The OpenAI client is NEVER instantiated at module level. # It is created lazily inside ai_chat() / ai_analyse() only when a key exists. # This means the app boots cleanly even when GROQ_API_KEY is not set. # --------------------------------------------------------------------------- BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" STATIC_DIR = BASE_DIR / "static" STAGE_MAP = { "RM": "Raw Material", "FP": "Finished Product", "ST": "Stability", "PV": "Process Validation", "CU": "Content/Blend Uniformity","DI": "Dissolution", "GR": "Granulation", "CO": "Compression", "CT": "Coating", "FL": "Filling", "IP": "In Process", "SS": "Stratified Sampling", "CL": "Cleaning Validation", "WS": "Working Standard", "OT": "Other", } GROQ_MODELS = [ {"id": "llama-3.3-70b-versatile", "name": "Llama 3.3 70B Versatile", "context": 128000}, {"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B Instant", "context": 128000}, {"id": "llama3-70b-8192", "name": "Llama 3 70B", "context": 8192}, {"id": "llama3-8b-8192", "name": "Llama 3 8B", "context": 8192}, {"id": "mixtral-8x7b-32768", "name": "Mixtral 8x7B", "context": 32768}, {"id": "gemma2-9b-it", "name": "Gemma 2 9B", "context": 8192}, ] DEFAULT_MODEL = "llama-3.3-70b-versatile" GROQ_BASE_URL = "https://api.groq.com/openai/v1" def _get_groq_client(): """ Create and return an OpenAI-compatible GROQ client on demand. Returns None if GROQ_API_KEY is not set or openai package is missing. Raises HTTPException with a helpful message if key is missing. """ api_key = os.environ.get("GROQ_API_KEY", "").strip() if not api_key: raise HTTPException( status_code=503, detail=( "AI features require GROQ_API_KEY. " "Add it as a Secret in your HF Space Settings → Secrets. " "Get a free key at https://console.groq.com" ), ) try: from openai import OpenAI return OpenAI(api_key=api_key, base_url=GROQ_BASE_URL) except ImportError: raise HTTPException( status_code=503, detail="openai package not installed. Add 'openai>=1.30.0' to requirements.txt", ) def _ai_enabled() -> bool: return bool(os.environ.get("GROQ_API_KEY", "").strip()) # ── FastAPI ──────────────────────────────────────────────────────────────── app = FastAPI( title="Chromeleon Analytics API", description="HPLC Analytics + AI — GROQ (OpenAI-compatible) on Hugging Face Spaces", version="3.0.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ── In-memory store ──────────────────────────────────────────────────────── class DataStore: records: list = [] meta: dict = {} agg: dict = {} loaded: bool = False load_time: float = 0.0 DB = DataStore() # ── Helpers ──────────────────────────────────────────────────────────────── def _flt(v): try: return float(v) if v is not None else None except: return None def _stage_label(code: str) -> str: return STAGE_MAP.get(code, "Other") def _welford(n, mean, M2, val): n += 1; d = val - mean mean += d / n; M2 += d * (val - mean) return n, mean, M2 # ── Startup ──────────────────────────────────────────────────────────────── @app.on_event("startup") def load_data(): t0 = time.time() chunks = sorted(DATA_DIR.glob("compact_*.json.gz")) if not chunks: print("⚠ No data chunks found in data/. Run ingest.py first.", flush=True) return all_records = [] for path in chunks: with gzip.open(path, "rt", encoding="utf-8") as f: all_records.extend(json.load(f)) print(f" ✓ {path.name}: {len(all_records):,} total", flush=True) DB.records = all_records total = len(all_records) stage_cnt = Counter(); it_cnt = Counter(); instr_cnt = Counter() wl_cnt = Counter(); prod_cnt = Counter(); ft_cnt = Counter() thermo_cnt = Counter(); ct_cnt_d = Counter() status_fin = 0 daily_map = defaultdict(lambda: {"n":0,"a":[],"p":[],"c":[],"r":[],"as":[]}) area_n=area_mean=area_M2=0.0 pres_n=pres_mean=pres_M2=0.0 ct_n=ct_mean=ct_M2=0.0 area_sample=[]; stage_area=defaultdict(list); it_area=defaultdict(list) for i, r in enumerate(all_records): sc = _stage_label(r.get("sc","OT")); it = r.get("it") or "Unknown" stage_cnt[sc]+=1; it_cnt[it]+=1 if r.get("st")=="Finished": status_fin+=1 if r.get("in"): instr_cnt[r["in"]]+=1 if r.get("wl"): wl_cnt[r["wl"].split(",")[0].strip()]+=1 if r.get("p"): prod_cnt[r["p"]]+=1 if r.get("ft"): ft_cnt[r["ft"]]+=1 th=_flt(r.get("th")); ct=_flt(r.get("ct")) if th and th>0: thermo_cnt[f"{th:.1f}"]+=1 if ct and ct>0: ct_cnt_d[f"{ct:.1f}"]+=1 d=r.get("d") if d: dd=daily_map[d]; dd["n"]+=1 pa=_flt(r.get("pa")); rp=_flt(r.get("rp")) pr=_flt(r.get("pr")); pas=_flt(r.get("pas")) if pa and pa>0: dd["a"].append(pa) if rp and rp>0: dd["p"].append(rp) if ct and ct>0: dd["c"].append(ct) if pr and pr>0: dd["r"].append(pr) if pas and pas>0: dd["as"].append(pas) pa=_flt(r.get("pa")) if pa and pa>0: area_n,area_mean,area_M2=_welford(area_n,area_mean,area_M2,pa) if i%5==0: area_sample.append(round(pa,1)) if len(stage_area[sc])<3000: stage_area[sc].append(pa) if len(it_area[it])<2000: it_area[it].append(pa) rp=_flt(r.get("rp")) if rp and rp>0: pres_n,pres_mean,pres_M2=_welford(pres_n,pres_mean,pres_M2,rp) if ct and ct>0: ct_n,ct_mean,ct_M2=_welford(ct_n,ct_mean,ct_M2,ct) def mavg(a): return sum(a)/len(a) if a else 0.0 def mstd(a): if len(a)<2: return 0.0 m=mavg(a); return math.sqrt(sum((x-m)**2 for x in a)/len(a)) daily=[] for d in sorted(daily_map): g=daily_map[d] daily.append({"d":d,"n":g["n"], "a": round(mavg(g["a"]),2) if g["a"] else None, "p": round(mavg(g["p"]),2) if g["p"] else None, "c": round(mavg(g["c"]),2) if g["c"] else None, "r": round(mavg(g["r"]),3) if g["r"] else None, "as":round(mavg(g["as"]),3) if g["as"] else None}) area_std=math.sqrt(area_M2/area_n) if area_n>1 else 0.0 pres_std=math.sqrt(pres_M2/pres_n) if pres_n>1 else 0.0 ct_std =math.sqrt(ct_M2/ct_n) if ct_n>1 else 0.0 DB.agg = { "total":total,"finished":status_fin, "area_mean":round(area_mean,2),"area_std":round(area_std,2),"area_n":int(area_n), "pres_mean":round(pres_mean,2),"pres_std":round(pres_std,2),"pres_n":int(pres_n), "ct_mean":round(ct_mean,2), "ct_std":round(ct_std,2), "ct_n":int(ct_n), "stage_cnt":dict(stage_cnt),"it_cnt":dict(it_cnt), "instr_cnt":dict(instr_cnt.most_common(20)), "wl_cnt":dict(wl_cnt.most_common(20)), "prod_cnt":dict(prod_cnt.most_common(20)), "ft_cnt":dict(ft_cnt), "thermo_cnt":dict(sorted(thermo_cnt.items(),key=lambda x:float(x[0]))[:20]), "ct_cnt":dict(sorted(ct_cnt_d.items(), key=lambda x:float(x[0]))[:20]), "daily":daily,"area_sample":area_sample[:2000], "sa_stats":{k:{"mean":round(mavg(v),1),"std":round(mstd(v),1),"n":len(v)} for k,v in stage_area.items() if v}, "it_stats": {k:{"mean":round(mavg(v),1),"std":round(mstd(v),1),"n":len(v)} for k,v in it_area.items() if v}, } DB.meta = { "total":total, "sequences": [v for v,_ in Counter(r.get("s") for r in all_records if r.get("s")).most_common(600)], "products": [v for v,_ in Counter(r.get("p") for r in all_records if r.get("p")).most_common(400)], "instruments":sorted(set(r.get("in") for r in all_records if r.get("in"))), "columns": sorted(set(r.get("co") for r in all_records if r.get("co") and r.get("co") not in (".", ""))), "inj_types": sorted(set(r.get("it") or "Unknown" for r in all_records)), "stage_cats": list(STAGE_MAP.values()), "tests": [v for v,_ in Counter(r.get("t") for r in all_records if r.get("t")).most_common(150)], "wavelengths":sorted(set(r.get("wl") for r in all_records if r.get("wl")))[:50], "flow_types": sorted(set(r.get("ft") for r in all_records if r.get("ft"))), } DB.loaded=True; DB.load_time=time.time()-t0 ai_tag = "✓ AI ready" if _ai_enabled() else "⚠ AI disabled (set GROQ_API_KEY Secret)" print(f"✓ Loaded {total:,} records in {DB.load_time:.1f}s | {ai_tag}", flush=True) # ── Filter ──────────────────────────────────────────────────────────────── def _apply_filters(records, p): q=( p.get("q") or "").lower().strip() seq=p.get("seq"); prod=p.get("product"); inst=p.get("instrument") col=p.get("column"); it=p.get("inj_type"); stage=p.get("stage") test=p.get("test"); wl=p.get("wavelength"); th_str=p.get("thermostat") ft=p.get("flow_type"); cmp=p.get("component") d_from=p.get("date_from","2025-01-01"); d_to=p.get("date_to","2025-12-31") p_min=p.get("pres_min"); p_max=p.get("pres_max") ct_min=p.get("ct_min"); ct_max=p.get("ct_max") sw_min=p.get("sw_min"); sw_max=p.get("sw_max") dil_min=p.get("dil_min");dil_max=p.get("dil_max") pa_min=p.get("area_min");pa_max=p.get("area_max") th_val=None if th_str: m=re.search(r"[\d.]+",th_str) if m: th_val=float(m.group()) out=[] for r in records: if seq and r.get("s") != seq: continue if prod and r.get("p") != prod: continue if inst and r.get("in") != inst: continue if col and r.get("co") != col: continue if it and (r.get("it") or "Unknown") != it: continue if stage and _stage_label(r.get("sc","OT")) != stage: continue if test and r.get("t") != test: continue if wl and (r.get("wl") or "").split(",")[0].strip() != wl: continue if th_val is not None: rth=r.get("th") if rth is None or abs(float(rth)-th_val)>0.5: continue if ft and r.get("ft") != ft: continue if cmp and r.get("cn") != cmp: continue rd=r.get("d") or "" if d_from and rdd_to: continue rp=r.get("rp") if p_min is not None and (rp is None or float(rp)p_max): continue ct=r.get("ct") if ct_min is not None and (ct is None or float(ct)ct_max): continue sw=r.get("sw") if sw_min is not None and (sw is None or float(sw)sw_max): continue d1=r.get("d1") if dil_min is not None and (d1 is None or float(d1)dil_max): continue pa=r.get("pa") if pa_min is not None and (pa is None or float(pa)pa_max): continue if q: hay="".join(str(r.get(k) or "") for k in ("p","b","ar","s","pn","in","cn")).lower() if q not in hay: continue out.append(r) return out # ═══════════════════════════════════════════════════════════════════════════ # CORE ROUTES # ═══════════════════════════════════════════════════════════════════════════ @app.get("/", response_class=HTMLResponse) def serve_dashboard(): dash = STATIC_DIR / "dashboard.html" if dash.exists(): return HTMLResponse(content=dash.read_text(encoding="utf-8")) return HTMLResponse( "

⬡ Chromeleon Analytics

" "

Dashboard not found. Copy chromeleon_dashboard_v3.html " "to static/dashboard.html.

" "

→ Interactive API Docs

" "

→ Health Check

", status_code=200, ) @app.get("/api/health") def health(): return { "status": "ok" if DB.loaded else "loading", "records": len(DB.records), "load_time_s": round(DB.load_time, 2), "ai_enabled": _ai_enabled(), "ai_provider": "GROQ (OpenAI-compatible)" if _ai_enabled() else None, "ai_note": None if _ai_enabled() else "Set GROQ_API_KEY Secret to enable AI", "port": 7860, } @app.get("/api/meta") def get_meta(): if not DB.loaded: raise HTTPException(503, "Data loading") return DB.meta @app.get("/api/aggregate") def get_aggregate(): if not DB.loaded: raise HTTPException(503, "Data loading") return DB.agg @app.get("/api/trends/daily") def daily_trends( metric: str = Query("n"), date_from: Optional[str]= Query(None), date_to: Optional[str]= Query(None), ): if not DB.loaded: raise HTTPException(503, "Data loading") daily = DB.agg.get("daily", []) if date_from: daily=[d for d in daily if d["d"]>=date_from] if date_to: daily=[d for d in daily if d["d"]<=date_to] return {"metric":metric,"labels":[d["d"] for d in daily],"values":[d.get(metric) for d in daily]} @app.get("/api/stats/summary") def stats_summary(): if not DB.loaded: raise HTTPException(503, "Data loading") a=DB.agg; areas=sorted(a.get("area_sample",[])); n=len(areas) def pct(p): if not n: return None return round(areas[min(int(n*p/100),n-1)],2) return { "area":{"n":a["area_n"],"mean":a["area_mean"],"std":a["area_std"], "rsd_pct":round(a["area_std"]/a["area_mean"]*100,3) if a["area_mean"] else None, "p10":pct(10),"p25":pct(25),"median":pct(50),"p75":pct(75),"p90":pct(90),"p99":pct(99)}, "pressure":{"n":a["pres_n"],"mean":a["pres_mean"],"std":a["pres_std"], "rsd_pct":round(a["pres_std"]/a["pres_mean"]*100,3) if a["pres_mean"] else None}, "col_temp":{"n":a["ct_n"],"mean":a["ct_mean"],"std":a["ct_std"]}, "total":a["total"],"finished":a["finished"], "finish_rate_pct":round(a["finished"]/a["total"]*100,2) if a["total"] else None, } @app.get("/api/injections") def list_injections( page:int=Query(1,ge=1), per_page:int=Query(50,ge=1,le=500), sort_by:str=Query("d"), sort_dir:str=Query("desc"), q:Optional[str]=Query(None), seq:Optional[str]=Query(None), product:Optional[str]=Query(None), instrument:Optional[str]=Query(None), column:Optional[str]=Query(None), inj_type:Optional[str]=Query(None), stage:Optional[str]=Query(None), test:Optional[str]=Query(None), wavelength:Optional[str]=Query(None), thermostat:Optional[str]=Query(None), flow_type:Optional[str]=Query(None), component:Optional[str]=Query(None), date_from:Optional[str]=Query("2025-01-01"), date_to:Optional[str]=Query("2025-12-31"), pres_min:Optional[float]=Query(None), pres_max:Optional[float]=Query(None), ct_min:Optional[float]=Query(None), ct_max:Optional[float]=Query(None), sw_min:Optional[float]=Query(None), sw_max:Optional[float]=Query(None), dil_min:Optional[float]=Query(None), dil_max:Optional[float]=Query(None), area_min:Optional[float]=Query(None), area_max:Optional[float]=Query(None), ): if not DB.loaded: raise HTTPException(503,"Data loading") params={k:v for k,v in { "q":q,"seq":seq,"product":product,"instrument":instrument,"column":column, "inj_type":inj_type,"stage":stage,"test":test,"wavelength":wavelength, "thermostat":thermostat,"flow_type":flow_type,"component":component, "date_from":date_from,"date_to":date_to, "pres_min":pres_min,"pres_max":pres_max,"ct_min":ct_min,"ct_max":ct_max, "sw_min":sw_min,"sw_max":sw_max,"dil_min":dil_min,"dil_max":dil_max, "area_min":area_min,"area_max":area_max, }.items() if v is not None} filtered=_apply_filters(DB.records,params); total=len(filtered) try: filtered.sort(key=lambda r:(r.get(sort_by) is None,r.get(sort_by)), reverse=(sort_dir.lower()=="desc")) except TypeError: pass start=(page-1)*per_page; page_data=filtered[start:start+per_page] for r in page_data: r["stage_label"]=_stage_label(r.get("sc","OT")) return {"total":total,"page":page,"per_page":per_page, "pages":math.ceil(total/per_page) if total else 0,"data":page_data} @app.get("/api/injections/export") def export_csv( q:Optional[str]=Query(None), seq:Optional[str]=Query(None), product:Optional[str]=Query(None), instrument:Optional[str]=Query(None), column:Optional[str]=Query(None), inj_type:Optional[str]=Query(None), stage:Optional[str]=Query(None), test:Optional[str]=Query(None), wavelength:Optional[str]=Query(None), thermostat:Optional[str]=Query(None), flow_type:Optional[str]=Query(None), component:Optional[str]=Query(None), date_from:Optional[str]=Query("2025-01-01"), date_to:Optional[str]=Query("2025-12-31"), pres_min:Optional[float]=Query(None), pres_max:Optional[float]=Query(None), ct_min:Optional[float]=Query(None), ct_max:Optional[float]=Query(None), ): if not DB.loaded: raise HTTPException(503,"Data loading") params={k:v for k,v in { "q":q,"seq":seq,"product":product,"instrument":instrument,"column":column, "inj_type":inj_type,"stage":stage,"test":test,"wavelength":wavelength, "thermostat":thermostat,"flow_type":flow_type,"component":component, "date_from":date_from,"date_to":date_to, "pres_min":pres_min,"pres_max":pres_max,"ct_min":ct_min,"ct_max":ct_max, }.items() if v is not None} filtered=_apply_filters(DB.records,params) COLS=["p","b","ar","d","s","it","sc","t","in","co","pn","pa","pr","pas","pp2", "rp","pp","ct","th","wl","d1","d2","d3","sw1","sw2","sw","fa","cc","op"] HDRS=["Product","Batch","AR","Date","Sequence","InjType","Stage","Test","Instrument", "Column","PeakName","PeakArea","RT_min","Asymmetry","Plates","Pres_Start_bar", "Pres_Stop_bar","ColTemp_C","Thermo_C","Wavelength","Dil_v1","Dil_v2","Dil_v3", "Std1_Wt","Std2_Wt","SampleWt_g","FlowAvg","CumCount","Operator"] def gen(): yield ",".join(HDRS)+"\n" for r in filtered: vals=[] for c in COLS: v=_stage_label(r.get("sc","OT")) if c=="sc" else r.get(c) if v is None: vals.append("") else: s=str(v); vals.append(f'"{s}"' if "," in s else s) yield ",".join(vals)+"\n" return StreamingResponse(gen(),media_type="text/csv", headers={"Content-Disposition":"attachment; filename=chromeleon_export.csv"}) @app.get("/api/injections/{injection_id}") def get_injection(injection_id:str): if not DB.loaded: raise HTTPException(503,"Data loading") for r in DB.records: if str(r.get("i"))==injection_id: return {**r,"stage_label":_stage_label(r.get("sc","OT"))} raise HTTPException(404,f"Injection {injection_id!r} not found") class CompareRequest(BaseModel): by: str groups: List[str] @app.post("/api/compare") def compare_groups(body:CompareRequest): if not DB.loaded: raise HTTPException(503,"Data loading") if len(body.groups)<2: raise HTTPException(400,"Need at least 2 groups") if len(body.groups)>3: raise HTTPException(400,"Maximum 3 groups") fm={"product":"p","seq":"s","instrument":"in","stage":None,"inj_type":"it"} if body.by not in fm: raise HTTPException(400,f"Unknown by: {body.by!r}") field=fm[body.by] def kfn(r): return _stage_label(r.get("sc","OT")) if body.by=="stage" else r.get(field) def gs(recs): areas=[float(r["pa"]) for r in recs if r.get("pa") and float(r.get("pa",0))>0] pres =[float(r["rp"]) for r in recs if r.get("rp") and float(r.get("rp",0))>0] cts =[float(r["ct"]) for r in recs if r.get("ct") is not None] rts =[float(r["pr"]) for r in recs if r.get("pr") and float(r.get("pr",0))>0] fins =sum(1 for r in recs if r.get("st")=="Finished") def avg(a): return round(sum(a)/len(a),4) if a else None def std(a): if len(a)<2: return None m=sum(a)/len(a); return round(math.sqrt(sum((x-m)**2 for x in a)/len(a)),4) def rsd(a): m,s=avg(a),std(a); return round(s/m*100,3) if (m and s) else None return {"count":len(recs),"finish_rate":round(fins/len(recs)*100,2) if recs else None, "area_mean":avg(areas),"area_std":std(areas),"area_rsd_pct":rsd(areas), "pres_mean":avg(pres),"pres_rsd_pct":rsd(pres),"ct_mean":avg(cts),"rt_mean":avg(rts)} return {"by":body.by,"groups":{g:gs([r for r in DB.records if kfn(r)==g]) for g in body.groups}} # ═══════════════════════════════════════════════════════════════════════════ # AI ROUTES — GROQ via lazy OpenAI-compatible client # ═══════════════════════════════════════════════════════════════════════════ SYSTEM_PROMPT = """You are an expert HPLC analytical chemist AI assistant integrated into Chromeleon Analytics. You help analysts interpret chromatography data, troubleshoot method issues, and extract insights from injection datasets. Live dataset stats: - Total injections : {total} - Finished : {finished} ({finish_rate:.1f}%) - Mean peak area : {area_mean:,.2f} (RSD {area_rsd:.2f}%) - Mean pressure : {pres_mean:.1f} bar - Mean col temp : {ct_mean:.1f} °C - Date range : 2025-01-02 to 2025-12-08 - Unique products : 3,509 | Sequences: 30,026 | Instruments: 71 Be concise, data-driven, and flag any system suitability concerns.""" @app.get("/api/ai/models") def list_models(): """List available GROQ models (no API key required).""" return { "enabled": _ai_enabled(), "provider": "GROQ", "base_url": GROQ_BASE_URL, "default": DEFAULT_MODEL, "models": GROQ_MODELS, "note": None if _ai_enabled() else "Set GROQ_API_KEY Secret to enable AI calls", } class ChatMessage(BaseModel): role: str content: str class ChatRequest(BaseModel): messages: List[ChatMessage] model: Optional[str] = DEFAULT_MODEL temperature: Optional[float] = 0.7 max_tokens: Optional[int] = 1024 context: Optional[dict] = None @app.post("/api/ai/chat") def ai_chat(body: ChatRequest): """AI chat — GROQ_API_KEY Secret must be set in HF Space Settings.""" client = _get_groq_client() # raises 503 with clear message if key missing a = DB.agg if DB.loaded else {} finish_rate = (a.get("finished",0)/a.get("total",1)*100) if a.get("total") else 0 area_rsd = (a.get("area_std",0)/a.get("area_mean",1)*100) if a.get("area_mean") else 0 sys_content = SYSTEM_PROMPT.format( total = a.get("total", "N/A"), finished = a.get("finished","N/A"), finish_rate= finish_rate, area_mean = a.get("area_mean", 0), area_rsd = area_rsd, pres_mean = a.get("pres_mean", 0), ct_mean = a.get("ct_mean", 0), ) if body.context: sys_content += f"\n\nCurrent view context:\n{json.dumps(body.context,indent=2)}" messages = [{"role":"system","content":sys_content}] messages += [{"role":m.role,"content":m.content} for m in body.messages] try: resp = client.chat.completions.create( model=body.model or DEFAULT_MODEL, messages=messages, temperature=body.temperature, max_tokens=body.max_tokens, ) choice = resp.choices[0] return { "reply": choice.message.content, "model": resp.model, "usage": { "prompt_tokens": resp.usage.prompt_tokens, "completion_tokens": resp.usage.completion_tokens, "total_tokens": resp.usage.total_tokens, }, "finish_reason": choice.finish_reason, } except Exception as e: raise HTTPException(500, f"GROQ API error: {e}") @app.post("/api/ai/analyse") def ai_analyse( product: Optional[str] = Query(None), instrument: Optional[str] = Query(None), stage: Optional[str] = Query(None), model: str = Query(DEFAULT_MODEL), ): """Auto-generate an AI analysis for a product / instrument / stage subset.""" client = _get_groq_client() params = {} if product: params["product"] = product if instrument: params["instrument"] = instrument if stage: params["stage"] = stage subset = _apply_filters(DB.records, params) if params else DB.records[:5000] if not subset: raise HTTPException(404, "No records match the given filters") areas=[float(r["pa"]) for r in subset if r.get("pa") and float(r.get("pa",0))>0] pres =[float(r["rp"]) for r in subset if r.get("rp") and float(r.get("rp",0))>0] cts =[float(r["ct"]) for r in subset if r.get("ct") is not None] def avg(a): return round(sum(a)/len(a),2) if a else None def std(a): if len(a)<2: return None m=avg(a); return round(math.sqrt(sum((x-m)**2 for x in a)/len(a)),2) ctx = { "filter":params or "all records","n_records":len(subset), "area_mean":avg(areas),"area_std":std(areas), "area_rsd_pct":round(std(areas)/avg(areas)*100,2) if avg(areas) and std(areas) else None, "pres_mean":avg(pres),"pres_std":std(pres),"ct_mean":avg(cts), "finish_rate_pct":round(sum(1 for r in subset if r.get("st")=="Finished")/len(subset)*100,1), } prompt = ( "Analyse the following HPLC data subset and provide:\n" "1. Summary of analytical performance\n" "2. Any concerns (RSD, pressure, temperature)\n" "3. Recommendations\n\n" f"Data: {json.dumps(ctx)}" ) try: resp = client.chat.completions.create( model=model, messages=[ {"role":"system","content":"You are an expert HPLC analytical chemist."}, {"role":"user", "content":prompt}, ], temperature=0.4, max_tokens=1024, ) return {"filter":params or "all","data_ctx":ctx, "analysis":resp.choices[0].message.content,"model":resp.model} except Exception as e: raise HTTPException(500, f"GROQ API error: {e}")