Spaces:
Configuration error
Configuration error
Download app.py from Srinu007/chromeleon: direct link, hf CLI and curl.
- Browser
- Download file 28.9 kB
-
https://huggingface.co/spaces/Srinu007/chromeleon/resolve/main/app.py
- Command line
-
hf download hf://spaces/Srinu007/chromeleon/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Srinu007/chromeleon/resolve/main/app.py
28.9 kB
| """ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 rd<d_from: continue | |
| if d_to and rd>d_to: continue | |
| rp=r.get("rp") | |
| if p_min is not None and (rp is None or float(rp)<p_min): continue | |
| if p_max 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_min): continue | |
| if ct_max 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_min): continue | |
| if sw_max 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_min): continue | |
| if dil_max 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_min): continue | |
| if pa_max 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def serve_dashboard(): | |
| dash = STATIC_DIR / "dashboard.html" | |
| if dash.exists(): | |
| return HTMLResponse(content=dash.read_text(encoding="utf-8")) | |
| return HTMLResponse( | |
| "<h1>⬑ Chromeleon Analytics</h1>" | |
| "<p>Dashboard not found. Copy <code>chromeleon_dashboard_v3.html</code> " | |
| "to <code>static/dashboard.html</code>.</p>" | |
| "<p><a href='/docs'>β Interactive API Docs</a></p>" | |
| "<p><a href='/api/health'>β Health Check</a></p>", | |
| status_code=200, | |
| ) | |
| 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, | |
| } | |
| def get_meta(): | |
| if not DB.loaded: raise HTTPException(503, "Data loading") | |
| return DB.meta | |
| def get_aggregate(): | |
| if not DB.loaded: raise HTTPException(503, "Data loading") | |
| return DB.agg | |
| 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]} | |
| 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, | |
| } | |
| 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} | |
| 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"}) | |
| 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] | |
| 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.""" | |
| 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 | |
| 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}") | |
| 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}") | |