zukhriddinai's picture
Deploy LabOps Guardian API
a608353 verified
Raw
History Blame Contribute Delete
29.7 kB
"""Qwen3 agent via Nebius Token Factory.
Replaces the keyword router in /api/chat with a real reasoning loop:
user message + live lab context β†’ Qwen picks tool β†’ backend executes β†’ Qwen replies
The agent is grounded: it only states facts it retrieved from tools, never hallucinated ones.
"""
from __future__ import annotations
import json
import os
import re
from typing import Any
from openai import AsyncOpenAI
from datetime import datetime, timedelta, timezone
from labops_api import storage, tools as lab_tools
from labops_api.names import normalize_equipment_id, normalize_location
from labops_api.storage import next_id, now_iso
# ── Nebius client ─────────────────────────────────────────────────────────────
def _nebius_client() -> AsyncOpenAI | None:
key = os.getenv("NEBIUS_API_KEY", "").strip()
if not key:
return None
return AsyncOpenAI(
api_key=key,
base_url=os.getenv("NEBIUS_BASE_URL", "https://api.tokenfactory.nebius.com/v1").rstrip("/"),
)
MODEL = os.getenv("NEBIUS_COMMAND_MODEL", "Qwen3-235B-A22B-Instruct-2507")
CONVERSATIONS: dict[str, list[dict[str, str]]] = {}
# ── System prompt ─────────────────────────────────────────────────────────────
SYSTEM = """You are LabOps Guardian, an AI coworker for a biotech (cardiovascular tissue) research lab.
You have real-time access to equipment state, incidents, SOPs, inventory, samples, reminders, and operational history through your tools.
Lab layout (static reference β€” ALWAYS call get_lab_state for live readings; this list just tells you what exists):
- Cold Chain Zone: Freezer (-60 Β°C, sample C17's home), Backup Freezer, Biosafety Cabinet, CO2 Incubator (37 Β°C / 5% CO2), Reagent Fridge (4 Β°C).
- Prep & Imaging: Bench 2 (room temp ~21 Β°C), Centrifuge 2, Microscope 1, Pipette Station, Inventory Shelf A, Chemical Cabinet 1, Autoclave, Biohazard Waste, a simulated shelf camera/sensor, and the PI / Postdoc station.
- Tracked samples: C17 (cardiovascular tissue, home = Freezer, -60 Β°C, 20-minute room-temp limit on a bench) and A12 (backup tissue, Backup Freezer).
- Inventory includes: 15 mL tubes (Inventory Shelf A, bin 3, count is camera-inferred), pipette tips (Shelf A, bin 1, low stock), reagent bottles (Chemical Cabinet 1).
- Local SOPs cover: cardiovascular tissue prep, freezer temperature excursion, freezer sample storage, centrifuge setup, centrifuge error code 42, inventory shortage, and reagent calculation policy.
Rules you must always follow:
- NEVER state a temperature, incident ID, sample name/location, count, or any live lab fact unless it is in the live snapshot provided this turn or you retrieved it from a tool. The static layout above tells you what exists, NOT its current readings β€” use the snapshot (or a tool) for those, and never invent them.
- A CURRENT LAB SNAPSHOT is provided to you every turn. Answer questions about equipment, samples, inventory, and open incidents DIRECTLY from that snapshot β€” do NOT call get_lab_state for them (that just wastes time). Only call get_lab_state if you genuinely need fresher data right after a change. Use the other tools for changes, SOPs, calculations, history, reminders, or messaging.
- For general inventory questions call list_inventory; for a specific item's location or stock call find_inventory.
- To check any reagent math, call validate_calculation. It handles v/v (β†’ microliters), w/v (β†’ grams of solid), and stock dilutions (pass stock_percent for C1V1=C2V2). Choose the basis from the wording, report the expected value, and state which basis you assumed.
- To escalate to a person, use send_emergency_message. ALWAYS draft first (confirmed=false), read the draft back, and only send (confirmed=true) after the user explicitly confirms. Never claim a message was sent unless the tool returns status "sent".
- Sample IDs such as C17 and A12 are sample records, not inventory items. Use move_sample or get_lab_state for sample questions.
- Canonical freezer names are "Freezer" and "Backup Freezer". "Freezer A" β†’ "Freezer"; "Freezer B" or "Freezer D" β†’ "Backup Freezer". Equipment can also be referenced casually (e.g. "incubator", "fridge", "hood", "centrifuge") β€” resolve to the right unit.
- Distinguish confirmed facts from historical context. Say "a prior incident involved X β€” this may be relevant but the current cause is not confirmed."
- When citing SOP steps say "based on the local SOP".
- Be brief and operational β€” you are talking to a lab worker, not writing a report.
- Your reply is BOTH spoken aloud and shown on a small screen, so keep it voice-friendly: 1-3 short sentences (or up to 3 brief "- " bullets for a real list). Lead with the answer.
- Do NOT use LaTeX or math notation ($...$, \\frac, etc.), tables, headings (#), or code blocks. Write numbers and units in plain words a voice can read naturally: "20 microliters", "37 degrees Celsius", "0.02 percent", "minus 60". Light markdown (a **bold** key value or short bullets) is fine; no emojis.
- If asked to update something (resolve an incident, add an observation, create a ticket, post a sensor reading), call the right tool and confirm what you did.
- After move_sample, only mention incidents or alarms if the tool result includes open_incidents. If open_incidents is empty, do not speculate about alarms.
- Do not wrap responses in XML tags. Return plain conversational text only.
/no_think
"""
# ── Tool definitions (OpenAI function-calling format) ─────────────────────────
TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "get_lab_state",
"description": "Get the current lab state: equipment readings, open incidents, samples, inventory, and tickets.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "move_sample",
"description": "Move a known sample to a new location and persist the update in the LabOps sample database.",
"parameters": {
"type": "object",
"properties": {
"sample_id": {"type": "string", "description": "Sample ID, e.g. C17 or A12"},
"to_location": {"type": "string", "description": "Destination, e.g. Freezer, Backup Freezer, Bench 2"},
"from_location": {"type": "string", "description": "Optional source location if the user stated it."},
"from_temperature": {"type": "string", "description": "Optional source temperature, e.g. -60C"},
"allowed_room_temp_minutes": {"type": "integer", "description": "Room-temperature limit if moving to bench."},
},
"required": ["sample_id", "to_location"],
},
},
},
{
"type": "function",
"function": {
"name": "get_incidents",
"description": "List all incidents. Filter by status open/investigating/resolved.",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["open", "investigating", "resolved"],
"description": "Filter by status. Omit to return all."},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "retrieve_sop",
"description": "Retrieve grounded SOP guidance from local documents. Never invents steps.",
"parameters": {
"type": "object",
"properties": {
"issue_type": {"type": "string",
"description": "e.g. temperature_excursion, centrifuge_error, inventory_shortage"},
"equipment_id": {"type": "string", "description": "e.g. freezer or backup_freezer"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "recall_history",
"description": "Recall prior incidents for a piece of equipment. Always note that past cause does not confirm current cause.",
"parameters": {
"type": "object",
"properties": {
"equipment_id": {"type": "string", "description": "e.g. freezer or backup_freezer"},
"issue_type": {"type": "string", "description": "e.g. temperature_excursion"},
},
"required": ["equipment_id"],
},
},
},
{
"type": "function",
"function": {
"name": "create_ticket",
"description": "Create a maintenance ticket for an open incident and assign it to a team.",
"parameters": {
"type": "object",
"properties": {
"incident_id": {"type": "string", "description": "e.g. LAB-INC-001"},
"summary": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"assigned_to": {"type": "string", "description": "e.g. Facilities, Lab Manager"},
},
"required": ["incident_id", "severity", "assigned_to"],
},
},
},
{
"type": "function",
"function": {
"name": "add_observation",
"description": "Append an observation to an incident log (e.g. door confirmed closed, alarm active).",
"parameters": {
"type": "object",
"properties": {
"incident_id": {"type": "string"},
"observation": {"type": "string"},
},
"required": ["incident_id", "observation"],
},
},
},
{
"type": "function",
"function": {
"name": "update_incident_status",
"description": "Update the status of an incident to investigating or resolved.",
"parameters": {
"type": "object",
"properties": {
"incident_id": {"type": "string"},
"status": {"type": "string", "enum": ["open", "investigating", "resolved"]},
},
"required": ["incident_id", "status"],
},
},
},
{
"type": "function",
"function": {
"name": "generate_handoff",
"description": "Generate a shift handoff summary, optionally focused on a specific incident.",
"parameters": {
"type": "object",
"properties": {
"incident_id": {"type": "string", "description": "Focus handoff on this incident."},
"shift": {"type": "string", "description": "e.g. night, morning"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "list_inventory",
"description": "List all known inventory records with locations, stock levels, counts, confidence, and source metadata.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "find_inventory",
"description": "Look up where an item is stored and its current stock level.",
"parameters": {
"type": "object",
"properties": {
"item_name": {"type": "string", "description": "e.g. 15 mL tubes, ethanol"},
},
"required": ["item_name"],
},
},
},
{
"type": "function",
"function": {
"name": "validate_calculation",
"description": (
"Check a reagent calculation and show the work with assumptions. Three modes: "
"percent_volume_volume (v/v β†’ microliters of neat reagent), "
"percent_weight_volume (w/v β†’ grams of solid, X g per 100 mL), and "
"stock_dilution (C1V1=C2V2 β€” pass stock_percent to dilute a stock to target_percent). "
"Use whenever the user asks you to verify a concentration, dilution, percent, volume, microliter, or gram amount. "
"Pick calculation_type from the wording (v/v vs w/v); if they mention a stock %, pass stock_percent."
),
"parameters": {
"type": "object",
"properties": {
"target_percent": {"type": "number", "description": "Target concentration percent, e.g. 0.02 for 0.02%"},
"final_volume_ml": {"type": "number", "description": "Final volume in mL, e.g. 100"},
"user_answer_ul": {"type": "number", "description": "Optional: microliters the user calculated (v/v or dilution), to verify."},
"user_answer_g": {"type": "number", "description": "Optional: grams the user calculated (w/v), to verify."},
"stock_percent": {"type": "number", "description": "Stock concentration percent, when diluting from a stock (enables C1V1=C2V2)."},
"calculation_type": {"type": "string", "enum": ["percent_volume_volume", "percent_weight_volume", "stock_dilution"], "description": "Defaults to percent_volume_volume."},
},
"required": ["target_percent", "final_volume_ml"],
},
},
},
{
"type": "function",
"function": {
"name": "send_emergency_message",
"description": (
"Draft or send an escalation message to a teammate (postdoc, PI, lab manager, facilities). "
"SAFETY GATE: FIRST call with confirmed=false to create a DRAFT, then read the draft back and ask the user to confirm. "
"Only call again with confirmed=true AFTER the user explicitly says to send. "
"Never send without explicit confirmation, and never claim it was sent unless the tool returns status 'sent'."
),
"parameters": {
"type": "object",
"properties": {
"recipient_role": {"type": "string", "description": "e.g. postdoc, PI, lab manager, facilities"},
"message": {"type": "string", "description": "The message body to send."},
"confirmed": {"type": "boolean", "description": "false = draft only (default); true = actually send, only after the user confirms."},
},
"required": ["recipient_role", "message"],
},
},
},
{
"type": "function",
"function": {
"name": "post_sensor_reading",
"description": "Log a sensor reading for a piece of equipment. Triggers anomaly detection automatically.",
"parameters": {
"type": "object",
"properties": {
"equipment_id": {"type": "string", "description": "e.g. freezer or backup_freezer"},
"value": {"type": "number", "description": "The sensor reading value"},
"unit": {"type": "string", "description": "e.g. C, RPM, percent"},
},
"required": ["equipment_id", "value"],
},
},
},
]
# ── Tool execution ────────────────────────────────────────────────────────────
def _run_tool(name: str, args: dict[str, Any]) -> str:
try:
if name == "get_lab_state":
return json.dumps({
"equipment": storage.load("equipment"),
"incidents": storage.load("incidents"),
"tickets": storage.load("tickets"),
"samples": storage.load("samples"),
"inventory": storage.load("inventory"),
"reminders": storage.load("reminders"),
"experiment_runs": storage.load("experiment_runs"),
"server_time": now_iso(),
})
if name == "validate_calculation":
return json.dumps(lab_tools.validate_calculation(
calculation_type=args.get("calculation_type") or "percent_volume_volume",
target_percent=args.get("target_percent"),
final_volume_ml=args.get("final_volume_ml"),
user_answer_ul=args.get("user_answer_ul"),
stock_percent=args.get("stock_percent"),
user_answer_g=args.get("user_answer_g"),
))
if name == "send_emergency_message":
return json.dumps(lab_tools.send_emergency_message(
recipient_role=args.get("recipient_role") or "postdoc",
message=args.get("message") or "",
confirmed=bool(args.get("confirmed", False)),
))
if name == "get_incidents":
incidents = storage.load("incidents")
status_filter = args.get("status")
if status_filter:
incidents = [i for i in incidents if i.get("status") == status_filter]
return json.dumps(incidents)
if name == "retrieve_sop":
return json.dumps(lab_tools.retrieve_sop(
issue_type=args.get("issue_type"),
equipment_id=normalize_equipment_id(args.get("equipment_id")),
))
if name == "recall_history":
return json.dumps(lab_tools.recall_history(
equipment_id=normalize_equipment_id(args["equipment_id"]) or args["equipment_id"],
issue_type=args.get("issue_type"),
))
if name == "create_ticket":
return json.dumps(lab_tools.create_ticket(
incident_id=args["incident_id"],
severity=args["severity"],
assigned_to=args["assigned_to"],
summary=args.get("summary"),
))
if name == "add_observation":
incidents = storage.load("incidents")
incident = next((i for i in incidents if i["incident_id"] == args["incident_id"]), None)
if not incident:
return json.dumps({"error": f"Incident {args['incident_id']} not found"})
incident.setdefault("observations", []).append(args["observation"])
incident["updated_at"] = now_iso()
storage.save("incidents", incidents)
return json.dumps({"ok": True, "incident_id": args["incident_id"],
"observations": incident["observations"]})
if name == "update_incident_status":
incidents = storage.load("incidents")
incident = next((i for i in incidents if i["incident_id"] == args["incident_id"]), None)
if not incident:
return json.dumps({"error": f"Incident {args['incident_id']} not found"})
incident["status"] = args["status"]
incident["updated_at"] = now_iso()
storage.save("incidents", incidents)
return json.dumps({"ok": True, "incident_id": args["incident_id"], "status": args["status"]})
if name == "generate_handoff":
return json.dumps(lab_tools.generate_handoff(
incident_id=args.get("incident_id"),
shift=args.get("shift"),
))
if name == "move_sample":
samples = storage.load("samples")
sample_id = str(args["sample_id"]).upper().replace(" ", "")
sample = next((s for s in samples if s.get("sample_id", "").upper() == sample_id), None)
if not sample:
return json.dumps({"error": f"Sample {sample_id} not found in sample records."})
from_location = normalize_location(args.get("from_location")) or sample.get("location")
to_location = normalize_location(args["to_location"]) or args["to_location"]
allowed_minutes = int(args.get("allowed_room_temp_minutes") or sample.get("max_room_temp_minutes") or 20)
sample["location"] = to_location
sample["source_type"] = "user_reported"
sample["confidence"] = "medium"
sample["updated_at"] = now_iso()
reminders_created: list[dict[str, Any]] = []
if any(k in to_location.lower() for k in ("bench", "room", "table", "counter")):
started = datetime.now(timezone.utc)
deadline = started + timedelta(minutes=allowed_minutes)
sample["room_temp_started_at"] = started.isoformat(timespec="seconds")
sample["room_temp_deadline"] = deadline.isoformat(timespec="seconds")
reminders = storage.load("reminders")
warning = {
"id": next_id("rem", reminders),
"kind": "warning",
"sample_id": sample_id,
"label": f"{sample_id} nearing room-temp limit ({allowed_minutes - 2} min)",
"due_at": (deadline - timedelta(minutes=2)).isoformat(timespec="seconds"),
"status": "open",
"created_at": now_iso(),
}
reminders.append(warning)
escalation = {
"id": next_id("rem", reminders),
"kind": "escalation",
"sample_id": sample_id,
"label": f"{sample_id} hit the {allowed_minutes}-min room-temp limit",
"due_at": deadline.isoformat(timespec="seconds"),
"status": "open",
"created_at": now_iso(),
}
reminders.append(escalation)
storage.save("reminders", reminders)
reminders_created = [warning, escalation]
else:
sample["room_temp_started_at"] = None
sample["room_temp_deadline"] = None
storage.save("samples", samples)
events = storage.load("events")
event = {
"id": next_id("evt", events),
"type": "sample_moved",
"payload": {
"sample_id": sample_id,
"from": from_location,
"to": to_location,
"from_temperature": args.get("from_temperature"),
"allowed_room_temp_minutes": allowed_minutes,
},
"source_type": "user_reported",
"confidence": "medium",
"timestamp": now_iso(),
}
events.append(event)
storage.save("events", events)
destination_equipment_id = normalize_equipment_id(to_location)
open_incidents = [
i for i in storage.load("incidents")
if i.get("status") != "resolved" and i.get("equipment_id") == destination_equipment_id
]
destination_equipment = next(
(e for e in storage.load("equipment") if e.get("id") == destination_equipment_id),
None,
)
return json.dumps({
"sample": sample,
"reminders": reminders_created,
"event": event,
"destination_equipment": destination_equipment,
"open_incidents": open_incidents,
})
if name == "list_inventory":
return json.dumps(storage.load("inventory"))
if name == "find_inventory":
return json.dumps(lab_tools.find_inventory(args["item_name"]))
if name == "post_sensor_reading":
equipment_list = storage.load("equipment")
equipment_id = normalize_equipment_id(args["equipment_id"]) or args["equipment_id"]
eq = next((e for e in equipment_list if e["id"] == equipment_id), None)
if not eq:
return json.dumps({"error": f"Equipment {equipment_id} not found"})
unit = args.get("unit", "C")
value = args["value"]
eq["current_temperature"] = f"{value}{unit}"
eq["updated_at"] = now_iso()
normal_range = eq.get("normal_range")
incident_result = None
if normal_range and (value > normal_range["max"] or value < normal_range["min"]):
eq["status"] = "alarm"
from labops_api.app import _severity_from_exceedance, _process_sensor_event
from labops_api.models import EventRequest
req = EventRequest(type="temperature_reading", equipment_id=equipment_id,
value=value, unit=unit)
incident_result = _process_sensor_event(req)
else:
eq["status"] = "ok"
storage.save("equipment", equipment_list)
return json.dumps({"ok": True, "equipment_id": equipment_id,
"reading": f"{value}{unit}", "incident": incident_result})
return json.dumps({"error": f"Unknown tool: {name}"})
except Exception as exc:
return json.dumps({"error": str(exc)})
def _strip_think(text: str) -> str:
"""Remove Qwen3 chain-of-thought <think>...</think> blocks from the reply."""
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def _state_context() -> str:
"""Compact live snapshot injected each turn so the agent can answer state/inventory/
sample questions in ONE call instead of round-tripping through get_lab_state."""
equipment = storage.load("equipment")
samples = storage.load("samples")
inventory = storage.load("inventory")
open_incidents = [i for i in storage.load("incidents") if i.get("status") != "resolved"]
snap = {
"equipment": [
{"id": e.get("id"), "name": e.get("name"), "temp": e.get("current_temperature"), "status": e.get("status")}
for e in equipment
],
"samples": [
{"id": s.get("sample_id"), "location": s.get("location"), "storage": s.get("storage_temperature"),
"room_temp_deadline": s.get("room_temp_deadline")}
for s in samples
],
"inventory": [
{"item": i.get("item_name"), "location": i.get("location"), "stock": i.get("stock_level"),
"confidence": i.get("confidence"),
"count": i.get("camera_inferred_count") if i.get("camera_inferred_count") is not None else i.get("record_count")}
for i in inventory
],
"open_incidents": open_incidents,
"server_time": now_iso(),
}
return (
"CURRENT LAB SNAPSHOT (already retrieved for you this turn β€” answer state/sample/"
"inventory/incident questions directly from this; do not call get_lab_state):\n"
+ json.dumps(snap, separators=(",", ":"))
)
# ── Main agent call ───────────────────────────────────────────────────────────
async def run(message: str, sender: str = "user") -> str:
"""Run the Qwen agent on a user message. Returns the final text reply."""
client = _nebius_client()
if not client:
return "Nebius is not configured (NEBIUS_API_KEY missing)."
history = CONVERSATIONS.get(sender, [])[-8:]
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM},
{"role": "system", "content": _state_context()},
*history,
{"role": "user", "content": message},
]
for _ in range(6): # max tool-call iterations
response = await client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="auto",
max_tokens=400,
)
choice = response.choices[0]
# Append assistant turn
messages.append(choice.message.model_dump(exclude_none=True))
if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
for tc in choice.message.tool_calls:
args = json.loads(tc.function.arguments or "{}")
result = _run_tool(tc.function.name, args)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
else:
# Final text reply β€” strip any Qwen3 thinking blocks
reply = _strip_think(choice.message.content or "I could not complete that.")
CONVERSATIONS[sender] = [
*history,
{"role": "user", "content": message},
{"role": "assistant", "content": reply},
][-8:]
return reply
reply = "I reached the tool call limit. Please try a simpler request."
CONVERSATIONS[sender] = [
*history,
{"role": "user", "content": message},
{"role": "assistant", "content": reply},
][-8:]
return reply