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"]