Skip to content

Commit 8ea90b8

Browse files
authored
Merge pull request #3 from DHEPLab/feat/adjust-ai-score-display
feat(api): adjust api to display AI score correctly & update documentation
2 parents 99e3ff5 + b4c4d00 commit 8ea90b8

File tree

6 files changed

+167
-128
lines changed

6 files changed

+167
-128
lines changed

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
1-
# AugMed-API
1+
# The AugMed App (Backend API)
2+
3+
AugMed is a web application, built for the UNC-Chapel Hill DHEP Lab, that allows the lab to collect data from participants in a user-friendly way. The app is designed to be used on a tablet, and it allows participants to answer questions about their health and well-being. The app is built using React, and it uses the DHEP Lab's API to store and retrieve data.
4+
5+
![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white)
6+
![Flask](https://img.shields.io/badge/Flask-000000?style=for-the-badge&logo=flask&logoColor=white)
7+
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?style=for-the-badge&logo=postgresql&logoColor=white)
8+
![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white)
9+
![Celery](https://img.shields.io/badge/Celery-37814A?style=for-the-badge&logo=celery&logoColor=white)
10+
![Alembic](https://img.shields.io/badge/Alembic-000000?style=for-the-badge&logo=alembic&logoColor=white)
11+
![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-3E8EDE?style=for-the-badge&logo=sqlalchemy&logoColor=white)
12+
![SQL](https://img.shields.io/badge/SQL-003B57?style=for-the-badge&logo=mysql&logoColor=white)
13+
![pytest](https://img.shields.io/badge/pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white)
14+
![Pipenv](https://img.shields.io/badge/Pipenv-343434?style=for-the-badge&logo=pipenv&logoColor=white)
15+
![Flake8](https://img.shields.io/badge/Flake8-000000?style=for-the-badge&logo=flake8&logoColor=white)
16+
![Pylint](https://img.shields.io/badge/Pylint-0D5BFF?style=for-the-badge&logo=pylint&logoColor=white)
17+
![Shell](https://img.shields.io/badge/Shell-4EAA25?style=for-the-badge&logo=gnu-bash&logoColor=white)
18+
![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white)
19+
![Docker Compose](https://img.shields.io/badge/Docker%20Compose-2496ED?style=for-the-badge&logo=docker&logoColor=white)
20+
![Git](https://img.shields.io/badge/Git-F05032?style=for-the-badge&logo=git&logoColor=white)
21+
![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)
22+
![Postman](https://img.shields.io/badge/Postman-FF6C37?style=for-the-badge&logo=postman&logoColor=white)
223

324
## Local Environment Setup
425

26+
Clone the repository if you haven't done so already, then follow these steps to set up your local environment:
27+
528
1. **Install Python**
629

730
Use the following command to install Python:
@@ -44,6 +67,19 @@ To run the application locally, follow these steps:
4467
```
4568
Adjust the command based on your application's entry point if different.
4669

70+
> **NOTE:** If your frontend is also running, ensure it is configured to communicate with this backend API. You may need to set the API URL in your frontend configuration.
71+
> Also, you might need to use CORS (Cross-Origin Resource Sharing) if your frontend and backend are served from different origins. You can use the `flask-cors` package to handle this:
72+
> ```shell
73+
> pipenv install flask-cors
74+
> ```
75+
> Then, in `src/__init__.py`, add:
76+
> ```python
77+
> from flask_cors import CORS
78+
> # Then, after declaring `app` in the same file:
79+
> CORS(app, origins=["http://localhost:3000"], supports_credentials=True, expose_headers=["Authorization"],)
80+
> ```
81+
> This will allow your frontend to make requests to the backend without running into CORS issues.
82+
4783
## Testing
4884
4985
### Pytest Naming
@@ -102,5 +138,8 @@ Configure your local hooks as follows:
102138
git config core.hooksPath .githooks
103139
```
104140

141+
This will ensure that your local git hooks are used instead of the default ones. You can find the hooks in the `.githooks` directory.
105142

143+
## License
106144

145+
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.

src/cases/service/case_service.py

Lines changed: 94 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,7 @@ def get_value_of_observation(self, observation) -> str | None:
201201
if value and observation.unit_concept_id:
202202
value = value + " " + self.get_concept_name(observation.unit_concept_id)
203203
if value and observation.qualifier_concept_id:
204-
value = (
205-
self.get_concept_name(observation.qualifier_concept_id) + " : " + value
206-
)
204+
value = (self.get_concept_name(observation.qualifier_concept_id) + " : " + value)
207205
return value
208206

209207
def get_nodes_of_background(self, case_id, title_config):
@@ -270,142 +268,144 @@ def get_page_configuration(self):
270268
"""
271269
return self.system_config_repository.get_config_by_id("page_config").json_config
272270

273-
def get_case_review(self, case_config_id): # pragma: no cover
271+
def get_case_review(self, case_config_id): # pragma: no cover
274272
"""
275-
1) Load the saved DisplayConfig (path_config list of { "path": "...", "style": {...} })
276-
for this case_config_id + user_email. If none or wrong user, error.
277-
2) Build the full “case_details” tree by calling `get_case_detail(case_id)`.
278-
3) Prune each parent (e.g. “Family History”, “Medical History”, etc.) down to exactly the
279-
leaves specified in path_config. Attach collapse/highlight/top style at the parent.
280-
4) If the raw CSV contained exactly the path "RISK ASSESSMENT.CRC risk assessments", then
281-
and only then fetch any “Adjusted CRC Risk: …” obs (concept_id = 45614722), rename it,
282-
and append as “AI Colorectal Cancer Risk Score” under importantInfos.
283-
5) Return a Case(...) object containing the pruned tree plus any “important info” nodes.
273+
1) Load saved DisplayConfig for this case_config_id + user_email.
274+
2) Build full unpruned case_details tree.
275+
3) Prune under “BACKGROUND” according to CSV path_config.
276+
4) If CSV included a “Colorectal Cancer Score” entry, use that value
277+
directly under “AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)”.
278+
5) Otherwise, if CSV toggled CRC risk assessments, look first for a
279+
“Colorectal Cancer Score” observation in the DB; if none, fall
280+
back to the old “Adjusted CRC Risk” observation logic. If still
281+
nothing, emit “N/A”.
284282
"""
283+
# load configuration & verify access
285284
configuration = self.configuration_repository.get_configuration_by_id(case_config_id)
286285
current_user = get_user_email_from_jwt()
287286
if not configuration or configuration.user_email != current_user:
288287
raise BusinessException(BusinessExceptionEnum.NoAccessToCaseReview)
289288

290-
# 1) Build the raw case_details (unpruned – contains all leaves)
289+
# raw, unpruned
291290
case_details = self.get_case_detail(configuration.case_id)
292291

293-
# 2) Gather every single path_config entry into a map: parent_key → list of {leaf, style}
292+
# index CSV path_config entries
294293
raw_path_cfg = configuration.path_config or []
295294
parent_to_entries: dict[str, list[dict]] = defaultdict(list)
296295

297-
# Also: detect if the CSV explicitly toggled CRC risk assessments at all
298-
has_crc_toggle = False
296+
csv_crc_score_leaf: str | None = None
297+
old_crc_toggle = False
299298

300299
for entry in raw_path_cfg:
301-
path_str = entry.get("path", "").strip()
302-
style_dict = entry.get("style", {}) or {}
300+
path_str = (entry.get("path") or "").strip()
301+
style = entry.get("style") or {}
303302
if not path_str:
304-
# skip lines with no path
305303
continue
306304

307-
# If exactly "RISK ASSESSMENT.CRC risk assessments" appears, note it:
308-
if path_str == "RISK ASSESSMENT.CRC risk assessments":
309-
has_crc_toggle = True
310-
311305
segments = path_str.split(".")
312306
if len(segments) < 2:
313-
# malformed, skip
314307
continue
315-
parent_key = ".".join(segments[:-1]) # e.g. "BACKGROUND.Family History"
316-
leaf_text = segments[-1] # e.g. "Diabetes: Yes"
317-
parent_to_entries[parent_key].append({
318-
"leaf": leaf_text,
319-
"style": style_dict,
320-
})
321-
322-
# 3) Build a list of important_infos for any node that has a “top” style
323-
important_infos: list[dict] = []
324308

325-
# 4) Walk down case_details to prune each parent. We only prune under “BACKGROUND”
326-
for top_node in case_details:
327-
if top_node.key != "BACKGROUND":
309+
parent_key = ".".join(segments[:-1])
310+
leaf_text = segments[-1]
311+
312+
# CSV‐provided score override?
313+
if path_str.startswith("RISK ASSESSMENT.Colorectal Cancer Score"):
314+
csv_crc_score_leaf = leaf_text
315+
316+
# old‐style toggle
317+
if path_str == "RISK ASSESSMENT.CRC risk assessments":
318+
old_crc_toggle = True
319+
320+
parent_to_entries[parent_key].append({"leaf": leaf_text, "style": style})
321+
322+
# prune under BACKGROUND
323+
important_infos: list[dict] = []
324+
for top in case_details:
325+
if top.key != "BACKGROUND":
328326
continue
329-
# top_node.values is a list of TreeNode children:
330-
# [ TreeNode("Patient Demographics"), TreeNode("Family History"), TreeNode("Social History"),
331-
# TreeNode("Medical History"), ... ]
332-
for child in top_node.values:
333-
# ALWAYS keep "Patient Demographics" as-is (Age/Gender). Skip pruning for it:
327+
for child in top.values:
334328
if child.key == "Patient Demographics":
335329
continue
336330

337-
parent_key = f"BACKGROUND.{child.key}"
338-
if parent_key not in parent_to_entries:
339-
# If CSV asked nothing about this parent, prune _all_ leaves under it.
331+
pk = f"BACKGROUND.{child.key}"
332+
if pk not in parent_to_entries:
340333
child.values = []
341334
continue
342335

343-
# Otherwise, collect all leaf entries under this parent
344-
entries = parent_to_entries[parent_key]
345-
leaves_to_keep = {e["leaf"] for e in entries}
336+
entries = parent_to_entries[pk]
337+
keep = {e["leaf"] for e in entries}
338+
child.values = [v for v in child.values if v in keep]
346339

347-
# child.values was originally a list of strings (all possible leaves). Prune:
348-
child.values = [val for val in child.values if val in leaves_to_keep]
349-
350-
# Merge all style dicts from entries for this parent into one combined style
351-
combined_style: dict = {}
340+
merged: dict = {}
352341
for e in entries:
353-
style_dict = e.get("style") or {}
354-
# CSV generator sets collapse=False to indicate “show this leaf” → invert it
355-
if "collapse" in style_dict:
356-
combined_style["collapse"] = not style_dict["collapse"]
357-
if "highlight" in style_dict:
358-
combined_style["highlight"] = style_dict["highlight"]
359-
if "top" in style_dict:
360-
# If multiple “top” values, pick the largest weight
361-
if "top" not in combined_style or style_dict["top"] > combined_style["top"]:
362-
combined_style["top"] = style_dict["top"]
363-
364-
# Attach the merged style to this parent node
365-
child.style = combined_style
366-
367-
# If there is a “top” key, add to important_infos
368-
if "top" in combined_style:
342+
s = e["style"]
343+
if "collapse" in s:
344+
merged["collapse"] = not s["collapse"]
345+
if "highlight" in s:
346+
merged["highlight"] = s["highlight"]
347+
if "top" in s:
348+
merged["top"] = max(merged.get("top", -1), s["top"])
349+
350+
child.style = merged
351+
if "top" in merged:
369352
important_infos.append({
370353
"key": child.key,
371354
"values": child.values,
372-
"weight": combined_style["top"],
355+
"weight": merged["top"],
373356
})
374357

375-
# 5) Sort important_infos by weight, then convert to TreeNode list
358+
# sort & wrap
376359
important_infos.sort(key=itemgetter("weight"))
377-
sorted_important_infos = list(map(lambda e: TreeNode(e["key"], e["values"]), important_infos))
360+
sorted_important = [TreeNode(e["key"], e["values"]) for e in important_infos]
361+
362+
# label we use in the UI
363+
ai_label = "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)"
364+
365+
if csv_crc_score_leaf:
366+
sorted_important.append(TreeNode(ai_label, [csv_crc_score_leaf]))
378367

379-
# 6) ONLY if the CSV included "RISK ASSESSMENT.CRC risk assessments",
380-
# fetch any “Adjusted CRC Risk: …” observation (concept_id = 45614722),
381-
# rename it to “AI-Predicted CRC Risk Score”, and append as “AI Colorectal Cancer Risk Score”.
382-
if has_crc_toggle:
368+
# fallback branch
369+
elif old_crc_toggle:
383370
crc_obs = self.observation_repository.get_observations_by_concept(
384371
configuration.case_id, [45614722]
385372
)
386-
for obs in crc_obs:
387-
if obs.value_as_string and obs.value_as_string.startswith("Adjusted CRC Risk"):
388-
# Rewrite the leaf text: replace "Adjusted CRC Risk" with "AI-Predicted CRC Risk Score"
389-
raw_text = obs.value_as_string # e.g. "Adjusted CRC Risk: 0.546125"
390-
new_leaf = raw_text.replace(
391-
"Adjusted CRC Risk", "AI-Predicted CRC Risk Score"
392-
)
393-
# Use "AI Colorectal Cancer Risk Score" as the parent key
394-
sorted_important_infos.append(
395-
TreeNode("AI Colorectal Cancer Risk Score", [new_leaf])
396-
)
397-
break
398373

399-
# 7) Return a Case object containing:
400-
# • person_source_value (e.g. MRN or whatever)
401-
# • case_id (converted to str)
402-
# • case_details
403-
# • importantInfos (only includes AI‐CRC node if CSV requested RISK ASSESSMENT toggles)
374+
# (i) is there a literal Colorectal Cancer Score: X row?
375+
csv_obs = next(
376+
(
377+
o.value_as_string
378+
for o in crc_obs
379+
if o.value_as_string and o.value_as_string.startswith("Colorectal Cancer Score")
380+
),
381+
None
382+
)
383+
if csv_obs:
384+
sorted_important.append(TreeNode(ai_label, [csv_obs]))
385+
else:
386+
# (ii) fall back to Adjusted CRC Risk
387+
for obs in crc_obs:
388+
txt = obs.value_as_string or ""
389+
if txt.startswith("Adjusted CRC Risk"):
390+
new_txt = txt.replace(
391+
"Adjusted CRC Risk",
392+
"AI-Predicted CRC Risk Score"
393+
)
394+
sorted_important.append(TreeNode(ai_label, [new_txt]))
395+
break
396+
else:
397+
# (iii) still nothing? emit N/A
398+
sorted_important.append(TreeNode(ai_label, ["N/A"]))
399+
400+
# if neither CSV nor old toggle, emit N/A
401+
else:
402+
sorted_important.append(TreeNode(ai_label, ["N/A"]))
403+
404404
return Case(
405405
self.person.person_source_value,
406406
str(configuration.case_id),
407407
case_details,
408-
sorted_important_infos,
408+
sorted_important,
409409
)
410410

411411
def __get_current_case_by_user(self, user_email) -> tuple[int, str] | tuple[None, None]:

src/common/exception/BusinessException.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ class BusinessExceptionEnum(Enum):
4949
"Failed to reset password due to expired token. Please resend reset password request.",
5050
)
5151
RenderTemplateError = ("1030", "Template render error.")
52-
SendEmailError = ("1040", "Email failed to send. Please try again.")
52+
SendEmailError = ("1040", "Email failed to send. Please try again. If the problem persists, "
53+
"contact [email protected] for support.")
5354

5455
def __init__(self, code: str, message: str):
5556
self._code = code

tests/cases/controller/case_controller_test.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ def test_get_case_review(client, session, mocker):
5454
expected["details"][0]["values"][1]["values"] = []
5555
expected["details"][0]["values"][2]["values"] = []
5656

57+
# inject the AI CRC Risk Score node
58+
expected["importantInfos"] = [
59+
{
60+
"key": "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)",
61+
"style": None,
62+
"values": ["N/A"],
63+
}
64+
]
65+
5766
assert data == expected
5867

5968

0 commit comments

Comments
 (0)