Skip to content

Commit 5640781

Browse files
committed
fix: protocol agent absent value
1 parent aa8ec7c commit 5640781

4 files changed

Lines changed: 181 additions & 2 deletions

File tree

services/admin/admin_protocol_service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,11 @@ def _validate_variables(variables: list[dict], protocol_type: int):
225225
)
226226

227227
else:
228-
if not operator or not value:
228+
# 0 is a legitimate threshold, not an empty field: comparing an exam
229+
# to "> 0" is how the absence of a result is expressed (the trigger
230+
# negates the variable), so only a genuinely empty value is rejected.
231+
# The editor sends numbers as text, so it reaches here as "0".
232+
if not operator or value is None or value == "" or value == []:
229233
raise ValidationError(
230234
f"Variável {name}: Todos os campos são obrigatórios",
231235
"errors.businessRules",

services/protocol_agent_service.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
# three are merged in as extra keys derived from the exam initials.
4343
EXAM_TYPE_ALIASES = {"cr", "tgo", "tgp", "plqt"}
4444

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+
4554
# Per-item criteria of a "combination" variable. They live as flat sibling keys
4655
# of the variable itself (that is what the form renders and what
4756
# utils.alert_protocol reads), but the model likes to wrap them in an object
@@ -90,7 +99,7 @@
9099
"- config.trigger: expression combining variables as {{name}} with "
91100
'"and", "or", "not" and parentheses (Python precedence: or < and < not). '
92101
"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"
94103
'- config.result: {"type": "SHOW_MESSAGE", "level": "low"|"medium"|"high", '
95104
'"message": "<short alert>", "description": "<longer explanation>"}.\n\n'
96105
"VARIABLE FIELDS (field → operator → value)\n"
@@ -134,6 +143,29 @@
134143
'"doseOperator": ">", "defaultMeasureUnit": "mg"}\n'
135144
'WRONG (criteria are lost): {"name": "...", "field": "combination", '
136145
'"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"
137169
"RULES\n"
138170
"- NEVER invent ids (sctid, idDrug, class, examType, examRefType, "
139171
"statsType...). Only ever write an id you read from a tool result in this "
@@ -331,6 +363,39 @@ def _normalize_variable(variable: dict) -> dict:
331363
return normalized
332364

333365

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+
334399
def _load_exam_catalogs(variables: list) -> dict:
335400
"""Load the catalogs needed to check the ids used by these variables.
336401
@@ -472,6 +537,7 @@ def _validate_proposal(proposal: dict, draft: dict) -> list[str]:
472537
errors.append(str(error))
473538

474539
errors.extend(_combination_criteria_errors(variables=variables))
540+
errors.extend(_sentinel_value_errors(variables=variables))
475541
errors.extend(
476542
_catalog_errors(
477543
variables=variables, catalogs=_load_exam_catalogs(variables=variables)

tests/unit/test_alerts_protocol.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,66 @@
10211021
},
10221022
False,
10231023
),
1024+
# absence of an exam: "> 0" is true whenever a result exists, so the
1025+
# negated trigger is what detects a patient without the exam
1026+
(
1027+
{
1028+
"variables": [
1029+
{"name": "tem", "field": "exam", "examType": "ckd21",
1030+
"operator": ">", "value": 0},
1031+
],
1032+
"trigger": "{{tem}}",
1033+
"result": {"message": "result"},
1034+
},
1035+
True,
1036+
),
1037+
(
1038+
{
1039+
"variables": [
1040+
{"name": "tem", "field": "exam", "examType": "ckd21",
1041+
"operator": ">", "value": 0},
1042+
],
1043+
"trigger": "not {{tem}}",
1044+
"result": {"message": "result"},
1045+
},
1046+
False,
1047+
),
1048+
(
1049+
{
1050+
"variables": [
1051+
{"name": "tem", "field": "exam", "examType": "hemograma",
1052+
"operator": ">", "value": 0},
1053+
],
1054+
"trigger": "not {{tem}}",
1055+
"result": {"message": "result"},
1056+
},
1057+
True,
1058+
),
1059+
# combined with another condition
1060+
(
1061+
{
1062+
"variables": [
1063+
{"name": "idoso", "field": "age", "operator": ">", "value": 40},
1064+
{"name": "tem", "field": "exam", "examType": "hemograma",
1065+
"operator": ">", "value": 0},
1066+
],
1067+
"trigger": "{{idoso}} and not {{tem}}",
1068+
"result": {"message": "result"},
1069+
},
1070+
True,
1071+
),
1072+
# the sentinel the model reaches for instead: never matches
1073+
(
1074+
{
1075+
"variables": [
1076+
{"name": "sem", "field": "exam", "examType": "ckd21",
1077+
"operator": "=", "value": -999},
1078+
],
1079+
"trigger": "{{sem}}",
1080+
"result": {"message": "result"},
1081+
},
1082+
False,
1083+
),
10241084
],
10251085
)
10261086
def test_trigger(protocol, has_result):

tests/unit/test_protocol_agent_service.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,52 @@ def test_validate_protocol_tool_reports_an_invented_exam():
668668
assert result["valid"] is False
669669
assert any("CREAT_FAKE" in e for e in result["errors"])
670670
assert any("search_reference_exams" in e for e in result["errors"])
671+
672+
673+
def test_sentinel_exam_value_is_rejected():
674+
# the model reaches for an impossible value to mean "no such exam"
675+
with _patch_global_exams("creatinina_NH"):
676+
errors = _validate_proposal(
677+
proposal=_exam_proposal(
678+
_exam_ref_variable(
679+
examRefType="creatinina_NH", operator="=", value=-999
680+
)
681+
),
682+
draft={},
683+
)
684+
685+
assert len(errors) == 1
686+
assert "-999" in errors[0]
687+
assert "not" in errors[0]
688+
689+
690+
def test_absence_pattern_passes():
691+
# the supported way to express absence: positive variable, negated trigger
692+
with _patch_global_exams("creatinina_NH"):
693+
proposal = _exam_proposal(
694+
_exam_ref_variable(examRefType="creatinina_NH", operator=">", value=0)
695+
)
696+
proposal["config"]["trigger"] = "not {{v1}}"
697+
698+
assert _validate_proposal(proposal=proposal, draft={}) == []
699+
700+
701+
def test_negated_trigger_is_accepted_by_the_trigger_validator():
702+
assert _is_valid_trigger(trigger="not {{v1}}", variable_names=["v1"])
703+
assert _is_valid_trigger(
704+
trigger="{{v1}} and not {{v2}}", variable_names=["v1", "v2"]
705+
)
706+
707+
708+
def test_plausible_negative_threshold_is_not_treated_as_a_sentinel():
709+
# some exams are legitimately negative (base excess, delta values)
710+
with _patch_exam_types("be"):
711+
assert (
712+
_validate_proposal(
713+
proposal=_exam_proposal(
714+
_exam_variable(examType="be", operator="<", value=-5)
715+
),
716+
draft={},
717+
)
718+
== []
719+
)

0 commit comments

Comments
 (0)