chromeleon / app.py
Srinu007's picture
Upload app.py
da60b1f verified
Raw History Blame Contribute Delete
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 ────────────────────────────────────────────────────────────────
@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 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
# ═══════════════════════════════════════════════════════════════════════════
@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(
"<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,
)
@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}")