Skip to content

Commit dadca51

Browse files
committed
improve operator handling and deep link cohort building
1 parent 7b148ab commit dadca51

17 files changed

Lines changed: 2443 additions & 189 deletions

File tree

plugins/functions/code-suggestion/src/agent/cohortAgent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { getCohortAgentPrompt } from "./cohortAgentPrompt";
3030
// degrades tool choice and adds latency to every step.
3131
const COHORT_AGENT_SERVER_TOOLS = new Set([
3232
"list_cohort_filters",
33+
"list_cohort_filter_values",
3334
"build_d2e_cohort_deeplink",
3435
"search_concepts",
3536
"check_concept_coverage_in_dataset",

plugins/functions/code-suggestion/src/agent/cohortAgentPrompt.ts

Lines changed: 231 additions & 77 deletions
Large diffs are not rendered by default.

plugins/functions/mcp-server/src/lib/cohortCatalog.ts

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,19 +143,123 @@ export function buildCohortCatalog(config: any): CohortCatalog {
143143
return { cards };
144144
}
145145

146+
/** Case/whitespace-insensitive comparison key for a card/attribute name. */
147+
export function normName(s: string): string {
148+
return String(s ?? "").trim().toLowerCase();
149+
}
150+
151+
/** Find a card by display name (exact ci, then substring). */
152+
export function findCardByName(
153+
catalog: CohortCatalog,
154+
name: string,
155+
): CatalogCard | undefined {
156+
const n = normName(name);
157+
return (
158+
catalog.cards.find((c) => normName(c.name) === n) ??
159+
catalog.cards.find(
160+
(c) => normName(c.name).includes(n) || n.includes(normName(c.name)),
161+
)
162+
);
163+
}
164+
165+
/** Find an attribute within a card by display name (exact ci, then substring). */
166+
export function findAttributeByName(
167+
card: CatalogCard,
168+
name: string,
169+
): CatalogAttribute | undefined {
170+
const n = normName(name);
171+
return (
172+
card.attributes.find((a) => normName(a.name) === n) ??
173+
card.attributes.find(
174+
(a) => normName(a.name).includes(n) || n.includes(normName(a.name)),
175+
)
176+
);
177+
}
178+
179+
/**
180+
* Every card that exposes an attribute by this name.
181+
*
182+
* Used to turn "card X has no attribute Y" into "…but card Z does". The model
183+
* puts demographics on the event card it is thinking about ("ER visits under
184+
* 80" → Age on the Visit card), and without this hint it reads the rejection as
185+
* "this dataset cannot filter on age" and gives up — the attribute is on Basic
186+
* Data, one clause away.
187+
*/
188+
export function findAttributeAcrossCards(
189+
catalog: CohortCatalog,
190+
name: string,
191+
): { card: CatalogCard; attribute: CatalogAttribute }[] {
192+
const out: { card: CatalogCard; attribute: CatalogAttribute }[] = [];
193+
for (const card of catalog.cards) {
194+
const attribute = findAttributeByName(card, name);
195+
if (attribute) out.push({ card, attribute });
196+
}
197+
return out;
198+
}
199+
200+
/** The card's primary concept-set attribute (cohortDefinitionKey "CodesetId"). */
201+
export function primaryConceptAttribute(
202+
card: CatalogCard,
203+
): CatalogAttribute | undefined {
204+
return card.attributes.find(
205+
(a) => a.kind === "conceptSet" && a.cohortDefinitionKey === "CodesetId",
206+
);
207+
}
208+
146209
/**
147210
* Compact human/LLM-readable summary of the catalog: one line per card listing
148211
* each attribute as `Name[kind]`, so the model can ground its filter choices on
149212
* the real cards/attributes before calling the build tool.
213+
*
214+
* The trailing notes are not decoration. Two failure modes come straight from a
215+
* bare card→attribute listing: the model looks for Age on the event card it is
216+
* building and concludes the dataset cannot filter by age (demographics are
217+
* patient-card attributes), and it invents a `category` token instead of looking
218+
* the column up. Both are answered here, next to the data they apply to.
150219
*/
151220
export function summarizeCatalog(catalog: CohortCatalog): string {
152221
const lines = catalog.cards.map((c) => {
153-
const attrs = c.attributes.map((a) => `${a.name}[${a.kind}]`).join(", ");
222+
const attrs = c.attributes
223+
.map(
224+
(a) =>
225+
`${a.name}[${a.kind}${
226+
a.kind === "conceptSet" && a.cohortDefinitionKey === "CodesetId"
227+
? "*"
228+
: ""
229+
}]`,
230+
)
231+
.join(", ");
154232
return `- ${c.name}: ${attrs || "(no filterable attributes)"}`;
155233
});
156-
return (
157-
"Filter cards available on this dataset (use only these cards and " +
158-
"attributes when composing cohort filters):\n" +
159-
lines.join("\n")
160-
);
234+
const patient = catalog.cards.find((c) => c.key === "patient");
235+
const demographics = (patient?.attributes ?? [])
236+
.filter((a) => a.kind !== "conceptSet")
237+
.map((a) => a.name)
238+
.join(", ");
239+
240+
return [
241+
"Filter cards available on this dataset. Use these exact card and attribute " +
242+
"NAMES in build_d2e_cohort_deeplink clauses (one clause per card occurrence, " +
243+
"clauses are AND-ed):",
244+
lines.join("\n"),
245+
"",
246+
"Attribute kinds — how to supply the value:",
247+
"- num → { attribute, op: '>='|'<='|'<'|'>'|'='|'!=', value: <number> }, " +
248+
"or op 'range' with value [low, high]",
249+
"- category → the EXACT stored token. Look it up with " +
250+
"list_cohort_filter_values({ card, attribute, query? }) and pass a value it " +
251+
"returned; never hardcode or guess one. Use op 'in' with a list to match any " +
252+
"of several tokens.",
253+
"- conceptSet→ a persisted concept-set id (from list_concept_sets / " +
254+
"create_concept_set), NOT an OMOP concept id. '*' marks the card's primary " +
255+
"concept set, which is what a clause's `conceptSetId` attaches to.",
256+
"- datetime → not supported by the deep-link builder yet.",
257+
"",
258+
`"${patient?.name ?? "Basic Data"}" is the PATIENT card: ${
259+
demographics || "the patient demographics"
260+
} live only there. ` +
261+
"A demographic filter always goes in its own clause for that card — event " +
262+
"cards (encounters, conditions, drugs, …) have no age or gender attribute, " +
263+
"which is how the data is modelled, not a limit on what you can filter.",
264+
].join("\n");
161265
}

plugins/functions/mcp-server/src/lib/cohortResolver.ts

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import type {
44
CatalogCard,
55
CatalogAttribute,
66
} from "./cohortCatalog";
7+
import {
8+
findAttributeAcrossCards,
9+
findAttributeByName,
10+
findCardByName,
11+
primaryConceptAttribute,
12+
} from "./cohortCatalog";
713
import type { CohortConstraint, CohortExpression } from "./cohortBookmarkTree";
814

915
/**
@@ -38,42 +44,37 @@ export interface ResolverDeps {
3844

3945
const NUM_OPS = new Set([">=", "<=", "<", ">", "=", "!="]);
4046

41-
function norm(s: string): string {
42-
return s.trim().toLowerCase();
43-
}
47+
/** Ops a category/text attribute accepts. `in` OR-s a list of stored tokens. */
48+
const CATEGORY_OPS = new Set(["=", "!=", "in", "not in"]);
4449

45-
/** Find a card by display name (exact ci, then substring). */
46-
function findCard(
50+
/**
51+
* "Card X has no attribute Y" — plus, when Y exists elsewhere, where to put it.
52+
*
53+
* The bare version of this error is what ended a real session with "the Visit
54+
* card doesn't have an age attribute" and no cohort: age is a patient
55+
* attribute, one clause away, and the model had no way to know that from the
56+
* rejection alone.
57+
*/
58+
function unknownAttributeError(
4759
catalog: CohortCatalog,
48-
name: string,
49-
): CatalogCard | undefined {
50-
const n = norm(name);
51-
return (
52-
catalog.cards.find((c) => norm(c.name) === n) ??
53-
catalog.cards.find(
54-
(c) => norm(c.name).includes(n) || n.includes(norm(c.name)),
55-
)
56-
);
57-
}
58-
59-
/** Find an attribute within a card by display name (exact ci, then substring). */
60-
function findAttr(
6160
card: CatalogCard,
62-
name: string,
63-
): CatalogAttribute | undefined {
64-
const n = norm(name);
65-
return (
66-
card.attributes.find((a) => norm(a.name) === n) ??
67-
card.attributes.find(
68-
(a) => norm(a.name).includes(n) || n.includes(norm(a.name)),
69-
)
61+
requested: string,
62+
): Error {
63+
const available = card.attributes.map((a) => a.name).join(", ") || "(none)";
64+
const elsewhere = findAttributeAcrossCards(catalog, requested).filter(
65+
(hit) => hit.card.key !== card.key,
7066
);
71-
}
72-
73-
/** The card's primary concept-set attribute (cohortDefinitionKey "CodesetId"). */
74-
function primaryConceptAttr(card: CatalogCard): CatalogAttribute | undefined {
75-
return card.attributes.find(
76-
(a) => a.kind === "conceptSet" && a.cohortDefinitionKey === "CodesetId",
67+
const hint = elsewhere.length
68+
? ` "${elsewhere[0].attribute.name}" IS available on card "${elsewhere[0].card.name}"` +
69+
`${
70+
elsewhere.length > 1
71+
? ` (also: ${elsewhere.slice(1).map((h) => `"${h.card.name}"`).join(", ")})`
72+
: ""
73+
} — move that constraint into its own clause for that card instead of ` +
74+
`dropping it. Demographics (age, gender, race) always live on the patient card.`
75+
: "";
76+
return new Error(
77+
`Card "${card.name}" has no attribute "${requested}". Available: ${available}.${hint}`,
7778
);
7879
}
7980

@@ -150,6 +151,56 @@ function numExpressions(c: ClauseConstraint): {
150151
return { expressions: [{ operator: c.op, value: v }], combine: "OR" };
151152
}
152153

154+
/**
155+
* Build category/text expressions, resolving each raw term to the token the
156+
* dataset actually stores.
157+
*
158+
* A list (`op:"in"`, or an array value) becomes several OR-ed expressions on the
159+
* one attribute. That matters for real questions: an encounter-type column
160+
* splits "an ER visit" across several tokens ("Emergency Room Visit",
161+
* "Emergency Room and Inpatient Visit"), and forcing one token per constraint
162+
* would quietly answer a narrower question than the user asked. Negation is
163+
* AND-ed instead — "neither A nor B" is not "not A or not B".
164+
*/
165+
async function categoryExpressions(
166+
c: ClauseConstraint,
167+
card: CatalogCard,
168+
attr: CatalogAttribute,
169+
deps: ResolverDeps,
170+
): Promise<{ expressions: CohortExpression[]; combine: "AND" | "OR" }> {
171+
const op = String(c.op ?? "=").trim().toLowerCase();
172+
if (!CATEGORY_OPS.has(op)) {
173+
throw new Error(
174+
`Unsupported operator "${c.op}" for text attribute "${attr.name}". Use ` +
175+
`"=" (or "in" with a list of values to match any of them), or "!=" / ` +
176+
`"not in" to exclude.`,
177+
);
178+
}
179+
const raws = (Array.isArray(c.value) ? c.value : [c.value])
180+
.map((v) => String(v ?? "").trim())
181+
.filter(Boolean);
182+
if (raws.length === 0) {
183+
throw new Error(`Constraint on "${attr.name}" has no value.`);
184+
}
185+
186+
const negate = op === "!=" || op === "not in";
187+
const resolved: string[] = [];
188+
// Sequential on purpose: the resolver's fetches share a per-attribute cache,
189+
// so the second token reuses the first one's domain read instead of racing it.
190+
for (const raw of raws) {
191+
const value = await deps.resolveValue(card, attr, raw);
192+
if (!resolved.includes(value)) resolved.push(value);
193+
}
194+
195+
return {
196+
expressions: resolved.map((value) => ({
197+
operator: negate ? "!=" : "=",
198+
value,
199+
})),
200+
combine: negate ? "AND" : "OR",
201+
};
202+
}
203+
153204
/**
154205
* Resolve clauses to constraints. Throws an actionable Error on any clause that
155206
* can't be resolved (unknown card/attribute, missing concept attribute,
@@ -164,7 +215,7 @@ export async function resolveClausesToConstraints(
164215

165216
for (let i = 0; i < clauses.length; i++) {
166217
const clause = clauses[i];
167-
const card = findCard(catalog, clause.card);
218+
const card = findCardByName(catalog, clause.card);
168219
if (!card) {
169220
throw new Error(
170221
`Unknown filter card "${clause.card}". Available: ${catalog.cards
@@ -196,7 +247,7 @@ export async function resolveClausesToConstraints(
196247
// the agent already resolved the id via the concept-set tools).
197248
if (hasConcept) {
198249
await assertValidConceptSetId(clause.conceptSetId, card.name, deps);
199-
const attr = primaryConceptAttr(card);
250+
const attr = primaryConceptAttribute(card);
200251
if (!attr) {
201252
throw new Error(
202253
`Card "${card.name}" has no concept set to attach concept set ${clause.conceptSetId} to.`,
@@ -212,25 +263,22 @@ export async function resolveClausesToConstraints(
212263

213264
// 2. explicit attribute constraints.
214265
for (const cc of clause.constraints ?? []) {
215-
const attr = findAttr(card, cc.attribute);
266+
const attr = findAttributeByName(card, cc.attribute);
216267
if (!attr) {
217-
throw new Error(
218-
`Card "${card.name}" has no attribute "${cc.attribute}". Available: ${card.attributes
219-
.map((a) => a.name)
220-
.join(", ")}.`,
221-
);
268+
throw unknownAttributeError(catalog, card, cc.attribute);
222269
}
223270

224271
let expressions: CohortExpression[];
225272
let combine: "AND" | "OR";
226273
if (attr.kind === "num") {
227274
({ expressions, combine } = numExpressions(cc));
228275
} else if (attr.kind === "category") {
229-
const resolved = await deps.resolveValue(card, attr, String(cc.value));
230-
expressions = [
231-
{ operator: cc.op === "!=" ? "!=" : "=", value: resolved },
232-
];
233-
combine = "OR";
276+
({ expressions, combine } = await categoryExpressions(
277+
cc,
278+
card,
279+
attr,
280+
deps,
281+
));
234282
} else if (attr.kind === "conceptSet") {
235283
// value IS the concept-set id (agent-resolved), e.g. a unit set.
236284
await assertValidConceptSetId(cc.value, card.name, deps);
944 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)