|
42 | 42 | # three are merged in as extra keys derived from the exam initials. |
43 | 43 | EXAM_TYPE_ALIASES = {"cr", "tgo", "tgp", "plqt"} |
44 | 44 |
|
| 45 | +# Fields where the model tends to invent an impossible value to mean "absent". |
| 46 | +# No real threshold reaches -999, so anything at or below it is a sentinel. |
| 47 | +SENTINEL_CHECKED_FIELDS = { |
| 48 | + ProtocolVariableFieldEnum.EXAM.value, |
| 49 | + ProtocolVariableFieldEnum.EXAM_REF.value, |
| 50 | + ProtocolVariableFieldEnum.CN_STATS.value, |
| 51 | +} |
| 52 | +SENTINEL_VALUE_THRESHOLD = -999 |
| 53 | + |
45 | 54 | # Per-item criteria of a "combination" variable. They live as flat sibling keys |
46 | 55 | # of the variable itself (that is what the form renders and what |
47 | 56 | # utils.alert_protocol reads), but the model likes to wrap them in an object |
|
90 | 99 | "- config.trigger: expression combining variables as {{name}} with " |
91 | 100 | '"and", "or", "not" and parentheses (Python precedence: or < and < not). ' |
92 | 101 | "Nothing else is allowed — no literals, no comparisons, no function " |
93 | | - "calls. Maximum 500 characters.\n" |
| 102 | + "calls. Write the operators in LOWER CASE. Maximum 500 characters.\n" |
94 | 103 | '- config.result: {"type": "SHOW_MESSAGE", "level": "low"|"medium"|"high", ' |
95 | 104 | '"message": "<short alert>", "description": "<longer explanation>"}.\n\n' |
96 | 105 | "VARIABLE FIELDS (field → operator → value)\n" |
|
134 | 143 | '"doseOperator": ">", "defaultMeasureUnit": "mg"}\n' |
135 | 144 | 'WRONG (criteria are lost): {"name": "...", "field": "combination", ' |
136 | 145 | '"operator": "PRESENT", "value": {"substance": ["22165008"]}}\n\n' |
| 146 | + "ABSENCE OF DATA (exam, exam_ref, cn_stats)\n" |
| 147 | + "When the user asks about the ABSENCE of an exam or indicator — 'paciente " |
| 148 | + "sem creatinina', 'não tem hemograma', 'nenhum exame de função renal' — " |
| 149 | + "there is NO value that means absent. NEVER invent an impossible number " |
| 150 | + "like -999 to represent it: no such row exists in the database, so the " |
| 151 | + "comparison is simply never true and the protocol never fires.\n" |
| 152 | + "The pattern is: declare the variable POSITIVELY with operator '>' and " |
| 153 | + "value 0, which is true whenever any result of that type exists, and negate " |
| 154 | + "it in the TRIGGER with 'not'. A variable is false when the patient has no " |
| 155 | + "result, so its negation is exactly 'the patient has no such exam'.\n" |
| 156 | + 'CORRECT — variable {"name": "tem_creatinina", "field": "exam_ref", ' |
| 157 | + '"examRefType": "<tpexam>", "operator": ">", "value": 0} ' |
| 158 | + 'with trigger "not {{tem_creatinina}}".\n' |
| 159 | + 'WRONG (never matches): {"name": "sem_creatinina", "field": "exam_ref", ' |
| 160 | + '"examRefType": "<tpexam>", "operator": "=", "value": -999} ' |
| 161 | + 'with trigger "{{sem_creatinina}}".\n' |
| 162 | + "Name the variable after what it detects when TRUE (tem_..., possui_...), " |
| 163 | + "because the trigger is what inverts it. Combine it freely with other " |
| 164 | + "variables, e.g. \"{{idoso}} and not {{tem_creatinina}}\".\n" |
| 165 | + "This trick is ONLY for the numeric fields (exam, exam_ref, cn_stats), which " |
| 166 | + "have no negative operator. For list fields (substance, class, idDrug, " |
| 167 | + "route, idDepartment, idSegment, idIcd) use the NOTIN operator directly on " |
| 168 | + "the variable and do NOT negate the trigger.\n\n" |
137 | 169 | "RULES\n" |
138 | 170 | "- NEVER invent ids (sctid, idDrug, class, examType, examRefType, " |
139 | 171 | "statsType...). Only ever write an id you read from a tool result in this " |
@@ -331,6 +363,39 @@ def _normalize_variable(variable: dict) -> dict: |
331 | 363 | return normalized |
332 | 364 |
|
333 | 365 |
|
| 366 | +def _sentinel_value_errors(variables: list) -> list[str]: |
| 367 | + """Reject an impossible value used to mean "the patient has no such result". |
| 368 | +
|
| 369 | + No row in the database carries a sentinel, so the comparison never matches |
| 370 | + and the protocol silently never fires. Absence is expressed by declaring the |
| 371 | + variable positively (operator '>' value 0, true when any result exists) and |
| 372 | + negating it in the trigger with 'not'. |
| 373 | + """ |
| 374 | + errors = [] |
| 375 | + |
| 376 | + for variable in variables: |
| 377 | + if not isinstance(variable, dict): |
| 378 | + continue |
| 379 | + |
| 380 | + if variable.get("field") not in SENTINEL_CHECKED_FIELDS: |
| 381 | + continue |
| 382 | + |
| 383 | + try: |
| 384 | + value = float(variable.get("value")) |
| 385 | + except (TypeError, ValueError): |
| 386 | + continue |
| 387 | + |
| 388 | + if value <= SENTINEL_VALUE_THRESHOLD: |
| 389 | + errors.append( |
| 390 | + f"Variável {variable.get('name')}: valor {variable.get('value')} não " |
| 391 | + "existe na base. Para detectar a ausência do resultado, declare a " |
| 392 | + 'variável com operator ">" e value 0 e negue no gatilho com "not ' |
| 393 | + '{{nome_da_variavel}}"' |
| 394 | + ) |
| 395 | + |
| 396 | + return errors |
| 397 | + |
| 398 | + |
334 | 399 | def _load_exam_catalogs(variables: list) -> dict: |
335 | 400 | """Load the catalogs needed to check the ids used by these variables. |
336 | 401 |
|
@@ -472,6 +537,7 @@ def _validate_proposal(proposal: dict, draft: dict) -> list[str]: |
472 | 537 | errors.append(str(error)) |
473 | 538 |
|
474 | 539 | errors.extend(_combination_criteria_errors(variables=variables)) |
| 540 | + errors.extend(_sentinel_value_errors(variables=variables)) |
475 | 541 | errors.extend( |
476 | 542 | _catalog_errors( |
477 | 543 | variables=variables, catalogs=_load_exam_catalogs(variables=variables) |
|
0 commit comments