Spaces:
Running
Running
File size: 8,885 Bytes
0734562 0372560 0734562 0372560 0734562 0372560 0734562 0372560 0734562 0372560 0734562 0372560 0734562 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | """Query planner: one natural-language request -> a list of search criteria.
This is a planner, not a chatbot. It has no memory, no dialogue, and never
answers a clinical question. It decomposes a cohort description into concepts
the existing search surfaces can already serve, and hands each one back as a
chip the user clicks. Nothing is searched automatically.
Two boundaries are load-bearing:
* Cohort logic is not ours. Age, care setting, index date, lookback -- the
research plan assigns those to SAGE. The planner extracts them so the user
can see they were understood, and returns them in `constraints`, which the
UI must render as explicitly NOT searched. Silently dropping them would let
a reader believe the result set honoured "after 2020".
* Every criterion carries a `quote`: the span of the user's own query it came
from, verified server-side to be a substring of that query. `concept` may be
a normalization ("HF" -> "heart failure") so it searches well, but the quote
keeps the decomposition auditable and kills the failure mode where a model
invents a criterion nobody asked for. This mirrors the rule already applied
to phenotype facets: every evidence span must appear in its source.
"""
from __future__ import annotations
from .llm import (KINDS, LlmError, builtin_catalog, builtin_configured,
builtin_model, builtin_provider, complete_json,
normalize_provider, parse_json_object, stream_json)
# Must stay in sync with the frontend CATS table and the served code
# categories. An unknown category is dropped, not coerced -- a chip that
# opens the wrong search surface is worse than a missing chip.
CATEGORIES = {"phenotype", "diagnosis", "medication", "lab", "procedure"}
CONSTRAINT_KINDS = {"age", "setting", "temporal", "demographic", "other"}
MAX_CRITERIA = 8
MAX_CONSTRAINTS = 8
MAX_QUERY_CHARS = 1000
MAX_FIELD_CHARS = 120
# Short, but not as short as it can be. Measured against the 40 SAGE CIPHER
# cohort specs: cutting the original 2,010-char prompt to 808 chars cost real
# quality -- phenotype routing collapsed from 4/8 to 1/8 on the affected
# queries, because the rule pairing a named condition with both phenotype and
# diagnosis had been trimmed away. Restoring that one line (932 chars) matches
# the long prompt's routing.
#
# Prompt length is not the latency lever it looks like: 808 vs 2,010 chars
# moved the median only ~0.7s. The cost is reasoning tokens, which scale with
# how complex the *request* is, not how long the instructions are. So keep
# this prompt tight for clarity and token cost, but do not trim rules hoping
# to buy speed -- you will pay in routing quality and get almost nothing back.
# Anything added here must earn its place against the 40-spec set.
SYSTEM = """\
Split a cohort request into ENCODE search criteria. Plan only: never answer \
clinical questions or produce codes.
Categories: phenotype (the condition naming the cohort), diagnosis (ICD \
codes for a condition), medication, lab, procedure.
Rules:
- One criterion per distinct clinical concept.
- The condition naming the cohort is a phenotype; also emit it as diagnosis \
when the request wants its ICD codes.
- concept: short searchable term, abbreviations expanded. Not a sentence.
- quote: the exact substring of the request it came from, verbatim.
- Cohort logic (age, setting, dates, sex, site, counts) goes in constraints, \
never criteria.
- Invent nothing; no concepts means empty criteria.
Return only this JSON:
{"criteria":[{"concept":"heart failure","category":"phenotype",\
"quote":"heart failure","rationale":"names the cohort"}],
"constraints":[{"text":"adults","kind":"age"}]}
kind: age|setting|temporal|demographic|other"""
def available() -> bool:
"""Whether this deployment ships a planner model of its own. A user can
still bring their own model when this is False."""
return builtin_configured()
def _clean(value: object, limit: int = MAX_FIELD_CHARS) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.split())[:limit].strip()
def _valid_criteria(raw: object, query_lower: str) -> list[dict]:
"""Keep only well-formed, category-known, query-grounded criteria."""
out: list[dict] = []
seen: set[tuple[str, str]] = set()
if not isinstance(raw, list):
return out
for item in raw:
if not isinstance(item, dict):
continue
concept = _clean(item.get("concept"))
category = _clean(item.get("category"), 32).lower()
quote = _clean(item.get("quote"))
if not concept or category not in CATEGORIES:
continue
# Grounding: the cited span must really be in the user's query.
# A model that cannot point at the text it used does not get a chip.
if not quote or quote.lower() not in query_lower:
continue
key = (category, concept.lower())
if key in seen:
continue
seen.add(key)
out.append({"concept": concept, "category": category, "quote": quote,
"rationale": _clean(item.get("rationale"))})
if len(out) >= MAX_CRITERIA:
break
return out
def _valid_constraints(raw: object) -> list[dict]:
out: list[dict] = []
seen: set[str] = set()
if not isinstance(raw, list):
return out
for item in raw:
if not isinstance(item, dict):
continue
text = _clean(item.get("text"))
if not text or text.lower() in seen:
continue
kind = _clean(item.get("kind"), 32).lower()
seen.add(text.lower())
out.append({"text": text, "kind": kind if kind in CONSTRAINT_KINDS else "other"})
if len(out) >= MAX_CONSTRAINTS:
break
return out
MAX_MODEL_TEXT = 100_000
def _assemble(q: str, raw: dict, label: str) -> dict:
criteria = _valid_criteria(raw.get("criteria"), q.lower())
constraints = _valid_constraints(raw.get("constraints"))
# A well-formed response that grounds nothing is a real outcome, not an
# error: the UI says so plainly rather than showing an empty panel.
note = None
if not criteria:
note = ("No searchable clinical concept was found in that description. "
"Try naming a condition, drug, lab, or procedure.")
return {"query": q, "criteria": criteria, "constraints": constraints,
"note": note, "model": label}
def plan(query: str, model: object = None, builtin: str | None = None) -> dict:
"""Decompose `query` by calling a model from here. `model` optionally
overrides the deployment's model with a user-supplied provider; `builtin`
names which of the deployment's own models to use."""
q = " ".join((query or "").split())[:MAX_QUERY_CHARS]
if not q:
raise LlmError("Empty query")
provider = normalize_provider(model) if model else builtin_provider(builtin)
raw = complete_json(SYSTEM, q, provider=provider)
return _assemble(q, raw, provider["label"])
def plan_streaming(query: str, builtin: str | None = None):
"""Yield ("thinking", delta) while the model reasons, then ("plan", dict).
Same validation as plan(); only the transport differs, so the built-in
model can show its reasoning instead of a blank box for ~25 seconds.
"""
q = " ".join((query or "").split())[:MAX_QUERY_CHARS]
if not q:
raise LlmError("Empty query")
provider = builtin_provider(builtin)
text = ""
for kind, payload in stream_json(SYSTEM, q, provider=provider):
if kind == "thinking":
yield ("thinking", payload)
elif kind == "usage":
yield ("usage", payload)
else:
text = payload
yield ("plan", _assemble(q, parse_json_object(text), provider["label"]))
def plan_from_text(query: str, text: object, label: object = None) -> dict:
"""Validate model output the *caller* obtained, without ever seeing their
credentials.
The browser calls its own model directly and posts the reply here. Parsing
and every schema rule -- grounding, the category allowlist, the caps --
then run in exactly one place, instead of being reimplemented in JS and
drifting from this file.
"""
q = " ".join((query or "").split())[:MAX_QUERY_CHARS]
if not q:
raise LlmError("Empty query")
if not isinstance(text, str) or not text.strip():
raise LlmError("The model returned an empty response")
if len(text) > MAX_MODEL_TEXT:
raise LlmError("The model response was too large to read")
shown = _clean(label, 60) if isinstance(label, str) else ""
return _assemble(q, parse_json_object(text), shown or "your model")
__all__ = ["plan", "plan_streaming", "plan_from_text", "available", "builtin_model", "SYSTEM",
"LlmError", "CATEGORIES", "KINDS"]
|