From 710aa7bc9effe41d4400c9f1577a440d0ca189f6 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 03:18:11 -0500 Subject: [PATCH 01/20] initial git status clean up - stragglers --- .gitignore | 4 +- .../phenotype_results.json | 12549 ++++++++++++++++ .../phenotype_summary.csv | 11 + dodo.py | 2 +- 4 files changed, 12563 insertions(+), 3 deletions(-) create mode 100644 docs/evaluation/phenotype_recommendations/phenotype_results.json create mode 100644 docs/evaluation/phenotype_recommendations/phenotype_summary.csv diff --git a/.gitignore b/.gitignore index 44b3f8b..cbf8660 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -demo-strategus-cohort-incidence/* -demo-strategus-cohort-methods/* +demo-* +TEST-* conda-pip-env-github-workflow.yml *sandbox* *.doit.db* diff --git a/docs/evaluation/phenotype_recommendations/phenotype_results.json b/docs/evaluation/phenotype_recommendations/phenotype_results.json new file mode 100644 index 0000000..71ef40e --- /dev/null +++ b/docs/evaluation/phenotype_recommendations/phenotype_results.json @@ -0,0 +1,12549 @@ +[ +{ + "benchmark_name": "Cardiac defibrillator in situ (MAP)", + "elapsed_seconds": 16.523, + "response": { + "status": "ok", + "search": { + "query": "Patients with an implanted cardiac defibrillator", + "results": [ + { + "phenotype_id": "ohdsi:1102", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit)CABG (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7, + "score_dense": 1.0, + "score_sparse": null, + "score_dense_raw": 0.6031639575958252, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1314", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5486494940186879, + "score_dense": 0.7837849914552685, + "score_sparse": null, + "score_dense_raw": 0.5725921392440796, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:2288", + "source_dataset": "va_cipher", + "name": "Cardiac defibrillator in situ (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Cardiac defibrillator in situ. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.56 means that patients with a MAP probability over 0.56 should be categorized as having Cardiac defibrillator in situ.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5011275926114841, + "score_dense": 0.28732513230212015, + "score_sparse": 1.0, + "score_dense_raw": 0.50239497423172, + "score_sparse_raw": 26.89369814287198 + }, + { + "phenotype_id": "ohdsi:875", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.46629742073043756, + "score_dense": 0.6661391724720537, + "score_sparse": null, + "score_dense_raw": 0.555957555770874, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:13288", + "source_dataset": "va_cipher", + "name": "Cardiac Defibrillator in Situ (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.4168974848833801, + "score_dense": 0.2506607728956228, + "score_sparse": 0.8047831461881473, + "score_dense_raw": 0.4972108006477356, + "score_sparse_raw": 23.10491339596917 + }, + { + "phenotype_id": "ohdsi:878", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3985619389753573, + "score_dense": 0.5693741985362247, + "score_sparse": null, + "score_dense_raw": 0.5422754287719727, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30617", + "source_dataset": "va_cipher", + "name": "Other Coronary Heart Disease (HDR UK)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.34492193395053244, + "score_dense": 0.4927456199293321, + "score_sparse": null, + "score_dense_raw": 0.5314404964447021, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:15146", + "source_dataset": "va_cipher", + "name": "Cardiac defibrillator in situ (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3343253493343416, + "score_dense": 0.14032898270060729, + "score_sparse": 0.7869835381463884, + "score_dense_raw": 0.48161038756370544, + "score_sparse_raw": 22.759457139848674 + }, + { + "phenotype_id": "ohdsi:1304", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3187275052672136, + "score_dense": 0.4553250075245909, + "score_sparse": null, + "score_dense_raw": 0.5261493921279907, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:16289", + "source_dataset": "va_cipher", + "name": "Bleeding (CART)", + "short_description": "Identify through diagnosis codes (link to table of codes) in- inpatient(NOT 'bedsection'), outpatient, fee basis files (looks only across the first ordinal number)", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "not_validated", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3013195212010492, + "score_dense": 0.3629356780267614, + "score_sparse": 0.1575484886077207, + "score_dense_raw": 0.5130859613418579, + "score_sparse_raw": 10.543329954659269 + }, + { + "phenotype_id": "cipher:30773", + "source_dataset": "va_cipher", + "name": "Trifascicular Block (Kuan)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.29849314271598526, + "score_dense": 0.4264187753085504, + "score_sparse": null, + "score_dense_raw": 0.5220621824264526, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30192", + "source_dataset": "va_cipher", + "name": "Other Cardiomyopathy (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2675464185055193, + "score_dense": 0.382209169293599, + "score_sparse": null, + "score_dense_raw": 0.5158111453056335, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:31291", + "source_dataset": "va_cipher", + "name": "Life Threatening Arrhythmias (CVD-COVID-UK Consortium)", + "short_description": "HDR UK phenotype based on ICD10 codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2544438926879525, + "score_dense": 0.36349127526850356, + "score_sparse": null, + "score_dense_raw": 0.5131645202636719, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:16278", + "source_dataset": "va_cipher", + "name": "ESLD (CART)", + "short_description": "If the covariate in CART data is yes, then the new covariate is yes. If the covariate in CART is no, missing or unknown, then look to VA administrative data- if it is noted at least twice in outpatient files or once in inpatient files in VA administrative data within past two years, the covariate is yes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "not_validated", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2543014453234605, + "score_dense": 0.3283811548834806, + "score_sparse": 0.08144878968341375, + "score_dense_raw": 0.5082001090049744, + "score_sparse_raw": 9.066380742035474 + }, + { + "phenotype_id": "ohdsi:1094", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lower Extremity Bypass - post op new Afib", + "short_description": "Non emergent (No ED visit) Lower Extremity Bypass (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation.", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.25131071206657724, + "score_dense": 0.3590153029522532, + "score_sparse": null, + "score_dense_raw": 0.5125316381454468, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1306", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lower Extremity Bypass, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.250574187407418, + "score_dense": 0.35796312486774, + "score_sparse": null, + "score_dense_raw": 0.5123828649520874, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1103", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Cardiac Valve Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit) Aortic Or Mitral Repair or Replacement (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.24952900698164, + "score_dense": 0.35647000997377143, + "score_sparse": null, + "score_dense_raw": 0.512171745300293, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:289", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Presence Of Cardiac Arrhythmia", + "short_description": "All events of Cardiac arrythmia or treatments for cardiac arrythmia", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.24462470942662898, + "score_dense": 0.3066736587047924, + "score_sparse": 0.09984382777758101, + "score_dense_raw": 0.5051307678222656, + "score_sparse_raw": 9.423393153561417 + }, + { + "phenotype_id": "cipher:29218", + "source_dataset": "va_cipher", + "name": "Coronary Heart Disease (HDR UK)", + "short_description": "HDR UK phenotype based on Read codes v2 and OXMIS codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.23734123534812507, + "score_dense": 0.3390589076401787, + "score_sparse": null, + "score_dense_raw": 0.509709894657135, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:78", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Cardiac arrhythmia with inpatient admission", + "short_description": "all events of cardiac arrhythmia or treatments for cardiac arrhythmia in inpatient or ER setting", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.23700845541375784, + "score_dense": 0.2966425570625346, + "score_sparse": 0.09786221823327869, + "score_dense_raw": 0.5037124156951904, + "score_sparse_raw": 9.384933912624128 + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend cardiac defibrillator phenotypes for patients.", + "intent_facets": { + "condition_or_topic": "cardiac defibrillator", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients with implanted devices", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Implanted device" + ], + "care_setting_cues": [ + "Inpatient care", + "Outpatient care" + ], + "population_cues": [ + "Patients with cardiac devices" + ] + }, + "reasoning_notes": [ + "The study intent explicitly mentions 'patients with an implanted cardiac defibrillator'. This indicates a diagnosis-related phenotype recommendation." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotypes related to patients with implanted cardiac defibrillators based on the study intent. Prioritizing definitions using ICD codes for potential validation and execution.", + "intent_facets": { + "condition_or_topic": "cardiac defibrillator", + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients with implanted devices", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop" + }, + "shortlist_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with cardiac defibrillator as a diagnosis-oriented study intent.", + "Included Cardiac defibrillator in situ (gwPheWAS) as a medication based candidate focused on Cardiac Defibrillator.", + "Included Cardiac defibrillator in situ (MAP) as a outcome candidate focused on Cardiac defibrillator in situ.", + "Included Cardiac Defibrillator in Situ (Phecode) as a comorbidity covariate candidate focused on Cardiac Defibrillator in Situ." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 9386, + "prompt_length_chars": 7072, + "recommendations": { + "plan": "Recommend phenotypes related to patients with an implanted cardiac defibrillator.", + "phenotype_recommendations": [ + { + "phenotype_id": "cipher:15146", + "phenotype_name": "Cardiac defibrillator in situ (gwPheWAS)", + "justification": "This phenotype directly addresses the study intent of patients with an implanted cardiac defibrillator, utilizing the Million Veteran Program data.", + "confidence": 0.95 + }, + { + "phenotype_id": "cipher:2288", + "phenotype_name": "Cardiac defibrillator in situ (MAP)", + "justification": "This phenotype identifies patients with a cardiac defibrillator in situ based on a MAP probability cutoff, providing an alternative approach to the first recommendation.", + "confidence": 0.85 + }, + { + "phenotype_id": "cipher:13288", + "phenotype_name": "Cardiac Defibrillator in Situ (Phecode)", + "justification": "This phenotype, based on Phecode groupings, offers another relevant definition for patients with a cardiac defibrillator, leveraging data from eMERGE studies.", + "confidence": 0.8 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.968472719192505, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-c9ee05bd-2366-4046-998a-c4ba33e86674\",\"created\":1778090447,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to patients with an implanted cardiac defibrillator.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:15146\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac defibrillator in situ (gwPheWAS)\\\",\\n \\\"justification\\\": \\\"This phenotype directly addresses the study intent of patients with an implanted cardiac defibrillator, utilizing the Million Veteran Program data.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:2288\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac defibrillator in situ (MAP)\\\",\\n \\\"justification\\\": \\\"This phenotype identifies patients with a cardiac defibrillator in situ based on a MAP probability cutoff, providing an alternative approach to the first recommendation.\\\",\\n \\\"confidence\\\": 0.85\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:13288\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac Defibrillator in Situ (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype, based on Phecode groupings, offers another relevant definition for patients with a cardiac defibrillator, leveraging data from eMERGE studies.\\\",\\n \\\"confidence\\\": 0.80\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1851,\"output_tokens\":293,\"total_tokens\":2144,\"prompt_tokens\":1851,\"completion_tokens\":293,\"response_token/s\":101.92,\"prompt_token/s\":4264.27,\"total_duration\":4659313380,\"load_duration\":670528436,\"prompt_eval_count\":1851,\"prompt_eval_duration\":434072141,\"eval_count\":293,\"eval_duration\":2874940783,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to patients with an implanted cardiac defibrillator.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:15146\",\n \"phenotype_name\": \"Cardiac defibrillator in situ (gwPheWAS)\",\n \"justification\": \"This phenotype directly addresses the study intent of patients with an implanted cardiac defibrillator, utilizing the Million Veteran Program data.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"cipher:2288\",\n \"phenotype_name\": \"Cardiac defibrillator in situ (MAP)\",\n \"justification\": \"This phenotype identifies patients with a cardiac defibrillator in situ based on a MAP probability cutoff, providing an alternative approach to the first recommendation.\",\n \"confidence\": 0.85\n },\n {\n \"phenotype_id\": \"cipher:13288\",\n \"phenotype_name\": \"Cardiac Defibrillator in Situ (Phecode)\",\n \"justification\": \"This phenotype, based on Phecode groupings, offers another relevant definition for patients with a cardiac defibrillator, leveraging data from eMERGE studies.\",\n \"confidence\": 0.80\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.850872278213501, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-de283342-de34-4cd1-b943-57af3489d0cb\",\"created\":1778090436,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend cardiac defibrillator phenotypes for patients.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"cardiac defibrillator\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"inpatient|outpatient\\\",\\n \\\"population_cue\\\": \\\"patients with implanted devices\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\",\\n \\\"role_cues\\\": [\\n \\\"Implanted device\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"Inpatient care\\\",\\n \\\"Outpatient care\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"Patients with cardiac devices\\\"\\n ]\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly mentions 'patients with an implanted cardiac defibrillator'. This indicates a diagnosis-related phenotype recommendation.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1205,\"output_tokens\":284,\"total_tokens\":1489,\"prompt_tokens\":1205,\"completion_tokens\":284,\"response_token/s\":100.99,\"prompt_token/s\":3552.0,\"total_duration\":4632388973,\"load_duration\":698521541,\"prompt_eval_count\":1205,\"prompt_eval_duration\":339245966,\"eval_count\":284,\"eval_duration\":2812169766,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend cardiac defibrillator phenotypes for patients.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"cardiac defibrillator\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"inpatient|outpatient\",\n \"population_cue\": \"patients with implanted devices\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"us_omop\",\n \"role_cues\": [\n \"Implanted device\"\n ],\n \"care_setting_cues\": [\n \"Inpatient care\",\n \"Outpatient care\"\n ],\n \"population_cues\": [\n \"Patients with cardiac devices\"\n ]\n },\n \"reasoning_notes\": [\n \"The study intent explicitly mentions 'patients with an implanted cardiac defibrillator'. This indicates a diagnosis-related phenotype recommendation.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 5.798890113830566, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-3bd4b8fe-88f3-4805-8b8e-bc402a9cf8e3\",\"created\":1778090442,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to patients with implanted cardiac defibrillators based on the study intent. Prioritizing definitions using ICD codes for potential validation and execution.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"cardiac defibrillator\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"inpatient|outpatient\\\",\\n \\\"population_cue\\\": \\\"patients with implanted devices\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"cipher:15146\\\",\\n \\\"cipher:2288\\\",\\n \\\"cipher:13288\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"All three candidate phenotypes (cipher:15146, cipher:2288, cipher:13288) directly relate to 'Cardiac Defibrillator' and are defined using ICD codes, aligning with the study intent.\\\",\\n \\\"Candidate cipher:15146 is based on the Million Veteran Program, a validated dataset.\\\",\\n \\\"Candidate cipher:2288 uses a MAP phenotype, a potentially valuable method.\\\",\\n \\\"Candidate cipher:13288 is based on Phecode definitions, a well-established approach.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2737,\"output_tokens\":341,\"total_tokens\":3078,\"prompt_tokens\":2737,\"completion_tokens\":341,\"response_token/s\":101.24,\"prompt_token/s\":4424.22,\"total_duration\":5476158764,\"load_duration\":682447821,\"prompt_eval_count\":2737,\"prompt_eval_duration\":618640205,\"eval_count\":341,\"eval_duration\":3368247388,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to patients with implanted cardiac defibrillators based on the study intent. Prioritizing definitions using ICD codes for potential validation and execution.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"cardiac defibrillator\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"inpatient|outpatient\",\n \"population_cue\": \"patients with implanted devices\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"us_omop\"\n },\n \"shortlist_ids\": [\n \"cipher:15146\",\n \"cipher:2288\",\n \"cipher:13288\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"All three candidate phenotypes (cipher:15146, cipher:2288, cipher:13288) directly relate to 'Cardiac Defibrillator' and are defined using ICD codes, aligning with the study intent.\",\n \"Candidate cipher:15146 is based on the Million Veteran Program, a validated dataset.\",\n \"Candidate cipher:2288 uses a MAP phenotype, a potentially valuable method.\",\n \"Candidate cipher:13288 is based on Phecode definitions, a well-established approach.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "cardiac defibrillator", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients with implanted devices", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Implanted device" + ], + "care_setting_cues": [ + "Inpatient care", + "Outpatient care" + ], + "population_cues": [ + "Patients with cardiac devices" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "cardiac defibrillator", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients with implanted devices", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Implanted device" + ], + "care_setting_cues": [ + "Inpatient care", + "Outpatient care" + ], + "population_cues": [ + "Patients with cardiac devices" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288", + "cipher:16289", + "cipher:30773" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288", + "cipher:16289", + "cipher:30773" + ], + "planner_input_shortlist_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288", + "cipher:16289", + "cipher:30773" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [ + "cipher:16289", + "cipher:30773" + ], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "enforced": false + }, + "candidates": [ + { + "rank": 1, + "original_rank": 8, + "phenotype_id": "cipher:15146", + "name": "Cardiac defibrillator in situ (gwPheWAS)", + "metadata_score": 22.6125, + "retrieval_score": 0.3343, + "phenotype_role": "medication_based", + "primary_clinical_topic": "Cardiac Defibrillator", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Cardiac Defibrillator" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 2, + "original_rank": 3, + "phenotype_id": "cipher:2288", + "name": "Cardiac defibrillator in situ (MAP)", + "metadata_score": 15.6725, + "retrieval_score": 0.5011, + "phenotype_role": "outcome", + "primary_clinical_topic": "Cardiac defibrillator in situ", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 20.0, + "detail": "Cardiac defibrillator in situ" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 3, + "original_rank": 5, + "phenotype_id": "cipher:13288", + "name": "Cardiac Defibrillator in Situ (Phecode)", + "metadata_score": 15.1325, + "retrieval_score": 0.4169, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Cardiac Defibrillator in Situ", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 20.0, + "detail": "Cardiac Defibrillator in Situ" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 4, + "original_rank": 10, + "phenotype_id": "cipher:16289", + "name": "Bleeding (CART)", + "metadata_score": -5.3875, + "retrieval_score": 0.3013, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Bleeding", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "VHA" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Bleeding" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 5, + "original_rank": 11, + "phenotype_id": "cipher:30773", + "name": "Trifascicular Block (Kuan)", + "metadata_score": -5.3875, + "retrieval_score": 0.2985, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Trifascicular Block", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "COVID-19", + "COVID-19 Diagnosis", + "SARS-CoV-2" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Trifascicular Block" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 6, + "original_rank": 12, + "phenotype_id": "cipher:30192", + "name": "Other Cardiomyopathy (HDR UK)", + "metadata_score": -5.3875, + "retrieval_score": 0.2675, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Cardiomyopathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Cardiomyopathy" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 7, + "original_rank": 7, + "phenotype_id": "cipher:30617", + "name": "Other Coronary Heart Disease (HDR UK)", + "metadata_score": -12.8875, + "retrieval_score": 0.3449, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Coronary Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Read Codes" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Heart Disease" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 8, + "original_rank": 1, + "phenotype_id": "ohdsi:1102", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -21.65, + "retrieval_score": 0.7, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft Surgery", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post CABG Afib" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft Surgery" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 9, + "original_rank": 2, + "phenotype_id": "ohdsi:1314", + "name": "[P] Coronary Artery Bypass Graft, adults, post op new Afib", + "metadata_score": -21.67, + "retrieval_score": 0.5486, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post Op" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft, adults, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 10, + "original_rank": 4, + "phenotype_id": "ohdsi:875", + "name": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED", + "metadata_score": -21.71, + "retrieval_score": 0.4663, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 11, + "original_rank": 6, + "phenotype_id": "ohdsi:878", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -24.25, + "retrieval_score": 0.3986, + "phenotype_role": "complication", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 12, + "original_rank": 9, + "phenotype_id": "ohdsi:1304", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "metadata_score": -24.25, + "retrieval_score": 0.3187, + "phenotype_role": "outcome", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Major Non Cardiac Surgery, adults, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "valid_unique_count": 3 + }, + "final_deterministic": { + "selected_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "matched_llm_ids": [ + "cipher:15146", + "cipher:2288", + "cipher:13288" + ], + "defaulted_ids": [], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 3, + "used_default_justification_count": 0 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.968472719192505, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-c9ee05bd-2366-4046-998a-c4ba33e86674\",\"created\":1778090447,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to patients with an implanted cardiac defibrillator.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:15146\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac defibrillator in situ (gwPheWAS)\\\",\\n \\\"justification\\\": \\\"This phenotype directly addresses the study intent of patients with an implanted cardiac defibrillator, utilizing the Million Veteran Program data.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:2288\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac defibrillator in situ (MAP)\\\",\\n \\\"justification\\\": \\\"This phenotype identifies patients with a cardiac defibrillator in situ based on a MAP probability cutoff, providing an alternative approach to the first recommendation.\\\",\\n \\\"confidence\\\": 0.85\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:13288\\\",\\n \\\"phenotype_name\\\": \\\"Cardiac Defibrillator in Situ (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype, based on Phecode groupings, offers another relevant definition for patients with a cardiac defibrillator, leveraging data from eMERGE studies.\\\",\\n \\\"confidence\\\": 0.80\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1851,\"output_tokens\":293,\"total_tokens\":2144,\"prompt_tokens\":1851,\"completion_tokens\":293,\"response_token/s\":101.92,\"prompt_token/s\":4264.27,\"total_duration\":4659313380,\"load_duration\":670528436,\"prompt_eval_count\":1851,\"prompt_eval_duration\":434072141,\"eval_count\":293,\"eval_duration\":2874940783,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to patients with an implanted cardiac defibrillator.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:15146\",\n \"phenotype_name\": \"Cardiac defibrillator in situ (gwPheWAS)\",\n \"justification\": \"This phenotype directly addresses the study intent of patients with an implanted cardiac defibrillator, utilizing the Million Veteran Program data.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"cipher:2288\",\n \"phenotype_name\": \"Cardiac defibrillator in situ (MAP)\",\n \"justification\": \"This phenotype identifies patients with a cardiac defibrillator in situ based on a MAP probability cutoff, providing an alternative approach to the first recommendation.\",\n \"confidence\": 0.85\n },\n {\n \"phenotype_id\": \"cipher:13288\",\n \"phenotype_name\": \"Cardiac Defibrillator in Situ (Phecode)\",\n \"justification\": \"This phenotype, based on Phecode groupings, offers another relevant definition for patients with a cardiac defibrillator, leveraging data from eMERGE studies.\",\n \"confidence\": 0.80\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Fasciitis (gwPheWAS)", + "elapsed_seconds": 14.100, + "response": { + "status": "ok", + "search": { + "query": "Patients diagnosed with fasciitis", + "results": [ + { + "phenotype_id": "cipher:30159", + "source_dataset": "va_cipher", + "name": "Enthesopathies and Synovial Disorders (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.7, + "score_dense": 1.0, + "score_sparse": null, + "score_dense_raw": 0.549125075340271, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:15684", + "source_dataset": "va_cipher", + "name": "Fasciitis (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.692377579201426, + "score_dense": 0.6266936820412182, + "score_sparse": 0.8456400059085772, + "score_dense_raw": 0.5176951885223389, + "score_sparse_raw": 14.448251150497011 + }, + { + "phenotype_id": "ohdsi:1075", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] FDA AESI Narcolepsy", + "short_description": "Narcolepsy", + "tags": [ + "AESI", + "FDA", + "Study", + "Symposium", + "Covid19SubjectsAesiIncidenceRate" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.6740145512056743, + "score_dense": 0.9628779302938205, + "score_sparse": null, + "score_dense_raw": 0.5459996461868286, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:2703", + "source_dataset": "va_cipher", + "name": "Fasciitis (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Fasciitis. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.41 means that patients with a MAP probability over 0.41 should be categorized as having Fasciitis.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.650456167625585, + "score_dense": 0.50065166803655, + "score_sparse": 1.0, + "score_dense_raw": 0.5070832967758179, + "score_sparse_raw": 16.171133810860592 + }, + { + "phenotype_id": "cipher:29553", + "source_dataset": "va_cipher", + "name": "Sleep Apnea (HDR UK)", + "short_description": "Read code, ICD code, and medication based algorithm", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.56293562822427, + "score_dense": 0.8041937546061001, + "score_sparse": null, + "score_dense_raw": 0.5326395034790039, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:14029", + "source_dataset": "va_cipher", + "name": "Fasciitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5570900308801044, + "score_dense": 0.440908469843805, + "score_sparse": 0.8281803399648033, + "score_dense_raw": 0.5020533204078674, + "score_sparse_raw": 14.2533758145181 + }, + { + "phenotype_id": "ohdsi:454", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Dermatomyositis", + "short_description": "all events of Dermatomyositis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4829083155106904, + "score_dense": 0.6898690221581292, + "score_sparse": null, + "score_dense_raw": 0.5230141282081604, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:2589", + "source_dataset": "va_cipher", + "name": "Disorders of muscle, ligament, and fascia (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Disorders of muscle, ligament, and fascia. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.34 means that patients with a MAP probability over 0.34 should be categorized as having Disorders of muscle, ligament, and fascia.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.4709230748536844, + "score_dense": 0.6727472497909778, + "score_sparse": null, + "score_dense_raw": 0.5215725898742676, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:479", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Chilblains", + "short_description": "all events of Chilblains. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4694398486685937, + "score_dense": 0.6706283552408482, + "score_sparse": null, + "score_dense_raw": 0.5213941931724548, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:363", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Joint stiffness ", + "short_description": "all events of Joint stiffness. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.46831987166281186, + "score_dense": 0.6690283880897313, + "score_sparse": null, + "score_dense_raw": 0.5212594866752625, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:385", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Intertrigo ", + "short_description": "all events of Intertrigo. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.45704477571787905, + "score_dense": 0.6529211081683987, + "score_sparse": null, + "score_dense_raw": 0.5199033617973328, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30170", + "source_dataset": "va_cipher", + "name": "Diabetes, ICD, Read, and Med Codes (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.4266849742553958, + "score_dense": 0.6095499632219941, + "score_sparse": null, + "score_dense_raw": 0.516251802444458, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:605", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Muscle pain", + "short_description": "all events of Muscle pain. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4077276289548745, + "score_dense": 0.5824680413641065, + "score_sparse": null, + "score_dense_raw": 0.5139716863632202, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30277", + "source_dataset": "va_cipher", + "name": "Polymyalgia Rheumatica (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.38088336593443384, + "score_dense": 0.5441190941920484, + "score_sparse": null, + "score_dense_raw": 0.5107429623603821, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30144", + "source_dataset": "va_cipher", + "name": "Fibromatoses (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, Read codes v2, and OPCS4 codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.37904531517583, + "score_dense": 0.5414933073940429, + "score_sparse": null, + "score_dense_raw": 0.5105218887329102, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:468", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Pityriasis rubra pilaris", + "short_description": "all events of Pityriasis rubra pilaris. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.37845608833517747, + "score_dense": 0.5406515547645393, + "score_sparse": null, + "score_dense_raw": 0.5104510188102722, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1181", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] STEROIDS 10", + "short_description": "STEROIDS record with 60 day persistence", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3616103456990971, + "score_dense": 0.5165862081415673, + "score_sparse": null, + "score_dense_raw": 0.5084248781204224, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:630", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Facial palsy", + "short_description": "all events of Facial palsy. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3512163644073856, + "score_dense": 0.5017376634391223, + "score_sparse": null, + "score_dense_raw": 0.5071747303009033, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:4022", + "source_dataset": "va_cipher", + "name": "Amyloidosis (VINCI)", + "short_description": "identify those that have ever been diagnosed with amyloidosis", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.30908634327790446, + "score_dense": 0.3061317679141455, + "score_sparse": 0.3159803524600088, + "score_dense_raw": 0.49070602655410767, + "score_sparse_raw": 8.536476935992626 + }, + { + "phenotype_id": "cipher:29559", + "source_dataset": "va_cipher", + "name": "Musculoskeletal Pain and injury in Adults (HDR UK)", + "short_description": "HDR UK phenotype based on SNOMED CT and Non-Standard codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_code_system:snomed", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2935524155610256, + "score_dense": 0.41936059365860806, + "score_sparse": null, + "score_dense_raw": 0.5002391338348389, + "score_sparse_raw": null + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend fasciitis phenotypes based on patient diagnosis.", + "intent_facets": { + "condition_or_topic": "fasciitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "reasoning_notes": [ + "The study intent explicitly mentions 'fasciitis', indicating a diagnosis." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotype definitions for patients diagnosed with fasciitis, prioritizing those with ICD-based diagnoses or MAP-derived probabilities.", + "intent_facets": { + "condition_or_topic": "fasciitis", + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified" + }, + "shortlist_ids": [ + "cipher:2703", + "cipher:14029", + "cipher:15684" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with fasciitis as a diagnosis-oriented study intent.", + "Included Fasciitis (MAP) as a diagnosis candidate focused on Fasciitis.", + "Included Fasciitis (Phecode) as a diagnosis candidate focused on Fasciitis.", + "Included Fasciitis (gwPheWAS) as a diagnosis candidate focused on Fasciitis." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8847, + "prompt_length_chars": 6885, + "recommendations": { + "plan": "Recommend phenotypes for patients diagnosed with fasciitis.", + "phenotype_recommendations": [ + { + "phenotype_id": "cipher:2703", + "phenotype_name": "Fasciitis (MAP)", + "justification": "Selected from the top reranked shortlisted candidates as a clinically aligned diagnosis match.", + "confidence": null + }, + { + "phenotype_id": "cipher:14029", + "phenotype_name": "Fasciitis (Phecode)", + "justification": "This phenotype represents a diagnosis of Fasciitis based on ICD-9 and ICD-10 codes. It is defined by the Phecode mapping system, aligning with the study intent of patients diagnosed with fasciitis.", + "confidence": 0.5570900308801044 + }, + { + "phenotype_id": "cipher:15684", + "phenotype_name": "Fasciitis (gwPheWAS)", + "justification": "This phenotype definition represents a diagnosis of Fasciitis, based on ICD codes used in the Million Veteran Program (MVP) phenome-wide GWAS, directly relevant to the study's focus.", + "confidence": 0.692377579201426 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.656525373458862, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-7b1c7777-7ed9-41e3-a051-2b5edc4e11b5\",\"created\":1778090461,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients diagnosed with fasciitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:14029\\\",\\n \\\"phenotype_name\\\": \\\"Fasciitis (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype represents a diagnosis of Fasciitis based on ICD-9 and ICD-10 codes. It is defined by the Phecode mapping system, aligning with the study intent of patients diagnosed with fasciitis.\\\",\\n \\\"confidence\\\": 0.5570900308801044\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:15684\\\",\\n \\\"phenotype_name\\\": \\\"Fasciitis (gwPheWAS)\\\",\\n \\\"justification\\\": \\\"This phenotype definition represents a diagnosis of Fasciitis, based on ICD codes used in the Million Veteran Program (MVP) phenome-wide GWAS, directly relevant to the study's focus.\\\",\\n \\\"confidence\\\": 0.692377579201426\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1832,\"output_tokens\":259,\"total_tokens\":2091,\"prompt_tokens\":1832,\"completion_tokens\":259,\"response_token/s\":99.53,\"prompt_token/s\":4279.29,\"total_duration\":4345136526,\"load_duration\":686290320,\"prompt_eval_count\":1832,\"prompt_eval_duration\":428108648,\"eval_count\":259,\"eval_duration\":2602177426,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients diagnosed with fasciitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:14029\",\n \"phenotype_name\": \"Fasciitis (Phecode)\",\n \"justification\": \"This phenotype represents a diagnosis of Fasciitis based on ICD-9 and ICD-10 codes. It is defined by the Phecode mapping system, aligning with the study intent of patients diagnosed with fasciitis.\",\n \"confidence\": 0.5570900308801044\n },\n {\n \"phenotype_id\": \"cipher:15684\",\n \"phenotype_name\": \"Fasciitis (gwPheWAS)\",\n \"justification\": \"This phenotype definition represents a diagnosis of Fasciitis, based on ICD codes used in the Million Veteran Program (MVP) phenome-wide GWAS, directly relevant to the study's focus.\",\n \"confidence\": 0.692377579201426\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.073171854019165, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-3d0ca6ce-5462-48b6-b13a-720571098618\",\"created\":1778090451,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend fasciitis phenotypes based on patient diagnosis.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"fasciitis\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"not_specified\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [],\\n \\\"care_setting_cues\\\": [],\\n \\\"population_cues\\\": [],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly mentions 'fasciitis', indicating a diagnosis.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1203,\"output_tokens\":237,\"total_tokens\":1440,\"prompt_tokens\":1203,\"completion_tokens\":237,\"response_token/s\":99.87,\"prompt_token/s\":4181.09,\"total_duration\":3948433638,\"load_duration\":659497362,\"prompt_eval_count\":1203,\"prompt_eval_duration\":287724165,\"eval_count\":237,\"eval_duration\":2373064013,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend fasciitis phenotypes based on patient diagnosis.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"fasciitis\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"not_specified\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [],\n \"care_setting_cues\": [],\n \"population_cues\": [],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"The study intent explicitly mentions 'fasciitis', indicating a diagnosis.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.62766695022583, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-cdf4fa62-d40f-4078-b0a6-870c10646ae9\",\"created\":1778090456,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotype definitions for patients diagnosed with fasciitis, prioritizing those with ICD-based diagnoses or MAP-derived probabilities.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"fasciitis\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"cipher:14029\\\",\\n \\\"cipher:2703\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Phecode (cipher:14029) and MAP (cipher:2703) are both ICD-based approaches for identifying patients with fasciitis. Both are included for further investigation.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2654,\"output_tokens\":240,\"total_tokens\":2894,\"prompt_tokens\":2654,\"completion_tokens\":240,\"response_token/s\":100.24,\"prompt_token/s\":4423.69,\"total_duration\":4368345468,\"load_duration\":711634393,\"prompt_eval_count\":2654,\"prompt_eval_duration\":599951275,\"eval_count\":240,\"eval_duration\":2394340447,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotype definitions for patients diagnosed with fasciitis, prioritizing those with ICD-based diagnoses or MAP-derived probabilities.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"fasciitis\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"cipher:14029\",\n \"cipher:2703\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Phecode (cipher:14029) and MAP (cipher:2703) are both ICD-based approaches for identifying patients with fasciitis. Both are included for further investigation.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "fasciitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "intent_facets_effective": { + "condition_or_topic": "fasciitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "cipher:15684", + "cipher:2703", + "cipher:14029", + "ohdsi:1075", + "ohdsi:454" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "cipher:15684", + "cipher:2703", + "cipher:14029", + "ohdsi:1075", + "ohdsi:454" + ], + "planner_input_shortlist_ids": [ + "cipher:14029", + "cipher:2703" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "cipher:15684", + "cipher:2703", + "cipher:14029", + "ohdsi:1075", + "ohdsi:454" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "cipher:2703", + "cipher:14029", + "cipher:15684" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 2, + "phenotype_id": "cipher:15684", + "name": "Fasciitis (gwPheWAS)", + "metadata_score": 35.6925, + "retrieval_score": 0.6924, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Fasciitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Musculoskeletal System" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Fasciitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Fasciitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 2, + "original_rank": 4, + "phenotype_id": "cipher:2703", + "name": "Fasciitis (MAP)", + "metadata_score": 35.6525, + "retrieval_score": 0.6505, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Fasciitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "MAP Phenotype" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Fasciitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Fasciitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 3, + "original_rank": 6, + "phenotype_id": "cipher:14029", + "name": "Fasciitis (Phecode)", + "metadata_score": 28.1125, + "retrieval_score": 0.5571, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Fasciitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Fasciitis" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 4, + "original_rank": 3, + "phenotype_id": "ohdsi:1075", + "name": "[P] FDA AESI Narcolepsy", + "metadata_score": -3.69, + "retrieval_score": 0.674, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Narcolepsy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Covid19 Subjects" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Narcolepsy" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 5, + "original_rank": 7, + "phenotype_id": "ohdsi:454", + "name": "[P][R] Dermatomyositis", + "metadata_score": -3.75, + "retrieval_score": 0.4829, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Dermatomyositis", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "cohort" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Dermatomyositis" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 6, + "original_rank": 9, + "phenotype_id": "ohdsi:479", + "name": "[P][R] Chilblains", + "metadata_score": -3.75, + "retrieval_score": 0.4694, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Chilblains", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Chilblains" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 7, + "original_rank": 10, + "phenotype_id": "ohdsi:363", + "name": "[P][R] Joint stiffness ", + "metadata_score": -3.75, + "retrieval_score": 0.4683, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Joint stiffness", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Joint stiffness" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 8, + "original_rank": 1, + "phenotype_id": "cipher:30159", + "name": "Enthesopathies and Synovial Disorders (HDR UK)", + "metadata_score": -3.7875, + "retrieval_score": 0.7, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Enthesopathies and Synovial Disorders", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Enthesopathies and Synovial Disorders" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 9, + "original_rank": 5, + "phenotype_id": "cipher:29553", + "name": "Sleep Apnea (HDR UK)", + "metadata_score": -3.8675, + "retrieval_score": 0.5629, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Sleep Apnea", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Sleep Apnea" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 10, + "original_rank": 12, + "phenotype_id": "cipher:30170", + "name": "Diabetes, ICD, Read, and Med Codes (HDR UK)", + "metadata_score": -3.8875, + "retrieval_score": 0.4267, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Diabetes Mellitus", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Diabetes Mellitus" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 11, + "original_rank": 11, + "phenotype_id": "ohdsi:385", + "name": "[P][R] Intertrigo ", + "metadata_score": -10.75, + "retrieval_score": 0.457, + "phenotype_role": "complication", + "primary_clinical_topic": "Intertrigo", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Intertrigo" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 12, + "original_rank": 8, + "phenotype_id": "cipher:2589", + "name": "Disorders of muscle, ligament, and fascia (MAP)", + "metadata_score": -11.3875, + "retrieval_score": 0.4709, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Muscle Ligament Fascia Disorders", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Inflammation" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Muscle Ligament Fascia Disorders" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "cipher:2703", + "cipher:14029", + "cipher:15684" + ], + "valid_unique_count": 2 + }, + "final_deterministic": { + "selected_ids": [ + "cipher:2703", + "cipher:14029", + "cipher:15684" + ], + "matched_llm_ids": [ + "cipher:14029", + "cipher:15684" + ], + "defaulted_ids": [ + "cipher:2703" + ], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 2, + "used_default_justification_count": 1 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.656525373458862, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-7b1c7777-7ed9-41e3-a051-2b5edc4e11b5\",\"created\":1778090461,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients diagnosed with fasciitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:14029\\\",\\n \\\"phenotype_name\\\": \\\"Fasciitis (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype represents a diagnosis of Fasciitis based on ICD-9 and ICD-10 codes. It is defined by the Phecode mapping system, aligning with the study intent of patients diagnosed with fasciitis.\\\",\\n \\\"confidence\\\": 0.5570900308801044\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:15684\\\",\\n \\\"phenotype_name\\\": \\\"Fasciitis (gwPheWAS)\\\",\\n \\\"justification\\\": \\\"This phenotype definition represents a diagnosis of Fasciitis, based on ICD codes used in the Million Veteran Program (MVP) phenome-wide GWAS, directly relevant to the study's focus.\\\",\\n \\\"confidence\\\": 0.692377579201426\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1832,\"output_tokens\":259,\"total_tokens\":2091,\"prompt_tokens\":1832,\"completion_tokens\":259,\"response_token/s\":99.53,\"prompt_token/s\":4279.29,\"total_duration\":4345136526,\"load_duration\":686290320,\"prompt_eval_count\":1832,\"prompt_eval_duration\":428108648,\"eval_count\":259,\"eval_duration\":2602177426,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients diagnosed with fasciitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:14029\",\n \"phenotype_name\": \"Fasciitis (Phecode)\",\n \"justification\": \"This phenotype represents a diagnosis of Fasciitis based on ICD-9 and ICD-10 codes. It is defined by the Phecode mapping system, aligning with the study intent of patients diagnosed with fasciitis.\",\n \"confidence\": 0.5570900308801044\n },\n {\n \"phenotype_id\": \"cipher:15684\",\n \"phenotype_name\": \"Fasciitis (gwPheWAS)\",\n \"justification\": \"This phenotype definition represents a diagnosis of Fasciitis, based on ICD codes used in the Million Veteran Program (MVP) phenome-wide GWAS, directly relevant to the study's focus.\",\n \"confidence\": 0.692377579201426\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Acute prostatitis (MAP)", + "elapsed_seconds": 13.788, + "response": { + "status": "ok", + "search": { + "query": "Patients with acute prostatitis", + "results": [ + { + "phenotype_id": "ohdsi:283", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Prostatitis", + "short_description": "All events of prostatits that is either a first occurrence of chronic prostatitis, or did not have an event of prostatitis in past 1 year, with no testicular lesions, bladder neoplasm or abdominal or inguinal hernia in prior 1 year. cohort end 180 days post cohort end date", + "tags": [ + "Submitted" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8768385595227461, + "score_dense": 1.0, + "score_sparse": 0.5894618650758208, + "score_dense_raw": 0.6148921847343445, + "score_sparse_raw": 14.915819849363546 + }, + { + "phenotype_id": "cipher:13720", + "source_dataset": "va_cipher", + "name": "Acute Prostatitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6139296548645155, + "score_dense": 0.4829053054738886, + "score_sparse": 0.9196531367759783, + "score_dense_raw": 0.5170927047729492, + "score_sparse_raw": 19.131134307851283 + }, + { + "phenotype_id": "cipher:15499", + "source_dataset": "va_cipher", + "name": "Acute prostatitis (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5582805926168594, + "score_dense": 0.4343685187980659, + "score_sparse": 0.847408764860711, + "score_dense_raw": 0.507912814617157, + "score_sparse_raw": 18.208842377144727 + }, + { + "phenotype_id": "cipher:2054", + "source_dataset": "va_cipher", + "name": "Acute prostatitis (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Acute prostatitis. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.54 means that patients with a MAP probability over 0.54 should be categorized as having Acute prostatitis.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5130485809073029, + "score_dense": 0.30435511558186124, + "score_sparse": 1.0, + "score_dense_raw": 0.48332303762435913, + "score_sparse_raw": 20.156864916956884 + }, + { + "phenotype_id": "ohdsi:1301", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Acute Urinary tract infections UTI events", + "short_description": "all events of acute urinary tract infection", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.49771039952100715, + "score_dense": 0.4582018512384818, + "score_sparse": 0.5898970121802332, + "score_dense_raw": 0.5124204754829407, + "score_sparse_raw": 14.92137505944331 + }, + { + "phenotype_id": "ohdsi:410", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Acute Urinary tract infections UTI", + "short_description": "", + "tags": [ + "Symptoms" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4671717040328711, + "score_dense": 0.43156150147536226, + "score_sparse": 0.5502621766670585, + "score_dense_raw": 0.5073819160461426, + "score_sparse_raw": 14.415385623870991 + }, + { + "phenotype_id": "ohdsi:994", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Genitourinary infection", + "short_description": "Condition record of any type of genital or urinary tract infection during an outpatient or ER vists", + "tags": [ + "usedInStudy", + "LEGEND", + "Diabetes", + "legendDiabetes" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4348939329600007, + "score_dense": 0.4006392448565591, + "score_sparse": 0.5148215385346977, + "score_dense_raw": 0.5015335083007812, + "score_sparse_raw": 13.962940489364122 + }, + { + "phenotype_id": "cipher:18650", + "source_dataset": "va_cipher", + "name": "Chronic Prostatitis or Chronic Pelvic Pain Syndrome (MVP)", + "short_description": "CP/CPPS cases and controls were identified using EHR ICD 9/10 codes to generate a binary variable. ICD codes 601.1 and N41.1 were used to identify CP/CPPS cases, defined as Veterans with one or more of the ICD codes. Controls were defined as Veterans who did not have either ICD code.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "not_validated", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:mvp", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3743861471140853, + "score_dense": 0.3887099727429069, + "score_sparse": 0.34096388731350163, + "score_dense_raw": 0.4992772936820984, + "score_sparse_raw": 11.743424912414131 + }, + { + "phenotype_id": "ohdsi:1186", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Urinary tract infectious 10", + "short_description": "First Urinary tract infectious record in 30 days continues for 1 day", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction", + "washout" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.337074310796412, + "score_dense": 0.48153472970916, + "score_sparse": null, + "score_dense_raw": 0.516833484172821, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:861", + "source_dataset": "ohdsi_phenotype_library", + "name": "[W] Earliest event of Urinary tract infections (UTI)", + "short_description": "Earliest event of diagnosis of urinary tract infections, cohort exist is end of observation period.", + "tags": [ + "ColorectalCancer", + "Cancer" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.32795920126372, + "score_dense": 0.3319941672531033, + "score_sparse": 0.31854428062182566, + "score_dense_raw": 0.48855048418045044, + "score_sparse_raw": 11.457209920253922 + }, + { + "phenotype_id": "cipher:3500", + "source_dataset": "va_cipher", + "name": "Prostatitis (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Prostatitis. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.43 means that patients with a MAP probability over 0.43 should be categorized as having Prostatitis.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.31553036470606355, + "score_dense": 0.2270231592322885, + "score_sparse": 0.5220471774782055, + "score_dense_raw": 0.46869704127311707, + "score_sparse_raw": 14.055185024337897 + }, + { + "phenotype_id": "cipher:15498", + "source_dataset": "va_cipher", + "name": "Prostatitis (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3113474612530756, + "score_dense": 0.27632055400380506, + "score_sparse": 0.39307691150137364, + "score_dense_raw": 0.4780207872390747, + "score_sparse_raw": 12.408714407207743 + }, + { + "phenotype_id": "cipher:30181", + "source_dataset": "va_cipher", + "name": "Non-Acute Cystitis (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2983387431845921, + "score_dense": 0.42063628877454645, + "score_sparse": 0.012977803474698494, + "score_dense_raw": 0.5053156018257141, + "score_sparse_raw": 7.556262501155824 + }, + { + "phenotype_id": "cipher:2373", + "source_dataset": "va_cipher", + "name": "Chronic prostatitis (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Chronic prostatitis. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.54 means that patients with a MAP probability over 0.54 should be categorized as having Chronic prostatitis.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.293689085019194, + "score_dense": 0.19907778435035056, + "score_sparse": 0.5144487865798288, + "score_dense_raw": 0.4634116590023041, + "score_sparse_raw": 13.958181833277612 + }, + { + "phenotype_id": "cipher:29775", + "source_dataset": "va_cipher", + "name": "Hyperplasia of Prostate (HDR UK)", + "short_description": "Read Code, Med Code, and ICD 10 Code based algorithm", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2923801155897399, + "score_dense": 0.4176858794139142, + "score_sparse": null, + "score_dense_raw": 0.5047575831413269, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:13719", + "source_dataset": "va_cipher", + "name": "Prostatitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2561366076509598, + "score_dense": 0.199123323140418, + "score_sparse": 0.38916760484222407, + "score_dense_raw": 0.4634202718734741, + "score_sparse_raw": 12.358807101063261 + }, + { + "phenotype_id": "cipher:13721", + "source_dataset": "va_cipher", + "name": "Chronic Prostatitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2549573633836113, + "score_dense": 0.2011353813077921, + "score_sparse": 0.3805419882271896, + "score_dense_raw": 0.4638008177280426, + "score_sparse_raw": 12.248690058164124 + }, + { + "phenotype_id": "ohdsi:1202", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Antibiotics Carbapenems 10", + "short_description": "any Antibiotics Carbapenems with 30 day persistence", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.22995439187751862, + "score_dense": 0.3285062741107409, + "score_sparse": null, + "score_dense_raw": 0.4878908097743988, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29206", + "source_dataset": "va_cipher", + "name": "Quinsy (HDR UK)", + "short_description": "HDR UK phenotype based on read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.1977304507016598, + "score_dense": 0.2824720724309426, + "score_sparse": null, + "score_dense_raw": 0.47918424010276794, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:397", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Hyperplasia of prostate ", + "short_description": "all events of Hyperplasia of prostate. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.1971058129241292, + "score_dense": 0.28157973274875603, + "score_sparse": null, + "score_dense_raw": 0.4790154695510864, + "score_sparse_raw": null + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend prostatitis phenotypes based on study intent.", + "intent_facets": { + "condition_or_topic": "acute prostatitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "required", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "acute inflammation of the prostate gland" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients with prostatitis" + ] + }, + "reasoning_notes": [ + "Study intent explicitly states 'Patients with acute prostatitis', indicating a diagnosis." + ], + "mode": "llm" + }, + "planning": { + "plan": "Select a shortlist of phenotypes for deeper review against the study intent (stub if no LLM).", + "intent_facets": {}, + "shortlist_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with acute prostatitis as a diagnosis-oriented study intent.", + "Included Acute Prostatitis (Phecode) as a diagnosis candidate focused on Acute Prostatitis.", + "Included Acute prostatitis (MAP) as a diagnosis candidate focused on Acute Prostatitis.", + "Included [P] Prostatitis as a diagnosis candidate focused on Prostatitis." + ], + "mode": "stub", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 5, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 9334, + "prompt_length_chars": 6890, + "recommendations": { + "plan": "Recommend phenotypes for patients with acute prostatitis.", + "phenotype_recommendations": [ + { + "phenotype_id": "cipher:13720", + "phenotype_name": "Acute Prostatitis (Phecode)", + "justification": "This phenotype directly represents 'Acute Prostatitis' based on ICD-9 and ICD-10 codes, aligning with the study intent.", + "confidence": 0.6139296548645155 + }, + { + "phenotype_id": "cipher:2054", + "phenotype_name": "Acute prostatitis (MAP)", + "justification": "Selected from the top reranked shortlisted candidates as a clinically aligned diagnosis match.", + "confidence": null + }, + { + "phenotype_id": "ohdsi:283", + "phenotype_name": "[P] Prostatitis", + "justification": "This phenotype captures prostatitis, including chronic prostatitis, which is relevant to 'acute prostatitis' and aligns with the study intent.", + "confidence": 0.8768385595227461 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.221900701522827, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-d63f911c-c558-4e34-a653-22289098035e\",\"created\":1778090475,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with acute prostatitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:13720\\\",\\n \\\"phenotype_name\\\": \\\"Acute Prostatitis (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype directly represents 'Acute Prostatitis' based on ICD-9 and ICD-10 codes, aligning with the study intent.\\\",\\n \\\"confidence\\\": 0.6139296548645155\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:283\\\",\\n \\\"phenotype_name\\\": \\\"[P] Prostatitis\\\",\\n \\\"justification\\\": \\\"This phenotype captures prostatitis, including chronic prostatitis, which is relevant to 'acute prostatitis' and aligns with the study intent.\\\",\\n \\\"confidence\\\": 0.8768385595227461\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1821,\"output_tokens\":228,\"total_tokens\":2049,\"prompt_tokens\":1821,\"completion_tokens\":228,\"response_token/s\":99.93,\"prompt_token/s\":4271.33,\"total_duration\":3952862332,\"load_duration\":695916639,\"prompt_eval_count\":1821,\"prompt_eval_duration\":426331322,\"eval_count\":228,\"eval_duration\":2281506313,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with acute prostatitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:13720\",\n \"phenotype_name\": \"Acute Prostatitis (Phecode)\",\n \"justification\": \"This phenotype directly represents 'Acute Prostatitis' based on ICD-9 and ICD-10 codes, aligning with the study intent.\",\n \"confidence\": 0.6139296548645155\n },\n {\n \"phenotype_id\": \"ohdsi:283\",\n \"phenotype_name\": \"[P] Prostatitis\",\n \"justification\": \"This phenotype captures prostatitis, including chronic prostatitis, which is relevant to 'acute prostatitis' and aligns with the study intent.\",\n \"confidence\": 0.8768385595227461\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.35905385017395, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-b6cf3962-1128-437d-8c13-7d342a072c94\",\"created\":1778090466,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend prostatitis phenotypes based on study intent.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"acute prostatitis\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"inpatient|outpatient\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"required\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"acute inflammation of the prostate gland\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"hospital\\\",\\n \\\"clinic\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"patients with prostatitis\\\"\\n ]\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"Study intent explicitly states 'Patients with acute prostatitis', indicating a diagnosis.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1202,\"output_tokens\":267,\"total_tokens\":1469,\"prompt_tokens\":1202,\"completion_tokens\":267,\"response_token/s\":101.13,\"prompt_token/s\":4151.15,\"total_duration\":4139123931,\"load_duration\":600432164,\"prompt_eval_count\":1202,\"prompt_eval_duration\":289558521,\"eval_count\":267,\"eval_duration\":2640065962,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend prostatitis phenotypes based on study intent.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"acute prostatitis\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"inpatient|outpatient\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"required\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"acute inflammation of the prostate gland\"\n ],\n \"care_setting_cues\": [\n \"hospital\",\n \"clinic\"\n ],\n \"population_cues\": [\n \"patients with prostatitis\"\n ]\n },\n \"reasoning_notes\": [\n \"Study intent explicitly states 'Patients with acute prostatitis', indicating a diagnosis.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "json_parse_failed", + "llm_duration_seconds": 4.3515002727508545, + "llm_error": "json_parse_failed", + "llm_parse_stage": "chat_completions_content:json_loads", + "llm_schema_valid": false, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-953c7915-6675-412d-8543-e64c9eee7669\",\"created\":1778090470,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with acute prostatitis. Prioritizing native OHDSI phenotypes and those with high execution readiness.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"acute prostatitis\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"mixed\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"required\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:283\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Phenotype ohdsi:283 (\\\\[P] Prostatitis) is a native OHDSI phenotype with a high execution readiness score and aligns well with the study intent. It's a well-established phenotype for prostatitis.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2739,\"output_tokens\":235,\"total_tokens\":2974,\"prompt_tokens\":2739,\"completion_tokens\":235,\"response_token/s\":103.0,\"prompt_token/s\":4298.98,\"total_duration\":4113777166,\"load_duration\":653390717,\"prompt_eval_count\":2739,\"prompt_eval_duration\":637127523,\"eval_count\":235,\"eval_duration\":2281503583,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with acute prostatitis. Prioritizing native OHDSI phenotypes and those with high execution readiness.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"acute prostatitis\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"mixed\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"required\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"ohdsi:283\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Phenotype ohdsi:283 (\\[P] Prostatitis) is a native OHDSI phenotype with a high execution readiness score and aligns well with the study intent. It's a well-established phenotype for prostatitis.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "acute prostatitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "required", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "acute inflammation of the prostate gland" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients with prostatitis" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "acute prostatitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "required", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "acute inflammation of the prostate gland" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients with prostatitis" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "planner_input_shortlist_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283", + "cipher:3500", + "cipher:15498" + ], + "enforced": false + }, + "candidates": [ + { + "rank": 1, + "original_rank": 2, + "phenotype_id": "cipher:13720", + "name": "Acute Prostatitis (Phecode)", + "metadata_score": 34.1925, + "retrieval_score": 0.6139, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Acute Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Acute Prostatitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Acute Prostatitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 2, + "original_rank": 4, + "phenotype_id": "cipher:2054", + "name": "Acute prostatitis (MAP)", + "metadata_score": 34.1525, + "retrieval_score": 0.513, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Acute Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Acute Prostatitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Acute Prostatitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 3, + "original_rank": 1, + "phenotype_id": "ohdsi:283", + "name": "[P] Prostatitis", + "metadata_score": 23.85, + "retrieval_score": 0.8768, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Prostatitis" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Prostatitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 4, + "original_rank": 11, + "phenotype_id": "cipher:3500", + "name": "Prostatitis (MAP)", + "metadata_score": 23.6125, + "retrieval_score": 0.3155, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "MAP" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Prostatitis" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Prostatitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 5, + "original_rank": 12, + "phenotype_id": "cipher:15498", + "name": "Prostatitis (gwPheWAS)", + "metadata_score": 23.6125, + "retrieval_score": 0.3113, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Prostatitis" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Prostatitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 6, + "original_rank": 3, + "phenotype_id": "cipher:15499", + "name": "Acute prostatitis (gwPheWAS)", + "metadata_score": 19.1725, + "retrieval_score": 0.5583, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Acute Prostatitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Acute Prostatitis" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 7, + "original_rank": 8, + "phenotype_id": "cipher:18650", + "name": "Chronic Prostatitis or Chronic Pelvic Pain Syndrome (MVP)", + "metadata_score": 5.7554, + "retrieval_score": 0.3744, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Chronic Prostatitis or Chronic Pelvic Pain Syndrome (MVP)", + "care_setting_scope": "unspecified", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.1429, + "detail": "Chronic Prostatitis or Chronic Pelvic Pain Syndrome (MVP)" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 8, + "original_rank": 5, + "phenotype_id": "ohdsi:1301", + "name": "[P] Acute Urinary tract infections UTI events", + "metadata_score": 2.5478, + "retrieval_score": 0.4977, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Urinary Tract Infection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "certain urinary tract infections", + "genitourinary tract infection" + ], + "reasons": [ + { + "kind": "topic_context", + "delta": 2.7778, + "detail": "{\"context_conditions\": [\"Cystitis\", \"Asymptomatic bacteriuria\"], \"target_conditions\": [\"Acute Urinary tract infections U... [truncated 12 chars]" + }, + { + "kind": "context_without_primary", + "delta": -3.0, + "detail": "topic only matched context fields" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 9, + "original_rank": 6, + "phenotype_id": "ohdsi:410", + "name": "[P] Acute Urinary tract infections UTI", + "metadata_score": -5.25, + "retrieval_score": 0.4672, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Urinary Tract Infection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Urinary Tract Infection" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 10, + "original_rank": 9, + "phenotype_id": "ohdsi:1186", + "name": "[P] Urinary tract infectious 10", + "metadata_score": -9.25, + "retrieval_score": 0.3371, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Urinary Tract Infection", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Urinary Tract Infection" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "status_prediction", + "delta": -4.0, + "detail": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction", + "washout" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 11, + "original_rank": 7, + "phenotype_id": "ohdsi:994", + "name": "[P] Genitourinary infection", + "metadata_score": -15.25, + "retrieval_score": 0.4349, + "phenotype_role": "complication", + "primary_clinical_topic": "Genitourinary Infection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Comorbidities", + "Risk Scores" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Genitourinary Infection" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "exclude_comorbidity", + "delta": -3.0, + "detail": [ + "Comorbidities", + "Risk Scores" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 12, + "original_rank": 10, + "phenotype_id": "ohdsi:861", + "name": "[W] Earliest event of Urinary tract infections (UTI)", + "metadata_score": -17.25, + "retrieval_score": 0.328, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Urinary Tract Infection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "UTI", + "Cystitis" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Urinary Tract Infection" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "status_withdrawn", + "delta": -12.0, + "detail": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283" + ], + "valid_unique_count": 2 + }, + "final_deterministic": { + "selected_ids": [ + "cipher:13720", + "cipher:2054", + "ohdsi:283" + ], + "matched_llm_ids": [ + "cipher:13720", + "ohdsi:283" + ], + "defaulted_ids": [ + "cipher:2054" + ], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 2, + "used_default_justification_count": 1 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.221900701522827, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-d63f911c-c558-4e34-a653-22289098035e\",\"created\":1778090475,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with acute prostatitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:13720\\\",\\n \\\"phenotype_name\\\": \\\"Acute Prostatitis (Phecode)\\\",\\n \\\"justification\\\": \\\"This phenotype directly represents 'Acute Prostatitis' based on ICD-9 and ICD-10 codes, aligning with the study intent.\\\",\\n \\\"confidence\\\": 0.6139296548645155\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:283\\\",\\n \\\"phenotype_name\\\": \\\"[P] Prostatitis\\\",\\n \\\"justification\\\": \\\"This phenotype captures prostatitis, including chronic prostatitis, which is relevant to 'acute prostatitis' and aligns with the study intent.\\\",\\n \\\"confidence\\\": 0.8768385595227461\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1821,\"output_tokens\":228,\"total_tokens\":2049,\"prompt_tokens\":1821,\"completion_tokens\":228,\"response_token/s\":99.93,\"prompt_token/s\":4271.33,\"total_duration\":3952862332,\"load_duration\":695916639,\"prompt_eval_count\":1821,\"prompt_eval_duration\":426331322,\"eval_count\":228,\"eval_duration\":2281506313,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with acute prostatitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:13720\",\n \"phenotype_name\": \"Acute Prostatitis (Phecode)\",\n \"justification\": \"This phenotype directly represents 'Acute Prostatitis' based on ICD-9 and ICD-10 codes, aligning with the study intent.\",\n \"confidence\": 0.6139296548645155\n },\n {\n \"phenotype_id\": \"ohdsi:283\",\n \"phenotype_name\": \"[P] Prostatitis\",\n \"justification\": \"This phenotype captures prostatitis, including chronic prostatitis, which is relevant to 'acute prostatitis' and aligns with the study intent.\",\n \"confidence\": 0.8768385595227461\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Esophagectomy", + "elapsed_seconds": 15.259, + "response": { + "status": "ok", + "search": { + "query": "Patients who underwent esophagectomy", + "results": [ + { + "phenotype_id": "ohdsi:1309", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Esophagectomy, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8513787193138312, + "score_dense": 1.0, + "score_sparse": 0.5045957310461044, + "score_dense_raw": 0.5840630531311035, + "score_sparse_raw": 13.099068473799315 + }, + { + "phenotype_id": "ohdsi:1294", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Esophagectomy, adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7840234720437443, + "score_dense": 0.901934881006185, + "score_sparse": 0.508896851131383, + "score_dense_raw": 0.5675992965698242, + "score_sparse_raw": 13.170608128979733 + }, + { + "phenotype_id": "ohdsi:870", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Esophagectomy, adults, inpt stay, no ED", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7723903233061381, + "score_dense": 0.9016377204340319, + "score_sparse": 0.47081306334105266, + "score_dense_raw": 0.5675494074821472, + "score_sparse_raw": 12.53716823600636 + }, + { + "phenotype_id": "ohdsi:1097", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit) Esophagectomy (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7629674502147897, + "score_dense": 0.862032287188474, + "score_sparse": 0.5318161639428598, + "score_dense_raw": 0.5609002113342285, + "score_sparse_raw": 13.551820395318726 + }, + { + "phenotype_id": "ohdsi:878", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.6190965963575995, + "score_dense": 0.8844237090822851, + "score_sparse": null, + "score_dense_raw": 0.5646594166755676, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:16942", + "source_dataset": "va_cipher", + "name": "Individuals With Esophageal Biopsy Specimens on Esophagogastroduodenoscopy (Low)", + "short_description": "Applied the International Classification of Diseases (ICD) codes for EoE [530.13 (ICD, 9th revision) and K20.0 (ICD, 10th revision)] to a base cohort of individuals who underwent esophagogastroduodenoscopy (EGD) with esophageal biopsy specimens within the VHA, as determined by procedure codes for EGD and natural language processing (NLP) methods to identify esophageal biopsy specimens.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:2", + "va_developed", + "major_revision", + "validated", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6136599866068306, + "score_dense": 0.4948110509649908, + "score_sparse": 0.8909741697711239, + "score_dense_raw": 0.4992489218711853, + "score_sparse_raw": 19.52562242427945 + }, + { + "phenotype_id": "ohdsi:1304", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5519162951567796, + "score_dense": 0.788451850223971, + "score_sparse": null, + "score_dense_raw": 0.548547089099884, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30087", + "source_dataset": "va_cipher", + "name": "Primary Malignancy, Oesophageal (HDR OK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5081785193024785, + "score_dense": 0.7259693132892551, + "score_sparse": null, + "score_dense_raw": 0.5380571484565735, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1289", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4349817638556886, + "score_dense": 0.6214025197938409, + "score_sparse": null, + "score_dense_raw": 0.5205018520355225, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:877", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent MNCS (age 18 or greater), post op Afib (parox)", + "short_description": "", + "tags": [ + "Surgery", + "NonEmergent" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.38708846189758855, + "score_dense": 0.5529835169965551, + "score_sparse": null, + "score_dense_raw": 0.5090152621269226, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:865", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent Major Non Cardiac Surgery among adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3870708168791465, + "score_dense": 0.5529583098273522, + "score_sparse": null, + "score_dense_raw": 0.5090110301971436, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30650", + "source_dataset": "va_cipher", + "name": "Smoking Status (Fairhust)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.37945214525435983, + "score_dense": 0.542074493220514, + "score_sparse": null, + "score_dense_raw": 0.5071837902069092, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1106", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent Major Non Cardiac Surgery no prior Opioid", + "short_description": "Persons having any of major non cardiac surgery", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3712280755320399, + "score_dense": 0.5303258221886284, + "score_sparse": null, + "score_dense_raw": 0.505211353302002, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:447", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Esophagitis", + "short_description": "all events of Esophagitis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3456683964938603, + "score_dense": 0.49381199499122896, + "score_sparse": null, + "score_dense_raw": 0.49908119440078735, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:649", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Malignant tumor of esophagus", + "short_description": "all events of Malignant tumor of esophagus. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3423783461185752, + "score_dense": 0.48911192302653606, + "score_sparse": null, + "score_dense_raw": 0.49829211831092834, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:448", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Dysphagia", + "short_description": "all events of Dysphagia. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3135549599720804, + "score_dense": 0.447935657102972, + "score_sparse": null, + "score_dense_raw": 0.4913792014122009, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:823", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Primary adenocarcinoma of colon or rectum, MSI-H or dMMR, oncological treatment no surgery", + "short_description": "First event primary adenocarcinoma of the colon or rectum, with MSI-H or dMMR molecular subtype, treated with oncological treatment no curative surgery", + "tags": [ + "ColorectalCancer", + "Cancer" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.31121910746029247, + "score_dense": 0.444598724943275, + "score_sparse": null, + "score_dense_raw": 0.49081897735595703, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29227", + "source_dataset": "va_cipher", + "name": "Cardiorespiratory Fitness (PheKB)", + "short_description": "ICD 9 and procedure code based algorithm defining patients who underwent exercise-stress test.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:2", + "major_revision", + "validated", + "has_contact", + "has_code_system:icd9", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3, + "score_dense": null, + "score_sparse": 1.0, + "score_dense_raw": null, + "score_sparse_raw": 21.33902687374567 + }, + { + "phenotype_id": "ohdsi:1102", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit)CABG (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.29722238297216663, + "score_dense": 0.4246034042459524, + "score_sparse": null, + "score_dense_raw": 0.48746204376220703, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29206", + "source_dataset": "va_cipher", + "name": "Quinsy (HDR UK)", + "short_description": "HDR UK phenotype based on read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.28666531873400364, + "score_dense": 0.40952188390571953, + "score_sparse": null, + "score_dense_raw": 0.4849300682544708, + "score_sparse_raw": null + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend esophagectomy phenotypes based on study intent.", + "intent_facets": { + "condition_or_topic": "esophagectomy", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital setting" + ], + "population_cues": [ + "patient cohort" + ] + }, + "reasoning_notes": [ + "Study intent indicates patients who underwent esophagectomy." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotypes related to esophagectomy and postoperative atrial fibrillation in patients undergoing inpatient procedures.", + "intent_facets": { + "condition_or_topic": "esophagectomy", + "phenotype_role": "procedure", + "care_setting": "inpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified" + }, + "shortlist_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309", + "ohdsi:870" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with esophagectomy as a procedure-oriented study intent.", + "Included [P] Esophagectomy, adults, inpt stay, no ED, post op new Afib as a procedure candidate focused on Esophagectomy.", + "Included [P] Esophagectomy, adults as a procedure candidate focused on Esophagectomy.", + "Included [P] Esophagectomy, adults, post op new Afib as a procedure candidate focused on Esophagectomy." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 4, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8778, + "prompt_length_chars": 5184, + "recommendations": { + "plan": "Recommend relevant phenotypes for patients who underwent esophagectomy, focusing on postoperative atrial fibrillation.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:1097", + "phenotype_name": "[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib", + "justification": "This phenotype directly reflects the study intent of patients who underwent esophagectomy and developed postoperative atrial fibrillation.", + "confidence": 0.95 + }, + { + "phenotype_id": "ohdsi:1294", + "phenotype_name": "[P] Esophagectomy, adults", + "justification": "This phenotype represents the surgical procedure itself, providing a broader context for the study.", + "confidence": 0.7 + }, + { + "phenotype_id": "ohdsi:1309", + "phenotype_name": "[P] Esophagectomy, adults, post op new Afib", + "justification": "This phenotype is also relevant as it specifically represents the procedure of esophagectomy alongside postoperative atrial fibrillation.", + "confidence": 0.85 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 5.077665090560913, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-171af169-8508-4b15-9f78-159717ef8a69\",\"created\":1778090490,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend relevant phenotypes for patients who underwent esophagectomy, focusing on postoperative atrial fibrillation.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1097\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype directly reflects the study intent of patients who underwent esophagectomy and developed postoperative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1309\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype is also relevant as it specifically represents the procedure of esophagectomy alongside postoperative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.85\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1294\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults\\\",\\n \\\"justification\\\": \\\"This phenotype represents the surgical procedure itself, providing a broader context for the study.\\\",\\n \\\"confidence\\\": 0.70\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1451,\"output_tokens\":294,\"total_tokens\":1745,\"prompt_tokens\":1451,\"completion_tokens\":294,\"response_token/s\":99.21,\"prompt_token/s\":4463.0,\"total_duration\":4621303626,\"load_duration\":630039307,\"prompt_eval_count\":1451,\"prompt_eval_duration\":325117626,\"eval_count\":294,\"eval_duration\":2963307145,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend relevant phenotypes for patients who underwent esophagectomy, focusing on postoperative atrial fibrillation.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:1097\",\n \"phenotype_name\": \"[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib\",\n \"justification\": \"This phenotype directly reflects the study intent of patients who underwent esophagectomy and developed postoperative atrial fibrillation.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"ohdsi:1309\",\n \"phenotype_name\": \"[P] Esophagectomy, adults, post op new Afib\",\n \"justification\": \"This phenotype is also relevant as it specifically represents the procedure of esophagectomy alongside postoperative atrial fibrillation.\",\n \"confidence\": 0.85\n },\n {\n \"phenotype_id\": \"ohdsi:1294\",\n \"phenotype_name\": \"[P] Esophagectomy, adults\",\n \"justification\": \"This phenotype represents the surgical procedure itself, providing a broader context for the study.\",\n \"confidence\": 0.70\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.5441062450408936, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-641de189-b1e2-4206-97b8-2b160c9edecd\",\"created\":1778090480,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend esophagectomy phenotypes based on study intent.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"esophagectomy\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"procedure\\\",\\n \\\"care_setting\\\": \\\"inpatient\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"surgical procedure\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"hospital setting\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"patient cohort\\\"\\n ],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"Study intent indicates patients who underwent esophagectomy.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1204,\"output_tokens\":266,\"total_tokens\":1470,\"prompt_tokens\":1204,\"completion_tokens\":266,\"response_token/s\":100.52,\"prompt_token/s\":4204.15,\"total_duration\":4313086769,\"load_duration\":698971083,\"prompt_eval_count\":1204,\"prompt_eval_duration\":286383615,\"eval_count\":266,\"eval_duration\":2646238578,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend esophagectomy phenotypes based on study intent.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"esophagectomy\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"procedure\",\n \"care_setting\": \"inpatient\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"surgical procedure\"\n ],\n \"care_setting_cues\": [\n \"hospital setting\"\n ],\n \"population_cues\": [\n \"patient cohort\"\n ],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"Study intent indicates patients who underwent esophagectomy.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.810514688491821, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-ad761b86-a13e-40a8-b185-16d720574abc\",\"created\":1778090485,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to esophagectomy and postoperative atrial fibrillation in patients undergoing inpatient procedures.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"esophagectomy\\\",\\n \\\"phenotype_role\\\": \\\"procedure\\\",\\n \\\"care_setting\\\": \\\"inpatient\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:1097\\\",\\n \\\"ohdsi:1294\\\",\\n \\\"ohdsi:1309\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Selected phenotypes directly relate to esophagectomy procedures and postoperative atrial fibrillation, aligning with the study intent.\\\",\\n \\\"Phenotype ohdsi:1309 (Esophagectomy, adults, post op new Afib) is highly relevant given the focus on postoperative complications.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2618,\"output_tokens\":265,\"total_tokens\":2883,\"prompt_tokens\":2618,\"completion_tokens\":265,\"response_token/s\":100.86,\"prompt_token/s\":4334.39,\"total_duration\":4600896341,\"load_duration\":721358826,\"prompt_eval_count\":2618,\"prompt_eval_duration\":604007167,\"eval_count\":265,\"eval_duration\":2627281227,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to esophagectomy and postoperative atrial fibrillation in patients undergoing inpatient procedures.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"esophagectomy\",\n \"phenotype_role\": \"procedure\",\n \"care_setting\": \"inpatient\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"ohdsi:1097\",\n \"ohdsi:1294\",\n \"ohdsi:1309\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Selected phenotypes directly relate to esophagectomy procedures and postoperative atrial fibrillation, aligning with the study intent.\",\n \"Phenotype ohdsi:1309 (Esophagectomy, adults, post op new Afib) is highly relevant given the focus on postoperative complications.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "esophagectomy", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital setting" + ], + "population_cues": [ + "patient cohort" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "esophagectomy", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital setting" + ], + "population_cues": [ + "patient cohort" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:870", + "ohdsi:1309", + "ohdsi:877" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:870", + "ohdsi:1309", + "ohdsi:877" + ], + "planner_input_shortlist_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:870", + "ohdsi:1309", + "ohdsi:877" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309", + "ohdsi:870" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 4, + "phenotype_id": "ohdsi:1097", + "name": "[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib", + "metadata_score": 32.1233, + "retrieval_score": 0.763, + "phenotype_role": "procedure", + "primary_clinical_topic": "Esophagectomy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Afib" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Esophagectomy" + }, + { + "kind": "topic_context", + "delta": 5.8333, + "detail": "{\"context_conditions\": [\"Atrial Fibrillation\"], \"target_conditions\": [\"Esophagectomy\"]}" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 2, + "original_rank": 2, + "phenotype_id": "ohdsi:1294", + "name": "[P] Esophagectomy, adults", + "metadata_score": 26.33, + "retrieval_score": 0.784, + "phenotype_role": "procedure", + "primary_clinical_topic": "Esophagectomy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Esophagectomy" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 3, + "original_rank": 3, + "phenotype_id": "ohdsi:870", + "name": "[P] Esophagectomy, adults, inpt stay, no ED", + "metadata_score": 26.31, + "retrieval_score": 0.7724, + "phenotype_role": "procedure", + "primary_clinical_topic": "Esophagectomy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Esophagectomy" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 4, + "original_rank": 1, + "phenotype_id": "ohdsi:1309", + "name": "[P] Esophagectomy, adults, post op new Afib", + "metadata_score": 22.35, + "retrieval_score": 0.8514, + "phenotype_role": "procedure", + "primary_clinical_topic": "Esophagectomy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Surgery" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Esophagectomy" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 5, + "original_rank": 10, + "phenotype_id": "ohdsi:877", + "name": "[P] Non-Emergent MNCS (age 18 or greater), post op Afib (parox)", + "metadata_score": -5.75, + "retrieval_score": 0.3871, + "phenotype_role": "complication", + "primary_clinical_topic": "Postoperative Cardiac Complications", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Non-Emergent MNCS" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Cardiac Complications" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 6, + "original_rank": 9, + "phenotype_id": "ohdsi:1289", + "name": "[P] Major Non Cardiac Surgery, adults", + "metadata_score": -9.25, + "retrieval_score": 0.435, + "phenotype_role": "procedure", + "primary_clinical_topic": "Surgery", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 7, + "original_rank": 11, + "phenotype_id": "ohdsi:865", + "name": "[P] Non-Emergent Major Non Cardiac Surgery among adults", + "metadata_score": -9.25, + "retrieval_score": 0.3871, + "phenotype_role": "procedure", + "primary_clinical_topic": "Surgery", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 8, + "original_rank": 8, + "phenotype_id": "cipher:30087", + "name": "Primary Malignancy, Oesophageal (HDR OK)", + "metadata_score": -9.3875, + "retrieval_score": 0.5082, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Primary Malignancy, Oesophageal", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Primary Malignancy, Oesophageal" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 9, + "original_rank": 12, + "phenotype_id": "cipher:30650", + "name": "Smoking Status (Fairhust)", + "metadata_score": -9.3875, + "retrieval_score": 0.3795, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Smoking Status", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Cancer" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Smoking Status" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 10, + "original_rank": 5, + "phenotype_id": "ohdsi:878", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -9.73, + "retrieval_score": 0.6191, + "phenotype_role": "complication", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 11, + "original_rank": 7, + "phenotype_id": "ohdsi:1304", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "metadata_score": -9.75, + "retrieval_score": 0.5519, + "phenotype_role": "outcome", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 12, + "original_rank": 6, + "phenotype_id": "cipher:16942", + "name": "Individuals With Esophageal Biopsy Specimens on Esophagogastroduodenoscopy (Low)", + "metadata_score": -11.8875, + "retrieval_score": 0.6137, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Esophageal Biopsy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Study Context", + "Procedure Codes" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Esophageal Biopsy" + }, + { + "kind": "care_setting_match", + "delta": 2.0, + "detail": "inpatient" + }, + { + "kind": "exclude_context", + "delta": -2.0, + "detail": [ + "Study Context", + "Procedure Codes" + ] + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Study Context", + "Procedure Codes" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309" + ], + "valid_unique_count": 3 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309" + ], + "matched_llm_ids": [ + "ohdsi:1097", + "ohdsi:1294", + "ohdsi:1309" + ], + "defaulted_ids": [], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 3, + "used_default_justification_count": 0 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 5.077665090560913, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-171af169-8508-4b15-9f78-159717ef8a69\",\"created\":1778090490,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend relevant phenotypes for patients who underwent esophagectomy, focusing on postoperative atrial fibrillation.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1097\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype directly reflects the study intent of patients who underwent esophagectomy and developed postoperative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1309\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype is also relevant as it specifically represents the procedure of esophagectomy alongside postoperative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.85\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1294\\\",\\n \\\"phenotype_name\\\": \\\"[P] Esophagectomy, adults\\\",\\n \\\"justification\\\": \\\"This phenotype represents the surgical procedure itself, providing a broader context for the study.\\\",\\n \\\"confidence\\\": 0.70\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1451,\"output_tokens\":294,\"total_tokens\":1745,\"prompt_tokens\":1451,\"completion_tokens\":294,\"response_token/s\":99.21,\"prompt_token/s\":4463.0,\"total_duration\":4621303626,\"load_duration\":630039307,\"prompt_eval_count\":1451,\"prompt_eval_duration\":325117626,\"eval_count\":294,\"eval_duration\":2963307145,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend relevant phenotypes for patients who underwent esophagectomy, focusing on postoperative atrial fibrillation.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:1097\",\n \"phenotype_name\": \"[P] Esophagectomy, adults, inpt stay, no ED, post op new Afib\",\n \"justification\": \"This phenotype directly reflects the study intent of patients who underwent esophagectomy and developed postoperative atrial fibrillation.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"ohdsi:1309\",\n \"phenotype_name\": \"[P] Esophagectomy, adults, post op new Afib\",\n \"justification\": \"This phenotype is also relevant as it specifically represents the procedure of esophagectomy alongside postoperative atrial fibrillation.\",\n \"confidence\": 0.85\n },\n {\n \"phenotype_id\": \"ohdsi:1294\",\n \"phenotype_name\": \"[P] Esophagectomy, adults\",\n \"justification\": \"This phenotype represents the surgical procedure itself, providing a broader context for the study.\",\n \"confidence\": 0.70\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Peripheral neuritis", + "elapsed_seconds": 13.943, + "response": { + "status": "ok", + "search": { + "query": "Patients diagnosed with peripheral neuritis", + "results": [ + { + "phenotype_id": "ohdsi:388", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Peripheral neuritis ", + "short_description": "all events of Peripheral neuritis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 1.0, + "score_dense": 1.0, + "score_sparse": 1.0, + "score_dense_raw": 0.6876607537269592, + "score_sparse_raw": 24.649660848039687 + }, + { + "phenotype_id": "ohdsi:389", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Peripheral Neuropathy or Neuritits", + "short_description": "first occurrence of peripheral neuritis or neuropathy", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8658868357910618, + "score_dense": 0.8157363553892361, + "score_sparse": 0.982904623395322, + "score_dense_raw": 0.6493250131607056, + "score_sparse_raw": 24.356634034493606 + }, + { + "phenotype_id": "ohdsi:541", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Idiopathic peripheral neuropathy", + "short_description": "all events of Idiopathic peripheral neuropathy. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.48548930419127023, + "score_dense": 0.6259766051663794, + "score_sparse": 0.1576856019160156, + "score_dense_raw": 0.6098458170890808, + "score_sparse_raw": 10.211797922750513 + }, + { + "phenotype_id": "ohdsi:236", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Idiopathic Peripheral Neuropathy", + "short_description": "all events of idiopathic peripheral neuropathy", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4702011191973093, + "score_dense": 0.6149629068737562, + "score_sparse": 0.13242361461893318, + "score_dense_raw": 0.6075544357299805, + "score_sparse_raw": 9.778789641465849 + }, + { + "phenotype_id": "cipher:30768", + "source_dataset": "va_cipher", + "name": "Peripheral Neuropathies: Excluding Cranial Nerve and Carpal Tunnel Syndromes (Kuan)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.45709114310970905, + "score_dense": 0.598628437447491, + "score_sparse": 0.12683745632155136, + "score_dense_raw": 0.6041560769081116, + "score_sparse_raw": 9.683038947948054 + }, + { + "phenotype_id": "ohdsi:238", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Optic neuritis ", + "short_description": "all events of Optic neuritis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3917194094612476, + "score_dense": 0.37199057491230053, + "score_sparse": 0.4377533567421241, + "score_dense_raw": 0.557004451751709, + "score_sparse_raw": 15.012356791470786 + }, + { + "phenotype_id": "ohdsi:540", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Optic neuritis", + "short_description": "all events of Optic neuritis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.39015234192900816, + "score_dense": 0.3693800393442548, + "score_sparse": 0.4386210479600993, + "score_dense_raw": 0.5564613342285156, + "score_sparse_raw": 15.027229630989023 + }, + { + "phenotype_id": "ohdsi:1174", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] neuropathy 10", + "short_description": "First neuropathy continues until end of observation", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3562752338759964, + "score_dense": 0.508964619822852, + "score_sparse": null, + "score_dense_raw": 0.5855016708374023, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:30167", + "source_dataset": "va_cipher", + "name": "Diabetic Neurological Complications (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.34308639938920965, + "score_dense": 0.48142507394774603, + "score_sparse": 0.020296158752624773, + "score_dense_raw": 0.5797721147537231, + "score_sparse_raw": 7.856845956730498 + }, + { + "phenotype_id": "ohdsi:623", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Motor neuropathy with multiple conduction block", + "short_description": "all events of Motor neuropathy with multiple conduction block. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.291069114874595, + "score_dense": 0.4158130212494215, + "score_sparse": null, + "score_dense_raw": 0.5661216378211975, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:657", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Paresthesia", + "short_description": "all events of Paresthesia. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2670210457240519, + "score_dense": 0.38145863674864555, + "score_sparse": null, + "score_dense_raw": 0.5589742660522461, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:2808", + "source_dataset": "va_cipher", + "name": "Hereditary and idiopathic peripheral neuropathy (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Hereditary and idiopathic peripheral neuropathy. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.46 means that patients with a MAP probability over 0.46 should be categorized as having Hereditary and idiopathic peripheral neuropathy.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.25908098750074526, + "score_dense": 0.3190544601209321, + "score_sparse": 0.11914288472030932, + "score_dense_raw": 0.5459911823272705, + "score_sparse_raw": 9.55114856319355 + }, + { + "phenotype_id": "cipher:30774", + "source_dataset": "va_cipher", + "name": "Trigeminal Neuralgia (Kuan)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.2588267373046879, + "score_dense": 0.3697524818638399, + "score_sparse": null, + "score_dense_raw": 0.5565388202667236, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:604", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Cranial nerve disorder", + "short_description": "all events of Cranial nerve disorder. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2562228484031455, + "score_dense": 0.3660326405759222, + "score_sparse": null, + "score_dense_raw": 0.55576491355896, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:3311", + "source_dataset": "va_cipher", + "name": "Other peripheral nerve disorders (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Other peripheral nerve disorders. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.23 means that patients with a MAP probability over 0.23 should be categorized as having Other peripheral nerve disorders.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.24949764243194958, + "score_dense": 0.3112474919219366, + "score_sparse": 0.1054146602886465, + "score_dense_raw": 0.5443669557571411, + "score_sparse_raw": 9.31583711297637 + }, + { + "phenotype_id": "cipher:3400", + "source_dataset": "va_cipher", + "name": "Peripheral autonomic neuropathy (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Peripheral autonomic neuropathy. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.58 means that patients with a MAP probability over 0.58 should be categorized as having Peripheral autonomic neuropathy.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.23765699808550025, + "score_dense": 0.2932175495488217, + "score_sparse": 0.10801571133775031, + "score_dense_raw": 0.5406158566474915, + "score_sparse_raw": 9.360420962657752 + }, + { + "phenotype_id": "ohdsi:1075", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] FDA AESI Narcolepsy", + "short_description": "Narcolepsy", + "tags": [ + "AESI", + "FDA", + "Study", + "Symposium", + "Covid19SubjectsAesiIncidenceRate" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.193041240989935, + "score_dense": 0.27577320141419287, + "score_sparse": null, + "score_dense_raw": 0.5369865894317627, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:550", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Chronic inflammatory demyelinating polyradiculoneuropathy", + "short_description": "all events of Chronic inflammatory demyelinating polyradiculoneuropathy. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.18543693882044301, + "score_dense": 0.2649099126006329, + "score_sparse": null, + "score_dense_raw": 0.5347265005111694, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:3113", + "source_dataset": "va_cipher", + "name": "Neuralgia, neuritis, and radiculitis NOS (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Neuralgia, neuritis, and radiculitis NOS. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.58 means that patients with a MAP probability over 0.58 should be categorized as having Neuralgia, neuritis, and radiculitis NOS.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.18092754097237138, + "score_dense": 0.10654047741114632, + "score_sparse": 0.3544973559485632, + "score_dense_raw": 0.5017780065536499, + "score_sparse_raw": 13.585290211700238 + }, + { + "phenotype_id": "ohdsi:412", + "source_dataset": "ohdsi_phenotype_library", + "name": "Transverse myelitis indexed on diagnosis (1Ps, 0Era, 365W)", + "short_description": "events with a diagnosis of transverse myelitis indexed on diagnosis of transverse myelitis. Events have a 365 days washout period. The events persist for 1 day. Symptoms of Transverse Myelitis included asthenia, muscle weakness, myelitis, paresthesia", + "tags": [ + "Accepted", + "Level2" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Accepted" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.17964817933623006, + "score_dense": 0.2566402561946144, + "score_sparse": null, + "score_dense_raw": 0.5330060124397278, + "score_sparse_raw": null + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend phenotypes for patients diagnosed with peripheral neuritis", + "intent_facets": { + "condition_or_topic": "peripheral neuritis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "reasoning_notes": [ + "The study intent explicitly states 'patients diagnosed with peripheral neuritis', indicating a diagnostic phenotype is desired." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotypes for patients diagnosed with peripheral neuritis, considering optic neuritis and peripheral neuropathy variations.", + "intent_facets": { + "condition_or_topic": "peripheral neuritis", + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified" + }, + "shortlist_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with peripheral neuritis as a diagnosis-oriented study intent.", + "Included [P][R] Peripheral neuritis as a diagnosis candidate focused on Peripheral neuritis.", + "Included [P] Peripheral Neuropathy or Neuritits as a diagnosis candidate focused on Peripheral Neuropathy.", + "Included [P][R] Optic neuritis as a diagnosis candidate focused on Optic neuritis.", + "Near-duplicate topical variants were removed to preserve distinct recommendation coverage: ohdsi:540." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8603, + "prompt_length_chars": 4973, + "recommendations": { + "plan": "Recommend phenotypes related to peripheral neuritis diagnosis and related conditions.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:388", + "phenotype_name": "[P][R] Peripheral neuritis ", + "justification": "Directly reflects the study intent of patients diagnosed with peripheral neuritis.", + "confidence": 1.0 + }, + { + "phenotype_id": "ohdsi:389", + "phenotype_name": "[P] Peripheral Neuropathy or Neuritits", + "justification": "Captures the first occurrence of peripheral neuritis or neuropathy, relevant to the study intent.", + "confidence": 0.8658868357910618 + }, + { + "phenotype_id": "ohdsi:238", + "phenotype_name": "[P][R] Optic neuritis ", + "justification": "Related to peripheral nerve involvement and aligns with the study's focus on neuritis.", + "confidence": 0.3917194094612476 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.517530679702759, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-5505aafe-dee3-4b50-a778-77bc3da979f8\",\"created\":1778090504,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to peripheral neuritis diagnosis and related conditions.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:388\\\",\\n \\\"phenotype_name\\\": \\\"Peripheral neuritis\\\",\\n \\\"justification\\\": \\\"Directly reflects the study intent of patients diagnosed with peripheral neuritis.\\\",\\n \\\"confidence\\\": 1.0\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:389\\\",\\n \\\"phenotype_name\\\": \\\"Peripheral Neuropathy or Neuritis\\\",\\n \\\"justification\\\": \\\"Captures the first occurrence of peripheral neuritis or neuropathy, relevant to the study intent.\\\",\\n \\\"confidence\\\": 0.8658868357910618\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:238\\\",\\n \\\"phenotype_name\\\": \\\"Optic neuritis\\\",\\n \\\"justification\\\": \\\"Related to peripheral nerve involvement and aligns with the study's focus on neuritis.\\\",\\n \\\"confidence\\\": 0.3917194094612476\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1369,\"output_tokens\":273,\"total_tokens\":1642,\"prompt_tokens\":1369,\"completion_tokens\":273,\"response_token/s\":102.56,\"prompt_token/s\":4196.84,\"total_duration\":4284586899,\"load_duration\":645555358,\"prompt_eval_count\":1369,\"prompt_eval_duration\":326198094,\"eval_count\":273,\"eval_duration\":2661928914,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to peripheral neuritis diagnosis and related conditions.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:388\",\n \"phenotype_name\": \"Peripheral neuritis\",\n \"justification\": \"Directly reflects the study intent of patients diagnosed with peripheral neuritis.\",\n \"confidence\": 1.0\n },\n {\n \"phenotype_id\": \"ohdsi:389\",\n \"phenotype_name\": \"Peripheral Neuropathy or Neuritis\",\n \"justification\": \"Captures the first occurrence of peripheral neuritis or neuropathy, relevant to the study intent.\",\n \"confidence\": 0.8658868357910618\n },\n {\n \"phenotype_id\": \"ohdsi:238\",\n \"phenotype_name\": \"Optic neuritis\",\n \"justification\": \"Related to peripheral nerve involvement and aligns with the study's focus on neuritis.\",\n \"confidence\": 0.3917194094612476\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.042486190795898, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-10dfac3e-a260-4304-ad1d-c9472148c17c\",\"created\":1778090495,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients diagnosed with peripheral neuritis\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"peripheral neuritis\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"not_specified\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [],\\n \\\"care_setting_cues\\\": [],\\n \\\"population_cues\\\": [],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly states 'patients diagnosed with peripheral neuritis', indicating a diagnostic phenotype is desired.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1203,\"output_tokens\":241,\"total_tokens\":1444,\"prompt_tokens\":1203,\"completion_tokens\":241,\"response_token/s\":101.24,\"prompt_token/s\":4269.96,\"total_duration\":3845889621,\"load_duration\":607084091,\"prompt_eval_count\":1203,\"prompt_eval_duration\":281735822,\"eval_count\":241,\"eval_duration\":2380375984,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients diagnosed with peripheral neuritis\",\n \"intent_facets\": {\n \"condition_or_topic\": \"peripheral neuritis\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"not_specified\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [],\n \"care_setting_cues\": [],\n \"population_cues\": [],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"The study intent explicitly states 'patients diagnosed with peripheral neuritis', indicating a diagnostic phenotype is desired.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.526796102523804, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-a7f84f4f-531d-45e7-98cf-204329eb9691\",\"created\":1778090499,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients diagnosed with peripheral neuritis, considering optic neuritis and peripheral neuropathy variations.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"peripheral neuritis\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:388\\\",\\n \\\"ohdsi:389\\\",\\n \\\"ohdsi:238\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Selected phenotypes directly related to peripheral neuritis diagnosis.\\\",\\n \\\"Included optic neuritis phenotypes due to potential overlap in symptoms and diagnosis.\\\",\\n \\\"Considered the peripheral neuropathy phenotype as a related condition.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2510,\"output_tokens\":243,\"total_tokens\":2753,\"prompt_tokens\":2510,\"completion_tokens\":243,\"response_token/s\":99.78,\"prompt_token/s\":4293.58,\"total_duration\":4312252772,\"load_duration\":695427761,\"prompt_eval_count\":2510,\"prompt_eval_duration\":584594038,\"eval_count\":243,\"eval_duration\":2435465026,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients diagnosed with peripheral neuritis, considering optic neuritis and peripheral neuropathy variations.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"peripheral neuritis\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"ohdsi:388\",\n \"ohdsi:389\",\n \"ohdsi:238\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Selected phenotypes directly related to peripheral neuritis diagnosis.\",\n \"Included optic neuritis phenotypes due to potential overlap in symptoms and diagnosis.\",\n \"Considered the peripheral neuropathy phenotype as a related condition.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "peripheral neuritis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "intent_facets_effective": { + "condition_or_topic": "peripheral neuritis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "not_specified", + "geography_coding_preference": "not_specified", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238", + "ohdsi:540", + "cipher:30768" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238", + "ohdsi:540", + "cipher:30768" + ], + "planner_input_shortlist_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238", + "ohdsi:540", + "cipher:30768" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [ + "ohdsi:540" + ], + "dedupe_backfilled_ids": [], + "dedupe_applied": true, + "enforced_shortlist_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "enforced": false + }, + "candidates": [ + { + "rank": 1, + "original_rank": 1, + "phenotype_id": "ohdsi:388", + "name": "[P][R] Peripheral neuritis ", + "metadata_score": 35.85, + "retrieval_score": 1.0, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Peripheral neuritis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Peripheral neuritis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Peripheral neuritis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 2, + "original_rank": 2, + "phenotype_id": "ohdsi:389", + "name": "[P] Peripheral Neuropathy or Neuritits", + "metadata_score": 20.08, + "retrieval_score": 0.8659, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Peripheral Neuropathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Peripheral Neuropathy" + }, + { + "kind": "topic_context", + "delta": 3.75, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Peripheral Neuropathy\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 3, + "original_rank": 6, + "phenotype_id": "ohdsi:238", + "name": "[P][R] Optic neuritis ", + "metadata_score": 20.0, + "retrieval_score": 0.3917, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Optic neuritis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Optic neuritis" + }, + { + "kind": "topic_context", + "delta": 3.75, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Optic neuritis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 4, + "original_rank": 7, + "phenotype_id": "ohdsi:540", + "name": "[P][R] Optic neuritis", + "metadata_score": 20.0, + "retrieval_score": 0.3902, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Optic neuritis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Optic neuritis" + }, + { + "kind": "topic_context", + "delta": 3.75, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Optic neuritis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 5, + "original_rank": 5, + "phenotype_id": "cipher:30768", + "name": "Peripheral Neuropathies: Excluding Cranial Nerve and Carpal Tunnel Syndromes (Kuan)", + "metadata_score": 19.8825, + "retrieval_score": 0.4571, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Peripheral Neuropathies", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Cranial Nerve", + "Carpal Tunnel" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Peripheral Neuropathies" + }, + { + "kind": "topic_context", + "delta": 3.75, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Peripheral Neuropathies\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 6, + "original_rank": 4, + "phenotype_id": "ohdsi:236", + "name": "[P] Idiopathic Peripheral Neuropathy", + "metadata_score": 19.6233, + "retrieval_score": 0.4702, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Peripheral Neuropathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Peripheral Neuropathy" + }, + { + "kind": "topic_context", + "delta": 3.3333, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Idiopathic Peripheral Neuropathy\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 7, + "original_rank": 12, + "phenotype_id": "cipher:2808", + "name": "Hereditary and idiopathic peripheral neuropathy (MAP)", + "metadata_score": 19.1125, + "retrieval_score": 0.2591, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Peripheral neuropathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "MAP algorithm" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Peripheral neuropathy" + }, + { + "kind": "topic_context", + "delta": 3.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Hereditary and idiopathic peripheral neuropathy\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 8, + "original_rank": 3, + "phenotype_id": "ohdsi:541", + "name": "[P][R] Idiopathic peripheral neuropathy", + "metadata_score": 18.31, + "retrieval_score": 0.4855, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Idiopathic peripheral neuropathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "peripheral neuropathy" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 10.6667, + "detail": "Idiopathic peripheral neuropathy" + }, + { + "kind": "topic_context", + "delta": 3.3333, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Idiopathic peripheral neuropathy\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 9, + "original_rank": 10, + "phenotype_id": "ohdsi:623", + "name": "[P][R] Motor neuropathy with multiple conduction block", + "metadata_score": -3.75, + "retrieval_score": 0.2911, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Motor neuropathy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "OHDSI cohort" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Motor neuropathy" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 10, + "original_rank": 11, + "phenotype_id": "ohdsi:657", + "name": "[P][R] Paresthesia", + "metadata_score": -3.75, + "retrieval_score": 0.267, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Paresthesia", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Cohort Study" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Paresthesia" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 11, + "original_rank": 9, + "phenotype_id": "cipher:30167", + "name": "Diabetic Neurological Complications (HDR UK)", + "metadata_score": -3.8875, + "retrieval_score": 0.3431, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Diabetic Neurological Complications", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Diabetic", + "Neurological" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Diabetic Neurological Complications" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 12, + "original_rank": 8, + "phenotype_id": "ohdsi:1174", + "name": "[P] neuropathy 10", + "metadata_score": -14.75, + "retrieval_score": 0.3563, + "phenotype_role": "outcome", + "primary_clinical_topic": "neuropathy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "prediction" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "neuropathy" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "status_prediction", + "delta": -4.0, + "detail": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "valid_unique_count": 3 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "matched_llm_ids": [ + "ohdsi:388", + "ohdsi:389", + "ohdsi:238" + ], + "defaulted_ids": [], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 3, + "used_default_justification_count": 0 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.517530679702759, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-5505aafe-dee3-4b50-a778-77bc3da979f8\",\"created\":1778090504,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to peripheral neuritis diagnosis and related conditions.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:388\\\",\\n \\\"phenotype_name\\\": \\\"Peripheral neuritis\\\",\\n \\\"justification\\\": \\\"Directly reflects the study intent of patients diagnosed with peripheral neuritis.\\\",\\n \\\"confidence\\\": 1.0\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:389\\\",\\n \\\"phenotype_name\\\": \\\"Peripheral Neuropathy or Neuritis\\\",\\n \\\"justification\\\": \\\"Captures the first occurrence of peripheral neuritis or neuropathy, relevant to the study intent.\\\",\\n \\\"confidence\\\": 0.8658868357910618\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:238\\\",\\n \\\"phenotype_name\\\": \\\"Optic neuritis\\\",\\n \\\"justification\\\": \\\"Related to peripheral nerve involvement and aligns with the study's focus on neuritis.\\\",\\n \\\"confidence\\\": 0.3917194094612476\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1369,\"output_tokens\":273,\"total_tokens\":1642,\"prompt_tokens\":1369,\"completion_tokens\":273,\"response_token/s\":102.56,\"prompt_token/s\":4196.84,\"total_duration\":4284586899,\"load_duration\":645555358,\"prompt_eval_count\":1369,\"prompt_eval_duration\":326198094,\"eval_count\":273,\"eval_duration\":2661928914,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to peripheral neuritis diagnosis and related conditions.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:388\",\n \"phenotype_name\": \"Peripheral neuritis\",\n \"justification\": \"Directly reflects the study intent of patients diagnosed with peripheral neuritis.\",\n \"confidence\": 1.0\n },\n {\n \"phenotype_id\": \"ohdsi:389\",\n \"phenotype_name\": \"Peripheral Neuropathy or Neuritis\",\n \"justification\": \"Captures the first occurrence of peripheral neuritis or neuropathy, relevant to the study intent.\",\n \"confidence\": 0.8658868357910618\n },\n {\n \"phenotype_id\": \"ohdsi:238\",\n \"phenotype_name\": \"Optic neuritis\",\n \"justification\": \"Related to peripheral nerve involvement and aligns with the study's focus on neuritis.\",\n \"confidence\": 0.3917194094612476\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "TNF-alpha + IL12/23 overlap", + "elapsed_seconds": 17.547, + "response": { + "status": "ok", + "search": { + "query": "Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days", + "results": [ + { + "phenotype_id": "ohdsi:760", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "short_description": "Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "tags": [ + "epi1073" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.9092037297452746, + "score_dense": 0.8702910424932494, + "score_sparse": 1.0, + "score_dense_raw": 0.6913614273071289, + "score_sparse_raw": 72.10960667845224 + }, + { + "phenotype_id": "ohdsi:757", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Concomitant TNF - alpha Inhibitors and IL23 Inhibitors - GE 30D overlap", + "short_description": "Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "tags": [ + "epi1073" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8825416217697113, + "score_dense": 1.0, + "score_sparse": 0.6084720725657046, + "score_dense_raw": 0.7284880876541138, + "score_sparse_raw": 49.49188363799724 + }, + { + "phenotype_id": "ohdsi:759", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "short_description": "Concomitant TNF - alpha Inhibitors and IL23 Inhibitors - GE 30D overlap", + "tags": [ + "epi1073" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8520435828710148, + "score_dense": 0.9480940936676086, + "score_sparse": 0.6279257243456294, + "score_dense_raw": 0.7136310338973999, + "score_sparse_raw": 50.6156790999637 + }, + { + "phenotype_id": "ohdsi:1057", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of IL-23 inhibitors nested in Plaque psoriasis", + "short_description": "New users of IL-23 inhibitors nested in Plaque psoriasis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.6201155028547742, + "score_dense": 0.6470340062192963, + "score_sparse": 0.5573056616708895, + "score_dense_raw": 0.6274584531784058, + "score_sparse_raw": 46.536110551319396 + }, + { + "phenotype_id": "ohdsi:1042", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of IL-23 inhibitors", + "short_description": "New users of IL-23 inhibitors", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5970513434403515, + "score_dense": 0.6893367159841496, + "score_sparse": 0.381718807504823, + "score_dense_raw": 0.6395667791366577, + "score_sparse_raw": 36.39283708651831 + }, + { + "phenotype_id": "ohdsi:1040", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.48975892646983576, + "score_dense": 0.5914607849690691, + "score_sparse": 0.2524545899716249, + "score_dense_raw": 0.6115517020225525, + "score_sparse_raw": 28.92552232722151 + }, + { + "phenotype_id": "ohdsi:1058", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Plaque psoriasis", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Plaque psoriasis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4767687180770139, + "score_dense": 0.5805138155797267, + "score_sparse": 0.2346968239040174, + "score_dense_raw": 0.608418345451355, + "score_sparse_raw": 27.89969452135768 + }, + { + "phenotype_id": "ohdsi:1059", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Psoriatic Arthritis", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Psoriatic Arthritis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.45386040067689143, + "score_dense": 0.5456731785402, + "score_sparse": 0.23963058566250475, + "score_dense_raw": 0.5984458923339844, + "score_sparse_raw": 28.18470728591911 + }, + { + "phenotype_id": "ohdsi:1068", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Ulcerative colitis", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Ulcerative colitis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4346688414509857, + "score_dense": 0.40855899783242966, + "score_sparse": 0.4955918098942835, + "score_dense_raw": 0.5591996312141418, + "score_sparse_raw": 42.9710345773272 + }, + { + "phenotype_id": "ohdsi:1069", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Crohns disease", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Crohns disease", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4198505750760132, + "score_dense": 0.5018646852212686, + "score_sparse": 0.22848431807041725, + "score_dense_raw": 0.5859065651893616, + "score_sparse_raw": 27.540811472446286 + }, + { + "phenotype_id": "ohdsi:1066", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Rheumatoid arthritis", + "short_description": "New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Rheumatoid arthritis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3265839923464564, + "score_dense": 0.39169134831453656, + "score_sparse": 0.17466682842093603, + "score_dense_raw": 0.5543715953826904, + "score_sparse_raw": 24.43189128991655 + }, + { + "phenotype_id": "ohdsi:1181", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] STEROIDS 10", + "short_description": "STEROIDS record with 60 day persistence", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2734788639507587, + "score_dense": 0.39068409135822674, + "score_sparse": null, + "score_dense_raw": 0.5540832877159119, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1187", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] acetaminophen exposure 10", + "short_description": "acetaminophen exposure with 30 day persistence window", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2588457634504279, + "score_dense": 0.33791169832019025, + "score_sparse": 0.07435858208764923, + "score_dense_raw": 0.5389782190322876, + "score_sparse_raw": 18.637300469519076 + }, + { + "phenotype_id": "ohdsi:1070", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of JAK inhibitors nested in Rheumatoid arthritis", + "short_description": "New users of JAK inhibitors nested in Rheumatoid arthritis", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.25662683926676183, + "score_dense": 0.3148741053748622, + "score_sparse": 0.12071655168119438, + "score_dense_raw": 0.5323841571807861, + "score_sparse_raw": 21.315300283876113 + }, + { + "phenotype_id": "ohdsi:1158", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Aspirin 10", + "short_description": "aspirin exposures w 0d prior obsv 30d gap", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2310529634538638, + "score_dense": 0.31749084941310673, + "score_sparse": 0.029364562882297076, + "score_dense_raw": 0.5331331491470337, + "score_sparse_raw": 16.038093124254164 + }, + { + "phenotype_id": "ohdsi:1200", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Rheumatoid Arthritis 10", + "short_description": "First Rheumatoid Arthritis record continues until end of observation", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.20883293757318333, + "score_dense": 0.2983327679616905, + "score_sparse": null, + "score_dense_raw": 0.5276495218276978, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1041", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] New users of JAK inhibitors", + "short_description": "New users of JAK inhibitors", + "tags": [ + "HowOften", + "Target", + "Study", + "Symposium" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.20116384101305534, + "score_dense": 0.21755042688162035, + "score_sparse": 0.16292847398640367, + "score_dense_raw": 0.5045271515846252, + "score_sparse_raw": 23.75379189789935 + }, + { + "phenotype_id": "ohdsi:29", + "source_dataset": "ohdsi_phenotype_library", + "name": "[W] Autoimmune condition (FP)", + "short_description": "Withdrawn by contributor before peer review. No clinical description, evaluation or discussions found. May need to be revisited in future", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Withdrawn" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.1932911522357377, + "score_dense": 0.2761302174796253, + "score_sparse": null, + "score_dense_raw": 0.5212944746017456, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1214", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Antibiotics Tetracyclines 10", + "short_description": "any Antibiotics Tetracyclines with 30 day persistence", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.191589716550441, + "score_dense": 0.258696779838868, + "score_sparse": 0.03500656887744464, + "score_dense_raw": 0.5163044929504395, + "score_sparse_raw": 16.36401962923445 + }, + { + "phenotype_id": "ohdsi:1208", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Antibiotics Oxazolidinones 10", + "short_description": "any Antibiotics Oxazolidinones with 30 day persistence", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.1909979208700961, + "score_dense": 0.2457709055796038, + "score_sparse": 0.06319428988124488, + "score_dense_raw": 0.5126047134399414, + "score_sparse_raw": 17.992363412993168 + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days", + "intent_facets": { + "condition_or_topic": "TNF-alpha inhibitor and IL-12/23 inhibitor exposure", + "clinical_topic_aliases": [], + "phenotype_role": "medication_based", + "care_setting": "any", + "population_cue": "concomitant exposure", + "validation_preference": "not_specified", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "medication", + "exposure" + ], + "care_setting_cues": [ + "any" + ], + "population_cues": [ + "concomitant" + ] + }, + "reasoning_notes": [ + "Study intent describes concurrent exposure to two specific inhibitors." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend OHDSI phenotypes related to concomitant TNF-alpha and IL-12/23 inhibitor exposure for at least 30 days, focusing on medication-based exposure.", + "intent_facets": { + "condition_or_topic": "TNF-alpha inhibitor and IL-12/23 inhibitor exposure", + "phenotype_role": "medication_based", + "care_setting": "any", + "population_cue": "concomitant exposure", + "validation_preference": "not_specified", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified" + }, + "shortlist_ids": [ + "ohdsi:1042", + "ohdsi:759", + "ohdsi:760" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with TNF-alpha inhibitor and IL-12/23 inhibitor exposure as a medication based-oriented study intent.", + "Included [P] New users of IL-23 inhibitors as a medication based candidate focused on IL-23 inhibitors.", + "Included [P] Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap as a medication based candidate focused on TNF-alpha Inhibitors, IL23 Inhibitors.", + "Included [P] Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap as a medication based candidate focused on IL-23 Inhibitors." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8951, + "prompt_length_chars": 5377, + "recommendations": { + "plan": "Recommend phenotypes for patients with concomitant TNF-alpha and IL-12/23 inhibitor exposure.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:1042", + "phenotype_name": "[P] New users of IL-23 inhibitors", + "justification": "Captures the initiation of IL-23 inhibitor exposure, related to the study's focus on inhibitor use.", + "confidence": 0.8 + }, + { + "phenotype_id": "ohdsi:759", + "phenotype_name": "[P] Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "justification": "This phenotype directly addresses the specified exposure (TNF-alpha and IL-23 inhibitors) and the duration (30 days) aligning with the study intent.", + "confidence": 0.95 + }, + { + "phenotype_id": "ohdsi:760", + "phenotype_name": "[P] Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "justification": "This phenotype captures concomitant use of IL-23 and IL-12/23 inhibitors, a key component of the study's focus.", + "confidence": 0.9 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 5.3984034061431885, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-e30d1f25-3095-40ec-820f-f4dc32f15499\",\"created\":1778090521,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with concomitant TNF-alpha and IL-12/23 inhibitor exposure.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:759\\\",\\n \\\"phenotype_name\\\": \\\"Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap\\\",\\n \\\"justification\\\": \\\"This phenotype directly addresses the specified exposure (TNF-alpha and IL-23 inhibitors) and the duration (30 days) aligning with the study intent.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:760\\\",\\n \\\"phenotype_name\\\": \\\"Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap\\\",\\n \\\"justification\\\": \\\"This phenotype captures concomitant use of IL-23 and IL-12/23 inhibitors, a key component of the study's focus.\\\",\\n \\\"confidence\\\": 0.9\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1042\\\",\\n \\\"phenotype_name\\\": \\\"New users of IL-23 inhibitors\\\",\\n \\\"justification\\\": \\\"Captures the initiation of IL-23 inhibitor exposure, related to the study's focus on inhibitor use.\\\",\\n \\\"confidence\\\": 0.8\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1564,\"output_tokens\":339,\"total_tokens\":1903,\"prompt_tokens\":1564,\"completion_tokens\":339,\"response_token/s\":103.03,\"prompt_token/s\":4311.77,\"total_duration\":5122670983,\"load_duration\":699021468,\"prompt_eval_count\":1564,\"prompt_eval_duration\":362728142,\"eval_count\":339,\"eval_duration\":3290461622,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with concomitant TNF-alpha and IL-12/23 inhibitor exposure.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:759\",\n \"phenotype_name\": \"Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap\",\n \"justification\": \"This phenotype directly addresses the specified exposure (TNF-alpha and IL-23 inhibitors) and the duration (30 days) aligning with the study intent.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"ohdsi:760\",\n \"phenotype_name\": \"Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap\",\n \"justification\": \"This phenotype captures concomitant use of IL-23 and IL-12/23 inhibitors, a key component of the study's focus.\",\n \"confidence\": 0.9\n },\n {\n \"phenotype_id\": \"ohdsi:1042\",\n \"phenotype_name\": \"New users of IL-23 inhibitors\",\n \"justification\": \"Captures the initiation of IL-23 inhibitor exposure, related to the study's focus on inhibitor use.\",\n \"confidence\": 0.8\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 5.860224008560181, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-79e1a9e4-9a9c-4608-ae4d-b3a9747a14a6\",\"created\":1778090510,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"TNF-alpha inhibitor and IL-12/23 inhibitor exposure\\\",\\n \\\"clinical_topic_aliases\\\": [\\n \\\"TNF inhibitors\\\",\\n \\\"IL-12/23 inhibitors\\\"\\n ],\\n \\\"phenotype_role\\\": \\\"medication_based\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"concomitant exposure\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"medication\\\",\\n \\\"exposure\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"any\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"concomitant\\\"\\n ],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"Study intent describes concurrent exposure to two specific inhibitors.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\\\",\\n \\\"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\\\",\\n []\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1222,\"output_tokens\":357,\"total_tokens\":1579,\"prompt_tokens\":1222,\"completion_tokens\":357,\"response_token/s\":101.0,\"prompt_token/s\":3785.63,\"total_duration\":5303417075,\"load_duration\":601830131,\"prompt_eval_count\":1222,\"prompt_eval_duration\":322799473,\"eval_count\":357,\"eval_duration\":3534495266,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\",\n \"intent_facets\": {\n \"condition_or_topic\": \"TNF-alpha inhibitor and IL-12/23 inhibitor exposure\",\n \"clinical_topic_aliases\": [\n \"TNF inhibitors\",\n \"IL-12/23 inhibitors\"\n ],\n \"phenotype_role\": \"medication_based\",\n \"care_setting\": \"any\",\n \"population_cue\": \"concomitant exposure\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"medication\",\n \"exposure\"\n ],\n \"care_setting_cues\": [\n \"any\"\n ],\n \"population_cues\": [\n \"concomitant\"\n ],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"Study intent describes concurrent exposure to two specific inhibitors.\"\n ],\n \"required\": [\n \"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\",\n \"Patients with concomitant TNF-alpha inhibitor and IL-12/23 inhibitor exposure for at least 30 days\",\n []\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 5.397253036499023, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-694377f0-130d-4032-b598-e60fbd930244\",\"created\":1778090516,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend OHDSI phenotypes related to concomitant TNF-alpha and IL-12/23 inhibitor exposure for at least 30 days, focusing on medication-based exposure.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"TNF-alpha inhibitor and IL-12/23 inhibitor exposure\\\",\\n \\\"phenotype_role\\\": \\\"medication_based\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"concomitant exposure\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:759\\\",\\n \\\"ohdsi:1042\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Phenotype ohdsi:759 (Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap) closely aligns with the study intent of assessing concomitant drug use. Phenotype ohdsi:1042 (New users of IL-23 inhibitors) captures the initiation of IL-23 inhibitor exposure, which is relevant to the exposure timeframe.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2774,\"output_tokens\":314,\"total_tokens\":3088,\"prompt_tokens\":2774,\"completion_tokens\":314,\"response_token/s\":100.73,\"prompt_token/s\":4370.09,\"total_duration\":5161197720,\"load_duration\":644138245,\"prompt_eval_count\":2774,\"prompt_eval_duration\":634769080,\"eval_count\":314,\"eval_duration\":3117243723,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend OHDSI phenotypes related to concomitant TNF-alpha and IL-12/23 inhibitor exposure for at least 30 days, focusing on medication-based exposure.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"TNF-alpha inhibitor and IL-12/23 inhibitor exposure\",\n \"phenotype_role\": \"medication_based\",\n \"care_setting\": \"any\",\n \"population_cue\": \"concomitant exposure\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"ohdsi:759\",\n \"ohdsi:1042\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Phenotype ohdsi:759 (Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap) closely aligns with the study intent of assessing concomitant drug use. Phenotype ohdsi:1042 (New users of IL-23 inhibitors) captures the initiation of IL-23 inhibitor exposure, which is relevant to the exposure timeframe.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "TNF-alpha inhibitor and IL-12/23 inhibitor exposure", + "clinical_topic_aliases": [], + "phenotype_role": "medication_based", + "care_setting": "any", + "population_cue": "concomitant exposure", + "validation_preference": "not_specified", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "medication", + "exposure" + ], + "care_setting_cues": [ + "any" + ], + "population_cues": [ + "concomitant" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "TNF-alpha inhibitor and IL-12/23 inhibitor exposure", + "clinical_topic_aliases": [], + "phenotype_role": "medication_based", + "care_setting": "any", + "population_cue": "concomitant exposure", + "validation_preference": "not_specified", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "medication", + "exposure" + ], + "care_setting_cues": [ + "any" + ], + "population_cues": [ + "concomitant" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:760", + "ohdsi:1042", + "ohdsi:1040", + "ohdsi:759", + "ohdsi:1069" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:760", + "ohdsi:1042", + "ohdsi:1040", + "ohdsi:759", + "ohdsi:1069" + ], + "planner_input_shortlist_ids": [ + "ohdsi:759", + "ohdsi:1042" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:760", + "ohdsi:1042", + "ohdsi:1040", + "ohdsi:759", + "ohdsi:1069" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "ohdsi:1042", + "ohdsi:759", + "ohdsi:760" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 1, + "phenotype_id": "ohdsi:760", + "name": "[P] Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "metadata_score": 22.1833, + "retrieval_score": 0.9092, + "phenotype_role": "medication_based", + "primary_clinical_topic": "IL-23 Inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "SARS-CoV-2" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "IL-23 Inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 2, + "original_rank": 5, + "phenotype_id": "ohdsi:1042", + "name": "[P] New users of IL-23 inhibitors", + "metadata_score": 22.1033, + "retrieval_score": 0.5971, + "phenotype_role": "medication_based", + "primary_clinical_topic": "IL-23 inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "IL-23 inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] New users of IL-23 inhibitors" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 3, + "original_rank": 6, + "phenotype_id": "ohdsi:1040", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors", + "metadata_score": 22.0833, + "retrieval_score": 0.4898, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF alpha inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "SARS-CoV-2" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "TNF alpha inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 4, + "original_rank": 3, + "phenotype_id": "ohdsi:759", + "name": "[P] Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap", + "metadata_score": 18.81, + "retrieval_score": 0.852, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF-alpha Inhibitors, IL23 Inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "study context" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 8.0, + "detail": "TNF-alpha Inhibitors, IL23 Inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap" + }, + { + "kind": "exclude_context", + "delta": -2.0, + "detail": [ + "study context" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 5, + "original_rank": 10, + "phenotype_id": "ohdsi:1069", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Crohns disease", + "metadata_score": 17.5, + "retrieval_score": 0.4199, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Complication due to Crohn's disease" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 6.0, + "detail": "TNF inhibitors" + }, + { + "kind": "topic_context", + "delta": 1.25, + "detail": "{\"context_conditions\": [\"Crohns disease\"], \"target_conditions\": [\"TNF inhibitors\"]}" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Crohns disease" + }, + { + "kind": "exclude_non_diagnosis", + "delta": -2.5, + "detail": [ + "Complication due to Crohn's disease" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 6, + "original_rank": 11, + "phenotype_id": "ohdsi:1066", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Rheumatoid arthritis", + "metadata_score": 16.0833, + "retrieval_score": 0.3266, + "phenotype_role": "medication_based", + "primary_clinical_topic": "Tumor Necrosis Factor alpha (TNFa) inhibitors", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cutaneous atrophy due to rheumatoid arthritis", + "Deformity of foot due to rheumatoid arthritis", + "Deformity of hand due to rheumatoid arthritis", + "Disease prognosis for rheumatoid arthritis assessed, good prognosis documented (RA)", + "Disease prognosis for rheumatoid arthritis assessed, poor prognosis documented (RA)", + "Myopathy due to rheumatoid arthritis", + "Patient receiving first-time biologic disease modifying anti-rheumatic drug therapy for rheumatoid arthritis (RA)", + "Polyneuropathy in rheumatoid arthritis" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 3.3333, + "detail": "Tumor Necrosis Factor alpha (TNFa) inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Rheumatoid arthritis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 7, + "original_rank": 9, + "phenotype_id": "ohdsi:1068", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Ulcerative colitis", + "metadata_score": 14.625, + "retrieval_score": 0.4347, + "phenotype_role": "medication_based", + "primary_clinical_topic": "Tumor Necrosis Factor alpha (TNFa) inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Complication due to ulcerative colitis", + "Rectal hemorrhage due to ulcerative colitis" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 3.3333, + "detail": "Tumor Necrosis Factor alpha (TNFa) inhibitors" + }, + { + "kind": "topic_context", + "delta": 1.0417, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Tumor Necrosis Factor alpha (TNFa) inhibitors\"]}" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Ulcerative colitis" + }, + { + "kind": "exclude_non_diagnosis", + "delta": -2.5, + "detail": [ + "Complication due to ulcerative colitis", + "Rectal hemorrhage due to ulcerative colitis" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 8, + "original_rank": 4, + "phenotype_id": "ohdsi:1057", + "name": "[P] New users of IL-23 inhibitors nested in Plaque psoriasis", + "metadata_score": 13.6233, + "retrieval_score": 0.6201, + "phenotype_role": "medication_based", + "primary_clinical_topic": "IL-23 inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "IL-23 inhibitors study" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "IL-23 inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "missing_medication_evidence", + "delta": -4.0, + "detail": "[P] New users of IL-23 inhibitors nested in Plaque psoriasis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 9, + "original_rank": 8, + "phenotype_id": "ohdsi:1059", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Psoriatic Arthritis", + "metadata_score": 13.5833, + "retrieval_score": 0.4539, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF alpha inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Rheumatoid arthritis" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "TNF alpha inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "missing_medication_evidence", + "delta": -4.0, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Psoriatic Arthritis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 10, + "original_rank": 2, + "phenotype_id": "ohdsi:757", + "name": "[P] Concomitant TNF - alpha Inhibitors and IL23 Inhibitors - GE 30D overlap", + "metadata_score": 12.33, + "retrieval_score": 0.8825, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF-alpha Inhibitors, IL23 Inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "SARS-CoV-2" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 8.0, + "detail": "TNF-alpha Inhibitors, IL23 Inhibitors" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "missing_medication_evidence", + "delta": -4.0, + "detail": "[P] Concomitant TNF - alpha Inhibitors and IL23 Inhibitors - GE 30D overlap" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 11, + "original_rank": 7, + "phenotype_id": "ohdsi:1058", + "name": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Plaque psoriasis", + "metadata_score": 11.375, + "retrieval_score": 0.4768, + "phenotype_role": "medication_based", + "primary_clinical_topic": "TNF inhibitors", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Psoriasis", + "RxNorm", + "SNOMED" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 6.0, + "detail": "TNF inhibitors" + }, + { + "kind": "topic_context", + "delta": 1.125, + "detail": "{\"context_conditions\": [\"Plaque psoriasis\", \"Psoriasis vulgaris\"], \"target_conditions\": [\"TNF inhibitors\"]}" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "missing_medication_evidence", + "delta": -4.0, + "detail": "[P] New users of Tumor Necrosis Factor alpha (TNFa) inhibitors nested in Plaque psoriasis" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 12, + "original_rank": 12, + "phenotype_id": "ohdsi:1181", + "name": "[P] STEROIDS 10", + "metadata_score": 0.75, + "retrieval_score": 0.2735, + "phenotype_role": "medication_based", + "primary_clinical_topic": "Steroids", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Steroids" + }, + { + "kind": "role_match_medication", + "delta": 8.0, + "detail": "medication_based" + }, + { + "kind": "medication_evidence", + "delta": 4.5, + "detail": "[P] STEROIDS 10" + }, + { + "kind": "status_prediction", + "delta": -4.0, + "detail": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:1042", + "ohdsi:759", + "ohdsi:760" + ], + "valid_unique_count": 3 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:1042", + "ohdsi:759", + "ohdsi:760" + ], + "matched_llm_ids": [ + "ohdsi:1042", + "ohdsi:759", + "ohdsi:760" + ], + "defaulted_ids": [], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 3, + "used_default_justification_count": 0 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 5.3984034061431885, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-e30d1f25-3095-40ec-820f-f4dc32f15499\",\"created\":1778090521,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with concomitant TNF-alpha and IL-12/23 inhibitor exposure.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:759\\\",\\n \\\"phenotype_name\\\": \\\"Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap\\\",\\n \\\"justification\\\": \\\"This phenotype directly addresses the specified exposure (TNF-alpha and IL-23 inhibitors) and the duration (30 days) aligning with the study intent.\\\",\\n \\\"confidence\\\": 0.95\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:760\\\",\\n \\\"phenotype_name\\\": \\\"Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap\\\",\\n \\\"justification\\\": \\\"This phenotype captures concomitant use of IL-23 and IL-12/23 inhibitors, a key component of the study's focus.\\\",\\n \\\"confidence\\\": 0.9\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1042\\\",\\n \\\"phenotype_name\\\": \\\"New users of IL-23 inhibitors\\\",\\n \\\"justification\\\": \\\"Captures the initiation of IL-23 inhibitor exposure, related to the study's focus on inhibitor use.\\\",\\n \\\"confidence\\\": 0.8\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1564,\"output_tokens\":339,\"total_tokens\":1903,\"prompt_tokens\":1564,\"completion_tokens\":339,\"response_token/s\":103.03,\"prompt_token/s\":4311.77,\"total_duration\":5122670983,\"load_duration\":699021468,\"prompt_eval_count\":1564,\"prompt_eval_duration\":362728142,\"eval_count\":339,\"eval_duration\":3290461622,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with concomitant TNF-alpha and IL-12/23 inhibitor exposure.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:759\",\n \"phenotype_name\": \"Concomitant TNF - alpha Inhibitors and IL12_23 Inhibitors - GE 30D overlap\",\n \"justification\": \"This phenotype directly addresses the specified exposure (TNF-alpha and IL-23 inhibitors) and the duration (30 days) aligning with the study intent.\",\n \"confidence\": 0.95\n },\n {\n \"phenotype_id\": \"ohdsi:760\",\n \"phenotype_name\": \"Concomitant IL 23 Inhibitors and IL12_23 Inhibitors - GE 30D overlap\",\n \"justification\": \"This phenotype captures concomitant use of IL-23 and IL-12/23 inhibitors, a key component of the study's focus.\",\n \"confidence\": 0.9\n },\n {\n \"phenotype_id\": \"ohdsi:1042\",\n \"phenotype_name\": \"New users of IL-23 inhibitors\",\n \"justification\": \"Captures the initiation of IL-23 inhibitor exposure, related to the study's focus on inhibitor use.\",\n \"confidence\": 0.8\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Allergic rhinitis", + "elapsed_seconds": 13.268, + "response": { + "status": "ok", + "search": { + "query": "Patients with allergic rhinitis", + "results": [ + { + "phenotype_id": "ohdsi:508", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Allergic rhinitis", + "short_description": "all events of Allergic rhinitis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.9980207886905605, + "score_dense": 1.0, + "score_sparse": 0.9934026289685353, + "score_dense_raw": 0.6152055263519287, + "score_sparse_raw": 28.411111102801126 + }, + { + "phenotype_id": "ohdsi:367", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Allergic Rhinitis", + "short_description": "All events of Allergic rhinitis", + "tags": [ + "respiratory" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8959445404627084, + "score_dense": 0.851349343518155, + "score_sparse": 1.0, + "score_dense_raw": 0.5862536430358887, + "score_sparse_raw": 28.59435808110568 + }, + { + "phenotype_id": "cipher:30258", + "source_dataset": "va_cipher", + "name": "Allergic and Chronic Rhinitis (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.8833163557566135, + "score_dense": 0.8519846707415135, + "score_sparse": 0.9564236207918467, + "score_dense_raw": 0.5863773822784424, + "score_sparse_raw": 27.383991136869327 + }, + { + "phenotype_id": "ohdsi:12", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Rhinitis or common cold or Sinusitis", + "short_description": "All events of Rhinitis or common cold", + "tags": [ + "Symptoms" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7437283456197479, + "score_dense": 0.7074091671046783, + "score_sparse": 0.8284730954882437, + "score_dense_raw": 0.5582191944122314, + "score_sparse_raw": 23.830068366338125 + }, + { + "phenotype_id": "ohdsi:369", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Allergic condition ", + "short_description": "all events of Allergic condition. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.6879008836001497, + "score_dense": 0.8050989906223437, + "score_sparse": 0.4144386338816972, + "score_dense_raw": 0.5772457122802734, + "score_sparse_raw": 12.329947539192952 + }, + { + "phenotype_id": "cipher:2081", + "source_dataset": "va_cipher", + "name": "Allergic rhinitis (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Allergic rhinitis. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.4 means that patients with a MAP probability over 0.4 should be categorized as having Allergic rhinitis.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6164008996764908, + "score_dense": 0.47960243083050824, + "score_sparse": 0.9355973269837836, + "score_dense_raw": 0.5138505101203918, + "score_sparse_raw": 26.80552504917938 + }, + { + "phenotype_id": "ohdsi:370", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Allergic disorder", + "short_description": "All events of Allergic Disorder", + "tags": [ + "symptoms" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5747103232021029, + "score_dense": 0.6274200619260423, + "score_sparse": 0.4517209328462448, + "score_dense_raw": 0.5426401495933533, + "score_sparse_raw": 13.365491636518565 + }, + { + "phenotype_id": "cipher:13411", + "source_dataset": "va_cipher", + "name": "Allergic Rhinitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5729555218527974, + "score_dense": 0.4584395463653453, + "score_sparse": 0.8401594646568524, + "score_dense_raw": 0.509728729724884, + "score_sparse_raw": 24.154666123124223 + }, + { + "phenotype_id": "ohdsi:354", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Nasal Congestion or Rhinitis or Common Cold", + "short_description": "all events of Nasal Congestion or Rhinitis or Common Cold", + "tags": [ + "Symptoms" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5446720691559281, + "score_dense": 0.5488561585196509, + "score_sparse": 0.5349091939739085, + "score_dense_raw": 0.5273386836051941, + "score_sparse_raw": 15.676108609453914 + }, + { + "phenotype_id": "cipher:15246", + "source_dataset": "va_cipher", + "name": "Allergic rhinitis (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5315376907755175, + "score_dense": 0.4025746666330029, + "score_sparse": 0.8324514137747184, + "score_dense_raw": 0.49884822964668274, + "score_sparse_raw": 23.9405691705605 + }, + { + "phenotype_id": "ohdsi:1077", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] FDA AESI Anaphylaxis v2", + "short_description": "Anaphylaxis v2", + "tags": [ + "AESI", + "FDA", + "Study", + "Symposium", + "Covid19", + "Covid19SubjectsAesiIncidenceRate" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3733926261137607, + "score_dense": 0.4188117758941366, + "score_sparse": 0.2674146099595501, + "score_dense_raw": 0.5020106434822083, + "score_sparse_raw": 8.246243895208549 + }, + { + "phenotype_id": "cipher:30143", + "source_dataset": "va_cipher", + "name": "Hypertrophy of Nasal Turbinates (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes, Med Codes, and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.34998214213143636, + "score_dense": 0.2956381695169937, + "score_sparse": 0.47678474489846934, + "score_dense_raw": 0.4780207872390747, + "score_sparse_raw": 14.061658003615197 + }, + { + "phenotype_id": "cipher:2458", + "source_dataset": "va_cipher", + "name": "Contact and allergic dermatitis of eyelid (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Contact and allergic dermatitis of eyelid. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.28 means that patients with a MAP probability over 0.28 should be categorized as having Contact and allergic dermatitis of eyelid.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3306809230176392, + "score_dense": 0.3006889597358312, + "score_sparse": 0.4006621706751912, + "score_dense_raw": 0.47900450229644775, + "score_sparse_raw": 11.947295837132232 + }, + { + "phenotype_id": "cipher:31165", + "source_dataset": "va_cipher", + "name": "Sinusitis (Palin)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.326667523562729, + "score_dense": 0.4666678908038986, + "score_sparse": null, + "score_dense_raw": 0.51133131980896, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1164", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Asthma 10", + "short_description": "First Asthma continues until end of observation", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3218663349254768, + "score_dense": 0.4598090498935383, + "score_sparse": null, + "score_dense_raw": 0.5099954605102539, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:241", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Urticaria ", + "short_description": "all events of Urticaria. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3204666369291651, + "score_dense": 0.34378134460763726, + "score_sparse": 0.26606565234606333, + "score_dense_raw": 0.48739737272262573, + "score_sparse_raw": 8.208775575577375 + }, + { + "phenotype_id": "cipher:29752", + "source_dataset": "va_cipher", + "name": "Anaphylaxis (HDR UK)", + "short_description": "ICD and Read Codes Based Algorithm", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3059309730590214, + "score_dense": 0.27511872218435046, + "score_sparse": 0.37782622509992037, + "score_dense_raw": 0.47402432560920715, + "score_sparse_raw": 11.313010149175929 + }, + { + "phenotype_id": "ohdsi:1075", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] FDA AESI Narcolepsy", + "short_description": "Narcolepsy", + "tags": [ + "AESI", + "FDA", + "Study", + "Symposium", + "Covid19SubjectsAesiIncidenceRate" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.30300042949856154, + "score_dense": 0.43271583260841456, + "score_sparse": 0.0003311555755711489, + "score_dense_raw": 0.5047186613082886, + "score_sparse_raw": 0.8277986179024117 + }, + { + "phenotype_id": "cipher:29553", + "source_dataset": "va_cipher", + "name": "Sleep Apnea (HDR UK)", + "short_description": "Read code, ICD code, and medication based algorithm", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.29561296759173183, + "score_dense": 0.4223042394167598, + "score_sparse": null, + "score_dense_raw": 0.502690851688385, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:13164", + "source_dataset": "va_cipher", + "name": "Allergic Conjunctivitis (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.28595844924628555, + "score_dense": 0.25079924684956806, + "score_sparse": 0.36799658817195974, + "score_dense_raw": 0.4692877531051636, + "score_sparse_raw": 11.039984536960253 + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend phenotypes for patients with allergic rhinitis.", + "intent_facets": { + "condition_or_topic": "allergic rhinitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "outpatient", + "population_cue": "any", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "us_omop", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "reasoning_notes": [ + "The study intent explicitly mentions 'allergic rhinitis' as the primary condition." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend relevant Allergic Rhinitis phenotypes from OHDSI and VA Cipher libraries for cohort definition.", + "intent_facets": { + "condition_or_topic": "allergic rhinitis", + "phenotype_role": "diagnosis", + "care_setting": "outpatient", + "population_cue": "any", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "us_omop" + }, + "shortlist_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081", + "cipher:30258" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with allergic rhinitis as a diagnosis-oriented study intent.", + "Included [P][R] Allergic rhinitis as a diagnosis candidate focused on Allergic rhinitis.", + "Included [P] Allergic Rhinitis as a diagnosis candidate focused on Allergic Rhinitis.", + "Included Allergic rhinitis (MAP) as a diagnosis candidate focused on Allergic Rhinitis." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 4, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8895, + "prompt_length_chars": 5896, + "recommendations": { + "plan": "Recommend phenotypes for patients with allergic rhinitis.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:508", + "phenotype_name": "[P][R] Allergic rhinitis", + "justification": "This phenotype represents the diagnosis of Allergic rhinitis, aligning directly with the study intent and is a primary clinical topic.", + "confidence": 0.998 + }, + { + "phenotype_id": "ohdsi:367", + "phenotype_name": "[P] Allergic Rhinitis", + "justification": "This phenotype represents events of Allergic Rhinitis, which is relevant to the study intent.", + "confidence": 0.895 + }, + { + "phenotype_id": "cipher:2081", + "phenotype_name": "Allergic rhinitis (MAP)", + "justification": "Selected from the top reranked shortlisted candidates as a clinically aligned diagnosis match.", + "confidence": null + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 3.681623697280884, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-275228ca-da72-415f-b14d-0760cad96dd3\",\"created\":1778090535,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with allergic rhinitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:508\\\",\\n \\\"phenotype_name\\\": \\\"Allergic rhinitis\\\",\\n \\\"justification\\\": \\\"This phenotype represents the diagnosis of Allergic rhinitis, aligning directly with the study intent and is a primary clinical topic.\\\",\\n \\\"confidence\\\": 0.998\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:367\\\",\\n \\\"phenotype_name\\\": \\\"Allergic Rhinitis\\\",\\n \\\"justification\\\": \\\"This phenotype represents events of Allergic Rhinitis, which is relevant to the study intent.\\\",\\n \\\"confidence\\\": 0.895\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1603,\"output_tokens\":189,\"total_tokens\":1792,\"prompt_tokens\":1603,\"completion_tokens\":189,\"response_token/s\":99.58,\"prompt_token/s\":4126.83,\"total_duration\":3417691677,\"load_duration\":649573389,\"prompt_eval_count\":1603,\"prompt_eval_duration\":388433500,\"eval_count\":189,\"eval_duration\":1897959759,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with allergic rhinitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:508\",\n \"phenotype_name\": \"Allergic rhinitis\",\n \"justification\": \"This phenotype represents the diagnosis of Allergic rhinitis, aligning directly with the study intent and is a primary clinical topic.\",\n \"confidence\": 0.998\n },\n {\n \"phenotype_id\": \"ohdsi:367\",\n \"phenotype_name\": \"Allergic Rhinitis\",\n \"justification\": \"This phenotype represents events of Allergic Rhinitis, which is relevant to the study intent.\",\n \"confidence\": 0.895\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.038694381713867, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-c94ad4d2-4a91-422a-b326-a02b6f35bd85\",\"created\":1778090526,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with allergic rhinitis.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"allergic rhinitis\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"outpatient\\\",\\n \\\"population_cue\\\": \\\"any\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\",\\n \\\"role_cues\\\": [],\\n \\\"care_setting_cues\\\": [],\\n \\\"population_cues\\\": [],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly mentions 'allergic rhinitis' as the primary condition.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1203,\"output_tokens\":240,\"total_tokens\":1443,\"prompt_tokens\":1203,\"completion_tokens\":240,\"response_token/s\":102.28,\"prompt_token/s\":4054.82,\"total_duration\":3908847455,\"load_duration\":650174278,\"prompt_eval_count\":1203,\"prompt_eval_duration\":296683758,\"eval_count\":240,\"eval_duration\":2346542766,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with allergic rhinitis.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"allergic rhinitis\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"outpatient\",\n \"population_cue\": \"any\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"us_omop\",\n \"role_cues\": [],\n \"care_setting_cues\": [],\n \"population_cues\": [],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"The study intent explicitly mentions 'allergic rhinitis' as the primary condition.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.734292030334473, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-45a0432f-f93a-469b-938f-e53cd272937e\",\"created\":1778090531,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend relevant Allergic Rhinitis phenotypes from OHDSI and VA Cipher libraries for cohort definition.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"allergic rhinitis\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"outpatient\\\",\\n \\\"population_cue\\\": \\\"any\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:508\\\",\\n \\\"ohdsi:367\\\",\\n \\\"cipher:2081\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Selected ohdsi:508 and ohdsi:367 as primary diagnosis phenotypes for Allergic Rhinitis. VA Cipher phenotypes included as supplementary options reflecting different classification methods.\\\",\\n \\\"Considering the study intent, diagnostic phenotypes are prioritized.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2668,\"output_tokens\":254,\"total_tokens\":2922,\"prompt_tokens\":2668,\"completion_tokens\":254,\"response_token/s\":97.97,\"prompt_token/s\":4295.94,\"total_duration\":4487049960,\"load_duration\":629683508,\"prompt_eval_count\":2668,\"prompt_eval_duration\":621051854,\"eval_count\":254,\"eval_duration\":2592594043,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend relevant Allergic Rhinitis phenotypes from OHDSI and VA Cipher libraries for cohort definition.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"allergic rhinitis\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"outpatient\",\n \"population_cue\": \"any\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"us_omop\"\n },\n \"shortlist_ids\": [\n \"ohdsi:508\",\n \"ohdsi:367\",\n \"cipher:2081\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Selected ohdsi:508 and ohdsi:367 as primary diagnosis phenotypes for Allergic Rhinitis. VA Cipher phenotypes included as supplementary options reflecting different classification methods.\",\n \"Considering the study intent, diagnostic phenotypes are prioritized.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "allergic rhinitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "outpatient", + "population_cue": "any", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "us_omop", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "intent_facets_effective": { + "condition_or_topic": "allergic rhinitis", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "outpatient", + "population_cue": "any", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "us_omop", + "role_cues": [], + "care_setting_cues": [], + "population_cues": [] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081", + "cipher:30258", + "cipher:13411" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081", + "cipher:30258", + "cipher:13411" + ], + "planner_input_shortlist_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081", + "cipher:30258", + "cipher:13411" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081", + "cipher:30258" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 1, + "phenotype_id": "ohdsi:508", + "name": "[P][R] Allergic rhinitis", + "metadata_score": 34.35, + "retrieval_score": 0.998, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Influenza" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Allergic rhinitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Allergic rhinitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 2, + "original_rank": 2, + "phenotype_id": "ohdsi:367", + "name": "[P] Allergic Rhinitis", + "metadata_score": 34.33, + "retrieval_score": 0.8959, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Respiratory", + "Allergic disorder" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Allergic Rhinitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Allergic Rhinitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 3, + "original_rank": 6, + "phenotype_id": "cipher:2081", + "name": "Allergic rhinitis (MAP)", + "metadata_score": 34.1125, + "retrieval_score": 0.6164, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Influenza" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Allergic Rhinitis" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Allergic Rhinitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 4, + "original_rank": 3, + "phenotype_id": "cipher:30258", + "name": "Allergic and Chronic Rhinitis (HDR UK)", + "metadata_score": 28.6725, + "retrieval_score": 0.8833, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic and Chronic Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Respiratory Distress" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 20.0, + "detail": "Allergic and Chronic Rhinitis" + }, + { + "kind": "topic_context", + "delta": 6.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Allergic and chronic rhinitis diagnosis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 5, + "original_rank": 8, + "phenotype_id": "cipher:13411", + "name": "Allergic Rhinitis (Phecode)", + "metadata_score": 26.6125, + "retrieval_score": 0.573, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Allergic Rhinitis" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 6, + "original_rank": 4, + "phenotype_id": "ohdsi:12", + "name": "[P] Rhinitis or common cold or Sinusitis", + "metadata_score": 21.7067, + "retrieval_score": 0.7437, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Upper respiratory infection" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Rhinitis" + }, + { + "kind": "topic_context", + "delta": 2.9167, + "detail": "{\"context_conditions\": [\"Common cold\", \"Sinusitis\", \"Respiratory Symptoms\"], \"target_conditions\": [\"Rhinitis\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 7, + "original_rank": 10, + "phenotype_id": "cipher:15246", + "name": "Allergic rhinitis (gwPheWAS)", + "metadata_score": 19.1125, + "retrieval_score": 0.5315, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Allergic Rhinitis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Allergic Rhinitis" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 8, + "original_rank": 7, + "phenotype_id": "ohdsi:370", + "name": "[P] Allergic disorder", + "metadata_score": 14.75, + "retrieval_score": 0.5747, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Allergic Disorder", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Allergic Disorder" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 9, + "original_rank": 5, + "phenotype_id": "ohdsi:369", + "name": "[P][R] Allergic condition ", + "metadata_score": 7.27, + "retrieval_score": 0.6879, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Allergic condition", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 12.0, + "detail": "Allergic condition" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 10, + "original_rank": 9, + "phenotype_id": "ohdsi:354", + "name": "[P] Nasal Congestion or Rhinitis or Common Cold", + "metadata_score": 5.0833, + "retrieval_score": 0.5447, + "phenotype_role": "outcome", + "primary_clinical_topic": "Nasal Congestion or Rhinitis or Common Cold", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Respiratory" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "Nasal Congestion or Rhinitis or Common Cold" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 11, + "original_rank": 12, + "phenotype_id": "cipher:30143", + "name": "Hypertrophy of Nasal Turbinates (HDR UK)", + "metadata_score": -5.3875, + "retrieval_score": 0.35, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Nasal Turbinate Hypertrophy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "HDR UK", + "CPRD GOLD" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Nasal Turbinate Hypertrophy" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 12, + "original_rank": 11, + "phenotype_id": "ohdsi:1077", + "name": "[P] FDA AESI Anaphylaxis v2", + "metadata_score": -12.25, + "retrieval_score": 0.3734, + "phenotype_role": "outcome", + "primary_clinical_topic": "Anaphylaxis", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Covid19 Anaphylaxis" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Anaphylaxis" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081" + ], + "valid_unique_count": 2 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:508", + "ohdsi:367", + "cipher:2081" + ], + "matched_llm_ids": [ + "ohdsi:508", + "ohdsi:367" + ], + "defaulted_ids": [ + "cipher:2081" + ], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 2, + "used_default_justification_count": 1 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 3.681623697280884, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-275228ca-da72-415f-b14d-0760cad96dd3\",\"created\":1778090535,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with allergic rhinitis.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:508\\\",\\n \\\"phenotype_name\\\": \\\"Allergic rhinitis\\\",\\n \\\"justification\\\": \\\"This phenotype represents the diagnosis of Allergic rhinitis, aligning directly with the study intent and is a primary clinical topic.\\\",\\n \\\"confidence\\\": 0.998\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:367\\\",\\n \\\"phenotype_name\\\": \\\"Allergic Rhinitis\\\",\\n \\\"justification\\\": \\\"This phenotype represents events of Allergic Rhinitis, which is relevant to the study intent.\\\",\\n \\\"confidence\\\": 0.895\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1603,\"output_tokens\":189,\"total_tokens\":1792,\"prompt_tokens\":1603,\"completion_tokens\":189,\"response_token/s\":99.58,\"prompt_token/s\":4126.83,\"total_duration\":3417691677,\"load_duration\":649573389,\"prompt_eval_count\":1603,\"prompt_eval_duration\":388433500,\"eval_count\":189,\"eval_duration\":1897959759,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with allergic rhinitis.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:508\",\n \"phenotype_name\": \"Allergic rhinitis\",\n \"justification\": \"This phenotype represents the diagnosis of Allergic rhinitis, aligning directly with the study intent and is a primary clinical topic.\",\n \"confidence\": 0.998\n },\n {\n \"phenotype_id\": \"ohdsi:367\",\n \"phenotype_name\": \"Allergic Rhinitis\",\n \"justification\": \"This phenotype represents events of Allergic Rhinitis, which is relevant to the study intent.\",\n \"confidence\": 0.895\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Ischemic Heart Disease (Sandhu)", + "elapsed_seconds": 14.457, + "response": { + "status": "ok", + "search": { + "query": "Patients with ischemic heart disease", + "results": [ + { + "phenotype_id": "ohdsi:654", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Ischemic heart disease", + "short_description": "all events of Ischemic heart disease. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.747925431648586, + "score_dense": 0.6398934737836944, + "score_sparse": 1.0, + "score_dense_raw": 0.5740451216697693, + "score_sparse_raw": 20.967831078369528 + }, + { + "phenotype_id": "ohdsi:1162", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary artery disease (CAD) 10", + "short_description": "First coronary artery disease (CAD) continues until observation end", + "tags": [ + "Prediction" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7405506847707688, + "score_dense": 0.6911494295697497, + "score_sparse": 0.8558202802398136, + "score_dense_raw": 0.5805231928825378, + "score_sparse_raw": 19.179738027308982 + }, + { + "phenotype_id": "cipher:30617", + "source_dataset": "va_cipher", + "name": "Other Coronary Heart Disease (HDR UK)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.7, + "score_dense": 1.0, + "score_sparse": null, + "score_dense_raw": 0.619557797908783, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:16261", + "source_dataset": "va_cipher", + "name": "Ischemic Heart Disease (Sandhu)", + "short_description": "ICD-10 based definition of IHD", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.599769083709863, + "score_dense": 0.46698644653386573, + "score_sparse": 0.9095952371205237, + "score_dense_raw": 0.552191972732544, + "score_sparse_raw": 19.846646153357582 + }, + { + "phenotype_id": "cipher:29218", + "source_dataset": "va_cipher", + "name": "Coronary Heart Disease (HDR UK)", + "short_description": "HDR UK phenotype based on Read codes v2 and OXMIS codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.589820226625524, + "score_dense": 0.824731173160751, + "score_sparse": 0.04169468470999463, + "score_dense_raw": 0.5974061489105225, + "score_sparse_raw": 9.083086969318911 + }, + { + "phenotype_id": "cipher:31868", + "source_dataset": "va_cipher", + "name": "Chronic Ischaemic Heart Disease, Unspecified (Allara)", + "short_description": "HDR UK phenotype based on ICD10 codes and SNOMED CT codes.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "has_code_system:snomed", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5879324361601286, + "score_dense": 0.6337630758478168, + "score_sparse": 0.4809942768888564, + "score_dense_raw": 0.573270320892334, + "score_sparse_raw": 14.531207893782407 + }, + { + "phenotype_id": "ohdsi:1102", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit)CABG (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5721209554535734, + "score_dense": 0.8173156506479622, + "score_sparse": null, + "score_dense_raw": 0.5964689254760742, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29560", + "source_dataset": "va_cipher", + "name": "Ischemic Heart Disease (HDR UK)", + "short_description": "The data (demographics, emergency department letters, discharge summaries, clinical notes,\nlab results, vital signs) were retrieved and analyzed in near real-time from the structured and\nunstructured components of the electronic health record (EHR) using a variety of natural\nlanguage processing (NLP) informatics tools belonging to the CogStack ecosystem, namely\nMedCAT and MedCATTrainer. The CogStack NLP pipeline captures negation, synonyms,\nand acronyms for medical SNOMED-CT concepts as well as surrounding linguistic context using\ndeep learning and long short-term memory networks. MedCAT produces unsupervised\nannotations for all SNOMED-CT concepts under parent terms Clinical Finding, Disorder,\nOrganism, and Event with disambiguation, pre-trained on MIMIC-III. The annotated\nSNOMED-CT terms are summarised", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_code_system:snomed", + "execution:unknown" + ], + "executable_definition_status": "unknown", + "execution_readiness_score": 0.15, + "score": 0.5478844526651153, + "score_dense": 0.4307888495517627, + "score_sparse": 0.8211075265962714, + "score_dense_raw": 0.5476170778274536, + "score_sparse_raw": 18.749236194759753 + }, + { + "phenotype_id": "cipher:30610", + "source_dataset": "va_cipher", + "name": "Ischaemic Heart Disease (HDR UK)", + "short_description": "HDR UK phenotype based on ICD10 codes and Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5414809692579987, + "score_dense": 0.5383148691978983, + "score_sparse": 0.5488685360648998, + "score_dense_raw": 0.5612069368362427, + "score_sparse_raw": 15.372973236234401 + }, + { + "phenotype_id": "ohdsi:1314", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5138894377851149, + "score_dense": 0.7341277682644499, + "score_sparse": null, + "score_dense_raw": 0.5859550833702087, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29772", + "source_dataset": "va_cipher", + "name": "Coronary Heart Disease, Not Otherwise Specified (HDR UK)", + "short_description": "Read Code, Med Code, and ICD Code based algorithm", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd10", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5121250560650007, + "score_dense": 0.7173862499687561, + "score_sparse": 0.03318227028957173, + "score_dense_raw": 0.5838391780853271, + "score_sparse_raw": 8.977517412808309 + }, + { + "phenotype_id": "ohdsi:875", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4953140349280354, + "score_dense": 0.707591478468622, + "score_sparse": null, + "score_dense_raw": 0.5826012492179871, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:197", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Disease from vessel disease to ischemic injury", + "short_description": "all occurrence of coronary artery disease", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.4681931842574968, + "score_dense": 0.30666515124147753, + "score_sparse": 0.8450919279615418, + "score_dense_raw": 0.5319294929504395, + "score_sparse_raw": 19.046686775787514 + }, + { + "phenotype_id": "cipher:3232", + "source_dataset": "va_cipher", + "name": "Other chronic ischemic heart disease, unspecified (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Other chronic ischemic heart disease, unspecified. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.51 means that patients with a MAP probability over 0.51 should be categorized as having Other chronic ischemic heart disease, unspecified.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.44751882967615775, + "score_dense": 0.2709985818827146, + "score_sparse": 0.8593994078608586, + "score_dense_raw": 0.5274217128753662, + "score_sparse_raw": 19.224125776423033 + }, + { + "phenotype_id": "ohdsi:1031", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Earliest event of Coronary artery disease (CAD)", + "short_description": "Earliest event of Coronary Artery Disease for the first time in the persons history Indexed on coronary artery disease diagnosis (condition or observation) cohort exit is the end of continuous observation.", + "tags": [ + "JnJ", + "Indication" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.37806889385361464, + "score_dense": 0.21868434369277365, + "score_sparse": 0.7499661775622438, + "score_dense_raw": 0.5208098888397217, + "score_sparse_raw": 17.86695294395027 + }, + { + "phenotype_id": "cipher:3212", + "source_dataset": "va_cipher", + "name": "Other acute and subacute forms of ischemic heart disease (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Other acute and subacute forms of ischemic heart disease. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.31 means that patients with a MAP probability over 0.31 should be categorized as having Other acute and subacute forms of ischemic heart disease.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.34304482984340223, + "score_dense": 0.1138804301229428, + "score_sparse": 0.8777617625244745, + "score_dense_raw": 0.5075640678405762, + "score_sparse_raw": 19.451852663600597 + }, + { + "phenotype_id": "ohdsi:532", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Heart disease", + "short_description": "all events of Heart disease. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3399171309673078, + "score_dense": 0.4483674673425086, + "score_sparse": 0.08686634609183932, + "score_dense_raw": 0.5498387813568115, + "score_sparse_raw": 9.643298446745021 + }, + { + "phenotype_id": "cipher:30748", + "source_dataset": "va_cipher", + "name": "Myocardial Infarction (Bhattarai)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3112954668822217, + "score_dense": 0.44470780983174535, + "score_sparse": null, + "score_dense_raw": 0.5493762493133545, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29216", + "source_dataset": "va_cipher", + "name": "Angina (HDR UK)", + "short_description": "HDR UK phenotype based on read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.31061046011232696, + "score_dense": 0.4437292287318957, + "score_sparse": null, + "score_dense_raw": 0.5492525696754456, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1299", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3037013002631086, + "score_dense": 0.43385900037586944, + "score_sparse": null, + "score_dense_raw": 0.5480051040649414, + "score_sparse_raw": null + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend phenotypes for patients with ischemic heart disease.", + "intent_facets": { + "condition_or_topic": "Ischemic heart disease", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "adult", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Primary diagnosis" + ], + "care_setting_cues": [ + "Hospital care", + "Clinic care" + ], + "population_cues": [ + "Adult patients" + ] + }, + "reasoning_notes": [ + "The study intent explicitly mentions 'patients with ischemic heart disease', indicating a primary diagnosis." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotypes related to Ischemic Heart Disease based on candidate evaluation.", + "intent_facets": { + "condition_or_topic": "Ischemic heart disease", + "phenotype_role": "diagnosis", + "care_setting": "mixed", + "population_cue": "Patients with Ischemic heart disease", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop" + }, + "shortlist_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560", + "cipher:29218" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with Ischemic heart disease as a diagnosis-oriented study intent.", + "Included [P][R] Ischemic heart disease as a diagnosis candidate focused on Ischemic heart disease.", + "Included Ischemic Heart Disease (Sandhu) as a diagnosis candidate focused on Ischemic Heart Disease.", + "Included Ischemic Heart Disease (HDR UK) as a diagnosis candidate focused on Ischemic Heart Disease." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 4, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8738, + "prompt_length_chars": 5845, + "recommendations": { + "plan": "Recommend phenotypes for patients with ischemic heart disease.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:654", + "phenotype_name": "[P][R] Ischemic heart disease", + "justification": "This phenotype represents the diagnosis of Ischemic heart disease, aligning directly with the study intent.", + "confidence": 0.747925431648586 + }, + { + "phenotype_id": "cipher:16261", + "phenotype_name": "Ischemic Heart Disease (Sandhu)", + "justification": "This phenotype identifies patients with Ischemic Heart Disease based on ICD-10 diagnostic codes, a relevant definition for the study intent.", + "confidence": 0.599769083709863 + }, + { + "phenotype_id": "cipher:29560", + "phenotype_name": "Ischemic Heart Disease (HDR UK)", + "justification": "Selected from the top reranked shortlisted candidates as a clinically aligned diagnosis match.", + "confidence": null + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.0370659828186035, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-6498bfdc-72f6-4b69-96f4-4de3eca98574\",\"created\":1778090549,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with ischemic heart disease.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:654\\\",\\n \\\"phenotype_name\\\": \\\"Ischemic heart disease\\\",\\n \\\"justification\\\": \\\"This phenotype represents the diagnosis of Ischemic heart disease, aligning directly with the study intent.\\\",\\n \\\"confidence\\\": 0.747925431648586\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:16261\\\",\\n \\\"phenotype_name\\\": \\\"Ischemic Heart Disease (Sandhu)\\\",\\n \\\"justification\\\": \\\"This phenotype identifies patients with Ischemic Heart Disease based on ICD-10 diagnostic codes, a relevant definition for the study intent.\\\",\\n \\\"confidence\\\": 0.599769083709863\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1582,\"output_tokens\":218,\"total_tokens\":1800,\"prompt_tokens\":1582,\"completion_tokens\":218,\"response_token/s\":100.53,\"prompt_token/s\":4359.42,\"total_duration\":3659459244,\"load_duration\":637480706,\"prompt_eval_count\":1582,\"prompt_eval_duration\":362892053,\"eval_count\":218,\"eval_duration\":2168445212,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with ischemic heart disease.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:654\",\n \"phenotype_name\": \"Ischemic heart disease\",\n \"justification\": \"This phenotype represents the diagnosis of Ischemic heart disease, aligning directly with the study intent.\",\n \"confidence\": 0.747925431648586\n },\n {\n \"phenotype_id\": \"cipher:16261\",\n \"phenotype_name\": \"Ischemic Heart Disease (Sandhu)\",\n \"justification\": \"This phenotype identifies patients with Ischemic Heart Disease based on ICD-10 diagnostic codes, a relevant definition for the study intent.\",\n \"confidence\": 0.599769083709863\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.702743053436279, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-eec7ad95-6ea2-4f8e-a530-3f12a4bac958\",\"created\":1778090540,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with ischemic heart disease.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"Ischemic heart disease\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"inpatient|outpatient\\\",\\n \\\"population_cue\\\": \\\"adult\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\",\\n \\\"role_cues\\\": [\\n \\\"Primary diagnosis\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"Hospital care\\\",\\n \\\"Clinic care\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"Adult patients\\\"\\n ]\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly mentions 'patients with ischemic heart disease', indicating a primary diagnosis.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1203,\"output_tokens\":271,\"total_tokens\":1474,\"prompt_tokens\":1203,\"completion_tokens\":271,\"response_token/s\":100.37,\"prompt_token/s\":4157.06,\"total_duration\":4486681069,\"load_duration\":741583825,\"prompt_eval_count\":1203,\"prompt_eval_duration\":289387216,\"eval_count\":271,\"eval_duration\":2699901860,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with ischemic heart disease.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"Ischemic heart disease\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"inpatient|outpatient\",\n \"population_cue\": \"adult\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"us_omop\",\n \"role_cues\": [\n \"Primary diagnosis\"\n ],\n \"care_setting_cues\": [\n \"Hospital care\",\n \"Clinic care\"\n ],\n \"population_cues\": [\n \"Adult patients\"\n ]\n },\n \"reasoning_notes\": [\n \"The study intent explicitly mentions 'patients with ischemic heart disease', indicating a primary diagnosis.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.882787466049194, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-60054769-6cb4-456b-a93d-186c231b066d\",\"created\":1778090545,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to Ischemic Heart Disease based on candidate evaluation.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"Ischemic heart disease\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"mixed\\\",\\n \\\"population_cue\\\": \\\"Patients with Ischemic heart disease\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"us_omop\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:654\\\",\\n \\\"cipher:16261\\\",\\n \\\"cipher:29560\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Candidate ohdsi:654 represents the diagnosis of Ischemic heart disease.\\\",\\n \\\"Candidate cipher:16261 provides a VA-based ICD-10 definition.\\\",\\n \\\"Candidate cipher:29560 offers another VA-based ICD-10 definition.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2620,\"output_tokens\":267,\"total_tokens\":2887,\"prompt_tokens\":2620,\"completion_tokens\":267,\"response_token/s\":99.64,\"prompt_token/s\":4257.1,\"total_duration\":4623942487,\"load_duration\":646747018,\"prompt_eval_count\":2620,\"prompt_eval_duration\":615441986,\"eval_count\":267,\"eval_duration\":2679523990,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to Ischemic Heart Disease based on candidate evaluation.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"Ischemic heart disease\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"mixed\",\n \"population_cue\": \"Patients with Ischemic heart disease\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"us_omop\"\n },\n \"shortlist_ids\": [\n \"ohdsi:654\",\n \"cipher:16261\",\n \"cipher:29560\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Candidate ohdsi:654 represents the diagnosis of Ischemic heart disease.\",\n \"Candidate cipher:16261 provides a VA-based ICD-10 definition.\",\n \"Candidate cipher:29560 offers another VA-based ICD-10 definition.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "Ischemic heart disease", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "adult", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Primary diagnosis" + ], + "care_setting_cues": [ + "Hospital care", + "Clinic care" + ], + "population_cues": [ + "Adult patients" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "Ischemic heart disease", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "inpatient|outpatient", + "population_cue": "adult", + "validation_preference": "preferred", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "us_omop", + "role_cues": [ + "Primary diagnosis" + ], + "care_setting_cues": [ + "Hospital care", + "Clinic care" + ], + "population_cues": [ + "Adult patients" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560", + "cipher:29218", + "cipher:30610" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560", + "cipher:29218", + "cipher:30610" + ], + "planner_input_shortlist_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560", + "cipher:29218", + "cipher:30610" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560", + "cipher:29218" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 1, + "phenotype_id": "ohdsi:654", + "name": "[P][R] Ischemic heart disease", + "metadata_score": 34.35, + "retrieval_score": 0.7479, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Ischemic heart disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Ischemic heart disease" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Ischemic heart disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 2, + "original_rank": 4, + "phenotype_id": "cipher:16261", + "name": "Ischemic Heart Disease (Sandhu)", + "metadata_score": 34.1525, + "retrieval_score": 0.5998, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Ischemic Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Ischemic Heart Disease" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Ischemic Heart Disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 3, + "original_rank": 8, + "phenotype_id": "cipher:29560", + "name": "Ischemic Heart Disease (HDR UK)", + "metadata_score": 34.0375, + "retrieval_score": 0.5479, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Ischemic Heart Disease", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Coronavirus" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Ischemic Heart Disease" + }, + { + "kind": "topic_context", + "delta": 7.5, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Ischemic Heart Disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.0375, + "detail": 0.15 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 4, + "original_rank": 5, + "phenotype_id": "cipher:29218", + "name": "Coronary Heart Disease (HDR UK)", + "metadata_score": 23.6325, + "retrieval_score": 0.5898, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Coronary Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Influenza" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Coronary Heart Disease" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Coronary Heart Disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 5, + "original_rank": 9, + "phenotype_id": "cipher:30610", + "name": "Ischaemic Heart Disease (HDR UK)", + "metadata_score": 23.6125, + "retrieval_score": 0.5415, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Ischaemic Heart Disease", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Acute Ischemia", + "Cardiac Ischemia" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Ischaemic Heart Disease" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Ischaemic Heart Disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 6, + "original_rank": 11, + "phenotype_id": "cipher:29772", + "name": "Coronary Heart Disease, Not Otherwise Specified (HDR UK)", + "metadata_score": 23.6125, + "retrieval_score": 0.5121, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Coronary Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Coronary Heart Disease" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Coronary Heart Disease\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 7, + "original_rank": 3, + "phenotype_id": "cipher:30617", + "name": "Other Coronary Heart Disease (HDR UK)", + "metadata_score": 11.1725, + "retrieval_score": 0.7, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Coronary Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Read Codes" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Coronary Heart Disease" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 8, + "original_rank": 6, + "phenotype_id": "cipher:31868", + "name": "Chronic Ischaemic Heart Disease, Unspecified (Allara)", + "metadata_score": 9.7792, + "retrieval_score": 0.5879, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Chronic Ischaemic Heart Disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "COVID-19 Study" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 14.6667, + "detail": "Chronic Ischaemic Heart Disease" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 9, + "original_rank": 2, + "phenotype_id": "ohdsi:1162", + "name": "[P] Coronary artery disease (CAD) 10", + "metadata_score": -0.67, + "retrieval_score": 0.7406, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Coronary artery disease", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Angina pectoris" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 8.0, + "detail": "Coronary artery disease" + }, + { + "kind": "role_penalty_covariate", + "delta": -3.5, + "detail": "comorbidity_covariate" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "status_prediction", + "delta": -4.0, + "detail": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Prediction" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 10, + "original_rank": 7, + "phenotype_id": "ohdsi:1102", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -21.75, + "retrieval_score": 0.5721, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft Surgery", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post CABG Afib" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft Surgery" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 11, + "original_rank": 10, + "phenotype_id": "ohdsi:1314", + "name": "[P] Coronary Artery Bypass Graft, adults, post op new Afib", + "metadata_score": -21.75, + "retrieval_score": 0.5139, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post Op" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft, adults, post op new Afib" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 12, + "original_rank": 12, + "phenotype_id": "ohdsi:875", + "name": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED", + "metadata_score": -21.75, + "retrieval_score": 0.4953, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft" + }, + { + "kind": "role_penalty_procedure", + "delta": -4.5, + "detail": "procedure" + }, + { + "kind": "disease_vs_procedure_mismatch", + "delta": -6.0, + "detail": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED" + }, + { + "kind": "native_ohdsi_cannot_override_procedure", + "delta": -2.0, + "detail": "ohdsi_phenotype_library" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560" + ], + "valid_unique_count": 2 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:654", + "cipher:16261", + "cipher:29560" + ], + "matched_llm_ids": [ + "ohdsi:654", + "cipher:16261" + ], + "defaulted_ids": [ + "cipher:29560" + ], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 2, + "used_default_justification_count": 1 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.0370659828186035, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-6498bfdc-72f6-4b69-96f4-4de3eca98574\",\"created\":1778090549,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes for patients with ischemic heart disease.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:654\\\",\\n \\\"phenotype_name\\\": \\\"Ischemic heart disease\\\",\\n \\\"justification\\\": \\\"This phenotype represents the diagnosis of Ischemic heart disease, aligning directly with the study intent.\\\",\\n \\\"confidence\\\": 0.747925431648586\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:16261\\\",\\n \\\"phenotype_name\\\": \\\"Ischemic Heart Disease (Sandhu)\\\",\\n \\\"justification\\\": \\\"This phenotype identifies patients with Ischemic Heart Disease based on ICD-10 diagnostic codes, a relevant definition for the study intent.\\\",\\n \\\"confidence\\\": 0.599769083709863\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1582,\"output_tokens\":218,\"total_tokens\":1800,\"prompt_tokens\":1582,\"completion_tokens\":218,\"response_token/s\":100.53,\"prompt_token/s\":4359.42,\"total_duration\":3659459244,\"load_duration\":637480706,\"prompt_eval_count\":1582,\"prompt_eval_duration\":362892053,\"eval_count\":218,\"eval_duration\":2168445212,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes for patients with ischemic heart disease.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:654\",\n \"phenotype_name\": \"Ischemic heart disease\",\n \"justification\": \"This phenotype represents the diagnosis of Ischemic heart disease, aligning directly with the study intent.\",\n \"confidence\": 0.747925431648586\n },\n {\n \"phenotype_id\": \"cipher:16261\",\n \"phenotype_name\": \"Ischemic Heart Disease (Sandhu)\",\n \"justification\": \"This phenotype identifies patients with Ischemic Heart Disease based on ICD-10 diagnostic codes, a relevant definition for the study intent.\",\n \"confidence\": 0.599769083709863\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Hemorrhage in Early Pregnancy", + "elapsed_seconds": 15.343, + "response": { + "status": "ok", + "search": { + "query": "Pregnant patients with hemorrhage in early pregnancy or threatened labor", + "results": [ + { + "phenotype_id": "cipher:2643", + "source_dataset": "va_cipher", + "name": "Early or threatened labor; hemorrhage in early pregnancy (MAP)", + "short_description": "Condition defined using MAP unsupervised clustering algorithm.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.9240317726461347, + "score_dense": 0.8914739609230498, + "score_sparse": 1.0, + "score_dense_raw": 0.5837870836257935, + "score_sparse_raw": 62.306688925447396 + }, + { + "phenotype_id": "cipher:13824", + "source_dataset": "va_cipher", + "name": "Early or Threatened Labor Hemorrhage in Early Pregnancy (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.8378878748133054, + "score_dense": 0.8033900152531847, + "score_sparse": 0.9183828804535874, + "score_dense_raw": 0.5681808590888977, + "score_sparse_raw": 58.05910705740922 + }, + { + "phenotype_id": "ohdsi:1434", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Miscarriage or Abortion", + "short_description": "Females aged 12 to 55 with two pregnancy markers and clear prior history enter the cohort upon a spontaneous abortion or stillbirth within 139 days", + "tags": [ + "JnJ", + "Pregnancy", + "Publication" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8103931210164301, + "score_dense": 1.0, + "score_sparse": 0.36797707005476743, + "score_dense_raw": 0.603015124797821, + "score_sparse_raw": 29.41445807560569 + }, + { + "phenotype_id": "ohdsi:1431", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Ectopic Pregnancy", + "short_description": "Females aged 12 to 55 with two distinct pregnancy markers and a clear prior history enter the cohort upon an ectopic pregnancy within 84 days", + "tags": [ + "rupamakadia", + "j&j", + "pregnancy", + "ectopicpregnancy" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7442844114337489, + "score_dense": 0.9053518695075583, + "score_sparse": 0.3684603425948605, + "score_dense_raw": 0.5862458944320679, + "score_sparse_raw": 29.43960892251168 + }, + { + "phenotype_id": "ohdsi:1432", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Still birth", + "short_description": "Females aged 12 to 55 with two distinct pregnancy markers and a clear prior history enter the cohort upon a stillbirth within 141 to 301 days", + "tags": [ + "rupamakadia", + "j&j", + "stillbirth", + "pregnancy", + "fullterm" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7394074351709856, + "score_dense": 0.8994100576822022, + "score_sparse": 0.366067982644814, + "score_dense_raw": 0.5851931571960449, + "score_sparse_raw": 29.31510385786211 + }, + { + "phenotype_id": "ohdsi:1433", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Live Birth", + "short_description": "Females aged 12 to 55 with two distinct pregnancy markers and a clear prior history enter the cohort upon a livebirth or delivery within 301 days", + "tags": [ + "rupamakadia", + "j&j", + "pregnancy", + "livebirth", + "delivery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7198908722030449, + "score_dense": 0.8715270745710504, + "score_sparse": 0.3660730666776989, + "score_dense_raw": 0.580253005027771, + "score_sparse_raw": 29.315368445071485 + }, + { + "phenotype_id": "cipher:15566", + "source_dataset": "va_cipher", + "name": "Early or threatened labor; hemorrhage in early pregnancy (gwPheWAS)", + "short_description": "This phenotype definition was used in the Million Veteran Program (MVP) phenome-wide GWAS. Clinical outcomes from eletronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "method_family:gw", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6707627890228415, + "score_dense": 0.6642257309854956, + "score_sparse": 0.6860159244433154, + "score_dense_raw": 0.5435245037078857, + "score_sparse_raw": 45.96608448364408 + }, + { + "phenotype_id": "cipher:13827", + "source_dataset": "va_cipher", + "name": "Hemorrhage in Early Pregnancy (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6480937299761338, + "score_dense": 0.7699961715650225, + "score_sparse": 0.3636546996020602, + "score_dense_raw": 0.5622643232345581, + "score_sparse_raw": 29.189509897283582 + }, + { + "phenotype_id": "cipher:17376", + "source_dataset": "va_cipher", + "name": "Hemorrhage in Early Pregnancy (VADC)", + "short_description": "This phenotype definition was used in the VA Data Commons. Clinical outcomes from electronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies. The rollup phecode mapping was used to create this definition.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "va_developed", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5977804856601274, + "score_dense": 0.7272435200878454, + "score_sparse": 0.29570007199545223, + "score_dense_raw": 0.5546896457672119, + "score_sparse_raw": 25.65296210344758 + }, + { + "phenotype_id": "cipher:2798", + "source_dataset": "va_cipher", + "name": "Hemorrhage in early pregnancy (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Hemorrhage in early pregnancy. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.83 means that patients with a MAP probability over 0.83 should be categorized as having Hemorrhage in early pregnancy.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.5600454250446391, + "score_dense": 0.6338095215667382, + "score_sparse": 0.3879291998264079, + "score_dense_raw": 0.5381355285644531, + "score_sparse_raw": 30.452822393260533 + }, + { + "phenotype_id": "ohdsi:677", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Preterm labor with preterm delivery", + "short_description": "all events of Preterm labor with preterm delivery. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5298810312004794, + "score_dense": 0.5582140788173163, + "score_sparse": 0.4637705867611933, + "score_dense_raw": 0.5247419476509094, + "score_sparse_raw": 34.39981907461084 + }, + { + "phenotype_id": "ohdsi:486", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Premature rupture of membranes", + "short_description": "all events of Premature rupture of membranes. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.45233422360214814, + "score_dense": 0.5142143264208439, + "score_sparse": 0.3079473170251915, + "score_dense_raw": 0.5169463157653809, + "score_sparse_raw": 26.290342792828056 + }, + { + "phenotype_id": "cipher:2796", + "source_dataset": "va_cipher", + "name": "Hemorrhage during pregnancy; childbirth and postpartum (MAP)", + "short_description": "Condition defined using MAP unsupervised clustering algorithm.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.4509653658005426, + "score_dense": 0.5826588716411202, + "score_sparse": 0.14368051883919486, + "score_dense_raw": 0.5290729403495789, + "score_sparse_raw": 17.74144181012738 + }, + { + "phenotype_id": "cipher:2118", + "source_dataset": "va_cipher", + "name": "Antepartum hemorrhage, abruptio placentae, and placenta previa (MAP)", + "short_description": "MAP is an unsupervised clustering algorithm that uses counts of ICD codes and Concept Unique Identifier (CUI) mentions along with healthcare utilization as measured by total number of ICD codes to predict the phenotype. The output of MAP includes the probability of having the disease for each patient as well as a threshold value that can be used to classify the binary disease status. Those with probability higher than the threshold is considered as a case for the phenotype. See here for detailed description of the MAP algorithm. There are 1866 MAP phenotypes, each corresponding a Phecode from J. Denny's \"Phecode Map\" (Denny et al, 1) containing both ICD-9 and ICD-10 codes. Each patient is assigned a probability from 0 to 1 of having Antepartum hemorrhage, abruptio placentae, and placenta previa. The probability cutoff denotes the point over which a patient should be labeled as having the condition. For this phenotype, the cutoff of 0.5 means that patients with a MAP probability over 0.5 should be categorized as having Antepartum hemorrhage, abruptio placentae, and placenta previa.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "va_developed", + "major_revision", + "has_publication", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.4273090949785306, + "score_dense": 0.5802612217215577, + "score_sparse": 0.07042079924480081, + "score_dense_raw": 0.5286481380462646, + "score_sparse_raw": 13.928802276460003 + }, + { + "phenotype_id": "ohdsi:627", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Miscarriage", + "short_description": "all events of Miscarriage. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.40269992134550986, + "score_dense": 0.5752856019221569, + "score_sparse": null, + "score_dense_raw": 0.5277665853500366, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:13821", + "source_dataset": "va_cipher", + "name": "Hemorrhage During Pregnancy Childbirth and Postpartum (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.39712053513808787, + "score_dense": 0.44964127766111556, + "score_sparse": 0.27457213591769014, + "score_dense_raw": 0.5055056214332581, + "score_sparse_raw": 24.553405555854177 + }, + { + "phenotype_id": "cipher:17372", + "source_dataset": "va_cipher", + "name": "Antepartum Hemorrhage Abruptio Placentae and Placenta Previa (VADC)", + "short_description": "This phenotype definition was used in the VA Data Commons. Clinical outcomes from electronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies. The rollup phecode mapping was used to create this definition.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "va_developed", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.38244591915071985, + "score_dense": 0.546351313072457, + "score_sparse": null, + "score_dense_raw": 0.5226401686668396, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:13822", + "source_dataset": "va_cipher", + "name": "Antepartum Hemorrhage Abruptio Placentae and Placenta Previa (Phecode)", + "short_description": "Phenotype defined using the \"Phecode\" grouping created by J Denny and colleagues. Phecodes map ICD-9 and ICD-10 codes to clinically relevant phenotypes and were created through manual review. Phecodes were originally created for use in phenome-wide association studies (PheWAS) and can be used to define disease conditions in electronic health records where ICD codes are available. The mapping provided here is for Phecode versions \"Phecode Map 1.2 with ICD-9 Codes\" https://phewascatalog.org/phecodes and \"Phecode Map 1.2 with ICD-10cm Codes (beta)\" https://www.phewascatalog.org/phecodes_icd10cm. \"Exclude range of phecodes\" were not applied.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:map", + "method_family:gwphewas", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3804958058788344, + "score_dense": 0.5435654369697634, + "score_sparse": null, + "score_dense_raw": 0.5221465826034546, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:17371", + "source_dataset": "va_cipher", + "name": "Hemorrhage During Pregnancy Childbirth and Postpartum (VADC)", + "short_description": "This phenotype definition was used in the VA Data Commons. Clinical outcomes from electronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies. The rollup phecode mapping was used to create this definition.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "va_developed", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3730141191241479, + "score_dense": 0.46888740566002823, + "score_sparse": 0.14930978387376037, + "score_dense_raw": 0.5089155435562134, + "score_sparse_raw": 18.03440441679723 + }, + { + "phenotype_id": "cipher:17374", + "source_dataset": "va_cipher", + "name": "Threatened Premature Labor (VADC)", + "short_description": "This phenotype definition was used in the VA Data Commons. Clinical outcomes from electronic health records (EHR) were defined by Phecodes, which are curated groupings of ICD codes (Denny 2013). Each Phecode represents ICD codes grouped into clinically relevant phenotypes for clinical studies. The rollup phecode mapping was used to create this definition.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "va_developed", + "not_validated", + "has_publication", + "has_tool_link", + "has_contact", + "has_code_system:icd9", + "has_code_system:icd10", + "method_family:phecode", + "method_family:mvp", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3688343551577948, + "score_dense": 0.40062822666757275, + "score_sparse": 0.29464865496831294, + "score_dense_raw": 0.4968217611312866, + "score_sparse_raw": 25.59824343749805 + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Phenotype recommendation for pregnant patients with hemorrhage in early pregnancy or threatened labor.", + "intent_facets": { + "condition_or_topic": "Hemorrhage, Threatened labor", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "Pregnant patients", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "Hemorrhage", + "Threatened labor" + ], + "care_setting_cues": [ + "Pregnancy", + "Obstetrics" + ], + "population_cues": [ + "Pregnant women", + "Early pregnancy" + ] + }, + "reasoning_notes": [ + "Identified 'hemorrhage' and 'threatened labor' as key components of the study intent." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend phenotype candidates for pregnant patients with hemorrhage in early pregnancy or threatened labor, focusing on hemorrhage-related complications.", + "intent_facets": { + "condition_or_topic": "Hemorrhage, Threatened labor", + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "Pregnant patients", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "Hemorrhage", + "Threatened labor" + ], + "care_setting_cues": [ + "Pregnancy", + "Obstetrics" + ], + "population_cues": [ + "Pregnant women", + "Early pregnancy" + ] + }, + "shortlist_ids": [ + "cipher:2643", + "cipher:17376", + "cipher:2798" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with Hemorrhage, Threatened labor as a diagnosis-oriented study intent.", + "Included Early or threatened labor; hemorrhage in early pregnancy (MAP) as a complication candidate focused on Early or threatened labor; hemorrhage in early pregnancy.", + "Included Hemorrhage in Early Pregnancy (VADC) as a outcome candidate focused on Hemorrhage.", + "Included Hemorrhage in early pregnancy (MAP) as a diagnosis candidate focused on Hemorrhage in early pregnancy." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 9468, + "prompt_length_chars": 6475, + "recommendations": { + "plan": "Recommend phenotypes related to hemorrhage in early pregnancy and threatened labor.", + "phenotype_recommendations": [ + { + "phenotype_id": "cipher:2643", + "phenotype_name": "Early or threatened labor; hemorrhage in early pregnancy (MAP)", + "justification": "This phenotype aligns directly with the study intent of 'Pregnant patients with hemorrhage in early pregnancy or threatened labor'. It's a MAP phenotype designed to identify this specific clinical sce", + "confidence": 0.92 + }, + { + "phenotype_id": "cipher:17376", + "phenotype_name": "Hemorrhage in Early Pregnancy (VADC)", + "justification": "This phenotype, representing hemorrhage outcomes in early pregnancy, is a relevant concept within the study intent and provides an alternative representation of the same clinical issue.", + "confidence": 0.85 + }, + { + "phenotype_id": "cipher:2798", + "phenotype_name": "Hemorrhage in early pregnancy (MAP)", + "justification": "Selected from the top reranked shortlisted candidates as a clinically aligned diagnosis match.", + "confidence": null + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 4.239260911941528, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-84269238-d54d-445e-b4b6-ea97031efa81\",\"created\":1778090564,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to hemorrhage in early pregnancy and threatened labor.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:2643\\\",\\n \\\"phenotype_name\\\": \\\"Early or threatened labor; hemorrhage in early pregnancy (MAP)\\\",\\n \\\"justification\\\": \\\"This phenotype aligns directly with the study intent of 'Pregnant patients with hemorrhage in early pregnancy or threatened labor'. It's a MAP phenotype designed to identify this specific clinical scenario.\\\",\\n \\\"confidence\\\": 0.92\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:17376\\\",\\n \\\"phenotype_name\\\": \\\"Hemorrhage in Early Pregnancy (VADC)\\\",\\n \\\"justification\\\": \\\"This phenotype, representing hemorrhage outcomes in early pregnancy, is a relevant concept within the study intent and provides an alternative representation of the same clinical issue.\\\",\\n \\\"confidence\\\": 0.85\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1689,\"output_tokens\":223,\"total_tokens\":1912,\"prompt_tokens\":1689,\"completion_tokens\":223,\"response_token/s\":96.36,\"prompt_token/s\":4173.94,\"total_duration\":3937711783,\"load_duration\":644596591,\"prompt_eval_count\":1689,\"prompt_eval_duration\":404653965,\"eval_count\":223,\"eval_duration\":2314346784,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to hemorrhage in early pregnancy and threatened labor.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:2643\",\n \"phenotype_name\": \"Early or threatened labor; hemorrhage in early pregnancy (MAP)\",\n \"justification\": \"This phenotype aligns directly with the study intent of 'Pregnant patients with hemorrhage in early pregnancy or threatened labor'. It's a MAP phenotype designed to identify this specific clinical scenario.\",\n \"confidence\": 0.92\n },\n {\n \"phenotype_id\": \"cipher:17376\",\n \"phenotype_name\": \"Hemorrhage in Early Pregnancy (VADC)\",\n \"justification\": \"This phenotype, representing hemorrhage outcomes in early pregnancy, is a relevant concept within the study intent and provides an alternative representation of the same clinical issue.\",\n \"confidence\": 0.85\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.475784063339233, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-71095aec-7534-4a67-8269-3df6f5908915\",\"created\":1778090554,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Phenotype recommendation for pregnant patients with hemorrhage in early pregnancy or threatened labor.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"Hemorrhage, Threatened labor\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"Pregnant patients\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"Hemorrhage\\\",\\n \\\"Threatened labor\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"Pregnancy\\\",\\n \\\"Obstetrics\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"Pregnant women\\\",\\n \\\"Early pregnancy\\\"\\n ],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"Identified 'hemorrhage' and 'threatened labor' as key components of the study intent.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1208,\"output_tokens\":262,\"total_tokens\":1470,\"prompt_tokens\":1208,\"completion_tokens\":262,\"response_token/s\":99.7,\"prompt_token/s\":3776.97,\"total_duration\":4204353895,\"load_duration\":640834599,\"prompt_eval_count\":1208,\"prompt_eval_duration\":319833072,\"eval_count\":262,\"eval_duration\":2627941958,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Phenotype recommendation for pregnant patients with hemorrhage in early pregnancy or threatened labor.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"Hemorrhage, Threatened labor\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"Pregnant patients\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"Hemorrhage\",\n \"Threatened labor\"\n ],\n \"care_setting_cues\": [\n \"Pregnancy\",\n \"Obstetrics\"\n ],\n \"population_cues\": [\n \"Pregnant women\",\n \"Early pregnancy\"\n ],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"Identified 'hemorrhage' and 'threatened labor' as key components of the study intent.\"\n ]\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 5.666582107543945, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-8b4d548d-2a4e-47f9-804f-15cc45438596\",\"created\":1778090560,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotype candidates for pregnant patients with hemorrhage in early pregnancy or threatened labor, focusing on hemorrhage-related complications.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"Hemorrhage, Threatened labor\\\",\\n \\\"phenotype_role\\\": \\\"diagnosis\\\",\\n \\\"care_setting\\\": \\\"any\\\",\\n \\\"population_cue\\\": \\\"Pregnant patients\\\",\\n \\\"validation_preference\\\": \\\"preferred\\\",\\n \\\"executability_preference\\\": \\\"allow_translation\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"Hemorrhage\\\",\\n \\\"Threatened labor\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"Pregnancy\\\",\\n \\\"Obstetrics\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"Pregnant women\\\",\\n \\\"Early pregnancy\\\"\\n ]\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"cipher:2643\\\",\\n \\\"cipher:17376\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Phenotype 2643 (cipher:2643) and 17376 (cipher:17376) are both MAP phenotypes defined using ICD codes relevant to hemorrhage in early pregnancy. Both are executable definitions and align well with the study intent.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2756,\"output_tokens\":330,\"total_tokens\":3086,\"prompt_tokens\":2756,\"completion_tokens\":330,\"response_token/s\":100.46,\"prompt_token/s\":4336.88,\"total_duration\":5362417646,\"load_duration\":652351928,\"prompt_eval_count\":2756,\"prompt_eval_duration\":635480026,\"eval_count\":330,\"eval_duration\":3284877725,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotype candidates for pregnant patients with hemorrhage in early pregnancy or threatened labor, focusing on hemorrhage-related complications.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"Hemorrhage, Threatened labor\",\n \"phenotype_role\": \"diagnosis\",\n \"care_setting\": \"any\",\n \"population_cue\": \"Pregnant patients\",\n \"validation_preference\": \"preferred\",\n \"executability_preference\": \"allow_translation\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"Hemorrhage\",\n \"Threatened labor\"\n ],\n \"care_setting_cues\": [\n \"Pregnancy\",\n \"Obstetrics\"\n ],\n \"population_cues\": [\n \"Pregnant women\",\n \"Early pregnancy\"\n ]\n },\n \"shortlist_ids\": [\n \"cipher:2643\",\n \"cipher:17376\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Phenotype 2643 (cipher:2643) and 17376 (cipher:17376) are both MAP phenotypes defined using ICD codes relevant to hemorrhage in early pregnancy. Both are executable definitions and align well with the study intent.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "Hemorrhage, Threatened labor", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "Pregnant patients", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "Hemorrhage", + "Threatened labor" + ], + "care_setting_cues": [ + "Pregnancy", + "Obstetrics" + ], + "population_cues": [ + "Pregnant women", + "Early pregnancy" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "Hemorrhage, Threatened labor", + "clinical_topic_aliases": [], + "phenotype_role": "diagnosis", + "care_setting": "any", + "population_cue": "Pregnant patients", + "validation_preference": "preferred", + "executability_preference": "allow_translation", + "geography_coding_preference": "not_specified", + "role_cues": [ + "Hemorrhage", + "Threatened labor" + ], + "care_setting_cues": [ + "Pregnancy", + "Obstetrics" + ], + "population_cues": [ + "Pregnant women", + "Early pregnancy" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "cipher:13824", + "cipher:2643", + "cipher:2798", + "cipher:17376", + "cipher:15566" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "cipher:13824", + "cipher:2643", + "cipher:2798", + "cipher:17376", + "cipher:15566" + ], + "planner_input_shortlist_ids": [ + "cipher:2643", + "cipher:17376" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "cipher:13824", + "cipher:2643", + "cipher:2798", + "cipher:17376", + "cipher:15566" + ], + "plain_diagnosis_fill_skipped_ids": [ + "cipher:13824" + ], + "quality_threshold_skipped_ids": [], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "cipher:2643", + "cipher:17376", + "cipher:2798" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 2, + "phenotype_id": "cipher:13824", + "name": "Early or Threatened Labor Hemorrhage in Early Pregnancy (Phecode)", + "metadata_score": 18.1925, + "retrieval_score": 0.8379, + "phenotype_role": "complication", + "primary_clinical_topic": "Early Labor Hemorrhage", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Pregnancy", + "Labor" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 16.0, + "detail": "Early Labor Hemorrhage" + }, + { + "kind": "topic_context", + "delta": 5.0, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Early Labor Hemorrhage\"]}" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 2, + "original_rank": 1, + "phenotype_id": "cipher:2643", + "name": "Early or threatened labor; hemorrhage in early pregnancy (MAP)", + "metadata_score": 16.6411, + "retrieval_score": 0.924, + "phenotype_role": "complication", + "primary_clinical_topic": "Early or threatened labor; hemorrhage in early pregnancy", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "MAP Phenotype" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 19.4286, + "detail": "Early or threatened labor; hemorrhage in early pregnancy" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 3, + "original_rank": 10, + "phenotype_id": "cipher:2798", + "name": "Hemorrhage in early pregnancy (MAP)", + "metadata_score": 13.7375, + "retrieval_score": 0.56, + "phenotype_role": "diagnosis", + "primary_clinical_topic": "Hemorrhage in early pregnancy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "MAP algorithm" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 7.3333, + "detail": "Hemorrhage in early pregnancy" + }, + { + "kind": "topic_context", + "delta": 2.2917, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Hemorrhage in early pregnancy\"]}" + }, + { + "kind": "role_match", + "delta": 4.0, + "detail": "diagnosis" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 4, + "original_rank": 9, + "phenotype_id": "cipher:17376", + "name": "Hemorrhage in Early Pregnancy (VADC)", + "metadata_score": 10.4458, + "retrieval_score": 0.5978, + "phenotype_role": "outcome", + "primary_clinical_topic": "Hemorrhage", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "pregnancy", + "early" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 13.3333, + "detail": "Hemorrhage" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 5, + "original_rank": 7, + "phenotype_id": "cipher:15566", + "name": "Early or threatened labor; hemorrhage in early pregnancy (gwPheWAS)", + "metadata_score": 9.3625, + "retrieval_score": 0.6708, + "phenotype_role": "complication", + "primary_clinical_topic": "Pregnancy Hemorrhage", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Early Pregnancy" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 9.3333, + "detail": "Pregnancy Hemorrhage" + }, + { + "kind": "topic_context", + "delta": 2.9167, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Pregnancy Hemorrhage\"]}" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 6, + "original_rank": 8, + "phenotype_id": "cipher:13827", + "name": "Hemorrhage in Early Pregnancy (Phecode)", + "metadata_score": 4.4458, + "retrieval_score": 0.6481, + "phenotype_role": "complication", + "primary_clinical_topic": "Hemorrhage in Early Pregnancy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 7.3333, + "detail": "Hemorrhage in Early Pregnancy" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "complication" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 7, + "original_rank": 11, + "phenotype_id": "ohdsi:677", + "name": "[P][R] Preterm labor with preterm delivery", + "metadata_score": 4.375, + "retrieval_score": 0.5299, + "phenotype_role": "outcome", + "primary_clinical_topic": "Preterm labor with preterm delivery", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cohort study", + "Outcome measure" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 7.3333, + "detail": "Preterm labor with preterm delivery" + }, + { + "kind": "topic_context", + "delta": 2.2917, + "detail": "{\"context_conditions\": [], \"target_conditions\": [\"Preterm labor with preterm delivery\"]}" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "exclude_non_diagnosis", + "delta": -2.5, + "detail": [ + "Cohort study", + "Outcome measure" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 8, + "original_rank": 3, + "phenotype_id": "ohdsi:1434", + "name": "[P] Miscarriage or Abortion", + "metadata_score": -10.69, + "retrieval_score": 0.8104, + "phenotype_role": "outcome", + "primary_clinical_topic": "Pregnancy Loss", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Delivery", + "Abnormal glucose tolerance test" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Pregnancy Loss" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 9, + "original_rank": 4, + "phenotype_id": "ohdsi:1431", + "name": "[P] Ectopic Pregnancy", + "metadata_score": -10.71, + "retrieval_score": 0.7443, + "phenotype_role": "outcome", + "primary_clinical_topic": "Ectopic Pregnancy", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Pregnancy" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Ectopic Pregnancy" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 10, + "original_rank": 5, + "phenotype_id": "ohdsi:1432", + "name": "[P] Still birth", + "metadata_score": -10.73, + "retrieval_score": 0.7394, + "phenotype_role": "outcome", + "primary_clinical_topic": "Stillbirth", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Stillbirth" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + }, + { + "rank": 11, + "original_rank": 6, + "phenotype_id": "ohdsi:1433", + "name": "[P] Live Birth", + "metadata_score": -10.75, + "retrieval_score": 0.7199, + "phenotype_role": "outcome", + "primary_clinical_topic": "Live Birth", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Coronavirus" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Live Birth" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 12, + "original_rank": 12, + "phenotype_id": "ohdsi:486", + "name": "[P][R] Premature rupture of membranes", + "metadata_score": -13.25, + "retrieval_score": 0.4523, + "phenotype_role": "outcome", + "primary_clinical_topic": "Premature Rupture of Membranes (PROM)", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Labor Delivery", + "Pregnancy Outcome", + "Rh Factor", + "Gestational Age" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Premature Rupture of Membranes (PROM)" + }, + { + "kind": "role_penalty_non_diagnosis", + "delta": -3.0, + "detail": "outcome" + }, + { + "kind": "exclude_non_diagnosis", + "delta": -2.5, + "detail": [ + "Labor Delivery", + "Pregnancy Outcome", + "Rh Factor", + "Gestational Age" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "cipher:2643", + "cipher:17376", + "cipher:2798" + ], + "valid_unique_count": 2 + }, + "final_deterministic": { + "selected_ids": [ + "cipher:2643", + "cipher:17376", + "cipher:2798" + ], + "matched_llm_ids": [ + "cipher:2643", + "cipher:17376" + ], + "defaulted_ids": [ + "cipher:2798" + ], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 2, + "used_default_justification_count": 1 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 4.239260911941528, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-84269238-d54d-445e-b4b6-ea97031efa81\",\"created\":1778090564,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend phenotypes related to hemorrhage in early pregnancy and threatened labor.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"cipher:2643\\\",\\n \\\"phenotype_name\\\": \\\"Early or threatened labor; hemorrhage in early pregnancy (MAP)\\\",\\n \\\"justification\\\": \\\"This phenotype aligns directly with the study intent of 'Pregnant patients with hemorrhage in early pregnancy or threatened labor'. It's a MAP phenotype designed to identify this specific clinical scenario.\\\",\\n \\\"confidence\\\": 0.92\\n },\\n {\\n \\\"phenotype_id\\\": \\\"cipher:17376\\\",\\n \\\"phenotype_name\\\": \\\"Hemorrhage in Early Pregnancy (VADC)\\\",\\n \\\"justification\\\": \\\"This phenotype, representing hemorrhage outcomes in early pregnancy, is a relevant concept within the study intent and provides an alternative representation of the same clinical issue.\\\",\\n \\\"confidence\\\": 0.85\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1689,\"output_tokens\":223,\"total_tokens\":1912,\"prompt_tokens\":1689,\"completion_tokens\":223,\"response_token/s\":96.36,\"prompt_token/s\":4173.94,\"total_duration\":3937711783,\"load_duration\":644596591,\"prompt_eval_count\":1689,\"prompt_eval_duration\":404653965,\"eval_count\":223,\"eval_duration\":2314346784,\"approximate_total\":\"0h0m3s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend phenotypes related to hemorrhage in early pregnancy and threatened labor.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"cipher:2643\",\n \"phenotype_name\": \"Early or threatened labor; hemorrhage in early pregnancy (MAP)\",\n \"justification\": \"This phenotype aligns directly with the study intent of 'Pregnant patients with hemorrhage in early pregnancy or threatened labor'. It's a MAP phenotype designed to identify this specific clinical scenario.\",\n \"confidence\": 0.92\n },\n {\n \"phenotype_id\": \"cipher:17376\",\n \"phenotype_name\": \"Hemorrhage in Early Pregnancy (VADC)\",\n \"justification\": \"This phenotype, representing hemorrhage outcomes in early pregnancy, is a relevant concept within the study intent and provides an alternative representation of the same clinical issue.\",\n \"confidence\": 0.85\n }\n ]\n}" + } + } + } +} +, +{ + "benchmark_name": "Lung Resection", + "elapsed_seconds": 15.529, + "response": { + "status": "ok", + "search": { + "query": "Patients who underwent lung resection", + "results": [ + { + "phenotype_id": "ohdsi:1268", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lung Resection, adults, inpt stay, no ED, post op new Afib", + "short_description": "Lung Resection - post op new Afib (any)", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8256917666679979, + "score_dense": 0.7832056360509666, + "score_sparse": 0.9248260714410713, + "score_dense_raw": 0.5601073503494263, + "score_sparse_raw": 20.400514143599803 + }, + { + "phenotype_id": "ohdsi:878", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.821205676623461, + "score_dense": 1.0, + "score_sparse": 0.4040189220782036, + "score_dense_raw": 0.5963104963302612, + "score_sparse_raw": 13.89847082887701 + }, + { + "phenotype_id": "ohdsi:869", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lung Resection, adults, inpt stay, no ED", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.8012927705679814, + "score_dense": 0.7493466415483864, + "score_sparse": 0.9225004049470368, + "score_dense_raw": 0.5544531345367432, + "score_sparse_raw": 20.371479242104478 + }, + { + "phenotype_id": "ohdsi:1293", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lung Resection, adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7714227459913576, + "score_dense": 0.7281507725013368, + "score_sparse": 0.8723906841347401, + "score_dense_raw": 0.5509135723114014, + "score_sparse_raw": 19.745881886905327 + }, + { + "phenotype_id": "ohdsi:1304", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.764783206876835, + "score_dense": 0.9184631495839279, + "score_sparse": 0.40619667389361797, + "score_dense_raw": 0.582694411277771, + "score_sparse_raw": 13.92565908205261 + }, + { + "phenotype_id": "ohdsi:1308", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lung Resection, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.7501510002971872, + "score_dense": 0.6869950793774302, + "score_sparse": 0.8975148157766201, + "score_dense_raw": 0.544040858745575, + "score_sparse_raw": 20.059545384766572 + }, + { + "phenotype_id": "cipher:30650", + "source_dataset": "va_cipher", + "name": "Smoking Status (Fairhust)", + "short_description": "HDR UK phenotype based on Read codes v2.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:1", + "major_revision", + "has_publication", + "has_contact", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.6179188687165685, + "score_dense": 0.8584948020436324, + "score_sparse": 0.0565750242867531, + "score_dense_raw": 0.5726801156997681, + "score_sparse_raw": 9.560789835749693 + }, + { + "phenotype_id": "ohdsi:1289", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Major Non Cardiac Surgery, adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.6073580533718108, + "score_dense": 0.693529377751476, + "score_sparse": 0.40629162981925887, + "score_dense_raw": 0.545132040977478, + "score_sparse_raw": 13.926844564130308 + }, + { + "phenotype_id": "ohdsi:865", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent Major Non Cardiac Surgery among adults", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.560513781645168, + "score_dense": 0.627218402685819, + "score_sparse": 0.40486966588364914, + "score_dense_raw": 0.5340585708618164, + "score_sparse_raw": 13.909091983136328 + }, + { + "phenotype_id": "ohdsi:877", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent MNCS (age 18 or greater), post op Afib (parox)", + "short_description": "", + "tags": [ + "Surgery", + "NonEmergent" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5541414101520118, + "score_dense": 0.6140919834470628, + "score_sparse": 0.41425673913022604, + "score_dense_raw": 0.5318665504455566, + "score_sparse_raw": 14.026285375973554 + }, + { + "phenotype_id": "ohdsi:1106", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Non-Emergent Major Non Cardiac Surgery no prior Opioid", + "short_description": "Persons having any of major non cardiac surgery", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.5480847361179623, + "score_dense": 0.6105541036478853, + "score_sparse": 0.40232287854814186, + "score_dense_raw": 0.531275749206543, + "score_sparse_raw": 13.877296487262061 + }, + { + "phenotype_id": "ohdsi:1102", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "short_description": "Non emergent (No ED visit)CABG (requiring inpatient stay) among adults, first occurrence Afib (any) that occurs 14d after index (date of surgery), no prior atrial fibrillation", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.38912655201632734, + "score_dense": 0.5558950743090391, + "score_sparse": null, + "score_dense_raw": 0.522148072719574, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:823", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Primary adenocarcinoma of colon or rectum, MSI-H or dMMR, oncological treatment no surgery", + "short_description": "First event primary adenocarcinoma of the colon or rectum, with MSI-H or dMMR molecular subtype, treated with oncological treatment no curative surgery", + "tags": [ + "ColorectalCancer", + "Cancer" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.35993856692204235, + "score_dense": 0.4664597430539841, + "score_sparse": 0.11138915594751157, + "score_dense_raw": 0.5072129964828491, + "score_sparse_raw": 10.245119647184076 + }, + { + "phenotype_id": "ohdsi:814", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Primary adenocarcinoma of the colon or rectum, no curative intended surgery and oncological treatment", + "short_description": "First event of Primary adenocarcinoma of the colon or rectum treated with no curative surgery but oncological treatment within 90 days of the operation", + "tags": [ + "ColorectalCancer", + "Cancer" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3446021909778496, + "score_dense": 0.44539736543599945, + "score_sparse": 0.10941345057550014, + "score_dense_raw": 0.5036957263946533, + "score_sparse_raw": 10.22045385309568 + }, + { + "phenotype_id": "ohdsi:1314", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, post op new Afib", + "short_description": "", + "tags": [ + "Surgery", + "WorkGroup" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.34138466304835885, + "score_dense": 0.4876923757833698, + "score_sparse": null, + "score_dense_raw": 0.5107586979866028, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:820", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Primary adenocarcinoma of the colon or rectum, MSI-L, MSI-indeterminate, MSS or pMMR, oncological treatment, no curative surgery", + "short_description": "First event of Primary adenocarcinoma of the colon or rectum, MSI-L, MSI-indeterminate, MSS or pMMR, treated with oncological therapy, but no curative intended surgery", + "tags": [ + "ColorectalCancer", + "Cancer" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.3412683218281811, + "score_dense": 0.4416421159033979, + "score_sparse": 0.10706280231934204, + "score_dense_raw": 0.5030686259269714, + "score_sparse_raw": 10.191107065521978 + }, + { + "phenotype_id": "ohdsi:350", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P][R] Hemoptysis", + "short_description": "all events of Hemoptysis. Persons exit on cohort end date", + "tags": [ + "Referent", + "Condition", + "respiratory", + "lung" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review", + "reference" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.33217443260788715, + "score_dense": 0.47453490372555307, + "score_sparse": null, + "score_dense_raw": 0.5085614919662476, + "score_sparse_raw": null + }, + { + "phenotype_id": "cipher:29227", + "source_dataset": "va_cipher", + "name": "Cardiorespiratory Fitness (PheKB)", + "short_description": "ICD 9 and procedure code based algorithm defining patients who underwent exercise-stress test.", + "tags": [ + "General" + ], + "signals": [ + "source:cipher", + "status:2", + "major_revision", + "validated", + "has_contact", + "has_code_system:icd9", + "execution:codes_only" + ], + "executable_definition_status": "codes_only", + "execution_readiness_score": 0.45, + "score": 0.3, + "score_dense": null, + "score_sparse": 1.0, + "score_dense_raw": null, + "score_sparse_raw": 21.33902687374567 + }, + { + "phenotype_id": "ohdsi:875", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Coronary Artery Bypass Graft, adults, inpt stay, no ED", + "short_description": "", + "tags": [ + "Surgery" + ], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2983054797874702, + "score_dense": 0.42615068541067175, + "score_sparse": null, + "score_dense_raw": 0.5004816651344299, + "score_sparse_raw": null + }, + { + "phenotype_id": "ohdsi:1267", + "source_dataset": "ohdsi_phenotype_library", + "name": "[P] Lung cancer using ICD0", + "short_description": "Lung cancer using ICD0", + "tags": [], + "signals": [ + "source:ohdsi", + "execution:native_ohdsi", + "status:Pending peer review" + ], + "executable_definition_status": "native_ohdsi", + "execution_readiness_score": 1.0, + "score": 0.2968761100101735, + "score_dense": 0.377937436065101, + "score_sparse": 0.10773301588200944, + "score_dense_raw": 0.4924303889274597, + "score_sparse_raw": 10.199474380793877 + } + ], + "count": 20, + "weights": { + "dense": 0.7, + "sparse": 0.3 + }, + "_meta": { + "tool": "phenotype_search" + } + }, + "intent_facets": { + "plan": "Recommend lung resection phenotypes based on study intent.", + "intent_facets": { + "condition_or_topic": "lung resection", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients undergoing surgery" + ] + }, + "reasoning_notes": [ + "The study intent explicitly mentions 'Patients who underwent lung resection', indicating a procedure." + ], + "mode": "llm" + }, + "planning": { + "plan": "Recommend lung resection phenotypes based on study intent of patients who underwent lung resection.", + "intent_facets": { + "condition_or_topic": "lung resection", + "phenotype_role": "procedure", + "care_setting": "inpatient", + "population_cue": "patients undergoing surgery", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified" + }, + "shortlist_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869" + ], + "needs_more_search": false, + "reasoning_notes": [ + "Selected shortlisted candidates align with lung resection as a procedure-oriented study intent.", + "Included [P] Lung Resection, adults, inpt stay, no ED, post op new Afib as a procedure candidate focused on Lung Resection.", + "Included [P] Lung Resection, adults, post op new Afib as a procedure candidate focused on Lung Resection.", + "Included [P] Lung Resection, adults, inpt stay, no ED as a procedure candidate focused on Lung Resection." + ], + "mode": "llm", + "invalid_ids_filtered": [] + }, + "llm_used": true, + "llm_status": "ok", + "fallback_reason": null, + "fallback_mode": null, + "candidate_limit": 10, + "candidate_offset": 0, + "candidate_count": 3, + "candidate_count_before_truncation": 20, + "plan_prompt_length_chars": 8538, + "prompt_length_chars": 5000, + "recommendations": { + "plan": "Recommend lung resection phenotypes based on study intent.", + "phenotype_recommendations": [ + { + "phenotype_id": "ohdsi:1268", + "phenotype_name": "[P] Lung Resection, adults, inpt stay, no ED, post op new Afib", + "justification": "This phenotype directly aligns with the study intent of patients undergoing lung resection and developing post-operative atrial fibrillation.", + "confidence": 0.8256917666679979 + }, + { + "phenotype_id": "ohdsi:1308", + "phenotype_name": "[P] Lung Resection, adults, post op new Afib", + "justification": "This phenotype is related to lung resection and post-operative atrial fibrillation, complementing the primary intent.", + "confidence": 0.7501510002971872 + }, + { + "phenotype_id": "ohdsi:869", + "phenotype_name": "[P] Lung Resection, adults, inpt stay, no ED", + "justification": "This phenotype represents the procedure of lung resection associated with inpatient stays, a relevant component of the study intent.", + "confidence": 0.8012927705679814 + } + ], + "mode": "llm", + "catalog_stats": { + "total_rows": 3, + "preview_rows": 3, + "allowed_ids": 3 + }, + "invalid_ids_filtered": [] + }, + "diagnostics": { + "llm_status": "ok", + "llm_duration_seconds": 5.448719501495361, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-ad14d651-24e0-4d75-bdcb-1c424420a036\",\"created\":1778090580,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend lung resection phenotypes based on study intent.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1268\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, inpt stay, no ED, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype directly aligns with the study intent of patients undergoing lung resection and developing post-operative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.8256917666679979\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1308\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype is related to lung resection and post-operative atrial fibrillation, complementing the primary intent.\\\",\\n \\\"confidence\\\": 0.7501510002971872\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:869\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, inpt stay, no ED\\\",\\n \\\"justification\\\": \\\"This phenotype represents the procedure of lung resection associated with inpatient stays, a relevant component of the study intent.\\\",\\n \\\"confidence\\\": 0.8012927705679814\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1410,\"output_tokens\":333,\"total_tokens\":1743,\"prompt_tokens\":1410,\"completion_tokens\":333,\"response_token/s\":99.08,\"prompt_token/s\":4096.49,\"total_duration\":5180320969,\"load_duration\":700940085,\"prompt_eval_count\":1410,\"prompt_eval_duration\":344197099,\"eval_count\":333,\"eval_duration\":3360780705,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend lung resection phenotypes based on study intent.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:1268\",\n \"phenotype_name\": \"Lung Resection, adults, inpt stay, no ED, post op new Afib\",\n \"justification\": \"This phenotype directly aligns with the study intent of patients undergoing lung resection and developing post-operative atrial fibrillation.\",\n \"confidence\": 0.8256917666679979\n },\n {\n \"phenotype_id\": \"ohdsi:1308\",\n \"phenotype_name\": \"Lung Resection, adults, post op new Afib\",\n \"justification\": \"This phenotype is related to lung resection and post-operative atrial fibrillation, complementing the primary intent.\",\n \"confidence\": 0.7501510002971872\n },\n {\n \"phenotype_id\": \"ohdsi:869\",\n \"phenotype_name\": \"Lung Resection, adults, inpt stay, no ED\",\n \"justification\": \"This phenotype represents the procedure of lung resection associated with inpatient stays, a relevant component of the study intent.\",\n \"confidence\": 0.8012927705679814\n }\n ]\n}", + "intent_facets": { + "llm_status": "ok", + "llm_duration_seconds": 4.592474937438965, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-b97d5827-dabc-41e1-af3f-c1c43e2bc39b\",\"created\":1778090570,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend lung resection phenotypes based on study intent.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"lung resection\\\",\\n \\\"clinical_topic_aliases\\\": [],\\n \\\"phenotype_role\\\": \\\"procedure\\\",\\n \\\"care_setting\\\": \\\"inpatient|outpatient\\\",\\n \\\"population_cue\\\": \\\"patients\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\",\\n \\\"role_cues\\\": [\\n \\\"surgical procedure\\\"\\n ],\\n \\\"care_setting_cues\\\": [\\n \\\"hospital\\\",\\n \\\"clinic\\\"\\n ],\\n \\\"population_cues\\\": [\\n \\\"patients undergoing surgery\\\"\\n ],\\n \\\"clinical_topic_aliases\\\": []\\n },\\n \\\"reasoning_notes\\\": [\\n \\\"The study intent explicitly mentions 'Patients who underwent lung resection', indicating a procedure.\\\"\\n ],\\n \\\"required\\\": [\\n \\\"plan\\\",\\n \\\"intent_facets\\\",\\n \\\"reasoning_notes\\\"\\n ],\\n \\\"additionalProperties\\\": false\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1203,\"output_tokens\":278,\"total_tokens\":1481,\"prompt_tokens\":1203,\"completion_tokens\":278,\"response_token/s\":97.98,\"prompt_token/s\":4191.39,\"total_duration\":4470934517,\"load_duration\":658331049,\"prompt_eval_count\":1203,\"prompt_eval_duration\":287017281,\"eval_count\":278,\"eval_duration\":2837357854,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend lung resection phenotypes based on study intent.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"lung resection\",\n \"clinical_topic_aliases\": [],\n \"phenotype_role\": \"procedure\",\n \"care_setting\": \"inpatient|outpatient\",\n \"population_cue\": \"patients\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\",\n \"role_cues\": [\n \"surgical procedure\"\n ],\n \"care_setting_cues\": [\n \"hospital\",\n \"clinic\"\n ],\n \"population_cues\": [\n \"patients undergoing surgery\"\n ],\n \"clinical_topic_aliases\": []\n },\n \"reasoning_notes\": [\n \"The study intent explicitly mentions 'Patients who underwent lung resection', indicating a procedure.\"\n ],\n \"required\": [\n \"plan\",\n \"intent_facets\",\n \"reasoning_notes\"\n ],\n \"additionalProperties\": false\n}" + }, + "planning": { + "llm_status": "ok", + "llm_duration_seconds": 4.676817893981934, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-12102243-4566-47ad-ac54-e961ef5fce17\",\"created\":1778090575,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend lung resection phenotypes based on study intent of patients who underwent lung resection.\\\",\\n \\\"intent_facets\\\": {\\n \\\"condition_or_topic\\\": \\\"lung resection\\\",\\n \\\"phenotype_role\\\": \\\"procedure\\\",\\n \\\"care_setting\\\": \\\"inpatient\\\",\\n \\\"population_cue\\\": \\\"patients undergoing surgery\\\",\\n \\\"validation_preference\\\": \\\"not_specified\\\",\\n \\\"executability_preference\\\": \\\"prefer_native_ohdsi\\\",\\n \\\"geography_coding_preference\\\": \\\"not_specified\\\"\\n },\\n \\\"shortlist_ids\\\": [\\n \\\"ohdsi:1268\\\",\\n \\\"ohdsi:1308\\\",\\n \\\"ohdsi:1289\\\"\\n ],\\n \\\"needs_more_search\\\": false,\\n \\\"reasoning_notes\\\": [\\n \\\"Selected lung resection phenotypes (ohdsi:1268, ohdsi:1308, ohdsi:1289) directly relate to the study intent of patients undergoing lung resection, focusing on procedures and their postoperative complications.\\\"\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":2579,\"output_tokens\":260,\"total_tokens\":2839,\"prompt_tokens\":2579,\"completion_tokens\":260,\"response_token/s\":101.49,\"prompt_token/s\":4369.1,\"total_duration\":4444527728,\"load_duration\":648256978,\"prompt_eval_count\":2579,\"prompt_eval_duration\":590281802,\"eval_count\":260,\"eval_duration\":2561766763,\"approximate_total\":\"0h0m4s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend lung resection phenotypes based on study intent of patients who underwent lung resection.\",\n \"intent_facets\": {\n \"condition_or_topic\": \"lung resection\",\n \"phenotype_role\": \"procedure\",\n \"care_setting\": \"inpatient\",\n \"population_cue\": \"patients undergoing surgery\",\n \"validation_preference\": \"not_specified\",\n \"executability_preference\": \"prefer_native_ohdsi\",\n \"geography_coding_preference\": \"not_specified\"\n },\n \"shortlist_ids\": [\n \"ohdsi:1268\",\n \"ohdsi:1308\",\n \"ohdsi:1289\"\n ],\n \"needs_more_search\": false,\n \"reasoning_notes\": [\n \"Selected lung resection phenotypes (ohdsi:1268, ohdsi:1308, ohdsi:1289) directly relate to the study intent of patients undergoing lung resection, focusing on procedures and their postoperative complications.\"\n ]\n}" + }, + "planning_rerank": { + "intent_facets_raw": { + "condition_or_topic": "lung resection", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients undergoing surgery" + ] + }, + "intent_facets_effective": { + "condition_or_topic": "lung resection", + "clinical_topic_aliases": [], + "phenotype_role": "procedure", + "care_setting": "inpatient|outpatient", + "population_cue": "patients", + "validation_preference": "not_specified", + "executability_preference": "prefer_native_ohdsi", + "geography_coding_preference": "not_specified", + "role_cues": [ + "surgical procedure" + ], + "care_setting_cues": [ + "hospital", + "clinic" + ], + "population_cues": [ + "patients undergoing surgery" + ] + }, + "candidate_count": 12, + "planner_allowed_count": 5, + "planner_allowed_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869", + "ohdsi:1293", + "ohdsi:1289" + ], + "shortlist_enforcement": { + "strict_top_k": 5, + "strict_pool_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869", + "ohdsi:1293", + "ohdsi:1289" + ], + "planner_input_shortlist_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:1289" + ], + "dropped_ids": [], + "replaced_ids": [], + "blocked_pool_ids": [], + "blocked_candidate_reasons": {}, + "preferred_pool_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869", + "ohdsi:1293", + "ohdsi:1289" + ], + "plain_diagnosis_fill_skipped_ids": [], + "quality_threshold_skipped_ids": [ + "ohdsi:1289" + ], + "duplicate_topic_ids": [], + "dedupe_backfilled_ids": [], + "dedupe_applied": false, + "enforced_shortlist_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869" + ], + "enforced": true + }, + "candidates": [ + { + "rank": 1, + "original_rank": 1, + "phenotype_id": "ohdsi:1268", + "name": "[P] Lung Resection, adults, inpt stay, no ED, post op new Afib", + "metadata_score": 28.6833, + "retrieval_score": 0.8257, + "phenotype_role": "procedure", + "primary_clinical_topic": "Lung Resection", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post Op" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Lung Resection" + }, + { + "kind": "topic_context", + "delta": 5.8333, + "detail": "{\"context_conditions\": [\"Atrial Fibrillation\", \"Postoperative Afib\"], \"target_conditions\": [\"Lung Resection\"]}" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.1, + "detail": 0 + } + ] + }, + { + "rank": 2, + "original_rank": 6, + "phenotype_id": "ohdsi:1308", + "name": "[P] Lung Resection, adults, post op new Afib", + "metadata_score": 24.5833, + "retrieval_score": 0.7502, + "phenotype_role": "procedure", + "primary_clinical_topic": "Lung Resection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "Surgical Procedure" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Lung Resection" + }, + { + "kind": "topic_context", + "delta": 5.8333, + "detail": "{\"context_conditions\": [\"Atrial Fibrillation\", \"Postoperative AFib\"], \"target_conditions\": [\"Lung Resection\"]}" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Surgical Procedure" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 5 + } + ] + }, + { + "rank": 3, + "original_rank": 3, + "phenotype_id": "ohdsi:869", + "name": "[P] Lung Resection, adults, inpt stay, no ED", + "metadata_score": 22.81, + "retrieval_score": 0.8013, + "phenotype_role": "procedure", + "primary_clinical_topic": "Lung Resection", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Lung Resection" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.06, + "detail": 2 + } + ] + }, + { + "rank": 4, + "original_rank": 4, + "phenotype_id": "ohdsi:1293", + "name": "[P] Lung Resection, adults", + "metadata_score": 22.79, + "retrieval_score": 0.7714, + "phenotype_role": "procedure", + "primary_clinical_topic": "Lung Resection", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_primary", + "delta": 24.0, + "detail": "Lung Resection" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.04, + "detail": 3 + } + ] + }, + { + "rank": 5, + "original_rank": 8, + "phenotype_id": "ohdsi:1289", + "name": "[P] Major Non Cardiac Surgery, adults", + "metadata_score": -9.25, + "retrieval_score": 0.6074, + "phenotype_role": "procedure", + "primary_clinical_topic": "Surgery", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 7 + } + ] + }, + { + "rank": 6, + "original_rank": 9, + "phenotype_id": "ohdsi:865", + "name": "[P] Non-Emergent Major Non Cardiac Surgery among adults", + "metadata_score": -9.25, + "retrieval_score": 0.5605, + "phenotype_role": "procedure", + "primary_clinical_topic": "Surgery", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 8 + } + ] + }, + { + "rank": 7, + "original_rank": 10, + "phenotype_id": "ohdsi:877", + "name": "[P] Non-Emergent MNCS (age 18 or greater), post op Afib (parox)", + "metadata_score": -9.25, + "retrieval_score": 0.5541, + "phenotype_role": "complication", + "primary_clinical_topic": "Postoperative Cardiac Complications", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Non-Emergent MNCS" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Cardiac Complications" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 9 + } + ] + }, + { + "rank": 8, + "original_rank": 11, + "phenotype_id": "ohdsi:1106", + "name": "[P] Non-Emergent Major Non Cardiac Surgery no prior Opioid", + "metadata_score": -9.25, + "retrieval_score": 0.5481, + "phenotype_role": "procedure", + "primary_clinical_topic": "Surgery", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 10 + } + ] + }, + { + "rank": 9, + "original_rank": 12, + "phenotype_id": "ohdsi:1102", + "name": "[P] Coronary Artery Bypass Graft Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -9.25, + "retrieval_score": 0.3891, + "phenotype_role": "procedure", + "primary_clinical_topic": "Coronary Artery Bypass Graft Surgery", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Post CABG Afib" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Coronary Artery Bypass Graft Surgery" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 11 + } + ] + }, + { + "rank": 10, + "original_rank": 7, + "phenotype_id": "cipher:30650", + "name": "Smoking Status (Fairhust)", + "metadata_score": -9.3875, + "retrieval_score": 0.6179, + "phenotype_role": "comorbidity_covariate", + "primary_clinical_topic": "Smoking Status", + "care_setting_scope": "mixed", + "exclude_from_primary_topic_match": [ + "COVID-19", + "Cancer" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Smoking Status" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "mixed" + }, + { + "kind": "execution_readiness", + "delta": 0.1125, + "detail": 0.45 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.0, + "detail": 6 + } + ] + }, + { + "rank": 11, + "original_rank": 2, + "phenotype_id": "ohdsi:878", + "name": "[P] Major Non Cardiac Surgery, adults, inpt stay, no ED, post op new Afib", + "metadata_score": -13.17, + "retrieval_score": 0.8212, + "phenotype_role": "complication", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.08, + "detail": 1 + } + ] + }, + { + "rank": 12, + "original_rank": 5, + "phenotype_id": "ohdsi:1304", + "name": "[P] Major Non Cardiac Surgery, adults, post op new Afib", + "metadata_score": -13.23, + "retrieval_score": 0.7648, + "phenotype_role": "outcome", + "primary_clinical_topic": "Postoperative Afib", + "care_setting_scope": "inpatient", + "exclude_from_primary_topic_match": [ + "Cardiac Surgery" + ], + "reasons": [ + { + "kind": "topic_mismatch", + "delta": -8.0, + "detail": "Postoperative Afib" + }, + { + "kind": "care_setting_penalty", + "delta": -1.5, + "detail": "inpatient" + }, + { + "kind": "exclude_procedure", + "delta": -4.0, + "detail": [ + "Cardiac Surgery" + ] + }, + { + "kind": "execution_readiness", + "delta": 0.25, + "detail": 1.0 + }, + { + "kind": "search_rank_tiebreak", + "delta": 0.02, + "detail": 4 + } + ] + } + ] + }, + "final_validation": { + "rejected": false, + "reason": null, + "invalid_ids": [], + "duplicate_ids": [], + "allowed_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869" + ], + "valid_unique_count": 3 + }, + "final_deterministic": { + "selected_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869" + ], + "matched_llm_ids": [ + "ohdsi:1268", + "ohdsi:1308", + "ohdsi:869" + ], + "defaulted_ids": [], + "invalid_llm_ids": [], + "duplicate_llm_ids": [], + "used_llm_justification_count": 3, + "used_default_justification_count": 0 + }, + "final": { + "llm_status": "ok", + "llm_duration_seconds": 5.448719501495361, + "llm_error": null, + "llm_parse_stage": "chat_completions_content", + "llm_schema_valid": true, + "llm_request_mode": "chat_completions", + "llm_raw_response": "{\"id\":\"gemma3:4b-ad14d651-24e0-4d75-bdcb-1c424420a036\",\"created\":1778090580,\"model\":\"gemma3:4b\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"```json\\n{\\n \\\"plan\\\": \\\"Recommend lung resection phenotypes based on study intent.\\\",\\n \\\"phenotype_recommendations\\\": [\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1268\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, inpt stay, no ED, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype directly aligns with the study intent of patients undergoing lung resection and developing post-operative atrial fibrillation.\\\",\\n \\\"confidence\\\": 0.8256917666679979\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:1308\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, post op new Afib\\\",\\n \\\"justification\\\": \\\"This phenotype is related to lung resection and post-operative atrial fibrillation, complementing the primary intent.\\\",\\n \\\"confidence\\\": 0.7501510002971872\\n },\\n {\\n \\\"phenotype_id\\\": \\\"ohdsi:869\\\",\\n \\\"phenotype_name\\\": \\\"Lung Resection, adults, inpt stay, no ED\\\",\\n \\\"justification\\\": \\\"This phenotype represents the procedure of lung resection associated with inpatient stays, a relevant component of the study intent.\\\",\\n \\\"confidence\\\": 0.8012927705679814\\n }\\n ]\\n}\\n```\"}}],\"object\":\"chat.completion\",\"usage\":{\"input_tokens\":1410,\"output_tokens\":333,\"total_tokens\":1743,\"prompt_tokens\":1410,\"completion_tokens\":333,\"response_token/s\":99.08,\"prompt_token/s\":4096.49,\"total_duration\":5180320969,\"load_duration\":700940085,\"prompt_eval_count\":1410,\"prompt_eval_duration\":344197099,\"eval_count\":333,\"eval_duration\":3360780705,\"approximate_total\":\"0h0m5s\",\"completion_tokens_details\":{\"reasoning_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}", + "llm_content_text": "{\n \"plan\": \"Recommend lung resection phenotypes based on study intent.\",\n \"phenotype_recommendations\": [\n {\n \"phenotype_id\": \"ohdsi:1268\",\n \"phenotype_name\": \"Lung Resection, adults, inpt stay, no ED, post op new Afib\",\n \"justification\": \"This phenotype directly aligns with the study intent of patients undergoing lung resection and developing post-operative atrial fibrillation.\",\n \"confidence\": 0.8256917666679979\n },\n {\n \"phenotype_id\": \"ohdsi:1308\",\n \"phenotype_name\": \"Lung Resection, adults, post op new Afib\",\n \"justification\": \"This phenotype is related to lung resection and post-operative atrial fibrillation, complementing the primary intent.\",\n \"confidence\": 0.7501510002971872\n },\n {\n \"phenotype_id\": \"ohdsi:869\",\n \"phenotype_name\": \"Lung Resection, adults, inpt stay, no ED\",\n \"justification\": \"This phenotype represents the procedure of lung resection associated with inpatient stays, a relevant component of the study intent.\",\n \"confidence\": 0.8012927705679814\n }\n ]\n}" + } + } + } +} + +] diff --git a/docs/evaluation/phenotype_recommendations/phenotype_summary.csv b/docs/evaluation/phenotype_recommendations/phenotype_summary.csv new file mode 100644 index 0000000..fa526a2 --- /dev/null +++ b/docs/evaluation/phenotype_recommendations/phenotype_summary.csv @@ -0,0 +1,11 @@ +benchmark_name,elapsed_seconds +"Cardiac defibrillator in situ (MAP)",16.523 +"Fasciitis (gwPheWAS)",14.100 +"Acute prostatitis (MAP)",13.788 +"Esophagectomy",15.259 +"Peripheral neuritis",13.943 +"TNF-alpha + IL12/23 overlap",17.547 +"Allergic rhinitis",13.268 +"Ischemic Heart Disease (Sandhu)",14.457 +"Hemorrhage in Early Pregnancy",15.343 +"Lung Resection",15.529 diff --git a/dodo.py b/dodo.py index 0f3ca6d..a082890 100644 --- a/dodo.py +++ b/dodo.py @@ -854,7 +854,7 @@ def _run_smoke() -> None: payload = json.dumps( { "phenotype": "Gastrointestinal bleeding", - "domain_keys": ["doi", "alternativeDiagnosis", "symptoms"], + "domain_keys": ["doi"], # could also add/change to "alternativeDiagnosis", "symptoms", "drugs", "treatmentProcedures", "diagnosticProcedures", "measurements" "candidate_limit": 10, "include_diagnostics": True, } From 14a0412079013266dfeae183cbe02390b6dfebe1 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 03:55:35 -0500 Subject: [PATCH 02/20] the first clean-refactor to refactor the R code into two slashOhdsi ("/ohdsi") packages --- R/slashOhdsiAcpClient/DESCRIPTION | 12 + R/slashOhdsiAcpClient/NAMESPACE | 16 + R/slashOhdsiAcpClient/R/actions_and_lint.R | 72 ++++ R/slashOhdsiAcpClient/R/client.R | 87 +++++ R/slashOhdsiAcpClient/R/flows.R | 132 +++++++ R/slashOhdsiAcpClient/R/utils.R | 20 + R/slashOhdsiAcpClient/README.md | 11 + R/slashOhdsiStrategusAssistant/DESCRIPTION | 12 + R/slashOhdsiStrategusAssistant/NAMESPACE | 3 + .../R/workflow_stage_context.R | 101 +++++ R/slashOhdsiStrategusAssistant/README.md | 12 + docs/R_PACKAGE_ARCHITECTURE_PLAN.md | 362 ++++++++++++++++++ 12 files changed, 840 insertions(+) create mode 100644 R/slashOhdsiAcpClient/DESCRIPTION create mode 100644 R/slashOhdsiAcpClient/NAMESPACE create mode 100644 R/slashOhdsiAcpClient/R/actions_and_lint.R create mode 100644 R/slashOhdsiAcpClient/R/client.R create mode 100644 R/slashOhdsiAcpClient/R/flows.R create mode 100644 R/slashOhdsiAcpClient/R/utils.R create mode 100644 R/slashOhdsiAcpClient/README.md create mode 100644 R/slashOhdsiStrategusAssistant/DESCRIPTION create mode 100644 R/slashOhdsiStrategusAssistant/NAMESPACE create mode 100644 R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R create mode 100644 R/slashOhdsiStrategusAssistant/README.md create mode 100644 docs/R_PACKAGE_ARCHITECTURE_PLAN.md diff --git a/R/slashOhdsiAcpClient/DESCRIPTION b/R/slashOhdsiAcpClient/DESCRIPTION new file mode 100644 index 0000000..51bea39 --- /dev/null +++ b/R/slashOhdsiAcpClient/DESCRIPTION @@ -0,0 +1,12 @@ +Package: slashOhdsiAcpClient +Type: Package +Title: ACP Client for slash-ohdsi Workflow Integrations +Version: 0.0.1 +Authors@R: person("Your","Name", email="you@example.org", role=c("aut","cre")) +Description: Thin R client for the OHDSI Study Agent ACP bridge. Provides explicit client objects, health checks, and typed wrappers around ACP flows and actions. +License: MIT + file LICENSE +Encoding: UTF-8 +Depends: R (>= 4.2) +Imports: httr, jsonlite +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 diff --git a/R/slashOhdsiAcpClient/NAMESPACE b/R/slashOhdsiAcpClient/NAMESPACE new file mode 100644 index 0000000..20d654e --- /dev/null +++ b/R/slashOhdsiAcpClient/NAMESPACE @@ -0,0 +1,16 @@ +export(acp_call_action) +export(acp_call_flow) +export(acp_check_health) +export(acp_client) +export(acp_cohort_methods_intent_split) +export(acp_concept_set_edit) +export(acp_execute_llm_actions_concept_set) +export(acp_is_connected) +export(acp_keeper_concept_sets_generate) +export(acp_lint_cohort_general_design) +export(acp_lint_concept_sets) +export(acp_phenotype_recommendation_advice) +export(acp_review_phenotypes) +export(acp_suggest_cohort_method_specs) +export(acp_suggest_phenotypes) +export(acp_workflow_context_dialogue) diff --git a/R/slashOhdsiAcpClient/R/actions_and_lint.R b/R/slashOhdsiAcpClient/R/actions_and_lint.R new file mode 100644 index 0000000..fbc253d --- /dev/null +++ b/R/slashOhdsiAcpClient/R/actions_and_lint.R @@ -0,0 +1,72 @@ +#' Call ACP execute-llm action for concept-set edits +#' @param client ACP client object +#' @param concept_set_ref path to concept set JSON +#' @param actions list of ACP action objects +#' @param write when TRUE, write changes through ACP +#' @param overwrite whether ACP should overwrite the source file +#' @param backup whether ACP should create a backup before overwrite +#' @return parsed ACP response +#' @export +acp_execute_llm_actions_concept_set <- function(client, + concept_set_ref, + actions, + write = FALSE, + overwrite = FALSE, + backup = TRUE) { + body <- list( + artifactRef = concept_set_ref, + actions = actions %||% list(), + write = isTRUE(write), + overwrite = isTRUE(overwrite), + backup = isTRUE(backup) + ) + acp_call_action(client, "execute_llm", body) +} + +#' Call ACP concept-set edit action +#' @param client ACP client object +#' @param artifact_ref path or URL to concept set JSON +#' @param ops deterministic concept-set operations +#' @param write whether ACP should write the result +#' @param backup whether ACP should create a backup +#' @param output_path optional output path +#' @return parsed ACP response +#' @export +acp_concept_set_edit <- function(client, + artifact_ref, + ops, + write = FALSE, + backup = TRUE, + output_path = NULL) { + body <- list( + artifactRef = artifact_ref, + ops = ops %||% list(), + write = isTRUE(write), + backup = isTRUE(backup) + ) + if (!is.null(output_path)) body$outputPath <- output_path + acp_call_action(client, "concept_set_edit", body) +} + +#' Call ACP concept-set lint flow +#' @param client ACP client object +#' @param concept_set_path path to concept set JSON +#' @param study_intent study intent text +#' @return parsed ACP response +#' @export +acp_lint_concept_sets <- function(client, concept_set_path, study_intent = "") { + body <- list( + concept_set_path = concept_set_path, + study_intent = as.character(study_intent %||% "") + ) + acp_call_flow(client, "concept_sets_review", body) +} + +#' Call ACP general cohort critique flow +#' @param client ACP client object +#' @param cohort_path path to cohort JSON +#' @return parsed ACP response +#' @export +acp_lint_cohort_general_design <- function(client, cohort_path) { + acp_call_flow(client, "cohort_critique_general_design", list(cohort_path = cohort_path)) +} diff --git a/R/slashOhdsiAcpClient/R/client.R b/R/slashOhdsiAcpClient/R/client.R new file mode 100644 index 0000000..eb65527 --- /dev/null +++ b/R/slashOhdsiAcpClient/R/client.R @@ -0,0 +1,87 @@ +#' Create an ACP client object +#' @param url ACP base URL, e.g. "http://127.0.0.1:8765" +#' @param token optional bearer token +#' @param check when TRUE, call `/health` before returning +#' @return ACP client object +#' @export +acp_client <- function(url = "http://127.0.0.1:8765", token = NULL, check = TRUE) { + client <- structure( + list( + url = sub("/$", "", as.character(url)), + token = token + ), + class = "acp_client" + ) + if (isTRUE(check)) acp_check_health(client) + client +} + +#' Check ACP health +#' @param client ACP client object +#' @return invisible(TRUE) when ACP is reachable +#' @export +acp_check_health <- function(client) { + client <- .as_acp_client(client) + resp <- httr::GET(paste0(client$url, "/health"), httr::timeout(.acp_timeout_seconds())) + if (httr::status_code(resp) != 200) stop("ACP bridge not reachable") + invisible(TRUE) +} + +#' Determine whether an ACP client object appears valid +#' @param client object to inspect +#' @return logical scalar +#' @export +acp_is_connected <- function(client) { + inherits(client, "acp_client") && is.character(client$url) && nzchar(client$url) +} + +.as_acp_client <- function(client) { + if (!acp_is_connected(client)) { + stop("Provide an ACP client created with acp_client().") + } + client +} + +.acp_headers <- function(client) { + headers <- c(`Content-Type` = "application/json") + if (!is.null(client$token) && nzchar(as.character(client$token))) { + headers <- c(headers, Authorization = paste("Bearer", client$token)) + } + headers +} + +.acp_post_json <- function(client, path, body) { + client <- .as_acp_client(client) + body <- .normalize_acp_body(body) + resp <- httr::POST( + paste0(client$url, path), + body = body, + encode = "json", + httr::add_headers(.headers = .acp_headers(client)), + httr::timeout(.acp_timeout_seconds()) + ) + if (httr::status_code(resp) >= 300) { + stop("ACP error: ", httr::content(resp, as = "text", encoding = "UTF-8")) + } + jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8"), simplifyVector = FALSE) +} + +#' Call an ACP flow endpoint +#' @param client ACP client object +#' @param flow_name flow name without the `/flows/` prefix +#' @param body request payload +#' @return parsed ACP response +#' @export +acp_call_flow <- function(client, flow_name, body = list()) { + .acp_post_json(client, sprintf("/flows/%s", flow_name), body) +} + +#' Call an ACP action endpoint +#' @param client ACP client object +#' @param action_name action name without the `/actions/` prefix +#' @param body request payload +#' @return parsed ACP response +#' @export +acp_call_action <- function(client, action_name, body = list()) { + .acp_post_json(client, sprintf("/actions/%s", action_name), body) +} diff --git a/R/slashOhdsiAcpClient/R/flows.R b/R/slashOhdsiAcpClient/R/flows.R new file mode 100644 index 0000000..f360ec1 --- /dev/null +++ b/R/slashOhdsiAcpClient/R/flows.R @@ -0,0 +1,132 @@ +#' Call phenotype recommendation flow +#' @param client ACP client object +#' @param study_intent non-empty study intent string +#' @param top_k number of candidates to retrieve +#' @param max_results maximum recommendations to return +#' @param candidate_limit optional candidate limit forwarded to ACP +#' @return parsed ACP response +#' @export +acp_suggest_phenotypes <- function(client, + study_intent, + top_k = 20, + max_results = 10, + candidate_limit = NULL) { + if (is.null(study_intent) || !nzchar(trimws(as.character(study_intent)))) { + stop("Provide a non-empty study_intent.") + } + body <- list( + study_intent = trimws(as.character(study_intent)), + top_k = top_k, + max_results = max_results + ) + if (!is.null(candidate_limit)) body$candidate_limit <- candidate_limit + acp_call_flow(client, "phenotype_recommendation", body) +} + +#' Call phenotype recommendation advice flow +#' @param client ACP client object +#' @param study_intent non-empty study intent string +#' @return parsed ACP response +#' @export +acp_phenotype_recommendation_advice <- function(client, study_intent) { + if (is.null(study_intent) || !nzchar(trimws(as.character(study_intent)))) { + stop("Provide a non-empty study_intent.") + } + acp_call_flow(client, "phenotype_recommendation_advice", list(study_intent = trimws(as.character(study_intent)))) +} + +#' Call phenotype improvements flow +#' @param client ACP client object +#' @param protocol_path path to protocol markdown or text +#' @param cohort_paths cohort JSON paths +#' @return parsed ACP response +#' @export +acp_review_phenotypes <- function(client, protocol_path, cohort_paths) { + cohort_paths <- as.character(cohort_paths %||% character(0)) + if (!length(cohort_paths)) stop("Provide at least one cohort path.") + body <- list( + protocol_path = normalizePath(protocol_path, winslash = "/", mustWork = FALSE), + cohort_paths = as.list(unname(vapply(cohort_paths, normalizePath, character(1), winslash = "/", mustWork = FALSE))) + ) + acp_call_flow(client, "phenotype_improvements", body) +} + +#' Call cohort methods intent split flow +#' @param client ACP client object +#' @param study_intent non-empty study intent string +#' @return parsed ACP response +#' @export +acp_cohort_methods_intent_split <- function(client, study_intent) { + if (is.null(study_intent) || !nzchar(trimws(as.character(study_intent)))) { + stop("Provide a non-empty study_intent.") + } + acp_call_flow(client, "cohort_methods_intent_split", list(study_intent = trimws(as.character(study_intent)))) +} + +#' Call cohort methods specification recommendation flow +#' @param client ACP client object +#' @param study_intent protocol context string +#' @param analytic_settings_description free-text analytic settings description +#' @return parsed ACP response +#' @export +acp_suggest_cohort_method_specs <- function(client, + study_intent, + analytic_settings_description) { + if (is.null(study_intent) || !nzchar(trimws(as.character(study_intent)))) { + stop("Provide a non-empty study_intent.") + } + if (is.null(analytic_settings_description) || !nzchar(trimws(as.character(analytic_settings_description)))) { + stop("Provide a non-empty analytic_settings_description.") + } + body <- list( + study_intent = trimws(as.character(study_intent)), + study_description = trimws(as.character(analytic_settings_description)), + analytic_settings_description = trimws(as.character(analytic_settings_description)) + ) + acp_call_flow(client, "cohort_methods_specifications_recommendation", body) +} + +#' Call workflow context dialogue flow +#' @param client ACP client object +#' @param stage_context workflow-stage context object +#' @param message latest user message +#' @return parsed ACP response +#' @export +acp_workflow_context_dialogue <- function(client, stage_context, message) { + if (!is.list(stage_context)) stop("stage_context must be a list.") + if (is.null(message) || !nzchar(trimws(as.character(message)))) { + stop("Provide a non-empty message.") + } + body <- list( + workflow_stage_context = stage_context, + message = trimws(as.character(message)) + ) + acp_call_flow(client, "workflow_context_dialogue", body) +} + +#' Call keeper concept set generation flow +#' @param client ACP client object +#' @param phenotype phenotype label +#' @param domain_keys character vector of domain keys +#' @param candidate_limit candidate limit +#' @param include_diagnostics whether to request diagnostics +#' @return parsed ACP response +#' @export +acp_keeper_concept_sets_generate <- function(client, + phenotype, + domain_keys, + candidate_limit = 5, + include_diagnostics = TRUE) { + if (is.null(phenotype) || !nzchar(trimws(as.character(phenotype)))) { + stop("Provide a non-empty phenotype.") + } + domain_keys <- as.character(domain_keys %||% character(0)) + if (!length(domain_keys)) stop("Provide at least one domain key.") + body <- list( + phenotype = trimws(as.character(phenotype)), + domain_keys = as.list(domain_keys), + candidate_limit = candidate_limit, + include_diagnostics = isTRUE(include_diagnostics) + ) + acp_call_flow(client, "keeper_concept_sets_generate", body) +} diff --git a/R/slashOhdsiAcpClient/R/utils.R b/R/slashOhdsiAcpClient/R/utils.R new file mode 100644 index 0000000..0219b21 --- /dev/null +++ b/R/slashOhdsiAcpClient/R/utils.R @@ -0,0 +1,20 @@ +`%||%` <- function(a, b) if (is.null(a)) b else a + +.normalize_acp_body <- function(body) { + if (!is.list(body)) return(body) + if (!is.null(body$protocolRef)) body$protocolRef <- as.character(body$protocolRef) + if (!is.null(body$cohortsCatalogRef)) body$cohortsCatalogRef <- as.character(body$cohortsCatalogRef) + if (!is.null(body$cohortRefs)) { + body$cohortRefs <- as.list(unname(vapply(body$cohortRefs, as.character, character(1)))) + } + if (!is.null(body$characterizationRefs)) { + body$characterizationRefs <- as.list(unname(vapply(body$characterizationRefs, as.character, character(1)))) + } + body +} + +.acp_timeout_seconds <- function(default = 180) { + timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) + if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default + timeout_seconds +} diff --git a/R/slashOhdsiAcpClient/README.md b/R/slashOhdsiAcpClient/README.md new file mode 100644 index 0000000..39934fd --- /dev/null +++ b/R/slashOhdsiAcpClient/README.md @@ -0,0 +1,11 @@ +# slashOhdsiAcpClient + +This package is the low-level ACP client for the slash-ohdsi R refactor. + +It owns: + +- ACP client construction +- HTTP transport and timeout handling +- flow and action wrappers + +It does not own Strategus shells, checkpointing, or workflow-stage decisions. diff --git a/R/slashOhdsiStrategusAssistant/DESCRIPTION b/R/slashOhdsiStrategusAssistant/DESCRIPTION new file mode 100644 index 0000000..ef6c759 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/DESCRIPTION @@ -0,0 +1,12 @@ +Package: slashOhdsiStrategusAssistant +Type: Package +Title: Strategus Workflow Assistant for slash-ohdsi +Version: 0.0.1 +Authors@R: person("Your","Name", email="you@example.org", role=c("aut","cre")) +Description: Higher-level Strategus workflow package for slash-ohdsi. Owns workflow-stage context construction, shell orchestration, checkpoints, and Strategus-facing assets. +License: MIT + file LICENSE +Encoding: UTF-8 +Depends: R (>= 4.2) +Imports: slashOhdsiAcpClient +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 diff --git a/R/slashOhdsiStrategusAssistant/NAMESPACE b/R/slashOhdsiStrategusAssistant/NAMESPACE new file mode 100644 index 0000000..b24f256 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/NAMESPACE @@ -0,0 +1,3 @@ +export(new_workflow_stage_context) +export(validate_workflow_stage_context) +export(workflow_stage_step_choices) diff --git a/R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R b/R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R new file mode 100644 index 0000000..8611683 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R @@ -0,0 +1,101 @@ +`%||%` <- function(a, b) if (is.null(a)) b else a + +workflow_stage_step_choices <- function() { + c( + "study_intent_capture", + "intent_split", + "target_selection", + "comparator_selection", + "outcome_selection", + "phenotype_review", + "keeper_concept_set_generation", + "keeper_case_review", + "analytic_settings_collection", + "cohort_method_spec_recommendation", + "cohort_method_spec_confirmation", + "incidence_design_setup", + "time_at_risk_configuration", + "workflow_summary" + ) +} + +#' Construct a workflow stage context object +#' @param workflow_type workflow identifier such as `strategus_cohort_methods` +#' @param current_step controlled step identifier +#' @param user_goal study intent or current user goal +#' @param step_label optional human-readable step label +#' @param entities optional entity state list +#' @param available_artifacts optional artifact references list +#' @param prior_questions optional list of prior question summaries +#' @param prior_answers optional list of prior answer summaries +#' @param last_user_message optional latest user message +#' @param constraints optional constraint flags +#' @param contract_version contract version integer +#' @return validated workflow stage context list +#' @export +new_workflow_stage_context <- function(workflow_type, + current_step, + user_goal, + step_label = NULL, + entities = NULL, + available_artifacts = NULL, + prior_questions = NULL, + prior_answers = NULL, + last_user_message = NULL, + constraints = NULL, + contract_version = 1L) { + context <- list( + contract_version = as.integer(contract_version), + workflow_type = as.character(workflow_type), + current_step = as.character(current_step), + step_label = step_label %||% gsub("_", " ", as.character(current_step), fixed = TRUE), + user_goal = as.character(user_goal), + entities = entities %||% list(target = NULL, comparator = NULL, outcomes = list()), + available_artifacts = available_artifacts %||% list( + protocol_path = NULL, + selected_target_ids = list(), + selected_comparator_ids = list(), + selected_outcome_ids = list(), + analysis_settings_path = NULL, + concept_set_paths = list() + ), + dialogue = list( + prior_questions = prior_questions %||% list(), + prior_answers = prior_answers %||% list(), + last_user_message = last_user_message + ), + constraints = constraints %||% list( + interactive = TRUE, + allow_recommendations = TRUE, + allow_generation = FALSE + ) + ) + validate_workflow_stage_context(context) + context +} + +#' Validate a workflow stage context object +#' @param context workflow stage context list +#' @return invisible(TRUE) when valid +#' @export +validate_workflow_stage_context <- function(context) { + if (!is.list(context)) stop("workflow stage context must be a list.") + required_fields <- c("contract_version", "workflow_type", "current_step", "user_goal") + missing_fields <- required_fields[!required_fields %in% names(context)] + if (length(missing_fields)) { + stop(sprintf("workflow stage context is missing required field(s): %s", paste(missing_fields, collapse = ", "))) + } + if (!nzchar(trimws(context$workflow_type %||% ""))) { + stop("workflow_type must be non-empty.") + } + if (!nzchar(trimws(context$current_step %||% ""))) { + stop("current_step must be non-empty.") + } + if (!(context$current_step %in% workflow_stage_step_choices())) { + stop(sprintf("Unsupported current_step '%s'.", context$current_step)) + } + if (!nzchar(trimws(context$user_goal %||% ""))) { + stop("user_goal must be non-empty.") + } + invisible(TRUE) +} diff --git a/R/slashOhdsiStrategusAssistant/README.md b/R/slashOhdsiStrategusAssistant/README.md new file mode 100644 index 0000000..04959df --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/README.md @@ -0,0 +1,12 @@ +# slashOhdsiStrategusAssistant + +This package is the workflow layer for the slash-ohdsi R refactor. + +It owns: + +- workflow-stage context construction +- Strategus shell orchestration +- checkpointing and artifact layout +- generated Strategus assets + +It depends on `slashOhdsiAcpClient` for ACP calls. diff --git a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md new file mode 100644 index 0000000..1543405 --- /dev/null +++ b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md @@ -0,0 +1,362 @@ +# R Package Architecture Plan + +## Purpose + +This document expands sprint item 3 in `CURRENT-SPRINT-PLAN.md` into a concrete architecture plan for the R side of the project. + +The current `R/OHDSIAssistant/` package mixes three responsibilities: + +- ACP transport and call/response helpers +- thin R wrappers around ACP flows and actions +- high-level Strategus workflow orchestration and interactive shells + +That coupling is already visible in the current code: + +- [`R/OHDSIAssistant/R/acp_client.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/acp_client.R) owns connection state and raw POST behavior +- [`R/OHDSIAssistant/R/phenotype_workflow.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/phenotype_workflow.R), [`R/OHDSIAssistant/R/cohort_methods_workflow.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/cohort_methods_workflow.R), [`R/OHDSIAssistant/R/lintStudyDesign.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/lintStudyDesign.R), and [`R/OHDSIAssistant/R/concept_set_actions.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/concept_set_actions.R) are flow/action wrappers but still depend directly on the transport internals +- [`R/OHDSIAssistant/R/strategus_incidence_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/strategus_incidence_shell.R) and [`R/OHDSIAssistant/R/strategus_cohort_methods_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R) directly call ACP endpoints while also owning interactive workflow state, checkpoints, artifact layout, and script generation + +The package split should isolate those concerns before more `/ohdsi` dialogue work, incidence-shell extension, and concept-set generation integration are added. + +## Goals + +1. Create a small ACP-focused R package with a stable, testable HTTP interface and typed request/response helpers. +2. Move Strategus shells and workflow orchestration into a separate higher-level package that depends on the ACP package rather than internal transport functions. +3. Define one small workflow-stage contract that all shells use when asking ACP for contextual dialogue or recommendations. +4. Keep the migration incremental so the existing `OHDSIAssistant` package can continue to work during the transition. + +## Non-Goals + +- Rewriting the ACP server API +- Reworking the generated Strategus script content unless required by the package split +- Solving future Atlas integration now beyond defining the contract seam + +## Proposed Package Split + +### Package A: ACP client package + +Working name: `slashOhdsiAcpClient` + +Responsibility: + +- manage ACP connection configuration +- perform authenticated HTTP requests +- expose thin, documented wrappers around ACP flows and actions +- normalize request payloads and response parsing +- provide error handling, timeout handling, and optional retry helpers + +This package should not: + +- own interactive shell state +- write Strategus project folders or scripts +- decide workflow progression +- embed stage-specific assumptions about cohort methods vs incidence workflows beyond request payload fields + +### Package B: Strategus workflow package + +Working name: `slashOhdsiStrategusAssistant` + +Responsibility: + +- own user-facing workflow shells +- collect inputs, manage checkpoints, and maintain local artifact layout +- interpret ACP responses in workflow context +- generate Strategus-ready scripts and analysis assets +- decide when to call dialogue, recommendation, improvement, or concept-set flows + +This package should depend on Package A only through exported functions and response objects. + +## Proposed Ownership Mapping + +### Move into ACP client package + +- `R/acp_client.R` +- `R/ops_llm_actions.R` +- thin ACP wrappers now embedded in: + - `R/phenotype_workflow.R` + - `R/cohort_methods_workflow.R` + - `R/lintStudyDesign.R` + - `R/concept_set_actions.R` + +Target exports in the ACP client package should look more like: + +- `acp_connect()` +- `acp_is_connected()` +- `acp_call_flow(flow_name, body)` +- `acp_call_action(action_name, body)` +- `acp_suggest_phenotypes(...)` +- `acp_review_phenotypes(...)` +- `acp_suggest_cohort_method_specs(...)` +- `acp_workflow_context_dialogue(...)` +- `acp_keeper_concept_sets_generate(...)` +- `acp_lint_study_design(...)` + +The important change is not only moving code. The workflow package must stop depending on unexported transport internals such as `.acp_post` and `acp_state`. + +### Keep or move into Strategus workflow package + +- `R/strategus_incidence_shell.R` +- `R/strategus_cohort_methods_shell.R` +- `R/db_details.R` +- `R/execution_settings.R` +- `R/utils_json.R` +- local script-generation helpers +- selection helpers that are meaningful only in workflow context +- artifact copy/apply helpers for selected cohorts and patched cohorts + +### Likely split between both packages + +Current files that mix concerns should be decomposed: + +- `R/phenotype_workflow.R` + - ACP client package: request/response wrappers + - workflow package: interactive selection and local definition-pull orchestration if still needed there +- `R/cohort_methods_workflow.R` + - ACP client package: spec recommendation call + - workflow package: any shell-facing summary rendering or default reconciliation +- `R/lintStudyDesign.R` + - ACP client package if retained as a generic ACP consumer + - workflow package only if it remains positioned as a Strategus-shell utility +- `R/concept_set_actions.R` + - ACP client package for generic concept-set action calls + - workflow package only for shell-local convenience wrappers if required + +## Workflow Stage Contract + +This is the key foundation for items 1, 2, and 4. + +The workflow package should pass a single small object into the ACP client package whenever context-aware dialogue or stage-specific recommendations are needed. + +Proposed R shape: + +```r +workflow_stage_context <- list( + workflow_type = "strategus_cohort_methods", # or "strategus_incidence" + current_step = "target_selection", # or others shown in the "Shared stages" and workflow-sepecific sections below + step_label = "Target cohort selection", # and others mentioned below + user_goal = studyIntent, + entities = list( + target = NULL, + comparator = NULL, + outcomes = list() + ), + available_artifacts = list( + protocol_path = NULL, + selected_target_ids = list(), + selected_comparator_ids = list(), + selected_outcome_ids = list(), + analysis_settings_path = NULL, + concept_set_paths = list() + ), + dialogue = list( + prior_questions = list(), + prior_answers = list(), # each item could be an identifiers of a JSONL record and a short ~50 character summary that the LLM could use to request more details if needed so context does not grow + last_user_message = NULL + ), + constraints = list( + interactive = TRUE, + allow_recommendations = TRUE, + allow_generation = FALSE + ) +) +``` + +Required fields for the first pass: + +- `workflow_type` +- `current_step` +- `user_goal` + +Recommended fields for the first pass: + +- `entities` +- `available_artifacts` +- `dialogue$last_user_message` +- `constraints` + +Rules for the contract: + +1. `current_step` must be a small controlled vocabulary owned by the workflow package. +2. ACP client functions should forward the object without embedding shell-specific branching into the transport layer. +3. The contract must be versionable. Add `contract_version = 1L` when this is implemented. +4. The same top-level fields must work for cohort-method, incidence shells, and other worflow shells, with some fields empty when irrelevant. + +## Controlled Vocabulary for `current_step` + +Use a stable step vocabulary now so item 2 does not invent a second shape later. + +Shared stages: + +- `study_intent_capture` +- `intent_split` # incidence analysis splits to "target" and "outcome" while cohort method adds "comparator" +- `target_selection` +- `outcome_selection` +- `phenotype_review` +- `keeper_concept_set_generation` +- `keeper_case_review` +- `workflow_summary` + +Cohort-method-specific stages: +- `comparator_selection` +- `analytic_settings_collection` +- `cohort_method_spec_recommendation` +- `cohort_method_spec_confirmation` + +Incidence-specific stages: + +- `incidence_design_setup` +- `time_at_risk_configuration` + +These labels do not need to be identical to UI labels. They should be stable machine-facing identifiers. + +## API Shape Between Packages + +The workflow package should not build raw endpoint paths. Instead it should call exported ACP package functions such as: + +```r +client <- acp_client(url = acpUrl, token = NULL) + +resp <- acp_workflow_context_dialogue( + client = client, + stage_context = workflow_stage_context, + message = user_message +) +``` + +Preferred client pattern: + +- explicit client object returned from `acp_client()` or similar constructor +- no hidden global mutable state as the primary interface +- No need for a compatibility bridge for `acp_connect()` during migration - this can be a clean refactor + +Why this matters: + +- shells can hold their own client handle +- tests can inject mock clients +- multiple ACP endpoints can be targeted in one R session if needed +- the workflow package stops depending on package-global side effects + +## Migration Plan + +### Phase 0: Document and freeze the seam + +- create this design note +- agree on package names or temporary names +- agree on the `workflow_stage_context` fields and `current_step` vocabulary + +Deliverable: + +- approved architecture note and contract + +### Phase 1: Extract the ACP client surface without changing behavior + +- create a new package directory for the ACP client +- move `acp_connect()` and `.acp_post()` behavior behind exported client functions +- add wrappers for the currently used flows and actions +- keep response shapes unchanged where possible + +Deliverable: + +- workflow code can call exported ACP helpers without using `.acp_post` + +### Phase 2: Refactor shells to consume the ACP package + +- replace direct `.acp_post()` usage in both Strategus shells +- replace checks against `acp_state$url` with explicit ACP client availability checks +- route all `/ohdsi` or stage-aware dialogue calls through `acp_workflow_context_dialogue()` + +Deliverable: + +- both shells depend only on the ACP package public API + +### Phase 3: Introduce the stage-context contract + +- define helper constructors in the workflow package for stage context objects +- update cohort-method workflow dialogue calls to use the contract +- extend `runStrategusIncidenceShell()` to use the same contract and `current_step` semantics + +Deliverable: + +- one shared stage-context payload across both shells + +### Phase 4: Integrate `keeper_concept_sets_generate` through the new seam + +- add a thin ACP wrapper in the ACP client package +- add workflow-package integration points for concept-set generation near covariate concept-set selection +- keep the function reusable outside shells + +Deliverable: + +- item 4 can land without reintroducing cross-layer coupling + +### Phase 5: Compatibility cleanup + +- This is a clean refactor that can replace the deprecated old combined exports from `OHDSIAssistant` +- update README and examples +- add package-focused tests + +Deliverable: + +- clear public surface and lower maintenance cost + +## Immediate Implementation Recommendations + +These are the concrete next code tasks that should happen first. + +1. Introduce an ACP client object and exported flow wrappers before any new incidence-shell `/ohdsi` work. +2. Extract a shared helper that builds `workflow_stage_context` objects from shell state. +3. Replace direct `.acp_post("/flows/workflow_context_dialogue", ...)` usage in the cohort-method shell with a wrapper call. +4. Extend the incidence shell to use the same wrapper and controlled `current_step` labels. +5. Only after that, add `keeper_concept_sets_generate` as another wrapper plus workflow insertion point. + +## Risks + +- If the split is attempted by moving files first without introducing a real public ACP API, the new package boundary will be cosmetic only. +- If the workflow-stage contract is too large, it will become a second shell implementation rather than a stable interface. +- If `current_step` labels diverge between shells, item 1 and item 2 will create parallel dialogue logic that is harder to maintain than the current state. + +## Testing Strategy + +Add tests at both package levels. + +ACP client package: + +- request payload tests +- response parsing tests +- error and timeout handling tests +- mock transport tests for each exported wrapper + +Workflow package: + +- stage-context builder tests +- shell checkpoint/resume tests +- integration tests that verify correct ACP wrapper calls per workflow stage +- regression tests for generated artifact layout and scripts + +## How This Unblocks the Other Sprint Items + +Item 1: + +- stage-aware dialogue UX becomes a workflow concern backed by one shared context contract + +Item 2: + +- incidence-shell `/ohdsi` support can reuse the same ACP wrapper and `current_step` vocabulary + +Item 4: + +- `keeper_concept_sets_generate` becomes a thin ACP wrapper plus a workflow insertion point, not another direct shell-to-endpoint special case + +Item 5: + +- follow-on workflow features can add new stages or wrappers without deep edits to transport code + +## Recommended Decision + +Proceed with a two-package split, but implement it as an API-first extraction instead of a file-move-first refactor. + +The first concrete coding milestone should be: + +- a new ACP client package surface +- a shared `workflow_stage_context` helper +- refactoring the cohort-method shell to use those two abstractions before adding any more shell features From 0de634d730b81d13624e4c5a15beccc13d17e3fe Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 04:17:24 -0500 Subject: [PATCH 03/20] migrated the incidence shell to the same interactive `/ohdsi` model as the cohort-method shell in [R/OHDSIAssistant/R/strategus_incidence_shell.R](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/strategus_incidence_shell.R:1). --- .../R/strategus_cohort_methods_shell.R | 183 +++++++++++-- .../R/strategus_incidence_shell.R | 257 +++++++++++++++++- 2 files changed, 416 insertions(+), 24 deletions(-) diff --git a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R index 77e0022..7da72ea 100644 --- a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R +++ b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R @@ -1586,6 +1586,87 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me if (!dir.exists(path)) dir.create(path, recursive = TRUE, showWarnings = FALSE) } + normalize_dialogue_step <- function(step) { + step <- as.character(step %||% "") + mapped <- switch( + step, + study_intent = "study_intent_capture", + target_recommendation = "target_selection", + comparator_recommendation = "comparator_selection", + outcome_recommendation = "outcome_selection", + target_improvements = "phenotype_review", + comparator_improvements = "phenotype_review", + outcome_improvements = "phenotype_review", + analytic_settings_step_by_step = "analytic_settings_collection", + step + ) + as.character(mapped %||% "") + } + + dialogue_step_label <- function(step, role = "") { + step <- normalize_dialogue_step(step) + role <- as.character(role %||% "") + role_label <- if (nzchar(role)) { + paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") + } else { + "" + } + switch( + step, + study_intent_capture = "Study intent capture", + intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", + target_selection = "Target selection", + comparator_selection = "Comparator selection", + outcome_selection = "Outcome selection", + phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + analytic_settings_collection = "Analytic settings collection", + cohort_method_spec_recommendation = "Cohort method specification recommendation", + cohort_method_spec_confirmation = "Cohort method specification confirmation", + workflow_summary = "Workflow summary", + gsub("_", " ", step, fixed = TRUE) + ) + } + + build_workflow_stage_context <- function(study_intent, dialogue_state) { + current_step <- normalize_dialogue_step(dialogue_state$current_step %||% "") + current_role <- as.character(dialogue_state$current_role %||% "") + current_context <- compact_dialogue_context(dialogue_state$current_context %||% list()) + + list( + contract_version = 1L, + workflow_type = "strategus_cohort_methods", + current_step = current_step, + step_label = dialogue_step_label(current_step, current_role), + user_goal = as.character(studyIntent %||% ""), + entities = compact_dialogue_context(list( + active_role = current_role, + role_statement = current_context$role_statement %||% current_context$statement, + target = current_context$target_statement %||% NULL, + comparator = current_context$comparator_statement %||% NULL, + outcomes = current_context$outcome_statements %||% list() + )), + available_artifacts = compact_dialogue_context(list( + selected_target_ids = as.list(current_context$selected_target_ids %||% list()), + selected_comparator_ids = as.list(current_context$selected_comparator_ids %||% list()), + selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), + analysis_settings_path = current_context$analysis_settings_path %||% NULL, + concept_set_paths = current_context$concept_set_paths %||% list() + )), + dialogue = list( + prior_questions = list(), + prior_answers = list(), + last_user_message = NULL + ), + constraints = list( + interactive = isTRUE(interactive), + allow_recommendations = TRUE, + allow_generation = FALSE + ), + legacy_context = current_context + ) + } + + compact_dialogue_context <- function(value) { if (!is.list(value) || length(value) == 0) return(list()) keep <- lapply(value, function(item) { @@ -1604,6 +1685,9 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me dialogue_state$current_step <- "" dialogue_state$current_role <- "" dialogue_state$current_context <- list() + dialogue_acp_client <- new.env(parent = emptyenv()) + dialogue_acp_client$client <- NULL + set_dialogue_context <- function(step = "", role = "", context = list()) { dialogue_state$current_step <- as.character(step %||% "") @@ -1661,24 +1745,18 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me ") return(list(handled = TRUE, value = "")) } - if (!ensure_acp_ready(acpUrl)) { - cat("ACP bridge unavailable. Connect ACP before using /ohdsi. -") + if (!ensure_workflow_dialogue_client(acpUrl)) { + cat("ACP bridge unavailable. Connect ACP before using /ohdsi.\n") return(list(handled = TRUE, value = "")) } - body <- list( - user_prompt = question, - study_intent = as.character(studyIntent %||% ""), - workflow_type = "cohort_methods", - current_step = as.character(dialogue_state$current_step %||% ""), - current_role = as.character(dialogue_state$current_role %||% ""), - current_context = compact_dialogue_context(dialogue_state$current_context %||% list()) - ) + stage_context <- build_workflow_stage_context(studyIntent = studyIntent, dialogue_state = dialogue_state) + stage_context$dialogue$last_user_message <- question message("Calling ACP flow: workflow_context_dialogue") response <- tryCatch( - .acp_post("/flows/workflow_context_dialogue", body), + call_acp_workflow_context_dialogue(dialogue_acp_client$client, stage_context, question), error = function(e) list(status = "error", error = conditionMessage(e)) ) + if (!identical(response$status %||% "", "ok")) { cat(sprintf("OHDSI guidance failed: %s ", as.character(response$error %||% "unknown error"))) @@ -2240,7 +2318,44 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me if (nzchar(trimws(entered))) trimws(entered) else default_value } + acp_timeout_seconds <- function(default = 180) { + timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) + if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default + timeout_seconds + } + + acp_client_is_ready <- function(client) { + is.list(client) && is.character(client$url) && length(client$url) == 1 && nzchar(client$url) + } + + create_acp_client <- function(url, token = NULL, check = TRUE) { + client <- list( + url = sub("/$", "", as.character(url)), + token = token + ) + if (isTRUE(check)) { + response <- httr::GET( + paste0(client$url, "/health"), + httr::timeout(acp_timeout_seconds()) + ) + if (httr::status_code(response) != 200) stop("ACP bridge not reachable") + } + client + } + + ensure_workflow_dialogue_client <- function(url) { + if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) + TRUE + }, error = function(e) { + FALSE + }) + } + ensure_acp_ready <- function(url) { + if (ensure_workflow_dialogue_client(url)) return(TRUE) has_acp_state <- exists("acp_state", inherits = TRUE) has_acp_connect <- exists("acp_connect", mode = "function", inherits = TRUE) has_acp_post <- exists(".acp_post", mode = "function", inherits = TRUE) @@ -2256,6 +2371,38 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me }) } + call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { + if (!acp_client_is_ready(dialogue_acp_client$client)) { + if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") + } + response <- httr::POST( + paste0(dialogue_acp_client$client$url, sprintf("/flows/%s", flow_name)), + body = body, + encode = "json", + httr::add_headers(.headers = c(`Content-Type` = "application/json")), + httr::timeout(acp_timeout_seconds()) + ) + if (httr::status_code(response) >= 300) { + stop(httr::content(response, as = "text", encoding = "UTF-8")) + } + jsonlite::fromJSON( + httr::content(response, as = "text", encoding = "UTF-8"), + simplifyVector = FALSE + ) + } + + call_acp_workflow_context_dialogue <- function(client, stage_context, message) { + if (!acp_client_is_ready(client)) stop("ACP bridge unavailable.") + call_shell_acp_flow( + flow_name = "workflow_context_dialogue", + body = list( + workflow_stage_context = stage_context, + message = trimws(as.character(message %||% "")) + ), + url = client$url + ) + } + collect_recommendation_selection <- function(recommendations, role_label, allow_multiple = FALSE) { if (length(recommendations) == 0) return(integer(0)) if (!isTRUE(interactive)) { @@ -2420,7 +2567,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me ) message(sprintf("Calling ACP flow: phenotype_recommendation (%s)", role_key)) recommendation_response <- tryCatch( - .acp_post("/flows/phenotype_recommendation", body), + call_shell_acp_flow("phenotype_recommendation", body), error = function(e) { list(status = "error", error = conditionMessage(e)) } @@ -2482,7 +2629,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me ) message(sprintf("Calling ACP flow: phenotype_recommendation (%s window 2)", role_key)) recommendation_response <- tryCatch( - .acp_post("/flows/phenotype_recommendation", body), + call_shell_acp_flow("phenotype_recommendation", body), error = function(e) { list(status = "error", error = conditionMessage(e)) } @@ -2505,7 +2652,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me used_advice <- TRUE message(sprintf("Calling ACP flow: phenotype_recommendation_advice (%s)", role_key)) advice <- tryCatch( - .acp_post("/flows/phenotype_recommendation_advice", list(study_intent = statement)), + call_shell_acp_flow("phenotype_recommendation_advice", list(study_intent = statement)), error = function(e) { list(status = "error", error = conditionMessage(e)) } @@ -2660,7 +2807,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me message(sprintf("Calling ACP flow: phenotype_improvements (%s cohort %s)", role_key, cid)) flow_called <- TRUE response_by_id[[as.character(cid)]] <- tryCatch( - .acp_post("/flows/phenotype_improvements", body), + call_shell_acp_flow("phenotype_improvements", body), error = function(e) { err <- list( status = "error", @@ -3548,7 +3695,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me } response <- tryCatch( - .acp_post(sprintf("/flows/%s", flow_name), body), + call_shell_acp_flow(flow_name, body, url = acp_url), error = function(e) { list( flow = flow_name, @@ -3944,7 +4091,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me message("Calling ACP flow: cohort_methods_intent_split") } cohort_methods_intent_split_response <- tryCatch( - .acp_post("/flows/cohort_methods_intent_split", list(study_intent = studyIntent)), + call_shell_acp_flow("cohort_methods_intent_split", list(study_intent = studyIntent)), error = function(e) { list(status = "error", error = conditionMessage(e)) } diff --git a/R/OHDSIAssistant/R/strategus_incidence_shell.R b/R/OHDSIAssistant/R/strategus_incidence_shell.R index 770e026..b40f070 100644 --- a/R/OHDSIAssistant/R/strategus_incidence_shell.R +++ b/R/OHDSIAssistant/R/strategus_incidence_shell.R @@ -37,10 +37,238 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (!dir.exists(path)) dir.create(path, recursive = TRUE) } + normalize_dialogue_step <- function(step) { + step <- as.character(step %||% "") + mapped <- switch( + step, + study_intent = "study_intent_capture", + target_recommendation = "target_selection", + target_recommendation_window2 = "target_selection", + target_recommendation_resume = "target_selection", + target_advice_call = "target_selection", + target_improvements = "phenotype_review", + outcome_recommendation = "outcome_selection", + outcome_recommendation_window2 = "outcome_selection", + outcome_recommendation_resume = "outcome_selection", + outcome_advice_call = "outcome_selection", + outcome_improvements = "phenotype_review", + step + ) + as.character(mapped %||% "") + } + + dialogue_step_label <- function(step, role = "") { + step <- normalize_dialogue_step(step) + role <- as.character(role %||% "") + role_label <- if (nzchar(role)) paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") else "" + switch( + step, + study_intent_capture = "Study intent capture", + intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", + target_selection = "Target selection", + outcome_selection = "Outcome selection", + phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + incidence_design_setup = "Incidence design setup", + time_at_risk_configuration = "Time-at-risk configuration", + workflow_summary = "Workflow summary", + gsub("_", " ", step, fixed = TRUE) + ) + } + + compact_dialogue_context <- function(value) { + if (!is.list(value) || length(value) == 0) return(list()) + keep <- lapply(value, function(item) { + if (is.null(item)) return(FALSE) + if (is.character(item) && length(item) == 1 && !nzchar(trimws(item))) return(FALSE) + if (is.atomic(item) && length(item) == 0) return(FALSE) + if (is.list(item) && length(item) == 0) return(FALSE) + TRUE + }) + keep_idx <- which(vapply(keep, isTRUE, logical(1))) + if (length(keep_idx) == 0) return(list()) + value[keep_idx] + } + + dialogue_state <- new.env(parent = emptyenv()) + dialogue_state$current_step <- "" + dialogue_state$current_role <- "" + dialogue_state$current_context <- list() + + dialogue_acp_client <- new.env(parent = emptyenv()) + dialogue_acp_client$client <- NULL + + set_dialogue_context <- function(step = "", role = "", context = list()) { + dialogue_state$current_step <- as.character(step %||% "") + dialogue_state$current_role <- as.character(role %||% "") + dialogue_state$current_context <- compact_dialogue_context(context %||% list()) + invisible(NULL) + } + + build_workflow_stage_context <- function(studyIntent, dialogue_state) { + current_step <- normalize_dialogue_step(dialogue_state$current_step %||% "") + current_role <- as.character(dialogue_state$current_role %||% "") + current_context <- compact_dialogue_context(dialogue_state$current_context %||% list()) + + list( + contract_version = 1L, + workflow_type = "strategus_incidence", + current_step = current_step, + step_label = dialogue_step_label(current_step, current_role), + user_goal = as.character(studyIntent %||% ""), + entities = compact_dialogue_context(list( + active_role = current_role, + role_statement = current_context$role_statement %||% current_context$statement, + target = current_context$target_statement %||% NULL, + outcomes = current_context$outcome_statement %||% current_context$outcome_statements %||% list() + )), + available_artifacts = compact_dialogue_context(list( + selected_target_ids = as.list(current_context$selected_target_ids %||% list()), + selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), + analysis_settings_path = current_context$analysis_settings_path %||% NULL, + concept_set_paths = current_context$concept_set_paths %||% list() + )), + dialogue = list( + prior_questions = list(), + prior_answers = list(), + last_user_message = NULL + ), + constraints = list( + interactive = isTRUE(interactive), + allow_recommendations = TRUE, + allow_generation = FALSE + ), + legacy_context = current_context + ) + } + + render_workflow_dialogue <- function(response) { + core <- response$dialogue %||% response + cat("\n== OHDSI Guidance ==\n") + answer <- as.character(core$answer %||% "") + if (nzchar(trimws(answer))) { + cat(answer, "\n") + } else { + cat("No contextual guidance was returned.\n") + } + guidance <- core$current_step_guidance %||% list() + if (length(guidance) > 0) { + cat("Current step guidance:\n") + for (item in guidance) cat(sprintf(" - %s\n", item)) + } + cautions <- core$cautions %||% list() + if (length(cautions) > 0) { + cat("Cautions:\n") + for (item in cautions) cat(sprintf(" - %s\n", item)) + } + next_actions <- core$suggested_next_actions %||% list() + if (length(next_actions) > 0) { + cat("Suggested next actions:\n") + for (item in next_actions) cat(sprintf(" - %s\n", item)) + } + cat("\n") + } + + acp_timeout_seconds <- function(default = 180) { + timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) + if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default + timeout_seconds + } + + acp_client_is_ready <- function(client) { + is.list(client) && is.character(client$url) && length(client$url) == 1 && nzchar(client$url) + } + + create_acp_client <- function(url, token = NULL, check = TRUE) { + client <- list( + url = sub("/$", "", as.character(url)), + token = token + ) + if (isTRUE(check)) { + response <- httr::GET( + paste0(client$url, "/health"), + httr::timeout(acp_timeout_seconds()) + ) + if (httr::status_code(response) != 200) stop("ACP bridge not reachable") + } + client + } + + ensure_workflow_dialogue_client <- function(url) { + if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) + TRUE + }, error = function(e) { + FALSE + }) + } + + call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { + if (!acp_client_is_ready(dialogue_acp_client$client)) { + if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") + } + response <- httr::POST( + paste0(dialogue_acp_client$client$url, sprintf("/flows/%s", flow_name)), + body = body, + encode = "json", + httr::add_headers(.headers = c(`Content-Type` = "application/json")), + httr::timeout(acp_timeout_seconds()) + ) + if (httr::status_code(response) >= 300) { + stop(httr::content(response, as = "text", encoding = "UTF-8")) + } + jsonlite::fromJSON( + httr::content(response, as = "text", encoding = "UTF-8"), + simplifyVector = FALSE + ) + } + + handle_workflow_dialogue_command <- function(entered) { + trimmed <- trimws(as.character(entered %||% "")) + if (!isTRUE(interactive) || !startsWith(trimmed, "/ohdsi")) { + return(list(handled = FALSE, value = entered)) + } + question <- trimws(sub("^/ohdsi", "", trimmed)) + if (!nzchar(question)) { + cat("Enter a question after /ohdsi. Example: /ohdsi why are these candidates weak here?\n") + return(list(handled = TRUE, value = "")) + } + if (!ensure_workflow_dialogue_client(acpUrl)) { + cat("ACP bridge unavailable. Connect ACP before using /ohdsi.\n") + return(list(handled = TRUE, value = "")) + } + stage_context <- build_workflow_stage_context(studyIntent = studyIntent, dialogue_state = dialogue_state) + stage_context$dialogue$last_user_message <- question + message("Calling ACP flow: workflow_context_dialogue") + response <- tryCatch( + call_shell_acp_flow( + "workflow_context_dialogue", + list(workflow_stage_context = stage_context, message = question) + ), + error = function(e) list(status = "error", error = conditionMessage(e)) + ) + if (!identical(response$status %||% "", "ok")) { + cat(sprintf("OHDSI guidance failed: %s\n", as.character(response$error %||% "unknown error"))) + return(list(handled = TRUE, value = "")) + } + render_workflow_dialogue(response) + list(handled = TRUE, value = "") + } + + readline_with_dialogue <- function(prompt) { + repeat { + entered <- readline(prompt) + handled <- handle_workflow_dialogue_command(entered) + if (isTRUE(handled$handled)) next + return(handled$value) + } + } + prompt_yesno <- function(prompt, default = TRUE) { if (!isTRUE(interactive)) return(default) suffix <- if (default) "[Y/n]" else "[y/N]" - resp <- tolower(trimws(readline(sprintf("%s %s ", prompt, suffix)))) + resp <- tolower(trimws(readline_with_dialogue(sprintf("%s %s ", prompt, suffix)))) if (resp == "") return(default) if (resp %in% c("y", "yes")) return(TRUE) if (resp %in% c("n", "no")) return(FALSE) @@ -69,8 +297,9 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide repeat { resp <- NULL err <- NULL + flow_name <- sub("^/flows/", "", as.character(path)) resp <- tryCatch( - .acp_post(path, body), + call_shell_acp_flow(flow_name, body), error = function(e) { err <<- e NULL @@ -264,7 +493,8 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide default_intent <- studyIntent %||% "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" if (interactive) { - entered <- readline(sprintf("Study intent [%s]: ", default_intent)) + set_dialogue_context("study_intent", context = list(default_intent = default_intent)) + entered <- readline_with_dialogue(sprintf("Study intent [%s]: ", default_intent)) if (nzchar(trimws(entered))) studyIntent <- entered else studyIntent <- default_intent } else { if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) studyIntent <- default_intent @@ -280,6 +510,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (interactive) { cat("\n== Step 1: Parse study intent into target/outcome statements ==\n") } + set_dialogue_context("intent_split", context = list(study_intent = studyIntent)) if (maybe_use_cache(intent_split_path, "intent split")) { intent_response <- read_json(intent_split_path) } else { @@ -300,9 +531,11 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cat("Questions to clarify:\n") for (q in intent_core$questions) cat(sprintf(" - %s\n", q)) } - inp <- readline(sprintf("Target cohort statement [%s]: ", target_statement)) + set_dialogue_context("intent_split", "target", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) + inp <- readline_with_dialogue(sprintf("Target cohort statement [%s]: ", target_statement)) if (nzchar(trimws(inp))) target_statement <- inp - inp <- readline(sprintf("Outcome cohort statement [%s]: ", outcome_statement)) + set_dialogue_context("intent_split", "outcome", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) + inp <- readline_with_dialogue(sprintf("Outcome cohort statement [%s]: ", outcome_statement)) if (nzchar(trimws(inp))) outcome_statement <- inp } if (!nzchar(trimws(target_statement))) stop("Missing target cohort statement.") @@ -327,6 +560,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (interactive) { cat("\n== Step 2: Target phenotype recommendations ==\n") } + set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) if (maybe_use_cache(recs_target_path, "target recommendations")) { rec_response_target <- read_json(recs_target_path) used_cached_recs_target <- TRUE @@ -369,6 +603,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } if (interactive) { + set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) ok_any <- prompt_yesno("Are any of these acceptable for the target?", default = TRUE) if (!ok_any) { widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) @@ -419,6 +654,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } if (interactive) { + set_dialogue_context("target_selection", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement)) if (!prompt_yesno("Continue to target cohort selection?", default = TRUE)) { return(invisible(list(output_dir = output_dir, recommendations = recs_target_path))) } @@ -444,6 +680,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide use_mapping <- FALSE if (interactive) { + set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) use_mapping <- prompt_yesno("Map cohort IDs to a new range (avoid collisions)?", default = TRUE) } cohort_id_base <- NA_integer_ @@ -452,7 +689,8 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cohort_id_base <- sample(10000:50000, 1) if (interactive) { msg <- sprintf("Enter cohort ID base (10000-50000) or press Enter to use %s: ", cohort_id_base) - inp <- trimws(readline(msg)) + set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()), suggested_cohort_id_base = cohort_id_base)) + inp <- trimws(readline_with_dialogue(msg)) if (nzchar(inp)) cohort_id_base <- as.integer(inp) } next_id <- cohort_id_base @@ -473,6 +711,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide do_target_improvements <- TRUE if (interactive) { + set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, selected_target_ids = as.list(selected_ids_target %||% list()))) do_target_improvements <- prompt_yesno("Continue to target phenotype improvements?", default = TRUE) if (do_target_improvements) { cat("\n== Step 4: Target phenotype improvements ==\n") @@ -521,6 +760,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cat(" No improvements returned for this cohort.\n") next } + set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, cohort_id = as.integer(cid), selected_target_ids = as.list(selected_ids_target %||% list()))) if (prompt_yesno(sprintf("Apply improvements for target cohort %s now?", cid), default = FALSE)) { cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) cohort_obj <- read_json(cohort_path) @@ -572,6 +812,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (interactive) { cat("\n== Step 5: Outcome phenotype recommendations ==\n") } + set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) if (maybe_use_cache(recs_outcome_path, "outcome recommendations")) { rec_response_outcome <- read_json(recs_outcome_path) used_cached_recs_outcome <- TRUE @@ -614,6 +855,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } if (interactive) { + set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) ok_any <- prompt_yesno("Are any of these acceptable for the outcomes?", default = TRUE) if (!ok_any) { widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) @@ -664,6 +906,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } if (interactive) { + set_dialogue_context("outcome_selection", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement)) if (!prompt_yesno("Continue to outcome cohort selection?", default = TRUE)) { return(invisible(list(output_dir = output_dir, recommendations = recs_outcome_path))) } @@ -701,6 +944,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide do_outcome_improvements <- TRUE if (interactive) { + set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) do_outcome_improvements <- prompt_yesno("Continue to outcome phenotype improvements?", default = TRUE) if (do_outcome_improvements) { cat("\n== Step 7: Outcome phenotype improvements ==\n") @@ -751,6 +995,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cat(" No improvements returned for this cohort.\n") next } + set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, cohort_id = as.integer(cid), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) if (prompt_yesno(sprintf("Apply improvements for outcome cohort %s now?", cid), default = FALSE)) { cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) cohort_obj <- read_json(cohort_path) From 0bf9f1ed716f18330c92792b39c4dc2183607a90 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 04:57:05 -0500 Subject: [PATCH 04/20] moved the remaining workflow-specific dialogue mapping out of `OHDSIAssistant` and into [workflow_dialogue_mapping.R](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R:1) --- R/OHDSIAssistant/R/slash_ohdsi_bridge.R | 130 +++++++++ .../R/strategus_cohort_methods_shell.R | 249 ++---------------- .../R/strategus_incidence_shell.R | 217 ++------------- R/slashOhdsiStrategusAssistant/NAMESPACE | 11 + .../R/workflow_dialogue.R | 149 +++++++++++ .../R/workflow_dialogue_mapping.R | 150 +++++++++++ 6 files changed, 494 insertions(+), 412 deletions(-) create mode 100644 R/OHDSIAssistant/R/slash_ohdsi_bridge.R create mode 100644 R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R create mode 100644 R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R diff --git a/R/OHDSIAssistant/R/slash_ohdsi_bridge.R b/R/OHDSIAssistant/R/slash_ohdsi_bridge.R new file mode 100644 index 0000000..a2988f5 --- /dev/null +++ b/R/OHDSIAssistant/R/slash_ohdsi_bridge.R @@ -0,0 +1,130 @@ +.studyAgentSlashBridgeState <- local({ + env <- new.env(parent = emptyenv()) + env$acp_env <- NULL + env$strategus_env <- NULL + env +}) + +.studyAgentRepoRoot <- function() { + candidates <- unique(c( + Sys.getenv("STUDY_AGENT_BASE_DIR", unset = ""), + getwd(), + file.path(getwd(), "OHDSI-Study-Agent"), + normalizePath(file.path(getwd(), "..", ".."), winslash = "/", mustWork = FALSE) + )) + candidates <- candidates[nzchar(candidates)] + for (candidate in candidates) { + if (dir.exists(file.path(candidate, "R", "slashOhdsiAcpClient")) && + dir.exists(file.path(candidate, "R", "slashOhdsiStrategusAssistant"))) { + return(normalizePath(candidate, winslash = "/", mustWork = FALSE)) + } + } + stop("Could not locate repo root for slash-ohdsi bridge helpers.") +} + +.studyAgentSourcePackageDir <- function(package_dir) { + env <- new.env(parent = baseenv()) + r_dir <- file.path(package_dir, "R") + files <- list.files(r_dir, pattern = "\\.[Rr]$", full.names = TRUE) + for (path in sort(files)) { + sys.source(path, envir = env) + } + env +} + +.studyAgentSlashAcpEnv <- function() { + if (!is.null(.studyAgentSlashBridgeState$acp_env)) { + return(.studyAgentSlashBridgeState$acp_env) + } + repo_root <- .studyAgentRepoRoot() + env <- .studyAgentSourcePackageDir(file.path(repo_root, "R", "slashOhdsiAcpClient")) + .studyAgentSlashBridgeState$acp_env <- env + env +} + +.studyAgentSlashStrategusEnv <- function() { + if (!is.null(.studyAgentSlashBridgeState$strategus_env)) { + return(.studyAgentSlashBridgeState$strategus_env) + } + repo_root <- .studyAgentRepoRoot() + env <- .studyAgentSourcePackageDir(file.path(repo_root, "R", "slashOhdsiStrategusAssistant")) + .studyAgentSlashBridgeState$strategus_env <- env + env +} + +.studyAgentSlashCreateAcpClient <- function(url = "http://127.0.0.1:8765", token = NULL, check = TRUE) { + env <- .studyAgentSlashAcpEnv() + env$acp_client(url = url, token = token, check = check) +} + +.studyAgentSlashAcpIsConnected <- function(client) { + env <- .studyAgentSlashAcpEnv() + isTRUE(env$acp_is_connected(client)) +} + +.studyAgentSlashCallAcpFlow <- function(client, flow_name, body = list()) { + env <- .studyAgentSlashAcpEnv() + env$acp_call_flow(client = client, flow_name = flow_name, body = body) +} + +.studyAgentSlashNewWorkflowStageContext <- function(...) { + env <- .studyAgentSlashStrategusEnv() + env$new_workflow_stage_context(...) +} + +.studyAgentSlashCompactWorkflowDialogueContext <- function(value) { + env <- .studyAgentSlashStrategusEnv() + env$compact_workflow_dialogue_context(value) +} + +.studyAgentSlashNewWorkflowDialogueSession <- function(...) { + env <- .studyAgentSlashStrategusEnv() + env$new_workflow_dialogue_session(...) +} + +.studyAgentSlashNormalizeIncidenceDialogueStep <- function(step) { + env <- .studyAgentSlashStrategusEnv() + env$normalize_incidence_dialogue_step(step) +} + +.studyAgentSlashIncidenceDialogueStepLabel <- function(step, role = "") { + env <- .studyAgentSlashStrategusEnv() + env$incidence_dialogue_step_label(step = step, role = role) +} + +.studyAgentSlashBuildIncidenceWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { + env <- .studyAgentSlashStrategusEnv() + env$build_incidence_workflow_stage_context( + study_intent = study_intent, + dialogue_state = dialogue_state, + interactive = interactive + ) +} + +.studyAgentSlashNormalizeCohortMethodsDialogueStep <- function(step) { + env <- .studyAgentSlashStrategusEnv() + env$normalize_cohort_methods_dialogue_step(step) +} + +.studyAgentSlashCohortMethodsDialogueStepLabel <- function(step, role = "") { + env <- .studyAgentSlashStrategusEnv() + env$cohort_methods_dialogue_step_label(step = step, role = role) +} + +.studyAgentSlashBuildCohortMethodsWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { + env <- .studyAgentSlashStrategusEnv() + env$build_cohort_methods_workflow_stage_context( + study_intent = study_intent, + dialogue_state = dialogue_state, + interactive = interactive + ) +} + +.studyAgentSlashWorkflowContextDialogue <- function(client, stage_context, message) { + env <- .studyAgentSlashAcpEnv() + env$acp_workflow_context_dialogue( + client = client, + stage_context = stage_context, + message = message + ) +} diff --git a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R index 7da72ea..01e60b2 100644 --- a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R +++ b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R @@ -1586,194 +1586,37 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me if (!dir.exists(path)) dir.create(path, recursive = TRUE, showWarnings = FALSE) } - normalize_dialogue_step <- function(step) { - step <- as.character(step %||% "") - mapped <- switch( - step, - study_intent = "study_intent_capture", - target_recommendation = "target_selection", - comparator_recommendation = "comparator_selection", - outcome_recommendation = "outcome_selection", - target_improvements = "phenotype_review", - comparator_improvements = "phenotype_review", - outcome_improvements = "phenotype_review", - analytic_settings_step_by_step = "analytic_settings_collection", - step - ) - as.character(mapped %||% "") - } - - dialogue_step_label <- function(step, role = "") { - step <- normalize_dialogue_step(step) - role <- as.character(role %||% "") - role_label <- if (nzchar(role)) { - paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") - } else { - "" - } - switch( - step, - study_intent_capture = "Study intent capture", - intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", - target_selection = "Target selection", - comparator_selection = "Comparator selection", - outcome_selection = "Outcome selection", - phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", - analytic_settings_collection = "Analytic settings collection", - cohort_method_spec_recommendation = "Cohort method specification recommendation", - cohort_method_spec_confirmation = "Cohort method specification confirmation", - workflow_summary = "Workflow summary", - gsub("_", " ", step, fixed = TRUE) - ) - } - - build_workflow_stage_context <- function(study_intent, dialogue_state) { - current_step <- normalize_dialogue_step(dialogue_state$current_step %||% "") - current_role <- as.character(dialogue_state$current_role %||% "") - current_context <- compact_dialogue_context(dialogue_state$current_context %||% list()) - - list( - contract_version = 1L, - workflow_type = "strategus_cohort_methods", - current_step = current_step, - step_label = dialogue_step_label(current_step, current_role), - user_goal = as.character(studyIntent %||% ""), - entities = compact_dialogue_context(list( - active_role = current_role, - role_statement = current_context$role_statement %||% current_context$statement, - target = current_context$target_statement %||% NULL, - comparator = current_context$comparator_statement %||% NULL, - outcomes = current_context$outcome_statements %||% list() - )), - available_artifacts = compact_dialogue_context(list( - selected_target_ids = as.list(current_context$selected_target_ids %||% list()), - selected_comparator_ids = as.list(current_context$selected_comparator_ids %||% list()), - selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), - analysis_settings_path = current_context$analysis_settings_path %||% NULL, - concept_set_paths = current_context$concept_set_paths %||% list() - )), - dialogue = list( - prior_questions = list(), - prior_answers = list(), - last_user_message = NULL - ), - constraints = list( - interactive = isTRUE(interactive), - allow_recommendations = TRUE, - allow_generation = FALSE - ), - legacy_context = current_context + normalize_dialogue_step <- .studyAgentSlashNormalizeCohortMethodsDialogueStep + + dialogue_step_label <- .studyAgentSlashCohortMethodsDialogueStepLabel + build_workflow_stage_context <- function(studyIntent, dialogue_state) { + .studyAgentSlashBuildCohortMethodsWorkflowStageContext( + study_intent = studyIntent, + dialogue_state = dialogue_state, + interactive = interactive ) } + compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext - compact_dialogue_context <- function(value) { - if (!is.list(value) || length(value) == 0) return(list()) - keep <- lapply(value, function(item) { - if (is.null(item)) return(FALSE) - if (is.character(item) && length(item) == 1 && !nzchar(trimws(item))) return(FALSE) - if (is.atomic(item) && length(item) == 0) return(FALSE) - if (is.list(item) && length(item) == 0) return(FALSE) - TRUE - }) - keep_idx <- which(vapply(keep, isTRUE, logical(1))) - if (length(keep_idx) == 0) return(list()) - value[keep_idx] - } - - dialogue_state <- new.env(parent = emptyenv()) - dialogue_state$current_step <- "" - dialogue_state$current_role <- "" - dialogue_state$current_context <- list() dialogue_acp_client <- new.env(parent = emptyenv()) dialogue_acp_client$client <- NULL - - - set_dialogue_context <- function(step = "", role = "", context = list()) { - dialogue_state$current_step <- as.character(step %||% "") - dialogue_state$current_role <- as.character(role %||% "") - dialogue_state$current_context <- compact_dialogue_context(context %||% list()) - invisible(NULL) - } - - render_workflow_dialogue <- function(response) { - core <- response$dialogue %||% response - cat(" -== OHDSI Guidance == -") - answer <- as.character(core$answer %||% "") - if (nzchar(trimws(answer))) { - cat(answer, " -") - } else { - cat("No contextual guidance was returned. -") - } - guidance <- core$current_step_guidance %||% list() - if (length(guidance) > 0) { - cat("Current step guidance: -") - for (item in guidance) cat(sprintf(" - %s -", item)) - } - cautions <- core$cautions %||% list() - if (length(cautions) > 0) { - cat("Cautions: -") - for (item in cautions) cat(sprintf(" - %s -", item)) - } - next_actions <- core$suggested_next_actions %||% list() - if (length(next_actions) > 0) { - cat("Suggested next actions: -") - for (item in next_actions) cat(sprintf(" - %s -", item)) - } - cat(" -") - } - - handle_workflow_dialogue_command <- function(entered) { - trimmed <- trimws(as.character(entered %||% "")) - if (!isTRUE(interactive) || !startsWith(trimmed, "/ohdsi")) { - return(list(handled = FALSE, value = entered)) - } - question <- trimws(sub("^/ohdsi", "", trimmed)) - if (!nzchar(question)) { - cat("Enter a question after /ohdsi. Example: /ohdsi why is washout important here? -") - return(list(handled = TRUE, value = "")) - } - if (!ensure_workflow_dialogue_client(acpUrl)) { - cat("ACP bridge unavailable. Connect ACP before using /ohdsi.\n") - return(list(handled = TRUE, value = "")) - } - stage_context <- build_workflow_stage_context(studyIntent = studyIntent, dialogue_state = dialogue_state) - stage_context$dialogue$last_user_message <- question - message("Calling ACP flow: workflow_context_dialogue") - response <- tryCatch( - call_acp_workflow_context_dialogue(dialogue_acp_client$client, stage_context, question), - error = function(e) list(status = "error", error = conditionMessage(e)) - ) - - if (!identical(response$status %||% "", "ok")) { - cat(sprintf("OHDSI guidance failed: %s -", as.character(response$error %||% "unknown error"))) - return(list(handled = TRUE, value = "")) - } - render_workflow_dialogue(response) - list(handled = TRUE, value = "") - } - - readline_with_dialogue <- function(prompt) { - repeat { - entered <- readline(prompt) - handled <- handle_workflow_dialogue_command(entered) - if (isTRUE(handled$handled)) next - return(handled$value) - } - } + dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( + interactive = interactive, + study_intent_getter = function() studyIntent, + build_stage_context = build_workflow_stage_context, + call_dialogue = function(stage_context, message) { + if (!ensure_workflow_dialogue_client(acpUrl)) { + stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") + } + message("Calling ACP flow: workflow_context_dialogue") + .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) + }, + empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why is washout important here?" + ) + dialogue_state <- dialogue_session$state + set_dialogue_context <- dialogue_session$set_context + readline_with_dialogue <- dialogue_session$readline prompt_yesno <- function(prompt, default = TRUE) { if (!isTRUE(interactive)) return(default) @@ -2325,22 +2168,11 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me } acp_client_is_ready <- function(client) { - is.list(client) && is.character(client$url) && length(client$url) == 1 && nzchar(client$url) + .studyAgentSlashAcpIsConnected(client) } create_acp_client <- function(url, token = NULL, check = TRUE) { - client <- list( - url = sub("/$", "", as.character(url)), - token = token - ) - if (isTRUE(check)) { - response <- httr::GET( - paste0(client$url, "/health"), - httr::timeout(acp_timeout_seconds()) - ) - if (httr::status_code(response) != 200) stop("ACP bridge not reachable") - } - client + .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) } ensure_workflow_dialogue_client <- function(url) { @@ -2375,32 +2207,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me if (!acp_client_is_ready(dialogue_acp_client$client)) { if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") } - response <- httr::POST( - paste0(dialogue_acp_client$client$url, sprintf("/flows/%s", flow_name)), - body = body, - encode = "json", - httr::add_headers(.headers = c(`Content-Type` = "application/json")), - httr::timeout(acp_timeout_seconds()) - ) - if (httr::status_code(response) >= 300) { - stop(httr::content(response, as = "text", encoding = "UTF-8")) - } - jsonlite::fromJSON( - httr::content(response, as = "text", encoding = "UTF-8"), - simplifyVector = FALSE - ) - } - - call_acp_workflow_context_dialogue <- function(client, stage_context, message) { - if (!acp_client_is_ready(client)) stop("ACP bridge unavailable.") - call_shell_acp_flow( - flow_name = "workflow_context_dialogue", - body = list( - workflow_stage_context = stage_context, - message = trimws(as.character(message %||% "")) - ), - url = client$url - ) + .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) } collect_recommendation_selection <- function(recommendations, role_label, allow_multiple = FALSE) { diff --git a/R/OHDSIAssistant/R/strategus_incidence_shell.R b/R/OHDSIAssistant/R/strategus_incidence_shell.R index b40f070..259aebf 100644 --- a/R/OHDSIAssistant/R/strategus_incidence_shell.R +++ b/R/OHDSIAssistant/R/strategus_incidence_shell.R @@ -37,137 +37,20 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (!dir.exists(path)) dir.create(path, recursive = TRUE) } - normalize_dialogue_step <- function(step) { - step <- as.character(step %||% "") - mapped <- switch( - step, - study_intent = "study_intent_capture", - target_recommendation = "target_selection", - target_recommendation_window2 = "target_selection", - target_recommendation_resume = "target_selection", - target_advice_call = "target_selection", - target_improvements = "phenotype_review", - outcome_recommendation = "outcome_selection", - outcome_recommendation_window2 = "outcome_selection", - outcome_recommendation_resume = "outcome_selection", - outcome_advice_call = "outcome_selection", - outcome_improvements = "phenotype_review", - step - ) - as.character(mapped %||% "") - } - - dialogue_step_label <- function(step, role = "") { - step <- normalize_dialogue_step(step) - role <- as.character(role %||% "") - role_label <- if (nzchar(role)) paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") else "" - switch( - step, - study_intent_capture = "Study intent capture", - intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", - target_selection = "Target selection", - outcome_selection = "Outcome selection", - phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", - incidence_design_setup = "Incidence design setup", - time_at_risk_configuration = "Time-at-risk configuration", - workflow_summary = "Workflow summary", - gsub("_", " ", step, fixed = TRUE) - ) - } + normalize_dialogue_step <- .studyAgentSlashNormalizeIncidenceDialogueStep - compact_dialogue_context <- function(value) { - if (!is.list(value) || length(value) == 0) return(list()) - keep <- lapply(value, function(item) { - if (is.null(item)) return(FALSE) - if (is.character(item) && length(item) == 1 && !nzchar(trimws(item))) return(FALSE) - if (is.atomic(item) && length(item) == 0) return(FALSE) - if (is.list(item) && length(item) == 0) return(FALSE) - TRUE - }) - keep_idx <- which(vapply(keep, isTRUE, logical(1))) - if (length(keep_idx) == 0) return(list()) - value[keep_idx] - } - - dialogue_state <- new.env(parent = emptyenv()) - dialogue_state$current_step <- "" - dialogue_state$current_role <- "" - dialogue_state$current_context <- list() + dialogue_step_label <- .studyAgentSlashIncidenceDialogueStepLabel + compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext dialogue_acp_client <- new.env(parent = emptyenv()) dialogue_acp_client$client <- NULL - - set_dialogue_context <- function(step = "", role = "", context = list()) { - dialogue_state$current_step <- as.character(step %||% "") - dialogue_state$current_role <- as.character(role %||% "") - dialogue_state$current_context <- compact_dialogue_context(context %||% list()) - invisible(NULL) - } - build_workflow_stage_context <- function(studyIntent, dialogue_state) { - current_step <- normalize_dialogue_step(dialogue_state$current_step %||% "") - current_role <- as.character(dialogue_state$current_role %||% "") - current_context <- compact_dialogue_context(dialogue_state$current_context %||% list()) - - list( - contract_version = 1L, - workflow_type = "strategus_incidence", - current_step = current_step, - step_label = dialogue_step_label(current_step, current_role), - user_goal = as.character(studyIntent %||% ""), - entities = compact_dialogue_context(list( - active_role = current_role, - role_statement = current_context$role_statement %||% current_context$statement, - target = current_context$target_statement %||% NULL, - outcomes = current_context$outcome_statement %||% current_context$outcome_statements %||% list() - )), - available_artifacts = compact_dialogue_context(list( - selected_target_ids = as.list(current_context$selected_target_ids %||% list()), - selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), - analysis_settings_path = current_context$analysis_settings_path %||% NULL, - concept_set_paths = current_context$concept_set_paths %||% list() - )), - dialogue = list( - prior_questions = list(), - prior_answers = list(), - last_user_message = NULL - ), - constraints = list( - interactive = isTRUE(interactive), - allow_recommendations = TRUE, - allow_generation = FALSE - ), - legacy_context = current_context + .studyAgentSlashBuildIncidenceWorkflowStageContext( + study_intent = studyIntent, + dialogue_state = dialogue_state, + interactive = interactive ) } - - render_workflow_dialogue <- function(response) { - core <- response$dialogue %||% response - cat("\n== OHDSI Guidance ==\n") - answer <- as.character(core$answer %||% "") - if (nzchar(trimws(answer))) { - cat(answer, "\n") - } else { - cat("No contextual guidance was returned.\n") - } - guidance <- core$current_step_guidance %||% list() - if (length(guidance) > 0) { - cat("Current step guidance:\n") - for (item in guidance) cat(sprintf(" - %s\n", item)) - } - cautions <- core$cautions %||% list() - if (length(cautions) > 0) { - cat("Cautions:\n") - for (item in cautions) cat(sprintf(" - %s\n", item)) - } - next_actions <- core$suggested_next_actions %||% list() - if (length(next_actions) > 0) { - cat("Suggested next actions:\n") - for (item in next_actions) cat(sprintf(" - %s\n", item)) - } - cat("\n") - } - acp_timeout_seconds <- function(default = 180) { timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default @@ -175,22 +58,11 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } acp_client_is_ready <- function(client) { - is.list(client) && is.character(client$url) && length(client$url) == 1 && nzchar(client$url) + .studyAgentSlashAcpIsConnected(client) } create_acp_client <- function(url, token = NULL, check = TRUE) { - client <- list( - url = sub("/$", "", as.character(url)), - token = token - ) - if (isTRUE(check)) { - response <- httr::GET( - paste0(client$url, "/health"), - httr::timeout(acp_timeout_seconds()) - ) - if (httr::status_code(response) != 200) stop("ACP bridge not reachable") - } - client + .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) } ensure_workflow_dialogue_client <- function(url) { @@ -208,62 +80,25 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (!acp_client_is_ready(dialogue_acp_client$client)) { if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") } - response <- httr::POST( - paste0(dialogue_acp_client$client$url, sprintf("/flows/%s", flow_name)), - body = body, - encode = "json", - httr::add_headers(.headers = c(`Content-Type` = "application/json")), - httr::timeout(acp_timeout_seconds()) - ) - if (httr::status_code(response) >= 300) { - stop(httr::content(response, as = "text", encoding = "UTF-8")) - } - jsonlite::fromJSON( - httr::content(response, as = "text", encoding = "UTF-8"), - simplifyVector = FALSE - ) + .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) } - handle_workflow_dialogue_command <- function(entered) { - trimmed <- trimws(as.character(entered %||% "")) - if (!isTRUE(interactive) || !startsWith(trimmed, "/ohdsi")) { - return(list(handled = FALSE, value = entered)) - } - question <- trimws(sub("^/ohdsi", "", trimmed)) - if (!nzchar(question)) { - cat("Enter a question after /ohdsi. Example: /ohdsi why are these candidates weak here?\n") - return(list(handled = TRUE, value = "")) - } - if (!ensure_workflow_dialogue_client(acpUrl)) { - cat("ACP bridge unavailable. Connect ACP before using /ohdsi.\n") - return(list(handled = TRUE, value = "")) - } - stage_context <- build_workflow_stage_context(studyIntent = studyIntent, dialogue_state = dialogue_state) - stage_context$dialogue$last_user_message <- question - message("Calling ACP flow: workflow_context_dialogue") - response <- tryCatch( - call_shell_acp_flow( - "workflow_context_dialogue", - list(workflow_stage_context = stage_context, message = question) - ), - error = function(e) list(status = "error", error = conditionMessage(e)) - ) - if (!identical(response$status %||% "", "ok")) { - cat(sprintf("OHDSI guidance failed: %s\n", as.character(response$error %||% "unknown error"))) - return(list(handled = TRUE, value = "")) - } - render_workflow_dialogue(response) - list(handled = TRUE, value = "") - } - - readline_with_dialogue <- function(prompt) { - repeat { - entered <- readline(prompt) - handled <- handle_workflow_dialogue_command(entered) - if (isTRUE(handled$handled)) next - return(handled$value) - } - } + dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( + interactive = interactive, + study_intent_getter = function() studyIntent, + build_stage_context = build_workflow_stage_context, + call_dialogue = function(stage_context, message) { + if (!ensure_workflow_dialogue_client(acpUrl)) { + stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") + } + message("Calling ACP flow: workflow_context_dialogue") + .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) + }, + empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why are these candidates weak here?" + ) + dialogue_state <- dialogue_session$state + set_dialogue_context <- dialogue_session$set_context + readline_with_dialogue <- dialogue_session$readline prompt_yesno <- function(prompt, default = TRUE) { if (!isTRUE(interactive)) return(default) diff --git a/R/slashOhdsiStrategusAssistant/NAMESPACE b/R/slashOhdsiStrategusAssistant/NAMESPACE index b24f256..bee943c 100644 --- a/R/slashOhdsiStrategusAssistant/NAMESPACE +++ b/R/slashOhdsiStrategusAssistant/NAMESPACE @@ -1,3 +1,14 @@ +export(build_cohort_methods_workflow_stage_context) +export(build_incidence_workflow_stage_context) +export(cohort_methods_dialogue_step_label) +export(compact_workflow_dialogue_context) +export(incidence_dialogue_step_label) +export(normalize_cohort_methods_dialogue_step) +export(normalize_incidence_dialogue_step) export(new_workflow_stage_context) +export(new_workflow_dialogue_session) +export(new_workflow_dialogue_state) +export(render_workflow_dialogue_response) +export(set_workflow_dialogue_context) export(validate_workflow_stage_context) export(workflow_stage_step_choices) diff --git a/R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R new file mode 100644 index 0000000..4f039df --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R @@ -0,0 +1,149 @@ +compact_workflow_dialogue_context <- function(value) { + if (!is.list(value) || length(value) == 0) return(list()) + keep <- lapply(value, function(item) { + if (is.null(item)) return(FALSE) + if (is.character(item) && length(item) == 1 && !nzchar(trimws(item))) return(FALSE) + if (is.atomic(item) && length(item) == 0) return(FALSE) + if (is.list(item) && length(item) == 0) return(FALSE) + TRUE + }) + keep_idx <- which(vapply(keep, isTRUE, logical(1))) + if (length(keep_idx) == 0) return(list()) + value[keep_idx] +} + +#' Construct mutable dialogue state for interactive workflow guidance +#' @return environment storing current step, role, and compact context +#' @export +new_workflow_dialogue_state <- function() { + state <- new.env(parent = emptyenv()) + state$current_step <- "" + state$current_role <- "" + state$current_context <- list() + state +} + +#' Update dialogue state for the current workflow step +#' @param dialogue_state dialogue state environment +#' @param step current workflow step identifier +#' @param role optional active role identifier +#' @param context optional step context list +#' @return invisible NULL +#' @export +set_workflow_dialogue_context <- function(dialogue_state, + step = "", + role = "", + context = list()) { + if (!is.environment(dialogue_state)) stop("dialogue_state must be an environment.") + dialogue_state$current_step <- as.character(step %||% "") + dialogue_state$current_role <- as.character(role %||% "") + dialogue_state$current_context <- compact_workflow_dialogue_context(context %||% list()) + invisible(NULL) +} + +#' Render a workflow dialogue response in the interactive shell +#' @param response ACP workflow dialogue response +#' @return invisible NULL +#' @export +render_workflow_dialogue_response <- function(response) { + core <- response$dialogue %||% response + cat("\n== OHDSI Guidance ==\n") + answer <- as.character(core$answer %||% "") + if (nzchar(trimws(answer))) { + cat(answer, "\n") + } else { + cat("No contextual guidance was returned.\n") + } + guidance <- core$current_step_guidance %||% list() + if (length(guidance) > 0) { + cat("Current step guidance:\n") + for (item in guidance) cat(sprintf(" - %s\n", item)) + } + cautions <- core$cautions %||% list() + if (length(cautions) > 0) { + cat("Cautions:\n") + for (item in cautions) cat(sprintf(" - %s\n", item)) + } + next_actions <- core$suggested_next_actions %||% list() + if (length(next_actions) > 0) { + cat("Suggested next actions:\n") + for (item in next_actions) cat(sprintf(" - %s\n", item)) + } + cat("\n") + invisible(NULL) +} + +#' Construct interactive /ohdsi dialogue handlers for a workflow shell +#' @param interactive whether shell prompts are interactive +#' @param study_intent_getter function returning current study intent +#' @param build_stage_context function taking studyIntent and dialogue_state +#' @param call_dialogue function taking stage_context and message +#' @param render_response function for displaying response text +#' @param empty_question_message text shown when `/ohdsi` has no question +#' @param command_prefix slash command prefix +#' @return list with `state`, `set_context`, `handle_command`, and `readline` +#' @export +new_workflow_dialogue_session <- function(interactive = TRUE, + study_intent_getter, + build_stage_context, + call_dialogue, + render_response = render_workflow_dialogue_response, + empty_question_message = "Enter a question after /ohdsi.", + command_prefix = "/ohdsi") { + if (!is.function(study_intent_getter)) stop("study_intent_getter must be a function.") + if (!is.function(build_stage_context)) stop("build_stage_context must be a function.") + if (!is.function(call_dialogue)) stop("call_dialogue must be a function.") + if (!is.function(render_response)) stop("render_response must be a function.") + + dialogue_state <- new_workflow_dialogue_state() + + handle_command <- function(entered) { + trimmed <- trimws(as.character(entered %||% "")) + if (!isTRUE(interactive) || !startsWith(trimmed, command_prefix)) { + return(list(handled = FALSE, value = entered)) + } + question <- trimws(sub(paste0("^", command_prefix), "", trimmed)) + if (!nzchar(question)) { + cat(empty_question_message, "\n") + return(list(handled = TRUE, value = "")) + } + stage_context <- build_stage_context( + studyIntent = study_intent_getter(), + dialogue_state = dialogue_state + ) + stage_context$dialogue$last_user_message <- question + response <- tryCatch( + call_dialogue(stage_context = stage_context, message = question), + error = function(e) list(status = "error", error = conditionMessage(e)) + ) + if (!identical(response$status %||% "", "ok")) { + cat(sprintf("OHDSI guidance failed: %s\n", as.character(response$error %||% "unknown error"))) + return(list(handled = TRUE, value = "")) + } + render_response(response) + list(handled = TRUE, value = "") + } + + readline_with_dialogue <- function(prompt) { + repeat { + entered <- readline(prompt) + handled <- handle_command(entered) + if (isTRUE(handled$handled)) next + return(handled$value) + } + } + + list( + state = dialogue_state, + set_context = function(step = "", role = "", context = list()) { + set_workflow_dialogue_context( + dialogue_state = dialogue_state, + step = step, + role = role, + context = context + ) + }, + handle_command = handle_command, + readline = readline_with_dialogue + ) +} diff --git a/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R new file mode 100644 index 0000000..c9c62e7 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R @@ -0,0 +1,150 @@ +normalize_incidence_dialogue_step <- function(step) { + step <- as.character(step %||% "") + mapped <- switch( + step, + study_intent = "study_intent_capture", + target_recommendation = "target_selection", + target_recommendation_window2 = "target_selection", + target_recommendation_resume = "target_selection", + target_advice_call = "target_selection", + target_improvements = "phenotype_review", + outcome_recommendation = "outcome_selection", + outcome_recommendation_window2 = "outcome_selection", + outcome_recommendation_resume = "outcome_selection", + outcome_advice_call = "outcome_selection", + outcome_improvements = "phenotype_review", + step + ) + as.character(mapped %||% "") +} + +incidence_dialogue_step_label <- function(step, role = "") { + step <- normalize_incidence_dialogue_step(step) + role <- as.character(role %||% "") + role_label <- if (nzchar(role)) paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") else "" + switch( + step, + study_intent_capture = "Study intent capture", + intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", + target_selection = "Target selection", + outcome_selection = "Outcome selection", + phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + incidence_design_setup = "Incidence design setup", + time_at_risk_configuration = "Time-at-risk configuration", + workflow_summary = "Workflow summary", + gsub("_", " ", step, fixed = TRUE) + ) +} + +build_incidence_workflow_stage_context <- function(study_intent, + dialogue_state, + interactive = TRUE) { + current_step <- normalize_incidence_dialogue_step(dialogue_state$current_step %||% "") + current_role <- as.character(dialogue_state$current_role %||% "") + current_context <- compact_workflow_dialogue_context(dialogue_state$current_context %||% list()) + + context <- new_workflow_stage_context( + workflow_type = "strategus_incidence", + current_step = current_step, + step_label = incidence_dialogue_step_label(current_step, current_role), + user_goal = as.character(study_intent %||% ""), + entities = compact_workflow_dialogue_context(list( + active_role = current_role, + role_statement = current_context$role_statement %||% current_context$statement, + target = current_context$target_statement %||% NULL, + outcomes = current_context$outcome_statement %||% current_context$outcome_statements %||% list() + )), + available_artifacts = compact_workflow_dialogue_context(list( + selected_target_ids = as.list(current_context$selected_target_ids %||% list()), + selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), + analysis_settings_path = current_context$analysis_settings_path %||% NULL, + concept_set_paths = current_context$concept_set_paths %||% list() + )), + last_user_message = NULL, + constraints = list( + interactive = isTRUE(interactive), + allow_recommendations = TRUE, + allow_generation = FALSE + ) + ) + context$legacy_context <- current_context + context +} + +normalize_cohort_methods_dialogue_step <- function(step) { + step <- as.character(step %||% "") + mapped <- switch( + step, + study_intent = "study_intent_capture", + target_recommendation = "target_selection", + comparator_recommendation = "comparator_selection", + outcome_recommendation = "outcome_selection", + target_improvements = "phenotype_review", + comparator_improvements = "phenotype_review", + outcome_improvements = "phenotype_review", + analytic_settings_step_by_step = "analytic_settings_collection", + step + ) + as.character(mapped %||% "") +} + +cohort_methods_dialogue_step_label <- function(step, role = "") { + step <- normalize_cohort_methods_dialogue_step(step) + role <- as.character(role %||% "") + role_label <- if (nzchar(role)) { + paste0(toupper(substring(role, 1, 1)), substring(role, 2), " ") + } else { + "" + } + switch( + step, + study_intent_capture = "Study intent capture", + intent_split = if (nzchar(role_label)) paste0("Intent split: ", trimws(role_label)) else "Intent split", + target_selection = "Target selection", + comparator_selection = "Comparator selection", + outcome_selection = "Outcome selection", + phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + analytic_settings_collection = "Analytic settings collection", + cohort_method_spec_recommendation = "Cohort method specification recommendation", + cohort_method_spec_confirmation = "Cohort method specification confirmation", + workflow_summary = "Workflow summary", + gsub("_", " ", step, fixed = TRUE) + ) +} + +build_cohort_methods_workflow_stage_context <- function(study_intent, + dialogue_state, + interactive = TRUE) { + current_step <- normalize_cohort_methods_dialogue_step(dialogue_state$current_step %||% "") + current_role <- as.character(dialogue_state$current_role %||% "") + current_context <- compact_workflow_dialogue_context(dialogue_state$current_context %||% list()) + + context <- new_workflow_stage_context( + workflow_type = "strategus_cohort_methods", + current_step = current_step, + step_label = cohort_methods_dialogue_step_label(current_step, current_role), + user_goal = as.character(study_intent %||% ""), + entities = compact_workflow_dialogue_context(list( + active_role = current_role, + role_statement = current_context$role_statement %||% current_context$statement, + target = current_context$target_statement %||% NULL, + comparator = current_context$comparator_statement %||% NULL, + outcomes = current_context$outcome_statements %||% list() + )), + available_artifacts = compact_workflow_dialogue_context(list( + selected_target_ids = as.list(current_context$selected_target_ids %||% list()), + selected_comparator_ids = as.list(current_context$selected_comparator_ids %||% list()), + selected_outcome_ids = as.list(current_context$selected_outcome_ids %||% list()), + analysis_settings_path = current_context$analysis_settings_path %||% NULL, + concept_set_paths = current_context$concept_set_paths %||% list() + )), + last_user_message = NULL, + constraints = list( + interactive = isTRUE(interactive), + allow_recommendations = TRUE, + allow_generation = FALSE + ) + ) + context$legacy_context <- current_context + context +} From a9ed40e560c5ee6243236c38110f38d813994139 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 05:32:05 -0500 Subject: [PATCH 05/20] Completion of the new slashOhdsiAcpClient and slashOhdsiStrategusAssistant R packages. OHDSIAssistant remains as a shim until the other packages are tested --- R/OHDSIAssistant/DESCRIPTION | 4 +- R/OHDSIAssistant/R/acp_client.R | 39 +- R/OHDSIAssistant/R/cohort_methods_workflow.R | 210 +- R/OHDSIAssistant/R/concept_set_actions.R | 199 +- R/OHDSIAssistant/R/db_details.R | 41 +- R/OHDSIAssistant/R/execution_settings.R | 62 +- R/OHDSIAssistant/R/lintStudyDesign.R | 116 +- R/OHDSIAssistant/R/ops_llm_actions.R | 30 +- R/OHDSIAssistant/R/phenotype_workflow.R | 393 +- R/OHDSIAssistant/R/rules_local_fallback.R | 64 +- R/OHDSIAssistant/R/slash_ohdsi_bridge.R | 130 - .../R/strategus_cohort_methods_shell.R | 6232 +---------------- .../R/strategus_incidence_shell.R | 1615 +---- R/OHDSIAssistant/R/utils_json.R | 22 +- R/OHDSIAssistant/README.md | 191 +- R/slashOhdsiAcpClient/DESCRIPTION | 2 +- R/slashOhdsiAcpClient/NAMESPACE | 12 + R/slashOhdsiAcpClient/R/compatibility_api.R | 340 + .../R/lint_and_concept_sets.R | 466 ++ R/slashOhdsiStrategusAssistant/DESCRIPTION | 2 +- R/slashOhdsiStrategusAssistant/NAMESPACE | 7 + .../R/cohort_methods_specs.R | 73 + R/slashOhdsiStrategusAssistant/R/db_details.R | 43 + .../R/execution_settings.R | 62 + .../R/slash_ohdsi_runtime.R | 63 + .../R/strategus_cohort_methods_shell.R | 6230 ++++++++++++++++ .../R/strategus_incidence_shell.R | 1617 +++++ R/slashOhdsiStrategusAssistant/README.md | 10 +- .../inst/templates/cmAnalysis_template.json | 65 + README.md | 4 +- ...DS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md | 2 +- docs/COHORT_METHODS_WORKFLOW.md | 2 +- docs/INCIDENCE_WORKFLOW.md | 2 +- docs/STRATEGUS_COHORT_METHODS_SHELL.md | 4 +- docs/STRATEGUS_SHELL.md | 2 +- .../test_cohort_methods_generated_scripts.py | 8 +- ...rt_methods_shell_recommendation_support.py | 2 +- 37 files changed, 9196 insertions(+), 9170 deletions(-) delete mode 100644 R/OHDSIAssistant/R/slash_ohdsi_bridge.R create mode 100644 R/slashOhdsiAcpClient/R/compatibility_api.R create mode 100644 R/slashOhdsiAcpClient/R/lint_and_concept_sets.R create mode 100644 R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R create mode 100644 R/slashOhdsiStrategusAssistant/R/db_details.R create mode 100644 R/slashOhdsiStrategusAssistant/R/execution_settings.R create mode 100644 R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R create mode 100644 R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R create mode 100644 R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R create mode 100644 R/slashOhdsiStrategusAssistant/inst/templates/cmAnalysis_template.json diff --git a/R/OHDSIAssistant/DESCRIPTION b/R/OHDSIAssistant/DESCRIPTION index 3493f5b..ed8938d 100644 --- a/R/OHDSIAssistant/DESCRIPTION +++ b/R/OHDSIAssistant/DESCRIPTION @@ -3,10 +3,10 @@ Type: Package Title: Assistant Prototype for OHDSI Study Design Linting Version: 0.0.1 Authors@R: person("Your","Name", email="you@example.org", role=c("aut","cre")) -Description: Prototype assistant client that connects to a local ACP bridge and performs study design linting (concept-sets-review, cohort-critique-general-design). +Description: Compatibility layer that forwards legacy OHDSIAssistant entrypoints to slashOhdsiAcpClient and slashOhdsiStrategusAssistant. License: MIT + file LICENSE Encoding: UTF-8 Depends: R (>= 4.2) -Imports: jsonlite, httr, utils +Imports: jsonlite, httr, utils, slashOhdsiAcpClient, slashOhdsiStrategusAssistant Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.2 diff --git a/R/OHDSIAssistant/R/acp_client.R b/R/OHDSIAssistant/R/acp_client.R index ad4f214..250639d 100644 --- a/R/OHDSIAssistant/R/acp_client.R +++ b/R/OHDSIAssistant/R/acp_client.R @@ -1,40 +1,11 @@ -acp_state <- new.env(parent = emptyenv()) - #' Connect to ACP bridge #' @param url e.g. "http://127.0.0.1:8765" #' @param token optional bearer token -#' @return invisible(TRUE) +#' @return invisible TRUE +#' @export acp_connect <- function(url = "http://127.0.0.1:8765", token = NULL) { - url <- sub("/$", "", url) - resp <- httr::GET(paste0(url, "/health")) - if (httr::status_code(resp) != 200) stop("ACP bridge not reachable") - acp_state$url <- url - acp_state$token <- token - invisible(TRUE) -} - -.acp_post <- function(path, body) { - if (is.null(acp_state$url)) stop("ACP not connected; call acp_connect().") - if (is.list(body)) { - if (!is.null(body$protocolRef)) body$protocolRef <- as.character(body$protocolRef) - if (!is.null(body$cohortsCatalogRef)) body$cohortsCatalogRef <- as.character(body$cohortsCatalogRef) - if (!is.null(body$cohortRefs)) body$cohortRefs <- as.list(unname(vapply(body$cohortRefs, as.character, character(1)))) - if (!is.null(body$characterizationRefs)) body$characterizationRefs <- as.list(unname(vapply(body$characterizationRefs, as.character, character(1)))) - } - url <- paste0(acp_state$url, path) - headers <- c(`Content-Type` = "application/json") - if (!is.null(acp_state$token)) { - headers <- c(headers, Authorization = paste("Bearer", acp_state$token)) + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use acp_connect().") } - timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", "180")) - if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- 180 - resp <- httr::POST( - url, - body = body, - encode = "json", - httr::add_headers(.headers = headers), - httr::timeout(timeout_seconds) - ) - if (httr::status_code(resp) >= 300) stop("ACP error: ", httr::content(resp, as = "text")) - jsonlite::fromJSON(httr::content(resp, as = "text"), simplifyVector = FALSE) + slashOhdsiAcpClient::acp_connect(url = url, token = token) } diff --git a/R/OHDSIAssistant/R/cohort_methods_workflow.R b/R/OHDSIAssistant/R/cohort_methods_workflow.R index 745a65e..ae13075 100644 --- a/R/OHDSIAssistant/R/cohort_methods_workflow.R +++ b/R/OHDSIAssistant/R/cohort_methods_workflow.R @@ -1,210 +1,16 @@ -#' Suggest cohort method study specifications from a free-text description. -#' -#' Calls the ACP flow `/flows/cohort_methods_specifications_recommendation` -#' and returns the cohort-methods recommendation, full analysis spec for -#' traceability, and per-section rationales. Falls back to a local stub -#' when `acp_state$url` is NULL. -#' -#' @param studyIntent protocol context string -#' @param analyticSettingsDescription free-text description of the study design -#' @param interactive when TRUE, prints a section summary (default: TRUE) +#' Suggest cohort method study specifications from a free-text description (compatibility wrapper) +#' @inheritParams slashOhdsiStrategusAssistant::suggestCohortMethodSpecs #' @return list response from ACP flow or local stub #' @export suggestCohortMethodSpecs <- function(studyIntent, analyticSettingsDescription, interactive = TRUE) { - if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { - stop("Provide a non-empty studyIntent.") + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to use suggestCohortMethodSpecs().") } - if (is.null(analyticSettingsDescription) || !nzchar(trimws(analyticSettingsDescription))) { - stop("Provide a non-empty analyticSettingsDescription.") - } - - body <- list( - study_intent = trimws(as.character(studyIntent)), - study_description = trimws(as.character(analyticSettingsDescription)), - analytic_settings_description = trimws(as.character(analyticSettingsDescription)) - ) - - res <- if (!is.null(acp_state$url)) { - .acp_post("/flows/cohort_methods_specifications_recommendation", body) - } else { - local_cohort_method_specs(body) - } - - if (isTRUE(interactive)) { - cat("\n== Cohort Method Specifications ==\n") - cat("Status:", res$status %||% "(missing)", "\n") - rec <- res$recommendation %||% list() - if (length(rec) > 0) { - cat("Profile:", rec$profile_name %||% "(none)", "\n") - cat("Recommendation status:", rec$status %||% "(none)", "\n") - } - rats <- res$section_rationales %||% list() - if (length(rats) > 0) { - for (section in names(rats)) { - entry <- rats[[section]] - cat(sprintf(" - %s: confidence=%s %s\n", - section, - entry$confidence %||% "?", - entry$rationale %||% "")) - } - } - failed <- res$diagnostics$failed_sections %||% list() - if (length(failed) > 0) { - cat("Backfilled sections:", paste(unlist(failed), collapse = ", "), "\n") - } - .studyAgentPrintCohortMethodSpecsSummary(rec) - } - invisible(res) -} - -.studyAgentCmSpecIsPresent <- function(value) { - if (is.null(value) || length(value) == 0) return(FALSE) - if (length(value) == 1 && is.atomic(value) && is.na(value)) return(FALSE) - TRUE -} - -.studyAgentCmSpecValue <- function(value, path = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - if (!.studyAgentCmSpecIsPresent(value)) return("") - if (is.character(value) && length(value) == 1 && !nzchar(trimws(value))) return("") - if (is.logical(value) && length(value) == 1) return(if (isTRUE(value)) "Yes" else "No") - if (is.character(value) && length(value) == 1) { - mapped <- switch( - path %||% "", - "startAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], - "endAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], - "ps_strategy" = c("match_on_ps" = "Match on propensity score", "stratify_by_ps" = "Stratify on propensity score", "none" = "None")[[value]], - "caliperScale" = c("propensity score" = "Propensity score", "standardized" = "Standardized", "standardized logit" = "Standardized logit")[[value]], - "modelType" = c("cox" = "Cox proportional hazards", "poisson" = "Poisson regression", "logistic" = "Logistic regression")[[value]], - "removeDuplicateSubjects" = c( - "keep all" = "Keep All", - "keep first" = "Keep First", - "remove all" = "Remove All", - "keep first, truncate to second" = "Keep First, Truncate to Second" - )[[value]], - NULL - ) - if (!is.null(mapped) && length(mapped) == 1 && !is.na(mapped)) return(mapped) - return(value) - } - if (is.numeric(value) && length(value) == 1) { - return(trimws(formatC(as.numeric(value), format = "fg", digits = 10))) - } - paste(as.character(value), collapse = ", ") -} - -.studyAgentCmSpecRegularized <- function(args) { - if (!is.list(args)) return(FALSE) - .studyAgentCmSpecIsPresent(args$prior) -} - -.studyAgentCmSpecPsStrategy <- function(ps) { - if (!is.list(ps)) return("none") - if (.studyAgentCmSpecIsPresent(ps$matchOnPsArgs)) return("match_on_ps") - if (.studyAgentCmSpecIsPresent(ps$stratifyByPsArgs)) return("stratify_by_ps") - "none" -} - -.studyAgentCmSpecTrimming <- function(trim_args) { - if (!is.list(trim_args) || length(trim_args) == 0) return("None") - if (.studyAgentCmSpecIsPresent(trim_args$equipoiseBounds)) return("By equipoise") - if (.studyAgentCmSpecIsPresent(trim_args$trimFraction)) { - fraction <- suppressWarnings(as.numeric(trim_args$trimFraction)) - if (length(fraction) == 1 && !is.na(fraction) && fraction > 0) { - return(sprintf("By percent (%s%%)", trimws(formatC(fraction * 100, format = "fg", digits = 6)))) - } - } - "None" -} - -.studyAgentCmSpecPrintSection <- function(title, rows) { - cat(sprintf("[%s]\n", title)) - for (row in rows) { - cat(sprintf(" - %s: %s\n", row[[1]], row[[2]])) - } -} - -.studyAgentPrintCohortMethodSpecsSummary <- function(recommendation) { - if (!is.list(recommendation)) return(invisible(NULL)) - study_population <- recommendation$study_population %||% list() - time_at_risk <- recommendation$time_at_risk %||% list() - ps <- recommendation$propensity_score_adjustment %||% list() - outcome <- recommendation$outcome_model %||% list() - cohort_method_data <- study_population$cohortMethodDataArgs %||% list() - if (length(study_population) == 0 && length(time_at_risk) == 0 && length(ps) == 0 && length(outcome) == 0) { - return(invisible(NULL)) - } - - cat("\n") - .studyAgentCmSpecPrintSection("Study Population", list( - list("Study start date", .studyAgentCmSpecValue(cohort_method_data$studyStartDate)), - list("Study end date", .studyAgentCmSpecValue(cohort_method_data$studyEndDate)), - list("Restrict to common period", .studyAgentCmSpecValue(cohort_method_data$restrictToCommonPeriod)), - list("First exposure only", .studyAgentCmSpecValue(cohort_method_data$firstExposureOnly)), - list("Washout period", .studyAgentCmSpecValue(cohort_method_data$washoutPeriod)), - list("Remove duplicate subjects", .studyAgentCmSpecValue(cohort_method_data$removeDuplicateSubjects, "removeDuplicateSubjects")), - list("Censor at new risk window", .studyAgentCmSpecValue(study_population$censorAtNewRiskWindow)), - list("Remove prior outcomes", .studyAgentCmSpecValue(study_population$removeSubjectsWithPriorOutcome)), - list("Prior outcome lookback", .studyAgentCmSpecValue(study_population$priorOutcomeLookback)), - list("Maximum cohort size", .studyAgentCmSpecValue(cohort_method_data$maxCohortSize)) - )) - .studyAgentCmSpecPrintSection("Time At Risk", list( - list("Minimum days at risk", .studyAgentCmSpecValue(study_population$minDaysAtRisk)), - list("Risk window start", .studyAgentCmSpecValue(time_at_risk$riskWindowStart)), - list("Risk window start anchor", .studyAgentCmSpecValue(time_at_risk$startAnchor, "startAnchor")), - list("Risk window end", .studyAgentCmSpecValue(time_at_risk$riskWindowEnd)), - list("Risk window end anchor", .studyAgentCmSpecValue(time_at_risk$endAnchor, "endAnchor")) - )) - .studyAgentCmSpecPrintSection("Propensity Score Adjustment", list( - list("PS trimming", .studyAgentCmSpecTrimming(ps$trimByPsArgs)), - list("PS adjustment strategy", .studyAgentCmSpecValue(.studyAgentCmSpecPsStrategy(ps), "ps_strategy")), - list("Max cohort size for PS fitting", .studyAgentCmSpecValue(ps$createPsArgs$maxCohortSizeForFitting)), - list("Test covariate correlation", .studyAgentCmSpecValue(ps$createPsArgs$errorOnHighCorrelation)), - list("Use regularization", .studyAgentCmSpecValue(.studyAgentCmSpecRegularized(ps$createPsArgs))), - list("Maximum match ratio", .studyAgentCmSpecValue(ps$matchOnPsArgs$maxRatio)), - list("Matching caliper", .studyAgentCmSpecValue(ps$matchOnPsArgs$caliper)), - list("Caliper scale", .studyAgentCmSpecValue(ps$matchOnPsArgs$caliperScale, "caliperScale")) - )) - .studyAgentCmSpecPrintSection("Outcome Model", list( - list("Outcome model", .studyAgentCmSpecValue(outcome$modelType, "modelType")), - list("Condition on strata", .studyAgentCmSpecValue(outcome$stratified)), - list("Use covariates in outcome model", .studyAgentCmSpecValue(outcome$useCovariates)), - list("Use IPTW", .studyAgentCmSpecValue(outcome$inversePtWeighting)), - list("Use regularization", .studyAgentCmSpecValue(.studyAgentCmSpecRegularized(outcome))) - )) - invisible(NULL) -} - -local_cohort_method_specs <- function(body) { - list( - source = "stub_no_acp", - status = "stub", - recommendation = list( - mode = "free_text", - input_method = "typed_text", - source = "local_stub_no_acp", - status = "stub", - profile_name = "Recommended from free-text description (stub)", - raw_description = body$analytic_settings_description %||% "", - study_population = list(), - time_at_risk = list(), - propensity_score_adjustment = list(), - outcome_model = list(), - deferred_inputs = list( - function_argument_description = "implemented", - description_file_path = "implemented", - interactive_typed_description = "implemented" - ), - defaults_snapshot = list() - ), - cohort_methods_specifications = list(), - section_rationales = list(), - diagnostics = list( - source = "local_stub_no_acp", - reason = "acp_state$url is NULL; call acp_connect(url) first." - ), - request = body + slashOhdsiStrategusAssistant::suggestCohortMethodSpecs( + studyIntent = studyIntent, + analyticSettingsDescription = analyticSettingsDescription, + interactive = interactive ) } diff --git a/R/OHDSIAssistant/R/concept_set_actions.R b/R/OHDSIAssistant/R/concept_set_actions.R index 1318b64..9352d50 100644 --- a/R/OHDSIAssistant/R/concept_set_actions.R +++ b/R/OHDSIAssistant/R/concept_set_actions.R @@ -1,183 +1,32 @@ -#' Propose includeDescendants patch for concept set -#' @param conceptSetRef path or URL to concept_set.json -#' @return list patch payload +#' Compatibility wrapper for concept set patch proposal +#' @inheritParams slashOhdsiAcpClient::proposeIncludeDescendantsPatch +#' @export proposeIncludeDescendantsPatch <- function(conceptSetRef) { - payload <- list( - artifactRef = conceptSetRef, - ops = list(list( - op = "set_include_descendants", - where = list(domainId = "Drug", conceptClassId = "Ingredient", includeDescendants = FALSE), - value = TRUE - )), - write = FALSE - ) - if (!is.null(acp_state$url)) { - res <- .acp_post("/actions/concept_set_edit", payload) - if (is.null(res$ops)) res$ops <- payload$ops - res$artifactRef <- conceptSetRef - return(res) - } - res <- local_apply_concept_set_action(payload, write = FALSE) - res$ops <- payload$ops - res$artifactRef <- conceptSetRef - res + slashOhdsiAcpClient::proposeIncludeDescendantsPatch(conceptSetRef) } -#' Preview concept set patch -#' @param conceptSetRef path or URL -#' @param patch patch object from proposeIncludeDescendantsPatch +#' Compatibility wrapper for concept set patch preview +#' @inheritParams slashOhdsiAcpClient::previewConceptSetPatch +#' @export previewConceptSetPatch <- function(conceptSetRef, patch) { - if (!is.null(patch$actions)) { - prev <- applyLLMActionsConceptSet(conceptSetRef, patch$actions, preview = TRUE) - cat(prev$plan %||% "LLM actions preview", "\n") - if (length(prev$preview_changes) == 0) { - cat("No matching items found.\n"); return(invisible(prev)) - } - df <- do.call(rbind, lapply(prev$preview_changes, as.data.frame)) - print(df) - return(invisible(prev)) - } - if (is.null(patch$preview_changes)) { - cat("No preview available.\n"); return(invisible(NULL)) - } - cat(patch$plan, "\n") - if (length(patch$preview_changes) == 0) { - cat("No matching items found.\n"); return(invisible(NULL)) - } - df <- do.call(rbind, lapply(patch$preview_changes, as.data.frame)) - print(df) - invisible(df) -} - -#' Apply concept set patch (writes file) -#' @param conceptSetRef path or URL -#' @param patch patch object -#' @param backup logical; if TRUE, create .bak before overwrite -#' @param outputPath optional output path -applyConceptSetPatch <- function(conceptSetRef, patch, backup = TRUE, outputPath = NULL, useActions = NULL, overwrite = TRUE) { - # Choose mode: actions vs deterministic ops - if (is.null(useActions)) useActions <- !is.null(patch$actions) - if (isTRUE(useActions)) { - res <- applyLLMActionsConceptSet( - conceptSetRef, - patch$actions %||% list(), - preview = FALSE, - overwrite = overwrite, - backup = backup - ) - return(invisible(res)) - } - - patch$write <- TRUE - patch$artifactRef <- conceptSetRef - patch$backup <- backup - if (!is.null(outputPath)) patch$outputPath <- outputPath - if (is.null(patch$ops)) { - patch$ops <- list(list( - op = "set_include_descendants", - where = list(domainId = "Drug", conceptClassId = "Ingredient", includeDescendants = FALSE), - value = TRUE - )) - } - - pre_hash <- tryCatch(tools::md5sum(conceptSetRef), error = function(e) NA_character_) - res <- if (!is.null(acp_state$url)) { - .acp_post("/actions/concept_set_edit", patch) - } else { - local_apply_concept_set_action(patch, write = TRUE) - } - post_hash <- tryCatch(tools::md5sum(res$written_to %||% conceptSetRef), error = function(e) NA_character_) - - outdir <- file.path(dirname(conceptSetRef), "inst", "assistant") - if (!dir.exists(outdir)) dir.create(outdir, recursive = TRUE) - log_entry <- list( - plan = res$plan, - preview_changes = res$preview_changes, - applied = res$applied, - written_to = res$written_to %||% conceptSetRef, - pre_hash = unname(pre_hash), - post_hash = unname(post_hash), - ts = format(Sys.time(), "%Y%m%dT%H%M%S") - ) - jsonlite::write_json(log_entry, file.path(outdir, paste0("concept_set_edit_", log_entry$ts, ".json")), auto_unbox = TRUE, pretty = TRUE) - if (isTRUE(res$applied)) { - cat(sprintf("Applied concept set patch to %s\n", res$written_to %||% conceptSetRef)) - if (isTRUE(backup) && !is.null(res$backup_file)) { - cat(sprintf("Backup created at %s\n", res$backup_file)) - } - } else { - cat("No changes applied.\n") - } - invisible(res) + slashOhdsiAcpClient::previewConceptSetPatch(conceptSetRef, patch) } - -local_apply_concept_set_action <- function(payload, write = FALSE) { - ref <- payload$artifactRef - cs <- read_json_ref(ref) - ops <- payload$ops %||% list() - all_preview <- list() - - for (op in ops) { - if (identical(op$op, "set_include_descendants")) { - where <- op$where %||% list() - value <- op$value %||% TRUE - res <- local_set_include_descendants(cs, where, value) - cs <- res$cs - all_preview <- c(all_preview, res$preview) - } - } - - written_to <- NULL - applied <- FALSE - backup_file <- NULL - if (isTRUE(write)) { - target <- payload$outputPath %||% ref - if (isTRUE(payload$backup) && file.exists(target)) { - ts <- format(Sys.time(), "%Y%m%dT%H%M%S") - backup_file <- paste0(target, ".bak_", ts) - file.copy(target, backup_file, overwrite = TRUE) - } - jsonlite::write_json(cs, target, auto_unbox = TRUE, pretty = TRUE) - written_to <- target - applied <- TRUE - } - - list( - plan = "Set includeDescendants=true for Drug/Ingredient entries that lack it.", - preview_changes = all_preview, - applied = applied, - written_to = written_to, - backup_file = backup_file +#' Compatibility wrapper for concept set patch application +#' @inheritParams slashOhdsiAcpClient::applyConceptSetPatch +#' @export +applyConceptSetPatch <- function(conceptSetRef, + patch, + backup = TRUE, + outputPath = NULL, + useActions = NULL, + overwrite = TRUE) { + slashOhdsiAcpClient::applyConceptSetPatch( + conceptSetRef = conceptSetRef, + patch = patch, + backup = backup, + outputPath = outputPath, + useActions = useActions, + overwrite = overwrite ) } - - -local_set_include_descendants <- function(cs, where, value = TRUE) { - items <- if (!is.null(cs$items)) cs$items else cs - preview <- list() - for (i in seq_along(items)) { - it <- items[[i]] - concept <- it$concept %||% list() - cid <- concept$conceptId %||% concept$CONCEPT_ID %||% NA_integer_ - dom <- concept$domainId %||% concept$DOMAIN_ID %||% NA_character_ - cls <- concept$conceptClassId %||% concept$CONCEPT_CLASS_ID %||% NA_character_ - inc <- it$includeDescendants %||% FALSE - if (!is.na(where$domainId %||% NA_character_) && !identical(dom, where$domainId)) next - if (!is.na(where$conceptClassId %||% NA_character_) && !identical(cls, where$conceptClassId)) next - if (!is.null(where$includeDescendants) && !identical(isTRUE(inc), isTRUE(where$includeDescendants))) next - preview <- c(preview, list(list( - conceptId = cid, - from = list(includeDescendants = inc), - to = list(includeDescendants = value) - ))) - it$includeDescendants <- isTRUE(value) - items[[i]] <- it - } - if (!is.null(cs$items)) { - cs$items <- items - } else { - cs <- items - } - list(cs = cs, preview = preview) -} diff --git a/R/OHDSIAssistant/R/db_details.R b/R/OHDSIAssistant/R/db_details.R index 1a9b7ae..ab10f0c 100644 --- a/R/OHDSIAssistant/R/db_details.R +++ b/R/OHDSIAssistant/R/db_details.R @@ -1,43 +1,22 @@ -#' Read Strategus database details from JSON -#' @param path path to strategus-db-details.json +#' Read Strategus database details from JSON (compatibility wrapper) +#' @inheritParams slashOhdsiStrategusAssistant::readStrategusDbDetails #' @return list of db settings #' @export readStrategusDbDetails <- function(path = file.path(getwd(), "strategus-db-details.json")) { - if (!file.exists(path)) { - stop("Database details file not found: ", path) + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to use readStrategusDbDetails().") } - jsonlite::read_json(path, simplifyVector = TRUE) + slashOhdsiStrategusAssistant::readStrategusDbDetails(path = path) } -#' Create DatabaseConnector connectionDetails from strategus-db-details.json -#' @param path path to strategus-db-details.json -#' @param dbDetails optional list of db settings (if already loaded) +#' Create DatabaseConnector connectionDetails from strategus-db-details.json (compatibility wrapper) +#' @inheritParams slashOhdsiStrategusAssistant::createStrategusConnectionDetails #' @return DatabaseConnector connectionDetails object #' @export createStrategusConnectionDetails <- function(path = file.path(getwd(), "strategus-db-details.json"), dbDetails = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - dbConfig <- dbDetails %||% readStrategusDbDetails(path) - dbms <- dbConfig$dbms %||% "postgresql" - server <- dbConfig$DB_SERVER %||% dbConfig$server - if (is.null(server) || !nzchar(server)) { - stop("Database server must be provided in strategus-db-details.json (DB_SERVER or server).") + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to use createStrategusConnectionDetails().") } - port <- dbConfig$DB_PORT %||% dbConfig$port %||% "5432" - user <- dbConfig$DB_USER %||% dbConfig$user - password <- dbConfig$DB_PASS %||% dbConfig$password - if (is.null(user) || is.null(password)) { - stop("Database credentials must be provided in strategus-db-details.json (DB_USER/DB_PASS or user/password).") - } - pathToDriver <- dbConfig$DB_DRIVER_PATH %||% dbConfig$pathToDriver - extraSettings <- dbConfig$extraSettings %||% "sslmode=disable" - DatabaseConnector::createConnectionDetails( - dbms = dbms, - server = server, - user = user, - password = password, - port = port, - pathToDriver = pathToDriver, - extraSettings = extraSettings - ) + slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = path, dbDetails = dbDetails) } diff --git a/R/OHDSIAssistant/R/execution_settings.R b/R/OHDSIAssistant/R/execution_settings.R index 4dabae8..e279b5c 100644 --- a/R/OHDSIAssistant/R/execution_settings.R +++ b/R/OHDSIAssistant/R/execution_settings.R @@ -1,62 +1,22 @@ -#' Read Strategus execution settings from JSON -#' @param path path to strategus-execution-settings.json +#' Read Strategus execution settings from JSON (compatibility wrapper) +#' @inheritParams slashOhdsiStrategusAssistant::readStrategusExecutionSettings #' @return list of execution settings #' @export readStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json")) { - if (!file.exists(path)) { - stop("Execution settings file not found: ", path) + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to use readStrategusExecutionSettings().") } - jsonlite::read_json(path, simplifyVector = TRUE) + slashOhdsiStrategusAssistant::readStrategusExecutionSettings(path = path) } -#' Create Strategus execution settings from JSON -#' @param path path to strategus-execution-settings.json -#' @param settings optional list of settings (if already loaded) +#' Create Strategus execution settings from JSON (compatibility wrapper) +#' @inheritParams slashOhdsiStrategusAssistant::createStrategusExecutionSettings #' @return list with executionSettings and resolved values #' @export createStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json"), settings = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - cfg <- settings %||% readStrategusExecutionSettings(path) - cdmDatabaseSchema <- cfg$cdmDatabaseSchema - workDatabaseSchema <- cfg$workDatabaseSchema - resultsDatabaseSchema <- cfg$resultsDatabaseSchema - vocabularyDatabaseSchema <- cfg$vocabularyDatabaseSchema - cohortTable <- cfg$cohortTable - workFolder <- cfg$workFolder - resultsFolder <- cfg$resultsFolder - cohortIdFieldName <- cfg$cohortIdFieldName %||% "cohort_definition_id" - maxCores <- cfg$maxCores %||% parallel::detectCores() - maxCores <- suppressWarnings(as.integer(maxCores)[1]) - if (is.na(maxCores) || maxCores < 1L) maxCores <- 1L - - if (!nzchar(cdmDatabaseSchema)) stop("cdmDatabaseSchema must be provided in strategus-execution-settings.json") - if (!nzchar(workDatabaseSchema)) stop("workDatabaseSchema must be provided in strategus-execution-settings.json") - if (!nzchar(resultsDatabaseSchema)) stop("resultsDatabaseSchema must be provided in strategus-execution-settings.json") - if (!nzchar(vocabularyDatabaseSchema)) stop("vocabularyDatabaseSchema must be provided in strategus-execution-settings.json") - if (!nzchar(cohortTable)) stop("cohortTable must be provided in strategus-execution-settings.json") - if (!nzchar(workFolder)) stop("workFolder must be provided in strategus-execution-settings.json") - if (!nzchar(resultsFolder)) stop("resultsFolder must be provided in strategus-execution-settings.json") - - executionSettings <- createCdmExecutionSettings( - cdmDatabaseSchema = cdmDatabaseSchema, - workDatabaseSchema = workDatabaseSchema, - cohortTableNames = CohortGenerator::getCohortTableNames(cohortTable = cohortTable), - workFolder = workFolder, - resultsFolder = resultsFolder, - maxCores = maxCores - ) - - list( - executionSettings = executionSettings, - cdmDatabaseSchema = cdmDatabaseSchema, - workDatabaseSchema = workDatabaseSchema, - resultsDatabaseSchema = resultsDatabaseSchema, - vocabularyDatabaseSchema = vocabularyDatabaseSchema, - cohortTable = cohortTable, - workFolder = workFolder, - resultsFolder = resultsFolder, - maxCores = maxCores, - cohortIdFieldName = cohortIdFieldName - ) + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to use createStrategusExecutionSettings().") + } + slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = path, settings = settings) } diff --git a/R/OHDSIAssistant/R/lintStudyDesign.R b/R/OHDSIAssistant/R/lintStudyDesign.R index c4315ce..851197f 100644 --- a/R/OHDSIAssistant/R/lintStudyDesign.R +++ b/R/OHDSIAssistant/R/lintStudyDesign.R @@ -1,92 +1,26 @@ -#' Lint study design (prototype) -#' @param studyProtocol path or URL to protocol.md -#' @param studyPackage path to local study folder ('.' ok) -#' @param lintTasks character vector of tasks -#' @param apply logical; not used in prototype (advisory only) -#' @param interactive logical; print plans and findings -#' @param streamThoughts logical; placeholder -#' @return list of results by task -lintStudyDesign <- function( - studyProtocol, - studyPackage = ".", - lintTasks = c("concept-sets-review","cohort-critique-general-design"), - apply = FALSE, - interactive = TRUE, - streamThoughts = TRUE, - handleActions = FALSE, - applyActions = FALSE, - overwriteActions = FALSE, - backupActions = TRUE -) { - conceptSetRef <- file.path(studyPackage, "concept_set.json") - cohortRef <- file.path(studyPackage, "cohort_definition.json") - - results <- list() - use_acp <- !is.null(acp_state$url) - - if ("concept-sets-review" %in% lintTasks) { - res <- if (use_acp) { -.acp_post("/flows/concept_sets_review", list( - concept_set_path = conceptSetRef, - study_intent = paste(readLines(studyProtocol, warn = FALSE), collapse=" ") - )) - } else { - local_concept_sets_review(conceptSetRef, studyIntent = paste(readLines(studyProtocol, warn = FALSE), collapse=" ")) - } - res$artifact <- conceptSetRef - core <- res$full_result %||% res - # optional actions handling - if (handleActions && use_acp && length(core$actions %||% list())) { - prev <- applyLLMActionsConceptSet(conceptSetRef, core$actions, preview = TRUE) - res$action_preview <- prev - if (applyActions) { - res$action_apply <- applyLLMActionsConceptSet( - conceptSetRef, - core$actions, - preview = FALSE, - overwrite = overwriteActions, - backup = backupActions - ) - } - } - if (interactive) { - cat("\n== Concept Sets Review ==\n") - cat(sprintf("File: %s\n", conceptSetRef)) - cat(core$plan %||% "", "\n") - print_findings(core$findings) - if (handleActions && !is.null(res$action_preview)) { - cat(sprintf("Action preview: %s changes, %s ignored\n", - res$action_preview$counts$changed %||% 0, - res$action_preview$counts$ignored %||% 0)) - } - if (applyActions && !is.null(res$action_apply) && isTRUE(res$action_apply$applied)) { - cat(sprintf("Actions applied. Written to: %s\n", res$action_apply$written_to %||% conceptSetRef)) - } - } - results$`concept-sets-review` <- res - } - - if ("cohort-critique-general-design" %in% lintTasks) { - res <- if (use_acp) { -.acp_post("/flows/cohort_critique_general_design", list(cohort_path = cohortRef)) - } else { - local_cohort_critique_general(cohortRef) - } - res$artifact <- cohortRef - core <- res$full_result %||% res - if (interactive) { - cat("\n== Cohort Critique: General Design ==\n") - cat(sprintf("File: %s\n", cohortRef)) - cat(core$plan %||% "", "\n") - print_findings(core$findings) - } - results$`cohort-critique-general-design` <- res - } - - outdir <- file.path(studyPackage, "inst", "assistant") - if (!dir.exists(outdir)) dir.create(outdir, recursive = TRUE) - ts <- format(Sys.time(), "%Y%m%dT%H%M%S") - jsonlite::write_json(results, file.path(outdir, paste0("advice_", ts, ".json")), auto_unbox = TRUE, pretty = TRUE) - - invisible(results) +#' Compatibility wrapper for study design linting +#' @inheritParams slashOhdsiAcpClient::lintStudyDesign +#' @export +lintStudyDesign <- function(studyProtocol, + studyPackage = ".", + lintTasks = c("concept-sets-review", "cohort-critique-general-design"), + apply = FALSE, + interactive = TRUE, + streamThoughts = TRUE, + handleActions = FALSE, + applyActions = FALSE, + overwriteActions = FALSE, + backupActions = TRUE) { + slashOhdsiAcpClient::lintStudyDesign( + studyProtocol = studyProtocol, + studyPackage = studyPackage, + lintTasks = lintTasks, + apply = apply, + interactive = interactive, + streamThoughts = streamThoughts, + handleActions = handleActions, + applyActions = applyActions, + overwriteActions = overwriteActions, + backupActions = backupActions + ) } diff --git a/R/OHDSIAssistant/R/ops_llm_actions.R b/R/OHDSIAssistant/R/ops_llm_actions.R index 15e5d34..4c9a26e 100644 --- a/R/OHDSIAssistant/R/ops_llm_actions.R +++ b/R/OHDSIAssistant/R/ops_llm_actions.R @@ -1,18 +1,16 @@ -#' Apply LLM-proposed actions to a concept set via ACP -#' @param conceptSetRef path to local concept set JSON -#' @param actions list of action objects (from tool response $actions) -#' @param preview logical; TRUE = dry run -#' @param overwrite logical; if FALSE, writes to -assistant-v*.json -#' @param backup logical; if TRUE and overwrite=TRUE, create timestamped .bak -#' @return list server response -applyLLMActionsConceptSet <- function(conceptSetRef, actions, preview = TRUE, overwrite = FALSE, backup = TRUE) { - if (is.null(acp_state$url)) stop("ACP not connected; call acp_connect() first.") - body <- list( - artifactRef = conceptSetRef, - actions = actions %||% list(), - write = !isTRUE(preview), - overwrite = isTRUE(overwrite), - backup = isTRUE(backup) +#' Compatibility wrapper for ACP concept-set LLM actions +#' @inheritParams slashOhdsiAcpClient::applyLLMActionsConceptSet +#' @export +applyLLMActionsConceptSet <- function(conceptSetRef, + actions, + preview = TRUE, + overwrite = FALSE, + backup = TRUE) { + slashOhdsiAcpClient::applyLLMActionsConceptSet( + conceptSetRef = conceptSetRef, + actions = actions, + preview = preview, + overwrite = overwrite, + backup = backup ) - .acp_post("/actions/execute_llm", body) } diff --git a/R/OHDSIAssistant/R/phenotype_workflow.R b/R/OHDSIAssistant/R/phenotype_workflow.R index be083e7..a3ba6fb 100644 --- a/R/OHDSIAssistant/R/phenotype_workflow.R +++ b/R/OHDSIAssistant/R/phenotype_workflow.R @@ -1,142 +1,47 @@ -#' Suggest phenotypes for a study protocol (ACP flow) -#' @param protocolPath optional path to protocol markdown/text -#' @param studyIntent optional study intent string (overrides protocolPath) -#' @param topK number of candidates to retrieve from MCP -#' @param maxResults max phenotypes to return -#' @param candidateLimit max candidates to pass to the LLM (optional) -#' @param interactive print plan and recommendations +#' Suggest phenotypes for a study protocol (compatibility wrapper) +#' @inheritParams slashOhdsiAcpClient::suggestPhenotypes #' @return list response from ACP flow or local stub +#' @export suggestPhenotypes <- function(protocolPath = NULL, studyIntent = NULL, topK = 20, maxResults = 3, candidateLimit = 10, interactive = TRUE) { - if (!is.null(protocolPath)) { - protocolPath <- normalizePath(protocolPath, winslash = "/", mustWork = FALSE) - } - if (is.null(studyIntent) && !is.null(protocolPath)) { - studyIntent <- paste(readLines(protocolPath, warn = FALSE), collapse = "\n") - } - if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { - if (interactive) { - studyIntent <- utils::edit("Enter study intent text below and save/close to continue.") - } - } - if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { - stop("Provide studyIntent or protocolPath (with content) to suggestPhenotypes().") - } - - body <- list( - study_intent = studyIntent, - top_k = topK, - max_results = maxResults + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use suggestPhenotypes().") + } + slashOhdsiAcpClient::suggestPhenotypes( + protocolPath = protocolPath, + studyIntent = studyIntent, + topK = topK, + maxResults = maxResults, + candidateLimit = candidateLimit, + interactive = interactive ) - if (!is.null(candidateLimit)) { - body$candidate_limit <- candidateLimit - } - - res <- if (!is.null(acp_state$url)) { - .acp_post("/flows/phenotype_recommendation", body) - } else { - local_phenotype_recommendations(studyIntent, maxResults) - } - - res$artifact <- list(protocolRef = protocolPath) - core <- res$recommendations %||% res - if (interactive) { - cat(" -== Phenotype Suggestions == -") - cat(core$plan %||% "", " -") - if (!is.null(core$mode)) cat(sprintf("Mode: %s -", core$mode)) - recs <- core$phenotype_recommendations %||% list() - if (length(recs) == 0) { - cat(" [stub] No recommendations (LLM not connected or no matches). -") - } else { - for (r in recs) { - cat(sprintf(" - %s (%s): %s -", - r$phenotype_name %||% "", - r$phenotype_id %||% "?", - r$justification %||% "")) - } - } - } - res } -#' Pull phenotype definitions to a local folder -#' @param cohortIds character vector of ACP phenotype ids, typically selected from suggestPhenotypes() -#' @param outputDir directory to write JSON definitions -#' @param overwrite logical; if FALSE, auto-version the filename +#' Pull phenotype definitions to a local folder (compatibility wrapper) +#' @inheritParams slashOhdsiAcpClient::pullPhenotypeDefinitions #' @return character vector of written file paths +#' @export pullPhenotypeDefinitions <- function(cohortIds, outputDir = ".", overwrite = FALSE) { - phenotype_ids <- as.character(cohortIds %||% character(0)) - if (length(phenotype_ids) == 0) return(character(0)) - - unsupported <- phenotype_ids[!grepl("^ohdsi:", phenotype_ids)] - if (length(unsupported) > 0) { - stop( - sprintf( - paste0( - "pullPhenotypeDefinitions() currently supports OHDSI phenotype ids only. ", - "Conversion of non-OHDSI phenotypes to computable OHDSI cohort definitions is not implemented yet. ", - "Unsupported ids: %s" - ), - paste(unique(unsupported), collapse = ", ") - ) - ) - } - - index_dir <- Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index") - index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) - index_def_dir <- file.path(index_dir, "definitions") - if (!dir.exists(index_def_dir)) { - stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) - } - - outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) - if (!dir.exists(outputDir)) dir.create(outputDir, recursive = TRUE) - - definition_path <- function(phenotype_id) { - file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", phenotype_id, fixed = TRUE))) + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use pullPhenotypeDefinitions().") } - - written <- character(0) - for (phenotype_id in phenotype_ids) { - src <- definition_path(phenotype_id) - if (!file.exists(src)) { - stop(sprintf("Phenotype JSON not found: %s", src)) - } - - safe <- gsub("[^A-Za-z0-9_-]+", "_", phenotype_id) - target <- file.path(outputDir, sprintf("%s.json", safe)) - if (!overwrite) { - idx <- 1 - while (file.exists(target)) { - target <- file.path(outputDir, sprintf("%s-v%d.json", safe, idx)) - idx <- idx + 1 - } - } - - file.copy(src, target, overwrite = TRUE) - written <- c(written, target) - } - written + slashOhdsiAcpClient::pullPhenotypeDefinitions( + cohortIds = cohortIds, + outputDir = outputDir, + overwrite = overwrite + ) } -#' Review phenotype definitions for improvements (prototype) -#' @param protocolPath path to protocol markdown/text -#' @param cohortJsonPaths character vector of cohort definition JSON paths -#' @param characterizationPaths optional vector of paths to characterization outputs -#' @param interactive logical; print plan and summaries -#' @return list response from ACP or local stub +#' Review phenotype definitions for improvements (compatibility wrapper) +#' @inheritParams slashOhdsiAcpClient::reviewPhenotypes +#' @return list response from ACP flow or local stub +#' @export reviewPhenotypes <- function(protocolPath, cohortJsonPaths, characterizationPaths = NULL, @@ -144,230 +49,56 @@ reviewPhenotypes <- function(protocolPath, apply = FALSE, select = NULL, outputDir = NULL) { - protocolPath <- normalizePath(protocolPath, winslash = "/", mustWork = FALSE) - cohortJsonPaths <- unname(vapply(cohortJsonPaths, normalizePath, character(1), winslash = "/", mustWork = FALSE)) - if (length(cohortJsonPaths) == 0) stop("No cohortJsonPaths provided to reviewPhenotypes().") - if (!is.null(characterizationPaths)) { - characterizationPaths <- unname(vapply(characterizationPaths, normalizePath, character(1), winslash = "/", mustWork = FALSE)) - } - - body <- list( - protocol_path = protocolPath, - cohort_paths = as.list(cohortJsonPaths) + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use reviewPhenotypes().") + } + slashOhdsiAcpClient::reviewPhenotypes( + protocolPath = protocolPath, + cohortJsonPaths = cohortJsonPaths, + characterizationPaths = characterizationPaths, + interactive = interactive, + apply = apply, + select = select, + outputDir = outputDir ) - if (!is.null(characterizationPaths) && length(characterizationPaths) > 0) { - warning("characterizationPaths are not yet forwarded to /flows/phenotype_improvements; ignoring them for now.") - } - - res <- if (!is.null(acp_state$url)) { - .acp_post("/flows/phenotype_improvements", body) - } else { - local_phenotype_improvements() - } - - res$artifact <- list(protocolPath = protocolPath, cohortPaths = cohortJsonPaths) - core <- res$full_result %||% res - if (interactive) { - cat(" -== Phenotype Improvements == -") - cat(core$plan %||% "", " -") - if (!is.null(core$mode)) cat(sprintf("Mode: %s -", core$mode)) - imp <- core$phenotype_improvements %||% list() - if (length(imp) == 0) { - cat(" [stub] No improvements returned (LLM not connected). -") - } else { - for (p in imp) { - cat(sprintf(" - [%s] %s -", - p$targetCohortId %||% "?", - p$summary %||% jsonlite::toJSON(p, auto_unbox = TRUE))) - } - } - } - if (apply) { - picks <- selectPhenotypeImprovements( - improvements = core$phenotype_improvements, - cohortJsonPaths = cohortJsonPaths, - select = select, - apply = TRUE, - outputDir = outputDir, - interactive = interactive - ) - res$selected_improvements <- picks$selected - res$written <- picks$written - if (interactive && length(picks$written)) { - cat(" -Saved improvement notes: -") - cat(paste(sprintf(" - %s", picks$written), collapse = " -"), " -") - } - } - res } -#' Select phenotype recommendations (interactive or programmatic) -#' @param recommendations list from suggestPhenotypes()$phenotype_recommendations -#' @param select either phenotype ids, integer indices, or "all"/NULL to pick all -#' @param interactive if TRUE and select is NULL, prompt user +#' Select phenotype recommendations (compatibility wrapper) +#' @inheritParams slashOhdsiAcpClient::selectPhenotypeRecommendations #' @return character vector of chosen phenotype ids +#' @export selectPhenotypeRecommendations <- function(recommendations, select = NULL, interactive = interactive()) { - recs <- recommendations %||% list() - if (length(recs) == 0) return(character(0)) - - ids <- vapply(recs, function(r) r$phenotype_id %||% NA_character_, character(1)) - - if (is.null(select) || identical(select, "all")) { - if (interactive) { - labels <- vapply(seq_along(recs), function(i) { - sprintf("%s (%s)", recs[[i]]$phenotype_name %||% "", recs[[i]]$phenotype_id %||% "?") - }, character(1)) - picks <- utils::select.list(labels, multiple = TRUE, title = "Select phenotypes to pull") - if (length(picks) == 0) return(character(0)) - idx <- match(picks, labels) - return(as.character(ids[idx])) - } - return(as.character(ids)) - } - - # explicit selection provided - if (is.numeric(select)) { - # if they look like indices (<= length), map to ids; else assume ids already supplied - if (all(select %% 1 == 0) && all(select >= 1) && all(select <= length(ids))) { - return(as.character(ids[select])) - } - return(as.character(select)) - } - - if (is.character(select)) { - if (all(select %in% ids)) { - return(as.character(select)) - } - idx <- suppressWarnings(as.integer(select)) - if (!anyNA(idx) && all(idx >= 1) && all(idx <= length(ids))) { - return(as.character(ids[idx])) - } + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use selectPhenotypeRecommendations().") } - - character(0) + slashOhdsiAcpClient::selectPhenotypeRecommendations( + recommendations = recommendations, + select = select, + interactive = interactive + ) } - -#' Select phenotype improvements and optionally persist notes -#' @param improvements list from reviewPhenotypes()$phenotype_improvements -#' @param cohortJsonPaths character vector of cohort JSON paths -#' @param select optional vector of phenotype ids, indices, or "all"/NULL to pick all -#' @param apply logical; if TRUE, write selected improvements to disk -#' @param outputDir directory for notes; defaults to directory of first cohortJsonPath -#' @param interactive prompt user selection when select is NULL -#' @return list with `selected` improvements and `written` file paths (if any) +#' Select phenotype improvements (compatibility wrapper) +#' @inheritParams slashOhdsiAcpClient::selectPhenotypeImprovements +#' @return list with selected improvements and written file paths +#' @export selectPhenotypeImprovements <- function(improvements, cohortJsonPaths, select = NULL, apply = FALSE, outputDir = NULL, interactive = interactive()) { - imps <- improvements %||% list() - if (length(imps) == 0) return(list(selected = list(), written = character(0))) - - ids <- vapply(imps, function(x) x$targetCohortId %||% NA_real_, numeric(1)) - cohortJsonPaths <- cohortJsonPaths %||% character(0) - cohortPathIds <- vapply(cohortJsonPaths, .extractCohortIdFromPath, integer(1), USE.NAMES = FALSE) - - # selection logic - idx <- integer(0) - if (is.null(select) || identical(select, "all")) { - if (interactive) { - labels <- vapply(seq_along(imps), function(i) { - cid <- ids[[i]] %||% NA_real_ - path_hint <- cohortJsonPaths[match(cid, cohortPathIds, nomatch = 0)] %||% "" - sprintf("Cohort %s: %s%s", - cid %||% "?", - imps[[i]]$summary %||% "", - ifelse(path_hint != "", sprintf(" [%s]", basename(path_hint)), "")) - }, character(1)) - picks <- utils::select.list(labels, multiple = TRUE, title = "Select phenotype improvements to keep") - if (length(picks) == 0) return(list(selected = list(), written = character(0))) - idx <- match(picks, labels) - } else { - idx <- seq_along(imps) - } - } else if (is.numeric(select)) { - if (all(select %% 1 == 0) && all(select >= 1) && all(select <= length(imps))) { - idx <- as.integer(select) - } else { - idx <- which(ids %in% as.integer(select)) - } - } - - if (length(idx) == 0) return(list(selected = list(), written = character(0))) - picked <- imps[idx] - written <- character(0) - - if (apply && length(picked)) { - if (is.null(outputDir)) { - outputDir <- dirname(cohortJsonPaths[[1]] %||% ".") - } - if (!dir.exists(outputDir)) dir.create(outputDir, recursive = TRUE, showWarnings = FALSE) - written <- .writePhenotypeImprovementNotes(picked, cohortJsonPaths, cohortPathIds, outputDir) - } - - list(selected = picked, written = written) -} - - -.extractCohortIdFromPath <- function(path) { - base <- basename(path %||% "") - m <- regexpr("[0-9]+", base) - if (m[1] > 0) { - val <- substr(base, m[1], m[1] + attr(m, "match.length") - 1) - return(suppressWarnings(as.integer(val))) - } - NA_integer_ -} - - -.writePhenotypeImprovementNotes <- function(improvements, cohortJsonPaths, cohortPathIds, outputDir) { - written <- character(0) - if (length(improvements) == 0) return(written) - ids <- vapply(improvements, function(x) x$targetCohortId %||% NA_integer_, integer(1)) - for (cid in unique(ids)) { - if (is.na(cid)) next - idx_imp <- which(ids == cid) - if (length(idx_imp) == 0) next - path_idx <- match(cid, cohortPathIds, nomatch = 0) - fname_base <- if (path_idx > 0) tools::file_path_sans_ext(basename(cohortJsonPaths[[path_idx]])) else paste0("cohort_", cid) - target <- file.path(outputDir, sprintf("%s_improvements.json", fname_base)) - jsonlite::write_json(improvements[idx_imp], path = target, auto_unbox = TRUE, pretty = TRUE) - written <- c(written, target) - } - written -} - - -local_phenotype_recommendations <- function(studyIntent, - maxResults = 10) { - recs <- list() - list( - plan = "Stub: deterministic phenotype suggestions (LLM not connected).", - phenotype_recommendations = recs, - mode = "stub" - ) -} - - -local_phenotype_improvements <- function() { - list( - plan = "Stub: no phenotype improvements available without LLM.", - phenotype_improvements = list(), - code_suggestion = NULL, - mode = "stub" + if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { + stop("slashOhdsiAcpClient must be installed or loaded to use selectPhenotypeImprovements().") + } + slashOhdsiAcpClient::selectPhenotypeImprovements( + improvements = improvements, + cohortJsonPaths = cohortJsonPaths, + select = select, + apply = apply, + outputDir = outputDir, + interactive = interactive ) } diff --git a/R/OHDSIAssistant/R/rules_local_fallback.R b/R/OHDSIAssistant/R/rules_local_fallback.R index 2afc884..a91fd30 100644 --- a/R/OHDSIAssistant/R/rules_local_fallback.R +++ b/R/OHDSIAssistant/R/rules_local_fallback.R @@ -1,63 +1 @@ -local_concept_sets_review <- function(conceptSetRef, studyIntent="") { - cs <- read_json_ref(conceptSetRef) - items <- if (!is.null(cs$items)) cs$items else cs - - get_item <- function(it) { - c <- it$concept %||% it - list(conceptId = c$conceptId %||% c$CONCEPT_ID %||% c$id %||% NA_integer_, - domainId = c$domainId %||% c$DOMAIN_ID %||% NA_character_) - } - lst <- lapply(items, get_item) - - plan <- sprintf("Local concept set review for %s", conceptSetRef) - findings <- list(); patches <- list(); risk_notes <- list() - - ids <- vapply(lst, function(x) x$conceptId, integer(1)) - ids <- ids[!is.na(ids)] - if (length(lst) == 0) { - findings <- c(findings, list(list(id="empty_concept_set", severity="high", impact="design", message="Concept set is empty."))) - } - if (length(ids)) { - dups <- ids[duplicated(ids)] - if (length(dups)) { - findings <- c(findings, list(list(id="duplicate_concepts", severity="medium", impact="design", - message=paste("Duplicate conceptIds:", paste(unique(dups), collapse=", "))))) - patches <- c(patches, list(list(artifact=conceptSetRef, type="jsonpatch", - ops=list(list(op="note", path="/items", value=list(removeDuplicatesOf=unique(dups))))))) - } - } - domains <- unique(vapply(lst, function(x) x$domainId %||% NA_character_, character(1))) - domains <- domains[!is.na(domains)] - if (length(domains) > 1) { - findings <- c(findings, list(list(id="mixed_domains", severity="low", impact="portability", - message=paste("Multiple domains:", paste(domains, collapse=", "))))) - } - - list(plan=plan, findings=findings, patches=patches, risk_notes=risk_notes) -} - -local_cohort_critique_general <- function(cohortRef) { - cdef <- read_json_ref(cohortRef) - plan <- sprintf("Local general cohort design lint for %s", cohortRef) - findings <- list(); patches <- list(); risk_notes <- list() - - pc <- cdef$PrimaryCriteria %||% list() - wash <- pc$ObservationWindow %||% list() - if (is.null(wash$PriorDays) || identical(wash$PriorDays, 0L)) { - findings <- c(findings, list(list(id="missing_washout", severity="medium", impact="validity", - message="No or zero-day washout; consider >=365 days."))) - patches <- c(patches, list(list(artifact=cohortRef, type="jsonpatch", - ops=list(list(op="note", path="/PrimaryCriteria/ObservationWindow", - value=list(ProposedPriorDays=365)))))) - } - - irules <- cdef$InclusionRules %||% list() - for (i in seq_along(irules)) { - w <- irules[[i]]$window %||% NULL - if (!is.null(w) && !is.null(w$start) && !is.null(w$end) && w$start > w$end) { - findings <- c(findings, list(list(id=paste0("inverted_window_", i), severity="high", impact="validity", - message=sprintf("InclusionRules[%d] has inverted window.", i)))) - } - } - list(plan=plan, findings=findings, patches=patches, risk_notes=risk_notes) -} +# Compatibility note: local fallback review logic moved to slashOhdsiAcpClient. diff --git a/R/OHDSIAssistant/R/slash_ohdsi_bridge.R b/R/OHDSIAssistant/R/slash_ohdsi_bridge.R deleted file mode 100644 index a2988f5..0000000 --- a/R/OHDSIAssistant/R/slash_ohdsi_bridge.R +++ /dev/null @@ -1,130 +0,0 @@ -.studyAgentSlashBridgeState <- local({ - env <- new.env(parent = emptyenv()) - env$acp_env <- NULL - env$strategus_env <- NULL - env -}) - -.studyAgentRepoRoot <- function() { - candidates <- unique(c( - Sys.getenv("STUDY_AGENT_BASE_DIR", unset = ""), - getwd(), - file.path(getwd(), "OHDSI-Study-Agent"), - normalizePath(file.path(getwd(), "..", ".."), winslash = "/", mustWork = FALSE) - )) - candidates <- candidates[nzchar(candidates)] - for (candidate in candidates) { - if (dir.exists(file.path(candidate, "R", "slashOhdsiAcpClient")) && - dir.exists(file.path(candidate, "R", "slashOhdsiStrategusAssistant"))) { - return(normalizePath(candidate, winslash = "/", mustWork = FALSE)) - } - } - stop("Could not locate repo root for slash-ohdsi bridge helpers.") -} - -.studyAgentSourcePackageDir <- function(package_dir) { - env <- new.env(parent = baseenv()) - r_dir <- file.path(package_dir, "R") - files <- list.files(r_dir, pattern = "\\.[Rr]$", full.names = TRUE) - for (path in sort(files)) { - sys.source(path, envir = env) - } - env -} - -.studyAgentSlashAcpEnv <- function() { - if (!is.null(.studyAgentSlashBridgeState$acp_env)) { - return(.studyAgentSlashBridgeState$acp_env) - } - repo_root <- .studyAgentRepoRoot() - env <- .studyAgentSourcePackageDir(file.path(repo_root, "R", "slashOhdsiAcpClient")) - .studyAgentSlashBridgeState$acp_env <- env - env -} - -.studyAgentSlashStrategusEnv <- function() { - if (!is.null(.studyAgentSlashBridgeState$strategus_env)) { - return(.studyAgentSlashBridgeState$strategus_env) - } - repo_root <- .studyAgentRepoRoot() - env <- .studyAgentSourcePackageDir(file.path(repo_root, "R", "slashOhdsiStrategusAssistant")) - .studyAgentSlashBridgeState$strategus_env <- env - env -} - -.studyAgentSlashCreateAcpClient <- function(url = "http://127.0.0.1:8765", token = NULL, check = TRUE) { - env <- .studyAgentSlashAcpEnv() - env$acp_client(url = url, token = token, check = check) -} - -.studyAgentSlashAcpIsConnected <- function(client) { - env <- .studyAgentSlashAcpEnv() - isTRUE(env$acp_is_connected(client)) -} - -.studyAgentSlashCallAcpFlow <- function(client, flow_name, body = list()) { - env <- .studyAgentSlashAcpEnv() - env$acp_call_flow(client = client, flow_name = flow_name, body = body) -} - -.studyAgentSlashNewWorkflowStageContext <- function(...) { - env <- .studyAgentSlashStrategusEnv() - env$new_workflow_stage_context(...) -} - -.studyAgentSlashCompactWorkflowDialogueContext <- function(value) { - env <- .studyAgentSlashStrategusEnv() - env$compact_workflow_dialogue_context(value) -} - -.studyAgentSlashNewWorkflowDialogueSession <- function(...) { - env <- .studyAgentSlashStrategusEnv() - env$new_workflow_dialogue_session(...) -} - -.studyAgentSlashNormalizeIncidenceDialogueStep <- function(step) { - env <- .studyAgentSlashStrategusEnv() - env$normalize_incidence_dialogue_step(step) -} - -.studyAgentSlashIncidenceDialogueStepLabel <- function(step, role = "") { - env <- .studyAgentSlashStrategusEnv() - env$incidence_dialogue_step_label(step = step, role = role) -} - -.studyAgentSlashBuildIncidenceWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { - env <- .studyAgentSlashStrategusEnv() - env$build_incidence_workflow_stage_context( - study_intent = study_intent, - dialogue_state = dialogue_state, - interactive = interactive - ) -} - -.studyAgentSlashNormalizeCohortMethodsDialogueStep <- function(step) { - env <- .studyAgentSlashStrategusEnv() - env$normalize_cohort_methods_dialogue_step(step) -} - -.studyAgentSlashCohortMethodsDialogueStepLabel <- function(step, role = "") { - env <- .studyAgentSlashStrategusEnv() - env$cohort_methods_dialogue_step_label(step = step, role = role) -} - -.studyAgentSlashBuildCohortMethodsWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { - env <- .studyAgentSlashStrategusEnv() - env$build_cohort_methods_workflow_stage_context( - study_intent = study_intent, - dialogue_state = dialogue_state, - interactive = interactive - ) -} - -.studyAgentSlashWorkflowContextDialogue <- function(client, stage_context, message) { - env <- .studyAgentSlashAcpEnv() - env$acp_workflow_context_dialogue( - client = client, - stage_context = stage_context, - message = message - ) -} diff --git a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R index 01e60b2..0faf76b 100644 --- a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R +++ b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R @@ -1,6230 +1,10 @@ -#' Interactive shell to generate Strategus CohortMethod scripts -#' @param outputDir directory where scripts and artifacts will be written -#' @param acpUrl ACP base URL for cohort-method recommendation calls -#' @param studyIntent study intent text -#' @param targetStatement optional explicit target cohort statement used for phenotype recommendation -#' @param comparatorStatement optional explicit comparator cohort statement used for phenotype recommendation -#' @param outcomeStatement optional explicit outcome cohort statement used for phenotype recommendation -#' @param targetCohortId target cohort definition ID -#' @param comparatorCohortId comparator cohort definition ID -#' @param outcomeCohortIds outcome cohort definition IDs -#' @param comparisonLabel optional label for the target-comparator comparison -#' @param topK number of candidates retrieved from MCP search -#' @param maxResults max phenotypes to show -#' @param candidateLimit max candidates to pass to LLM -#' @param indexDir phenotype index directory (contains definitions/ and catalog.jsonl) -#' @param negativeControlConceptSetId optional negative control concept set ID -#' @param includeCovariateConceptSetId optional covariate include concept set ID -#' @param excludeCovariateConceptSetId optional covariate exclude concept set ID -#' @param analyticSettingsDescription optional free-text analytic settings description -#' @param analyticSettingsDescriptionPath optional path to a text file containing the free-text analytic settings description -#' @param incidenceOutputDir optional Strategus CohortIncidence output directory used for cached target/outcome cohort reuse -#' @param interactive whether to prompt for missing inputs -#' @param bannerPath optional path to ASCII banner -#' @param studyAgentBaseDir base directory to resolve relative paths -#' @param reset when TRUE, delete outputDir before running -#' @param allowCache reuse cached flow artifacts when present -#' @param promptOnCache prompt before using cached flow artifacts -#' @param autoApplyImprovements when TRUE, apply improvements without prompting (defaults to TRUE for non-interactive) -#' @param resume when TRUE, prefer cached manual inputs when present -#' @param remapCohortIds when TRUE, assign new local cohort IDs -#' @param cohortIdBase optional starting cohort ID when remapping +#' Compatibility wrapper for the Strategus cohort-methods shell +#' @param ... forwarded to slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell #' @return invisible list with output paths #' @export -.studyAgentAnalyticSettingsSectionPaths <- function() { - list( - study_population = c( - "get_db_cohort_method_data.studyStartDate", - "get_db_cohort_method_data.studyEndDate", - "get_db_cohort_method_data.restrictToCommonPeriod", - "get_db_cohort_method_data.firstExposureOnly", - "get_db_cohort_method_data.washoutPeriod", - "create_study_population.removeDuplicateSubjects", - "create_study_population.censorAtNewRiskWindow", - "create_study_population.removeSubjectsWithPriorOutcome", - "create_study_population.priorOutcomeLookback" - , - "create_study_population.maxCohortSize" - ), - time_at_risk = c( - "create_study_population.minDaysAtRisk", - "create_study_population.riskWindowStart", - "create_study_population.startAnchor", - "create_study_population.riskWindowEnd", - "create_study_population.endAnchor" - ), - propensity_score_adjustment = c( - "ps_adjustment.strategy", - "ps_adjustment.trimmingStrategy", - "ps_adjustment.trimmingPercent", - "ps_adjustment.equipoiseLowerBound", - "ps_adjustment.equipoiseUpperBound", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization", - "match_on_ps.caliper", - "match_on_ps.caliperScale", - "match_on_ps.maxRatio", - "stratify_by_ps.numberOfStrata", - "stratify_by_ps.baseSelection" - ), - outcome_model = c( - "fit_outcome_model.modelType", - "fit_outcome_model.stratified", - "fit_outcome_model.useCovariates", - "fit_outcome_model.inversePtWeighting", - "fit_outcome_model.useRegularization" - ) - ) -} - -.studyAgentAnalyticSettingsSectionTitles <- function() { - c( - study_population = "Study Population", - time_at_risk = "Time At Risk", - propensity_score_adjustment = "Propensity Score Adjustment", - outcome_model = "Outcome Model" - ) -} - -.studyAgentAnalyticSettingDocs <- function() { - list( - "get_db_cohort_method_data.studyStartDate" = list( - label = "Study start date", - summary_label = "Study start date" - ), - "get_db_cohort_method_data.studyEndDate" = list( - label = "Study end date", - summary_label = "Study end date" - ), - "get_db_cohort_method_data.firstExposureOnly" = list( - label = "First exposure only", - summary_label = "First exposure only", - description = "Should only the first exposure per subject be included?" - ), - "get_db_cohort_method_data.washoutPeriod" = list( - label = "Washout period", - summary_label = "Washout period", - description = "The minimum required continuous observation time (in days) prior to index date for a person to be included in the cohort." - ), - "get_db_cohort_method_data.restrictToCommonPeriod" = list( - label = "Restrict to common period", - summary_label = "Restrict to common period", - description = "Restrict the study to the period when both exposures are present in the data? (E.g. when both drugs are on the market)" - ), - "get_db_cohort_method_data.removeDuplicateSubjects" = list( - label = "Duplicate subjects during extraction", - summary_label = "Duplicate subjects during extraction", - description = "Controls how people who appear in both target and comparator cohorts are handled while extracting data." - ), - "create_study_population.removeDuplicateSubjects" = list( - label = "Remove duplicate subjects", - summary_label = "Remove duplicate subjects", - description = "Remove subjects that are in both the target and comparator cohort?" - ), - "create_study_population.maxCohortSize" = list( - label = "Maximum cohort size", - summary_label = "Maximum cohort size", - description = "If either the target or the comparator cohort is larger than this number it will be sampled to this size. (0 for this value indicates no maximum size)" - ), - "create_study_population.removeSubjectsWithPriorOutcome" = list( - label = "Remove prior outcomes", - summary_label = "Remove prior outcomes", - description = "Remove subjects that have the outcome prior to the risk window start?" - ), - "create_study_population.priorOutcomeLookback" = list( - label = "Prior outcome lookback", - summary_label = "Prior outcome lookback", - description = "How many days should we look back when identifying prior outcomes?" - ), - "create_study_population.riskWindowStart" = list( - label = "Risk window start", - summary_label = "Risk window start" - ), - "create_study_population.minDaysAtRisk" = list( - label = "Minimum days at risk", - summary_label = "Minimum days at risk", - description = "The minimum number of days at risk?" - ), - "create_study_population.startAnchor" = list( - label = "Risk window start anchor", - summary_label = "Risk window start anchor" - ), - "create_study_population.riskWindowEnd" = list( - label = "Risk window end", - summary_label = "Risk window end" - ), - "create_study_population.endAnchor" = list( - label = "Risk window end anchor", - summary_label = "Risk window end anchor" - ), - "create_study_population.censorAtNewRiskWindow" = list( - label = "Censor at new risk window", - summary_label = "Censor at new risk window", - description = "If a subject is in multiple cohorts, should time-at-risk be censored when the new time-at-risk start to prevent overlap?" - ), - "ps_adjustment.strategy" = list( - label = "PS adjustment strategy", - summary_label = "PS adjustment strategy" - ), - "ps_adjustment.trimmingStrategy" = list( - label = "PS trimming", - summary_label = "PS trimming", - description = "How do you want to trim your cohorts based on the propensity score distribution?" - ), - "ps_adjustment.trimmingPercent" = list( - label = "Trimming percent", - summary_label = "Trimming percent", - description = "What percentage of each tail should be removed?" - ), - "ps_adjustment.equipoiseLowerBound" = list( - label = "Equipoise lower bound", - summary_label = "Equipoise lower bound", - description = "What is the lower preference score bound for trimming to equipoise?" - ), - "ps_adjustment.equipoiseUpperBound" = list( - label = "Equipoise upper bound", - summary_label = "Equipoise upper bound", - description = "What is the upper preference score bound for trimming to equipoise?" - ), - "create_ps.estimator" = list( - label = "PS estimator", - summary_label = "PS estimator", - description = "Defines the treatment effect target used when propensity scores are converted into adjustment weights or summaries." - ), - "create_ps.maxCohortSizeForFitting" = list( - label = "Max cohort size for PS fitting", - summary_label = "Max cohort size for PS fitting", - description = "What is the maximum number of people to include in the propensity score model when fitting? Setting this number to 0 means no down-sampling will be applied:" - ), - "create_ps.errorOnHighCorrelation" = list( - label = "Test covariate correlation", - summary_label = "Test covariate correlation", - description = "Test each covariate for correlation with the target assignment? If any covariate has an unusually high correlation (either positive or negative), this will throw an error." - ), - "create_ps.useRegularization" = list( - label = "Use regularization", - summary_label = "Use regularization", - description = "Use regularization when fitting the propensity model?" - ), - "match_on_ps.caliper" = list( - label = "Matching caliper", - summary_label = "Matching caliper", - description = "What is the caliper for matching:" - ), - "match_on_ps.caliperScale" = list( - label = "Caliper scale", - summary_label = "Caliper scale", - description = "What is the caliper scale:" - ), - "match_on_ps.maxRatio" = list( - label = "Maximum match ratio", - summary_label = "Maximum match ratio", - description = "What is the maximum number of persons in the comparator arm to be matched to each person in the target arm within the defined caliper? (0 = means no maximum - all comparators will be assigned to a target person):" - ), - "stratify_by_ps.numberOfStrata" = list( - label = "Number of strata", - summary_label = "Number of strata", - description = "Into how many strata should the propensity score be divided? The boundaries of the strata are automatically defined to contain equal numbers of target persons:" - ), - "stratify_by_ps.baseSelection" = list( - label = "Base selection for strata bounds", - summary_label = "Base selection for strata bounds", - description = "What is the base selection of subjects where the strata bounds are to be determined? Strata are defined as equally-sized strata inside this selection." - ), - "fit_outcome_model.modelType" = list( - label = "Outcome model", - summary_label = "Outcome model" - ), - "fit_outcome_model.stratified" = list( - label = "Condition on strata", - summary_label = "Condition on strata", - description = "Should the regression be conditioned on the strata defined in the population object (e.g. by matching or stratifying on propensity scores)?" - ), - "fit_outcome_model.useCovariates" = list( - label = "Use covariates in outcome model", - summary_label = "Use covariates in outcome model", - description = "Should the covariates also be included in the outcome model?" - ), - "fit_outcome_model.inversePtWeighting" = list( - label = "Use IPTW", - summary_label = "Use IPTW", - description = "Use inverse probability of treatment weighting?" - ), - "fit_outcome_model.useRegularization" = list( - label = "Use regularization", - summary_label = "Use regularization", - description = "Use regularization when fitting the outcome model?" - ) - ) -} - -.studyAgentSummaryLabel <- function(path) { - docs <- .studyAgentAnalyticSettingDocs() - doc <- docs[[path]] - if (is.null(doc)) return(path) - if (!is.null(doc$summary_label)) return(as.character(doc$summary_label)) - if (!is.null(doc$label)) return(as.character(doc$label)) - path -} - -.studyAgentFormatDateForPrompt <- function(value) { - if (is.null(value) || length(value) == 0 || is.na(value)) return("") - value <- trimws(as.character(value[[1]])) - if (!nzchar(value)) return("") - value -} - -.studyAgentFormatAnalyticSettingValue <- function(value, path = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - if (is.null(value) || length(value) == 0 || is.na(value)) return("") - if (is.character(value) && length(value) == 1 && !nzchar(trimws(value))) return("") - if (!is.null(path) && path %in% c("get_db_cohort_method_data.studyStartDate", "get_db_cohort_method_data.studyEndDate")) { - return(.studyAgentFormatDateForPrompt(value)) - } - if (is.logical(value) && length(value) == 1) return(if (isTRUE(value)) "Yes" else "No") - if (is.character(value) && length(value) == 1) { - mapped <- switch( - path %||% "", - "create_study_population.startAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], - "create_study_population.endAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], - "ps_adjustment.strategy" = c("match_on_ps" = "Match on propensity score", "stratify_by_ps" = "Stratify on propensity score", "none" = "None")[[value]], - "ps_adjustment.trimmingStrategy" = c("none" = "None", "by_percent" = "By percent", "by_equipoise" = "By equipoise")[[value]], - "match_on_ps.caliperScale" = c("propensity score" = "Propensity score", "standardized" = "Standardized", "standardized logit" = "Standardized logit")[[value]], - "fit_outcome_model.modelType" = c("cox" = "Cox proportional hazards", "poisson" = "Poisson regression", "logistic" = "Logistic regression")[[value]], - "create_study_population.removeDuplicateSubjects" = c("keep all" = "Keep All", "keep first" = "Keep First", "remove all" = "Remove All")[[value]], - "get_db_cohort_method_data.removeDuplicateSubjects" = c("keep all" = "Keep All", "keep first" = "Keep First", "remove all" = "Remove All", "keep first, truncate to second" = "Keep First, Truncate to Second")[[value]], - "stratify_by_ps.baseSelection" = c("all" = "Entire study population", "target" = "Target", "comparator" = "Comparator")[[value]], - NULL - ) - if (!is.null(mapped) && length(mapped) == 1 && !is.na(mapped)) return(mapped) - } - if (!is.null(path) && identical(path, "ps_adjustment.trimmingPercent") && is.numeric(value) && length(value) == 1) { - formatted <- formatC(as.numeric(value), format = "fg", digits = 6) - return(sprintf("%s%%", formatted)) - } - if (is.numeric(value) && length(value) == 1) return(as.character(value)) - paste(as.character(value), collapse = ", ") -} - -.studyAgentOutcomeModelDefaults <- function(ps_strategy = "match_on_ps", - match_max_ratio = 1L, - model_type = "cox") { - normalized_strategy <- as.character(if (is.null(ps_strategy)) "match_on_ps" else ps_strategy) - normalized_ratio <- suppressWarnings(as.integer(if (is.null(match_max_ratio)) 1L else match_max_ratio)) - if (length(normalized_ratio) == 0 || is.na(normalized_ratio)) normalized_ratio <- 1L - - stratified_default <- FALSE - if (identical(normalized_strategy, "stratify_by_ps")) { - stratified_default <- TRUE - } else if (identical(normalized_strategy, "match_on_ps") && normalized_ratio != 1L) { - stratified_default <- TRUE - } - - list( - modelType = as.character(if (is.null(model_type)) "cox" else model_type), - stratified = isTRUE(stratified_default), - useCovariates = FALSE, - inversePtWeighting = FALSE, - useRegularization = TRUE - ) -} - -.studyAgentPrintDefaultSummary <- function(header, defaults, paths) { - docs <- .studyAgentAnalyticSettingDocs() - cat(sprintf("%s\n", header)) - for (path in paths) { - doc <- docs[[path]] - if (is.null(doc)) doc <- list(label = path, description = "") - label <- .studyAgentSummaryLabel(path) - value <- .studyAgentFormatAnalyticSettingValue(.studyAgentGetNestedValue(defaults, path), path = path) - cat(sprintf(" - %s: %s\n", label, value)) - } -} - -.studyAgentPromptKeepDefaults <- function(question, defaults, paths, io_ask_yesno) { - cat(sprintf("%s\n", question)) - .studyAgentPrintDefaultSummary( - "Default settings:", - defaults, - paths - ) - io_ask_yesno("Keep these defaults? Choose No if you want to set the remaining options yourself.", default = TRUE) -} - -.studyAgentPrintAnalyticSettingDescription <- function(path) { - docs <- .studyAgentAnalyticSettingDocs() - doc <- docs[[path]] - if (is.null(doc) || is.null(doc$description)) return(invisible(NULL)) - description <- trimws(as.character(doc$description)) - if (!nzchar(description)) return(invisible(NULL)) - cat(sprintf("%s\n", description)) - invisible(NULL) -} - -.studyAgentPromptAnalyticSetting <- function(working, - path, - ask_yesno, - ask_choice, - ask_integer, - ask_numeric) { - `%||%` <- function(x, y) if (is.null(x)) y else x - - .studyAgentPrintAnalyticSettingDescription(path) - - updated <- switch( - path, - "get_db_cohort_method_data.restrictToCommonPeriod" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "get_db_cohort_method_data.firstExposureOnly" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "get_db_cohort_method_data.washoutPeriod" = .studyAgentSetNestedValue( - working, - path, - ask_integer( - "", - default = as.integer(.studyAgentGetNestedValue(working, path)), - min_value = 0L, - allow_negative = FALSE - ) - ), - "create_study_population.removeDuplicateSubjects" = .studyAgentSetNestedValue( - working, - path, - ask_choice( - "", - choices = c("keep all", "keep first", "remove all"), - labels = c("Keep All", "Keep First", "Remove All"), - default = .studyAgentGetNestedValue(working, path) %||% "keep all" - ) - ), - "create_study_population.censorAtNewRiskWindow" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "create_study_population.removeSubjectsWithPriorOutcome" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "create_study_population.priorOutcomeLookback" = .studyAgentSetNestedValue( - working, - path, - ask_integer( - "", - default = as.integer(.studyAgentGetNestedValue(working, path)), - min_value = 0L, - allow_negative = FALSE - ) - ), - "create_study_population.maxCohortSize" = .studyAgentSetNestedValue( - working, - path, - ask_integer( - "", - default = as.integer(.studyAgentGetNestedValue(working, path)), - min_value = 0L, - allow_negative = FALSE - ) - ), - "create_study_population.minDaysAtRisk" = .studyAgentSetNestedValue( - working, - path, - ask_integer( - "", - default = as.integer(.studyAgentGetNestedValue(working, path)), - min_value = 0L, - allow_negative = FALSE - ) - ), - "ps_adjustment.trimmingStrategy" = { - updated_working <- .studyAgentSetNestedValue( - working, - path, - ask_choice( - "", - choices = c("none", "by_percent", "by_equipoise"), - labels = c("None", "By percent", "By equipoise"), - default = .studyAgentGetNestedValue(working, path) %||% "none" - ) - ) - selected_strategy <- .studyAgentGetNestedValue(updated_working, path) %||% "none" - if (identical(selected_strategy, "by_percent")) { - updated_working <- .studyAgentSetNestedValue( - updated_working, - "ps_adjustment.trimmingPercent", - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.trimmingPercent") %||% 5), - min_value = 0 - ) - ) - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound", 0.25) - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound", 0.75) - } else if (identical(selected_strategy, "by_equipoise")) { - updated_working <- .studyAgentSetNestedValue( - updated_working, - "ps_adjustment.equipoiseLowerBound", - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound") %||% 0.25), - min_value = 0 - ) - ) - updated_working <- .studyAgentSetNestedValue( - updated_working, - "ps_adjustment.equipoiseUpperBound", - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound") %||% 0.75), - min_value = 0 - ) - ) - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.trimmingPercent", 5) - } else { - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.trimmingPercent", 5) - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound", 0.25) - updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound", 0.75) - } - updated_working - }, - "ps_adjustment.trimmingPercent" = .studyAgentSetNestedValue( - working, - path, - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 5), - min_value = 0 - ) - ), - "ps_adjustment.equipoiseLowerBound" = .studyAgentSetNestedValue( - working, - path, - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 0.25), - min_value = 0 - ) - ), - "ps_adjustment.equipoiseUpperBound" = .studyAgentSetNestedValue( - working, - path, - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 0.75), - min_value = 0 - ) - ), - "create_ps.maxCohortSizeForFitting" = .studyAgentSetNestedValue( - working, - path, - ask_integer( - "", - default = as.integer(.studyAgentGetNestedValue(working, path)), - min_value = 0L, - allow_negative = FALSE - ) - ), - "create_ps.errorOnHighCorrelation" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "create_ps.useRegularization" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "match_on_ps.caliper" = .studyAgentSetNestedValue( - working, - path, - ask_numeric( - "", - default = as.numeric(.studyAgentGetNestedValue(working, path)), - min_value = 0 - ) - ), - "match_on_ps.caliperScale" = .studyAgentSetNestedValue( - working, - path, - ask_choice( - "", - choices = c("propensity score", "standardized", "standardized logit"), - labels = c("Propensity score", "Standardized", "Standardized logit"), - default = .studyAgentGetNestedValue(working, path) %||% "standardized logit" - ) - ), - "stratify_by_ps.baseSelection" = .studyAgentSetNestedValue( - working, - path, - ask_choice( - "", - choices = c("all", "target", "comparator"), - labels = c("Entire study population", "Target", "Comparator"), - default = .studyAgentGetNestedValue(working, path) %||% "all" - ) - ), - "fit_outcome_model.stratified" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "fit_outcome_model.useCovariates" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "fit_outcome_model.inversePtWeighting" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - "fit_outcome_model.useRegularization" = .studyAgentSetNestedValue( - working, - path, - ask_yesno( - "", - default = isTRUE(.studyAgentGetNestedValue(working, path)) - ) - ), - stop(sprintf("Unsupported analytic setting customization path: %s", path)) - ) - - updated -} - -.studyAgentCustomizeAnalyticSettings <- function(working, - paths, - ask_yesno, - ask_choice, - ask_integer, - ask_numeric) { - updated <- working - for (path in paths) { - updated <- .studyAgentPromptAnalyticSetting( - updated, - path, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - updated -} - -.studyAgentSummaryPathsForSection <- function(section_name, section_paths, settings) { - paths <- section_paths[[section_name]] - if (!identical(section_name, "propensity_score_adjustment")) { - return(paths) - } - - strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.strategy") - if (identical(strategy, "match_on_ps")) { - trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") - trim_paths <- c("ps_adjustment.trimmingStrategy") - if (identical(trim_strategy, "by_percent")) { - trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") - } else if (identical(trim_strategy, "by_equipoise")) { - trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") - } - return(c( - trim_paths, - "ps_adjustment.strategy", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization", - "match_on_ps.maxRatio", - "match_on_ps.caliper", - "match_on_ps.caliperScale" - )) - } - if (identical(strategy, "stratify_by_ps")) { - trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") - trim_paths <- c("ps_adjustment.trimmingStrategy") - if (identical(trim_strategy, "by_percent")) { - trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") - } else if (identical(trim_strategy, "by_equipoise")) { - trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") - } - return(c( - trim_paths, - "ps_adjustment.strategy", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization", - "stratify_by_ps.numberOfStrata", - "stratify_by_ps.baseSelection" - )) - } - trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") - trim_paths <- c("ps_adjustment.trimmingStrategy") - if (identical(trim_strategy, "by_percent")) { - trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") - } else if (identical(trim_strategy, "by_equipoise")) { - trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") - } - c(trim_paths, "ps_adjustment.strategy") -} - -.studyAgentPrintFinalSettingsSummary <- function(settings, section_paths) { - `%||%` <- function(x, y) if (is.null(x)) y else x - docs <- .studyAgentAnalyticSettingDocs() - section_titles <- .studyAgentAnalyticSettingsSectionTitles() - cat("\nFinal analytic settings\n") - cat(sprintf("Profile name: %s\n", .studyAgentFormatAnalyticSettingValue(settings$profile_name))) - for (section_name in names(section_paths)) { - title <- section_titles[[section_name]] %||% gsub("_", " ", section_name, fixed = TRUE) - cat(sprintf("[%s]\n", title)) - for (path in .studyAgentSummaryPathsForSection(section_name, section_paths, settings)) { - label <- .studyAgentSummaryLabel(path) - value <- .studyAgentFormatAnalyticSettingValue(.studyAgentGetNestedValue(settings, path), path = path) - cat(sprintf(" - %s: %s\n", label, value)) - } - } -} - -.studyAgentValueForReviewFile <- function(value) { - if (is.null(value) || length(value) == 0 || all(is.na(value))) return("") - if (is.logical(value) && length(value) == 1) return(if (isTRUE(value)) "true" else "false") - if (length(value) == 1) return(as.character(value)) - paste(as.character(value), collapse = ", ") -} - -.studyAgentCoerceReviewValue <- function(value, current_value) { - `%||%` <- function(x, y) if (is.null(x)) y else x - value <- trimws(as.character(value %||% "")) - if (!nzchar(value)) { - if (is.character(current_value) && length(current_value) == 1 && !nzchar(trimws(current_value))) return("") - if (length(current_value) == 0 || all(is.na(current_value))) return(NA) - } - if (is.logical(current_value) && length(current_value) == 1) { - normalized <- tolower(value) - if (normalized %in% c("true", "t", "yes", "y", "1")) return(TRUE) - if (normalized %in% c("false", "f", "no", "n", "0")) return(FALSE) - return(value) - } - if (is.integer(current_value) && length(current_value) == 1) { - return(suppressWarnings(as.integer(value))) - } - if (is.numeric(current_value) && length(current_value) == 1) { - return(suppressWarnings(as.numeric(value))) - } - value -} - -.studyAgentWriteAnalyticSettingsReviewFile <- function(settings, section_paths, path) { - `%||%` <- function(x, y) if (is.null(x)) y else x - section_titles <- .studyAgentAnalyticSettingsSectionTitles() - lines <- c( - "# Edit values after ':' only. Save this file, close the editor, then return to the R shell.", - "# Blank study dates are allowed. Boolean values accept true/false.", - "", - sprintf("profile_name: %s", .studyAgentValueForReviewFile(settings$profile_name)), - "" - ) - for (section_name in names(section_paths)) { - title <- section_titles[[section_name]] %||% section_name - lines <- c(lines, sprintf("[%s]", title)) - for (path_key in .studyAgentSummaryPathsForSection(section_name, section_paths, settings)) { - lines <- c(lines, sprintf( - "%s: %s", - path_key, - .studyAgentValueForReviewFile(.studyAgentGetNestedValue(settings, path_key)) - )) - } - lines <- c(lines, "") - } - writeLines(lines, con = path, useBytes = TRUE) - invisible(path) -} - -.studyAgentReadAnalyticSettingsReviewFile <- function(path, settings) { - lines <- readLines(path, warn = FALSE) - updated <- settings - for (line in lines) { - stripped <- trimws(line) - if (!nzchar(stripped) || startsWith(stripped, "#") || grepl("^\\[[^]]+\\]$", stripped)) next - if (!grepl(":", stripped, fixed = TRUE)) next - key <- trimws(sub(":.*$", "", stripped)) - value <- trimws(sub("^[^:]*:", "", stripped)) - if (identical(key, "profile_name")) { - updated$profile_name <- value - next - } - current_value <- .studyAgentGetNestedValue(updated, key) - updated <- .studyAgentSetNestedValue( - updated, - key, - .studyAgentCoerceReviewValue(value, current_value) - ) - } - updated -} - -.studyAgentDefaultCohortMethodAnalyticSettings <- function(covariate_enabled = FALSE) { - list( - profile_name = "Analytic Setting 1", - source = "manual_shell", - customized_sections = character(0), - get_db_cohort_method_data = list( - studyStartDate = "", - studyEndDate = "", - firstExposureOnly = TRUE, - washoutPeriod = 365L, - restrictToCommonPeriod = TRUE, - removeDuplicateSubjects = "keep first, truncate to second" - ), - create_study_population = list( - maxCohortSize = 0L, - removeDuplicateSubjects = "keep all", - removeSubjectsWithPriorOutcome = TRUE, - priorOutcomeLookback = 99999L, - minDaysAtRisk = 1L, - riskWindowStart = 0L, - startAnchor = "cohort start", - riskWindowEnd = 0L, - endAnchor = "cohort end", - censorAtNewRiskWindow = FALSE - ), - create_ps = list( - estimator = "att", - maxCohortSizeForFitting = 250000L, - errorOnHighCorrelation = FALSE, - useRegularization = TRUE - ), - ps_adjustment = list( - strategy = "match_on_ps", - trimmingStrategy = "none", - trimmingPercent = 5, - equipoiseLowerBound = 0.25, - equipoiseUpperBound = 0.75 - ), - match_on_ps = list( - caliper = 0.2, - caliperScale = "standardized logit", - maxRatio = 1L - ), - stratify_by_ps = list( - numberOfStrata = 10L, - baseSelection = "all" - ), - fit_outcome_model = list( - modelType = "cox", - stratified = FALSE, - useCovariates = FALSE, - inversePtWeighting = FALSE, - useRegularization = TRUE - ), - covariate_concept_sets = list( - enabled = isTRUE(covariate_enabled), - include_all_concepts = TRUE, - include_concept_set_id = NA_integer_, - exclude_concept_set_id = NA_integer_ - ) - ) -} - -.studyAgentGetNestedValue <- function(x, path) { - parts <- strsplit(path, ".", fixed = TRUE)[[1]] - current <- x - for (part in parts) { - if (!is.list(current) || is.null(current[[part]])) return(NULL) - current <- current[[part]] - } - current -} - -.studyAgentSetNestedValue <- function(x, path, value) { - parts <- strsplit(path, ".", fixed = TRUE)[[1]] - - set_rec <- function(obj, idx = 1L) { - key <- parts[[idx]] - if (idx == length(parts)) { - obj[[key]] <- value - return(obj) - } - child <- obj[[key]] - if (!is.list(child)) child <- list() - obj[[key]] <- set_rec(child, idx + 1L) - obj - } - - set_rec(x, 1L) -} - -.studyAgentResetSectionPaths <- function(current_settings, default_settings, paths) { - updated <- current_settings - for (path in paths) { - updated <- .studyAgentSetNestedValue( - updated, - path, - .studyAgentGetNestedValue(default_settings, path) - ) - } - updated -} - -.studyAgentDeepMerge <- function(defaults, overrides) { - if (is.null(overrides)) return(defaults) - for (name in names(overrides)) { - override_value <- overrides[[name]] - default_value <- defaults[[name]] - if (is.list(default_value) && is.list(override_value) && !is.data.frame(override_value)) { - defaults[[name]] <- .studyAgentDeepMerge(default_value, override_value) - } else if (!is.null(override_value)) { - defaults[[name]] <- override_value - } - } - defaults -} - -.studyAgentDateStringOrEmpty <- function(value, label) { - if (is.null(value) || length(value) == 0 || is.na(value)) return("") - value <- trimws(as.character(value[[1]])) - if (!nzchar(value)) return("") - if (grepl("^[0-9]{8}$", value)) return(value) - stop(sprintf("%s must be blank or formatted as YYYYMMDD.", label)) -} - -.studyAgentDefaultCmAnalysisTemplate <- function() { - list( - description = "", - getDbCohortMethodDataArgs = list( - studyStartDate = "", - studyEndDate = "", - firstExposureOnly = FALSE, - removeDuplicateSubjects = "keep all", - restrictToCommonPeriod = FALSE, - washoutPeriod = 365L, - maxCohortSize = 0L - ), - createStudyPopArgs = list( - removeSubjectsWithPriorOutcome = TRUE, - priorOutcomeLookback = 99999L, - minDaysAtRisk = 1L, - riskWindowStart = 1L, - startAnchor = "cohort start", - riskWindowEnd = 0L, - endAnchor = "cohort end", - censorAtNewRiskWindow = FALSE - ), - trimByPsArgs = list( - trimFraction = 0.05, - equipoiseBounds = NA - ), - matchOnPsArgs = list( - maxRatio = 1L, - caliper = 0.2, - caliperScale = "standardized logit" - ), - stratifyByPsArgs = NA, - createPsArgs = list( - maxCohortSizeForFitting = 250000L, - errorOnHighCorrelation = TRUE, - prior = list( - priorType = "laplace", - useCrossValidation = TRUE - ), - control = list( - tolerance = 2e-7, - cvType = "auto", - fold = 10L, - cvRepetitions = 10L, - noiseLevel = "silent", - resetCoefficients = TRUE, - startingVariance = 0.01 - ) - ), - fitOutcomeModelArgs = list( - modelType = "cox", - stratified = FALSE, - useCovariates = FALSE, - inversePtWeighting = FALSE, - prior = list( - priorType = "laplace", - useCrossValidation = TRUE - ), - control = list( - tolerance = 2e-7, - cvType = "auto", - fold = 10L, - cvRepetitions = 10L, - noiseLevel = "quiet", - resetCoefficients = TRUE, - startingVariance = 0.01 - ) - ) - ) -} - -.studyAgentLoadCmAnalysisTemplate <- function(template_path = NULL) { - template <- .studyAgentDefaultCmAnalysisTemplate() - if (!is.null(template_path) && length(template_path) > 0 && !is.na(template_path) && nzchar(template_path) && file.exists(template_path)) { - loaded <- jsonlite::fromJSON(template_path, simplifyVector = FALSE) - template <- .studyAgentDeepMerge(template, loaded) - } - template -} - -.studyAgentBuildCmAnalysisJson <- function(settings, template = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - template <- template %||% .studyAgentDefaultCmAnalysisTemplate() - - ps_strategy <- settings$ps_adjustment$strategy %||% "match_on_ps" - trimming_strategy <- settings$ps_adjustment$trimmingStrategy %||% "none" - ps_regularized <- isTRUE(settings$create_ps$useRegularization) - outcome_regularized <- isTRUE(settings$fit_outcome_model$useRegularization) - - ps_prior <- if (ps_regularized) template$createPsArgs$prior else NA - ps_control <- if (ps_regularized) template$createPsArgs$control else NA - outcome_prior <- if (outcome_regularized) template$fitOutcomeModelArgs$prior else NA - outcome_control <- if (outcome_regularized) template$fitOutcomeModelArgs$control else NA - - trim_args <- NA - if (identical(trimming_strategy, "by_percent")) { - trim_args <- list( - trimFraction = as.numeric(settings$ps_adjustment$trimmingPercent) / 100, - equipoiseBounds = NA - ) - } else if (identical(trimming_strategy, "by_equipoise")) { - trim_args <- list( - trimFraction = NA_real_, - equipoiseBounds = c( - as.numeric(settings$ps_adjustment$equipoiseLowerBound), - as.numeric(settings$ps_adjustment$equipoiseUpperBound) - ) - ) - } - - match_args <- if (identical(ps_strategy, "match_on_ps")) { - list( - maxRatio = as.integer(settings$match_on_ps$maxRatio), - caliper = as.numeric(settings$match_on_ps$caliper), - caliperScale = as.character(settings$match_on_ps$caliperScale) - ) - } else { - NA - } - - stratify_args <- if (identical(ps_strategy, "stratify_by_ps")) { - list( - numberOfStrata = as.integer(settings$stratify_by_ps$numberOfStrata), - baseSelection = as.character(settings$stratify_by_ps$baseSelection) - ) - } else { - NA - } - - create_ps_args <- if (identical(ps_strategy, "none") && identical(trimming_strategy, "none")) { - NA - } else { - list( - maxCohortSizeForFitting = as.integer(settings$create_ps$maxCohortSizeForFitting), - errorOnHighCorrelation = isTRUE(settings$create_ps$errorOnHighCorrelation), - prior = ps_prior, - control = ps_control - ) - } - - list( - description = as.character(settings$profile_name), - getDbCohortMethodDataArgs = list( - studyStartDate = as.character(settings$get_db_cohort_method_data$studyStartDate %||% ""), - studyEndDate = as.character(settings$get_db_cohort_method_data$studyEndDate %||% ""), - firstExposureOnly = isTRUE(settings$get_db_cohort_method_data$firstExposureOnly), - removeDuplicateSubjects = as.character(settings$get_db_cohort_method_data$removeDuplicateSubjects), - restrictToCommonPeriod = isTRUE(settings$get_db_cohort_method_data$restrictToCommonPeriod), - washoutPeriod = as.integer(settings$get_db_cohort_method_data$washoutPeriod), - maxCohortSize = as.integer(settings$create_study_population$maxCohortSize) - ), - createStudyPopArgs = list( - removeSubjectsWithPriorOutcome = isTRUE(settings$create_study_population$removeSubjectsWithPriorOutcome), - priorOutcomeLookback = as.integer(settings$create_study_population$priorOutcomeLookback), - minDaysAtRisk = as.integer(settings$create_study_population$minDaysAtRisk), - riskWindowStart = as.integer(settings$create_study_population$riskWindowStart), - startAnchor = as.character(settings$create_study_population$startAnchor), - riskWindowEnd = as.integer(settings$create_study_population$riskWindowEnd), - endAnchor = as.character(settings$create_study_population$endAnchor), - censorAtNewRiskWindow = isTRUE(settings$create_study_population$censorAtNewRiskWindow) - ), - trimByPsArgs = trim_args, - matchOnPsArgs = match_args, - stratifyByPsArgs = stratify_args, - createPsArgs = create_ps_args, - fitOutcomeModelArgs = list( - modelType = as.character(settings$fit_outcome_model$modelType), - stratified = isTRUE(settings$fit_outcome_model$stratified), - useCovariates = isTRUE(settings$fit_outcome_model$useCovariates), - inversePtWeighting = isTRUE(settings$fit_outcome_model$inversePtWeighting), - prior = outcome_prior, - control = outcome_control - ) - ) -} - -.studyAgentCollectStepByStepAnalyticSettings <- function(default_settings, - seed_settings, - interactive = TRUE, - io = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - - normalize_seed <- function(settings) { - settings <- settings %||% list() - if (is.null(settings$ps_adjustment)) { - settings$ps_adjustment <- list(strategy = "match_on_ps") - } - if (is.null(settings$stratify_by_ps)) { - settings$stratify_by_ps <- list( - numberOfStrata = default_settings$stratify_by_ps$numberOfStrata, - baseSelection = default_settings$stratify_by_ps$baseSelection - ) - } - settings - } - - ask_text <- function(prompt, default = "", allow_blank = FALSE) { - if (!isTRUE(interactive)) return(default) - value <- io$text(prompt = prompt, default = default, allow_blank = allow_blank) - trimmed <- trimws(as.character(value %||% "")) - if (!nzchar(trimmed) && !isTRUE(allow_blank)) { - stop(sprintf("A non-empty value is required for: %s", prompt)) - } - trimmed - } - - ask_yesno <- function(prompt, default = TRUE) { - if (!isTRUE(interactive)) return(default) - io$yesno(prompt = prompt, default = default) - } - - ask_choice <- function(prompt, choices, default, labels = choices) { - if (!isTRUE(interactive)) return(default) - io$choice(prompt = prompt, choices = choices, default = default, labels = labels) - } - - ask_integer <- function(prompt, default, min_value = NULL, allow_negative = TRUE) { - if (!isTRUE(interactive)) return(as.integer(default)) - io$integer( - prompt = prompt, - default = default, - min_value = min_value, - allow_negative = allow_negative - ) - } - - ask_numeric <- function(prompt, default, min_value = NULL) { - if (!isTRUE(interactive)) return(as.numeric(default)) - io$numeric(prompt = prompt, default = default, min_value = min_value) - } - - section_paths <- .studyAgentAnalyticSettingsSectionPaths() - working <- .studyAgentDeepMerge(default_settings, normalize_seed(seed_settings)) - working <- .studyAgentSetNestedValue( - working, - "get_db_cohort_method_data.removeDuplicateSubjects", - .studyAgentGetNestedValue(default_settings, "get_db_cohort_method_data.removeDuplicateSubjects") - ) - working <- .studyAgentSetNestedValue( - working, - "create_ps.estimator", - .studyAgentGetNestedValue(default_settings, "create_ps.estimator") - ) - working <- .studyAgentSetNestedValue( - working, - "create_ps.errorOnHighCorrelation", - isTRUE(.studyAgentGetNestedValue(default_settings, "create_ps.errorOnHighCorrelation")) - ) - working <- .studyAgentSetNestedValue( - working, - "create_ps.useRegularization", - isTRUE(.studyAgentGetNestedValue(default_settings, "create_ps.useRegularization")) - ) - working <- .studyAgentSetNestedValue( - working, - "stratify_by_ps.baseSelection", - .studyAgentGetNestedValue(default_settings, "stratify_by_ps.baseSelection") - ) - working$source <- "manual_shell" - working$customized_sections <- character(0) - - show_section <- function(label) { - if (isTRUE(interactive) && !is.null(io$section_header)) { - io$section_header(label) - } - } - - show_section("Study Population") - study_start <- ask_text( - "Study start date (YYYYMMDD, leave blank for no restriction)", - default = .studyAgentFormatDateForPrompt(.studyAgentGetNestedValue(working, "get_db_cohort_method_data.studyStartDate")), - allow_blank = TRUE - ) - study_end <- ask_text( - "Study end date (YYYYMMDD, leave blank for no restriction)", - default = .studyAgentFormatDateForPrompt(.studyAgentGetNestedValue(working, "get_db_cohort_method_data.studyEndDate")), - allow_blank = TRUE - ) - working <- .studyAgentSetNestedValue( - working, - "get_db_cohort_method_data.studyStartDate", - .studyAgentDateStringOrEmpty(study_start, "Study start date") - ) - working <- .studyAgentSetNestedValue( - working, - "get_db_cohort_method_data.studyEndDate", - .studyAgentDateStringOrEmpty(study_end, "Study end date") - ) - study_population_non_core <- setdiff( - section_paths$study_population, - c( - "get_db_cohort_method_data.studyStartDate", - "get_db_cohort_method_data.studyEndDate" - ) - ) - if (isTRUE(interactive)) { - keep_study_population_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining study population settings, keep the defaults or choose each option yourself?", - default_settings, - study_population_non_core - , - ask_yesno - ) - if (isTRUE(keep_study_population_defaults)) { - working <- .studyAgentResetSectionPaths(working, default_settings, study_population_non_core) - } else { - working <- .studyAgentCustomizeAnalyticSettings( - working, - study_population_non_core, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - } - - show_section("Time At Risk") - anchor_choices <- c("cohort start", "cohort end") - anchor_labels <- c("cohort start date", "cohort end date") - working <- .studyAgentSetNestedValue( - working, - "create_study_population.startAnchor", - ask_choice( - "Risk window start anchor", - choices = anchor_choices, - labels = anchor_labels, - default = .studyAgentGetNestedValue(working, "create_study_population.startAnchor") %||% anchor_choices[[1]] - ) - ) - working <- .studyAgentSetNestedValue( - working, - "create_study_population.riskWindowStart", - ask_integer( - "Risk window start (days)", - default = as.integer(.studyAgentGetNestedValue(working, "create_study_population.riskWindowStart")), - allow_negative = TRUE - ) - ) - working <- .studyAgentSetNestedValue( - working, - "create_study_population.endAnchor", - ask_choice( - "Risk window end anchor", - choices = anchor_choices, - labels = anchor_labels, - default = .studyAgentGetNestedValue(working, "create_study_population.endAnchor") %||% anchor_choices[[2]] - ) - ) - working <- .studyAgentSetNestedValue( - working, - "create_study_population.riskWindowEnd", - ask_integer( - "Risk window end (days)", - default = as.integer(.studyAgentGetNestedValue(working, "create_study_population.riskWindowEnd")), - allow_negative = TRUE - ) - ) - if (isTRUE(interactive)) { - tar_non_core <- c("create_study_population.minDaysAtRisk") - keep_tar_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining time-at-risk settings, keep the defaults or choose each option yourself?", - default_settings, - tar_non_core, - ask_yesno - ) - if (isTRUE(keep_tar_defaults)) { - working <- .studyAgentResetSectionPaths(working, default_settings, tar_non_core) - } else { - working <- .studyAgentCustomizeAnalyticSettings( - working, - tar_non_core, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - } - - show_section("Propensity Score Adjustment") - strategy_choices <- c("match_on_ps", "stratify_by_ps", "none") - strategy_labels <- c("Match on propensity score", "Stratify on propensity score", "None") - working <- .studyAgentSetNestedValue( - working, - "ps_adjustment.strategy", - ask_choice( - "PS adjustment strategy", - choices = strategy_choices, - labels = strategy_labels, - default = .studyAgentGetNestedValue(working, "ps_adjustment.strategy") %||% strategy_choices[[1]] - ) - ) - current_strategy <- .studyAgentGetNestedValue(working, "ps_adjustment.strategy") %||% "match_on_ps" - if (isTRUE(interactive)) { - if (identical(current_strategy, "none")) { - ps_default_paths <- c( - "ps_adjustment.trimmingStrategy", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization" - ) - keep_ps_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining propensity score adjustment settings, keep the defaults?", - default_settings, - ps_default_paths, - ask_yesno - ) - if (isTRUE(keep_ps_defaults)) { - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c( - ps_default_paths, - "ps_adjustment.trimmingPercent", - "ps_adjustment.equipoiseLowerBound", - "ps_adjustment.equipoiseUpperBound" - ) - ) - } else { - working <- .studyAgentCustomizeAnalyticSettings( - working, - ps_default_paths, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c( - "match_on_ps.caliper", - "match_on_ps.caliperScale", - "match_on_ps.maxRatio", - "stratify_by_ps.numberOfStrata", - "stratify_by_ps.baseSelection" - ) - ) - } else if (identical(current_strategy, "match_on_ps")) { - working <- .studyAgentSetNestedValue( - working, - "match_on_ps.maxRatio", - ask_integer( - "Maximum match ratio (0 means no maximum)", - default = as.integer(.studyAgentGetNestedValue(working, "match_on_ps.maxRatio")), - min_value = 0L, - allow_negative = FALSE - ) - ) - ps_default_paths <- c( - "ps_adjustment.trimmingStrategy", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization", - "match_on_ps.caliper", - "match_on_ps.caliperScale" - ) - keep_ps_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining propensity score adjustment settings, keep the defaults?", - default_settings, - ps_default_paths, - ask_yesno - ) - if (isTRUE(keep_ps_defaults)) { - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c( - ps_default_paths, - "ps_adjustment.trimmingPercent", - "ps_adjustment.equipoiseLowerBound", - "ps_adjustment.equipoiseUpperBound" - ) - ) - } else { - working <- .studyAgentCustomizeAnalyticSettings( - working, - ps_default_paths, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c("stratify_by_ps.numberOfStrata", "stratify_by_ps.baseSelection") - ) - } else if (identical(current_strategy, "stratify_by_ps")) { - working <- .studyAgentSetNestedValue( - working, - "stratify_by_ps.numberOfStrata", - ask_integer( - "Number of strata", - default = as.integer(.studyAgentGetNestedValue(working, "stratify_by_ps.numberOfStrata")), - min_value = 1L, - allow_negative = FALSE - ) - ) - working <- .studyAgentSetNestedValue( - working, - "stratify_by_ps.baseSelection", - .studyAgentGetNestedValue(default_settings, "stratify_by_ps.baseSelection") - ) - ps_default_paths <- c( - "ps_adjustment.trimmingStrategy", - "create_ps.maxCohortSizeForFitting", - "create_ps.errorOnHighCorrelation", - "create_ps.useRegularization", - "stratify_by_ps.baseSelection" - ) - keep_ps_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining propensity score adjustment settings, keep the defaults?", - default_settings, - ps_default_paths, - ask_yesno - ) - if (isTRUE(keep_ps_defaults)) { - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c( - ps_default_paths, - "ps_adjustment.trimmingPercent", - "ps_adjustment.equipoiseLowerBound", - "ps_adjustment.equipoiseUpperBound" - ) - ) - } else { - working <- .studyAgentCustomizeAnalyticSettings( - working, - ps_default_paths, - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - working <- .studyAgentResetSectionPaths( - working, - default_settings, - c("match_on_ps.caliper", "match_on_ps.caliperScale", "match_on_ps.maxRatio") - ) - } - } - - show_section("Outcome Model") - outcome_model_defaults <- .studyAgentOutcomeModelDefaults( - ps_strategy = current_strategy, - match_max_ratio = .studyAgentGetNestedValue(working, "match_on_ps.maxRatio"), - model_type = .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% default_settings$fit_outcome_model$modelType - ) - model_choices <- c("cox", "poisson", "logistic") - model_labels <- c("Cox proportional hazards", "Poisson regression", "Logistic regression") - working <- .studyAgentSetNestedValue( - working, - "fit_outcome_model.modelType", - ask_choice( - "Outcome model", - choices = model_choices, - labels = model_labels, - default = .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% model_choices[[1]] - ) - ) - outcome_model_defaults$modelType <- .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% outcome_model_defaults$modelType - outcome_defaults_for_display <- .studyAgentDeepMerge( - default_settings, - list(fit_outcome_model = outcome_model_defaults) - ) - keep_outcome_defaults <- !isTRUE(interactive) - if (isTRUE(interactive)) { - keep_outcome_defaults <- .studyAgentPromptKeepDefaults( - "For the remaining outcome model settings, keep the defaults or choose each option yourself?", - outcome_defaults_for_display, - setdiff(section_paths$outcome_model, "fit_outcome_model.modelType") - , - ask_yesno - ) - if (!isTRUE(keep_outcome_defaults)) { - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.stratified", isTRUE(outcome_model_defaults$stratified)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useCovariates", isTRUE(outcome_model_defaults$useCovariates)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.inversePtWeighting", isTRUE(outcome_model_defaults$inversePtWeighting)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useRegularization", isTRUE(outcome_model_defaults$useRegularization)) - working <- .studyAgentCustomizeAnalyticSettings( - working, - setdiff(section_paths$outcome_model, "fit_outcome_model.modelType"), - ask_yesno = ask_yesno, - ask_choice = ask_choice, - ask_integer = ask_integer, - ask_numeric = ask_numeric - ) - } - } - if (isTRUE(keep_outcome_defaults)) { - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.stratified", isTRUE(outcome_model_defaults$stratified)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useCovariates", isTRUE(outcome_model_defaults$useCovariates)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.inversePtWeighting", isTRUE(outcome_model_defaults$inversePtWeighting)) - working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useRegularization", isTRUE(outcome_model_defaults$useRegularization)) - } - - if (isTRUE(interactive)) { - working$profile_name <- ask_text( - "Analytic settings profile name", - default = as.character(working$profile_name %||% default_settings$profile_name), - allow_blank = FALSE - ) - } - - customized_sections <- names(section_paths)[vapply(names(section_paths), function(section_name) { - paths <- section_paths[[section_name]] - any(vapply(paths, function(path) { - !identical( - .studyAgentGetNestedValue(working, path), - .studyAgentGetNestedValue(default_settings, path) - ) - }, logical(1))) - }, logical(1))] - working$customized_sections <- customized_sections - - list( - settings = working, - section_flow = names(section_paths), - customized_sections = customized_sections - ) -} - -runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-methods", - acpUrl = "http://127.0.0.1:8765", - studyIntent = NULL, - targetStatement = NULL, - comparatorStatement = NULL, - outcomeStatement = NULL, - targetCohortId = NULL, - comparatorCohortId = NULL, - outcomeCohortIds = NULL, - comparisonLabel = NULL, - topK = 20, - maxResults = 3, - candidateLimit = 10, - indexDir = Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index"), - negativeControlConceptSetId = NULL, - includeCovariateConceptSetId = NULL, - excludeCovariateConceptSetId = NULL, - analyticSettingsDescription = NULL, - analyticSettingsDescriptionPath = NULL, - incidenceOutputDir = "demo-strategus-cohort-incidence", - interactive = TRUE, - bannerPath = "ohdsi-logo-ascii.txt", - studyAgentBaseDir = Sys.getenv("STUDY_AGENT_BASE_DIR", ""), - reset = FALSE, - allowCache = TRUE, - promptOnCache = TRUE, - autoApplyImprovements = NA, - resume = FALSE, - remapCohortIds = TRUE, - cohortIdBase = NULL) { - `%||%` <- function(x, y) if (is.null(x)) y else x - - ensure_dir <- function(path) { - if (!dir.exists(path)) dir.create(path, recursive = TRUE, showWarnings = FALSE) - } - - normalize_dialogue_step <- .studyAgentSlashNormalizeCohortMethodsDialogueStep - - dialogue_step_label <- .studyAgentSlashCohortMethodsDialogueStepLabel - build_workflow_stage_context <- function(studyIntent, dialogue_state) { - .studyAgentSlashBuildCohortMethodsWorkflowStageContext( - study_intent = studyIntent, - dialogue_state = dialogue_state, - interactive = interactive - ) - } - - compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext - - dialogue_acp_client <- new.env(parent = emptyenv()) - dialogue_acp_client$client <- NULL - dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( - interactive = interactive, - study_intent_getter = function() studyIntent, - build_stage_context = build_workflow_stage_context, - call_dialogue = function(stage_context, message) { - if (!ensure_workflow_dialogue_client(acpUrl)) { - stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") - } - message("Calling ACP flow: workflow_context_dialogue") - .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) - }, - empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why is washout important here?" - ) - dialogue_state <- dialogue_session$state - set_dialogue_context <- dialogue_session$set_context - readline_with_dialogue <- dialogue_session$readline - - prompt_yesno <- function(prompt, default = TRUE) { - if (!isTRUE(interactive)) return(default) - suffix <- if (default) "[Y/n]" else "[y/N]" - resp <- tolower(trimws(readline_with_dialogue(sprintf("%s %s ", prompt, suffix)))) - if (resp == "") return(default) - if (resp %in% c("y", "yes")) return(TRUE) - if (resp %in% c("n", "no")) return(FALSE) - default - } - - maybe_use_cache <- function(path, label) { - if (!allowCache || !file.exists(path)) return(FALSE) - if (isTRUE(resume)) return(TRUE) - if (!promptOnCache) return(TRUE) - prompt_yesno(sprintf("Use cached %s at %s?", label, path), default = TRUE) - } - - read_json <- function(path) { - jsonlite::fromJSON(path, simplifyVector = FALSE) - } - - write_json <- function(x, path) { - jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE, na = "null") - } - - analysis_label_max_chars <- 50L - shorten_analysis_label <- function(value, max_chars = analysis_label_max_chars) { - value <- trimws(as.character(value %||% "")) - if (!nzchar(value)) return(value) - if (nchar(value, type = "chars") <= max_chars) return(value) - paste0(substr(value, 1L, max_chars - 3L), "...") - } - prompt_analysis_label <- function(label, default, max_chars = analysis_label_max_chars) { - default <- trimws(as.character(default %||% label)) - if (!nzchar(default)) default <- label - if (!isTRUE(interactive)) return(shorten_analysis_label(default, max_chars)) - - current <- default - repeat { - prompt <- if (nchar(current, type = "chars") <= max_chars) { - sprintf("%s analysis label [%s]: ", label, current) - } else { - sprintf( - "%s analysis label [%s]:\nLabel must be %s characters or fewer.\n%s analysis label: ", - label, - current, - max_chars, - label - ) - } - entered <- trimws(readline_with_dialogue(prompt)) - candidate <- if (nzchar(entered)) entered else if (nchar(current, type = "chars") <= max_chars) current else "" - if (!nzchar(candidate)) { - cat(sprintf("Analysis label must be %s characters or fewer. Please enter a shorter label.\n", max_chars)) - next - } - if (nchar(candidate, type = "chars") > max_chars) { - cat(sprintf("Analysis label must be %s characters or fewer. Please shorten it.\n", max_chars)) - current <- candidate - next - } - return(candidate) - } - } - - if (length(autoApplyImprovements) == 0 || is.na(autoApplyImprovements[[1]])) { - autoApplyImprovements <- !isTRUE(interactive) - } else { - autoApplyImprovements <- isTRUE(autoApplyImprovements) - } - - apply_action <- function(obj, action) { - path <- action$path %||% "" - value <- action$value - if (!nzchar(path)) return(obj) - segs <- strsplit(path, "/", fixed = TRUE)[[1]] - segs <- segs[segs != ""] - - set_in <- function(x, segs, value) { - if (length(segs) == 0) return(value) - seg <- segs[[1]] - name <- seg - idx <- NA_integer_ - if (grepl("\\[\\d+\\]$", seg)) { - name <- sub("\\[\\d+\\]$", "", seg) - idx <- as.integer(sub("^.*\\[(\\d+)\\]$", "\\1", seg)) - } - if (name != "") { - if (is.null(x[[name]])) x[[name]] <- list() - if (length(segs) == 1) { - if (!is.na(idx)) { - if (length(x[[name]]) < idx) { - while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() - } - x[[name]][[idx]] <- value - } else { - x[[name]] <- value - } - return(x) - } - if (!is.na(idx)) { - if (length(x[[name]]) < idx) { - while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() - } - x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value) - } else { - x[[name]] <- set_in(x[[name]], segs[-1], value) - } - return(x) - } - idx <- suppressWarnings(as.integer(seg)) - if (is.na(idx)) return(x) - if (idx == 0) idx <- 1 - if (length(x) < idx) { - while (length(x) < idx) x[[length(x) + 1]] <- list() - } - if (length(segs) == 1) { - x[[idx]] <- value - return(x) - } - x[[idx]] <- set_in(x[[idx]], segs[-1], value) - x - } - - set_in(obj, segs, value) - } - - is_mutating_improvement_action <- function(action) { - action_type <- tolower(trimws(as.character(action$type %||% "note"))) - action_type %in% c("set", "replace", "update") - } - - checkpoint_path <- function(label) { - file.path(output_dir, paste0("checkpoint_", label, ".json")) - } - - mark_checkpoint <- function(label, payload = list()) { - checkpoint <- list(step = label) - if (length(payload) > 0) checkpoint <- c(checkpoint, payload) - write_json(checkpoint, checkpoint_path(label)) - } - - checkpoint_label_for_role_advice <- function(role_label) { - role_slug <- tolower(gsub("[^A-Za-z0-9]+", "_", trimws(role_label))) - role_slug <- gsub("^_+|_+$", "", role_slug) - paste0(role_slug, "_advice") - } - - is_absolute_path <- function(path) { - grepl("^(/|[A-Za-z]:[\\\\/])", path) - } - - resolve_path <- function(path, base_dir = "") { - if (!nzchar(path)) return(path) - if (is_absolute_path(path)) return(path) - if (nzchar(base_dir)) return(file.path(base_dir, path)) - path - } - - parse_ids <- function(x) { - if (is.null(x)) return(integer(0)) - if (is.numeric(x) || is.integer(x)) return(as.integer(x)) - if (is.character(x)) { - pieces <- unlist(strsplit(paste(x, collapse = ","), "[,[:space:]]+")) - pieces <- pieces[nzchar(trimws(pieces))] - return(as.integer(pieces)) - } - integer(0) - } - - normalize_selected_ids <- function(value, label, allow_multiple = FALSE) { - ids <- unique(parse_ids(value)) - ids <- as.integer(ids[!is.na(ids)]) - if (!isTRUE(allow_multiple) && length(ids) > 1) { - stop(sprintf("%s must contain exactly one cohort ID.", label)) - } - ids - } - - collect_single_id <- function(value, label) { - ids <- parse_ids(value) - ids <- ids[!is.na(ids)] - if (length(ids) > 1) stop(sprintf("%s must contain exactly one cohort ID.", label)) - if (length(ids) == 1) return(as.integer(ids[[1]])) - if (!isTRUE(interactive)) stop(sprintf("Missing %s.", label)) - entered <- trimws(readline_with_dialogue(sprintf("%s cohort ID: ", label))) - ids <- parse_ids(entered) - ids <- ids[!is.na(ids)] - if (length(ids) != 1) stop(sprintf("%s must contain exactly one cohort ID.", label)) - as.integer(ids[[1]]) - } - - collect_multiple_ids <- function(value, label) { - ids <- parse_ids(value) - ids <- unique(ids[!is.na(ids)]) - if (length(ids) > 0) return(as.integer(ids)) - if (!isTRUE(interactive)) stop(sprintf("Missing %s.", label)) - entered <- trimws(readline_with_dialogue(sprintf("%s cohort IDs (comma-separated): ", label))) - ids <- parse_ids(entered) - ids <- unique(ids[!is.na(ids)]) - if (length(ids) == 0) stop(sprintf("%s must include at least one cohort ID.", label)) - as.integer(ids) - } - - collect_optional_single_id <- function(value, label, prompt = NULL) { - ids <- parse_ids(value) - ids <- unique(ids[!is.na(ids)]) - if (length(ids) > 1) stop(sprintf("%s must contain at most one ID.", label)) - if (length(ids) == 1) return(validate_positive_integer(ids[[1]], label)) - if (!isTRUE(interactive)) return(NULL) - entered <- trimws(readline_with_dialogue(prompt %||% sprintf("%s ID [optional]: ", label))) - if (!nzchar(entered)) return(NULL) - ids <- parse_ids(entered) - ids <- unique(ids[!is.na(ids)]) - if (length(ids) != 1) stop(sprintf("%s must contain at most one ID.", label)) - validate_positive_integer(ids[[1]], label) - } - - prompt_yesno_strict <- function(prompt, default = TRUE) { - if (!isTRUE(interactive)) return(default) - suffix <- if (default) "[Y/n]" else "[y/N]" - options <- list( - yes = c("y", "yes", "true", "t"), - no = c("n", "no", "false", "f") - ) - - repeat { - prompt_text <- trimws(as.character(prompt %||% "")) - rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s %s ", prompt_text, suffix) else sprintf("%s ", suffix) - entered <- tolower(trimws(readline_with_dialogue(rendered_prompt))) - if (entered == "") return(default) - if (entered %in% options$yes) return(TRUE) - if (entered %in% options$no) return(FALSE) - cat("Please answer with y/yes or n/no.\n") - } - } - - prompt_non_null_text <- function(prompt, default = NULL) { - if (!isTRUE(interactive)) return(default) - repeat { - default_value <- if (is.null(default)) "" else as.character(default) - entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, default_value))) - if (entered == "" && !is.null(default)) return(default) - if (entered == "") { - cat("A value is required.\n") - next - } - return(entered) - } - } - - prompt_bool <- function(prompt, default = TRUE) { - prompt_yesno_strict(prompt, default = default) - } - - prompt_integer <- function(prompt, default = NULL, allow_null = FALSE, must_be_positive = FALSE, allow_negative = TRUE) { - if (!isTRUE(interactive)) { - if (is.null(default)) return(NULL) - if (is.na(default) && allow_null) return(NULL) - return(as.integer(default)) - } - prompt_suffix <- if (is.null(default)) "" else sprintf(" [%s]", default) - repeat { - prompt_text <- trimws(as.character(prompt %||% "")) - rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s%s: ", prompt_text, prompt_suffix) else sprintf("%s: ", prompt_suffix) - entered <- trimws(readline_with_dialogue(rendered_prompt)) - if (entered == "") { - if (allow_null) return(NULL) - if (is.null(default)) { - cat("A value is required.\n") - next - } - return(as.integer(default)) - } - value <- suppressWarnings(as.integer(entered)) - if (is.na(value) || !is.finite(value)) { - cat("Please enter a valid integer.\n") - next - } - if (must_be_positive && value <= 0) { - cat("Please enter a positive integer.\n") - next - } - if (!allow_negative && value < 0) { - cat("Please enter a non-negative integer.\n") - next - } - return(value) - } - } - - prompt_numeric <- function(prompt, default = NULL, must_be_positive = TRUE) { - if (!isTRUE(interactive)) { - if (is.null(default)) return(NULL) - return(as.numeric(default)) - } - prompt_suffix <- if (is.null(default)) "" else sprintf(" [%s]", default) - repeat { - prompt_text <- trimws(as.character(prompt %||% "")) - rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s%s: ", prompt_text, prompt_suffix) else sprintf("%s: ", prompt_suffix) - entered <- trimws(readline_with_dialogue(rendered_prompt)) - if (entered == "") { - if (is.null(default)) { - cat("A value is required.\n") - next - } - return(as.numeric(default)) - } - value <- suppressWarnings(as.numeric(entered)) - if (is.na(value) || !is.finite(value)) { - cat("Please enter a valid number.\n") - next - } - if (must_be_positive && value <= 0) { - cat("Please enter a positive number.\n") - next - } - return(value) - } - } - - prompt_enum <- function(prompt, choices, default = NULL) { - normalized_choices <- tolower(trimws(choices)) - if (!isTRUE(interactive)) { - return(if (is.null(default)) choices[[1]] else default) - } - - if (!is.null(default)) { - default <- as.character(default) - default_norm <- tolower(trimws(default)) - default <- if (default_norm %in% normalized_choices) { - choices[[which(normalized_choices == default_norm)[1]]] - } else { - choices[[1]] - } - } else { - default <- choices[[1]] - } - - repeat { - prompt_text <- trimws(as.character(prompt %||% "")) - rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s [%s]: ", prompt_text, default) else sprintf("[%s]: ", default) - entered <- trimws(readline_with_dialogue(rendered_prompt)) - if (entered == "") { - return(default) - } - entered_norm <- tolower(trimws(entered)) - match_index <- which(normalized_choices == entered_norm) - if (length(match_index) != 1) { - cat(sprintf("Please enter one of: %s\n", paste(choices, collapse = ", "))) - next - } - return(choices[[match_index[1]]]) - } - } - - collect_outcome_ids <- function(value) { - ids <- parse_ids(value) - ids <- unique(ids[!is.na(ids)]) - if (length(ids) > 0) return(as.integer(ids)) - if (!isTRUE(interactive)) stop("Missing Outcome.") - - collected <- integer(0) - repeat { - entered <- trimws(readline_with_dialogue("Outcome cohort ID: ")) - parsed <- parse_ids(entered) - parsed <- parsed[!is.na(parsed)] - if (length(parsed) != 1) { - cat("Please enter exactly one outcome cohort ID.\n") - next - } - - outcome_id <- as.integer(parsed[[1]]) - if (outcome_id %in% collected) { - cat(sprintf("Outcome cohort ID %s is already selected.\n", outcome_id)) - } else { - collected <- c(collected, outcome_id) - } - - add_another <- prompt_yesno("Add another outcome cohort id?", default = FALSE) - if (!isTRUE(add_another)) break - } - - if (length(collected) == 0) stop("Outcome must include at least one cohort ID.") - as.integer(collected) - } - - load_catalog <- function(index_dir) { - catalog_path <- file.path(index_dir, "catalog.jsonl") - if (!file.exists(catalog_path)) { - return(data.frame( - cohortId = integer(0), - name = character(0), - short_description = character(0), - stringsAsFactors = FALSE - )) - } - lines <- readLines(catalog_path, warn = FALSE) - lines <- lines[nzchar(trimws(lines))] - if (length(lines) == 0) { - return(data.frame( - cohortId = integer(0), - name = character(0), - short_description = character(0), - stringsAsFactors = FALSE - )) - } - parsed <- lapply(lines, function(line) jsonlite::fromJSON(line, simplifyVector = TRUE)) - parse_catalog_cohort_id <- function(x) { - direct <- suppressWarnings(as.integer(x$cohortId %||% NA_integer_)) - if (!is.na(direct)) return(direct) - phenotype_id <- as.character(x$phenotype_id %||% "") - if (grepl("^ohdsi:[0-9]+$", phenotype_id)) { - return(suppressWarnings(as.integer(sub("^ohdsi:", "", phenotype_id)))) - } - suppressWarnings(as.integer(phenotype_id)) - } - data.frame( - cohortId = vapply(parsed, parse_catalog_cohort_id, integer(1)), - name = vapply(parsed, function(x) x$cohortName %||% x$phenotype_name %||% x$name %||% "", character(1)), - short_description = vapply(parsed, function(x) x$short_description %||% "", character(1)), - stringsAsFactors = FALSE - ) - } - - recommendation_name <- function(rec) { - first_nonempty(rec$cohortName, rec$phenotype_name, rec$name, "") - } - - recommendation_identifier <- function(rec) { - first_nonempty( - as.character(rec$cohortId %||% ""), - as.character(rec$phenotype_id %||% ""), - as.character(rec$id %||% ""), - "" - ) - } - - recommendation_cohort_id <- function(rec) { - direct <- suppressWarnings(as.integer(rec$cohortId %||% NA_integer_)) - if (!is.na(direct)) return(direct) - phenotype_id <- as.character(rec$phenotype_id %||% "") - if (grepl("^ohdsi:[0-9]+$", phenotype_id)) { - return(suppressWarnings(as.integer(sub("^ohdsi:", "", phenotype_id)))) - } - suppressWarnings(as.integer(phenotype_id)) - } - - recommendation_is_ohdsi_computable <- function(rec) { - identifier <- recommendation_identifier(rec) - if (!nzchar(identifier)) return(FALSE) - if (grepl("^[0-9]+$", identifier)) return(TRUE) - grepl("^ohdsi:[0-9]+$", identifier) - } - - recommendation_id_label <- function(rec) { - cohort_id <- recommendation_cohort_id(rec) - if (!is.na(cohort_id)) return(as.character(cohort_id)) - identifier <- recommendation_identifier(rec) - if (nzchar(identifier)) return(identifier) - "?" - } - - unsupported_recommendation_message <- function(rec, role_label) { - identifier <- recommendation_identifier(rec) - if (!nzchar(identifier)) identifier <- "unknown" - sprintf( - paste( - "Selected %s phenotype %s (%s), but this workflow can only continue with a computable OHDSI cohort definition.", - "Descriptive phenotypes such as CIPHER recommendations are not yet convertible to executable cohort JSON in the shell.", - "Choose an OHDSI-backed phenotype for now." - ), - tolower(role_label), - recommendation_name(rec), - identifier - ) - } - - lookup_catalog_value <- function(cohort_id, catalog_df, field = "name", fallback = NULL) { - idx <- which(catalog_df$cohortId == as.integer(cohort_id))[1] - if (!is.na(idx)) { - value <- catalog_df[[field]][[idx]] - if (!is.null(value) && nzchar(trimws(value))) return(value) - } - fallback %||% sprintf("Cohort %s", cohort_id) - } - - format_cohort_selection_summary <- function(selected_ids, catalog_df) { - ids <- as.integer(unique(selected_ids[!is.na(selected_ids)])) - if (length(ids) == 0) return(NULL) - labels <- vapply(ids, function(id) { - sprintf( - "%s (ID %s)", - lookup_catalog_value(id, catalog_df, "name", sprintf("Cohort %s", id)), - id - ) - }, character(1)) - paste(labels, collapse = ", ") - } - - cache_label_with_selection <- function(label, selected_ids, catalog_df) { - selection_summary <- format_cohort_selection_summary(selected_ids, catalog_df) - if (is.null(selection_summary) || !nzchar(trimws(selection_summary))) return(label) - sprintf("%s [%s]", label, selection_summary) - } - - load_cached_role_selection <- function(map_path, role, role_dir) { - if (!file.exists(map_path) || !dir.exists(role_dir)) return(NULL) - payload <- tryCatch(read_json(map_path), error = function(e) NULL) - if (is.null(payload)) return(NULL) - mapping <- payload$mapping %||% payload - is_row_mapping <- is.list(mapping) && - length(mapping) > 0 && - is.list(mapping[[1]]) && - any(names(mapping[[1]]) %in% c("role", "original_id", "cohort_id")) - if (isTRUE(is_row_mapping)) { - roles <- vapply(mapping, function(item) as.character(item$role %||% NA_character_), character(1)) - original_ids <- vapply(mapping, function(item) as.integer(item$original_id %||% NA_integer_), integer(1)) - cohort_ids <- vapply(mapping, function(item) as.integer(item$cohort_id %||% NA_integer_), integer(1)) - } else { - roles <- as.character(unlist(mapping$role %||% character(0), use.names = FALSE)) - original_ids <- as.integer(unlist(mapping$original_id %||% integer(0), use.names = FALSE)) - cohort_ids <- as.integer(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE)) - } - if (!length(roles) || length(roles) != length(original_ids) || length(roles) != length(cohort_ids)) { - return(NULL) - } - keep <- which(roles == role & !is.na(original_ids) & !is.na(cohort_ids)) - if (length(keep) == 0) return(NULL) - selected_ids <- as.integer(unique(original_ids[keep])) - new_ids <- as.integer(cohort_ids[keep]) - cached_files <- file.path(role_dir, sprintf("%s.json", new_ids)) - if (!all(file.exists(cached_files))) return(NULL) - list(selected_ids = selected_ids, new_ids = new_ids) - } - - prompt_statement <- function(label, default = NULL) { - if (!isTRUE(interactive)) return(default) - default_value <- trimws(as.character(default %||% "")) - entered <- readline_with_dialogue(sprintf("%s statement [%s]: ", label, default_value)) - if (nzchar(trimws(entered))) trimws(entered) else default_value - } - - acp_timeout_seconds <- function(default = 180) { - timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) - if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default - timeout_seconds - } - - acp_client_is_ready <- function(client) { - .studyAgentSlashAcpIsConnected(client) - } - - create_acp_client <- function(url, token = NULL, check = TRUE) { - .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) - } - - ensure_workflow_dialogue_client <- function(url) { - if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) - if (is.null(url) || !nzchar(trimws(url))) return(FALSE) - tryCatch({ - dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) - TRUE - }, error = function(e) { - FALSE - }) - } - - ensure_acp_ready <- function(url) { - if (ensure_workflow_dialogue_client(url)) return(TRUE) - has_acp_state <- exists("acp_state", inherits = TRUE) - has_acp_connect <- exists("acp_connect", mode = "function", inherits = TRUE) - has_acp_post <- exists(".acp_post", mode = "function", inherits = TRUE) - if (!has_acp_state || !has_acp_connect || !has_acp_post) return(FALSE) - acp_state_value <- get("acp_state", inherits = TRUE) - if (!is.null(acp_state_value$url)) return(TRUE) - if (is.null(url) || !nzchar(trimws(url))) return(FALSE) - tryCatch({ - acp_connect(url) - TRUE - }, error = function(e) { - FALSE - }) - } - - call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { - if (!acp_client_is_ready(dialogue_acp_client$client)) { - if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") - } - .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) - } - - collect_recommendation_selection <- function(recommendations, role_label, allow_multiple = FALSE) { - if (length(recommendations) == 0) return(integer(0)) - if (!isTRUE(interactive)) { - unsupported <- vapply(recommendations, function(rec) !isTRUE(recommendation_is_ohdsi_computable(rec)), logical(1)) - if (any(unsupported)) { - stop(unsupported_recommendation_message(recommendations[[which(unsupported)[1]]], role_label)) - } - if (isTRUE(allow_multiple)) { - return(as.integer(vapply(recommendations, recommendation_cohort_id, integer(1)))) - } - return(as.integer(recommendation_cohort_id(recommendations[[1]]))) - } - - labels <- vapply(seq_along(recommendations), function(i) { - rec <- recommendations[[i]] - sprintf("%s (ID %s)", recommendation_name(rec), recommendation_id_label(rec)) - }, character(1)) - picks <- utils::select.list( - labels, - multiple = isTRUE(allow_multiple), - title = sprintf("Select %s phenotype%s", tolower(role_label), if (isTRUE(allow_multiple)) "s" else "") - ) - if (!length(picks) || !any(nzchar(picks))) return(integer(0)) - selected_recs <- lapply(picks, function(label) { - idx <- which(labels == label)[1] - recommendations[[idx]] - }) - unsupported <- vapply(selected_recs, function(rec) !isTRUE(recommendation_is_ohdsi_computable(rec)), logical(1)) - if (any(unsupported)) { - stop(unsupported_recommendation_message(selected_recs[[which(unsupported)[1]]], role_label)) - } - selected_ids <- vapply(picks, function(label) { - idx <- which(labels == label)[1] - recommendation_cohort_id(recommendations[[idx]]) - }, numeric(1)) - as.integer(selected_ids[!is.na(selected_ids)]) - } - - run_role_recommendation <- function(role_label, - statement, - output_path, - top_k, - max_results, - candidate_limit, - allow_multiple = FALSE, - preferred_selected_ids = NULL, - preferred_selection_source = "manual_input", - cached_selected_ids = NULL, - selected_cache_label = NULL, - selected_cache_dir = NULL, - cohort_method_cache = NULL, - incidence_cache = NULL, - recommendation_role = NULL, - workflow_type = "cohort_methods", - exclude_metadata = NULL) { - role_key <- tolower(role_label) - recommendation_role <- tolower(trimws(as.character(recommendation_role %||% role_key))) - preferred_selected_ids <- normalize_selected_ids( - preferred_selected_ids, - sprintf("%s cohort ID%s", role_label, if (isTRUE(allow_multiple)) "s" else ""), - allow_multiple = allow_multiple - ) - if (length(preferred_selected_ids) > 0) { - return(list( - selected_ids = preferred_selected_ids, - selection_source = preferred_selection_source %||% "manual_input", - recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), - recommendation_source = "not_run", - used_cached_recommendation = FALSE, - used_cached_selection = FALSE, - used_window2 = FALSE, - used_advice = FALSE, - statement = statement - )) - } - selected_cache_ok <- !is.null(cohort_method_cache$selection$selected_ids) && - length(cohort_method_cache$selection$selected_ids) > 0 && - !is.null(cohort_method_cache$selection$cache_dir) && - dir.exists(cohort_method_cache$selection$cache_dir) - if (isTRUE(selected_cache_ok)) { - cached_selected_ids <- as.integer(unique(cohort_method_cache$selection$selected_ids)) - if (maybe_use_cache( - cohort_method_cache$selection$cache_dir, - cache_label_with_selection( - selected_cache_label %||% sprintf("%s cohort selection", role_key), - cached_selected_ids, - catalog_df - ) - )) { - return(list( - selected_ids = cached_selected_ids, - selection_source = "cohort_method_cached_selected_cohort", - recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), - recommendation_source = if (file.exists(output_path)) "cached_recommendation" else "cached_selected_cohort_only", - used_cached_recommendation = FALSE, - used_cached_selection = TRUE, - used_window2 = FALSE, - used_advice = FALSE, - statement = statement - )) - } - } - incidence_cache_ok <- !is.null(incidence_cache$selection$selected_ids) && - length(incidence_cache$selection$selected_ids) > 0 && - !is.null(incidence_cache$selection$cache_dir) && - dir.exists(incidence_cache$selection$cache_dir) - if (!isTRUE(selected_cache_ok) && isTRUE(incidence_cache_ok)) { - incidence_selected_ids <- as.integer(unique(incidence_cache$selection$selected_ids)) - if (maybe_use_cache( - incidence_cache$selection$cache_dir, - cache_label_with_selection( - incidence_cache$selection$label %||% sprintf("incidence %s cohort selection", role_key), - incidence_selected_ids, - catalog_df - ) - )) { - return(list( - selected_ids = incidence_selected_ids, - selection_source = "incidence_cached_selected_cohort", - recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), - recommendation_source = "incidence_cached_selected_cohort_only", - used_cached_recommendation = FALSE, - used_cached_selection = TRUE, - used_window2 = FALSE, - used_advice = FALSE, - statement = statement - )) - } - } - - set_dialogue_context( - paste0(role_key, "_recommendation"), - recommendation_role, - context = list( - statement = statement, - top_k = top_k, - max_results = max_results, - candidate_limit = candidate_limit, - workflow_type = workflow_type, - exclude_metadata = exclude_metadata - ) - ) - - recommendation_response <- NULL - recommendation_path <- output_path - used_cached_recommendation <- FALSE - used_window2 <- FALSE - used_advice <- FALSE - - if (maybe_use_cache(output_path, sprintf("%s recommendations", role_key))) { - recommendation_response <- read_json(output_path) - used_cached_recommendation <- TRUE - } else if (ensure_acp_ready(acpUrl)) { - body <- list( - study_intent = statement, - top_k = top_k, - max_results = max_results, - candidate_limit = candidate_limit, - recommendation_role = recommendation_role, - workflow_type = workflow_type, - exclude_metadata = exclude_metadata - ) - message(sprintf("Calling ACP flow: phenotype_recommendation (%s)", role_key)) - recommendation_response <- tryCatch( - call_shell_acp_flow("phenotype_recommendation", body), - error = function(e) { - list(status = "error", error = conditionMessage(e)) - } - ) - write_json(recommendation_response, output_path) - } - - recommendations_core <- recommendation_response$recommendations %||% recommendation_response - recommendations <- recommendations_core$phenotype_recommendations %||% list() - no_candidate_reason <- as.character(recommendation_response$fallback_reason %||% recommendations_core$fallback_reason %||% "") - - if (isTRUE(interactive) && length(recommendations) == 0 && !is.null(recommendation_response)) { - cat(sprintf(" -== %s Phenotype Recommendations == -", role_label)) - if (identical(no_candidate_reason, "no_direct_role_match")) { - cat("No sufficiently direct computable OHDSI phenotype match was found for this cohort statement. -") - cat("Enter a cohort ID manually if you want to continue with a known cohort definition. -") - } else if (identical(no_candidate_reason, "no_viable_candidates_after_rerank")) { - cat("No viable phenotype candidates were identified from the current search results. -") - cat("Enter a cohort ID manually if you want to continue with a known cohort definition. -") - } else { - cat("No phenotype recommendations were returned. -") - cat("Enter a cohort ID manually if you want to continue with a known cohort definition. -") - } - } - - if (isTRUE(interactive) && length(recommendations) > 0) { - cat(sprintf("\n== %s Phenotype Recommendations ==\n", role_label)) - for (i in seq_along(recommendations)) { - rec <- recommendations[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, recommendation_name(rec), recommendation_id_label(rec))) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - if (!isTRUE(recommendation_is_ohdsi_computable(rec))) { - cat(" Not directly computable in this workflow; descriptive phenotype conversion is not yet implemented.\n") - } - } - ok_any <- prompt_yesno(sprintf("Are any of these acceptable for the %s?", role_key), default = TRUE) - if (!ok_any && ensure_acp_ready(acpUrl)) { - widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) - if (isTRUE(widen)) { - used_window2 <- TRUE - recommendation_path <- file.path(dirname(output_path), sprintf("%s_window2.json", tools::file_path_sans_ext(basename(output_path)))) - body <- list( - study_intent = statement, - top_k = top_k, - max_results = max_results, - candidate_limit = candidate_limit, - candidate_offset = candidate_limit, - recommendation_role = recommendation_role, - workflow_type = workflow_type, - exclude_metadata = exclude_metadata - ) - message(sprintf("Calling ACP flow: phenotype_recommendation (%s window 2)", role_key)) - recommendation_response <- tryCatch( - call_shell_acp_flow("phenotype_recommendation", body), - error = function(e) { - list(status = "error", error = conditionMessage(e)) - } - ) - write_json(recommendation_response, recommendation_path) - recommendations_core <- recommendation_response$recommendations %||% recommendation_response - recommendations <- recommendations_core$phenotype_recommendations %||% list() - cat(sprintf("\n== %s Phenotype Recommendations (window 2) ==\n", role_label)) - for (i in seq_along(recommendations)) { - rec <- recommendations[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, recommendation_name(rec), recommendation_id_label(rec))) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - if (!isTRUE(recommendation_is_ohdsi_computable(rec))) { - cat(" Not directly computable in this workflow; descriptive phenotype conversion is not yet implemented.\n") - } - } - ok_any <- prompt_yesno(sprintf("Are any of these acceptable for the %s?", role_key), default = TRUE) - } - if (!ok_any) { - used_advice <- TRUE - message(sprintf("Calling ACP flow: phenotype_recommendation_advice (%s)", role_key)) - advice <- tryCatch( - call_shell_acp_flow("phenotype_recommendation_advice", list(study_intent = statement)), - error = function(e) { - list(status = "error", error = conditionMessage(e)) - } - ) - advice_core <- advice$advice %||% advice - cat("\n== Advisory guidance ==\n") - cat(advice_core$advice %||% "", "\n") - if (length(advice_core$next_steps %||% list()) > 0) { - cat("Next steps:\n") - for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) - } - if (length(advice_core$questions %||% list()) > 0) { - cat("Questions to clarify:\n") - for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) - } - checkpoint_label <- checkpoint_label_for_role_advice(role_label) - mark_checkpoint(checkpoint_label, list( - role_label = role_label, - role_key = role_key, - recommendations_path = recommendation_path, - statement = statement - )) - cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") - stop(sprintf( - "Stopping after %s advice. Resume with resume=TRUE once phenotypes are updated.", - role_key - )) - } - } - } - - selected_ids <- collect_recommendation_selection(recommendations, role_label, allow_multiple = allow_multiple) - selected_ids <- as.integer(unique(selected_ids[!is.na(selected_ids)])) - - list( - selected_ids = selected_ids, - selection_source = if (length(selected_ids) > 0) "recommendation" else "none", - recommendation_path = json_string_or_null(if (file.exists(recommendation_path)) recommendation_path else NULL), - recommendation_source = if (used_cached_recommendation) "cached_recommendation" else if (!is.null(recommendation_response)) "acp_flow" else "not_run", - used_cached_recommendation = isTRUE(used_cached_recommendation), - used_cached_selection = FALSE, - used_window2 = isTRUE(used_window2), - used_advice = isTRUE(used_advice), - statement = statement - ) - } - - resolve_index_definition_path <- function(source_id, index_def_dir) { - source_text <- trimws(as.character(source_id %||% "")) - candidates <- character(0) - if (nzchar(source_text)) { - candidates <- c(candidates, file.path(index_def_dir, sprintf("%s.json", source_text))) - if (grepl("^[0-9]+$", source_text)) { - candidates <- c(candidates, file.path(index_def_dir, sprintf("ohdsi__%s.json", source_text))) - } - if (grepl("^[A-Za-z0-9_]+:[A-Za-z0-9_.-]+$", source_text)) { - candidates <- c( - candidates, - file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", source_text, fixed = TRUE))) - ) - } - } - candidates <- unique(candidates[nzchar(candidates)]) - hit <- candidates[file.exists(candidates)][1] - if (length(hit) == 0 || is.na(hit) || !nzchar(hit)) return(NA_character_) - hit - } - - copy_cohort_json_multi <- function(source_id, dest_id, dest_dirs, index_def_dir) { - src <- resolve_index_definition_path(source_id, index_def_dir) - if (is.na(src) || !file.exists(src)) { - stop(sprintf("Cohort JSON not found for source %s in %s", source_id, index_def_dir)) - } - dests <- character(0) - for (dest_dir in dest_dirs) { - ensure_dir(dest_dir) - dest <- file.path(dest_dir, sprintf("%s.json", dest_id)) - file.copy(src, dest, overwrite = TRUE) - dests <- c(dests, dest) - } - dests - } - - run_role_improvements <- function(role_key, - role_label, - cohort_ids, - selected_role_dir, - patched_role_dir, - improvements_path, - role_statement = NULL) { - cohort_ids <- as.integer(cohort_ids) - cohort_ids <- cohort_ids[!is.na(cohort_ids)] - response_by_id <- list() - used_cache <- FALSE - applied_ids <- integer(0) - patched_paths <- character(0) - errors <- list() - flow_called <- FALSE - expected_meta <- list( - role = role_key, - cohort_ids = as.list(cohort_ids), - role_statement = role_statement %||% "", - study_intent = studyIntent - ) - - if (length(cohort_ids) == 0) { - return(list( - role = role_key, - status = "not_applicable", - cohort_ids = as.list(integer(0)), - improvements_path = improvements_path, - used_cache = FALSE, - flow_called = FALSE, - auto_apply = isTRUE(autoApplyImprovements), - applied = FALSE, - applied_ids = as.list(integer(0)), - patched_dir = patched_role_dir, - patched_paths = as.list(character(0)), - errors = list() - )) - } - - if (maybe_use_cache(improvements_path, sprintf("%s improvements", role_key))) { - cached_response <- read_json(improvements_path) - cached_meta <- cached_response$`_meta` %||% list() - cached_ids <- as.integer(unlist(cached_meta$cohort_ids %||% integer(0), use.names = FALSE)) - cache_valid <- identical(as.character(cached_meta$role %||% ""), role_key) && - identical(cached_ids, cohort_ids) && - identical(as.character(cached_meta$role_statement %||% ""), as.character(expected_meta$role_statement)) && - identical(as.character(cached_meta$study_intent %||% ""), as.character(studyIntent)) - if (isTRUE(cache_valid)) { - response_by_id <- cached_response[names(cached_response) != "_meta"] - used_cache <- TRUE - if (interactive) { - cat(sprintf("\nLoaded cached %s improvements from %s\n", role_key, improvements_path)) - } - } else if (interactive) { - cat(sprintf("\nIgnoring stale cached %s improvements at %s\n", role_key, improvements_path)) - } - } - if (!isTRUE(used_cache) && ensure_acp_ready(acpUrl)) { - for (cid in cohort_ids) { - cohort_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - cohort_obj$id <- cid - body <- list( - protocol_text = studyIntent, - role = role_key, - role_statement = role_statement %||% "", - cohorts = list(cohort_obj) - ) - message(sprintf("Calling ACP flow: phenotype_improvements (%s cohort %s)", role_key, cid)) - flow_called <- TRUE - response_by_id[[as.character(cid)]] <- tryCatch( - call_shell_acp_flow("phenotype_improvements", body), - error = function(e) { - err <- list( - status = "error", - error = conditionMessage(e), - flow = "phenotype_improvements", - role = role_key, - cohort_id = as.integer(cid) - ) - errors[[as.character(cid)]] <<- err - err - } - ) - } - response_with_meta <- c(list(`_meta` = expected_meta), response_by_id) - write_json(response_with_meta, improvements_path) - } else if (!isTRUE(used_cache)) { - errors[["acp"]] <- list( - status = "skipped", - error = "ACP bridge unavailable, ACP helpers not loaded, or ACP not connected.", - flow = "phenotype_improvements", - role = role_key - ) - write_json(c(list(`_meta` = expected_meta), response_by_id), improvements_path) - } - - for (cid in names(response_by_id)) { - if (identical(cid, "_meta")) next - set_dialogue_context( - paste0(role_key, "_improvements"), - role_key, - context = list( - role_statement = role_statement, - cohort_id = as.integer(cid), - study_intent = studyIntent - ) - ) - resp <- response_by_id[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() - if (interactive) { - cat(sprintf("\n== Improvements for %s cohort %s ==\n", role_key, cid)) - for (item in items) { - cat(sprintf("- %s\n", item$summary %||% "(no summary)")) - if (!is.null(item$actions)) { - for (act in item$actions) { - cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) - } - } - } - if (length(items) == 0) { - cat(" No improvements returned for this cohort.\n") - } - } - if (length(items) == 0) next - - should_apply <- FALSE - if (interactive) { - should_apply <- prompt_yesno(sprintf("Apply improvements for %s cohort %s now?", role_key, cid), default = FALSE) - } else { - should_apply <- isTRUE(autoApplyImprovements) - } - if (!isTRUE(should_apply)) next - - cohort_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - mutation_count <- 0L - for (item in items) { - if (is.null(item$actions)) next - for (act in item$actions) { - if (is_mutating_improvement_action(act)) { - cohort_obj <- apply_action(cohort_obj, act) - mutation_count <- mutation_count + 1L - } - } - } - if (mutation_count == 0L) next - ensure_dir(patched_role_dir) - ensure_dir(patched_dir) - out_path <- file.path(patched_role_dir, sprintf("%s.json", cid)) - write_json(cohort_obj, out_path) - file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) - applied_ids <- c(applied_ids, as.integer(cid)) - patched_paths <- c(patched_paths, out_path) - if (interactive) { - cat(sprintf("Patched %s cohort saved: %s\n", role_key, out_path)) - } - } - - status <- if (length(errors) > 0 && length(response_by_id) == 0) { - "skipped" - } else if (length(errors) > 0) { - "completed_with_errors" - } else if (length(response_by_id) > 0) { - "completed" - } else { - "not_run" - } - - list( - role = role_key, - label = role_label, - status = status, - cohort_ids = as.list(cohort_ids), - improvements_path = improvements_path, - used_cache = isTRUE(used_cache), - flow_called = isTRUE(flow_called), - auto_apply = isTRUE(autoApplyImprovements), - applied = length(applied_ids) > 0, - applied_ids = as.list(as.integer(unique(applied_ids))), - patched_dir = patched_role_dir, - patched_paths = as.list(patched_paths), - errors = errors - ) - } - - skipped_role_improvements <- function(role_key, - role_label, - cohort_ids, - patched_role_dir, - improvements_path, - reason = "user_skipped") { - cohort_ids <- as.integer(cohort_ids) - cohort_ids <- cohort_ids[!is.na(cohort_ids)] - list( - role = role_key, - label = role_label, - status = "not_run", - reason = reason, - cohort_ids = as.list(cohort_ids), - improvements_path = improvements_path, - used_cache = FALSE, - flow_called = FALSE, - auto_apply = isTRUE(autoApplyImprovements), - applied = FALSE, - applied_ids = as.list(integer(0)), - patched_dir = patched_role_dir, - patched_paths = as.list(character(0)), - errors = list() - ) - } - - run_role_improvement_gate <- function(role_key, - role_label, - cohort_ids, - selected_role_dir, - patched_role_dir, - improvements_path, - role_statement = NULL) { - do_improvements <- TRUE - if (interactive) { - do_improvements <- prompt_yesno( - sprintf("Continue to %s phenotype improvements?", role_key), - default = TRUE - ) - if (isTRUE(do_improvements)) { - cat(sprintf("\n== %s phenotype improvements ==\n", role_label)) - } - } - result <- if (isTRUE(do_improvements)) { - run_role_improvements( - role_key = role_key, - role_label = role_label, - cohort_ids = cohort_ids, - selected_role_dir = selected_role_dir, - patched_role_dir = patched_role_dir, - improvements_path = improvements_path, - role_statement = role_statement - ) - } else { - skipped_role_improvements( - role_key = role_key, - role_label = role_label, - cohort_ids = cohort_ids, - patched_role_dir = patched_role_dir, - improvements_path = improvements_path - ) - } - result$prompt_choice <- isTRUE(do_improvements) - result - } - - clear_json_files <- function(dir_path) { - if (!dir.exists(dir_path)) return(invisible(FALSE)) - files <- list.files(dir_path, pattern = "\\.(json)$", full.names = TRUE) - if (length(files) > 0) unlink(files, force = TRUE) - invisible(TRUE) - } - clear_sql_files <- function(dir_path) { - sql_dir <- file.path(dir_path, "sql") - if (!dir.exists(sql_dir)) return(invisible(FALSE)) - files <- list.files(sql_dir, pattern = "\\.(sql)$", full.names = TRUE) - if (length(files) > 0) unlink(files, force = TRUE) - invisible(TRUE) - } - clear_patched_role_outputs <- function() { - clear_json_files(patched_dir) - clear_json_files(patched_target_dir) - clear_json_files(patched_comparator_dir) - clear_json_files(patched_outcome_dir) - clear_sql_files(patched_dir) - clear_sql_files(patched_target_dir) - clear_sql_files(patched_comparator_dir) - clear_sql_files(patched_outcome_dir) - } - - write_lines <- function(path, lines) { - writeLines(lines, con = path, useBytes = TRUE) - } - - assert_cohort_json_exists <- function(source_id, index_def_dir, label) { - src <- resolve_index_definition_path(source_id, index_def_dir) - if (is.na(src) || !file.exists(src)) { - stop(sprintf("%s cohort JSON not found for source %s in %s", label, source_id, index_def_dir)) - } - invisible(src) - } - - cohort_json_exists <- function(source_id, index_def_dir) { - src <- resolve_index_definition_path(source_id, index_def_dir) - !is.na(src) && file.exists(src) - } - - validate_positive_integer <- function(value, label) { - if (length(value) != 1 || is.na(value) || !is.finite(value) || value <= 0) { - stop(sprintf("%s must be a positive integer.", label)) - } - as.integer(value) - } - - json_int_or_null <- function(value) { - if (is.null(value)) return(NA_integer_) - as.integer(value) - } - - json_string_or_null <- function(value) { - if (is.null(value)) return(NA_character_) - as.character(value) - } - - deep_merge <- function(defaults, overrides) { - if (is.null(overrides)) return(defaults) - for (name in names(overrides)) { - override_value <- overrides[[name]] - default_value <- defaults[[name]] - if (is.list(default_value) && is.list(override_value) && !is.data.frame(override_value)) { - defaults[[name]] <- deep_merge(default_value, override_value) - } else if (!is.null(override_value)) { - defaults[[name]] <- override_value - } - } - defaults - } - - validate_choice <- function(value, choices, label) { - if (length(value) != 1 || is.na(value) || !value %in% choices) { - stop(sprintf("%s must be one of: %s", label, paste(choices, collapse = ", "))) - } - as.character(value) - } - - validate_integer_value <- function(value, label, min_value = NULL) { - parsed <- suppressWarnings(as.integer(value)) - if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { - stop(sprintf("%s must be an integer.", label)) - } - if (!is.null(min_value) && parsed < min_value) { - stop(sprintf("%s must be >= %s.", label, min_value)) - } - as.integer(parsed) - } - - validate_numeric_value <- function(value, label, min_value = NULL) { - parsed <- suppressWarnings(as.numeric(value)) - if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { - stop(sprintf("%s must be numeric.", label)) - } - if (!is.null(min_value) && parsed < min_value) { - stop(sprintf("%s must be >= %s.", label, min_value)) - } - parsed - } - - validate_logical_value <- function(value, label) { - if (length(value) != 1 || is.na(value) || !is.logical(value)) { - stop(sprintf("%s must be TRUE or FALSE.", label)) - } - isTRUE(value) - } - - normalize_analytic_settings <- function(settings) { - validate_date_or_blank <- function(value, label) { - .studyAgentDateStringOrEmpty(value, label) - } - - normalize_optional_concept_set_id <- function(value, label) { - if (is.null(value) || length(value) == 0) return(NA_integer_) - if (length(value) == 1 && is.na(value)) return(NA_integer_) - if (is.character(value) && length(value) == 1) { - normalized <- tolower(trimws(value)) - if (!nzchar(normalized) || normalized %in% c("na", "null", "none")) return(NA_integer_) - } - parsed <- suppressWarnings(as.integer(value)) - if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { - stop(sprintf("%s must be a positive integer or blank.", label)) - } - if (parsed == 0L) return(NA_integer_) - validate_positive_integer(parsed, label) - } - - allowed_sections <- c( - "study_population", - "covariate_settings", - "time_at_risk", - "propensity_score_adjustment", - "outcome_model" - ) - section_aliases <- c(covariates = "covariate_settings") - - profile_name <- trimws(as.character(settings$profile_name %||% "")) - if (!nzchar(profile_name)) { - stop("analytic_settings.profile_name must be a non-empty string.") - } - - customized_sections <- as.character(unlist(settings$customized_sections %||% character(0), use.names = FALSE)) - aliased_sections <- unname(section_aliases[customized_sections]) - customized_sections <- ifelse(is.na(aliased_sections), customized_sections, aliased_sections) - customized_sections <- unique(customized_sections[nzchar(customized_sections)]) - invalid_sections <- setdiff(customized_sections, allowed_sections) - if (length(invalid_sections) > 0) { - stop(sprintf( - "analytic_settings.customized_sections contains unsupported values: %s", - paste(invalid_sections, collapse = ", ") - )) - } - - settings$profile_name <- profile_name - settings$source <- "manual_shell" - settings$customized_sections <- customized_sections - settings$get_db_cohort_method_data$studyStartDate <- validate_date_or_blank( - settings$get_db_cohort_method_data$studyStartDate, - "analytic_settings.get_db_cohort_method_data.studyStartDate" - ) - settings$get_db_cohort_method_data$studyEndDate <- validate_date_or_blank( - settings$get_db_cohort_method_data$studyEndDate, - "analytic_settings.get_db_cohort_method_data.studyEndDate" - ) - settings$get_db_cohort_method_data$firstExposureOnly <- validate_logical_value( - settings$get_db_cohort_method_data$firstExposureOnly, - "analytic_settings.get_db_cohort_method_data.firstExposureOnly" - ) - settings$get_db_cohort_method_data$washoutPeriod <- validate_integer_value( - settings$get_db_cohort_method_data$washoutPeriod, - "analytic_settings.get_db_cohort_method_data.washoutPeriod", - min_value = 0L - ) - settings$get_db_cohort_method_data$restrictToCommonPeriod <- validate_logical_value( - settings$get_db_cohort_method_data$restrictToCommonPeriod, - "analytic_settings.get_db_cohort_method_data.restrictToCommonPeriod" - ) - settings$get_db_cohort_method_data$removeDuplicateSubjects <- validate_choice( - settings$get_db_cohort_method_data$removeDuplicateSubjects, - c("keep all", "keep first", "remove all", "keep first, truncate to second"), - "analytic_settings.get_db_cohort_method_data.removeDuplicateSubjects" - ) - settings$create_study_population$removeDuplicateSubjects <- validate_choice( - settings$create_study_population$removeDuplicateSubjects, - c("keep all", "keep first", "remove all"), - "analytic_settings.create_study_population.removeDuplicateSubjects" - ) - settings$create_study_population$maxCohortSize <- validate_integer_value( - settings$create_study_population$maxCohortSize, - "analytic_settings.create_study_population.maxCohortSize", - min_value = 0L - ) - settings$create_study_population$removeSubjectsWithPriorOutcome <- validate_logical_value( - settings$create_study_population$removeSubjectsWithPriorOutcome, - "analytic_settings.create_study_population.removeSubjectsWithPriorOutcome" - ) - settings$create_study_population$priorOutcomeLookback <- validate_integer_value( - settings$create_study_population$priorOutcomeLookback, - "analytic_settings.create_study_population.priorOutcomeLookback", - min_value = 0L - ) - settings$create_study_population$minDaysAtRisk <- validate_integer_value( - settings$create_study_population$minDaysAtRisk, - "analytic_settings.create_study_population.minDaysAtRisk", - min_value = 0L - ) - settings$create_study_population$riskWindowStart <- validate_integer_value( - settings$create_study_population$riskWindowStart, - "analytic_settings.create_study_population.riskWindowStart" - ) - settings$create_study_population$startAnchor <- validate_choice( - settings$create_study_population$startAnchor, - c("cohort start", "cohort end"), - "analytic_settings.create_study_population.startAnchor" - ) - settings$create_study_population$riskWindowEnd <- validate_integer_value( - settings$create_study_population$riskWindowEnd, - "analytic_settings.create_study_population.riskWindowEnd" - ) - settings$create_study_population$endAnchor <- validate_choice( - settings$create_study_population$endAnchor, - c("cohort start", "cohort end"), - "analytic_settings.create_study_population.endAnchor" - ) - settings$create_study_population$censorAtNewRiskWindow <- validate_logical_value( - settings$create_study_population$censorAtNewRiskWindow, - "analytic_settings.create_study_population.censorAtNewRiskWindow" - ) - settings$create_ps$estimator <- validate_choice( - settings$create_ps$estimator, - c("att", "ate"), - "analytic_settings.create_ps.estimator" - ) - settings$create_ps$maxCohortSizeForFitting <- validate_integer_value( - settings$create_ps$maxCohortSizeForFitting, - "analytic_settings.create_ps.maxCohortSizeForFitting", - min_value = 0L - ) - settings$create_ps$errorOnHighCorrelation <- validate_logical_value( - settings$create_ps$errorOnHighCorrelation, - "analytic_settings.create_ps.errorOnHighCorrelation" - ) - settings$create_ps$useRegularization <- validate_logical_value( - settings$create_ps$useRegularization, - "analytic_settings.create_ps.useRegularization" - ) - settings$match_on_ps$caliper <- validate_numeric_value( - settings$match_on_ps$caliper, - "analytic_settings.match_on_ps.caliper", - min_value = 0 - ) - settings$match_on_ps$caliperScale <- validate_choice( - settings$match_on_ps$caliperScale, - c("propensity score", "standardized", "standardized logit"), - "analytic_settings.match_on_ps.caliperScale" - ) - settings$match_on_ps$maxRatio <- validate_integer_value( - settings$match_on_ps$maxRatio, - "analytic_settings.match_on_ps.maxRatio", - min_value = 0L - ) - settings$ps_adjustment$strategy <- validate_choice( - settings$ps_adjustment$strategy, - c("match_on_ps", "stratify_by_ps", "none"), - "analytic_settings.ps_adjustment.strategy" - ) - settings$ps_adjustment$trimmingStrategy <- validate_choice( - settings$ps_adjustment$trimmingStrategy, - c("none", "by_percent", "by_equipoise"), - "analytic_settings.ps_adjustment.trimmingStrategy" - ) - settings$ps_adjustment$trimmingPercent <- validate_numeric_value( - settings$ps_adjustment$trimmingPercent, - "analytic_settings.ps_adjustment.trimmingPercent", - min_value = 0 - ) - if (settings$ps_adjustment$trimmingPercent >= 50) { - stop("analytic_settings.ps_adjustment.trimmingPercent must be < 50.") - } - settings$ps_adjustment$equipoiseLowerBound <- validate_numeric_value( - settings$ps_adjustment$equipoiseLowerBound, - "analytic_settings.ps_adjustment.equipoiseLowerBound", - min_value = 0 - ) - settings$ps_adjustment$equipoiseUpperBound <- validate_numeric_value( - settings$ps_adjustment$equipoiseUpperBound, - "analytic_settings.ps_adjustment.equipoiseUpperBound", - min_value = 0 - ) - if (settings$ps_adjustment$equipoiseLowerBound >= settings$ps_adjustment$equipoiseUpperBound || - settings$ps_adjustment$equipoiseUpperBound > 1) { - stop("analytic_settings.ps_adjustment equipoise bounds must satisfy 0 <= lower < upper <= 1.") - } - settings$stratify_by_ps$numberOfStrata <- validate_integer_value( - settings$stratify_by_ps$numberOfStrata, - "analytic_settings.stratify_by_ps.numberOfStrata", - min_value = 1L - ) - settings$stratify_by_ps$baseSelection <- validate_choice( - settings$stratify_by_ps$baseSelection, - c("all", "target", "comparator"), - "analytic_settings.stratify_by_ps.baseSelection" - ) - settings$fit_outcome_model$modelType <- validate_choice( - settings$fit_outcome_model$modelType, - c("cox", "logistic", "poisson"), - "analytic_settings.fit_outcome_model.modelType" - ) - settings$fit_outcome_model$stratified <- validate_logical_value( - settings$fit_outcome_model$stratified, - "analytic_settings.fit_outcome_model.stratified" - ) - settings$fit_outcome_model$useCovariates <- validate_logical_value( - settings$fit_outcome_model$useCovariates, - "analytic_settings.fit_outcome_model.useCovariates" - ) - settings$fit_outcome_model$inversePtWeighting <- validate_logical_value( - settings$fit_outcome_model$inversePtWeighting, - "analytic_settings.fit_outcome_model.inversePtWeighting" - ) - settings$fit_outcome_model$useRegularization <- validate_logical_value( - settings$fit_outcome_model$useRegularization, - "analytic_settings.fit_outcome_model.useRegularization" - ) - settings$covariate_concept_sets$enabled <- validate_logical_value( - settings$covariate_concept_sets$enabled, - "analytic_settings.covariate_concept_sets.enabled" - ) - settings$covariate_concept_sets$include_all_concepts <- validate_logical_value( - settings$covariate_concept_sets$include_all_concepts, - "analytic_settings.covariate_concept_sets.include_all_concepts" - ) - - include_id <- settings$covariate_concept_sets$include_concept_set_id - exclude_id <- settings$covariate_concept_sets$exclude_concept_set_id - settings$covariate_concept_sets$include_concept_set_id <- normalize_optional_concept_set_id( - include_id, - "analytic_settings.covariate_concept_sets.include_concept_set_id" - ) - settings$covariate_concept_sets$exclude_concept_set_id <- normalize_optional_concept_set_id( - exclude_id, - "analytic_settings.covariate_concept_sets.exclude_concept_set_id" - ) - - settings - } - - collect_text_value <- function(value, prompt, default = "") { - current <- value %||% default - if (!isTRUE(interactive)) return(current) - entered <- readline_with_dialogue(sprintf("%s [%s]: ", prompt, current)) - if (nzchar(trimws(entered))) entered else current - } - - collect_choice_value <- function(value, label, choices, prompt = NULL, default = NULL) { - current <- value %||% default %||% choices[[1]] - if (!current %in% choices) current <- default %||% choices[[1]] - if (!isTRUE(interactive)) return(current) - - cat(sprintf("%s\n", prompt %||% label)) - for (i in seq_along(choices)) { - marker <- if (identical(choices[[i]], current)) " [default]" else "" - cat(sprintf(" %s. %s%s\n", i, choices[[i]], marker)) - } - - repeat { - entered <- trimws(readline_with_dialogue(sprintf("Select option [%s]: ", match(current, choices)))) - if (!nzchar(entered)) return(current) - option_idx <- suppressWarnings(as.integer(entered)) - if (!is.na(option_idx) && option_idx >= 1 && option_idx <= length(choices)) { - return(choices[[option_idx]]) - } - if (entered %in% choices) return(entered) - cat(sprintf("Please enter one of: %s\n", paste(seq_along(choices), collapse = ", "))) - } - } - - collect_integer_value <- function(value, label, prompt, default = NULL, min_value = NULL) { - current <- value %||% default - current <- validate_integer_value(current, label, min_value = min_value) - if (!isTRUE(interactive)) return(current) - - repeat { - entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, current))) - if (!nzchar(entered)) return(current) - parsed <- suppressWarnings(as.integer(entered)) - if (!is.na(parsed) && (is.null(min_value) || parsed >= min_value)) { - return(as.integer(parsed)) - } - if (is.null(min_value)) { - cat(sprintf("%s must be an integer.\n", label)) - } else { - cat(sprintf("%s must be an integer >= %s.\n", label, min_value)) - } - } - } - - collect_numeric_value <- function(value, label, prompt, default = NULL, min_value = NULL) { - current <- value %||% default - current <- validate_numeric_value(current, label, min_value = min_value) - if (!isTRUE(interactive)) return(current) - - repeat { - entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, format(current, trim = TRUE, scientific = FALSE)))) - if (!nzchar(entered)) return(current) - parsed <- suppressWarnings(as.numeric(entered)) - if (!is.na(parsed) && (is.null(min_value) || parsed >= min_value)) { - return(parsed) - } - if (is.null(min_value)) { - cat(sprintf("%s must be numeric.\n", label)) - } else { - cat(sprintf("%s must be numeric >= %s.\n", label, min_value)) - } - } - } - - review_analytic_settings_interactively <- function(settings) { - if (!isTRUE(interactive)) return(settings) - section_paths <- .studyAgentAnalyticSettingsSectionPaths() - repeat { - .studyAgentPrintFinalSettingsSummary(settings, section_paths) - if (prompt_yesno_strict("Use these analytic settings?", default = TRUE)) { - return(settings) - } - - review_path <- file.path(output_dir, "analytic_settings_review.txt") - .studyAgentWriteAnalyticSettingsReviewFile(settings, section_paths, review_path) - cat(sprintf("\nOpening analytic settings review file:\n %s\n", review_path)) - cat("Edit values after ':', save the file, close the editor, then return here.\n") - tryCatch( - utils::file.edit(review_path), - error = function(e) { - cat(sprintf("Could not open editor automatically: %s\n", conditionMessage(e))) - cat("Open the file manually, edit it, save it, then return here.\n") - } - ) - - repeat { - entered <- tolower(trimws(readline_with_dialogue("Press Enter after saving, or type 'r' to reopen the file: "))) - if (identical(entered, "r")) { - tryCatch( - utils::file.edit(review_path), - error = function(e) cat(sprintf("Could not open editor: %s\n", conditionMessage(e))) - ) - next - } - break - } - - parsed <- tryCatch( - normalize_analytic_settings(.studyAgentReadAnalyticSettingsReviewFile(review_path, settings)), - error = function(e) e - ) - if (inherits(parsed, "error")) { - cat(sprintf("\nAnalytic settings validation failed: %s\n", conditionMessage(parsed))) - cat("Please edit the review file and try again.\n") - next - } - settings <- parsed - } - } - - flatten_named_values <- function(x, prefix = NULL) { - if (is.list(x) && !is.data.frame(x)) { - pieces <- unlist( - lapply(names(x), function(name) { - key <- if (is.null(prefix) || !nzchar(prefix)) name else paste(prefix, name, sep = ".") - flatten_named_values(x[[name]], key) - }), - recursive = FALSE, - use.names = FALSE - ) - return(pieces) - } - - value <- if (length(x) == 0 || all(is.na(x))) { - "null" - } else if (length(x) > 1) { - paste(as.character(x), collapse = ", ") - } else { - as.character(x) - } - - stats::setNames(list(value), prefix %||% "value") - } - - shell_settings_from_acp_recommendation <- function(recommendation, defaults_snapshot) { - settings <- defaults_snapshot %||% list() - settings$profile_name <- as.character( - recommendation$profile_name %||% settings$profile_name %||% "Recommended from ACP" - ) - settings$source <- "acp_flow" - - study_population <- recommendation$study_population %||% list() - cohort_method_data_args <- study_population$cohortMethodDataArgs %||% list() - if (length(cohort_method_data_args) > 0) { - settings$get_db_cohort_method_data <- utils::modifyList( - settings$get_db_cohort_method_data %||% list(), - cohort_method_data_args - ) - } - - create_study_population <- study_population - create_study_population$cohortMethodDataArgs <- NULL - time_at_risk <- recommendation$time_at_risk %||% list() - if (length(create_study_population) > 0 || length(time_at_risk) > 0) { - settings$create_study_population <- utils::modifyList( - settings$create_study_population %||% list(), - c(create_study_population, time_at_risk) - ) - } - - ps <- recommendation$propensity_score_adjustment %||% list() - create_ps <- ps$createPsArgs %||% list() - if (length(create_ps) > 0) { - settings$create_ps <- utils::modifyList( - settings$create_ps %||% list(), - list( - maxCohortSizeForFitting = create_ps$maxCohortSizeForFitting %||% settings$create_ps$maxCohortSizeForFitting, - errorOnHighCorrelation = create_ps$errorOnHighCorrelation %||% settings$create_ps$errorOnHighCorrelation, - useRegularization = !is.null(create_ps$prior) - ) - ) - } - - trim_args <- ps$trimByPsArgs - match_args <- ps$matchOnPsArgs - stratify_args <- ps$stratifyByPsArgs - settings$ps_adjustment <- utils::modifyList( - settings$ps_adjustment %||% list(), - list( - strategy = if (!is.null(match_args)) { - "match_on_ps" - } else if (!is.null(stratify_args)) { - "stratify_by_ps" - } else { - "none" - }, - trimmingStrategy = if (is.null(trim_args)) { - "none" - } else if (!is.null(trim_args$equipoiseBounds)) { - "by_equipoise" - } else { - "by_percent" - }, - trimmingPercent = if (!is.null(trim_args$trimFraction)) { - as.numeric(trim_args$trimFraction) * 100 - } else { - settings$ps_adjustment$trimmingPercent %||% 5 - }, - equipoiseLowerBound = if (!is.null(trim_args$equipoiseBounds) && length(trim_args$equipoiseBounds) >= 1) { - as.numeric(trim_args$equipoiseBounds[[1]]) - } else { - settings$ps_adjustment$equipoiseLowerBound %||% 0.25 - }, - equipoiseUpperBound = if (!is.null(trim_args$equipoiseBounds) && length(trim_args$equipoiseBounds) >= 2) { - as.numeric(trim_args$equipoiseBounds[[2]]) - } else { - settings$ps_adjustment$equipoiseUpperBound %||% 0.75 - } - ) - ) - if (!is.null(match_args)) { - settings$match_on_ps <- utils::modifyList(settings$match_on_ps %||% list(), match_args) - } - if (!is.null(stratify_args)) { - settings$stratify_by_ps <- utils::modifyList(settings$stratify_by_ps %||% list(), stratify_args) - } - - outcome_model <- recommendation$outcome_model %||% list() - if (length(outcome_model) > 0) { - settings$fit_outcome_model <- utils::modifyList( - settings$fit_outcome_model %||% list(), - list( - modelType = outcome_model$modelType %||% settings$fit_outcome_model$modelType, - stratified = outcome_model$stratified %||% settings$fit_outcome_model$stratified, - useCovariates = outcome_model$useCovariates %||% settings$fit_outcome_model$useCovariates, - inversePtWeighting = outcome_model$inversePtWeighting %||% settings$fit_outcome_model$inversePtWeighting, - useRegularization = !is.null(outcome_model$prior) - ) - ) - } - - settings - } - - print_analytic_settings_recommendation_preview <- function(acp_response, - recommendation, - recommendation_path = NULL, - acp_response_path = NULL) { - response <- acp_response$response %||% list() - diagnostics <- response$diagnostics %||% acp_response$diagnostics %||% list() - source <- as.character(acp_response$source %||% recommendation$source %||% "unknown") - wrapper_status <- as.character(acp_response$status %||% "unknown") - flow_status <- as.character(response$status %||% wrapper_status) - - acp_success <- identical(source, "acp_flow") && identical(flow_status, "ok") - - if (isTRUE(acp_success)) { - cat("\nAnalytic settings recommendation from ACP\n") - failed_sections <- diagnostics$failed_sections %||% list() - if (length(failed_sections) > 0) { - cat(sprintf(" - Backfilled sections: %s\n", paste(unlist(failed_sections), collapse = ", "))) - } - - rationales <- response$section_rationales %||% acp_response$section_rationales %||% list() - if (length(rationales) > 0) { - cat("\nRationales\n") - for (section in names(rationales)) { - entry <- rationales[[section]] - section_title <- .studyAgentAnalyticSettingsSectionTitles()[[section]] %||% section - cat(sprintf( - "[%s] confidence=%s\n%s\n", - section_title, - entry$confidence %||% "?", - entry$rationale %||% "" - )) - } - } - } else { - cat("\nCohort Method analytic settings recommendation could not be generated by ACP.\n") - cat("Using the current default analytic settings for now.\n") - reason <- diagnostics$reason %||% response$error %||% acp_response$error %||% NULL - if (!is.null(reason) && nzchar(as.character(reason))) { - cat(sprintf("Reason: %s\n", as.character(reason))) - } else { - message <- acp_response$message %||% acp_response$error %||% diagnostics$reason %||% NULL - if (!is.null(message) && nzchar(as.character(message))) { - cat(sprintf("Reason: %s\n", as.character(message))) - } - } - detail_paths <- c( - if (!is.null(acp_response_path) && nzchar(as.character(acp_response_path))) as.character(acp_response_path) else NULL, - if (!is.null(recommendation_path) && nzchar(as.character(recommendation_path))) as.character(recommendation_path) else NULL - ) - if (length(detail_paths) > 0) { - cat("Details saved to:\n") - for (path in detail_paths) { - cat(sprintf(" - %s\n", path)) - } - } - } - } - - build_dummy_analytic_settings_recommendation <- function(description, defaults_snapshot, input_method = "typed_text") { - list( - mode = "free_text", - input_method = input_method, - source = "manual_shell", - status = "dummy_generated", - profile_name = "Recommended from free-text description", - raw_description = description, - study_population = "TODO: derive study population settings from free-text description", - time_at_risk = "TODO: derive time-at-risk settings from free-text description", - propensity_score_adjustment = "TODO: derive propensity score adjustment settings from free-text description", - outcome_model = "TODO: derive outcome model settings from free-text description", - deferred_inputs = list( - function_argument_description = "implemented", - description_file_path = "implemented", - interactive_typed_description = "implemented" - ), - defaults_snapshot = defaults_snapshot - ) - } - - call_cohort_methods_specifications_recommendation <- function(acp_url, - body, - defaults_snapshot, - input_method = "typed_text") { - flow_name <- "cohort_methods_specifications_recommendation" - dummy_recommendation <- build_dummy_analytic_settings_recommendation( - description = body$analytic_settings_description %||% body$study_description %||% "", - defaults_snapshot = defaults_snapshot, - input_method = input_method - ) - - ensure_connected <- function(url) { - has_acp_state <- exists("acp_state", inherits = TRUE) - has_acp_connect <- exists("acp_connect", mode = "function", inherits = TRUE) - if (!has_acp_state || !has_acp_connect) return(FALSE) - if (!is.null(get("acp_state", inherits = TRUE)$url)) return(TRUE) - if (is.null(url) || !nzchar(trimws(url))) return(FALSE) - tryCatch({ - acp_connect(url) - TRUE - }, error = function(e) { - FALSE - }) - } - - use_acp <- ensure_connected(acp_url) - has_acp_post <- exists(".acp_post", mode = "function", inherits = TRUE) - if (!isTRUE(use_acp) || !has_acp_post) { - return(list( - flow = flow_name, - source = "stub_acp_placeholder", - status = "stub", - message = "ACP bridge unavailable, ACP helpers not loaded, or ACP not connected. Returning placeholder cohort methods specifications recommendation.", - request = body, - recommendation = dummy_recommendation - )) - } - - response <- tryCatch( - call_shell_acp_flow(flow_name, body, url = acp_url), - error = function(e) { - list( - flow = flow_name, - source = "stub_acp_placeholder", - status = "stub", - error = conditionMessage(e), - message = "ACP flow failed. Returning placeholder cohort methods specifications recommendation.", - request = body, - recommendation = dummy_recommendation - ) - } - ) - - if (is.list(response) && identical(response$source, "stub_acp_placeholder")) { - return(response) - } - - recommendation <- response$recommendation %||% - response$recommendations %||% - response$cohort_methods_specifications_recommendation %||% - dummy_recommendation - if (!is.list(recommendation)) recommendation <- dummy_recommendation - - list( - flow = flow_name, - source = "acp_flow", - status = "received", - request = body, - response = response, - recommendation = recommendation - ) - } - - study_base_dir <- "" - if (nzchar(studyAgentBaseDir)) { - study_base_dir <- normalizePath(studyAgentBaseDir, winslash = "/", mustWork = FALSE) - } - - if (!is.null(analyticSettingsDescription)) { - analyticSettingsDescription <- trimws(as.character(analyticSettingsDescription)) - if (!nzchar(analyticSettingsDescription)) analyticSettingsDescription <- NULL - } - if (!is.null(analyticSettingsDescriptionPath)) { - analyticSettingsDescriptionPath <- trimws(as.character(analyticSettingsDescriptionPath)) - if (!nzchar(analyticSettingsDescriptionPath)) analyticSettingsDescriptionPath <- NULL - } - - outputDir <- resolve_path(outputDir, study_base_dir) - outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) - if (isTRUE(reset) && dir.exists(outputDir)) { - ok <- TRUE - if (isTRUE(interactive)) { - ok <- prompt_yesno(sprintf("Delete existing output directory %s?", outputDir), default = FALSE) - } - if (ok) unlink(outputDir, recursive = TRUE, force = TRUE) - } - - base_dir <- outputDir - incidence_base_dir <- resolve_path(incidenceOutputDir, study_base_dir) - incidence_base_dir <- normalizePath(incidence_base_dir, winslash = "/", mustWork = FALSE) - index_dir <- resolve_path(indexDir, study_base_dir) - index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) - catalog_df <- load_catalog(index_dir) - analytic_settings_description_path_resolved <- if (is.null(analyticSettingsDescriptionPath)) { - NULL - } else { - normalizePath(resolve_path(analyticSettingsDescriptionPath, study_base_dir), winslash = "/", mustWork = FALSE) - } - if (!dir.exists(index_dir) && !is_absolute_path(indexDir) && !nzchar(studyAgentBaseDir)) { - alt <- file.path(getwd(), "OHDSI-Study-Agent", indexDir) - if (dir.exists(alt)) index_dir <- normalizePath(alt, winslash = "/", mustWork = FALSE) - } - index_def_dir <- file.path(index_dir, "definitions") - if (!dir.exists(index_def_dir)) stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) - - output_dir <- file.path(base_dir, "outputs") - selected_dir <- file.path(base_dir, "selected-cohorts") - patched_dir <- file.path(base_dir, "patched-cohorts") - selected_target_dir <- file.path(base_dir, "selected-target-cohorts") - selected_comparator_dir <- file.path(base_dir, "selected-comparator-cohorts") - selected_outcome_dir <- file.path(base_dir, "selected-outcome-cohorts") - patched_target_dir <- file.path(base_dir, "patched-target-cohorts") - patched_comparator_dir <- file.path(base_dir, "patched-comparator-cohorts") - patched_outcome_dir <- file.path(base_dir, "patched-outcome-cohorts") - concept_sets_dir <- file.path(base_dir, "concept-sets") - keeper_dir <- file.path(base_dir, "keeper-case-review") - analysis_settings_dir <- file.path(base_dir, "analysis-settings") - scripts_dir <- file.path(base_dir, "scripts") - cm_results_dir <- file.path(base_dir, "cm-results") - cm_diagnostics_dir <- file.path(base_dir, "cm-diagnostics") - cm_data_dir <- file.path(base_dir, "cm-data") - - dirs <- c( - output_dir, selected_dir, patched_dir, selected_target_dir, selected_comparator_dir, - selected_outcome_dir, patched_target_dir, patched_comparator_dir, patched_outcome_dir, - concept_sets_dir, - keeper_dir, analysis_settings_dir, scripts_dir, cm_results_dir, cm_diagnostics_dir, - cm_data_dir - ) - for (dir_path in dirs) ensure_dir(dir_path) - - manual_intent_path <- file.path(output_dir, "manual_intent.json") - manual_inputs_path <- file.path(output_dir, "manual_inputs.json") - cohort_methods_intent_split_path <- file.path(output_dir, "cohort_methods_intent_split.json") - cohort_roles_path <- file.path(output_dir, "cohort_roles.json") - cohort_id_map_path <- file.path(output_dir, "cohort_id_map.json") - incidence_cohort_id_map_path <- file.path(incidence_base_dir, "outputs", "cohort_id_map.json") - incidence_selected_target_dir <- file.path(incidence_base_dir, "selected-target-cohorts") - incidence_selected_outcome_dir <- file.path(incidence_base_dir, "selected-outcome-cohorts") - cm_comparisons_path <- file.path(output_dir, "cm_comparisons.json") - improvements_status_path <- file.path(output_dir, "improvements_status.json") - cm_evaluation_todo_path <- file.path(output_dir, "cm_evaluation_todo.json") - cm_defaults_path <- file.path(output_dir, "cm_analysis_defaults.json") - cm_analysis_json_path <- file.path(analysis_settings_dir, "cmAnalysis.json") - cm_analysis_template_path <- system.file("templates", "cmAnalysis_template.json", package = "OHDSIAssistant") - if (!nzchar(cm_analysis_template_path)) { - cm_analysis_template_path <- resolve_path("mcp_server/prompts/cohort_methods/cmAnalysis_template.json", study_base_dir) - cm_analysis_template_path <- normalizePath(cm_analysis_template_path, winslash = "/", mustWork = FALSE) - } - if (!file.exists(cm_analysis_template_path)) { - cm_analysis_template_path <- NA_character_ - } - cm_acp_specifications_recommendation_path <- file.path(output_dir, "cm_acp_specifications_recommendation.json") - cm_analytic_settings_recommendation_path <- file.path(output_dir, "cm_analytic_settings_recommendation.json") - cm_concept_set_selections_path <- file.path(output_dir, "cm_concept_set_selections.json") - recs_target_path <- file.path(output_dir, "recommendations_target.json") - recs_comparator_path <- file.path(output_dir, "recommendations_comparator.json") - recs_outcome_path <- file.path(output_dir, "recommendations_outcome.json") - improvements_target_path <- file.path(output_dir, "improvements_target.json") - improvements_comparator_path <- file.path(output_dir, "improvements_comparator.json") - improvements_outcome_path <- file.path(output_dir, "improvements_outcome.json") - state_path <- file.path(output_dir, "study_agent_state.json") - - cached_inputs <- NULL - cached_manual_intent <- NULL - cached_cm_target_selection <- load_cached_role_selection(cohort_id_map_path, "target", selected_target_dir) - cached_cm_comparator_selection <- load_cached_role_selection(cohort_id_map_path, "comparator", selected_comparator_dir) - cached_cm_outcome_selection <- load_cached_role_selection(cohort_id_map_path, "outcome", selected_outcome_dir) - cached_incidence_target_selection <- load_cached_role_selection(incidence_cohort_id_map_path, "target", incidence_selected_target_dir) - cached_incidence_outcome_selection <- load_cached_role_selection(incidence_cohort_id_map_path, "outcome", incidence_selected_outcome_dir) - - if (interactive) { - banner_path <- resolve_path(bannerPath, study_base_dir) - banner_path <- normalizePath(banner_path, winslash = "/", mustWork = FALSE) - if (!file.exists(banner_path) && !is_absolute_path(bannerPath) && !nzchar(studyAgentBaseDir)) { - alt <- file.path(getwd(), "OHDSI-Study-Agent", bannerPath) - if (file.exists(alt)) banner_path <- normalizePath(alt, winslash = "/", mustWork = FALSE) - } - if (file.exists(banner_path)) { - cat(paste(readLines(banner_path, warn = FALSE), collapse = "\n"), "\n") - } - cat("\nStudy Agent: Strategus CohortMethod shell\n") - } - - default_intent <- studyIntent %||% cached_inputs$study_intent %||% - "Compare a target exposure versus a comparator exposure on one or more outcomes using a cohort method design." - if (isTRUE(interactive)) { - set_dialogue_context("study_intent", context = list(default_intent = default_intent)) - entered <- readline_with_dialogue(sprintf("Study intent [%s]: ", default_intent)) - if (nzchar(trimws(entered))) { - studyIntent <- entered - } else { - studyIntent <- default_intent - } - } else if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { - studyIntent <- default_intent - } - - nonempty_string <- function(value) { - !is.null(value) && length(value) > 0 && nzchar(trimws(as.character(value[[1]]))) - } - first_nonempty <- function(...) { - values <- list(...) - for (value in values) { - if (nonempty_string(value)) return(trimws(as.character(value[[1]]))) - } - NULL - } - normalize_statement_list <- function(value) { - if (is.null(value)) return(character(0)) - if (is.data.frame(value)) value <- unlist(value, recursive = TRUE, use.names = FALSE) - if (is.list(value) && !is.character(value)) value <- unlist(value, recursive = TRUE, use.names = FALSE) - values <- trimws(as.character(value)) - values <- values[!is.na(values) & nzchar(values)] - unique(values) - } - statement_dedupe_key <- function(value) { - key <- tolower(trimws(as.character(value))) - key <- gsub("[[:space:]]+", " ", key) - key <- gsub("[[:punct:]]+$", "", key) - key - } - dedupe_statement_list <- function(statements) { - statements <- normalize_statement_list(statements) - if (length(statements) == 0) return(character(0)) - keys <- statement_dedupe_key(statements) - statements[!duplicated(keys)] - } - combine_statement_list <- function(primary = NULL, ...) { - statements <- normalize_statement_list(primary) - for (value in list(...)) { - statements <- c(statements, normalize_statement_list(value)) - } - dedupe_statement_list(statements) - } - prompt_outcome_statements <- function(defaults) { - defaults <- dedupe_statement_list(defaults) - if (!isTRUE(interactive)) return(defaults) - if (length(defaults) > 1) { - cat("\nSuggested outcome statements:\n") - for (i in seq_along(defaults)) { - cat(sprintf(" %s. %s\n", i, defaults[[i]])) - } - default_selection <- paste(seq_along(defaults), collapse = ",") - use_manual_outcome <- FALSE - repeat { - entered <- trimws(readline_with_dialogue(sprintf( - "Keep outcome statements [%s] (comma-separated numbers, 0/none to enter manually, Enter keeps all): ", - default_selection - ))) - if (!nzchar(entered)) { - selected <- seq_along(defaults) - } else if (tolower(entered) %in% c("a", "all")) { - selected <- seq_along(defaults) - } else if (tolower(entered) %in% c("0", "n", "none")) { - selected <- integer(0) - use_manual_outcome <- TRUE - } else { - selected <- suppressWarnings(parse_ids(entered)) - selected <- unique(selected[!is.na(selected)]) - } - invalid <- setdiff(selected, seq_along(defaults)) - if (!isTRUE(use_manual_outcome) && (length(selected) == 0 || length(invalid) > 0)) { - cat(sprintf("Please enter one or more valid outcome numbers, such as 1 or 1,3, or 0/none to enter manually. Valid choices: %s\n", default_selection)) - next - } - defaults <- defaults[selected] - break - } - cat("Press Enter to keep each selected statement, or type an edited statement.\n") - } - if (length(defaults) == 0) { - entered <- prompt_statement("Outcome", default = "") - return(dedupe_statement_list(entered)) - } - resolved <- vapply(seq_along(defaults), function(i) { - label <- if (length(defaults) == 1) "Outcome" else sprintf("Outcome %s", i) - prompt_statement(label, default = defaults[[i]]) - }, character(1)) - repeat { - add_another <- prompt_yesno("Add another outcome statement?", default = FALSE) - if (!isTRUE(add_another)) break - next_label <- sprintf("Outcome %s", length(resolved) + 1L) - resolved <- c(resolved, prompt_statement(next_label, default = "")) - } - dedupe_statement_list(resolved) - } - summarize_intent_split_error <- function(split_core) { - error_text <- as.character(split_core$error %||% split_core$message %||% "") - if (!nzchar(trimws(error_text))) return("unknown error") - nested_text <- trimws(sub("^ACP error:\\s*", "", error_text)) - if (grepl("^\\{", nested_text)) { - nested <- tryCatch(jsonlite::fromJSON(nested_text, simplifyVector = FALSE), error = function(e) NULL) - if (is.list(nested)) { - diagnostics <- nested$diagnostics %||% list() - parts <- c( - nested$error %||% nested$status, - diagnostics$llm_status, - diagnostics$llm_error - ) - parts <- as.character(parts[!vapply(parts, is.null, logical(1))]) - parts <- parts[nzchar(trimws(parts))] - if (length(parts) > 0) return(paste(unique(parts), collapse = " / ")) - } - } - error_text - } - - target_statement_default <- first_nonempty( - targetStatement, - cached_manual_intent$target_statement, - cached_inputs$target_statement - ) - comparator_statement_default <- first_nonempty( - comparatorStatement, - cached_manual_intent$comparator_statement, - cached_inputs$comparator_statement - ) - outcome_statement_default <- first_nonempty( - outcomeStatement, - cached_manual_intent$outcome_statement, - cached_inputs$outcome_statement, - normalize_statement_list(cached_manual_intent$outcome_statements), - normalize_statement_list(cached_inputs$outcome_statements) - ) - outcome_statements_default <- combine_statement_list( - outcome_statement_default, - cached_manual_intent$outcome_statements, - cached_inputs$outcome_statements - ) - - explicit_target_ids_from_args <- normalize_selected_ids( - targetCohortId, - "Target cohort ID", - allow_multiple = FALSE - ) - explicit_comparator_ids_from_args <- normalize_selected_ids( - comparatorCohortId, - "Comparator cohort ID", - allow_multiple = FALSE - ) - explicit_outcome_ids_from_args <- normalize_selected_ids( - outcomeCohortIds, - "Outcome cohort IDs", - allow_multiple = TRUE - ) - all_cohort_ids_from_function_args <- length(explicit_target_ids_from_args) == 1 && - length(explicit_comparator_ids_from_args) == 1 && - length(explicit_outcome_ids_from_args) > 0 - skip_intent_split_and_recommendation <- FALSE - explicit_outcome_statements_from_args <- character(0) - if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) { - cat("\nAll target, comparator, and outcome cohort IDs were provided as function arguments:\n") - cat(sprintf(" Target: %s\n", format_cohort_selection_summary(explicit_target_ids_from_args, catalog_df))) - cat(sprintf(" Comparator: %s\n", format_cohort_selection_summary(explicit_comparator_ids_from_args, catalog_df))) - cat(sprintf(" Outcome: %s\n", format_cohort_selection_summary(explicit_outcome_ids_from_args, catalog_df))) - skip_intent_split_and_recommendation <- prompt_yesno( - "Skip study intent split, phenotype recommendation, and phenotype improvements, and use these cohort IDs directly?", - default = TRUE - ) - } - - cohort_id_statement <- function(role_label, ids) { - ids <- as.integer(ids[!is.na(ids)]) - if (length(ids) == 0) return(NULL) - labels <- vapply(ids, function(id) { - lookup_catalog_value(id, catalog_df, "name", sprintf("Cohort %s", id)) - }, character(1)) - if (length(ids) == 1) { - return(sprintf("%s cohort: %s (ID %s)", role_label, labels[[1]], ids[[1]])) - } - items <- sprintf("%s (ID %s)", labels, ids) - sprintf("%s cohorts: %s", role_label, paste(items, collapse = "; ")) - } - - if (isTRUE(skip_intent_split_and_recommendation)) { - target_statement_default <- first_nonempty( - target_statement_default, - cohort_id_statement("Target", explicit_target_ids_from_args) - ) - comparator_statement_default <- first_nonempty( - comparator_statement_default, - cohort_id_statement("Comparator", explicit_comparator_ids_from_args) - ) - explicit_outcome_statements_from_args <- vapply(explicit_outcome_ids_from_args, function(id) { - cohort_id_statement("Outcome", as.integer(id)) - }, character(1)) - outcome_statement_default <- first_nonempty( - outcome_statement_default, - explicit_outcome_statements_from_args - ) - outcome_statements_default <- combine_statement_list( - outcome_statement_default, - outcome_statements_default, - explicit_outcome_statements_from_args - ) - } - - cohort_methods_intent_split_source <- "not_run" - cohort_methods_intent_split_status <- "not_run" - cohort_methods_intent_split_response <- NULL - have_all_statement_defaults <- nonempty_string(target_statement_default) && - nonempty_string(comparator_statement_default) && - nonempty_string(outcome_statement_default) - - if (isTRUE(skip_intent_split_and_recommendation)) { - cohort_methods_intent_split_source <- "skipped_explicit_cohort_ids" - cohort_methods_intent_split_status <- "skipped" - if (isTRUE(interactive)) { - cat("\nSkipping study intent split, phenotype recommendation, and phenotype improvements for explicit cohort IDs.\n") - } - } else if (!isTRUE(have_all_statement_defaults)) { - if (isTRUE(interactive)) { - cat("\n== Step 1: Parse study intent into target/comparator/outcome statements ==\n") - } - if (maybe_use_cache(cohort_methods_intent_split_path, "cohort-methods intent split")) { - cohort_methods_intent_split_response <- read_json(cohort_methods_intent_split_path) - cohort_methods_intent_split_source <- "cached" - } else if (ensure_acp_ready(acpUrl)) { - if (isTRUE(interactive)) { - cat("Calling ACP flow: cohort_methods_intent_split\n") - } else { - message("Calling ACP flow: cohort_methods_intent_split") - } - cohort_methods_intent_split_response <- tryCatch( - call_shell_acp_flow("cohort_methods_intent_split", list(study_intent = studyIntent)), - error = function(e) { - list(status = "error", error = conditionMessage(e)) - } - ) - write_json(cohort_methods_intent_split_response, cohort_methods_intent_split_path) - cohort_methods_intent_split_source <- "acp_flow" - } - - split_core <- cohort_methods_intent_split_response$intent_split %||% cohort_methods_intent_split_response - if (!is.null(split_core) && is.null(split_core$error)) { - cohort_methods_intent_split_status <- as.character(split_core$status %||% "ok") - target_statement_default <- first_nonempty(target_statement_default, split_core$target_statement) - comparator_statement_default <- first_nonempty(comparator_statement_default, split_core$comparator_statement) - outcome_statement_default <- first_nonempty( - outcome_statement_default, - split_core$outcome_statement, - normalize_statement_list(split_core$outcome_statements) - ) - outcome_statements_default <- combine_statement_list( - outcome_statement_default, - outcome_statements_default, - split_core$outcome_statement, - split_core$outcome_statements - ) - if (isTRUE(interactive)) { - rationale <- as.character(split_core$rationale %||% "") - if (nzchar(rationale)) { - cat("\nSuggested rationale:\n") - cat(rationale, "\n") - } - if (length(split_core$questions %||% list()) > 0) { - cat("Questions to clarify:\n") - for (q in split_core$questions) cat(sprintf(" - %s\n", q)) - } - } - } else if (!is.null(split_core$error)) { - cohort_methods_intent_split_status <- "error" - if (isTRUE(interactive)) { - cat("\nACP cohort_methods_intent_split failed:\n") - cat(sprintf(" %s\n", summarize_intent_split_error(split_core))) - cat("Proceeding with manual target/comparator/outcome statement entry.\n") - } - } - } else { - cohort_methods_intent_split_source <- "manual_or_cached_statements" - cohort_methods_intent_split_status <- "not_needed" - } - - if (!isTRUE(interactive) && - identical(cohort_methods_intent_split_status, "needs_clarification") && - (!nonempty_string(target_statement_default) || - !nonempty_string(comparator_statement_default) || - !nonempty_string(outcome_statement_default))) { - stop("Cohort methods intent split needs clarification. Provide targetStatement, comparatorStatement, and outcomeStatement for non-interactive execution.") - } - - outcome_statements_default <- combine_statement_list(outcome_statement_default, outcome_statements_default) - - if (isTRUE(skip_intent_split_and_recommendation)) { - targetStatement <- target_statement_default - comparatorStatement <- comparator_statement_default - outcomeStatements <- outcome_statements_default - outcomeStatement <- first_nonempty(outcomeStatements) - } else { - set_dialogue_context("intent_split", "target", context = list( - target_statement = target_statement_default, - comparator_statement = comparator_statement_default, - outcome_statements = outcome_statements_default - )) - targetStatement <- prompt_statement("Target", default = target_statement_default) - set_dialogue_context("intent_split", "comparator", context = list( - target_statement = targetStatement, - comparator_statement = comparator_statement_default, - outcome_statements = outcome_statements_default - )) - comparatorStatement <- prompt_statement("Comparator", default = comparator_statement_default) - set_dialogue_context("intent_split", "outcome", context = list( - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statements = outcome_statements_default - )) - outcomeStatements <- prompt_outcome_statements(outcome_statements_default) - outcomeStatement <- first_nonempty(outcomeStatements) - } - - if (!nonempty_string(targetStatement) || !nonempty_string(comparatorStatement) || !nonempty_string(outcomeStatement)) { - stop( - "Missing target, comparator, or outcome cohort statement. ", - "Provide explicit targetStatement/comparatorStatement/outcomeStatement, ", - "reuse a valid cache, or run ACP with /flows/cohort_methods_intent_split available." - ) - } - - validate_target_id <- function(target_id) { - if (!cohort_json_exists(target_id, index_def_dir)) { - return(sprintf("Target cohort ID %s was not found in %s. Please enter a valid target cohort ID.", target_id, index_def_dir)) - } - NULL - } - validate_comparator_id <- function(comparator_id, target_id) { - if (target_id == comparator_id) { - return("Target and comparator cohort IDs must be different.") - } - if (!cohort_json_exists(comparator_id, index_def_dir)) { - return(sprintf("Comparator cohort ID %s was not found in %s. Please enter a valid comparator cohort ID.", comparator_id, index_def_dir)) - } - NULL - } - validate_outcome_ids <- function(outcome_ids, target_id, comparator_id) { - if (any(outcome_ids %in% c(target_id, comparator_id))) { - return("Outcome cohort IDs must be distinct from the target and comparator cohort IDs.") - } - missing_outcomes <- outcome_ids[!vapply(outcome_ids, cohort_json_exists, logical(1), index_def_dir = index_def_dir)] - if (length(missing_outcomes) > 0) { - return(sprintf( - "Outcome cohort ID(s) %s were not found in %s. Please enter valid outcome cohort IDs.", - paste(missing_outcomes, collapse = ", "), - index_def_dir - )) - } - NULL - } - validate_manual_ids <- function(target_id, comparator_id, outcome_ids) { - target_error <- validate_target_id(target_id) - if (!is.null(target_error)) return(target_error) - comparator_error <- validate_comparator_id(comparator_id, target_id) - if (!is.null(comparator_error)) return(comparator_error) - validate_outcome_ids(outcome_ids, target_id, comparator_id) - } - patched_outputs_cleared <- FALSE - ensure_patched_outputs_cleared <- function() { - if (!isTRUE(patched_outputs_cleared)) { - clear_patched_role_outputs() - patched_outputs_cleared <<- TRUE - } - } - improvements_results <- list() - use_function_argument_ids_for_selection <- !( - isTRUE(all_cohort_ids_from_function_args) && - isTRUE(interactive) && - !isTRUE(skip_intent_split_and_recommendation) - ) - preferred_target_ids <- if (isTRUE(use_function_argument_ids_for_selection)) targetCohortId else NULL - preferred_comparator_ids <- if (isTRUE(use_function_argument_ids_for_selection)) comparatorCohortId else NULL - - target_rec <- run_role_recommendation( - role_label = "Target", - statement = targetStatement, - output_path = recs_target_path, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - allow_multiple = FALSE, - preferred_selected_ids = preferred_target_ids, - preferred_selection_source = "function_argument", - cached_selected_ids = cached_inputs$target_cohort_id %||% NULL, - selected_cache_label = "target cohort selection", - selected_cache_dir = selected_target_dir, - cohort_method_cache = list( - selection = list( - selected_ids = cached_cm_target_selection$selected_ids %||% NULL, - cache_dir = selected_target_dir - ) - ), - incidence_cache = list( - selection = list( - selected_ids = cached_incidence_target_selection$selected_ids %||% NULL, - cache_dir = incidence_selected_target_dir, - label = "incidence target cohort selection" - ) - ), - recommendation_role = "target", - workflow_type = "cohort_methods", - exclude_metadata = list(executable_definition_status = list("codes_only")) - ) - - targetCohortId <- if (length(target_rec$selected_ids) > 0) { - as.integer(target_rec$selected_ids[[1]]) - } else { - collect_single_id(targetCohortId %||% cached_inputs$target_cohort_id, "Target") - } - if (!length(target_rec$selected_ids)) target_rec$selection_source <- "manual_input" - target_validation_error <- validate_target_id(targetCohortId) - while (!is.null(target_validation_error) && isTRUE(interactive)) { - cat(sprintf("%s\n", target_validation_error)) - targetCohortId <- collect_single_id(NULL, "Target") - target_rec$selection_source <- "manual_input" - target_rec$selected_ids <- as.integer(targetCohortId) - target_validation_error <- validate_target_id(targetCohortId) - } - if (!is.null(target_validation_error)) { - stop(target_validation_error) - } - selected_target_id <- as.integer(targetCohortId) - - default_cohort_id_base_ids <- suppressWarnings(as.integer(c( - targetCohortId, - comparatorCohortId, - outcomeCohortIds, - cached_inputs$target_cohort_id %||% NULL, - cached_inputs$comparator_cohort_id %||% NULL, - cached_inputs$outcome_cohort_ids %||% NULL, - catalog_df$cohortId - ))) - default_cohort_id_base_ids <- default_cohort_id_base_ids[!is.na(default_cohort_id_base_ids)] - default_cohort_id_base <- if (length(default_cohort_id_base_ids) > 0) { - max(default_cohort_id_base_ids, na.rm = TRUE) + 1000L - } else { - 1000L - } - use_mapping <- isTRUE(remapCohortIds) - if (isTRUE(interactive)) { - use_mapping <- prompt_yesno("Map cohort IDs to a new range (avoid collisions)?", default = isTRUE(remapCohortIds)) - } - if (use_mapping) { - cohortIdBase <- cohortIdBase %||% cached_inputs$cohort_id_base %||% default_cohort_id_base - cohortIdBase <- suppressWarnings(as.integer(cohortIdBase)) - if (isTRUE(interactive)) { - entered <- trimws(readline_with_dialogue(sprintf("Cohort ID base [%s]: ", cohortIdBase))) - if (nzchar(entered)) cohortIdBase <- suppressWarnings(as.integer(entered)) - } - cohortIdBase <- validate_positive_integer(cohortIdBase, "cohortIdBase") - } else { - cohortIdBase <- NA_integer_ - } - - next_id <- cohortIdBase - map_ids <- function(ids) { - if (!use_mapping) return(as.integer(ids)) - new_ids <- seq.int(next_id, length.out = length(ids)) - next_id <<- max(new_ids) + 1L - as.integer(new_ids) - } - new_target_id <- map_ids(selected_target_id) - copy_cohort_json_multi(selected_target_id, new_target_id, c(selected_target_dir, selected_dir), index_def_dir) - ensure_patched_outputs_cleared() - improvements_results$target <- if (isTRUE(skip_intent_split_and_recommendation)) { - skipped_role_improvements( - role_key = "target", - role_label = "Target", - cohort_ids = new_target_id, - patched_role_dir = patched_target_dir, - improvements_path = improvements_target_path, - reason = "explicit_cohort_ids_skip_confirmed" - ) - } else { - run_role_improvement_gate( - role_key = "target", - role_label = "Target", - cohort_ids = new_target_id, - selected_role_dir = selected_target_dir, - patched_role_dir = patched_target_dir, - improvements_path = improvements_target_path, - role_statement = targetStatement - ) - } - - comparator_rec <- run_role_recommendation( - role_label = "Comparator", - statement = comparatorStatement, - output_path = recs_comparator_path, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - allow_multiple = FALSE, - preferred_selected_ids = preferred_comparator_ids, - preferred_selection_source = "function_argument", - cached_selected_ids = cached_inputs$comparator_cohort_id %||% NULL, - selected_cache_label = "comparator cohort selection", - selected_cache_dir = selected_comparator_dir, - cohort_method_cache = list( - selection = list( - selected_ids = cached_cm_comparator_selection$selected_ids %||% NULL, - cache_dir = selected_comparator_dir - ) - ), - incidence_cache = list( - selection = list( - selected_ids = NULL, - cache_dir = NULL, - label = NULL - ) - ), - recommendation_role = "comparator", - workflow_type = "cohort_methods", - exclude_metadata = list(executable_definition_status = list("codes_only")) - ) - - comparatorCohortId <- if (length(comparator_rec$selected_ids) > 0) { - as.integer(comparator_rec$selected_ids[[1]]) - } else { - collect_single_id(comparatorCohortId %||% cached_inputs$comparator_cohort_id, "Comparator") - } - if (!length(comparator_rec$selected_ids)) comparator_rec$selection_source <- "manual_input" - comparator_validation_error <- validate_comparator_id(comparatorCohortId, targetCohortId) - while (!is.null(comparator_validation_error) && isTRUE(interactive)) { - cat(sprintf("%s\n", comparator_validation_error)) - comparatorCohortId <- collect_single_id(NULL, "Comparator") - comparator_rec$selection_source <- "manual_input" - comparator_rec$selected_ids <- as.integer(comparatorCohortId) - comparator_validation_error <- validate_comparator_id(comparatorCohortId, targetCohortId) - } - if (!is.null(comparator_validation_error)) { - stop(comparator_validation_error) - } - selected_comparator_id <- as.integer(comparatorCohortId) - new_comparator_id <- map_ids(selected_comparator_id) - copy_cohort_json_multi(selected_comparator_id, new_comparator_id, c(selected_comparator_dir, selected_dir), index_def_dir) - improvements_results$comparator <- if (isTRUE(skip_intent_split_and_recommendation)) { - skipped_role_improvements( - role_key = "comparator", - role_label = "Comparator", - cohort_ids = new_comparator_id, - patched_role_dir = patched_comparator_dir, - improvements_path = improvements_comparator_path, - reason = "explicit_cohort_ids_skip_confirmed" - ) - } else { - run_role_improvement_gate( - role_key = "comparator", - role_label = "Comparator", - cohort_ids = new_comparator_id, - selected_role_dir = selected_comparator_dir, - patched_role_dir = patched_comparator_dir, - improvements_path = improvements_comparator_path, - role_statement = comparatorStatement - ) - } - - cached_input_outcome_ids <- normalize_selected_ids( - cached_inputs$outcome_cohort_ids %||% NULL, - "cached outcome cohort IDs", - allow_multiple = TRUE - ) - preferred_outcome_ids <- normalize_selected_ids( - if (isTRUE(use_function_argument_ids_for_selection)) outcomeCohortIds else NULL, - "Outcome cohort IDs", - allow_multiple = TRUE - ) - preferred_outcome_source <- "function_argument" - if (length(preferred_outcome_ids) == 0 && - length(outcomeStatements) <= 1 && - length(cached_input_outcome_ids) > 0) { - preferred_outcome_ids <- cached_input_outcome_ids - preferred_outcome_source <- "cached_manual_input" - } - outcome_recommendation_path <- function(i) { - if (identical(as.integer(i), 1L)) return(recs_outcome_path) - file.path(output_dir, sprintf("recommendations_outcome_%s.json", as.integer(i))) - } - run_per_outcome_recommendations <- length(outcomeStatements) > 1 && - length(preferred_outcome_ids) == 0 - if (isTRUE(run_per_outcome_recommendations)) { - outcome_recs <- lapply(seq_along(outcomeStatements), function(i) { - run_role_recommendation( - role_label = if (identical(as.integer(i), 1L)) "Outcome" else sprintf("Outcome %s", i), - statement = outcomeStatements[[i]], - output_path = outcome_recommendation_path(i), - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - allow_multiple = FALSE, - preferred_selected_ids = NULL, - preferred_selection_source = "function_argument", - cached_selected_ids = NULL, - selected_cache_label = NULL, - selected_cache_dir = NULL, - cohort_method_cache = NULL, - incidence_cache = NULL, - recommendation_role = "outcome", - workflow_type = "cohort_methods", - exclude_metadata = list(executable_definition_status = list("codes_only")) - ) - }) - } else { - outcome_recs <- list(run_role_recommendation( - role_label = "Outcome", - statement = outcomeStatement, - output_path = recs_outcome_path, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - allow_multiple = TRUE, - preferred_selected_ids = preferred_outcome_ids, - preferred_selection_source = preferred_outcome_source, - cached_selected_ids = cached_inputs$outcome_cohort_ids %||% NULL, - selected_cache_label = "outcome cohort selections", - selected_cache_dir = selected_outcome_dir, - cohort_method_cache = list( - selection = list( - selected_ids = cached_cm_outcome_selection$selected_ids %||% NULL, - cache_dir = selected_outcome_dir - ) - ), - incidence_cache = list( - selection = list( - selected_ids = cached_incidence_outcome_selection$selected_ids %||% NULL, - cache_dir = incidence_selected_outcome_dir, - label = "incidence outcome cohort selection" - ) - ), - recommendation_role = "outcome", - workflow_type = "cohort_methods", - exclude_metadata = list(executable_definition_status = list("codes_only")) - )) - } - outcome_recommendations <- lapply(seq_along(outcome_recs), function(i) { - rec <- outcome_recs[[i]] - list( - outcome_index = as.integer(i), - statement = rec$statement %||% outcomeStatements[[min(i, length(outcomeStatements))]], - selected_ids = as.list(as.integer(rec$selected_ids)), - path = json_string_or_null(rec$recommendation_path), - source = rec$recommendation_source, - selection_source = rec$selection_source, - used_cached_recommendation = isTRUE(rec$used_cached_recommendation), - used_cached_selection = isTRUE(rec$used_cached_selection), - used_window2 = isTRUE(rec$used_window2), - used_advice = isTRUE(rec$used_advice) - ) - }) - outcome_rec <- outcome_recs[[1]] - outcome_selected_ids_by_rec <- lapply(outcome_recs, function(rec) as.integer(rec$selected_ids)) - outcome_selected_statements_by_rec <- lapply(seq_along(outcome_recs), function(i) { - rec <- outcome_recs[[i]] - ids <- outcome_selected_ids_by_rec[[i]] - if (length(ids) == 0) return(character(0)) - rep( - rec$statement %||% outcomeStatements[[min(i, length(outcomeStatements))]], - length(ids) - ) - }) - outcome_selected_ids_flat <- as.integer(unlist(outcome_selected_ids_by_rec, use.names = FALSE)) - outcome_selected_statements_flat <- as.character(unlist(outcome_selected_statements_by_rec, use.names = FALSE)) - valid_outcome_selection <- !is.na(outcome_selected_ids_flat) - outcome_selected_ids_flat <- outcome_selected_ids_flat[valid_outcome_selection] - outcome_selected_statements_flat <- outcome_selected_statements_flat[valid_outcome_selection] - unique_outcome_selection <- !duplicated(outcome_selected_ids_flat) - outcome_rec$selected_ids <- as.integer(outcome_selected_ids_flat[unique_outcome_selection]) - outcome_selected_statements <- as.character(outcome_selected_statements_flat[unique_outcome_selection]) - if (length(outcome_recs) > 1) { - outcome_rec$recommendation_source <- "per_outcome" - outcome_rec$used_cached_recommendation <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_cached_recommendation), logical(1))) - outcome_rec$used_cached_selection <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_cached_selection), logical(1))) - outcome_rec$used_window2 <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_window2), logical(1))) - outcome_rec$used_advice <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_advice), logical(1))) - } - - outcomeCohortIds <- if (length(outcome_rec$selected_ids) > 0) { - as.integer(outcome_rec$selected_ids) - } else { - collect_outcome_ids(outcomeCohortIds %||% cached_inputs$outcome_cohort_ids) - } - if (!length(outcome_rec$selected_ids)) outcome_rec$selection_source <- "manual_input" - outcomeStatementsForSelectedCohorts <- if ( - isTRUE(skip_intent_split_and_recommendation) && - length(explicit_outcome_statements_from_args) == length(outcomeCohortIds) - ) { - as.character(explicit_outcome_statements_from_args) - } else if (length(outcome_selected_statements) == length(outcomeCohortIds)) { - as.character(outcome_selected_statements) - } else { - vapply( - seq_along(outcomeCohortIds), - function(i) outcomeStatements[[min(i, length(outcomeStatements))]], - character(1) - ) - } - outcome_validation_error <- validate_outcome_ids(outcomeCohortIds, targetCohortId, comparatorCohortId) - while (!is.null(outcome_validation_error) && isTRUE(interactive)) { - cat(sprintf("%s\n", outcome_validation_error)) - outcomeCohortIds <- collect_outcome_ids(NULL) - outcome_rec$selection_source <- "manual_input" - outcome_rec$selected_ids <- as.integer(outcomeCohortIds) - outcomeStatementsForSelectedCohorts <- vapply( - seq_along(outcomeCohortIds), - function(i) outcomeStatements[[min(i, length(outcomeStatements))]], - character(1) - ) - outcome_validation_error <- validate_outcome_ids(outcomeCohortIds, targetCohortId, comparatorCohortId) - } - if (!is.null(outcome_validation_error)) { - stop(outcome_validation_error) - } - validation_error <- validate_manual_ids(targetCohortId, comparatorCohortId, outcomeCohortIds) - if (!is.null(validation_error)) { - stop(validation_error) - } - selected_outcome_ids <- as.integer(outcomeCohortIds) - new_outcome_ids <- map_ids(selected_outcome_ids) - - for (i in seq_along(selected_outcome_ids)) { - copy_cohort_json_multi(selected_outcome_ids[[i]], new_outcome_ids[[i]], c(selected_outcome_dir, selected_dir), index_def_dir) - } - improvements_results$outcome <- if (isTRUE(skip_intent_split_and_recommendation)) { - skipped_role_improvements( - role_key = "outcome", - role_label = "Outcome", - cohort_ids = new_outcome_ids, - patched_role_dir = patched_outcome_dir, - improvements_path = improvements_outcome_path, - reason = "explicit_cohort_ids_skip_confirmed" - ) - } else { - run_role_improvement_gate( - role_key = "outcome", - role_label = "Outcome", - cohort_ids = new_outcome_ids, - selected_role_dir = selected_outcome_dir, - patched_role_dir = patched_outcome_dir, - improvements_path = improvements_outcome_path, - role_statement = paste(unique(outcomeStatementsForSelectedCohorts), collapse = "\n") - ) - } - do_target_improvements <- isTRUE(improvements_results$target$prompt_choice) - do_comparator_improvements <- isTRUE(improvements_results$comparator$prompt_choice) - do_outcome_improvements <- isTRUE(improvements_results$outcome$prompt_choice) - - target_original_name <- lookup_catalog_value(targetCohortId, catalog_df, "name", sprintf("Target cohort %s", targetCohortId)) - comparator_original_name <- lookup_catalog_value(comparatorCohortId, catalog_df, "name", sprintf("Comparator cohort %s", comparatorCohortId)) - outcome_original_names <- vapply( - outcomeCohortIds, - function(id) lookup_catalog_value(id, catalog_df, "name", sprintf("Outcome cohort %s", id)), - character(1) - ) - target_name <- prompt_analysis_label( - "Target", - cached_inputs$target_analysis_label %||% cached_inputs$target_name %||% target_original_name - ) - comparator_name <- prompt_analysis_label( - "Comparator", - cached_inputs$comparator_analysis_label %||% cached_inputs$comparator_name %||% comparator_original_name - ) - outcome_names <- vapply(seq_along(outcome_original_names), function(i) { - cached_labels <- cached_inputs$outcome_analysis_labels %||% cached_inputs$outcome_names %||% NULL - default_label <- if (!is.null(cached_labels) && length(cached_labels) >= i) cached_labels[[i]] else outcome_original_names[[i]] - prompt_analysis_label(sprintf("Outcome %s", i), default_label) - }, character(1)) - target_desc <- lookup_catalog_value(targetCohortId, catalog_df, "short_description", "") - comparator_desc <- lookup_catalog_value(comparatorCohortId, catalog_df, "short_description", "") - outcome_descs <- vapply( - outcomeCohortIds, - function(id) lookup_catalog_value(id, catalog_df, "short_description", ""), - character(1) - ) - - comparisonLabel <- comparisonLabel %||% cached_inputs$comparison_label - if (is.null(comparisonLabel) || !nzchar(trimws(comparisonLabel))) { - comparisonLabel <- sprintf("%s vs %s", target_name, comparator_name) - } - comparisonLabel <- prompt_analysis_label("Comparison", comparisonLabel) - - cached_analytic_settings <- cached_inputs$analytic_settings %||% list() - cached_analytics <- if (is.null(cached_analytic_settings)) list() else cached_analytic_settings - cached_covariate_settings <- cached_analytics$covariate_concept_sets %||% list() - - negative_control_enabled <- isTRUE(cached_inputs$negative_control_enabled) || - !is.null(negativeControlConceptSetId %||% cached_inputs$negative_control_concept_set_id) - if (isTRUE(interactive)) { - negative_control_enabled <- prompt_yesno( - "Add a negative control concept set selection?", - default = negative_control_enabled - ) - } - if (isTRUE(negative_control_enabled)) { - negativeControlConceptSetId <- collect_optional_single_id( - negativeControlConceptSetId %||% cached_inputs$negative_control_concept_set_id, - "Negative control concept set", - "Negative control concept set ID: " - ) - if (is.null(negativeControlConceptSetId)) { - stop("Negative control concept set ID is required when negative control concept set selection is enabled.") - } - } else { - negativeControlConceptSetId <- NULL - } - - cached_include_covariate_id <- cached_covariate_settings$include_concept_set_id %||% - cached_inputs$covariate_include_concept_set_id - cached_exclude_covariate_id <- cached_covariate_settings$exclude_concept_set_id %||% - cached_inputs$covariate_exclude_concept_set_id - cached_include_all_covariates <- cached_covariate_settings$include_all_concepts %||% - cached_inputs$covariate_include_all_concepts - covariate_enabled <- isTRUE(cached_covariate_settings$enabled %||% cached_inputs$covariate_concept_sets_enabled) || - !is.null(includeCovariateConceptSetId %||% excludeCovariateConceptSetId %||% - cached_include_covariate_id %||% cached_exclude_covariate_id) - if (isTRUE(interactive)) { - covariate_enabled <- prompt_yesno( - "Add covariate concept set selections?", - default = covariate_enabled - ) - } - include_all_covariates <- isTRUE(cached_include_all_covariates) || !isTRUE(covariate_enabled) - if (isTRUE(covariate_enabled)) { - includeCovariateConceptSetId <- collect_optional_single_id( - includeCovariateConceptSetId %||% cached_include_covariate_id, - "Covariate include concept set", - "Covariate include concept set ID [optional; leave blank if you want to include all concepts or only set an exclude concept set]: " - ) - excludeCovariateConceptSetId <- collect_optional_single_id( - excludeCovariateConceptSetId %||% cached_exclude_covariate_id, - "Covariate exclude concept set", - "Covariate exclude concept set ID [optional]: " - ) - include_all_covariates <- is.null(includeCovariateConceptSetId) - } else { - includeCovariateConceptSetId <- NULL - excludeCovariateConceptSetId <- NULL - } - - default_analytic_settings <- .studyAgentDefaultCohortMethodAnalyticSettings( - covariate_enabled = covariate_enabled - ) - - cached_get_db <- cached_analytics$get_db_cohort_method_data %||% list() - cached_study_pop <- cached_analytics$create_study_population %||% list() - cached_ps <- cached_analytics$create_ps %||% list() - cached_ps_adjustment <- cached_analytics$ps_adjustment %||% list() - cached_match <- cached_analytics$match_on_ps %||% list() - cached_stratify <- cached_analytics$stratify_by_ps %||% list() - cached_outcome_model <- cached_analytics$fit_outcome_model %||% list() - cached_covariates <- cached_analytics$covariate_concept_sets %||% list() - - merge_or_default <- function(default_value, cache_value) { - if (is.null(cache_value) || (is.numeric(cache_value) && length(cache_value) == 0)) { - default_value - } else { - cache_value - } - } - - effective_analytic_settings <- list( - profile_name = merge_or_default(default_analytic_settings$profile_name, cached_analytics$profile_name), - source = "manual_shell", - customized_sections = character(0), - get_db_cohort_method_data = list( - studyStartDate = merge_or_default( - default_analytic_settings$get_db_cohort_method_data$studyStartDate, - cached_get_db$studyStartDate - ), - studyEndDate = merge_or_default( - default_analytic_settings$get_db_cohort_method_data$studyEndDate, - cached_get_db$studyEndDate - ), - firstExposureOnly = merge_or_default( - default_analytic_settings$get_db_cohort_method_data$firstExposureOnly, - cached_get_db$firstExposureOnly - ), - washoutPeriod = as.integer(merge_or_default( - default_analytic_settings$get_db_cohort_method_data$washoutPeriod, - cached_get_db$washoutPeriod - )), - restrictToCommonPeriod = isTRUE(cached_get_db$restrictToCommonPeriod %||% default_analytic_settings$get_db_cohort_method_data$restrictToCommonPeriod), - removeDuplicateSubjects = merge_or_default( - default_analytic_settings$get_db_cohort_method_data$removeDuplicateSubjects, - cached_get_db$removeDuplicateSubjects - ) - ), - create_study_population = list( - maxCohortSize = as.integer(merge_or_default( - default_analytic_settings$create_study_population$maxCohortSize, - cached_study_pop$maxCohortSize - )), - removeDuplicateSubjects = merge_or_default( - default_analytic_settings$create_study_population$removeDuplicateSubjects, - cached_study_pop$removeDuplicateSubjects - ), - removeSubjectsWithPriorOutcome = isTRUE(cached_study_pop$removeSubjectsWithPriorOutcome %||% - default_analytic_settings$create_study_population$removeSubjectsWithPriorOutcome), - priorOutcomeLookback = as.integer(merge_or_default( - default_analytic_settings$create_study_population$priorOutcomeLookback, - cached_study_pop$priorOutcomeLookback - )), - minDaysAtRisk = as.integer(merge_or_default( - default_analytic_settings$create_study_population$minDaysAtRisk, - cached_study_pop$minDaysAtRisk - )), - riskWindowStart = as.integer(merge_or_default( - default_analytic_settings$create_study_population$riskWindowStart, - cached_study_pop$riskWindowStart - )), - startAnchor = merge_or_default( - default_analytic_settings$create_study_population$startAnchor, - cached_study_pop$startAnchor - ), - riskWindowEnd = as.integer(merge_or_default( - default_analytic_settings$create_study_population$riskWindowEnd, - cached_study_pop$riskWindowEnd - )), - endAnchor = merge_or_default( - default_analytic_settings$create_study_population$endAnchor, - cached_study_pop$endAnchor - ), - censorAtNewRiskWindow = isTRUE(cached_study_pop$censorAtNewRiskWindow %||% - default_analytic_settings$create_study_population$censorAtNewRiskWindow) - ), - create_ps = list( - estimator = merge_or_default( - default_analytic_settings$create_ps$estimator, - cached_ps$estimator - ), - maxCohortSizeForFitting = as.integer(merge_or_default( - default_analytic_settings$create_ps$maxCohortSizeForFitting, - cached_ps$maxCohortSizeForFitting - )), - errorOnHighCorrelation = isTRUE(cached_ps$errorOnHighCorrelation %||% default_analytic_settings$create_ps$errorOnHighCorrelation), - useRegularization = isTRUE(cached_ps$useRegularization %||% default_analytic_settings$create_ps$useRegularization) - ), - ps_adjustment = list( - strategy = merge_or_default( - default_analytic_settings$ps_adjustment$strategy, - cached_ps_adjustment$strategy - ), - trimmingStrategy = merge_or_default( - default_analytic_settings$ps_adjustment$trimmingStrategy, - cached_ps_adjustment$trimmingStrategy - ), - trimmingPercent = as.numeric(merge_or_default( - default_analytic_settings$ps_adjustment$trimmingPercent, - cached_ps_adjustment$trimmingPercent - )), - equipoiseLowerBound = as.numeric(merge_or_default( - default_analytic_settings$ps_adjustment$equipoiseLowerBound, - cached_ps_adjustment$equipoiseLowerBound - )), - equipoiseUpperBound = as.numeric(merge_or_default( - default_analytic_settings$ps_adjustment$equipoiseUpperBound, - cached_ps_adjustment$equipoiseUpperBound - )) - ), - match_on_ps = list( - caliper = as.numeric(merge_or_default( - default_analytic_settings$match_on_ps$caliper, - cached_match$caliper - )), - caliperScale = merge_or_default( - default_analytic_settings$match_on_ps$caliperScale, - cached_match$caliperScale - ), - maxRatio = as.integer(merge_or_default( - default_analytic_settings$match_on_ps$maxRatio, - cached_match$maxRatio - )) - ), - stratify_by_ps = list( - numberOfStrata = as.integer(merge_or_default( - default_analytic_settings$stratify_by_ps$numberOfStrata, - cached_stratify$numberOfStrata - )), - baseSelection = merge_or_default( - default_analytic_settings$stratify_by_ps$baseSelection, - cached_stratify$baseSelection - ) - ), - fit_outcome_model = list( - modelType = merge_or_default( - default_analytic_settings$fit_outcome_model$modelType, - cached_outcome_model$modelType - ), - stratified = isTRUE(cached_outcome_model$stratified %||% default_analytic_settings$fit_outcome_model$stratified), - useCovariates = isTRUE(cached_outcome_model$useCovariates %||% default_analytic_settings$fit_outcome_model$useCovariates), - inversePtWeighting = isTRUE(cached_outcome_model$inversePtWeighting %||% default_analytic_settings$fit_outcome_model$inversePtWeighting), - useRegularization = isTRUE(cached_outcome_model$useRegularization %||% default_analytic_settings$fit_outcome_model$useRegularization) - ), - covariate_concept_sets = list( - enabled = isTRUE(cached_covariates$enabled %||% covariate_enabled), - include_all_concepts = isTRUE(cached_covariates$include_all_concepts %||% include_all_covariates %||% TRUE), - include_concept_set_id = json_int_or_null(merge_or_default(cached_covariates$include_concept_set_id, includeCovariateConceptSetId)), - exclude_concept_set_id = json_int_or_null(merge_or_default(cached_covariates$exclude_concept_set_id, excludeCovariateConceptSetId)) - ) - ) - - has_function_argument_description <- !is.null(analyticSettingsDescription) || !is.null(analytic_settings_description_path_resolved) - cached_mode <- as.character(cached_inputs$analytic_settings_mode %||% if (has_function_argument_description) "free_text" else "step_by_step") - analytic_settings_mode <- if (isTRUE(interactive)) { - mode_default <- if (has_function_argument_description || identical(cached_mode, "free_text")) "free-text" else "step-by-step" - cat("\nHow would you like to configure analytic settings?\n") - cat(" 1. Step-by-step\n") - cat(" Walk through the required analytic settings sections in order.\n") - cat(" In the current stage, the shell walks the section flow and shows the OHDSI defaults for the remaining sub-settings.\n") - cat(" 2. Free-text\n") - cat(" Describe the analytic settings you want in natural language.\n") - cat(" The shell will create a dummy recommendation JSON, show the proposed key/value pairs, and ask you to confirm.\n") - mode_choice <- collect_choice_value( - value = mode_default, - label = "Analytic settings configuration mode", - choices = c("step-by-step", "free-text"), - prompt = "Choose analytic settings mode by number.", - default = mode_default - ) - if (identical(mode_choice, "free-text")) "free_text" else "step_by_step" - } else if (has_function_argument_description || - (identical(cached_mode, "free_text") && - (nzchar(trimws(as.character(cached_inputs$analytic_settings_description %||% ""))) || - nzchar(trimws(as.character(cached_inputs$analytic_settings_description_path %||% "")))))) { - "free_text" - } else { - "step_by_step" - } - analytic_settings_selection_source <- if (isTRUE(interactive)) "manual_prompt" else if (!is.null(cached_inputs$analytic_settings_mode)) "cached" else "default_non_interactive" - analytic_settings_input_method <- if (identical(analytic_settings_mode, "free_text")) { - as.character(cached_inputs$analytic_settings_input_method %||% "typed_text") - } else { - "step_by_step" - } - analytic_settings_description <- cached_inputs$analytic_settings_description %||% NULL - analytic_settings_description_path <- cached_inputs$analytic_settings_description_path %||% NULL - analytic_settings_recommendation_source <- as.character(cached_inputs$analytic_settings_recommendation_source %||% if (identical(analytic_settings_mode, "free_text")) "pending" else "not_applicable") - analytic_settings_acp_response_path <- json_string_or_null(cached_inputs$analytic_settings_acp_response_path) - analytic_settings_recommendation_path <- json_string_or_null(cached_inputs$analytic_settings_recommendation_path) - analytic_settings_recommendation_status <- as.character(cached_inputs$analytic_settings_recommendation_status %||% if (identical(analytic_settings_mode, "free_text")) "pending" else "not_applicable") - analytic_settings_confirmed <- isTRUE(cached_inputs$analytic_settings_confirmed %||% FALSE) - analytic_settings_section_flow <- c("study_population", "time_at_risk", "propensity_score_adjustment", "outcome_model") - - effective_analytic_settings$covariate_concept_sets$include_all_concepts <- isTRUE(!isTRUE(covariate_enabled)) || - isTRUE(include_all_covariates) - - if (identical(analytic_settings_mode, "step_by_step")) { - if (isTRUE(interactive)) { - cat("\nAnalytic settings mode: step-by-step\n") - cat("The shell will collect each required section in order and ask for the analytic settings profile name last.\n") - } - - set_dialogue_context( - "analytic_settings_step_by_step", - "analytic_settings", - context = list( - study_intent = studyIntent, - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statements = outcomeStatements, - comparison_label = comparisonLabel - ) - ) - - step_by_step_io <- list( - section_header = function(label) { - set_dialogue_context( - "analytic_settings_step_by_step", - "analytic_settings", - context = list( - section = label, - study_intent = studyIntent, - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statements = outcomeStatements, - comparison_label = comparisonLabel - ) - ) - cat(sprintf("\n[%s]\n", label)) - }, - text = function(prompt, default = "", allow_blank = FALSE) { - entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, default))) - if (!nzchar(entered)) { - if (isTRUE(allow_blank)) return(default) - return(default) - } - entered - }, - yesno = function(prompt, default = TRUE) { - prompt_yesno_strict(prompt, default = default) - }, - choice = function(prompt, choices, default, labels = choices) { - default_index <- match(default, choices) - if (is.na(default_index)) default_index <- 1L - selected_label <- collect_choice_value( - value = labels[[default_index]], - label = prompt, - choices = labels, - prompt = prompt, - default = labels[[default_index]] - ) - choices[[match(selected_label, labels)]] - }, - integer = function(prompt, default, min_value = NULL, allow_negative = TRUE) { - repeat { - value <- prompt_integer( - prompt = prompt, - default = default, - allow_null = FALSE, - must_be_positive = FALSE, - allow_negative = allow_negative - ) - if (!is.null(min_value) && value < min_value) { - cat(sprintf("Please enter an integer >= %s.\n", min_value)) - next - } - return(value) - } - }, - numeric = function(prompt, default, min_value = NULL) { - repeat { - value <- prompt_numeric( - prompt = prompt, - default = default, - must_be_positive = FALSE - ) - if (!is.null(min_value) && value < min_value) { - cat(sprintf("Please enter a number >= %s.\n", min_value)) - next - } - return(value) - } - } - ) - step_by_step_result <- .studyAgentCollectStepByStepAnalyticSettings( - default_settings = default_analytic_settings, - seed_settings = effective_analytic_settings, - interactive = interactive, - io = step_by_step_io - ) - effective_analytic_settings <- step_by_step_result$settings - analytic_settings_section_flow <- step_by_step_result$section_flow - - analytic_settings_description <- NULL - analytic_settings_description_path <- NULL - analytic_settings_recommendation_source <- "not_applicable" - analytic_settings_acp_response_path <- NA_character_ - analytic_settings_recommendation_path <- NA_character_ - analytic_settings_recommendation_status <- "not_applicable" - analytic_settings_confirmed <- TRUE - } else { - repeat { - if (!is.null(analyticSettingsDescription)) { - analytic_settings_input_method <- "function_argument_text" - analytic_settings_description <- analyticSettingsDescription - analytic_settings_description_path <- NULL - } else if (!is.null(analytic_settings_description_path_resolved)) { - if (!file.exists(analytic_settings_description_path_resolved)) { - stop(sprintf("Analytic settings description file not found: %s", analytic_settings_description_path_resolved)) - } - file_lines <- readLines(analytic_settings_description_path_resolved, warn = FALSE) - analytic_settings_description <- trimws(paste(file_lines, collapse = "\n")) - if (!nzchar(analytic_settings_description)) { - stop(sprintf("Analytic settings description file is empty: %s", analytic_settings_description_path_resolved)) - } - analytic_settings_input_method <- "function_argument_path" - analytic_settings_description_path <- analytic_settings_description_path_resolved - } else if (!is.null(analytic_settings_description) && nzchar(trimws(as.character(analytic_settings_description)))) { - analytic_settings_description <- trimws(as.character(analytic_settings_description)) - analytic_settings_input_method <- as.character(cached_inputs$analytic_settings_input_method %||% "typed_text") - } else if (!is.null(analytic_settings_description_path) && nzchar(trimws(as.character(analytic_settings_description_path)))) { - cached_description_path <- normalizePath(resolve_path(as.character(analytic_settings_description_path), study_base_dir), winslash = "/", mustWork = FALSE) - if (!file.exists(cached_description_path)) { - stop(sprintf("Cached analytic settings description file not found: %s", cached_description_path)) - } - file_lines <- readLines(cached_description_path, warn = FALSE) - analytic_settings_description <- trimws(paste(file_lines, collapse = "\n")) - if (!nzchar(analytic_settings_description)) { - stop(sprintf("Cached analytic settings description file is empty: %s", cached_description_path)) - } - analytic_settings_input_method <- "cached_path" - analytic_settings_description_path <- cached_description_path - } else if (isTRUE(interactive)) { - analytic_settings_description <- prompt_non_null_text( - "Study description for analytic settings", - default = analytic_settings_description - ) - analytic_settings_input_method <- "typed_text" - analytic_settings_description_path <- NULL - } else { - stop("Free-text analytic settings mode requires `analyticSettingsDescription`, `analyticSettingsDescriptionPath`, or a cached description in non-interactive runs.") - } - - acp_request_body <- list( - study_intent = studyIntent, - study_description = analytic_settings_description, - analytic_settings_description = analytic_settings_description - ) - if (isTRUE(interactive)) { - cat("Calling ACP flow: cohort_methods_specifications_recommendation\n") - } else { - message("Calling ACP flow: cohort_methods_specifications_recommendation") - } - - ensure_acp_ready(acpUrl) - shell_suggestion_response <- tryCatch( - suggestCohortMethodSpecs( - studyIntent = acp_request_body$study_intent, - analyticSettingsDescription = acp_request_body$analytic_settings_description, - interactive = FALSE - ), - error = function(e) { - list( - source = "stub_no_acp", - status = "stub", - error = conditionMessage(e), - message = "ACP flow failed. Returning placeholder cohort methods specifications recommendation.", - request = acp_request_body - ) - } - ) - recommendation <- shell_suggestion_response$recommendation %||% - shell_suggestion_response$response$recommendation %||% - shell_suggestion_response$response$recommendations %||% - shell_suggestion_response$response$cohort_methods_specifications_recommendation %||% - shell_suggestion_response$cohort_methods_specifications_recommendation %||% - build_dummy_analytic_settings_recommendation( - description = acp_request_body$analytic_settings_description, - defaults_snapshot = effective_analytic_settings, - input_method = analytic_settings_input_method - ) - if (is.null(recommendation$defaults_snapshot) || length(recommendation$defaults_snapshot) == 0) { - recommendation$defaults_snapshot <- effective_analytic_settings - } - recommendation_source <- as.character( - if (identical(shell_suggestion_response$source, "stub_no_acp")) "stub_acp_placeholder" - else shell_suggestion_response$source %||% "acp_flow" - ) - acp_specifications_response <- list( - flow = "cohort_methods_specifications_recommendation", - source = recommendation_source, - status = shell_suggestion_response$status %||% "received", - request = acp_request_body, - response = shell_suggestion_response, - recommendation = recommendation - ) - - write_json(acp_specifications_response, cm_acp_specifications_recommendation_path) - analytic_settings_acp_response_path <- cm_acp_specifications_recommendation_path - analytic_settings_recommendation_source <- as.character(acp_specifications_response$source %||% "unknown") - analytic_settings_recommendation <- recommendation - write_json(analytic_settings_recommendation, cm_analytic_settings_recommendation_path) - analytic_settings_recommendation_path <- cm_analytic_settings_recommendation_path - - if (isTRUE(interactive)) { - print_analytic_settings_recommendation_preview( - acp_response = acp_specifications_response, - recommendation = analytic_settings_recommendation, - recommendation_path = analytic_settings_recommendation_path, - acp_response_path = analytic_settings_acp_response_path - ) - analytic_settings_confirmed <- FALSE - } else { - analytic_settings_confirmed <- isTRUE(cached_inputs$analytic_settings_confirmed %||% TRUE) - } - - if (identical(analytic_settings_recommendation_source, "acp_flow") && - identical(as.character(acp_specifications_response$response$status %||% acp_specifications_response$status %||% "unknown"), "ok")) { - recommendation_defaults_snapshot <- analytic_settings_recommendation$defaults_snapshot - if (is.null(recommendation_defaults_snapshot) || length(recommendation_defaults_snapshot) == 0) { - recommendation_defaults_snapshot <- effective_analytic_settings - } - effective_analytic_settings <- shell_settings_from_acp_recommendation( - analytic_settings_recommendation, - recommendation_defaults_snapshot - ) - } else { - effective_analytic_settings$profile_name <- as.character( - analytic_settings_recommendation$profile_name %||% effective_analytic_settings$profile_name - ) - } - analytic_settings_flow_status <- as.character( - acp_specifications_response$response$status %||% acp_specifications_response$status %||% "unknown" - ) - analytic_settings_recommendation_status <- if ( - identical(analytic_settings_recommendation_source, "acp_flow") && - identical(analytic_settings_flow_status, "ok") - ) { - if (isTRUE(analytic_settings_confirmed)) "confirmed_via_acp" else "received_from_acp" - } else if (identical(analytic_settings_recommendation_source, "acp_flow")) { - if (isTRUE(analytic_settings_confirmed)) "confirmed_acp_fallback" else "received_acp_fallback" - } else { - if (isTRUE(analytic_settings_confirmed)) "stub_fallback" else "stub_generated" - } - break - } - } - - if (isTRUE(interactive)) { - effective_analytic_settings <- review_analytic_settings_interactively(effective_analytic_settings) - analytic_settings_confirmed <- TRUE - } - - effective_analytic_settings$customized_sections <- names(.studyAgentAnalyticSettingsSectionPaths())[vapply( - names(.studyAgentAnalyticSettingsSectionPaths()), - function(section_name) { - paths <- .studyAgentAnalyticSettingsSectionPaths()[[section_name]] - any(vapply(paths, function(path) { - !identical( - .studyAgentGetNestedValue(effective_analytic_settings, path), - .studyAgentGetNestedValue(default_analytic_settings, path) - ) - }, logical(1))) - }, - logical(1) - )] - - effective_analytic_settings <- normalize_analytic_settings(effective_analytic_settings) - covariate_enabled <- isTRUE(effective_analytic_settings$covariate_concept_sets$enabled) - include_all_covariates <- isTRUE(effective_analytic_settings$covariate_concept_sets$include_all_concepts) - includeCovariateConceptSetId <- if (is.na(effective_analytic_settings$covariate_concept_sets$include_concept_set_id)) { - NULL - } else { - as.integer(effective_analytic_settings$covariate_concept_sets$include_concept_set_id) - } - excludeCovariateConceptSetId <- if (is.na(effective_analytic_settings$covariate_concept_sets$exclude_concept_set_id)) { - NULL - } else { - as.integer(effective_analytic_settings$covariate_concept_sets$exclude_concept_set_id) - } - - manual_intent <- list( - source = cohort_methods_intent_split_source, - intent_split_status = cohort_methods_intent_split_status, - intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), - explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), - skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), - skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), - skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), - skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", - study_intent = studyIntent, - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statement = outcomeStatement, - outcome_statements = as.list(outcomeStatements) - ) - write_json(manual_intent, manual_intent_path) - - manual_inputs <- list( - study_intent = studyIntent, - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statement = outcomeStatement, - outcome_statements = as.list(outcomeStatements), - cohort_methods_intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), - cohort_methods_intent_split_source = cohort_methods_intent_split_source, - cohort_methods_intent_split_status = cohort_methods_intent_split_status, - explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), - skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), - skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), - skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), - skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", - use_function_argument_ids_for_selection = isTRUE(use_function_argument_ids_for_selection), - comparison_label = comparisonLabel, - target_cohort_id = as.integer(targetCohortId), - comparator_cohort_id = as.integer(comparatorCohortId), - outcome_cohort_ids = as.integer(outcomeCohortIds), - outcome_cohort_statements = as.list(outcomeStatementsForSelectedCohorts), - target_recommendation = list( - statement = targetStatement, - path = json_string_or_null(target_rec$recommendation_path), - source = target_rec$recommendation_source, - selection_source = target_rec$selection_source, - used_cached_recommendation = isTRUE(target_rec$used_cached_recommendation), - used_cached_selection = isTRUE(target_rec$used_cached_selection), - used_window2 = isTRUE(target_rec$used_window2), - used_advice = isTRUE(target_rec$used_advice) - ), - comparator_recommendation = list( - statement = comparatorStatement, - path = json_string_or_null(comparator_rec$recommendation_path), - source = comparator_rec$recommendation_source, - selection_source = comparator_rec$selection_source, - used_cached_recommendation = isTRUE(comparator_rec$used_cached_recommendation), - used_cached_selection = isTRUE(comparator_rec$used_cached_selection), - used_window2 = isTRUE(comparator_rec$used_window2), - used_advice = isTRUE(comparator_rec$used_advice) - ), - outcome_recommendation = list( - statement = outcomeStatement, - path = json_string_or_null(outcome_rec$recommendation_path), - source = outcome_rec$recommendation_source, - selection_source = outcome_rec$selection_source, - used_cached_recommendation = isTRUE(outcome_rec$used_cached_recommendation), - used_cached_selection = isTRUE(outcome_rec$used_cached_selection), - used_window2 = isTRUE(outcome_rec$used_window2), - used_advice = isTRUE(outcome_rec$used_advice) - ), - outcome_recommendations = outcome_recommendations, - negative_control_enabled = isTRUE(negative_control_enabled), - negative_control_concept_set_id = json_int_or_null(negativeControlConceptSetId), - covariate_concept_sets_enabled = isTRUE(covariate_enabled), - covariate_include_all_concepts = isTRUE(include_all_covariates), - covariate_include_concept_set_id = json_int_or_null(includeCovariateConceptSetId), - covariate_exclude_concept_set_id = json_int_or_null(excludeCovariateConceptSetId), - target_name = target_name, - comparator_name = comparator_name, - outcome_names = as.list(outcome_names), - target_original_name = target_original_name, - comparator_original_name = comparator_original_name, - outcome_original_names = as.list(outcome_original_names), - target_analysis_label = target_name, - comparator_analysis_label = comparator_name, - outcome_analysis_labels = as.list(outcome_names), - target_description = target_desc, - comparator_description = comparator_desc, - outcome_descriptions = as.list(outcome_descs), - customized_sections = as.list(effective_analytic_settings$customized_sections), - analytic_settings_mode = analytic_settings_mode, - analytic_settings_selection_source = analytic_settings_selection_source, - analytic_settings_input_method = analytic_settings_input_method, - analytic_settings_description = json_string_or_null(analytic_settings_description), - analytic_settings_description_path = json_string_or_null(analytic_settings_description_path), - analytic_settings_recommendation_source = analytic_settings_recommendation_source, - analytic_settings_acp_response_path = json_string_or_null(analytic_settings_acp_response_path), - analytic_settings_recommendation_path = json_string_or_null(analytic_settings_recommendation_path), - analytic_settings_recommendation_status = analytic_settings_recommendation_status, - analytic_settings_confirmed = isTRUE(analytic_settings_confirmed), - analytic_settings_section_flow = as.list(analytic_settings_section_flow), - cm_analysis_json_path = cm_analysis_json_path, - cm_analysis_template_path = json_string_or_null(cm_analysis_template_path), - remap_cohort_ids = use_mapping, - cohort_id_base = cohortIdBase - ) - manual_inputs$analytic_settings <- effective_analytic_settings - write_json(manual_inputs, manual_inputs_path) - - improvements_applied <- any(vapply(improvements_results, function(x) isTRUE(x$applied), logical(1))) - if (isTRUE(improvements_applied)) { - ensure_complete_patched_role <- function(selected_role_dir, patched_role_dir, cohort_ids) { - ensure_dir(patched_role_dir) - ensure_dir(patched_dir) - for (cid in as.integer(cohort_ids)) { - role_path <- file.path(patched_role_dir, sprintf("%s.json", cid)) - selected_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) - combined_path <- file.path(patched_dir, sprintf("%s.json", cid)) - if (!file.exists(role_path) && file.exists(selected_path)) { - file.copy(selected_path, role_path, overwrite = TRUE) - } - source_path <- if (file.exists(role_path)) role_path else selected_path - if (file.exists(source_path)) { - file.copy(source_path, combined_path, overwrite = TRUE) - } - } - } - ensure_complete_patched_role(selected_target_dir, patched_target_dir, new_target_id) - ensure_complete_patched_role(selected_comparator_dir, patched_comparator_dir, new_comparator_id) - ensure_complete_patched_role(selected_outcome_dir, patched_outcome_dir, new_outcome_ids) - } - improvements_cache_used <- vapply(improvements_results, function(x) isTRUE(x$used_cache), logical(1)) - improvements_flow_called <- vapply(improvements_results, function(x) isTRUE(x$flow_called), logical(1)) - - cohort_map <- data.frame( - original_id = c(selected_target_id, selected_comparator_id, selected_outcome_ids), - cohort_id = c(new_target_id, new_comparator_id, new_outcome_ids), - role = c("target", "comparator", rep("outcome", length(new_outcome_ids))), - cohort_name = c(target_name, comparator_name, outcome_names), - original_cohort_name = c(target_original_name, comparator_original_name, outcome_original_names), - short_description = c(target_desc, comparator_desc, outcome_descs), - stringsAsFactors = FALSE - ) - write_json(list(mapping = cohort_map), cohort_id_map_path) - - write_json( - list( - comparison_label = comparisonLabel, - targets = as.integer(new_target_id), - comparators = as.integer(new_comparator_id), - outcomes = as.integer(new_outcome_ids) - ), - cohort_roles_path - ) - - cm_comparisons <- list( - comparisons = list( - list( - comparison_id = 1L, - label = comparisonLabel, - study_intent = studyIntent, - target = list( - source_id = as.integer(selected_target_id), - cohort_id = as.integer(new_target_id), - name = target_name, - original_name = target_original_name - ), - comparator = list( - source_id = as.integer(selected_comparator_id), - cohort_id = as.integer(new_comparator_id), - name = comparator_name, - original_name = comparator_original_name - ), - outcomes = lapply(seq_along(new_outcome_ids), function(i) { - list( - source_id = as.integer(selected_outcome_ids[[i]]), - cohort_id = as.integer(new_outcome_ids[[i]]), - name = outcome_names[[i]], - original_name = outcome_original_names[[i]], - statement = outcomeStatementsForSelectedCohorts[[i]] - ) - }) - ) - ) - ) - write_json(cm_comparisons, cm_comparisons_path) - - role_statuses <- vapply(improvements_results, function(x) as.character(x$status %||% "not_run"), character(1)) - improvements_status_value <- if (all(role_statuses %in% c("completed", "not_applicable"))) { - "completed" - } else if (all(role_statuses %in% c("skipped", "not_applicable"))) { - "skipped" - } else if (any(role_statuses %in% c("completed", "completed_with_errors"))) { - "partial" - } else { - "not_run" - } - improvements_status <- list( - status = improvements_status_value, - flow = "phenotype_improvements", - applies_to = c("target", "comparator", "outcome"), - auto_apply = isTRUE(autoApplyImprovements), - applied = isTRUE(improvements_applied), - combined_patched_dir = patched_dir, - role_artifacts = list( - target = improvements_target_path, - comparator = improvements_comparator_path, - outcome = improvements_outcome_path - ), - roles = improvements_results - ) - write_json(improvements_status, improvements_status_path) - - cm_evaluation_todo <- list( - status = "todo", - items = list( - list( - name = "negative_controls", - status = if (isTRUE(negative_control_enabled)) "dummy_selected" else "todo", - enabled = isTRUE(negative_control_enabled), - concept_set_id = json_int_or_null(negativeControlConceptSetId), - source = json_string_or_null(if (isTRUE(negative_control_enabled)) "manual_shell" else NULL) - ), - list(name = "positive_control_synthesis", status = "todo"), - list(name = "empirical_calibration", status = "todo") - ), - note = "Current stage only scaffolds CohortMethod execution for outcomes of interest." - ) - write_json(cm_evaluation_todo, cm_evaluation_todo_path) - - create_dummy_concept_set <- function(path, concept_set_id, label) { - if (is.null(concept_set_id)) return(NULL) - payload <- list( - conceptSetId = as.integer(concept_set_id), - name = sprintf("Dummy %s %s", label, concept_set_id), - expression = list(items = list()), - note = "Placeholder only. Replace this dummy concept set with real concept set content in a later stage." - ) - write_json(payload, path) - path - } - - negative_control_path <- create_dummy_concept_set( - file.path(concept_sets_dir, "negative_control_concept_set.json"), - negativeControlConceptSetId, - "negative control concept set" - ) - covariate_include_path <- create_dummy_concept_set( - file.path(concept_sets_dir, "covariate_include_concept_set.json"), - includeCovariateConceptSetId, - "covariate include concept set" - ) - covariate_exclude_path <- create_dummy_concept_set( - file.path(concept_sets_dir, "covariate_exclude_concept_set.json"), - excludeCovariateConceptSetId, - "covariate exclude concept set" - ) - - cm_concept_set_selections <- list( - negative_control = list( - enabled = isTRUE(negative_control_enabled), - concept_set_id = json_int_or_null(negativeControlConceptSetId), - artifact_path = json_string_or_null(negative_control_path), - status = if (isTRUE(negative_control_enabled)) "dummy_selected" else "not_selected" - ), - covariates = list( - enabled = isTRUE(covariate_enabled), - include_all_concepts = isTRUE(include_all_covariates), - include = list( - concept_set_id = json_int_or_null(includeCovariateConceptSetId), - artifact_path = json_string_or_null(covariate_include_path) - ), - exclude = list( - concept_set_id = json_int_or_null(excludeCovariateConceptSetId), - artifact_path = json_string_or_null(covariate_exclude_path) - ), - status = if (isTRUE(covariate_enabled)) "dummy_selected" else "not_selected" - ), - note = "Concept set IDs are manual placeholders in the current R-only stage." - ) - write_json(cm_concept_set_selections, cm_concept_set_selections_path) - - cm_defaults <- list( - analysis_id = 1L, - description = effective_analytic_settings$profile_name, - profile_name = effective_analytic_settings$profile_name, - source = "manual_shell", - mode = analytic_settings_mode, - input_method = analytic_settings_input_method, - recommendation_path = json_string_or_null(analytic_settings_recommendation_path), - customized_sections = effective_analytic_settings$customized_sections, - get_db_cohort_method_data = effective_analytic_settings$get_db_cohort_method_data, - create_study_population = effective_analytic_settings$create_study_population, - create_ps = effective_analytic_settings$create_ps, - ps_adjustment = effective_analytic_settings$ps_adjustment, - match_on_ps = effective_analytic_settings$match_on_ps, - stratify_by_ps = effective_analytic_settings$stratify_by_ps, - fit_outcome_model = effective_analytic_settings$fit_outcome_model, - covariate_concept_sets = effective_analytic_settings$covariate_concept_sets - ) - cm_defaults$covariate_concept_sets$enabled <- isTRUE(effective_analytic_settings$covariate_concept_sets$enabled) - cm_defaults$covariate_concept_sets$note <- "Placeholder only. Dummy concept set IDs are captured for future concept set materialization." - cm_defaults$get_db_cohort_method_data$removeDuplicateSubjects <- as.character(cm_defaults$get_db_cohort_method_data$removeDuplicateSubjects) - cm_defaults$create_study_population$removeDuplicateSubjects <- as.character(cm_defaults$create_study_population$removeDuplicateSubjects) - cm_defaults$cm_analysis_json_path <- cm_analysis_json_path - write_json(cm_defaults, cm_defaults_path) - - cm_analysis_template <- .studyAgentLoadCmAnalysisTemplate(cm_analysis_template_path) - cm_analysis_json <- .studyAgentBuildCmAnalysisJson( - settings = effective_analytic_settings, - template = cm_analysis_template - ) - write_json(cm_analysis_json, cm_analysis_json_path) - - cohort_rows <- list( - data.frame( - atlas_id = selected_target_id, - cohort_id = new_target_id, - cohort_name = target_name, - cohort_type = "target", - logic_description = if (nzchar(target_desc)) target_desc else "Manual target cohort selection", - generate_stats = TRUE, - stringsAsFactors = FALSE - ), - data.frame( - atlas_id = selected_comparator_id, - cohort_id = new_comparator_id, - cohort_name = comparator_name, - cohort_type = "comparator", - logic_description = if (nzchar(comparator_desc)) comparator_desc else "Manual comparator cohort selection", - generate_stats = TRUE, - stringsAsFactors = FALSE - ) - ) - if (length(new_outcome_ids) > 0) { - for (i in seq_along(new_outcome_ids)) { - cohort_rows[[length(cohort_rows) + 1]] <- data.frame( - atlas_id = selected_outcome_ids[[i]], - cohort_id = new_outcome_ids[[i]], - cohort_name = outcome_names[[i]], - cohort_type = "outcome", - logic_description = if (nzchar(outcome_descs[[i]])) outcome_descs[[i]] else "Manual outcome cohort selection", - generate_stats = TRUE, - stringsAsFactors = FALSE - ) - } - } - cohort_df <- do.call(rbind, cohort_rows) - cohort_csv <- file.path(selected_dir, "Cohorts.csv") - write.csv(cohort_df, cohort_csv, row.names = FALSE) - - state <- list( - study_intent = studyIntent, - target_statement = targetStatement, - comparator_statement = comparatorStatement, - outcome_statement = outcomeStatement, - outcome_statements = as.list(outcomeStatements), - outcome_cohort_statements = as.list(outcomeStatementsForSelectedCohorts), - comparison_label = comparisonLabel, - target_analysis_label = target_name, - comparator_analysis_label = comparator_name, - outcome_analysis_labels = as.list(outcome_names), - target_original_name = target_original_name, - comparator_original_name = comparator_original_name, - outcome_original_names = as.list(outcome_original_names), - output_dir = output_dir, - selected_dir = selected_dir, - patched_dir = patched_dir, - selected_target_dir = selected_target_dir, - selected_comparator_dir = selected_comparator_dir, - selected_outcome_dir = selected_outcome_dir, - patched_target_dir = patched_target_dir, - patched_comparator_dir = patched_comparator_dir, - patched_outcome_dir = patched_outcome_dir, - keeper_dir = keeper_dir, - analysis_settings_dir = analysis_settings_dir, - scripts_dir = scripts_dir, - cm_results_dir = cm_results_dir, - cm_diagnostics_dir = cm_diagnostics_dir, - cm_data_dir = cm_data_dir, - manual_intent_path = manual_intent_path, - manual_inputs_path = manual_inputs_path, - cohort_methods_intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), - cohort_id_map_path = cohort_id_map_path, - cohort_roles_path = cohort_roles_path, - cm_comparisons_path = cm_comparisons_path, - improvements_status_path = improvements_status_path, - improvements_target_path = improvements_target_path, - improvements_comparator_path = improvements_comparator_path, - improvements_outcome_path = improvements_outcome_path, - improvements_paths = list( - target = improvements_target_path, - comparator = improvements_comparator_path, - outcome = improvements_outcome_path, - status = improvements_status_path - ), - improvements_cache_used = as.list(improvements_cache_used), - improvements_flow_called = as.list(improvements_flow_called), - improvements_applied = isTRUE(improvements_applied), - improvements_auto_apply = isTRUE(autoApplyImprovements), - improvements_prompt_choices = list( - target = isTRUE(do_target_improvements), - comparator = isTRUE(do_comparator_improvements), - outcome = isTRUE(do_outcome_improvements) - ), - improvements_results = improvements_results, - cm_evaluation_todo_path = cm_evaluation_todo_path, - cm_defaults_path = cm_defaults_path, - cm_analysis_json_path = cm_analysis_json_path, - cm_analysis_template_path = json_string_or_null(cm_analysis_template_path), - cm_acp_specifications_recommendation_path = json_string_or_null(analytic_settings_acp_response_path), - cm_analytic_settings_recommendation_path = json_string_or_null(analytic_settings_recommendation_path), - cm_concept_set_selections_path = cm_concept_set_selections_path, - cohort_csv = cohort_csv, - used_cached_inputs = !is.null(cached_inputs), - cohort_methods_intent_split_source = cohort_methods_intent_split_source, - cohort_methods_intent_split_status = cohort_methods_intent_split_status, - explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), - skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), - skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), - skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), - skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", - use_function_argument_ids_for_selection = isTRUE(use_function_argument_ids_for_selection), - resume_enabled = isTRUE(resume), - remap_cohort_ids = use_mapping, - cohort_id_base = cohortIdBase, - analytic_settings_mode = analytic_settings_mode, - analytic_settings_selection_source = analytic_settings_selection_source, - analytic_settings_input_method = analytic_settings_input_method, - analytic_settings_description = json_string_or_null(analytic_settings_description), - analytic_settings_description_path = json_string_or_null(analytic_settings_description_path), - analytic_settings_recommendation_source = analytic_settings_recommendation_source, - analytic_settings_acp_response_path = json_string_or_null(analytic_settings_acp_response_path), - analytic_settings_recommendation_status = analytic_settings_recommendation_status, - analytic_settings_confirmed = isTRUE(analytic_settings_confirmed), - analytic_settings_section_flow = as.list(analytic_settings_section_flow), - analytic_settings_profile_name = effective_analytic_settings$profile_name, - analytic_settings_customized_sections = as.character(effective_analytic_settings$customized_sections), - analytic_settings = effective_analytic_settings, - negative_control_enabled = isTRUE(negative_control_enabled), - negative_control_concept_set_id = json_int_or_null(negativeControlConceptSetId), - covariate_concept_sets_enabled = isTRUE(covariate_enabled), - covariate_include_all_concepts = isTRUE(include_all_covariates), - covariate_include_concept_set_id = json_int_or_null(includeCovariateConceptSetId), - covariate_exclude_concept_set_id = json_int_or_null(excludeCovariateConceptSetId), - target_recommendation_path = json_string_or_null(target_rec$recommendation_path), - comparator_recommendation_path = json_string_or_null(comparator_rec$recommendation_path), - outcome_recommendation_path = json_string_or_null(outcome_rec$recommendation_path), - outcome_recommendation_paths = as.list(vapply( - outcome_recommendations, - function(rec) as.character(rec$path %||% NA_character_), - character(1) - )), - target_recommendation_source = target_rec$recommendation_source, - comparator_recommendation_source = comparator_rec$recommendation_source, - outcome_recommendation_source = outcome_rec$recommendation_source, - target_selection_source = target_rec$selection_source, - comparator_selection_source = comparator_rec$selection_source, - outcome_selection_source = outcome_rec$selection_source, - target_used_cached_recommendation = isTRUE(target_rec$used_cached_recommendation), - comparator_used_cached_recommendation = isTRUE(comparator_rec$used_cached_recommendation), - outcome_used_cached_recommendation = isTRUE(outcome_rec$used_cached_recommendation), - target_used_cached_selection = isTRUE(target_rec$used_cached_selection), - comparator_used_cached_selection = isTRUE(comparator_rec$used_cached_selection), - outcome_used_cached_selection = isTRUE(outcome_rec$used_cached_selection), - outcome_recommendations = outcome_recommendations, - target_ids = as.integer(new_target_id), - comparator_ids = as.integer(new_comparator_id), - outcome_ids = as.integer(new_outcome_ids) - ) - write_json(state, state_path) - - package_root <- resolve_path("R/OHDSIAssistant", study_base_dir) - if (!dir.exists(package_root)) { - alt <- file.path(getwd(), "R", "OHDSIAssistant") - if (dir.exists(alt)) package_root <- alt - } - package_root <- normalizePath(package_root, winslash = "/", mustWork = FALSE) - - script_header <- c( - "# Generated by OHDSIAssistant::runStrategusCohortMethodsShell", - "# Edit values as needed and run in order.", - "# Current stage: manual shell output with ACP/MCP status artifacts.", - "" - ) - package_loader_lines <- c( - sprintf("package_root <- '%s'", package_root), - "if (!requireNamespace('OHDSIAssistant', quietly = TRUE)) {", - " if (requireNamespace('devtools', quietly = TRUE) && dir.exists(package_root)) {", - " devtools::load_all(package_root)", - " } else {", - " stop('OHDSIAssistant is not installed and devtools::load_all(package_root) is unavailable: ', package_root)", - " }", - "}", - "library(OHDSIAssistant)" - ) - - script_02 <- c( - script_header, - "library(jsonlite)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", - "selected_comparator_dir <- file.path(base_dir, 'selected-comparator-cohorts')", - "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "patched_target_dir <- file.path(base_dir, 'patched-target-cohorts')", - "patched_comparator_dir <- file.path(base_dir, 'patched-comparator-cohorts')", - "patched_outcome_dir <- file.path(base_dir, 'patched-outcome-cohorts')", - "dir.create(patched_dir, recursive = TRUE, showWarnings = FALSE)", - "for (dir_path in c(patched_dir, patched_target_dir, patched_comparator_dir, patched_outcome_dir)) {", - " if (dir.exists(dir_path)) unlink(list.files(dir_path, pattern = '\\\\.(json)$', full.names = TRUE), force = TRUE)", - " sql_dir <- file.path(dir_path, 'sql')", - " if (dir.exists(sql_dir)) unlink(list.files(sql_dir, pattern = '\\\\.(sql)$', full.names = TRUE), force = TRUE)", - "}", - "", - "apply_action <- function(obj, action) {", - " path <- action$path %||% ''", - " value <- action$value", - " if (!nzchar(path)) return(obj)", - " segs <- strsplit(path, '/', fixed = TRUE)[[1]]", - " segs <- segs[segs != '']", - " set_in <- function(x, segs, value) {", - " if (length(segs) == 0) return(value)", - " seg <- segs[[1]]", - " name <- seg", - " idx <- NA_integer_", - " if (grepl('\\\\[\\\\d+\\\\]$', seg)) {", - " name <- sub('\\\\[\\\\d+\\\\]$', '', seg)", - " idx <- as.integer(sub('^.*\\\\[(\\\\d+)\\\\]$', '\\\\1', seg))", - " }", - " if (name != '') {", - " if (is.null(x[[name]])) x[[name]] <- list()", - " if (length(segs) == 1) {", - " if (!is.na(idx)) {", - " while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", - " x[[name]][[idx]] <- value", - " } else {", - " x[[name]] <- value", - " }", - " return(x)", - " }", - " if (!is.na(idx)) {", - " while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", - " x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value)", - " } else {", - " x[[name]] <- set_in(x[[name]], segs[-1], value)", - " }", - " return(x)", - " }", - " idx <- suppressWarnings(as.integer(seg))", - " if (is.na(idx)) return(x)", - " if (idx == 0) idx <- 1", - " while (length(x) < idx) x[[length(x) + 1]] <- list()", - " if (length(segs) == 1) {", - " x[[idx]] <- value", - " return(x)", - " }", - " x[[idx]] <- set_in(x[[idx]], segs[-1], value)", - " x", - " }", - " set_in(obj, segs, value)", - "}", - "is_mutating_improvement_action <- function(action) {", - " action_type <- tolower(trimws(as.character(action$type %||% 'note')))", - " action_type %in% c('set', 'replace', 'update')", - "}", - "", - "apply_for_role <- function(improvements_path, selected_role_dir, patched_role_dir) {", - " if (!file.exists(improvements_path)) return(invisible(FALSE))", - " improvements <- jsonlite::fromJSON(improvements_path, simplifyVector = FALSE)", - " dir.create(patched_role_dir, recursive = TRUE, showWarnings = FALSE)", - " applied <- FALSE", - " for (cid in names(improvements)) {", - " resp <- improvements[[cid]]", - " core <- resp$full_result %||% resp", - " items <- core$phenotype_improvements %||% list()", - " selected_path <- file.path(selected_role_dir, sprintf('%s.json', cid))", - " if (!file.exists(selected_path)) next", - " cohort_obj <- jsonlite::fromJSON(selected_path, simplifyVector = FALSE)", - " mutation_count <- 0L", - " if (length(items) > 0) {", - " for (item in items) {", - " if (is.null(item$actions)) next", - " for (act in item$actions) {", - " if (is_mutating_improvement_action(act)) {", - " cohort_obj <- apply_action(cohort_obj, act)", - " mutation_count <- mutation_count + 1L", - " }", - " }", - " }", - " }", - " if (mutation_count == 0L) next", - " applied <- TRUE", - " out_path <- file.path(patched_role_dir, sprintf('%s.json', cid))", - " jsonlite::write_json(cohort_obj, out_path, pretty = TRUE, auto_unbox = TRUE, na = 'null')", - " file.copy(out_path, file.path(patched_dir, sprintf('%s.json', cid)), overwrite = TRUE)", - " }", - " invisible(applied)", - "}", - "", - "apply_for_role(file.path(output_dir, 'improvements_target.json'), selected_target_dir, patched_target_dir)", - "apply_for_role(file.path(output_dir, 'improvements_comparator.json'), selected_comparator_dir, patched_comparator_dir)", - "apply_for_role(file.path(output_dir, 'improvements_outcome.json'), selected_outcome_dir, patched_outcome_dir)", - "for (path in list.files(selected_dir, pattern = '\\\\.(json)$', full.names = TRUE)) {", - " dest <- file.path(patched_dir, basename(path))", - " if (!file.exists(dest)) file.copy(path, dest, overwrite = TRUE)", - "}", - "" - ) - write_lines(file.path(scripts_dir, "02_apply_improvements.R"), script_02) - - script_03 <- c( - script_header, - "library(Strategus)", - "library(CohortGenerator)", - "library(DatabaseConnector)", - "library(dplyr)", - "library(CirceR)", - "library(SqlRender)", - "", - package_loader_lines, - "library(jsonlite)", - "library(ParallelLogger)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(cohort_json_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", - "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path = db_details_path)", - "dbms <- connectionDetails$dbms %||% 'postgresql'", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path = execution_settings_path)", - "executionSettings_cohorts <- exec$executionSettings", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "workDatabaseSchema <- exec$workDatabaseSchema", - "resultsDatabaseSchema <- exec$resultsDatabaseSchema", - "vocabularyDatabaseSchema <- exec$vocabularyDatabaseSchema", - "cohortTable <- exec$cohortTable", - "cohortIdFieldName <- exec$cohortIdFieldName", - "dir.create(exec$workFolder, recursive = TRUE, showWarnings = FALSE)", - "dir.create(exec$resultsFolder, recursive = TRUE, showWarnings = FALSE)", - "", - "cohort_settings <- read.csv(cohort_csv, stringsAsFactors = FALSE)", - "if (nrow(cohort_settings) > 0) {", - " id_col <- if ('cohort_id' %in% names(cohort_settings)) 'cohort_id' else 'cohortId'", - " for (i in seq_len(nrow(cohort_settings))) {", - " cohort_id <- cohort_settings[[id_col]][i]", - " sql_path <- file.path(sql_dir, sprintf('%s.sql', cohort_id))", - " if (!file.exists(sql_path)) {", - " json_path <- file.path(cohort_json_dir, sprintf('%s.json', cohort_id))", - " if (!file.exists(json_path)) stop('Missing cohort JSON: ', json_path)", - " json_text <- readChar(json_path, nchars = file.info(json_path)$size, useBytes = TRUE)", - " cohort_expression <- CirceR::cohortExpressionFromJson(json_text)", - " generateOptions <- CirceR::createGenerateOptions(", - " cohortIdFieldName = cohortIdFieldName,", - " cdmSchema = cdmDatabaseSchema,", - " targetTable = paste0(workDatabaseSchema, '.', cohortTable),", - " resultSchema = resultsDatabaseSchema,", - " vocabularySchema = vocabularyDatabaseSchema,", - " generateStats = TRUE", - " )", - " sql <- CirceR::buildCohortQuery(cohort_expression, generateOptions)", - " sql <- SqlRender::render(sql)", - " sql <- SqlRender::translate(sql, targetDialect = dbms)", - " writeLines(sql, sql_path, useBytes = TRUE)", - " }", - " }", - "}", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "cohortGeneratorModuleSpecifications <- cgModule$createModuleSpecifications(generateStats = TRUE)", - "", - "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", - " addSharedResources(cohortDefinitionSharedResource) %>%", - " addModuleSpecifications(cohortGeneratorModuleSpecifications)", - "", - "execute(", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = executionSettings_cohorts,", - " connectionDetails = connectionDetails", - ")", - "" - ) - write_lines(file.path(scripts_dir, "03_generate_cohorts.R"), script_03) - - script_04 <- c( - script_header, - "library(Keeper)", - "library(jsonlite)", - "library(DatabaseConnector)", - "", - package_loader_lines, - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "keeper_dir <- file.path(base_dir, 'keeper-case-review')", - "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", - "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'), simplifyVector = TRUE)$mapping", - "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", - "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path = db_details_path)", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path = execution_settings_path)", - "databaseId <- ''", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "cohortDatabaseSchema <- exec$workDatabaseSchema", - "cohortTable <- exec$cohortTable", - "", - "# TODO: Replace these placeholder concept vectors with study-specific Keeper settings.", - "keeperConcepts <- list(", - " doi = integer(0),", - " symptoms = integer(0),", - " comorbidities = integer(0),", - " drugs = integer(0),", - " diagnosticProcedures = integer(0),", - " measurements = integer(0),", - " alternativeDiagnosis = integer(0),", - " treatmentProcedures = integer(0),", - " complications = integer(0)", - ")", - "", - "for (i in seq_len(nrow(id_map))) {", - " cid <- id_map$cohort_id[i]", - " role <- id_map$role[i]", - " cohort_name <- id_map$cohort_name[i]", - " role_dir <- file.path(keeper_dir, role)", - " dir.create(role_dir, recursive = TRUE, showWarnings = FALSE)", - " keeper <- createKeeper(", - " connectionDetails = connectionDetails,", - " databaseId = databaseId,", - " cdmDatabaseSchema = cdmDatabaseSchema,", - " cohortDatabaseSchema = cohortDatabaseSchema,", - " cohortTable = cohortTable,", - " cohortDefinitionId = cid,", - " cohortName = cohort_name,", - " sampleSize = 100,", - " assignNewId = TRUE,", - " useAncestor = TRUE,", - " doi = keeperConcepts$doi,", - " symptoms = keeperConcepts$symptoms,", - " comorbidities = keeperConcepts$comorbidities,", - " drugs = keeperConcepts$drugs,", - " diagnosticProcedures = keeperConcepts$diagnosticProcedures,", - " measurements = keeperConcepts$measurements,", - " alternativeDiagnosis = keeperConcepts$alternativeDiagnosis,", - " treatmentProcedures = keeperConcepts$treatmentProcedures,", - " complications = keeperConcepts$complications", - " )", - " out_path <- file.path(role_dir, sprintf('%s.csv', cid))", - " write.csv(keeper, out_path, row.names = FALSE)", - "}", - "", - "# TODO: When ACP is implemented for cohort methods, add optional LLM-based Keeper row review here.", - "" - ) - write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) - - script_05 <- c( - script_header, - "library(Strategus)", - "library(CohortDiagnostics)", - "library(CohortGenerator)", - "library(DatabaseConnector)", - "library(dplyr)", - "", - package_loader_lines, - "", - sprintf("base_dir <- '%s'", base_dir), - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(cohort_json_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", - "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path = db_details_path)", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path = execution_settings_path)", - "executionSettings_diagnostics <- exec$executionSettings", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "", - "cdModule <- CohortDiagnosticsModule$new()", - "cohortDiagnosticsModuleSpecifications <- cdModule$createModuleSpecifications(", - " runInclusionStatistics = TRUE,", - " runIncludedSourceConcepts = TRUE,", - " runOrphanConcepts = TRUE,", - " runTimeSeries = FALSE,", - " runVisitContext = TRUE,", - " runBreakdownIndexEvents = TRUE,", - " runIncidenceRate = TRUE,", - " runCohortRelationship = TRUE,", - " runTemporalCohortCharacterization = TRUE", - ")", - "", - "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", - " addSharedResources(cohortDefinitionSharedResource) %>%", - " addModuleSpecifications(cohortDiagnosticsModuleSpecifications)", - "", - "execute(", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = executionSettings_diagnostics,", - " connectionDetails = connectionDetails", - ")", - "" - ) - write_lines(file.path(scripts_dir, "05_diagnostics.R"), script_05) - - script_06 <- c( - script_header, - "library(Strategus)", - "library(CohortGenerator)", - "library(CohortIncidence)", - "library(jsonlite)", - "library(ParallelLogger)", - "", - package_loader_lines, - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "analysis_settings_dir <- file.path(base_dir, 'analysis-settings')", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "defaults <- jsonlite::fromJSON(file.path(output_dir, 'cm_analysis_defaults.json'), simplifyVector = TRUE)", - "conceptSetSelections <- jsonlite::fromJSON(file.path(output_dir, 'cm_concept_set_selections.json'), simplifyVector = FALSE)", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(cohort_json_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "getDbDefaults <- defaults$get_db_cohort_method_data", - "studyPopulationDefaults <- defaults$create_study_population", - "psDefaults <- defaults$create_ps", - "psAdjustmentDefaults <- defaults$ps_adjustment %||% list()", - "matchDefaults <- defaults$match_on_ps", - "stratifyDefaults <- defaults$stratify_by_ps %||% list()", - "outcomeModelDefaults <- defaults$fit_outcome_model", - "covariateConceptDefaults <- defaults$covariate_concept_sets %||% list()", - "comparison_payload <- jsonlite::fromJSON(file.path(output_dir, 'cm_comparisons.json'), simplifyVector = FALSE)", - "comparisons <- comparison_payload$comparisons %||% list()", - "if (length(comparisons) == 0) stop('No comparisons found in cm_comparisons.json')", - "comparison <- comparisons[[1]]", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "lookup_cohort_name <- function(cohort_id, fallback = NULL) {", - " row <- cohortDefinitionSet[as.integer(cohortDefinitionSet$cohortId) == as.integer(cohort_id), , drop = FALSE]", - " if (nrow(row) > 0 && 'cohortName' %in% names(row) && nzchar(as.character(row$cohortName[1]))) {", - " return(as.character(row$cohortName[1]))", - " }", - " fallback %||% sprintf('Cohort %s', cohort_id)", - "}", - "to_ci_anchor <- function(anchor) {", - " anchor <- tolower(trimws(as.character(anchor %||% 'cohort start')))", - " if (identical(anchor, 'cohort end')) 'end' else 'start'", - "}", - "", - "analyticSettingsProfile <- defaults$profile_name %||% 'Analytic Setting 1'", - "psAdjustmentStrategy <- psAdjustmentDefaults$strategy %||% 'match_on_ps'", - "psTrimmingStrategy <- psAdjustmentDefaults$trimmingStrategy %||% 'none'", - "psTrimmingPercent <- as.numeric(psAdjustmentDefaults$trimmingPercent %||% 5)", - "if (is.na(psTrimmingPercent)) psTrimmingPercent <- 5", - "equipoiseLowerBound <- as.numeric(psAdjustmentDefaults$equipoiseLowerBound %||% 0.25)", - "equipoiseUpperBound <- as.numeric(psAdjustmentDefaults$equipoiseUpperBound %||% 0.75)", - "if (is.na(equipoiseLowerBound)) equipoiseLowerBound <- 0.25", - "if (is.na(equipoiseUpperBound)) equipoiseUpperBound <- 0.75", - "matchMaxRatio <- as.integer(matchDefaults$maxRatio %||% 1L)", - "if (is.na(matchMaxRatio)) matchMaxRatio <- 1L", - "derivedOutcomeStratified <- if (identical(psAdjustmentStrategy, 'stratify_by_ps')) {", - " TRUE", - "} else if (identical(psAdjustmentStrategy, 'match_on_ps')) {", - " matchMaxRatio != 1L", - "} else {", - " FALSE", - "}", - "", - "target_id <- as.numeric(comparison$target$cohort_id %||% NA_real_)", - "comparator_id <- as.numeric(comparison$comparator$cohort_id %||% NA_real_)", - "outcome_ids <- vapply(comparison$outcomes %||% list(), function(x) as.numeric(x$cohort_id %||% NA_real_), numeric(1))", - "if (is.na(target_id)) stop('Missing target cohort ID in cm_comparisons.json')", - "if (is.na(comparator_id)) stop('Missing comparator cohort ID in cm_comparisons.json')", - "if (length(outcome_ids) == 0) stop('Missing outcome cohort IDs in cm_comparisons.json')", - "target_name <- lookup_cohort_name(target_id, comparison$target$name %||% 'Target')", - "comparator_name <- lookup_cohort_name(comparator_id, comparison$comparator$name %||% 'Comparator')", - "outcome_names <- vapply(comparison$outcomes %||% list(), function(x) {", - " oid <- as.numeric(x$cohort_id %||% NA_real_)", - " lookup_cohort_name(oid, x$name %||% sprintf('Outcome %s', oid))", - "}, character(1))", - "", - "negativeControlConceptSet <- conceptSetSelections$negative_control %||% list()", - "covariateConceptSelections <- conceptSetSelections$covariates %||% list()", - "includedConceptSetId <- as.integer(covariateConceptDefaults$include_concept_set_id %||% covariateConceptSelections$include$concept_set_id %||% NA_integer_)", - "excludedConceptSetId <- as.integer(covariateConceptDefaults$exclude_concept_set_id %||% covariateConceptSelections$exclude$concept_set_id %||% NA_integer_)", - "includedCovariateConceptIds <- numeric(0)", - "excludedCovariateConceptIds <- numeric(0)", - "if (!is.na(includedConceptSetId)) message('TODO: Replace dummy covariate include concept set ', includedConceptSetId, ' with actual concept IDs before production use.')", - "if (!is.na(excludedConceptSetId)) message('TODO: Replace dummy covariate exclude concept set ', excludedConceptSetId, ' with actual concept IDs before production use.')", - "if (isTRUE(negativeControlConceptSet$enabled %||% FALSE)) message('TODO: Negative control concept set selected as dummy placeholder: ', negativeControlConceptSet$concept_set_id %||% NA_integer_)", - "", - "# Shared cohort definitions are included so downstream modules can resolve cohort metadata.", - "# Cohort generation itself is intentionally not included here; run 03_generate_cohorts.R first.", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "", - "# Characterization module: one characterization configuration for target and comparator cohorts.", - "characterizationTargetIds <- as.numeric(unique(c(target_id, comparator_id)))", - "characterizationModule <- CharacterizationModule$new()", - "characterizationModuleSpecifications <- characterizationModule$createModuleSpecifications(", - " targetIds = characterizationTargetIds,", - " outcomeIds = as.numeric(outcome_ids),", - " limitToFirstInNDays = as.numeric(rep(if (isTRUE(getDbDefaults$firstExposureOnly %||% TRUE)) 99999 else 0, length(characterizationTargetIds))),", - " minPriorObservation = as.numeric(getDbDefaults$washoutPeriod %||% 0),", - " outcomeWashoutDays = as.numeric(rep(as.numeric(studyPopulationDefaults$priorOutcomeLookback %||% 99999), length(outcome_ids))),", - " riskWindowStart = as.numeric(studyPopulationDefaults$riskWindowStart %||% 0),", - " startAnchor = studyPopulationDefaults$startAnchor %||% 'cohort start',", - " riskWindowEnd = as.numeric(studyPopulationDefaults$riskWindowEnd %||% 0),", - " endAnchor = studyPopulationDefaults$endAnchor %||% 'cohort end',", - " mode = 'CohortIncidence'", - ")", - "", - "# CohortIncidence module: one incidence analysis across target/comparator cohorts and outcomes.", - "ciTargets <- list(", - " CohortIncidence::createCohortRef(id = target_id, name = target_name),", - " CohortIncidence::createCohortRef(id = comparator_id, name = comparator_name)", - ")", - "ciOutcomes <- lapply(seq_along(outcome_ids), function(i) {", - " CohortIncidence::createOutcomeDef(", - " id = as.numeric(outcome_ids[[i]]),", - " name = outcome_names[[i]],", - " cohortId = as.numeric(outcome_ids[[i]]),", - " cleanWindow = as.numeric(studyPopulationDefaults$priorOutcomeLookback %||% 99999)", - " )", - "})", - "ciTar <- CohortIncidence::createTimeAtRiskDef(", - " id = 1,", - " startWith = to_ci_anchor(studyPopulationDefaults$startAnchor %||% 'cohort start'),", - " startOffset = as.numeric(studyPopulationDefaults$riskWindowStart %||% 0),", - " endWith = to_ci_anchor(studyPopulationDefaults$endAnchor %||% 'cohort end'),", - " endOffset = as.numeric(studyPopulationDefaults$riskWindowEnd %||% 0)", - ")", - "ciAnalysis <- CohortIncidence::createIncidenceAnalysis(", - " targets = c(target_id, comparator_id),", - " outcomes = outcome_ids,", - " tars = c(1)", - ")", - "ciDesign <- CohortIncidence::createIncidenceDesign(", - " targetDefs = ciTargets,", - " outcomeDefs = ciOutcomes,", - " tars = list(ciTar),", - " analysisList = list(ciAnalysis),", - " strataSettings = CohortIncidence::createStrataSettings(byYear = TRUE, byGender = TRUE)", - ")", - "ciModule <- CohortIncidenceModule$new()", - "cohortIncidenceModuleSpecifications <- ciModule$createModuleSpecifications(", - " irDesign = ciDesign$toList()", - ")", - "", - "# CohortMethod module: one comparative analysis for the selected target/comparator/outcomes.", - "priorOutcomeLookback <- studyPopulationDefaults$priorOutcomeLookback %||% 99999L", - "riskWindowStart <- studyPopulationDefaults$riskWindowStart %||% 0L", - "startAnchor <- studyPopulationDefaults$startAnchor %||% 'cohort start'", - "riskWindowEnd <- studyPopulationDefaults$riskWindowEnd %||% 0L", - "endAnchor <- studyPopulationDefaults$endAnchor %||% 'cohort end'", - "outcomes <- lapply(outcome_ids, function(outcome_id) {", - " CohortMethod::createOutcome(", - " outcomeId = outcome_id,", - " outcomeOfInterest = TRUE,", - " priorOutcomeLookback = priorOutcomeLookback,", - " riskWindowStart = riskWindowStart,", - " startAnchor = startAnchor,", - " riskWindowEnd = riskWindowEnd,", - " endAnchor = endAnchor", - " )", - "})", - "", - "targetComparatorOutcomesList <- list(", - " CohortMethod::createTargetComparatorOutcomes(", - " targetId = target_id,", - " comparatorId = comparator_id,", - " outcomes = outcomes,", - " excludedCovariateConceptIds = excludedCovariateConceptIds,", - " includedCovariateConceptIds = includedCovariateConceptIds", - " )", - ")", - "", - "covariateSettings <- FeatureExtraction::createDefaultCovariateSettings()", - "getDbCohortMethodDataArgs <- CohortMethod::createGetDbCohortMethodDataArgs(", - " removeDuplicateSubjects = getDbDefaults$removeDuplicateSubjects,", - " firstExposureOnly = getDbDefaults$firstExposureOnly,", - " washoutPeriod = getDbDefaults$washoutPeriod,", - " restrictToCommonPeriod = getDbDefaults$restrictToCommonPeriod,", - " studyStartDate = getDbDefaults$studyStartDate %||% '',", - " studyEndDate = getDbDefaults$studyEndDate %||% '',", - " maxCohortSize = studyPopulationDefaults$maxCohortSize %||% getDbDefaults$maxCohortSize %||% 0,", - " covariateSettings = covariateSettings", - ")", - "createStudyPopulationArgs <- CohortMethod::createCreateStudyPopulationArgs(", - " removeSubjectsWithPriorOutcome = studyPopulationDefaults$removeSubjectsWithPriorOutcome,", - " priorOutcomeLookback = studyPopulationDefaults$priorOutcomeLookback,", - " minDaysAtRisk = studyPopulationDefaults$minDaysAtRisk,", - " riskWindowStart = studyPopulationDefaults$riskWindowStart,", - " startAnchor = studyPopulationDefaults$startAnchor,", - " riskWindowEnd = studyPopulationDefaults$riskWindowEnd,", - " endAnchor = studyPopulationDefaults$endAnchor,", - " censorAtNewRiskWindow = studyPopulationDefaults$censorAtNewRiskWindow", - ")", - "psPrior <- if (isTRUE(psDefaults$useRegularization %||% TRUE)) {", - " Cyclops::createPrior(priorType = 'laplace', exclude = c(0), useCrossValidation = TRUE)", - "} else {", - " Cyclops::createPrior(priorType = 'none')", - "}", - "createPsArgs <- if (identical(psAdjustmentStrategy, 'none') && identical(psTrimmingStrategy, 'none')) NULL else CohortMethod::createCreatePsArgs(", - " estimator = psDefaults$estimator,", - " maxCohortSizeForFitting = psDefaults$maxCohortSizeForFitting,", - " errorOnHighCorrelation = isTRUE(psDefaults$errorOnHighCorrelation %||% FALSE),", - " prior = psPrior", - ")", - "trimByPsArgs <- if (identical(psTrimmingStrategy, 'by_percent')) {", - " CohortMethod::createTrimByPsArgs(", - " trimFraction = psTrimmingPercent / 100,", - " trimMethod = 'symmetric'", - " )", - "} else if (identical(psTrimmingStrategy, 'by_equipoise')) {", - " CohortMethod::createTrimByPsArgs(", - " equipoiseBounds = c(equipoiseLowerBound, equipoiseUpperBound)", - " )", - "} else {", - " NULL", - "}", - "matchOnPsArgs <- if (identical(psAdjustmentStrategy, 'match_on_ps')) CohortMethod::createMatchOnPsArgs(", - " caliper = matchDefaults$caliper,", - " caliperScale = matchDefaults$caliperScale,", - " maxRatio = matchDefaults$maxRatio", - ") else NULL", - "stratifyByPsArgs <- if (identical(psAdjustmentStrategy, 'stratify_by_ps')) CohortMethod::createStratifyByPsArgs(", - " numberOfStrata = stratifyDefaults$numberOfStrata,", - " baseSelection = stratifyDefaults$baseSelection", - ") else NULL", - "outcomeModelPrior <- if (isTRUE(outcomeModelDefaults$useRegularization %||% TRUE)) {", - " Cyclops::createPrior(priorType = 'laplace', useCrossValidation = TRUE)", - "} else {", - " Cyclops::createPrior(priorType = 'none')", - "}", - "fitOutcomeModelArgs <- CohortMethod::createFitOutcomeModelArgs(", - " modelType = outcomeModelDefaults$modelType,", - " stratified = outcomeModelDefaults$stratified %||% derivedOutcomeStratified,", - " useCovariates = isTRUE(outcomeModelDefaults$useCovariates %||% FALSE),", - " inversePtWeighting = isTRUE(outcomeModelDefaults$inversePtWeighting %||% FALSE),", - " prior = outcomeModelPrior", - ")", - "", - "cmAnalysisList <- list(", - " CohortMethod::createCmAnalysis(", - " analysisId = as.integer(defaults$analysis_id %||% 1L),", - " description = analyticSettingsProfile %||% comparison$label %||% 'Default cohort method analysis',", - " getDbCohortMethodDataArgs = getDbCohortMethodDataArgs,", - " createStudyPopulationArgs = createStudyPopulationArgs,", - " createPsArgs = createPsArgs,", - " trimByPsArgs = trimByPsArgs,", - " matchOnPsArgs = matchOnPsArgs,", - " stratifyByPsArgs = stratifyByPsArgs,", - " fitOutcomeModelArgs = fitOutcomeModelArgs", - " )", - ")", - "cmAnalysesSpecifications <- CohortMethod::createCmAnalysesSpecifications(", - " cmAnalysisList = cmAnalysisList,", - " targetComparatorOutcomesList = targetComparatorOutcomesList,", - " analysesToExclude = NULL,", - " refitPsForEveryOutcome = FALSE,", - " refitPsForEveryStudyPopulation = TRUE,", - " cmDiagnosticThresholds = CohortMethod::createCmDiagnosticThresholds()", - ")", - "cmModule <- CohortMethodModule$new()", - "cohortMethodModuleSpecifications <- cmModule$createModuleSpecifications(", - " cmAnalysesSpecifications = cmAnalysesSpecifications$toList()", - ")", - "", - "analysisSpecifications <- Strategus::createEmptyAnalysisSpecifications()", - "analysisSpecifications <- Strategus::addSharedResources(analysisSpecifications, cohortDefinitionSharedResource)", - "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, characterizationModuleSpecifications)", - "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, cohortIncidenceModuleSpecifications)", - "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, cohortMethodModuleSpecifications)", - "analysis_spec_path <- file.path(analysis_settings_dir, 'analysisSpecification.json')", - "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)", - "", - "jsonlite::write_json(", - " list(", - " comparison_label = comparison$label %||% '',", - " target_id = target_id,", - " comparator_id = comparator_id,", - " outcome_ids = as.list(outcome_ids),", - " analysis_specification_path = analysis_spec_path,", - " modules = c('CharacterizationModule', 'CohortIncidenceModule', 'CohortMethodModule'),", - " defaults_path = file.path(output_dir, 'cm_analysis_defaults.json'),", - " cm_analysis_json_path = file.path(analysis_settings_dir, 'cmAnalysis.json'),", - " concept_set_selections_path = file.path(output_dir, 'cm_concept_set_selections.json'),", - " negative_control_concept_set_id = negativeControlConceptSet$concept_set_id %||% NULL,", - " study_start_date = getDbDefaults$studyStartDate %||% '',", - " study_end_date = getDbDefaults$studyEndDate %||% '',", - " ps_adjustment_strategy = psAdjustmentStrategy,", - " ps_trimming_strategy = psTrimmingStrategy,", - " covariate_include_all_concepts = covariateConceptDefaults$include_all_concepts %||% covariateConceptSelections$include_all_concepts %||% TRUE,", - " covariate_include_concept_set_id = if (is.na(includedConceptSetId)) NULL else includedConceptSetId,", - " covariate_exclude_concept_set_id = if (is.na(excludedConceptSetId)) NULL else excludedConceptSetId,", - " analytic_settings_profile_name = defaults$profile_name %||% NULL,", - " analytic_settings_customized_sections = defaults$customized_sections %||% character(0),", - " TODO = 'Replace dummy concept set selections with actual concept definitions and concept IDs when ACP/MCP support is implemented.'", - " ),", - " file.path(output_dir, 'cm_analysis_state.json'),", - " pretty = TRUE,", - " auto_unbox = TRUE", - ")", - "", - "# Execute the just-created Strategus specification.", - "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", - "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path = db_details_path)", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path = execution_settings_path)", - "", - "result <- Strategus::execute(", - " connectionDetails = connectionDetails,", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = exec$executionSettings", - ")", - "", - "result_path <- file.path(analysis_settings_dir, 'strategus_execute_result.rds')", - "saveRDS(result, result_path)", - "message('Strategus execution result saved to: ', result_path)", - "" - ) - write_lines(file.path(scripts_dir, "06_cm_spec.R"), script_06) - - if (interactive) { - cat("\n== Session Summary ==\n") - cat(sprintf("Study intent: %s\n", studyIntent)) - cat(sprintf("Comparison: %s\n", comparisonLabel)) - cat(sprintf("Target: %s (atlas %s -> cohort %s)\n", target_name, selected_target_id, new_target_id)) - cat(sprintf("Comparator: %s (atlas %s -> cohort %s)\n", comparator_name, selected_comparator_id, new_comparator_id)) - cat("Outcomes:\n") - for (i in seq_along(new_outcome_ids)) { - cat(sprintf(" - %s (atlas %s -> cohort %s)\n", outcome_names[[i]], selected_outcome_ids[[i]], new_outcome_ids[[i]])) - } - if (isTRUE(negative_control_enabled)) { - cat(sprintf("Negative control concept set: %s\n", negativeControlConceptSetId)) - } - if (isTRUE(covariate_enabled)) { - include_label <- if (is.null(includeCovariateConceptSetId)) "all concepts" else as.character(includeCovariateConceptSetId) - exclude_label <- if (is.null(excludeCovariateConceptSetId)) "none" else as.character(excludeCovariateConceptSetId) - cat(sprintf("Covariate concept sets: include=%s, exclude=%s\n", include_label, exclude_label)) - } - cat(sprintf("Cohort ID remap: %s\n", if (isTRUE(use_mapping)) sprintf("enabled (base %s)", cohortIdBase) else "disabled")) - cat(sprintf("Analytic settings mode: %s\n", analytic_settings_mode)) - cat(sprintf("Analytic settings profile: %s\n", effective_analytic_settings$profile_name)) - section_label <- if (length(effective_analytic_settings$customized_sections) == 0) { - "defaults only" - } else { - paste(as.character(effective_analytic_settings$customized_sections), collapse = ", ") - } - cat(sprintf("Customized analytic sections: %s\n", section_label)) - if (identical(analytic_settings_mode, "free_text")) { - cat(sprintf("Analytic settings description: %s\n", analytic_settings_description)) - cat(sprintf("Analytic settings recommendation source: %s\n", analytic_settings_recommendation_source)) - cat(sprintf("Analytic settings recommendation: %s (%s)\n", analytic_settings_recommendation_path, analytic_settings_recommendation_status)) - if (!is.na(analytic_settings_acp_response_path)) { - cat(sprintf("ACP specifications response: %s\n", analytic_settings_acp_response_path)) - } - } - cat("Generated scripts:\n") - cat(" - 02_apply_improvements.R\n") - cat(" - 03_generate_cohorts.R\n") - cat(" - 04_keeper_review.R\n") - cat(" - 05_diagnostics.R\n") - cat(" - 06_cm_spec.R\n") - cat("Status/TODO artifacts:\n") - cat(sprintf(" - %s\n", improvements_status_path)) - cat(sprintf(" - %s\n", cm_evaluation_todo_path)) +runStrategusCohortMethodsShell <- function(...) { + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to run the Strategus cohort-methods shell.") } - - invisible(list( - output_dir = output_dir, - scripts_dir = scripts_dir, - cohort_methods_intent_split = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), - manual_intent = manual_intent_path, - manual_inputs = manual_inputs_path, - cm_comparisons = cm_comparisons_path, - improvements_status = improvements_status_path, - improvements_target = improvements_target_path, - improvements_comparator = improvements_comparator_path, - improvements_outcome = improvements_outcome_path, - cm_concept_set_selections = cm_concept_set_selections_path, - cm_analysis_json = cm_analysis_json_path, - cohort_csv = cohort_csv, - state = state_path - )) + slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell(...) } diff --git a/R/OHDSIAssistant/R/strategus_incidence_shell.R b/R/OHDSIAssistant/R/strategus_incidence_shell.R index 259aebf..f60bef1 100644 --- a/R/OHDSIAssistant/R/strategus_incidence_shell.R +++ b/R/OHDSIAssistant/R/strategus_incidence_shell.R @@ -1,1613 +1,10 @@ -#' Interactive shell to generate Strategus CohortIncidence scripts -#' @param outputDir directory where scripts and artifacts will be written -#' @param acpUrl ACP base URL -#' @param studyIntent study intent text -#' @param topK number of candidates retrieved from MCP search -#' @param maxResults max phenotypes to show -#' @param candidateLimit max candidates to pass to LLM -#' @param indexDir phenotype index directory (contains definitions/) -#' @param interactive whether to prompt for inputs -#' @param bannerPath optional path to ASCII banner -#' @param studyAgentBaseDir base directory to resolve relative paths (outputDir, indexDir, bannerPath) -#' @param reset when TRUE, delete outputDir before running -#' @param allowCache reuse cached artifacts when present -#' @param promptOnCache prompt before using cached artifacts -#' @param autoApplyImprovements when TRUE, apply improvements without prompting (defaults to TRUE for non-interactive) -#' @param resume when TRUE, resume from last checkpoint if present +#' Compatibility wrapper for the Strategus incidence shell +#' @param ... forwarded to slashOhdsiStrategusAssistant::runStrategusIncidenceShell #' @return invisible list with output paths #' @export -runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incidence", - acpUrl = "http://127.0.0.1:8765", - studyIntent = NULL, - topK = 20, - maxResults = 20, - candidateLimit = 20, - indexDir = Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index"), - interactive = TRUE, - bannerPath = "ohdsi-logo-ascii.txt", - studyAgentBaseDir = Sys.getenv("STUDY_AGENT_BASE_DIR", ""), - reset = FALSE, - allowCache = TRUE, - promptOnCache = TRUE, - autoApplyImprovements = NA, - resume = FALSE) { - `%||%` <- function(x, y) if (is.null(x)) y else x - - ensure_dir <- function(path) { - if (!dir.exists(path)) dir.create(path, recursive = TRUE) +runStrategusIncidenceShell <- function(...) { + if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { + stop("slashOhdsiStrategusAssistant must be installed or loaded to run the Strategus incidence shell.") } - - normalize_dialogue_step <- .studyAgentSlashNormalizeIncidenceDialogueStep - - dialogue_step_label <- .studyAgentSlashIncidenceDialogueStepLabel - compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext - - dialogue_acp_client <- new.env(parent = emptyenv()) - dialogue_acp_client$client <- NULL - build_workflow_stage_context <- function(studyIntent, dialogue_state) { - .studyAgentSlashBuildIncidenceWorkflowStageContext( - study_intent = studyIntent, - dialogue_state = dialogue_state, - interactive = interactive - ) - } - acp_timeout_seconds <- function(default = 180) { - timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) - if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default - timeout_seconds - } - - acp_client_is_ready <- function(client) { - .studyAgentSlashAcpIsConnected(client) - } - - create_acp_client <- function(url, token = NULL, check = TRUE) { - .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) - } - - ensure_workflow_dialogue_client <- function(url) { - if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) - if (is.null(url) || !nzchar(trimws(url))) return(FALSE) - tryCatch({ - dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) - TRUE - }, error = function(e) { - FALSE - }) - } - - call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { - if (!acp_client_is_ready(dialogue_acp_client$client)) { - if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") - } - .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) - } - - dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( - interactive = interactive, - study_intent_getter = function() studyIntent, - build_stage_context = build_workflow_stage_context, - call_dialogue = function(stage_context, message) { - if (!ensure_workflow_dialogue_client(acpUrl)) { - stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") - } - message("Calling ACP flow: workflow_context_dialogue") - .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) - }, - empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why are these candidates weak here?" - ) - dialogue_state <- dialogue_session$state - set_dialogue_context <- dialogue_session$set_context - readline_with_dialogue <- dialogue_session$readline - - prompt_yesno <- function(prompt, default = TRUE) { - if (!isTRUE(interactive)) return(default) - suffix <- if (default) "[Y/n]" else "[y/N]" - resp <- tolower(trimws(readline_with_dialogue(sprintf("%s %s ", prompt, suffix)))) - if (resp == "") return(default) - if (resp %in% c("y", "yes")) return(TRUE) - if (resp %in% c("n", "no")) return(FALSE) - default - } - - maybe_use_cache <- function(path, label) { - if (!allowCache || !file.exists(path)) return(FALSE) - if (!promptOnCache) return(TRUE) - prompt_yesno(sprintf("Use cached %s at %s?", label, path), default = TRUE) - } - - if (is.na(autoApplyImprovements)) { - autoApplyImprovements <- !isTRUE(interactive) - } - - read_json <- function(path) { - jsonlite::fromJSON(path, simplifyVector = FALSE) - } - - write_json <- function(x, path) { - jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE) - } - - acp_try <- function(path, body, label) { - repeat { - resp <- NULL - err <- NULL - flow_name <- sub("^/flows/", "", as.character(path)) - resp <- tryCatch( - call_shell_acp_flow(flow_name, body), - error = function(e) { - err <<- e - NULL - } - ) - if (is.null(err)) return(resp) - msg <- conditionMessage(err) - if (!isTRUE(interactive)) stop(msg) - retry <- prompt_yesno(sprintf("ACP call failed (%s). Try again?", msg), default = TRUE) - if (!retry) { - mark_checkpoint(label, list(path = path, error = msg)) - stop(sprintf("Stopping after ACP error. Resume with resume=TRUE once ready. (%s)", label)) - } - } - } - - checkpoint_path <- function(label) { - file.path(output_dir, paste0("checkpoint_", label, ".json")) - } - - mark_checkpoint <- function(label, payload = list()) { - checkpoint <- list(step = label) - if (length(payload) > 0) checkpoint <- c(checkpoint, payload) - write_json(checkpoint, checkpoint_path(label)) - } - - has_checkpoint <- function(label) { - file.exists(checkpoint_path(label)) - } - - is_absolute_path <- function(path) { - grepl("^(/|[A-Za-z]:[\\\\/])", path) - } - - resolve_path <- function(path, base_dir = "") { - if (!nzchar(path)) return(path) - if (is_absolute_path(path)) return(path) - if (nzchar(base_dir)) return(file.path(base_dir, path)) - path - } - - phenotype_definition_path <- function(phenotype_id, index_def_dir) { - file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", phenotype_id, fixed = TRUE))) - } - - stop_if_unsupported_selected <- function(phenotype_ids, role_label) { - unsupported <- phenotype_ids[!grepl("^ohdsi:", phenotype_ids %||% character(0))] - if (length(unsupported) > 0) { - stop( - sprintf( - paste0( - "Selected %s phenotype(s) include non-OHDSI ids (%s). ", - "This demo workflow does not yet support converting non-OHDSI phenotype definitions ", - "into computable OHDSI cohort definitions. Please re-run and choose an OHDSI phenotype." - ), - role_label, - paste(unique(unsupported), collapse = ", ") - ) - ) - } - } - - copy_cohort_json_multi <- function(source_id, dest_id, dest_dirs, index_def_dir) { - src <- phenotype_definition_path(source_id, index_def_dir) - if (!file.exists(src)) stop(sprintf("Cohort JSON not found: %s", src)) - dests <- character(0) - for (dest_dir in dest_dirs) { - ensure_dir(dest_dir) - dest <- file.path(dest_dir, sprintf("%s.json", dest_id)) - file.copy(src, dest, overwrite = TRUE) - dests <- c(dests, dest) - } - dests - } - - apply_action <- function(obj, action) { - path <- action$path %||% "" - value <- action$value - if (!nzchar(path)) return(obj) - segs <- strsplit(path, "/", fixed = TRUE)[[1]] - segs <- segs[segs != ""] - - set_in <- function(x, segs, value) { - if (length(segs) == 0) return(value) - seg <- segs[[1]] - name <- seg - idx <- NA_integer_ - if (grepl("\\[\\d+\\]$", seg)) { - name <- sub("\\[\\d+\\]$", "", seg) - idx <- as.integer(sub("^.*\\[(\\d+)\\]$", "\\1", seg)) - } - if (name != "") { - if (is.null(x[[name]])) x[[name]] <- list() - if (length(segs) == 1) { - if (!is.na(idx)) { - if (length(x[[name]]) < idx) { - while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- NULL - } - x[[name]][[idx]] <- value - } else { - x[[name]] <- value - } - return(x) - } - if (!is.na(idx)) { - if (length(x[[name]]) < idx) { - while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() - } - x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value) - } else { - x[[name]] <- set_in(x[[name]], segs[-1], value) - } - return(x) - } - idx <- suppressWarnings(as.integer(seg)) - if (is.na(idx)) return(x) - if (idx == 0) idx <- 1 - if (length(x) < idx) { - while (length(x) < idx) x[[length(x) + 1]] <- list() - } - if (length(segs) == 1) { - x[[idx]] <- value - return(x) - } - x[[idx]] <- set_in(x[[idx]], segs[-1], value) - x - } - - set_in(obj, segs, value) - } - - study_base_dir <- "" - if (nzchar(studyAgentBaseDir)) { - study_base_dir <- normalizePath(studyAgentBaseDir, winslash = "/", mustWork = FALSE) - } - outputDir <- resolve_path(outputDir, study_base_dir) - outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) - if (isTRUE(reset) && dir.exists(outputDir)) { - ok <- TRUE - if (isTRUE(interactive)) { - ok <- prompt_yesno(sprintf("Delete existing output directory %s?", outputDir), default = FALSE) - } - if (ok) { - unlink(outputDir, recursive = TRUE, force = TRUE) - } - } - base_dir <- outputDir - index_dir <- resolve_path(indexDir, study_base_dir) - index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) - if (!dir.exists(index_dir) && !is_absolute_path(indexDir) && !nzchar(studyAgentBaseDir)) { - alt <- file.path(getwd(), "OHDSI-Study-Agent", indexDir) - if (dir.exists(alt)) index_dir <- normalizePath(alt, winslash = "/", mustWork = FALSE) - } - index_def_dir <- file.path(index_dir, "definitions") - if (!dir.exists(index_def_dir)) stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) - - output_dir <- file.path(base_dir, "outputs") - selected_dir <- file.path(base_dir, "selected-cohorts") - patched_dir <- file.path(base_dir, "patched-cohorts") - selected_target_dir <- file.path(base_dir, "selected-target-cohorts") - selected_outcome_dir <- file.path(base_dir, "selected-outcome-cohorts") - patched_target_dir <- file.path(base_dir, "patched-target-cohorts") - patched_outcome_dir <- file.path(base_dir, "patched-outcome-cohorts") - keeper_dir <- file.path(base_dir, "keeper-case-review") - analysis_settings_dir <- file.path(base_dir, "analysis-settings") - scripts_dir <- file.path(base_dir, "scripts") - - ensure_dir(output_dir) - ensure_dir(selected_dir) - ensure_dir(patched_dir) - ensure_dir(selected_target_dir) - ensure_dir(selected_outcome_dir) - ensure_dir(patched_target_dir) - ensure_dir(patched_outcome_dir) - ensure_dir(keeper_dir) - ensure_dir(analysis_settings_dir) - ensure_dir(scripts_dir) - - if (interactive) { - banner_path <- resolve_path(bannerPath, study_base_dir) - banner_path <- normalizePath(banner_path, winslash = "/", mustWork = FALSE) - if (!file.exists(banner_path) && !is_absolute_path(bannerPath) && !nzchar(studyAgentBaseDir)) { - alt <- file.path(getwd(), "OHDSI-Study-Agent", bannerPath) - if (file.exists(alt)) banner_path <- normalizePath(alt, winslash = "/", mustWork = FALSE) - } - if (file.exists(banner_path)) { - cat(paste(readLines(banner_path, warn = FALSE), collapse = "\n"), "\n") - } - cat("\nStudy Agent: Strategus CohortIncidence shell\n") - } - - default_intent <- studyIntent %||% "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" - if (interactive) { - set_dialogue_context("study_intent", context = list(default_intent = default_intent)) - entered <- readline_with_dialogue(sprintf("Study intent [%s]: ", default_intent)) - if (nzchar(trimws(entered))) studyIntent <- entered else studyIntent <- default_intent - } else { - if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) studyIntent <- default_intent - } - - if (interactive) { - cat("\nConnecting to ACP...\n") - } - acp_connect(acpUrl) - - intent_split_path <- file.path(output_dir, "intent_split.json") - intent_response <- NULL - if (interactive) { - cat("\n== Step 1: Parse study intent into target/outcome statements ==\n") - } - set_dialogue_context("intent_split", context = list(study_intent = studyIntent)) - if (maybe_use_cache(intent_split_path, "intent split")) { - intent_response <- read_json(intent_split_path) - } else { - message("Calling ACP flow: phenotype_intent_split") - intent_response <- acp_try("/flows/phenotype_intent_split", list(study_intent = studyIntent), "intent_split") - write_json(intent_response, intent_split_path) - } - intent_core <- intent_response$intent_split %||% intent_response - target_statement <- intent_core$target_statement %||% "" - outcome_statement <- intent_core$outcome_statement %||% "" - rationale <- intent_core$rationale %||% "" - if (interactive) { - if (nzchar(rationale)) { - cat("\nSuggested rationale:\n") - cat(rationale, "\n") - } - if (length(intent_core$questions %||% list()) > 0) { - cat("Questions to clarify:\n") - for (q in intent_core$questions) cat(sprintf(" - %s\n", q)) - } - set_dialogue_context("intent_split", "target", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) - inp <- readline_with_dialogue(sprintf("Target cohort statement [%s]: ", target_statement)) - if (nzchar(trimws(inp))) target_statement <- inp - set_dialogue_context("intent_split", "outcome", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) - inp <- readline_with_dialogue(sprintf("Outcome cohort statement [%s]: ", outcome_statement)) - if (nzchar(trimws(inp))) outcome_statement <- inp - } - if (!nzchar(trimws(target_statement))) stop("Missing target cohort statement.") - if (!nzchar(trimws(outcome_statement))) stop("Missing outcome cohort statement.") - - recs_target_path <- file.path(output_dir, "recommendations_target.json") - recs_outcome_path <- file.path(output_dir, "recommendations_outcome.json") - used_cached_recs_target <- FALSE - used_cached_recs_outcome <- FALSE - used_window2_target <- FALSE - used_window2_outcome <- FALSE - used_advice_target <- FALSE - used_advice_outcome <- FALSE - rec_response_target <- NULL - rec_response_outcome <- NULL - - do_target_recs <- !isTRUE(resume) || !has_checkpoint("target_advice") - if (interactive && !do_target_recs) { - cat("\n== Step 2: Target phenotype recommendations (resumed) ==\n") - } - if (do_target_recs) { - if (interactive) { - cat("\n== Step 2: Target phenotype recommendations ==\n") - } - set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) - if (maybe_use_cache(recs_target_path, "target recommendations")) { - rec_response_target <- read_json(recs_target_path) - used_cached_recs_target <- TRUE - } else { - message("Calling ACP flow: phenotype_recommendation (target)") - body <- list( - study_intent = target_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit - ) - rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation") - write_json(rec_response_target, recs_target_path) - } - } else if (file.exists(recs_target_path)) { - rec_response_target <- read_json(recs_target_path) - used_cached_recs_target <- TRUE - } else { - do_target_recs <- TRUE - message("No cached target recommendations found; rerunning target recommendations.") - body <- list( - study_intent = target_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit - ) - rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation_resume") - write_json(rec_response_target, recs_target_path) - } - - recs_core_target <- rec_response_target$recommendations %||% rec_response_target - recommendations_target <- recs_core_target$phenotype_recommendations %||% list() - if (length(recommendations_target) == 0) stop("No target phenotype recommendations returned.") - - cat("\n== Target Phenotype Recommendations ==\n") - for (i in seq_along(recommendations_target)) { - rec <- recommendations_target[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - } - - if (interactive) { - set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) - ok_any <- prompt_yesno("Are any of these acceptable for the target?", default = TRUE) - if (!ok_any) { - widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) - if (widen) { - message("Generating additional recommendations (next window)...") - used_window2_target <- TRUE - body <- list( - study_intent = target_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - candidate_offset = candidateLimit - ) - rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation_window2") - recs_target_path <- file.path(output_dir, "recommendations_target_window2.json") - write_json(rec_response_target, recs_target_path) - - recs_core_target <- rec_response_target$recommendations %||% rec_response_target - recommendations_target <- recs_core_target$phenotype_recommendations %||% list() - cat("\n== Target Phenotype Recommendations (window 2) ==\n") - for (i in seq_along(recommendations_target)) { - rec <- recommendations_target[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - } - ok_any <- prompt_yesno("Are any of these acceptable?", default = TRUE) - } - if (!ok_any) { - message("Generating advisory guidance (this may take a moment)...") - advice <- acp_try("/flows/phenotype_recommendation_advice", list(study_intent = studyIntent), "target_advice_call") - used_advice_target <- TRUE - advice_core <- advice$advice %||% advice - cat("\n== Advisory guidance ==\n") - cat(advice_core$advice %||% "", "\n") - if (length(advice_core$next_steps %||% list()) > 0) { - cat("Next steps:\n") - for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) - } - if (length(advice_core$questions %||% list()) > 0) { - cat("Questions to clarify:\n") - for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) - } - mark_checkpoint("target_advice", list(recommendations_path = recs_target_path)) - cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") - stop("Stopping after target advice. Resume with resume=TRUE once phenotypes are updated.") - } - } - } - - if (interactive) { - set_dialogue_context("target_selection", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement)) - if (!prompt_yesno("Continue to target cohort selection?", default = TRUE)) { - return(invisible(list(output_dir = output_dir, recommendations = recs_target_path))) - } - cat("\n== Step 3: Select target cohorts ==\n") - } - - selected_ids_target <- NULL - if (interactive) { - labels <- vapply(seq_along(recommendations_target), function(i) { - rec <- recommendations_target[[i]] - sprintf("%s (ID %s)", rec$phenotype_name %||% "", rec$phenotype_id %||% "?") - }, character(1)) - picks <- utils::select.list(labels, multiple = FALSE, title = "Select target phenotype") - if (nzchar(picks)) { - idx <- which(labels == picks)[1] - selected_ids_target <- recommendations_target[[idx]]$phenotype_id - } - } else { - selected_ids_target <- recommendations_target[[1]]$phenotype_id - } - selected_ids_target <- as.character(selected_ids_target) - if (length(selected_ids_target) == 0) stop("No target cohort selected.") - - use_mapping <- FALSE - if (interactive) { - set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) - use_mapping <- prompt_yesno("Map cohort IDs to a new range (avoid collisions)?", default = TRUE) - } - cohort_id_base <- NA_integer_ - next_id <- NA_integer_ - if (use_mapping) { - cohort_id_base <- sample(10000:50000, 1) - if (interactive) { - msg <- sprintf("Enter cohort ID base (10000-50000) or press Enter to use %s: ", cohort_id_base) - set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()), suggested_cohort_id_base = cohort_id_base)) - inp <- trimws(readline_with_dialogue(msg)) - if (nzchar(inp)) cohort_id_base <- as.integer(inp) - } - next_id <- cohort_id_base - } - - map_ids <- function(ids) { - if (!use_mapping) return(ids) - new <- seq(next_id, length.out = length(ids)) - next_id <<- max(new) + 1 - new - } - - stop_if_unsupported_selected(selected_ids_target, "target") - - new_ids_target <- map_ids(selected_ids_target) - - copy_cohort_json_multi(selected_ids_target, new_ids_target, c(selected_target_dir, selected_dir), index_def_dir) - - do_target_improvements <- TRUE - if (interactive) { - set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, selected_target_ids = as.list(selected_ids_target %||% list()))) - do_target_improvements <- prompt_yesno("Continue to target phenotype improvements?", default = TRUE) - if (do_target_improvements) { - cat("\n== Step 4: Target phenotype improvements ==\n") - } - } - - improvements_target_path <- file.path(output_dir, "improvements_target.json") - imp_response_target <- list() - improvements_applied <- FALSE - used_cached_improvements_target <- FALSE - if (isTRUE(do_target_improvements)) { - if (maybe_use_cache(improvements_target_path, "target improvements")) { - imp_response_target <- read_json(improvements_target_path) - used_cached_improvements_target <- TRUE - if (interactive) { - cat(sprintf("\nLoaded cached target improvements from %s\n", improvements_target_path)) - } - } else { - cohort_obj <- read_json(file.path(selected_target_dir, sprintf("%s.json", new_ids_target))) - cohort_obj$id <- new_ids_target - body <- list( - protocol_text = studyIntent, - cohorts = list(cohort_obj) - ) - message(sprintf("Calling ACP flow: phenotype_improvements (target cohort %s)", new_ids_target)) - resp <- acp_try("/flows/phenotype_improvements", body, "target_improvements") - imp_response_target[[as.character(new_ids_target)]] <- resp - write_json(imp_response_target, improvements_target_path) - } - - if (interactive) { - for (cid in names(imp_response_target)) { - resp <- imp_response_target[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() - cat(sprintf("\n== Improvements for target cohort %s ==\n", cid)) - for (item in items) { - cat(sprintf("- %s\n", item$summary %||% "(no summary)")) - if (!is.null(item$actions)) { - for (act in item$actions) { - cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) - } - } - } - if (length(items) == 0) { - cat(" No improvements returned for this cohort.\n") - next - } - set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, cohort_id = as.integer(cid), selected_target_ids = as.list(selected_ids_target %||% list()))) - if (prompt_yesno(sprintf("Apply improvements for target cohort %s now?", cid), default = FALSE)) { - cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - for (item in items) { - if (is.null(item$actions)) next - for (act in item$actions) { - cohort_obj <- apply_action(cohort_obj, act) - } - } - ensure_dir(patched_target_dir) - ensure_dir(patched_dir) - out_path <- file.path(patched_target_dir, sprintf("%s.json", cid)) - write_json(cohort_obj, out_path) - file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) - improvements_applied <- TRUE - cat(sprintf("Patched target cohort saved: %s\n", out_path)) - } - } - } - if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { - for (cid in names(imp_response_target)) { - resp <- imp_response_target[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() - if (length(items) == 0) next - cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - for (item in items) { - if (is.null(item$actions)) next - for (act in item$actions) { - cohort_obj <- apply_action(cohort_obj, act) - } - } - ensure_dir(patched_target_dir) - ensure_dir(patched_dir) - out_path <- file.path(patched_target_dir, sprintf("%s.json", cid)) - write_json(cohort_obj, out_path) - file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) - improvements_applied <- TRUE - } - } - } - - do_outcome_recs <- !isTRUE(resume) || !has_checkpoint("outcome_advice") - if (interactive && !do_outcome_recs) { - cat("\n== Step 5: Outcome phenotype recommendations (resumed) ==\n") - } - if (do_outcome_recs) { - if (interactive) { - cat("\n== Step 5: Outcome phenotype recommendations ==\n") - } - set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) - if (maybe_use_cache(recs_outcome_path, "outcome recommendations")) { - rec_response_outcome <- read_json(recs_outcome_path) - used_cached_recs_outcome <- TRUE - } else { - message("Calling ACP flow: phenotype_recommendation (outcome)") - body <- list( - study_intent = outcome_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit - ) - rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation") - write_json(rec_response_outcome, recs_outcome_path) - } - } else if (file.exists(recs_outcome_path)) { - rec_response_outcome <- read_json(recs_outcome_path) - used_cached_recs_outcome <- TRUE - } else { - do_outcome_recs <- TRUE - message("No cached outcome recommendations found; rerunning outcome recommendations.") - body <- list( - study_intent = outcome_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit - ) - rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation_resume") - write_json(rec_response_outcome, recs_outcome_path) - } - - recs_core_outcome <- rec_response_outcome$recommendations %||% rec_response_outcome - recommendations_outcome <- recs_core_outcome$phenotype_recommendations %||% list() - if (length(recommendations_outcome) == 0) stop("No outcome phenotype recommendations returned.") - - cat("\n== Outcome Phenotype Recommendations ==\n") - for (i in seq_along(recommendations_outcome)) { - rec <- recommendations_outcome[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - } - - if (interactive) { - set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) - ok_any <- prompt_yesno("Are any of these acceptable for the outcomes?", default = TRUE) - if (!ok_any) { - widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) - if (widen) { - message("Generating additional recommendations (next window)...") - used_window2_outcome <- TRUE - body <- list( - study_intent = outcome_statement, - top_k = topK, - max_results = maxResults, - candidate_limit = candidateLimit, - candidate_offset = candidateLimit - ) - rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation_window2") - recs_outcome_path <- file.path(output_dir, "recommendations_outcome_window2.json") - write_json(rec_response_outcome, recs_outcome_path) - - recs_core_outcome <- rec_response_outcome$recommendations %||% rec_response_outcome - recommendations_outcome <- recs_core_outcome$phenotype_recommendations %||% list() - cat("\n== Outcome Phenotype Recommendations (window 2) ==\n") - for (i in seq_along(recommendations_outcome)) { - rec <- recommendations_outcome[[i]] - cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) - if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) - } - ok_any <- prompt_yesno("Are any of these acceptable?", default = TRUE) - } - if (!ok_any) { - message("Generating advisory guidance (this may take a moment)...") - advice <- acp_try("/flows/phenotype_recommendation_advice", list(study_intent = studyIntent), "outcome_advice_call") - used_advice_outcome <- TRUE - advice_core <- advice$advice %||% advice - cat("\n== Advisory guidance ==\n") - cat(advice_core$advice %||% "", "\n") - if (length(advice_core$next_steps %||% list()) > 0) { - cat("Next steps:\n") - for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) - } - if (length(advice_core$questions %||% list()) > 0) { - cat("Questions to clarify:\n") - for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) - } - mark_checkpoint("outcome_advice", list(recommendations_path = recs_outcome_path)) - cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") - stop("Stopping after outcome advice. Resume with resume=TRUE once phenotypes are updated.") - } - } - } - - if (interactive) { - set_dialogue_context("outcome_selection", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement)) - if (!prompt_yesno("Continue to outcome cohort selection?", default = TRUE)) { - return(invisible(list(output_dir = output_dir, recommendations = recs_outcome_path))) - } - cat("\n== Step 6: Select outcome cohorts ==\n") - } - - selected_ids_outcome <- NULL - if (interactive) { - labels <- vapply(seq_along(recommendations_outcome), function(i) { - rec <- recommendations_outcome[[i]] - sprintf("%s (ID %s)", rec$phenotype_name %||% "", rec$phenotype_id %||% "?") - }, character(1)) - picks <- utils::select.list(labels, multiple = TRUE, title = "Select outcome phenotypes") - selected_ids_outcome <- vapply(picks, function(label) { - idx <- which(labels == label)[1] - recommendations_outcome[[idx]]$phenotype_id %||% NA_character_ - }, character(1)) - } else { - if (length(recommendations_outcome) >= 2) { - selected_ids_outcome <- vapply(recommendations_outcome[-1], function(r) r$phenotype_id %||% NA_character_, character(1)) - } else { - selected_ids_outcome <- vapply(recommendations_outcome, function(r) r$phenotype_id %||% NA_character_, character(1)) - } - } - selected_ids_outcome <- as.character(selected_ids_outcome) - if (length(selected_ids_outcome) == 0) stop("No outcome cohorts selected.") - - stop_if_unsupported_selected(selected_ids_outcome, "outcome") - - new_ids_outcome <- map_ids(selected_ids_outcome) - - for (i in seq_along(new_ids_outcome)) { - copy_cohort_json_multi(selected_ids_outcome[[i]], new_ids_outcome[[i]], c(selected_outcome_dir, selected_dir), index_def_dir) - } - - do_outcome_improvements <- TRUE - if (interactive) { - set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) - do_outcome_improvements <- prompt_yesno("Continue to outcome phenotype improvements?", default = TRUE) - if (do_outcome_improvements) { - cat("\n== Step 7: Outcome phenotype improvements ==\n") - } - } - - improvements_outcome_path <- file.path(output_dir, "improvements_outcome.json") - imp_response_outcome <- list() - used_cached_improvements_outcome <- FALSE - if (isTRUE(do_outcome_improvements)) { - if (maybe_use_cache(improvements_outcome_path, "outcome improvements")) { - imp_response_outcome <- read_json(improvements_outcome_path) - used_cached_improvements_outcome <- TRUE - if (interactive) { - cat(sprintf("\nLoaded cached outcome improvements from %s\n", improvements_outcome_path)) - } - } else { - for (i in seq_along(new_ids_outcome)) { - cid <- new_ids_outcome[[i]] - cohort_obj <- read_json(file.path(selected_outcome_dir, sprintf("%s.json", cid))) - cohort_obj$id <- cid - body <- list( - protocol_text = studyIntent, - cohorts = list(cohort_obj) - ) - message(sprintf("Calling ACP flow: phenotype_improvements (outcome cohort %s)", cid)) - resp <- acp_try("/flows/phenotype_improvements", body, "outcome_improvements") - imp_response_outcome[[as.character(cid)]] <- resp - } - write_json(imp_response_outcome, improvements_outcome_path) - } - - if (interactive) { - for (cid in names(imp_response_outcome)) { - resp <- imp_response_outcome[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() - cat(sprintf("\n== Improvements for outcome cohort %s ==\n", cid)) - for (item in items) { - cat(sprintf("- %s\n", item$summary %||% "(no summary)")) - if (!is.null(item$actions)) { - for (act in item$actions) { - cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) - } - } - } - if (length(items) == 0) { - cat(" No improvements returned for this cohort.\n") - next - } - set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, cohort_id = as.integer(cid), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) - if (prompt_yesno(sprintf("Apply improvements for outcome cohort %s now?", cid), default = FALSE)) { - cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - for (item in items) { - if (is.null(item$actions)) next - for (act in item$actions) { - cohort_obj <- apply_action(cohort_obj, act) - } - } - ensure_dir(patched_outcome_dir) - ensure_dir(patched_dir) - out_path <- file.path(patched_outcome_dir, sprintf("%s.json", cid)) - write_json(cohort_obj, out_path) - file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) - improvements_applied <- TRUE - cat(sprintf("Patched outcome cohort saved: %s\n", out_path)) - } - } - } - if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { - for (cid in names(imp_response_outcome)) { - resp <- imp_response_outcome[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() - if (length(items) == 0) next - cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) - cohort_obj <- read_json(cohort_path) - for (item in items) { - if (is.null(item$actions)) next - for (act in item$actions) { - cohort_obj <- apply_action(cohort_obj, act) - } - } - ensure_dir(patched_outcome_dir) - ensure_dir(patched_dir) - out_path <- file.path(patched_outcome_dir, sprintf("%s.json", cid)) - write_json(cohort_obj, out_path) - file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) - improvements_applied <- TRUE - } - } - } - - id_map <- data.frame( - original_id = c(selected_ids_target, selected_ids_outcome), - cohort_id = c(new_ids_target, new_ids_outcome), - role = c(rep("target", length(new_ids_target)), rep("outcome", length(new_ids_outcome))), - stringsAsFactors = FALSE - ) - write_json(list(mapping = id_map), file.path(output_dir, "cohort_id_map.json")) - - roles_path <- file.path(output_dir, "cohort_roles.json") - target_ids <- as.integer(new_ids_target) - outcome_ids <- as.integer(new_ids_outcome) - write_json(list(targets = target_ids, outcomes = outcome_ids), roles_path) - if (length(target_ids) == 0) { - stop("No target cohort assigned. Update cohort_roles.json and re-run.") - } - - cohort_csv <- file.path(selected_dir, "Cohorts.csv") - cohort_rows <- list() - if (length(new_ids_target) > 0) { - for (i in seq_along(new_ids_target)) { - cid <- selected_ids_target[[i]] - new_id <- new_ids_target[[i]] - rec <- recommendations_target[[which(vapply(recommendations_target, function(r) r$phenotype_id == cid, logical(1)))]] - cohort_rows[[length(cohort_rows) + 1]] <- data.frame( - atlas_id = cid, - cohort_id = new_id, - cohort_name = rec$phenotype_name %||% paste0("Cohort ", new_id), - logic_description = rec$justification %||% NA_character_, - generate_stats = TRUE, - stringsAsFactors = FALSE - ) - } - } - if (length(new_ids_outcome) > 0) { - for (i in seq_along(new_ids_outcome)) { - cid <- selected_ids_outcome[[i]] - new_id <- new_ids_outcome[[i]] - rec <- recommendations_outcome[[which(vapply(recommendations_outcome, function(r) r$phenotype_id == cid, logical(1)))]] - cohort_rows[[length(cohort_rows) + 1]] <- data.frame( - atlas_id = cid, - cohort_id = new_id, - cohort_name = rec$phenotype_name %||% paste0("Cohort ", new_id), - logic_description = rec$justification %||% NA_character_, - generate_stats = TRUE, - stringsAsFactors = FALSE - ) - } - } - cohort_df <- do.call(rbind, cohort_rows) - write.csv(cohort_df, cohort_csv, row.names = FALSE) - - - state <- list( - study_intent = studyIntent, - target_statement = target_statement, - outcome_statement = outcome_statement, - output_dir = output_dir, - selected_dir = selected_dir, - patched_dir = patched_dir, - selected_target_dir = selected_target_dir, - selected_outcome_dir = selected_outcome_dir, - patched_target_dir = patched_target_dir, - patched_outcome_dir = patched_outcome_dir, - keeper_dir = keeper_dir, - analysis_settings_dir = analysis_settings_dir, - index_def_dir = index_def_dir, - intent_split_path = intent_split_path, - recommendations_target_path = recs_target_path, - recommendations_outcome_path = recs_outcome_path, - improvements_target_path = improvements_target_path, - improvements_outcome_path = improvements_outcome_path, - cohort_csv = cohort_csv, - cohort_id_map = id_map, - cohort_id_base = cohort_id_base, - cohort_roles_path = roles_path, - target_ids = target_ids, - outcome_ids = outcome_ids, - resume_enabled = resume, - checkpoint_target_advice = has_checkpoint("target_advice"), - checkpoint_outcome_advice = has_checkpoint("outcome_advice"), - used_cached_recommendations_target = used_cached_recs_target, - used_cached_recommendations_outcome = used_cached_recs_outcome, - used_cached_improvements_target = used_cached_improvements_target, - used_cached_improvements_outcome = used_cached_improvements_outcome, - used_window2_target = used_window2_target, - used_window2_outcome = used_window2_outcome, - used_advisory_flow_target = used_advice_target, - used_advisory_flow_outcome = used_advice_outcome, - improvements_applied = improvements_applied - ) - state_path <- file.path(output_dir, "study_agent_state.json") - write_json(state, state_path) - - # ---- Generate scripts ---- - if (interactive) { - cat("\n== Step 8: Generate scripts ==\n") - } - write_lines <- function(path, lines) { - writeLines(lines, con = path, useBytes = TRUE) - } - - script_header <- c( - "# Generated by OHDSIAssistant::runStrategusIncidenceShell", - "# Edit values as needed and run in order.", - if (improvements_applied) "# NOTE: improvements were already applied in the shell run; this script is a portable record." - else "# NOTE: improvements not applied yet; see 02_apply_improvements.R.", - "" - ) - - # 01 - select - script_01 <- c( - script_header, - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "phenotype_definition_path <- function(phenotype_id, index_def_dir) {", - " file.path(index_def_dir, sprintf('%s.json', gsub(':', '__', phenotype_id, fixed = TRUE)))", - "}", - "stop_if_unsupported_selected <- function(phenotype_ids, role_label) {", - " unsupported <- phenotype_ids[!grepl('^ohdsi:', phenotype_ids %||% character(0))]", - " if (length(unsupported) > 0) stop(sprintf('Selected %s phenotype(s) include non-OHDSI ids (%s). This demo workflow does not yet support converting non-OHDSI phenotype definitions into computable OHDSI cohort definitions. Please re-run and choose an OHDSI phenotype.', role_label, paste(unique(unsupported), collapse = ', ')))", - "}", - "copy_cohort_json <- function(source_id, dest_id, dest_dirs, index_def_dir) {", - " src <- phenotype_definition_path(source_id, index_def_dir)", - " if (!file.exists(src)) stop('Cohort JSON not found: ', src)", - " for (dest_dir in dest_dirs) {", - " dir.create(dest_dir, recursive = TRUE, showWarnings = FALSE)", - " dest <- file.path(dest_dir, sprintf('%s.json', dest_id))", - " file.copy(src, dest, overwrite = TRUE)", - " }", - "}", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - sprintf("index_def_dir <- '%s'", index_def_dir), - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", - "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", - "dir.create(selected_dir, recursive = TRUE, showWarnings = FALSE)", - "dir.create(selected_target_dir, recursive = TRUE, showWarnings = FALSE)", - "dir.create(selected_outcome_dir, recursive = TRUE, showWarnings = FALSE)", - "recs_target <- jsonlite::fromJSON(file.path(output_dir, 'recommendations_target.json'), simplifyVector = FALSE)", - "recs_outcome <- jsonlite::fromJSON(file.path(output_dir, 'recommendations_outcome.json'), simplifyVector = FALSE)", - "items_target <- (recs_target$recommendations %||% recs_target)$phenotype_recommendations %||% list()", - "items_outcome <- (recs_outcome$recommendations %||% recs_outcome)$phenotype_recommendations %||% list()", - "labels_target <- vapply(seq_along(items_target), function(i) sprintf('%s (ID %s)', items_target[[i]]$phenotype_name %||% '', items_target[[i]]$phenotype_id %||% '?'), character(1))", - "labels_outcome <- vapply(seq_along(items_outcome), function(i) sprintf('%s (ID %s)', items_outcome[[i]]$phenotype_name %||% '', items_outcome[[i]]$phenotype_id %||% '?'), character(1))", - "target_pick <- utils::select.list(labels_target, multiple = FALSE, title = 'Select target phenotype')", - "target_ids <- if (nzchar(target_pick)) (items_target[[which(labels_target == target_pick)[1]]]$phenotype_id %||% '') else character(0)", - "outcome_picks <- utils::select.list(labels_outcome, multiple = TRUE, title = 'Select outcome phenotypes')", - "outcome_ids <- vapply(outcome_picks, function(label) items_outcome[[which(labels_outcome == label)[1]]]$phenotype_id %||% NA_character_, character(1))", - "if (length(target_ids) == 0) stop('No target cohort selected.')", - "if (length(outcome_ids) == 0) stop('No outcome cohorts selected.')", - "resp <- tolower(trimws(readline('Map cohort IDs to a new range (avoid collisions)? [Y/n]: ')))", - "use_mapping <- !(resp %in% c('n', 'no'))", - "cohort_id_base <- NA_integer_", - "next_id <- NA_integer_", - "if (use_mapping) {", - " cohort_id_base <- sample(10000:50000, 1)", - " inp <- trimws(readline(sprintf('Enter cohort ID base (10000-50000) or press Enter to use %s: ', cohort_id_base)))", - " if (nzchar(inp)) cohort_id_base <- as.integer(inp)", - " next_id <- cohort_id_base", - "}", - "map_ids <- function(ids) {", - " if (!use_mapping) return(ids)", - " new <- seq(next_id, length.out = length(ids))", - " next_id <<- max(new) + 1", - " new", - "}", - "stop_if_unsupported_selected(target_ids, 'target')", - "new_ids_target <- map_ids(target_ids)", - "stop_if_unsupported_selected(outcome_ids, 'outcome')", - "new_ids_outcome <- map_ids(outcome_ids)", - "for (i in seq_along(target_ids)) copy_cohort_json(target_ids[[i]], new_ids_target[[i]], c(selected_target_dir, selected_dir), index_def_dir)", - "for (i in seq_along(outcome_ids)) copy_cohort_json(outcome_ids[[i]], new_ids_outcome[[i]], c(selected_outcome_dir, selected_dir), index_def_dir)", - "id_map <- data.frame(", - " original_id = c(target_ids, outcome_ids),", - " cohort_id = c(new_ids_target, new_ids_outcome),", - " role = c(rep('target', length(new_ids_target)), rep('outcome', length(new_ids_outcome))),", - " stringsAsFactors = FALSE", - ")", - "jsonlite::write_json(list(mapping = id_map), file.path(output_dir, 'cohort_id_map.json'), pretty = TRUE, auto_unbox = TRUE)", - "jsonlite::write_json(list(targets = new_ids_target, outcomes = new_ids_outcome), file.path(output_dir, 'cohort_roles.json'), pretty = TRUE, auto_unbox = TRUE)", - "cohort_rows <- list()", - "for (i in seq_along(new_ids_target)) {", - " cid <- target_ids[[i]]", - " new_id <- new_ids_target[[i]]", - " rec <- items_target[[which(vapply(items_target, function(r) r$phenotype_id == cid, logical(1)))[1]]]", - " cohort_rows[[length(cohort_rows) + 1]] <- data.frame(atlas_id = cid, cohort_id = new_id, cohort_name = rec$phenotype_name %||% paste0('Cohort ', new_id), logic_description = rec$justification %||% NA_character_, generate_stats = TRUE, stringsAsFactors = FALSE)", - "}", - "for (i in seq_along(new_ids_outcome)) {", - " cid <- outcome_ids[[i]]", - " new_id <- new_ids_outcome[[i]]", - " rec <- items_outcome[[which(vapply(items_outcome, function(r) r$phenotype_id == cid, logical(1)))[1]]]", - " cohort_rows[[length(cohort_rows) + 1]] <- data.frame(atlas_id = cid, cohort_id = new_id, cohort_name = rec$phenotype_name %||% paste0('Cohort ', new_id), logic_description = rec$justification %||% NA_character_, generate_stats = TRUE, stringsAsFactors = FALSE)", - "}", - "cohort_df <- do.call(rbind, cohort_rows)", - "write.csv(cohort_df, file.path(selected_dir, 'Cohorts.csv'), row.names = FALSE)", - "" - ) - write_lines(file.path(scripts_dir, "01_recommend_and_select.R"), script_01) - - # 02 - apply improvements - script_02 <- c( - script_header, - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "apply_action <- function(obj, action) {", - " path <- action$path %||% ''", - " value <- action$value", - " if (!nzchar(path)) return(obj)", - " segs <- strsplit(path, '/', fixed = TRUE)[[1]]", - " segs <- segs[segs != '']", - " set_in <- function(x, segs, value) {", - " if (length(segs) == 0) return(value)", - " seg <- segs[[1]]", - " name <- seg", - " idx <- NA_integer_", - " if (grepl('\\\\[\\\\d+\\\\]$', seg)) {", - " name <- sub('\\\\[\\\\d+\\\\]$', '', seg)", - " idx <- as.integer(sub('^.*\\\\[(\\\\d+)\\\\]$', '\\\\1', seg))", - " }", - " if (name != '') {", - " if (is.null(x[[name]])) x[[name]] <- list()", - " if (length(segs) == 1) {", - " if (!is.na(idx)) {", - " if (length(x[[name]]) < idx) while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- NULL", - " x[[name]][[idx]] <- value", - " } else {", - " x[[name]] <- value", - " }", - " return(x)", - " }", - " if (!is.na(idx)) {", - " if (length(x[[name]]) < idx) while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", - " x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value)", - " } else {", - " x[[name]] <- set_in(x[[name]], segs[-1], value)", - " }", - " return(x)", - " }", - " idx <- suppressWarnings(as.integer(seg))", - " if (is.na(idx)) return(x)", - " if (idx == 0) idx <- 1", - " if (length(x) < idx) while (length(x) < idx) x[[length(x) + 1]] <- list()", - " if (length(segs) == 1) { x[[idx]] <- value; return(x) }", - " x[[idx]] <- set_in(x[[idx]], segs[-1], value)", - " x", - " }", - " set_in(obj, segs, value)", - "}", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", - "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "patched_target_dir <- file.path(base_dir, 'patched-target-cohorts')", - "patched_outcome_dir <- file.path(base_dir, 'patched-outcome-cohorts')", - "dir.create(patched_dir, recursive = TRUE, showWarnings = FALSE)", - "dir.create(patched_target_dir, recursive = TRUE, showWarnings = FALSE)", - "dir.create(patched_outcome_dir, recursive = TRUE, showWarnings = FALSE)", - "improvements_target_path <- file.path(output_dir, 'improvements_target.json')", - "improvements_outcome_path <- file.path(output_dir, 'improvements_outcome.json')", - "improvements_target <- if (file.exists(improvements_target_path)) jsonlite::fromJSON(improvements_target_path, simplifyVector = FALSE) else list()", - "improvements_outcome <- if (file.exists(improvements_outcome_path)) jsonlite::fromJSON(improvements_outcome_path, simplifyVector = FALSE) else list()", - "apply_for_role <- function(improvements, selected_role_dir, patched_role_dir) {", - " for (cid in names(improvements)) {", - " resp <- improvements[[cid]]", - " core <- resp$full_result %||% resp", - " items <- core$phenotype_improvements %||% list()", - " if (length(items) == 0) next", - " cohort_path <- file.path(selected_role_dir, sprintf('%s.json', cid))", - " cohort_obj <- jsonlite::fromJSON(cohort_path, simplifyVector = FALSE)", - " for (item in items) {", - " if (is.null(item$actions)) next", - " for (act in item$actions) cohort_obj <- apply_action(cohort_obj, act)", - " }", - " out_path <- file.path(patched_role_dir, sprintf('%s.json', cid))", - " jsonlite::write_json(cohort_obj, out_path, pretty = TRUE, auto_unbox = TRUE)", - " file.copy(out_path, file.path(patched_dir, sprintf('%s.json', cid)), overwrite = TRUE)", - " }", - "}", - "apply_for_role(improvements_target, selected_target_dir, patched_target_dir)", - "apply_for_role(improvements_outcome, selected_outcome_dir, patched_outcome_dir)", - "" - ) - write_lines(file.path(scripts_dir, "02_apply_improvements.R"), script_02) - - # 03 - generate cohorts - script_03 <- c( - script_header, - "library(Strategus)", - "library(CohortGenerator)", - "library(DatabaseConnector)", - "library(dplyr)", - "library(CirceR)", - "library(SqlRender)", - "", - "# loads the OHDSI StudyAssistant since it is not yet an installed package", - "if (!requireNamespace('OHDSIAssistant', quietly = TRUE)) {", - " if (requireNamespace('devtools', quietly = TRUE)) {", - " devtools::load_all('OHDSI-Study-Agent/R/OHDSIAssistant')", - " } else {", - " source('OHDSI-Study-Agent/R/OHDSIAssistant/R/zzz.R')", - " }", - "}", - "library(OHDSIAssistant)", - "library(jsonlite)", - "library(ParallelLogger)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(selected_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path='')", - "dbms <- connectionDetails$dbms %||% 'postgresql'", - "exec <- OHDSIAssistant::createStrategusExecutionSettings()", - "executionSettings_cohorts <- exec$executionSettings", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "workDatabaseSchema <- exec$workDatabaseSchema", - "resultsDatabaseSchema <- exec$resultsDatabaseSchema", - "vocabularyDatabaseSchema <- exec$vocabularyDatabaseSchema", - "cohortTable <- exec$cohortTable", - "cohortIdFieldName <- exec$cohortIdFieldName", - "dir.create(exec$workFolder, recursive = TRUE, showWarnings = FALSE)", - "dir.create(exec$resultsFolder, recursive = TRUE, showWarnings = FALSE)", - "", - "cohort_settings <- read.csv(cohort_csv, stringsAsFactors = FALSE)", - "if (nrow(cohort_settings) > 0) {", - " id_col <- if ('cohort_id' %in% names(cohort_settings)) 'cohort_id' else 'cohortId'", - " for (i in seq_len(nrow(cohort_settings))) {", - " cohort_id <- cohort_settings[[id_col]][i]", - " sql_path <- file.path(sql_dir, sprintf('%s.sql', cohort_id))", - " if (!file.exists(sql_path)) {", - " json_path <- file.path(cohort_json_dir, sprintf('%s.json', cohort_id))", - " if (!file.exists(json_path)) stop('Missing cohort JSON: ', json_path)", - " json_text <- readChar(json_path, nchars = file.info(json_path)$size, useBytes = TRUE)", - " cohort_expression <- CirceR::cohortExpressionFromJson(json_text)", - " generateOptions <- CirceR::createGenerateOptions(", - " cohortIdFieldName = cohortIdFieldName,", - " cdmSchema = cdmDatabaseSchema,", - " targetTable = paste0(workDatabaseSchema, '.', cohortTable),", - " resultSchema = resultsDatabaseSchema,", - " vocabularySchema = vocabularyDatabaseSchema,", - " generateStats = TRUE", - " )", - " sql <- CirceR::buildCohortQuery(cohort_expression, generateOptions)", - " sql <- SqlRender::render(sql)", - " sql <- SqlRender::translate(sql, targetDialect = dbms)", - " writeLines(sql, sql_path, useBytes = TRUE)", - " }", - " }", - "}", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "cohortGeneratorModuleSpecifications <- cgModule$createModuleSpecifications(generateStats = TRUE)", - "", - "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", - " addSharedResources(cohortDefinitionSharedResource) %>%", - " addModuleSpecifications(cohortGeneratorModuleSpecifications)", - "", - "execute(", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = executionSettings_cohorts,", - " connectionDetails = connectionDetails", - ")", - "" - ) - write_lines(file.path(scripts_dir, "03_generate_cohorts.R"), script_03) - - # 04 - Keeper review - script_04 <- c( - script_header, - "library(Keeper)", - "library(jsonlite)", - "library(DatabaseConnector)", - "", - "# loads the OHDSI StudyAssistant since it is not yet an installed package", - "if (!requireNamespace('OHDSIAssistant', quietly = TRUE)) {", - " if (requireNamespace('devtools', quietly = TRUE)) {", - " devtools::load_all('OHDSI-Study-Agent/R/OHDSIAssistant')", - " } else {", - " source('OHDSI-Study-Agent/R/OHDSIAssistant/R/zzz.R')", - " }", - "}", - "library(OHDSIAssistant)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "keeper_dir <- file.path(base_dir, 'keeper-case-review')", - "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", - "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'))$mapping", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path='')", - "", - "exec <- OHDSIAssistant::createStrategusExecutionSettings()", - "# TODO: fill in databaseId (used by Keeper for labeling outputs)", - "databaseId <- ''", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "cohortDatabaseSchema <- exec$workDatabaseSchema", - "cohortTable <- exec$cohortTable", - "", - "for (cid in id_map$cohort_id) {", - " keeper <- createKeeper(", - " connectionDetails = connectionDetails,", - " databaseId = databaseId,", - " cdmDatabaseSchema = cdmDatabaseSchema,", - " cohortDatabaseSchema = cohortDatabaseSchema,", - " cohortTable = cohortTable,", - " cohortDefinitionId = cid,", - " cohortName = paste('Cohort', cid),", - " sampleSize = 100,", - " assignNewId = TRUE,", - " useAncestor = TRUE,", - " doi = c(4202064, 192671, 2108878, 2108900, 2002608),", - " symptoms = c(4103703, 443530, 4245614, 28779),", - " comorbidities = c(81893, 201606, 313217, 318800, 432585, 4027663, 4180790, 4212540, - 40481531, 42535737, 46271022),", - " drugs = c(904453, 906780, 923645, 929887, 948078, 953076, 961047, 985247, 992956, - 997276, 1102917, 1113648, 1115008, 1118045, 1118084, 1124300, 1126128, - 1136980, 1146810, 1150345, 1153928, 1177480, 1178663, 1185922, 1195492, - 1236607, 1303425, 1313200, 1353766, 1507835, 1522957, 1721543, 1746940, - 1777806, 19044727, 19119253, 36863425),", - " diagnosticProcedures = c(4087381, 4143985, 4294382, 42872565, 45888171, 46257627),", - " measurements = c(3000905, 3000963, 3003458, 3012471, 3016251, 3018677, 3020416, - 3022217, 3023314, 3024929, 3034426),", - " alternativeDiagnosis = c(24966, 76725, 195562, 316457, 318800, 4096682),", - " treatmentProcedures = c(0),", - " complications = c(132797, 196152, 439777, 4192647)", - " )", - " out_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", - " write.csv(keeper, out_path, row.names = FALSE)", - "}", - "# Optional: if ACP is available, use phenotype_validation_review on rows from keeper_dir.", - "# Uncomment to enable:", - " if (requireNamespace('OHDSIAssistant', quietly = TRUE) || 'OHDSIAssistant' %in% loadedNamespaces()) {", - " OHDSIAssistant::acp_connect('http://127.0.0.1:8765')", - " roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", - " intent <- jsonlite::fromJSON(file.path(output_dir, 'intent_split.json'), simplifyVector = TRUE)", - " cohort_type <- utils::select.list(c('target', 'outcome'), title = 'Keeper review for which cohort type?')", - " if (!nzchar(cohort_type)) stop('No cohort type selected.')", - " get_intent_field <- function(obj, field) {", - " if (!is.null(obj$intent_split) && !is.null(obj$intent_split[[field]])) return(obj$intent_split[[field]])", - " obj[[field]]", - " }", - " default_disease <- if (cohort_type == 'target') get_intent_field(intent, 'target_statement') else get_intent_field(intent, 'outcome_statement')", - " disease_name <- readline(sprintf('Disease name [%s]: ', default_disease))", - " if (!nzchar(trimws(disease_name))) disease_name <- default_disease", - " sample_n <- as.integer(readline('How many cases per cohort to review? [5]: '))", - " if (is.na(sample_n) || sample_n <= 0) sample_n <- 5", - " random_pick <- tolower(trimws(readline('Randomly sample cases? [Y/n]: ')))", - " random_pick <- !(random_pick %in% c('n', 'no'))", - " write_output <- tolower(trimws(readline('Write LLM review rows to file? [Y/n]: ')))", - " write_output <- !(write_output %in% c('n', 'no'))", - " cohort_ids <- if (cohort_type == 'target') roles$targets else roles$outcomes", - " for (cid in cohort_ids) {", - " keeper_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", - " keeper_rows <- read.csv(keeper_path, stringsAsFactors = FALSE)", - " if (nrow(keeper_rows) == 0) next", - " n <- min(sample_n, nrow(keeper_rows))", - " idx <- if (random_pick) sample(seq_len(nrow(keeper_rows)), n) else seq_len(n)", - " selected <- keeper_rows[idx, , drop = FALSE]", - " results <- list()", - " for (i in seq_len(nrow(selected))) {", - " row_payload <- as.list(selected[i, , drop = FALSE])", - " row_payload <- lapply(row_payload, function(x) if (length(x) == 0) NA else x)", - " resp <- OHDSIAssistant:::`.acp_post`(", - " '/flows/phenotype_validation_review',", - " list(keeper_row = row_payload, disease_name = disease_name)", - " )", - " if (!is.null(resp$status) && resp$status == 'error') {", - " out <- c(row_payload, list(label = NA, rationale = NA, acp_error = resp$error %||% 'acp_error'))", - " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", - " next", - " }", - " label <- resp$label", - " rationale <- resp$rationale", - " if (is.null(label) && !is.null(resp$full_result$label)) label <- resp$full_result$label", - " if (is.null(rationale) && !is.null(resp$full_result$rationale)) rationale <- resp$full_result$rationale", - " if (is.null(label) && !is.null(resp$result$label)) label <- resp$result$label", - " if (is.null(rationale) && !is.null(resp$result$rationale)) rationale <- resp$result$rationale", - " if (is.null(label)) label <- NA", - " if (is.null(rationale)) rationale <- NA", - " out <- c(row_payload, list(label = label, rationale = rationale))", - " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", - " }", - " if (length(results) > 0) {", - " out_df <- do.call(rbind, results)", - " if (write_output) {", - " out_path <- file.path(keeper_dir, sprintf('%s_llm_review.csv', cid))", - " write.csv(out_df, out_path, row.names = FALSE)", - " }", - " print(out_df)", - " }", - " }", - " }", - "" - ) - write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) - - # 05 - diagnostics - script_05 <- c( - script_header, - "library(Strategus)", - "library(CohortDiagnostics)", - "library(CohortGenerator)", - "library(DatabaseConnector)", - "library(dplyr)", - "", - "# loads the OHDSI StudyAssistant since it is not yet an installed package", - "if (!requireNamespace('OHDSIAssistant', quietly = TRUE)) {", - " if (requireNamespace('devtools', quietly = TRUE)) {", - " devtools::load_all('OHDSI-Study-Agent/R/OHDSIAssistant')", - " } else {", - " source('OHDSI-Study-Agent/R/OHDSIAssistant/R/zzz.R')", - " }", - "}", - "library(OHDSIAssistant)", - "library(jsonlite)", - "library(ParallelLogger)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(selected_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path='')", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path='')", - "executionSettings_diagnostics <- exec$executionSettings", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "", - "cdModule <- CohortDiagnosticsModule$new()", - "cohortDiagnosticsModuleSpecifications <- cdModule$createModuleSpecifications(", - " runInclusionStatistics = TRUE,", - " runIncludedSourceConcepts = TRUE,", - " runOrphanConcepts = TRUE,", - " runTimeSeries = FALSE,", - " runVisitContext = TRUE,", - " runBreakdownIndexEvents = TRUE,", - " runIncidenceRate = TRUE,", - " runCohortRelationship = TRUE,", - " runTemporalCohortCharacterization = TRUE", - ")", - "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", - " addSharedResources(cohortDefinitionSharedResource) %>%", - " addModuleSpecifications(cohortDiagnosticsModuleSpecifications)", - "", - " execute(", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = executionSettings_diagnostics,", - " connectionDetails = connectionDetails", - " )", - "" - ) - write_lines(file.path(scripts_dir, "05_diagnostics.R"), script_05) - - # 06 - incidence spec - script_06 <- c( - script_header, - "library(Strategus)", - "library(CohortGenerator)", - "library(CohortIncidence)", - "library(DatabaseConnector)", - "library(dplyr)", - "", - "# loads the OHDSI StudyAssistant since it is not yet an installed package", - "if (!requireNamespace('OHDSIAssistant', quietly = TRUE)) {", - " if (requireNamespace('devtools', quietly = TRUE)) {", - " devtools::load_all('OHDSI-Study-Agent/R/OHDSIAssistant')", - " } else {", - " source('OHDSI-Study-Agent/R/OHDSIAssistant/R/zzz.R')", - " }", - "}", - "library(OHDSIAssistant)", - "library(jsonlite)", - "library(ParallelLogger)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", - "", - sprintf("base_dir <- '%s'", base_dir), - "output_dir <- file.path(base_dir, 'outputs')", - "analysis_settings_dir <- file.path(base_dir, 'analysis-settings')", - "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", - "selected_dir <- file.path(base_dir, 'selected-cohorts')", - "patched_dir <- file.path(base_dir, 'patched-cohorts')", - "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", - "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", - "sql_dir <- file.path(selected_dir, 'sql')", - "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", - "", - "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path='')", - "exec <- OHDSIAssistant::createStrategusExecutionSettings(path='')", - "executionSettings_incidence <- exec$executionSettings", - "", - "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", - " settingsFileName = cohort_csv,", - " jsonFolder = cohort_json_dir,", - " sqlFolder = sql_dir", - ")", - "", - "roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", - "target_ids <- as.character(roles$targets %||% character(0))", - "outcome_ids <- as.character(roles$outcomes %||% character(0))", - "if (length(target_ids) == 0) stop('No target cohorts defined in cohort_roles.json')", - "if (length(outcome_ids) == 0) stop('No outcome cohorts defined in cohort_roles.json')", - "cgModule <- CohortGeneratorModule$new()", - "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", - " cohortDefinitionSet = cohortDefinitionSet", - ")", - "targets <- lapply(target_ids, function(id) {", - " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", - " CohortIncidence::createCohortRef(id = id, name = row$cohortName[1])", - "})", - "outcomes <- lapply(outcome_ids, function(id) {", - " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", - " CohortIncidence::createOutcomeDef(id = id, name = row$cohortName[1])", - "})", - "", - "tars <- list(", - " CohortIncidence::createTimeAtRiskDef(id = 1, startWith = 'start', endWith = 'end'),", - " CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)", - ")", - "", - "analysis1 <- CohortIncidence::createIncidenceAnalysis(", - " targets = sapply(targets, function(x) x$id),", - " outcomes = sapply(outcomes, function(x) x$id),", - " tars = c(1, 2)", - ")", - "", - "irDesign <- CohortIncidence::createIncidenceDesign(", - " targetDefs = targets,", - " outcomeDefs = outcomes,", - " tars = tars,", - " analysisList = list(analysis1),", - " strataSettings = CohortIncidence::createStrataSettings(byYear = TRUE, byGender = TRUE)", - ")", - "", - "ciModule <- CohortIncidenceModule$new()", - "cohortIncidenceModuleSpecifications <- ciModule$createModuleSpecifications(", - " irDesign = irDesign$toList()", - ")", - "", - "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", - " addSharedResources(cohortDefinitionSharedResource) %>%", - " addModuleSpecifications(cohortIncidenceModuleSpecifications)", - "analysis_spec_path <- file.path(analysis_settings_dir, 'analysisSpecification.json')", - "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)", - "", - " execute(", - " analysisSpecifications = analysisSpecifications,", - " executionSettings = executionSettings_incidence,", - " connectionDetails = connectionDetails", - " )", - "" - ) - write_lines(file.path(scripts_dir, "06_incidence_spec.R"), script_06) - - if (interactive) { - cat("\n== Session Summary ==\n") - cat("Target cohort statement:\n") - cat(sprintf(" %s\n", target_statement)) - cat("Outcome cohort statement:\n") - cat(sprintf(" %s\n", outcome_statement)) - cat("Target cohorts:\n") - for (i in seq_along(new_ids_target)) { - rec <- recommendations_target[[which(vapply(recommendations_target, function(r) r$phenotype_id == selected_ids_target[[i]], logical(1)))]] - cat(sprintf(" - %s (atlas %s -> cohort %s)\n", rec$phenotype_name %||% "", selected_ids_target[[i]], new_ids_target[[i]])) - } - cat("Outcome cohorts:\n") - for (i in seq_along(new_ids_outcome)) { - rec <- recommendations_outcome[[which(vapply(recommendations_outcome, function(r) r$phenotype_id == selected_ids_outcome[[i]], logical(1)))]] - cat(sprintf(" - %s (atlas %s -> cohort %s)\n", rec$phenotype_name %||% "", selected_ids_outcome[[i]], new_ids_outcome[[i]])) - } - cat("JSON outputs:\n") - cat(sprintf(" - Selected target cohorts: %s\n", selected_target_dir)) - cat(sprintf(" - Selected outcome cohorts: %s\n", selected_outcome_dir)) - cat(sprintf(" - Selected cohorts (combined): %s\n", selected_dir)) - if (improvements_applied) { - cat(sprintf(" - Patched target cohorts: %s\n", patched_target_dir)) - cat(sprintf(" - Patched outcome cohorts: %s\n", patched_outcome_dir)) - cat(sprintf(" - Patched cohorts (combined): %s\n", patched_dir)) - } else { - cat(" - Patched cohorts: (not applied)\n") - } - cat("Scripts written:\n") - cat(sprintf(" - %s\n", scripts_dir)) - cat("Recommended run order (if you want to re-run outside the shell):\n") - cat(" 1) Rscript scripts/03_generate_cohorts.R\n") - cat(" 2) Rscript scripts/04_keeper_review.R\n") - cat(" 3) Rscript scripts/05_diagnostics.R\n") - cat(" 4) Rscript scripts/06_incidence_spec.R\n") - cat("Notes:\n") - if (improvements_applied) { - cat(" - Improvements were already applied in this session; scripts are a portable record.\n") - } else { - cat(" - Improvements were not applied; see scripts/02_apply_improvements.R if desired.\n") - } - cat(sprintf("Session state saved to %s\n", state_path)) - } - message("Study agent shell complete. Scripts written to: ", scripts_dir) - invisible(list( - output_dir = output_dir, - scripts_dir = scripts_dir, - intent_split = intent_split_path, - recommendations_target = recs_target_path, - recommendations_outcome = recs_outcome_path, - improvements_target = improvements_target_path, - improvements_outcome = improvements_outcome_path, - cohort_csv = cohort_csv - )) + slashOhdsiStrategusAssistant::runStrategusIncidenceShell(...) } diff --git a/R/OHDSIAssistant/R/utils_json.R b/R/OHDSIAssistant/R/utils_json.R index c20d85d..2339aeb 100644 --- a/R/OHDSIAssistant/R/utils_json.R +++ b/R/OHDSIAssistant/R/utils_json.R @@ -1,21 +1 @@ -read_json_ref <- function(ref) { - if (grepl("^https?://", ref)) { - txt <- readLines(ref, warn = FALSE) - return(jsonlite::fromJSON(paste(txt, collapse="\n"), simplifyVector = FALSE)) - } - jsonlite::fromJSON(ref, simplifyVector = FALSE) -} - -print_findings <- function(findings) { - if (length(findings) == 0) { - cat(" [OK] No findings.\n"); return(invisible(NULL)) - } - for (f in findings) { - cat(sprintf(" - [%s][%s] %s\n", - toupper((f$severity %||% "INFO")), - (f$impact %||% ""), - (f$message %||% jsonlite::toJSON(f, auto_unbox=TRUE)))) - } -} - -`%||%` <- function(a,b) if (is.null(a)) b else a +# Compatibility note: JSON utility helpers moved to slashOhdsiAcpClient. diff --git a/R/OHDSIAssistant/README.md b/R/OHDSIAssistant/README.md index 9463591..6480e26 100644 --- a/R/OHDSIAssistant/README.md +++ b/R/OHDSIAssistant/README.md @@ -1,179 +1,26 @@ -# OHDSIAssistant (R) - ACP Client +# OHDSIAssistant -This package provides a thin R client for the ACP study agent. It assumes the ACP server is already running and accessible over HTTP. +This package is now a compatibility layer during the R refactor. -## Quick Start +Canonical packages: -```r -devtools::load_all("R/OHDSIAssistant") -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") -``` +- `slashOhdsiAcpClient` for ACP connectivity and phenotype-oriented helper APIs +- `slashOhdsiStrategusAssistant` for Strategus workflows, shell entrypoints, and Strategus helper utilities -## Phenotype Recommendations (ACP Flow) +Compatibility exports still available here forward to those packages, including: -File-based study intent: +- `acp_connect()` +- `suggestPhenotypes()` +- `reviewPhenotypes()` +- `runStrategusIncidenceShell()` +- `runStrategusCohortMethodsShell()` +- `suggestCohortMethodSpecs()` -```r -rec <- OHDSIAssistant::suggestPhenotypes( - protocolPath = "demo/protocol.md", - maxResults = 10, - candidateLimit = 10, - interactive = TRUE -) -``` - -Direct study intent: - -```r -rec <- OHDSIAssistant::suggestPhenotypes( - studyIntent = "Identify clinical risk factors for older adults with GI bleeding in hospital settings.", - maxResults = 10 -) -``` - -Interactive prompt (if no intent provided): - -```r -rec <- OHDSIAssistant::suggestPhenotypes() -``` - -## Notes - -- The ACP server must be running and configured with its MCP connection. -- The R client calls ACP `/flows/phenotype_recommendation`. -- The response includes `recommendations`, which contains the validated core output. - -## Strategus Incidence Shell (ACP-assisted, outputs pure R) - -This helper runs an interactive, ACP-backed design session and writes a set of clean R scripts -that do not require ACP to run at other sites. - -```r -OHDSIAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - studyIntent = "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" -) -``` - -It generates scripts under `demo-strategus-cohort-incidence/scripts/` following the flow: - -1. `01_recommend_and_select.R` -2. `02_apply_improvements.R` -3. `03_generate_cohorts.R` -4. `04_keeper_review.R` -5. `05_diagnostics.R` -6. `06_incidence_spec.R` - -## Suggest Cohort Method Specifications - -Use `suggestCohortMethodSpecs()` when you want ACP to turn a free-text analytic-settings description into a CohortMethod recommendation without running the full shell. - -```r -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -res <- OHDSIAssistant::suggestCohortMethodSpecs( - studyIntent = "What is the risk of angioedema or acute myocardial infarction in new users of ACE inhibitors compared to new users of thiazide and thiazide-like diuretics?", - analyticSettingsDescription = "Use one-to-one propensity score matching, a 365-day washout, and a Cox outcome model.", - interactive = TRUE -) -``` - -The helper calls ACP `/flows/cohort_methods_specifications_recommendation`. When ACP is not connected, it returns a local stub with the same broad response shape. - -## Strategus Cohort Methods Shell - -Use `runStrategusCohortMethodsShell()` when you want the full cohort-methods workflow: intent split, target/comparator/outcome recommendation or explicit cohort IDs, analytic-settings collection, output artifacts, generated R scripts, and a merged `06_cm_spec.R` that builds and executes the Strategus specification. - -Fully interactive run: - -```r -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -OHDSIAssistant::runStrategusCohortMethodsShell() -``` - -Provide only the study intent and let the shell recommend/select target, comparator, and outcome cohorts: - -```r -OHDSIAssistant::runStrategusCohortMethodsShell( - studyIntent = "What is the risk of angioedema or acute myocardial infarction in new users of ACE inhibitors compared to new users of thiazide and thiazide-like diuretics?" -) -``` - -Provide explicit cohort IDs when you already know the target, comparator, and outcome cohorts: - -```r -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -OHDSIAssistant::runStrategusCohortMethodsShell( - outputDir = "demo-strategus-cohort-methods", - studyIntent = "What is the risk of angioedema or acute myocardial infarction in new users of ACE inhibitors compared to new users of thiazide and thiazide-like diuretics?", - targetCohortId = 12345, - comparatorCohortId = 23456, - outcomeCohortIds = c(34567, 45678), - comparisonLabel = "ace_inhibitors_vs_thiazide_diuretics" -) -``` - -To exercise the analytic-settings flow with stable demo inputs, pass explicit target/comparator/outcome IDs and either choose `step_by_step` when prompted or provide a free-text description: - -```r -OHDSIAssistant::runStrategusCohortMethodsShell( - outputDir = "demo-strategus-cohort-methods-analytic-settings", - studyIntent = "What is the risk of angioedema or acute myocardial infarction in new users of ACE inhibitors compared to new users of thiazide and thiazide-like diuretics?", - targetCohortId = 12345, - comparatorCohortId = 23456, - outcomeCohortIds = c(34567), - comparisonLabel = "ace_inhibitors_vs_thiazide_diuretics", - analyticSettingsDescription = "Use one-to-one propensity score matching and a Cox outcome model." -) -``` - -The shell writes outputs under `outputDir`, including `outputs/cm_analysis_defaults.json`, `outputs/cm_acp_specifications_recommendation.json` for free-text mode, `analysis-settings/cmAnalysis.json`, `analysis-settings/analysisSpecification.json`, and scripts under `scripts/`. - -Generated scripts are: - -1. `02_apply_improvements.R` -2. `03_generate_cohorts.R` -3. `04_keeper_review.R` -4. `05_diagnostics.R` -5. `06_cm_spec.R` - -Before running scripts that connect to the database, place these two files at the root of -`outputDir`: - -```text -/strategus-db-details.json -/strategus-execution-settings.json -``` - -`strategus-db-details.json`: - -```json -{ - "dbms": "postgresql", - "DB_SERVER": "localhost/database_name", - "DB_PORT": "5432", - "DB_USER": "ohdsi", - "DB_PASS": "change_me", - "DB_DRIVER_PATH": "~/jdbcDrivers", - "extraSettings": "sslmode=disable" -} -``` - -`strategus-execution-settings.json`: - -```json -{ - "cdmDatabaseSchema": "cdm_schema", - "workDatabaseSchema": "work_schema", - "resultsDatabaseSchema": "results_schema", - "vocabularyDatabaseSchema": "vocab_schema", - "cohortTable": "cohort", - "workFolder": "demo-strategus-cohort-methods/work", - "resultsFolder": "demo-strategus-cohort-methods/results", - "cohortIdFieldName": "cohort_definition_id", - "maxCores": 1 -} -``` +Preferred entrypoints going forward: +- `slashOhdsiAcpClient::acp_connect()` +- `slashOhdsiAcpClient::suggestPhenotypes()` +- `slashOhdsiAcpClient::reviewPhenotypes()` +- `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` +- `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` +- `slashOhdsiStrategusAssistant::suggestCohortMethodSpecs()` diff --git a/R/slashOhdsiAcpClient/DESCRIPTION b/R/slashOhdsiAcpClient/DESCRIPTION index 51bea39..64f00b7 100644 --- a/R/slashOhdsiAcpClient/DESCRIPTION +++ b/R/slashOhdsiAcpClient/DESCRIPTION @@ -7,6 +7,6 @@ Description: Thin R client for the OHDSI Study Agent ACP bridge. Provides explic License: MIT + file LICENSE Encoding: UTF-8 Depends: R (>= 4.2) -Imports: httr, jsonlite +Imports: httr, jsonlite, utils, tools Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.2 diff --git a/R/slashOhdsiAcpClient/NAMESPACE b/R/slashOhdsiAcpClient/NAMESPACE index 20d654e..c417f95 100644 --- a/R/slashOhdsiAcpClient/NAMESPACE +++ b/R/slashOhdsiAcpClient/NAMESPACE @@ -14,3 +14,15 @@ export(acp_review_phenotypes) export(acp_suggest_cohort_method_specs) export(acp_suggest_phenotypes) export(acp_workflow_context_dialogue) +export(acp_connect) +export(acp_get_default_client) +export(suggestPhenotypes) +export(pullPhenotypeDefinitions) +export(reviewPhenotypes) +export(selectPhenotypeRecommendations) +export(selectPhenotypeImprovements) +export(lintStudyDesign) +export(proposeIncludeDescendantsPatch) +export(previewConceptSetPatch) +export(applyConceptSetPatch) +export(applyLLMActionsConceptSet) diff --git a/R/slashOhdsiAcpClient/R/compatibility_api.R b/R/slashOhdsiAcpClient/R/compatibility_api.R new file mode 100644 index 0000000..e47d33b --- /dev/null +++ b/R/slashOhdsiAcpClient/R/compatibility_api.R @@ -0,0 +1,340 @@ +.acp_default_state <- local({ + env <- new.env(parent = emptyenv()) + env$client <- NULL + env +}) + +#' Connect to ACP and store a default client +#' @param url ACP base URL +#' @param token optional bearer token +#' @return invisible TRUE +#' @export +acp_connect <- function(url = "http://127.0.0.1:8765", token = NULL) { + .acp_default_state$client <- acp_client(url = url, token = token, check = TRUE) + invisible(TRUE) +} + +#' Get the default ACP client if connected +#' @return ACP client object or NULL +#' @export +acp_get_default_client <- function() { + .acp_default_state$client +} + +.extract_cohort_id_from_path <- function(path) { + base <- basename(path %||% "") + match <- regexpr("[0-9]+", base) + if (match[[1]] < 1) return(NA_integer_) + suppressWarnings(as.integer(substr(base, match[[1]], match[[1]] + attr(match, "match.length") - 1))) +} + +.write_phenotype_improvement_notes <- function(improvements, cohortJsonPaths, cohortPathIds, outputDir) { + ids <- vapply(improvements, function(x) x$targetCohortId %||% NA_integer_, integer(1)) + written <- character(0) + for (i in seq_along(improvements)) { + cohort_id <- ids[[i]] + src_path <- cohortJsonPaths[match(cohort_id, cohortPathIds, nomatch = 0)] %||% "" + stem <- if (nzchar(src_path)) tools::file_path_sans_ext(basename(src_path)) else paste0("cohort_", cohort_id) + out_path <- file.path(outputDir, sprintf("%s_improvements.json", stem)) + jsonlite::write_json(improvements[[i]], out_path, pretty = TRUE, auto_unbox = TRUE) + written <- c(written, out_path) + } + written +} + +local_phenotype_recommendations <- function(studyIntent, maxResults = 3) { + list( + source = "stub_no_acp", + status = "stub", + recommendations = list( + plan = sprintf("Local stub phenotype recommendations for: %s", studyIntent), + mode = "stub_no_acp", + phenotype_recommendations = vector("list", length = 0) + ) + ) +} + +local_phenotype_improvements <- function() { + list( + source = "stub_no_acp", + status = "stub", + full_result = list( + plan = "Local stub phenotype improvements", + mode = "stub_no_acp", + phenotype_improvements = vector("list", length = 0) + ) + ) +} + +#' Suggest phenotypes for a study protocol +#' @param protocolPath optional path to protocol markdown/text +#' @param studyIntent optional study intent string +#' @param topK number of candidates to retrieve from MCP +#' @param maxResults max phenotypes to return +#' @param candidateLimit max candidates to pass to the LLM +#' @param interactive print plan and recommendations +#' @return list response from ACP flow or local stub +#' @export +suggestPhenotypes <- function(protocolPath = NULL, + studyIntent = NULL, + topK = 20, + maxResults = 3, + candidateLimit = 10, + interactive = TRUE) { + if (!is.null(protocolPath)) { + protocolPath <- normalizePath(protocolPath, winslash = "/", mustWork = FALSE) + } + if (is.null(studyIntent) && !is.null(protocolPath)) { + studyIntent <- paste(readLines(protocolPath, warn = FALSE), collapse = "\n") + } + if (is.null(studyIntent) && isTRUE(interactive)) { + studyIntent <- utils::edit("Enter study intent text below and save/close to continue.") + } + if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { + stop("Provide studyIntent or protocolPath (with content) to suggestPhenotypes().") + } + + client <- acp_get_default_client() + res <- if (!is.null(client)) { + acp_suggest_phenotypes( + client = client, + study_intent = studyIntent, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit + ) + } else { + local_phenotype_recommendations(studyIntent, maxResults) + } + + res$artifact <- list(protocolRef = protocolPath) + core <- res$recommendations %||% res + if (isTRUE(interactive)) { + cat("\n== Phenotype Suggestions ==\n") + cat(core$plan %||% "", "\n") + if (!is.null(core$mode)) cat(sprintf("Mode: %s\n", core$mode)) + recs <- core$phenotype_recommendations %||% list() + if (length(recs) == 0) { + cat(" [stub] No recommendations (LLM not connected or no matches).\n") + } else { + for (r in recs) { + cat(sprintf(" - %s (%s): %s\n", + r$phenotype_name %||% "", + r$phenotype_id %||% "?", + r$justification %||% "")) + } + } + } + res +} + +#' Pull phenotype definitions to a local folder +#' @param cohortIds character vector of ACP phenotype ids +#' @param outputDir directory to write JSON definitions +#' @param overwrite logical; if FALSE, auto-version the filename +#' @return character vector of written file paths +#' @export +pullPhenotypeDefinitions <- function(cohortIds, + outputDir = ".", + overwrite = FALSE) { + phenotype_ids <- as.character(cohortIds %||% character(0)) + if (length(phenotype_ids) == 0) return(character(0)) + + unsupported <- phenotype_ids[!grepl("^ohdsi:", phenotype_ids)] + if (length(unsupported) > 0) { + stop(sprintf( + paste0( + "pullPhenotypeDefinitions() currently supports OHDSI phenotype ids only. ", + "Conversion of non-OHDSI phenotypes to computable OHDSI cohort definitions is not implemented yet. ", + "Unsupported ids: %s" + ), + paste(unique(unsupported), collapse = ", ") + )) + } + + index_dir <- Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index") + index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) + index_def_dir <- file.path(index_dir, "definitions") + if (!dir.exists(index_def_dir)) stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) + + outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) + if (!dir.exists(outputDir)) dir.create(outputDir, recursive = TRUE) + + definition_path <- function(phenotype_id) { + file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", phenotype_id, fixed = TRUE))) + } + + written <- character(0) + for (phenotype_id in phenotype_ids) { + src <- definition_path(phenotype_id) + if (!file.exists(src)) stop(sprintf("Phenotype JSON not found: %s", src)) + safe <- gsub("[^A-Za-z0-9_-]+", "_", phenotype_id) + target <- file.path(outputDir, sprintf("%s.json", safe)) + if (!overwrite) { + idx <- 1 + while (file.exists(target)) { + target <- file.path(outputDir, sprintf("%s-v%d.json", safe, idx)) + idx <- idx + 1 + } + } + file.copy(src, target, overwrite = TRUE) + written <- c(written, target) + } + written +} + +#' Review phenotype definitions for improvements +#' @param protocolPath path to protocol markdown/text +#' @param cohortJsonPaths character vector of cohort definition JSON paths +#' @param characterizationPaths optional vector of characterization outputs +#' @param interactive logical; print plan and summaries +#' @param apply logical; write selected improvements to disk +#' @param select selection vector for improvements +#' @param outputDir directory for written improvement notes +#' @return list response from ACP or local stub +#' @export +reviewPhenotypes <- function(protocolPath, + cohortJsonPaths, + characterizationPaths = NULL, + interactive = TRUE, + apply = FALSE, + select = NULL, + outputDir = NULL) { + protocolPath <- normalizePath(protocolPath, winslash = "/", mustWork = FALSE) + cohortJsonPaths <- unname(vapply(cohortJsonPaths, normalizePath, character(1), winslash = "/", mustWork = FALSE)) + if (length(cohortJsonPaths) == 0) stop("No cohortJsonPaths provided to reviewPhenotypes().") + if (!is.null(characterizationPaths)) { + characterizationPaths <- unname(vapply(characterizationPaths, normalizePath, character(1), winslash = "/", mustWork = FALSE)) + } + + client <- acp_get_default_client() + res <- if (!is.null(client)) { + acp_review_phenotypes(client, protocol_path = protocolPath, cohort_paths = cohortJsonPaths) + } else { + local_phenotype_improvements() + } + + res$artifact <- list(protocolPath = protocolPath, cohortPaths = cohortJsonPaths) + core <- res$full_result %||% res + if (isTRUE(interactive)) { + cat("\n== Phenotype Improvements ==\n") + cat(core$plan %||% "", "\n") + if (!is.null(core$mode)) cat(sprintf("Mode: %s\n", core$mode)) + imp <- core$phenotype_improvements %||% list() + if (length(imp) == 0) { + cat(" [stub] No improvements returned (LLM not connected).\n") + } else { + for (p in imp) { + cat(sprintf(" - [%s] %s\n", + p$targetCohortId %||% "?", + p$summary %||% jsonlite::toJSON(p, auto_unbox = TRUE))) + } + } + } + if (isTRUE(apply)) { + picks <- selectPhenotypeImprovements( + improvements = core$phenotype_improvements, + cohortJsonPaths = cohortJsonPaths, + select = select, + apply = TRUE, + outputDir = outputDir, + interactive = interactive + ) + res$selected_improvements <- picks$selected + res$written <- picks$written + } + res +} + +#' Select phenotype recommendations +#' @param recommendations list from suggestPhenotypes()$phenotype_recommendations +#' @param select phenotype ids, integer indices, or "all"/NULL +#' @param interactive if TRUE and select is NULL, prompt user +#' @return character vector of chosen phenotype ids +#' @export +selectPhenotypeRecommendations <- function(recommendations, + select = NULL, + interactive = interactive()) { + recs <- recommendations %||% list() + if (length(recs) == 0) return(character(0)) + ids <- vapply(recs, function(r) r$phenotype_id %||% NA_character_, character(1)) + if (is.null(select) || identical(select, "all")) { + if (isTRUE(interactive)) { + labels <- vapply(seq_along(recs), function(i) { + sprintf("%s (%s)", recs[[i]]$phenotype_name %||% "", recs[[i]]$phenotype_id %||% "?") + }, character(1)) + picks <- utils::select.list(labels, multiple = TRUE, title = "Select phenotypes to pull") + if (length(picks) == 0) return(character(0)) + idx <- match(picks, labels) + return(as.character(ids[idx])) + } + return(as.character(ids)) + } + if (is.numeric(select)) { + if (all(select %% 1 == 0) && all(select >= 1) && all(select <= length(ids))) return(as.character(ids[select])) + return(as.character(select)) + } + if (is.character(select)) { + if (all(select %in% ids)) return(as.character(select)) + idx <- suppressWarnings(as.integer(select)) + if (!anyNA(idx) && all(idx >= 1) && all(idx <= length(ids))) return(as.character(ids[idx])) + } + character(0) +} + +#' Select phenotype improvements and optionally persist notes +#' @param improvements list from reviewPhenotypes()$phenotype_improvements +#' @param cohortJsonPaths cohort JSON paths +#' @param select selection vector or "all"/NULL +#' @param apply when TRUE, write selected improvements to disk +#' @param outputDir destination directory +#' @param interactive prompt user when select is NULL +#' @return list with selected improvements and written file paths +#' @export +selectPhenotypeImprovements <- function(improvements, + cohortJsonPaths, + select = NULL, + apply = FALSE, + outputDir = NULL, + interactive = interactive()) { + imps <- improvements %||% list() + if (length(imps) == 0) return(list(selected = list(), written = character(0))) + ids <- vapply(imps, function(x) x$targetCohortId %||% NA_real_, numeric(1)) + cohortJsonPaths <- cohortJsonPaths %||% character(0) + cohortPathIds <- vapply(cohortJsonPaths, .extract_cohort_id_from_path, integer(1), USE.NAMES = FALSE) + + idx <- integer(0) + if (is.null(select) || identical(select, "all")) { + if (isTRUE(interactive)) { + labels <- vapply(seq_along(imps), function(i) { + cid <- ids[[i]] %||% NA_real_ + path_hint <- cohortJsonPaths[match(cid, cohortPathIds, nomatch = 0)] %||% "" + sprintf("Cohort %s: %s%s", + cid %||% "?", + imps[[i]]$summary %||% "", + ifelse(path_hint != "", sprintf(" [%s]", basename(path_hint)), "")) + }, character(1)) + picks <- utils::select.list(labels, multiple = TRUE, title = "Select phenotype improvements to keep") + if (length(picks) == 0) return(list(selected = list(), written = character(0))) + idx <- match(picks, labels) + } else { + idx <- seq_along(imps) + } + } else if (is.numeric(select)) { + if (all(select %% 1 == 0) && all(select >= 1) && all(select <= length(imps))) { + idx <- as.integer(select) + } else { + idx <- which(ids %in% as.integer(select)) + } + } + + if (length(idx) == 0) return(list(selected = list(), written = character(0))) + picked <- imps[idx] + written <- character(0) + if (isTRUE(apply) && length(picked)) { + if (is.null(outputDir)) outputDir <- dirname(cohortJsonPaths[[1]] %||% ".") + if (!dir.exists(outputDir)) dir.create(outputDir, recursive = TRUE, showWarnings = FALSE) + written <- .write_phenotype_improvement_notes(picked, cohortJsonPaths, cohortPathIds, outputDir) + } + list(selected = picked, written = written) +} diff --git a/R/slashOhdsiAcpClient/R/lint_and_concept_sets.R b/R/slashOhdsiAcpClient/R/lint_and_concept_sets.R new file mode 100644 index 0000000..d7172ec --- /dev/null +++ b/R/slashOhdsiAcpClient/R/lint_and_concept_sets.R @@ -0,0 +1,466 @@ +#' Apply LLM-proposed actions to a concept set via ACP +#' @param conceptSetRef path to local concept set JSON +#' @param actions list of action objects +#' @param preview logical; TRUE = dry run +#' @param overwrite logical; if FALSE, ACP may choose a versioned path +#' @param backup logical; if TRUE and overwrite=TRUE, create timestamped backup +#' @return list server response +#' @export +applyLLMActionsConceptSet <- function(conceptSetRef, + actions, + preview = TRUE, + overwrite = FALSE, + backup = TRUE) { + client <- acp_get_default_client() + if (is.null(client)) stop("ACP not connected; call acp_connect() first.") + acp_execute_llm_actions_concept_set( + client = client, + concept_set_ref = conceptSetRef, + actions = actions %||% list(), + write = !isTRUE(preview), + overwrite = isTRUE(overwrite), + backup = isTRUE(backup) + ) +} + +#' Propose includeDescendants patch for concept set +#' @param conceptSetRef path or URL to concept_set.json +#' @return list patch payload +#' @export +proposeIncludeDescendantsPatch <- function(conceptSetRef) { + payload <- list( + artifactRef = conceptSetRef, + ops = list(list( + op = "set_include_descendants", + where = list(domainId = "Drug", conceptClassId = "Ingredient", includeDescendants = FALSE), + value = TRUE + )), + write = FALSE + ) + + client <- acp_get_default_client() + if (!is.null(client)) { + res <- acp_concept_set_edit( + client = client, + artifact_ref = conceptSetRef, + ops = payload$ops, + write = FALSE + ) + if (is.null(res$ops)) res$ops <- payload$ops + res$artifactRef <- conceptSetRef + return(res) + } + + res <- local_apply_concept_set_action(payload, write = FALSE) + res$ops <- payload$ops + res$artifactRef <- conceptSetRef + res +} + +#' Preview concept set patch +#' @param conceptSetRef path or URL +#' @param patch patch object from proposeIncludeDescendantsPatch +#' @return preview result +#' @export +previewConceptSetPatch <- function(conceptSetRef, patch) { + if (!is.null(patch$actions)) { + prev <- applyLLMActionsConceptSet(conceptSetRef, patch$actions, preview = TRUE) + cat(prev$plan %||% "LLM actions preview", "\n") + if (length(prev$preview_changes %||% list()) == 0) { + cat("No matching items found.\n") + return(invisible(prev)) + } + df <- do.call(rbind, lapply(prev$preview_changes, as.data.frame)) + print(df) + return(invisible(prev)) + } + + if (is.null(patch$preview_changes)) { + cat("No preview available.\n") + return(invisible(NULL)) + } + + cat(patch$plan %||% "", "\n") + if (length(patch$preview_changes) == 0) { + cat("No matching items found.\n") + return(invisible(NULL)) + } + df <- do.call(rbind, lapply(patch$preview_changes, as.data.frame)) + print(df) + invisible(df) +} + +#' Apply concept set patch +#' @param conceptSetRef path or URL +#' @param patch patch object +#' @param backup logical; if TRUE, create .bak before overwrite +#' @param outputPath optional output path +#' @param useActions optional override for action mode +#' @param overwrite logical; overwrite source path in action mode +#' @return result list +#' @export +applyConceptSetPatch <- function(conceptSetRef, + patch, + backup = TRUE, + outputPath = NULL, + useActions = NULL, + overwrite = TRUE) { + if (is.null(useActions)) useActions <- !is.null(patch$actions) + if (isTRUE(useActions)) { + res <- applyLLMActionsConceptSet( + conceptSetRef, + patch$actions %||% list(), + preview = FALSE, + overwrite = overwrite, + backup = backup + ) + return(invisible(res)) + } + + patch$write <- TRUE + patch$artifactRef <- conceptSetRef + patch$backup <- backup + if (!is.null(outputPath)) patch$outputPath <- outputPath + if (is.null(patch$ops)) { + patch$ops <- list(list( + op = "set_include_descendants", + where = list(domainId = "Drug", conceptClassId = "Ingredient", includeDescendants = FALSE), + value = TRUE + )) + } + + pre_hash <- tryCatch(tools::md5sum(conceptSetRef), error = function(e) NA_character_) + client <- acp_get_default_client() + res <- if (!is.null(client)) { + acp_concept_set_edit( + client = client, + artifact_ref = conceptSetRef, + ops = patch$ops, + write = TRUE, + backup = backup, + output_path = outputPath + ) + } else { + local_apply_concept_set_action(patch, write = TRUE) + } + post_hash <- tryCatch(tools::md5sum(res$written_to %||% conceptSetRef), error = function(e) NA_character_) + + outdir <- file.path(dirname(conceptSetRef), "inst", "assistant") + if (!dir.exists(outdir)) dir.create(outdir, recursive = TRUE) + log_entry <- list( + plan = res$plan, + preview_changes = res$preview_changes, + applied = res$applied, + written_to = res$written_to %||% conceptSetRef, + pre_hash = unname(pre_hash), + post_hash = unname(post_hash), + ts = format(Sys.time(), "%Y%m%dT%H%M%S") + ) + jsonlite::write_json( + log_entry, + file.path(outdir, paste0("concept_set_edit_", log_entry$ts, ".json")), + auto_unbox = TRUE, + pretty = TRUE + ) + if (isTRUE(res$applied)) { + cat(sprintf("Applied concept set patch to %s\n", res$written_to %||% conceptSetRef)) + if (isTRUE(backup) && !is.null(res$backup_file)) { + cat(sprintf("Backup created at %s\n", res$backup_file)) + } + } else { + cat("No changes applied.\n") + } + invisible(res) +} + +#' Lint study design +#' @param studyProtocol path or URL to protocol.md +#' @param studyPackage path to local study folder +#' @param lintTasks character vector of tasks +#' @param apply logical; advisory placeholder +#' @param interactive logical; print plans and findings +#' @param streamThoughts logical; placeholder +#' @param handleActions logical; preview ACP action output when available +#' @param applyActions logical; apply ACP action output when available +#' @param overwriteActions logical; overwrite target file in ACP action mode +#' @param backupActions logical; backup target file in ACP action mode +#' @return invisible list of task results +#' @export +lintStudyDesign <- function(studyProtocol, + studyPackage = ".", + lintTasks = c("concept-sets-review", "cohort-critique-general-design"), + apply = FALSE, + interactive = TRUE, + streamThoughts = TRUE, + handleActions = FALSE, + applyActions = FALSE, + overwriteActions = FALSE, + backupActions = TRUE) { + conceptSetRef <- file.path(studyPackage, "concept_set.json") + cohortRef <- file.path(studyPackage, "cohort_definition.json") + study_intent <- paste(readLines(studyProtocol, warn = FALSE), collapse = " ") + + results <- list() + client <- acp_get_default_client() + + if ("concept-sets-review" %in% lintTasks) { + res <- if (!is.null(client)) { + acp_lint_concept_sets(client, concept_set_path = conceptSetRef, study_intent = study_intent) + } else { + local_concept_sets_review(conceptSetRef, studyIntent = study_intent) + } + res$artifact <- conceptSetRef + core <- res$full_result %||% res + if (handleActions && !is.null(client) && length(core$actions %||% list())) { + prev <- applyLLMActionsConceptSet(conceptSetRef, core$actions, preview = TRUE) + res$action_preview <- prev + if (applyActions) { + res$action_apply <- applyLLMActionsConceptSet( + conceptSetRef, + core$actions, + preview = FALSE, + overwrite = overwriteActions, + backup = backupActions + ) + } + } + if (interactive) { + cat("\n== Concept Sets Review ==\n") + cat(sprintf("File: %s\n", conceptSetRef)) + cat(core$plan %||% "", "\n") + print_findings(core$findings) + if (handleActions && !is.null(res$action_preview)) { + cat(sprintf( + "Action preview: %s changes, %s ignored\n", + res$action_preview$counts$changed %||% 0, + res$action_preview$counts$ignored %||% 0 + )) + } + if (applyActions && !is.null(res$action_apply) && isTRUE(res$action_apply$applied)) { + cat(sprintf("Actions applied. Written to: %s\n", res$action_apply$written_to %||% conceptSetRef)) + } + } + results$`concept-sets-review` <- res + } + + if ("cohort-critique-general-design" %in% lintTasks) { + res <- if (!is.null(client)) { + acp_lint_cohort_general_design(client, cohort_path = cohortRef) + } else { + local_cohort_critique_general(cohortRef) + } + res$artifact <- cohortRef + core <- res$full_result %||% res + if (interactive) { + cat("\n== Cohort Critique: General Design ==\n") + cat(sprintf("File: %s\n", cohortRef)) + cat(core$plan %||% "", "\n") + print_findings(core$findings) + } + results$`cohort-critique-general-design` <- res + } + + outdir <- file.path(studyPackage, "inst", "assistant") + if (!dir.exists(outdir)) dir.create(outdir, recursive = TRUE) + ts <- format(Sys.time(), "%Y%m%dT%H%M%S") + jsonlite::write_json( + results, + file.path(outdir, paste0("advice_", ts, ".json")), + auto_unbox = TRUE, + pretty = TRUE + ) + + invisible(results) +} + +local_apply_concept_set_action <- function(payload, write = FALSE) { + ref <- payload$artifactRef + cs <- read_json_ref(ref) + ops <- payload$ops %||% list() + all_preview <- list() + + for (op in ops) { + if (identical(op$op, "set_include_descendants")) { + where <- op$where %||% list() + value <- op$value %||% TRUE + res <- local_set_include_descendants(cs, where, value) + cs <- res$cs + all_preview <- c(all_preview, res$preview) + } + } + + written_to <- NULL + applied <- FALSE + backup_file <- NULL + if (isTRUE(write)) { + target <- payload$outputPath %||% ref + if (isTRUE(payload$backup) && file.exists(target)) { + ts <- format(Sys.time(), "%Y%m%dT%H%M%S") + backup_file <- paste0(target, ".bak_", ts) + file.copy(target, backup_file, overwrite = TRUE) + } + jsonlite::write_json(cs, target, auto_unbox = TRUE, pretty = TRUE) + written_to <- target + applied <- TRUE + } + + list( + plan = "Set includeDescendants=true for Drug/Ingredient entries that lack it.", + preview_changes = all_preview, + applied = applied, + written_to = written_to, + backup_file = backup_file + ) +} + +local_set_include_descendants <- function(cs, where, value = TRUE) { + items <- if (!is.null(cs$items)) cs$items else cs + preview <- list() + for (i in seq_along(items)) { + it <- items[[i]] + concept <- it$concept %||% list() + cid <- concept$conceptId %||% concept$CONCEPT_ID %||% NA_integer_ + dom <- concept$domainId %||% concept$DOMAIN_ID %||% NA_character_ + cls <- concept$conceptClassId %||% concept$CONCEPT_CLASS_ID %||% NA_character_ + inc <- it$includeDescendants %||% FALSE + if (!is.na(where$domainId %||% NA_character_) && !identical(dom, where$domainId)) next + if (!is.na(where$conceptClassId %||% NA_character_) && !identical(cls, where$conceptClassId)) next + if (!is.null(where$includeDescendants) && !identical(isTRUE(inc), isTRUE(where$includeDescendants))) next + preview <- c(preview, list(list( + conceptId = cid, + from = list(includeDescendants = inc), + to = list(includeDescendants = value) + ))) + it$includeDescendants <- isTRUE(value) + items[[i]] <- it + } + if (!is.null(cs$items)) { + cs$items <- items + } else { + cs <- items + } + list(cs = cs, preview = preview) +} + +local_concept_sets_review <- function(conceptSetRef, studyIntent = "") { + cs <- read_json_ref(conceptSetRef) + items <- if (!is.null(cs$items)) cs$items else cs + + get_item <- function(it) { + concept <- it$concept %||% it + list( + conceptId = concept$conceptId %||% concept$CONCEPT_ID %||% concept$id %||% NA_integer_, + domainId = concept$domainId %||% concept$DOMAIN_ID %||% NA_character_ + ) + } + lst <- lapply(items, get_item) + + plan <- sprintf("Local concept set review for %s", conceptSetRef) + findings <- list() + patches <- list() + risk_notes <- list() + + ids <- vapply(lst, function(x) x$conceptId, integer(1)) + ids <- ids[!is.na(ids)] + if (length(lst) == 0) { + findings <- c(findings, list(list( + id = "empty_concept_set", + severity = "high", + impact = "design", + message = "Concept set is empty." + ))) + } + if (length(ids)) { + dups <- ids[duplicated(ids)] + if (length(dups)) { + findings <- c(findings, list(list( + id = "duplicate_concepts", + severity = "medium", + impact = "design", + message = paste("Duplicate conceptIds:", paste(unique(dups), collapse = ", ")) + ))) + patches <- c(patches, list(list( + artifact = conceptSetRef, + type = "jsonpatch", + ops = list(list(op = "note", path = "/items", value = list(removeDuplicatesOf = unique(dups)))) + ))) + } + } + domains <- unique(vapply(lst, function(x) x$domainId %||% NA_character_, character(1))) + domains <- domains[!is.na(domains)] + if (length(domains) > 1) { + findings <- c(findings, list(list( + id = "mixed_domains", + severity = "low", + impact = "portability", + message = paste("Multiple domains:", paste(domains, collapse = ", ")) + ))) + } + + list(plan = plan, findings = findings, patches = patches, risk_notes = risk_notes) +} + +local_cohort_critique_general <- function(cohortRef) { + cdef <- read_json_ref(cohortRef) + plan <- sprintf("Local general cohort design lint for %s", cohortRef) + findings <- list() + patches <- list() + risk_notes <- list() + + pc <- cdef$PrimaryCriteria %||% list() + wash <- pc$ObservationWindow %||% list() + if (is.null(wash$PriorDays) || identical(wash$PriorDays, 0L)) { + findings <- c(findings, list(list( + id = "missing_washout", + severity = "medium", + impact = "validity", + message = "No or zero-day washout; consider >=365 days." + ))) + patches <- c(patches, list(list( + artifact = cohortRef, + type = "jsonpatch", + ops = list(list( + op = "note", + path = "/PrimaryCriteria/ObservationWindow", + value = list(ProposedPriorDays = 365) + )) + ))) + } + + irules <- cdef$InclusionRules %||% list() + for (i in seq_along(irules)) { + window <- irules[[i]]$window %||% NULL + if (!is.null(window) && !is.null(window$start) && !is.null(window$end) && window$start > window$end) { + findings <- c(findings, list(list( + id = paste0("inverted_window_", i), + severity = "high", + impact = "validity", + message = sprintf("InclusionRules[%d] has inverted window.", i) + ))) + } + } + list(plan = plan, findings = findings, patches = patches, risk_notes = risk_notes) +} + +read_json_ref <- function(ref) { + if (grepl("^https?://", ref)) { + txt <- readLines(ref, warn = FALSE) + return(jsonlite::fromJSON(paste(txt, collapse = "\n"), simplifyVector = FALSE)) + } + jsonlite::fromJSON(ref, simplifyVector = FALSE) +} + +print_findings <- function(findings) { + if (length(findings %||% list()) == 0) { + cat(" [OK] No findings.\n") + return(invisible(NULL)) + } + for (finding in findings) { + cat(sprintf( + " - [%s][%s] %s\n", + toupper(finding$severity %||% "INFO"), + finding$impact %||% "", + finding$message %||% jsonlite::toJSON(finding, auto_unbox = TRUE) + )) + } +} diff --git a/R/slashOhdsiStrategusAssistant/DESCRIPTION b/R/slashOhdsiStrategusAssistant/DESCRIPTION index ef6c759..62bec69 100644 --- a/R/slashOhdsiStrategusAssistant/DESCRIPTION +++ b/R/slashOhdsiStrategusAssistant/DESCRIPTION @@ -7,6 +7,6 @@ Description: Higher-level Strategus workflow package for slash-ohdsi. Owns workf License: MIT + file LICENSE Encoding: UTF-8 Depends: R (>= 4.2) -Imports: slashOhdsiAcpClient +Imports: slashOhdsiAcpClient, jsonlite, DatabaseConnector, CohortGenerator, Strategus, utils Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.2 diff --git a/R/slashOhdsiStrategusAssistant/NAMESPACE b/R/slashOhdsiStrategusAssistant/NAMESPACE index bee943c..4f2324c 100644 --- a/R/slashOhdsiStrategusAssistant/NAMESPACE +++ b/R/slashOhdsiStrategusAssistant/NAMESPACE @@ -12,3 +12,10 @@ export(render_workflow_dialogue_response) export(set_workflow_dialogue_context) export(validate_workflow_stage_context) export(workflow_stage_step_choices) +export(createStrategusConnectionDetails) +export(createStrategusExecutionSettings) +export(readStrategusDbDetails) +export(readStrategusExecutionSettings) +export(runStrategusIncidenceShell) +export(runStrategusCohortMethodsShell) +export(suggestCohortMethodSpecs) diff --git a/R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R b/R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R new file mode 100644 index 0000000..7605b32 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R @@ -0,0 +1,73 @@ +#' Suggest cohort method study specifications from a free-text description +#' @param studyIntent protocol context string +#' @param analyticSettingsDescription free-text analytic-settings description +#' @param interactive when TRUE, prints a section summary +#' @return list response from ACP flow or local stub +#' @export +suggestCohortMethodSpecs <- function(studyIntent, + analyticSettingsDescription, + interactive = TRUE) { + if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) stop("Provide a non-empty studyIntent.") + if (is.null(analyticSettingsDescription) || !nzchar(trimws(analyticSettingsDescription))) { + stop("Provide a non-empty analyticSettingsDescription.") + } + body <- list( + study_intent = trimws(as.character(studyIntent)), + study_description = trimws(as.character(analyticSettingsDescription)), + analytic_settings_description = trimws(as.character(analyticSettingsDescription)) + ) + + client <- slashOhdsiAcpClient::acp_get_default_client() + res <- if (!is.null(client)) { + slashOhdsiAcpClient::acp_suggest_cohort_method_specs( + client = client, + study_intent = studyIntent, + analytic_settings_description = analyticSettingsDescription + ) + } else { + local_cohort_method_specs(body) + } + + if (isTRUE(interactive)) { + cat("\n== Cohort Method Specifications ==\n") + cat("Status:", res$status %||% "(missing)", "\n") + rec <- res$recommendation %||% list() + if (length(rec) > 0) { + cat("Profile:", rec$profile_name %||% "(none)", "\n") + cat("Recommendation status:", rec$status %||% "(none)", "\n") + } + } + invisible(res) +} + +local_cohort_method_specs <- function(body) { + list( + source = "stub_no_acp", + status = "stub", + recommendation = list( + mode = "free_text", + input_method = "typed_text", + source = "local_stub_no_acp", + status = "stub", + profile_name = "Recommended from free-text description (stub)", + raw_description = body$analytic_settings_description %||% "", + study_population = list(), + time_at_risk = list(), + propensity_score_adjustment = list(), + outcome_model = list(), + deferred_inputs = list( + function_argument_description = "implemented", + description_file_path = "implemented", + interactive_typed_description = "implemented" + ), + defaults_snapshot = list() + ), + cohort_methods_specifications = list(), + section_rationales = list(), + diagnostics = list( + source = "local_stub_no_acp", + reason = "No default ACP client is connected; call slashOhdsiAcpClient::acp_connect(url) first." + ), + request = body + ) +} diff --git a/R/slashOhdsiStrategusAssistant/R/db_details.R b/R/slashOhdsiStrategusAssistant/R/db_details.R new file mode 100644 index 0000000..1a9b7ae --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/db_details.R @@ -0,0 +1,43 @@ +#' Read Strategus database details from JSON +#' @param path path to strategus-db-details.json +#' @return list of db settings +#' @export +readStrategusDbDetails <- function(path = file.path(getwd(), "strategus-db-details.json")) { + if (!file.exists(path)) { + stop("Database details file not found: ", path) + } + jsonlite::read_json(path, simplifyVector = TRUE) +} + +#' Create DatabaseConnector connectionDetails from strategus-db-details.json +#' @param path path to strategus-db-details.json +#' @param dbDetails optional list of db settings (if already loaded) +#' @return DatabaseConnector connectionDetails object +#' @export +createStrategusConnectionDetails <- function(path = file.path(getwd(), "strategus-db-details.json"), + dbDetails = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + dbConfig <- dbDetails %||% readStrategusDbDetails(path) + dbms <- dbConfig$dbms %||% "postgresql" + server <- dbConfig$DB_SERVER %||% dbConfig$server + if (is.null(server) || !nzchar(server)) { + stop("Database server must be provided in strategus-db-details.json (DB_SERVER or server).") + } + port <- dbConfig$DB_PORT %||% dbConfig$port %||% "5432" + user <- dbConfig$DB_USER %||% dbConfig$user + password <- dbConfig$DB_PASS %||% dbConfig$password + if (is.null(user) || is.null(password)) { + stop("Database credentials must be provided in strategus-db-details.json (DB_USER/DB_PASS or user/password).") + } + pathToDriver <- dbConfig$DB_DRIVER_PATH %||% dbConfig$pathToDriver + extraSettings <- dbConfig$extraSettings %||% "sslmode=disable" + DatabaseConnector::createConnectionDetails( + dbms = dbms, + server = server, + user = user, + password = password, + port = port, + pathToDriver = pathToDriver, + extraSettings = extraSettings + ) +} diff --git a/R/slashOhdsiStrategusAssistant/R/execution_settings.R b/R/slashOhdsiStrategusAssistant/R/execution_settings.R new file mode 100644 index 0000000..eb40a32 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/execution_settings.R @@ -0,0 +1,62 @@ +#' Read Strategus execution settings from JSON +#' @param path path to strategus-execution-settings.json +#' @return list of execution settings +#' @export +readStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json")) { + if (!file.exists(path)) { + stop("Execution settings file not found: ", path) + } + jsonlite::read_json(path, simplifyVector = TRUE) +} + +#' Create Strategus execution settings from JSON +#' @param path path to strategus-execution-settings.json +#' @param settings optional list of settings (if already loaded) +#' @return list with executionSettings and resolved values +#' @export +createStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json"), + settings = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + cfg <- settings %||% readStrategusExecutionSettings(path) + cdmDatabaseSchema <- cfg$cdmDatabaseSchema + workDatabaseSchema <- cfg$workDatabaseSchema + resultsDatabaseSchema <- cfg$resultsDatabaseSchema + vocabularyDatabaseSchema <- cfg$vocabularyDatabaseSchema + cohortTable <- cfg$cohortTable + workFolder <- cfg$workFolder + resultsFolder <- cfg$resultsFolder + cohortIdFieldName <- cfg$cohortIdFieldName %||% "cohort_definition_id" + maxCores <- cfg$maxCores %||% parallel::detectCores() + maxCores <- suppressWarnings(as.integer(maxCores)[1]) + if (is.na(maxCores) || maxCores < 1L) maxCores <- 1L + + if (!nzchar(cdmDatabaseSchema)) stop("cdmDatabaseSchema must be provided in strategus-execution-settings.json") + if (!nzchar(workDatabaseSchema)) stop("workDatabaseSchema must be provided in strategus-execution-settings.json") + if (!nzchar(resultsDatabaseSchema)) stop("resultsDatabaseSchema must be provided in strategus-execution-settings.json") + if (!nzchar(vocabularyDatabaseSchema)) stop("vocabularyDatabaseSchema must be provided in strategus-execution-settings.json") + if (!nzchar(cohortTable)) stop("cohortTable must be provided in strategus-execution-settings.json") + if (!nzchar(workFolder)) stop("workFolder must be provided in strategus-execution-settings.json") + if (!nzchar(resultsFolder)) stop("resultsFolder must be provided in strategus-execution-settings.json") + + executionSettings <- Strategus::createCdmExecutionSettings( + cdmDatabaseSchema = cdmDatabaseSchema, + workDatabaseSchema = workDatabaseSchema, + cohortTableNames = CohortGenerator::getCohortTableNames(cohortTable = cohortTable), + workFolder = workFolder, + resultsFolder = resultsFolder, + maxCores = maxCores + ) + + list( + executionSettings = executionSettings, + cdmDatabaseSchema = cdmDatabaseSchema, + workDatabaseSchema = workDatabaseSchema, + resultsDatabaseSchema = resultsDatabaseSchema, + vocabularyDatabaseSchema = vocabularyDatabaseSchema, + cohortTable = cohortTable, + workFolder = workFolder, + resultsFolder = resultsFolder, + maxCores = maxCores, + cohortIdFieldName = cohortIdFieldName + ) +} diff --git a/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R b/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R new file mode 100644 index 0000000..a6706e6 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R @@ -0,0 +1,63 @@ +.studyAgentSlashCreateAcpClient <- function(url = "http://127.0.0.1:8765", token = NULL, check = TRUE) { + slashOhdsiAcpClient::acp_client(url = url, token = token, check = check) +} + +.studyAgentSlashAcpIsConnected <- function(client) { + isTRUE(slashOhdsiAcpClient::acp_is_connected(client)) +} + +.studyAgentSlashCallAcpFlow <- function(client, flow_name, body = list()) { + slashOhdsiAcpClient::acp_call_flow(client = client, flow_name = flow_name, body = body) +} + +.studyAgentSlashNewWorkflowStageContext <- function(...) { + new_workflow_stage_context(...) +} + +.studyAgentSlashCompactWorkflowDialogueContext <- function(value) { + compact_workflow_dialogue_context(value) +} + +.studyAgentSlashNewWorkflowDialogueSession <- function(...) { + new_workflow_dialogue_session(...) +} + +.studyAgentSlashNormalizeIncidenceDialogueStep <- function(step) { + normalize_incidence_dialogue_step(step) +} + +.studyAgentSlashIncidenceDialogueStepLabel <- function(step, role = "") { + incidence_dialogue_step_label(step = step, role = role) +} + +.studyAgentSlashBuildIncidenceWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { + build_incidence_workflow_stage_context( + study_intent = study_intent, + dialogue_state = dialogue_state, + interactive = interactive + ) +} + +.studyAgentSlashNormalizeCohortMethodsDialogueStep <- function(step) { + normalize_cohort_methods_dialogue_step(step) +} + +.studyAgentSlashCohortMethodsDialogueStepLabel <- function(step, role = "") { + cohort_methods_dialogue_step_label(step = step, role = role) +} + +.studyAgentSlashBuildCohortMethodsWorkflowStageContext <- function(study_intent, dialogue_state, interactive = TRUE) { + build_cohort_methods_workflow_stage_context( + study_intent = study_intent, + dialogue_state = dialogue_state, + interactive = interactive + ) +} + +.studyAgentSlashWorkflowContextDialogue <- function(client, stage_context, message) { + slashOhdsiAcpClient::acp_workflow_context_dialogue( + client = client, + stage_context = stage_context, + message = message + ) +} diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R new file mode 100644 index 0000000..437ea90 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R @@ -0,0 +1,6230 @@ +#' Interactive shell to generate Strategus CohortMethod scripts +#' @param outputDir directory where scripts and artifacts will be written +#' @param acpUrl ACP base URL for cohort-method recommendation calls +#' @param studyIntent study intent text +#' @param targetStatement optional explicit target cohort statement used for phenotype recommendation +#' @param comparatorStatement optional explicit comparator cohort statement used for phenotype recommendation +#' @param outcomeStatement optional explicit outcome cohort statement used for phenotype recommendation +#' @param targetCohortId target cohort definition ID +#' @param comparatorCohortId comparator cohort definition ID +#' @param outcomeCohortIds outcome cohort definition IDs +#' @param comparisonLabel optional label for the target-comparator comparison +#' @param topK number of candidates retrieved from MCP search +#' @param maxResults max phenotypes to show +#' @param candidateLimit max candidates to pass to LLM +#' @param indexDir phenotype index directory (contains definitions/ and catalog.jsonl) +#' @param negativeControlConceptSetId optional negative control concept set ID +#' @param includeCovariateConceptSetId optional covariate include concept set ID +#' @param excludeCovariateConceptSetId optional covariate exclude concept set ID +#' @param analyticSettingsDescription optional free-text analytic settings description +#' @param analyticSettingsDescriptionPath optional path to a text file containing the free-text analytic settings description +#' @param incidenceOutputDir optional Strategus CohortIncidence output directory used for cached target/outcome cohort reuse +#' @param interactive whether to prompt for missing inputs +#' @param bannerPath optional path to ASCII banner +#' @param studyAgentBaseDir base directory to resolve relative paths +#' @param reset when TRUE, delete outputDir before running +#' @param allowCache reuse cached flow artifacts when present +#' @param promptOnCache prompt before using cached flow artifacts +#' @param autoApplyImprovements when TRUE, apply improvements without prompting (defaults to TRUE for non-interactive) +#' @param resume when TRUE, prefer cached manual inputs when present +#' @param remapCohortIds when TRUE, assign new local cohort IDs +#' @param cohortIdBase optional starting cohort ID when remapping +#' @return invisible list with output paths +#' @export +.studyAgentAnalyticSettingsSectionPaths <- function() { + list( + study_population = c( + "get_db_cohort_method_data.studyStartDate", + "get_db_cohort_method_data.studyEndDate", + "get_db_cohort_method_data.restrictToCommonPeriod", + "get_db_cohort_method_data.firstExposureOnly", + "get_db_cohort_method_data.washoutPeriod", + "create_study_population.removeDuplicateSubjects", + "create_study_population.censorAtNewRiskWindow", + "create_study_population.removeSubjectsWithPriorOutcome", + "create_study_population.priorOutcomeLookback" + , + "create_study_population.maxCohortSize" + ), + time_at_risk = c( + "create_study_population.minDaysAtRisk", + "create_study_population.riskWindowStart", + "create_study_population.startAnchor", + "create_study_population.riskWindowEnd", + "create_study_population.endAnchor" + ), + propensity_score_adjustment = c( + "ps_adjustment.strategy", + "ps_adjustment.trimmingStrategy", + "ps_adjustment.trimmingPercent", + "ps_adjustment.equipoiseLowerBound", + "ps_adjustment.equipoiseUpperBound", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization", + "match_on_ps.caliper", + "match_on_ps.caliperScale", + "match_on_ps.maxRatio", + "stratify_by_ps.numberOfStrata", + "stratify_by_ps.baseSelection" + ), + outcome_model = c( + "fit_outcome_model.modelType", + "fit_outcome_model.stratified", + "fit_outcome_model.useCovariates", + "fit_outcome_model.inversePtWeighting", + "fit_outcome_model.useRegularization" + ) + ) +} + +.studyAgentAnalyticSettingsSectionTitles <- function() { + c( + study_population = "Study Population", + time_at_risk = "Time At Risk", + propensity_score_adjustment = "Propensity Score Adjustment", + outcome_model = "Outcome Model" + ) +} + +.studyAgentAnalyticSettingDocs <- function() { + list( + "get_db_cohort_method_data.studyStartDate" = list( + label = "Study start date", + summary_label = "Study start date" + ), + "get_db_cohort_method_data.studyEndDate" = list( + label = "Study end date", + summary_label = "Study end date" + ), + "get_db_cohort_method_data.firstExposureOnly" = list( + label = "First exposure only", + summary_label = "First exposure only", + description = "Should only the first exposure per subject be included?" + ), + "get_db_cohort_method_data.washoutPeriod" = list( + label = "Washout period", + summary_label = "Washout period", + description = "The minimum required continuous observation time (in days) prior to index date for a person to be included in the cohort." + ), + "get_db_cohort_method_data.restrictToCommonPeriod" = list( + label = "Restrict to common period", + summary_label = "Restrict to common period", + description = "Restrict the study to the period when both exposures are present in the data? (E.g. when both drugs are on the market)" + ), + "get_db_cohort_method_data.removeDuplicateSubjects" = list( + label = "Duplicate subjects during extraction", + summary_label = "Duplicate subjects during extraction", + description = "Controls how people who appear in both target and comparator cohorts are handled while extracting data." + ), + "create_study_population.removeDuplicateSubjects" = list( + label = "Remove duplicate subjects", + summary_label = "Remove duplicate subjects", + description = "Remove subjects that are in both the target and comparator cohort?" + ), + "create_study_population.maxCohortSize" = list( + label = "Maximum cohort size", + summary_label = "Maximum cohort size", + description = "If either the target or the comparator cohort is larger than this number it will be sampled to this size. (0 for this value indicates no maximum size)" + ), + "create_study_population.removeSubjectsWithPriorOutcome" = list( + label = "Remove prior outcomes", + summary_label = "Remove prior outcomes", + description = "Remove subjects that have the outcome prior to the risk window start?" + ), + "create_study_population.priorOutcomeLookback" = list( + label = "Prior outcome lookback", + summary_label = "Prior outcome lookback", + description = "How many days should we look back when identifying prior outcomes?" + ), + "create_study_population.riskWindowStart" = list( + label = "Risk window start", + summary_label = "Risk window start" + ), + "create_study_population.minDaysAtRisk" = list( + label = "Minimum days at risk", + summary_label = "Minimum days at risk", + description = "The minimum number of days at risk?" + ), + "create_study_population.startAnchor" = list( + label = "Risk window start anchor", + summary_label = "Risk window start anchor" + ), + "create_study_population.riskWindowEnd" = list( + label = "Risk window end", + summary_label = "Risk window end" + ), + "create_study_population.endAnchor" = list( + label = "Risk window end anchor", + summary_label = "Risk window end anchor" + ), + "create_study_population.censorAtNewRiskWindow" = list( + label = "Censor at new risk window", + summary_label = "Censor at new risk window", + description = "If a subject is in multiple cohorts, should time-at-risk be censored when the new time-at-risk start to prevent overlap?" + ), + "ps_adjustment.strategy" = list( + label = "PS adjustment strategy", + summary_label = "PS adjustment strategy" + ), + "ps_adjustment.trimmingStrategy" = list( + label = "PS trimming", + summary_label = "PS trimming", + description = "How do you want to trim your cohorts based on the propensity score distribution?" + ), + "ps_adjustment.trimmingPercent" = list( + label = "Trimming percent", + summary_label = "Trimming percent", + description = "What percentage of each tail should be removed?" + ), + "ps_adjustment.equipoiseLowerBound" = list( + label = "Equipoise lower bound", + summary_label = "Equipoise lower bound", + description = "What is the lower preference score bound for trimming to equipoise?" + ), + "ps_adjustment.equipoiseUpperBound" = list( + label = "Equipoise upper bound", + summary_label = "Equipoise upper bound", + description = "What is the upper preference score bound for trimming to equipoise?" + ), + "create_ps.estimator" = list( + label = "PS estimator", + summary_label = "PS estimator", + description = "Defines the treatment effect target used when propensity scores are converted into adjustment weights or summaries." + ), + "create_ps.maxCohortSizeForFitting" = list( + label = "Max cohort size for PS fitting", + summary_label = "Max cohort size for PS fitting", + description = "What is the maximum number of people to include in the propensity score model when fitting? Setting this number to 0 means no down-sampling will be applied:" + ), + "create_ps.errorOnHighCorrelation" = list( + label = "Test covariate correlation", + summary_label = "Test covariate correlation", + description = "Test each covariate for correlation with the target assignment? If any covariate has an unusually high correlation (either positive or negative), this will throw an error." + ), + "create_ps.useRegularization" = list( + label = "Use regularization", + summary_label = "Use regularization", + description = "Use regularization when fitting the propensity model?" + ), + "match_on_ps.caliper" = list( + label = "Matching caliper", + summary_label = "Matching caliper", + description = "What is the caliper for matching:" + ), + "match_on_ps.caliperScale" = list( + label = "Caliper scale", + summary_label = "Caliper scale", + description = "What is the caliper scale:" + ), + "match_on_ps.maxRatio" = list( + label = "Maximum match ratio", + summary_label = "Maximum match ratio", + description = "What is the maximum number of persons in the comparator arm to be matched to each person in the target arm within the defined caliper? (0 = means no maximum - all comparators will be assigned to a target person):" + ), + "stratify_by_ps.numberOfStrata" = list( + label = "Number of strata", + summary_label = "Number of strata", + description = "Into how many strata should the propensity score be divided? The boundaries of the strata are automatically defined to contain equal numbers of target persons:" + ), + "stratify_by_ps.baseSelection" = list( + label = "Base selection for strata bounds", + summary_label = "Base selection for strata bounds", + description = "What is the base selection of subjects where the strata bounds are to be determined? Strata are defined as equally-sized strata inside this selection." + ), + "fit_outcome_model.modelType" = list( + label = "Outcome model", + summary_label = "Outcome model" + ), + "fit_outcome_model.stratified" = list( + label = "Condition on strata", + summary_label = "Condition on strata", + description = "Should the regression be conditioned on the strata defined in the population object (e.g. by matching or stratifying on propensity scores)?" + ), + "fit_outcome_model.useCovariates" = list( + label = "Use covariates in outcome model", + summary_label = "Use covariates in outcome model", + description = "Should the covariates also be included in the outcome model?" + ), + "fit_outcome_model.inversePtWeighting" = list( + label = "Use IPTW", + summary_label = "Use IPTW", + description = "Use inverse probability of treatment weighting?" + ), + "fit_outcome_model.useRegularization" = list( + label = "Use regularization", + summary_label = "Use regularization", + description = "Use regularization when fitting the outcome model?" + ) + ) +} + +.studyAgentSummaryLabel <- function(path) { + docs <- .studyAgentAnalyticSettingDocs() + doc <- docs[[path]] + if (is.null(doc)) return(path) + if (!is.null(doc$summary_label)) return(as.character(doc$summary_label)) + if (!is.null(doc$label)) return(as.character(doc$label)) + path +} + +.studyAgentFormatDateForPrompt <- function(value) { + if (is.null(value) || length(value) == 0 || is.na(value)) return("") + value <- trimws(as.character(value[[1]])) + if (!nzchar(value)) return("") + value +} + +.studyAgentFormatAnalyticSettingValue <- function(value, path = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + if (is.null(value) || length(value) == 0 || is.na(value)) return("") + if (is.character(value) && length(value) == 1 && !nzchar(trimws(value))) return("") + if (!is.null(path) && path %in% c("get_db_cohort_method_data.studyStartDate", "get_db_cohort_method_data.studyEndDate")) { + return(.studyAgentFormatDateForPrompt(value)) + } + if (is.logical(value) && length(value) == 1) return(if (isTRUE(value)) "Yes" else "No") + if (is.character(value) && length(value) == 1) { + mapped <- switch( + path %||% "", + "create_study_population.startAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], + "create_study_population.endAnchor" = c("cohort start" = "cohort start date", "cohort end" = "cohort end date")[[value]], + "ps_adjustment.strategy" = c("match_on_ps" = "Match on propensity score", "stratify_by_ps" = "Stratify on propensity score", "none" = "None")[[value]], + "ps_adjustment.trimmingStrategy" = c("none" = "None", "by_percent" = "By percent", "by_equipoise" = "By equipoise")[[value]], + "match_on_ps.caliperScale" = c("propensity score" = "Propensity score", "standardized" = "Standardized", "standardized logit" = "Standardized logit")[[value]], + "fit_outcome_model.modelType" = c("cox" = "Cox proportional hazards", "poisson" = "Poisson regression", "logistic" = "Logistic regression")[[value]], + "create_study_population.removeDuplicateSubjects" = c("keep all" = "Keep All", "keep first" = "Keep First", "remove all" = "Remove All")[[value]], + "get_db_cohort_method_data.removeDuplicateSubjects" = c("keep all" = "Keep All", "keep first" = "Keep First", "remove all" = "Remove All", "keep first, truncate to second" = "Keep First, Truncate to Second")[[value]], + "stratify_by_ps.baseSelection" = c("all" = "Entire study population", "target" = "Target", "comparator" = "Comparator")[[value]], + NULL + ) + if (!is.null(mapped) && length(mapped) == 1 && !is.na(mapped)) return(mapped) + } + if (!is.null(path) && identical(path, "ps_adjustment.trimmingPercent") && is.numeric(value) && length(value) == 1) { + formatted <- formatC(as.numeric(value), format = "fg", digits = 6) + return(sprintf("%s%%", formatted)) + } + if (is.numeric(value) && length(value) == 1) return(as.character(value)) + paste(as.character(value), collapse = ", ") +} + +.studyAgentOutcomeModelDefaults <- function(ps_strategy = "match_on_ps", + match_max_ratio = 1L, + model_type = "cox") { + normalized_strategy <- as.character(if (is.null(ps_strategy)) "match_on_ps" else ps_strategy) + normalized_ratio <- suppressWarnings(as.integer(if (is.null(match_max_ratio)) 1L else match_max_ratio)) + if (length(normalized_ratio) == 0 || is.na(normalized_ratio)) normalized_ratio <- 1L + + stratified_default <- FALSE + if (identical(normalized_strategy, "stratify_by_ps")) { + stratified_default <- TRUE + } else if (identical(normalized_strategy, "match_on_ps") && normalized_ratio != 1L) { + stratified_default <- TRUE + } + + list( + modelType = as.character(if (is.null(model_type)) "cox" else model_type), + stratified = isTRUE(stratified_default), + useCovariates = FALSE, + inversePtWeighting = FALSE, + useRegularization = TRUE + ) +} + +.studyAgentPrintDefaultSummary <- function(header, defaults, paths) { + docs <- .studyAgentAnalyticSettingDocs() + cat(sprintf("%s\n", header)) + for (path in paths) { + doc <- docs[[path]] + if (is.null(doc)) doc <- list(label = path, description = "") + label <- .studyAgentSummaryLabel(path) + value <- .studyAgentFormatAnalyticSettingValue(.studyAgentGetNestedValue(defaults, path), path = path) + cat(sprintf(" - %s: %s\n", label, value)) + } +} + +.studyAgentPromptKeepDefaults <- function(question, defaults, paths, io_ask_yesno) { + cat(sprintf("%s\n", question)) + .studyAgentPrintDefaultSummary( + "Default settings:", + defaults, + paths + ) + io_ask_yesno("Keep these defaults? Choose No if you want to set the remaining options yourself.", default = TRUE) +} + +.studyAgentPrintAnalyticSettingDescription <- function(path) { + docs <- .studyAgentAnalyticSettingDocs() + doc <- docs[[path]] + if (is.null(doc) || is.null(doc$description)) return(invisible(NULL)) + description <- trimws(as.character(doc$description)) + if (!nzchar(description)) return(invisible(NULL)) + cat(sprintf("%s\n", description)) + invisible(NULL) +} + +.studyAgentPromptAnalyticSetting <- function(working, + path, + ask_yesno, + ask_choice, + ask_integer, + ask_numeric) { + `%||%` <- function(x, y) if (is.null(x)) y else x + + .studyAgentPrintAnalyticSettingDescription(path) + + updated <- switch( + path, + "get_db_cohort_method_data.restrictToCommonPeriod" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "get_db_cohort_method_data.firstExposureOnly" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "get_db_cohort_method_data.washoutPeriod" = .studyAgentSetNestedValue( + working, + path, + ask_integer( + "", + default = as.integer(.studyAgentGetNestedValue(working, path)), + min_value = 0L, + allow_negative = FALSE + ) + ), + "create_study_population.removeDuplicateSubjects" = .studyAgentSetNestedValue( + working, + path, + ask_choice( + "", + choices = c("keep all", "keep first", "remove all"), + labels = c("Keep All", "Keep First", "Remove All"), + default = .studyAgentGetNestedValue(working, path) %||% "keep all" + ) + ), + "create_study_population.censorAtNewRiskWindow" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "create_study_population.removeSubjectsWithPriorOutcome" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "create_study_population.priorOutcomeLookback" = .studyAgentSetNestedValue( + working, + path, + ask_integer( + "", + default = as.integer(.studyAgentGetNestedValue(working, path)), + min_value = 0L, + allow_negative = FALSE + ) + ), + "create_study_population.maxCohortSize" = .studyAgentSetNestedValue( + working, + path, + ask_integer( + "", + default = as.integer(.studyAgentGetNestedValue(working, path)), + min_value = 0L, + allow_negative = FALSE + ) + ), + "create_study_population.minDaysAtRisk" = .studyAgentSetNestedValue( + working, + path, + ask_integer( + "", + default = as.integer(.studyAgentGetNestedValue(working, path)), + min_value = 0L, + allow_negative = FALSE + ) + ), + "ps_adjustment.trimmingStrategy" = { + updated_working <- .studyAgentSetNestedValue( + working, + path, + ask_choice( + "", + choices = c("none", "by_percent", "by_equipoise"), + labels = c("None", "By percent", "By equipoise"), + default = .studyAgentGetNestedValue(working, path) %||% "none" + ) + ) + selected_strategy <- .studyAgentGetNestedValue(updated_working, path) %||% "none" + if (identical(selected_strategy, "by_percent")) { + updated_working <- .studyAgentSetNestedValue( + updated_working, + "ps_adjustment.trimmingPercent", + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.trimmingPercent") %||% 5), + min_value = 0 + ) + ) + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound", 0.25) + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound", 0.75) + } else if (identical(selected_strategy, "by_equipoise")) { + updated_working <- .studyAgentSetNestedValue( + updated_working, + "ps_adjustment.equipoiseLowerBound", + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound") %||% 0.25), + min_value = 0 + ) + ) + updated_working <- .studyAgentSetNestedValue( + updated_working, + "ps_adjustment.equipoiseUpperBound", + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound") %||% 0.75), + min_value = 0 + ) + ) + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.trimmingPercent", 5) + } else { + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.trimmingPercent", 5) + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseLowerBound", 0.25) + updated_working <- .studyAgentSetNestedValue(updated_working, "ps_adjustment.equipoiseUpperBound", 0.75) + } + updated_working + }, + "ps_adjustment.trimmingPercent" = .studyAgentSetNestedValue( + working, + path, + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 5), + min_value = 0 + ) + ), + "ps_adjustment.equipoiseLowerBound" = .studyAgentSetNestedValue( + working, + path, + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 0.25), + min_value = 0 + ) + ), + "ps_adjustment.equipoiseUpperBound" = .studyAgentSetNestedValue( + working, + path, + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(working, path) %||% 0.75), + min_value = 0 + ) + ), + "create_ps.maxCohortSizeForFitting" = .studyAgentSetNestedValue( + working, + path, + ask_integer( + "", + default = as.integer(.studyAgentGetNestedValue(working, path)), + min_value = 0L, + allow_negative = FALSE + ) + ), + "create_ps.errorOnHighCorrelation" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "create_ps.useRegularization" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "match_on_ps.caliper" = .studyAgentSetNestedValue( + working, + path, + ask_numeric( + "", + default = as.numeric(.studyAgentGetNestedValue(working, path)), + min_value = 0 + ) + ), + "match_on_ps.caliperScale" = .studyAgentSetNestedValue( + working, + path, + ask_choice( + "", + choices = c("propensity score", "standardized", "standardized logit"), + labels = c("Propensity score", "Standardized", "Standardized logit"), + default = .studyAgentGetNestedValue(working, path) %||% "standardized logit" + ) + ), + "stratify_by_ps.baseSelection" = .studyAgentSetNestedValue( + working, + path, + ask_choice( + "", + choices = c("all", "target", "comparator"), + labels = c("Entire study population", "Target", "Comparator"), + default = .studyAgentGetNestedValue(working, path) %||% "all" + ) + ), + "fit_outcome_model.stratified" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "fit_outcome_model.useCovariates" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "fit_outcome_model.inversePtWeighting" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + "fit_outcome_model.useRegularization" = .studyAgentSetNestedValue( + working, + path, + ask_yesno( + "", + default = isTRUE(.studyAgentGetNestedValue(working, path)) + ) + ), + stop(sprintf("Unsupported analytic setting customization path: %s", path)) + ) + + updated +} + +.studyAgentCustomizeAnalyticSettings <- function(working, + paths, + ask_yesno, + ask_choice, + ask_integer, + ask_numeric) { + updated <- working + for (path in paths) { + updated <- .studyAgentPromptAnalyticSetting( + updated, + path, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + updated +} + +.studyAgentSummaryPathsForSection <- function(section_name, section_paths, settings) { + paths <- section_paths[[section_name]] + if (!identical(section_name, "propensity_score_adjustment")) { + return(paths) + } + + strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.strategy") + if (identical(strategy, "match_on_ps")) { + trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") + trim_paths <- c("ps_adjustment.trimmingStrategy") + if (identical(trim_strategy, "by_percent")) { + trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") + } else if (identical(trim_strategy, "by_equipoise")) { + trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") + } + return(c( + trim_paths, + "ps_adjustment.strategy", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization", + "match_on_ps.maxRatio", + "match_on_ps.caliper", + "match_on_ps.caliperScale" + )) + } + if (identical(strategy, "stratify_by_ps")) { + trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") + trim_paths <- c("ps_adjustment.trimmingStrategy") + if (identical(trim_strategy, "by_percent")) { + trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") + } else if (identical(trim_strategy, "by_equipoise")) { + trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") + } + return(c( + trim_paths, + "ps_adjustment.strategy", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization", + "stratify_by_ps.numberOfStrata", + "stratify_by_ps.baseSelection" + )) + } + trim_strategy <- .studyAgentGetNestedValue(settings, "ps_adjustment.trimmingStrategy") + trim_paths <- c("ps_adjustment.trimmingStrategy") + if (identical(trim_strategy, "by_percent")) { + trim_paths <- c(trim_paths, "ps_adjustment.trimmingPercent") + } else if (identical(trim_strategy, "by_equipoise")) { + trim_paths <- c(trim_paths, "ps_adjustment.equipoiseLowerBound", "ps_adjustment.equipoiseUpperBound") + } + c(trim_paths, "ps_adjustment.strategy") +} + +.studyAgentPrintFinalSettingsSummary <- function(settings, section_paths) { + `%||%` <- function(x, y) if (is.null(x)) y else x + docs <- .studyAgentAnalyticSettingDocs() + section_titles <- .studyAgentAnalyticSettingsSectionTitles() + cat("\nFinal analytic settings\n") + cat(sprintf("Profile name: %s\n", .studyAgentFormatAnalyticSettingValue(settings$profile_name))) + for (section_name in names(section_paths)) { + title <- section_titles[[section_name]] %||% gsub("_", " ", section_name, fixed = TRUE) + cat(sprintf("[%s]\n", title)) + for (path in .studyAgentSummaryPathsForSection(section_name, section_paths, settings)) { + label <- .studyAgentSummaryLabel(path) + value <- .studyAgentFormatAnalyticSettingValue(.studyAgentGetNestedValue(settings, path), path = path) + cat(sprintf(" - %s: %s\n", label, value)) + } + } +} + +.studyAgentValueForReviewFile <- function(value) { + if (is.null(value) || length(value) == 0 || all(is.na(value))) return("") + if (is.logical(value) && length(value) == 1) return(if (isTRUE(value)) "true" else "false") + if (length(value) == 1) return(as.character(value)) + paste(as.character(value), collapse = ", ") +} + +.studyAgentCoerceReviewValue <- function(value, current_value) { + `%||%` <- function(x, y) if (is.null(x)) y else x + value <- trimws(as.character(value %||% "")) + if (!nzchar(value)) { + if (is.character(current_value) && length(current_value) == 1 && !nzchar(trimws(current_value))) return("") + if (length(current_value) == 0 || all(is.na(current_value))) return(NA) + } + if (is.logical(current_value) && length(current_value) == 1) { + normalized <- tolower(value) + if (normalized %in% c("true", "t", "yes", "y", "1")) return(TRUE) + if (normalized %in% c("false", "f", "no", "n", "0")) return(FALSE) + return(value) + } + if (is.integer(current_value) && length(current_value) == 1) { + return(suppressWarnings(as.integer(value))) + } + if (is.numeric(current_value) && length(current_value) == 1) { + return(suppressWarnings(as.numeric(value))) + } + value +} + +.studyAgentWriteAnalyticSettingsReviewFile <- function(settings, section_paths, path) { + `%||%` <- function(x, y) if (is.null(x)) y else x + section_titles <- .studyAgentAnalyticSettingsSectionTitles() + lines <- c( + "# Edit values after ':' only. Save this file, close the editor, then return to the R shell.", + "# Blank study dates are allowed. Boolean values accept true/false.", + "", + sprintf("profile_name: %s", .studyAgentValueForReviewFile(settings$profile_name)), + "" + ) + for (section_name in names(section_paths)) { + title <- section_titles[[section_name]] %||% section_name + lines <- c(lines, sprintf("[%s]", title)) + for (path_key in .studyAgentSummaryPathsForSection(section_name, section_paths, settings)) { + lines <- c(lines, sprintf( + "%s: %s", + path_key, + .studyAgentValueForReviewFile(.studyAgentGetNestedValue(settings, path_key)) + )) + } + lines <- c(lines, "") + } + writeLines(lines, con = path, useBytes = TRUE) + invisible(path) +} + +.studyAgentReadAnalyticSettingsReviewFile <- function(path, settings) { + lines <- readLines(path, warn = FALSE) + updated <- settings + for (line in lines) { + stripped <- trimws(line) + if (!nzchar(stripped) || startsWith(stripped, "#") || grepl("^\\[[^]]+\\]$", stripped)) next + if (!grepl(":", stripped, fixed = TRUE)) next + key <- trimws(sub(":.*$", "", stripped)) + value <- trimws(sub("^[^:]*:", "", stripped)) + if (identical(key, "profile_name")) { + updated$profile_name <- value + next + } + current_value <- .studyAgentGetNestedValue(updated, key) + updated <- .studyAgentSetNestedValue( + updated, + key, + .studyAgentCoerceReviewValue(value, current_value) + ) + } + updated +} + +.studyAgentDefaultCohortMethodAnalyticSettings <- function(covariate_enabled = FALSE) { + list( + profile_name = "Analytic Setting 1", + source = "manual_shell", + customized_sections = character(0), + get_db_cohort_method_data = list( + studyStartDate = "", + studyEndDate = "", + firstExposureOnly = TRUE, + washoutPeriod = 365L, + restrictToCommonPeriod = TRUE, + removeDuplicateSubjects = "keep first, truncate to second" + ), + create_study_population = list( + maxCohortSize = 0L, + removeDuplicateSubjects = "keep all", + removeSubjectsWithPriorOutcome = TRUE, + priorOutcomeLookback = 99999L, + minDaysAtRisk = 1L, + riskWindowStart = 0L, + startAnchor = "cohort start", + riskWindowEnd = 0L, + endAnchor = "cohort end", + censorAtNewRiskWindow = FALSE + ), + create_ps = list( + estimator = "att", + maxCohortSizeForFitting = 250000L, + errorOnHighCorrelation = FALSE, + useRegularization = TRUE + ), + ps_adjustment = list( + strategy = "match_on_ps", + trimmingStrategy = "none", + trimmingPercent = 5, + equipoiseLowerBound = 0.25, + equipoiseUpperBound = 0.75 + ), + match_on_ps = list( + caliper = 0.2, + caliperScale = "standardized logit", + maxRatio = 1L + ), + stratify_by_ps = list( + numberOfStrata = 10L, + baseSelection = "all" + ), + fit_outcome_model = list( + modelType = "cox", + stratified = FALSE, + useCovariates = FALSE, + inversePtWeighting = FALSE, + useRegularization = TRUE + ), + covariate_concept_sets = list( + enabled = isTRUE(covariate_enabled), + include_all_concepts = TRUE, + include_concept_set_id = NA_integer_, + exclude_concept_set_id = NA_integer_ + ) + ) +} + +.studyAgentGetNestedValue <- function(x, path) { + parts <- strsplit(path, ".", fixed = TRUE)[[1]] + current <- x + for (part in parts) { + if (!is.list(current) || is.null(current[[part]])) return(NULL) + current <- current[[part]] + } + current +} + +.studyAgentSetNestedValue <- function(x, path, value) { + parts <- strsplit(path, ".", fixed = TRUE)[[1]] + + set_rec <- function(obj, idx = 1L) { + key <- parts[[idx]] + if (idx == length(parts)) { + obj[[key]] <- value + return(obj) + } + child <- obj[[key]] + if (!is.list(child)) child <- list() + obj[[key]] <- set_rec(child, idx + 1L) + obj + } + + set_rec(x, 1L) +} + +.studyAgentResetSectionPaths <- function(current_settings, default_settings, paths) { + updated <- current_settings + for (path in paths) { + updated <- .studyAgentSetNestedValue( + updated, + path, + .studyAgentGetNestedValue(default_settings, path) + ) + } + updated +} + +.studyAgentDeepMerge <- function(defaults, overrides) { + if (is.null(overrides)) return(defaults) + for (name in names(overrides)) { + override_value <- overrides[[name]] + default_value <- defaults[[name]] + if (is.list(default_value) && is.list(override_value) && !is.data.frame(override_value)) { + defaults[[name]] <- .studyAgentDeepMerge(default_value, override_value) + } else if (!is.null(override_value)) { + defaults[[name]] <- override_value + } + } + defaults +} + +.studyAgentDateStringOrEmpty <- function(value, label) { + if (is.null(value) || length(value) == 0 || is.na(value)) return("") + value <- trimws(as.character(value[[1]])) + if (!nzchar(value)) return("") + if (grepl("^[0-9]{8}$", value)) return(value) + stop(sprintf("%s must be blank or formatted as YYYYMMDD.", label)) +} + +.studyAgentDefaultCmAnalysisTemplate <- function() { + list( + description = "", + getDbCohortMethodDataArgs = list( + studyStartDate = "", + studyEndDate = "", + firstExposureOnly = FALSE, + removeDuplicateSubjects = "keep all", + restrictToCommonPeriod = FALSE, + washoutPeriod = 365L, + maxCohortSize = 0L + ), + createStudyPopArgs = list( + removeSubjectsWithPriorOutcome = TRUE, + priorOutcomeLookback = 99999L, + minDaysAtRisk = 1L, + riskWindowStart = 1L, + startAnchor = "cohort start", + riskWindowEnd = 0L, + endAnchor = "cohort end", + censorAtNewRiskWindow = FALSE + ), + trimByPsArgs = list( + trimFraction = 0.05, + equipoiseBounds = NA + ), + matchOnPsArgs = list( + maxRatio = 1L, + caliper = 0.2, + caliperScale = "standardized logit" + ), + stratifyByPsArgs = NA, + createPsArgs = list( + maxCohortSizeForFitting = 250000L, + errorOnHighCorrelation = TRUE, + prior = list( + priorType = "laplace", + useCrossValidation = TRUE + ), + control = list( + tolerance = 2e-7, + cvType = "auto", + fold = 10L, + cvRepetitions = 10L, + noiseLevel = "silent", + resetCoefficients = TRUE, + startingVariance = 0.01 + ) + ), + fitOutcomeModelArgs = list( + modelType = "cox", + stratified = FALSE, + useCovariates = FALSE, + inversePtWeighting = FALSE, + prior = list( + priorType = "laplace", + useCrossValidation = TRUE + ), + control = list( + tolerance = 2e-7, + cvType = "auto", + fold = 10L, + cvRepetitions = 10L, + noiseLevel = "quiet", + resetCoefficients = TRUE, + startingVariance = 0.01 + ) + ) + ) +} + +.studyAgentLoadCmAnalysisTemplate <- function(template_path = NULL) { + template <- .studyAgentDefaultCmAnalysisTemplate() + if (!is.null(template_path) && length(template_path) > 0 && !is.na(template_path) && nzchar(template_path) && file.exists(template_path)) { + loaded <- jsonlite::fromJSON(template_path, simplifyVector = FALSE) + template <- .studyAgentDeepMerge(template, loaded) + } + template +} + +.studyAgentBuildCmAnalysisJson <- function(settings, template = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + template <- template %||% .studyAgentDefaultCmAnalysisTemplate() + + ps_strategy <- settings$ps_adjustment$strategy %||% "match_on_ps" + trimming_strategy <- settings$ps_adjustment$trimmingStrategy %||% "none" + ps_regularized <- isTRUE(settings$create_ps$useRegularization) + outcome_regularized <- isTRUE(settings$fit_outcome_model$useRegularization) + + ps_prior <- if (ps_regularized) template$createPsArgs$prior else NA + ps_control <- if (ps_regularized) template$createPsArgs$control else NA + outcome_prior <- if (outcome_regularized) template$fitOutcomeModelArgs$prior else NA + outcome_control <- if (outcome_regularized) template$fitOutcomeModelArgs$control else NA + + trim_args <- NA + if (identical(trimming_strategy, "by_percent")) { + trim_args <- list( + trimFraction = as.numeric(settings$ps_adjustment$trimmingPercent) / 100, + equipoiseBounds = NA + ) + } else if (identical(trimming_strategy, "by_equipoise")) { + trim_args <- list( + trimFraction = NA_real_, + equipoiseBounds = c( + as.numeric(settings$ps_adjustment$equipoiseLowerBound), + as.numeric(settings$ps_adjustment$equipoiseUpperBound) + ) + ) + } + + match_args <- if (identical(ps_strategy, "match_on_ps")) { + list( + maxRatio = as.integer(settings$match_on_ps$maxRatio), + caliper = as.numeric(settings$match_on_ps$caliper), + caliperScale = as.character(settings$match_on_ps$caliperScale) + ) + } else { + NA + } + + stratify_args <- if (identical(ps_strategy, "stratify_by_ps")) { + list( + numberOfStrata = as.integer(settings$stratify_by_ps$numberOfStrata), + baseSelection = as.character(settings$stratify_by_ps$baseSelection) + ) + } else { + NA + } + + create_ps_args <- if (identical(ps_strategy, "none") && identical(trimming_strategy, "none")) { + NA + } else { + list( + maxCohortSizeForFitting = as.integer(settings$create_ps$maxCohortSizeForFitting), + errorOnHighCorrelation = isTRUE(settings$create_ps$errorOnHighCorrelation), + prior = ps_prior, + control = ps_control + ) + } + + list( + description = as.character(settings$profile_name), + getDbCohortMethodDataArgs = list( + studyStartDate = as.character(settings$get_db_cohort_method_data$studyStartDate %||% ""), + studyEndDate = as.character(settings$get_db_cohort_method_data$studyEndDate %||% ""), + firstExposureOnly = isTRUE(settings$get_db_cohort_method_data$firstExposureOnly), + removeDuplicateSubjects = as.character(settings$get_db_cohort_method_data$removeDuplicateSubjects), + restrictToCommonPeriod = isTRUE(settings$get_db_cohort_method_data$restrictToCommonPeriod), + washoutPeriod = as.integer(settings$get_db_cohort_method_data$washoutPeriod), + maxCohortSize = as.integer(settings$create_study_population$maxCohortSize) + ), + createStudyPopArgs = list( + removeSubjectsWithPriorOutcome = isTRUE(settings$create_study_population$removeSubjectsWithPriorOutcome), + priorOutcomeLookback = as.integer(settings$create_study_population$priorOutcomeLookback), + minDaysAtRisk = as.integer(settings$create_study_population$minDaysAtRisk), + riskWindowStart = as.integer(settings$create_study_population$riskWindowStart), + startAnchor = as.character(settings$create_study_population$startAnchor), + riskWindowEnd = as.integer(settings$create_study_population$riskWindowEnd), + endAnchor = as.character(settings$create_study_population$endAnchor), + censorAtNewRiskWindow = isTRUE(settings$create_study_population$censorAtNewRiskWindow) + ), + trimByPsArgs = trim_args, + matchOnPsArgs = match_args, + stratifyByPsArgs = stratify_args, + createPsArgs = create_ps_args, + fitOutcomeModelArgs = list( + modelType = as.character(settings$fit_outcome_model$modelType), + stratified = isTRUE(settings$fit_outcome_model$stratified), + useCovariates = isTRUE(settings$fit_outcome_model$useCovariates), + inversePtWeighting = isTRUE(settings$fit_outcome_model$inversePtWeighting), + prior = outcome_prior, + control = outcome_control + ) + ) +} + +.studyAgentCollectStepByStepAnalyticSettings <- function(default_settings, + seed_settings, + interactive = TRUE, + io = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + + normalize_seed <- function(settings) { + settings <- settings %||% list() + if (is.null(settings$ps_adjustment)) { + settings$ps_adjustment <- list(strategy = "match_on_ps") + } + if (is.null(settings$stratify_by_ps)) { + settings$stratify_by_ps <- list( + numberOfStrata = default_settings$stratify_by_ps$numberOfStrata, + baseSelection = default_settings$stratify_by_ps$baseSelection + ) + } + settings + } + + ask_text <- function(prompt, default = "", allow_blank = FALSE) { + if (!isTRUE(interactive)) return(default) + value <- io$text(prompt = prompt, default = default, allow_blank = allow_blank) + trimmed <- trimws(as.character(value %||% "")) + if (!nzchar(trimmed) && !isTRUE(allow_blank)) { + stop(sprintf("A non-empty value is required for: %s", prompt)) + } + trimmed + } + + ask_yesno <- function(prompt, default = TRUE) { + if (!isTRUE(interactive)) return(default) + io$yesno(prompt = prompt, default = default) + } + + ask_choice <- function(prompt, choices, default, labels = choices) { + if (!isTRUE(interactive)) return(default) + io$choice(prompt = prompt, choices = choices, default = default, labels = labels) + } + + ask_integer <- function(prompt, default, min_value = NULL, allow_negative = TRUE) { + if (!isTRUE(interactive)) return(as.integer(default)) + io$integer( + prompt = prompt, + default = default, + min_value = min_value, + allow_negative = allow_negative + ) + } + + ask_numeric <- function(prompt, default, min_value = NULL) { + if (!isTRUE(interactive)) return(as.numeric(default)) + io$numeric(prompt = prompt, default = default, min_value = min_value) + } + + section_paths <- .studyAgentAnalyticSettingsSectionPaths() + working <- .studyAgentDeepMerge(default_settings, normalize_seed(seed_settings)) + working <- .studyAgentSetNestedValue( + working, + "get_db_cohort_method_data.removeDuplicateSubjects", + .studyAgentGetNestedValue(default_settings, "get_db_cohort_method_data.removeDuplicateSubjects") + ) + working <- .studyAgentSetNestedValue( + working, + "create_ps.estimator", + .studyAgentGetNestedValue(default_settings, "create_ps.estimator") + ) + working <- .studyAgentSetNestedValue( + working, + "create_ps.errorOnHighCorrelation", + isTRUE(.studyAgentGetNestedValue(default_settings, "create_ps.errorOnHighCorrelation")) + ) + working <- .studyAgentSetNestedValue( + working, + "create_ps.useRegularization", + isTRUE(.studyAgentGetNestedValue(default_settings, "create_ps.useRegularization")) + ) + working <- .studyAgentSetNestedValue( + working, + "stratify_by_ps.baseSelection", + .studyAgentGetNestedValue(default_settings, "stratify_by_ps.baseSelection") + ) + working$source <- "manual_shell" + working$customized_sections <- character(0) + + show_section <- function(label) { + if (isTRUE(interactive) && !is.null(io$section_header)) { + io$section_header(label) + } + } + + show_section("Study Population") + study_start <- ask_text( + "Study start date (YYYYMMDD, leave blank for no restriction)", + default = .studyAgentFormatDateForPrompt(.studyAgentGetNestedValue(working, "get_db_cohort_method_data.studyStartDate")), + allow_blank = TRUE + ) + study_end <- ask_text( + "Study end date (YYYYMMDD, leave blank for no restriction)", + default = .studyAgentFormatDateForPrompt(.studyAgentGetNestedValue(working, "get_db_cohort_method_data.studyEndDate")), + allow_blank = TRUE + ) + working <- .studyAgentSetNestedValue( + working, + "get_db_cohort_method_data.studyStartDate", + .studyAgentDateStringOrEmpty(study_start, "Study start date") + ) + working <- .studyAgentSetNestedValue( + working, + "get_db_cohort_method_data.studyEndDate", + .studyAgentDateStringOrEmpty(study_end, "Study end date") + ) + study_population_non_core <- setdiff( + section_paths$study_population, + c( + "get_db_cohort_method_data.studyStartDate", + "get_db_cohort_method_data.studyEndDate" + ) + ) + if (isTRUE(interactive)) { + keep_study_population_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining study population settings, keep the defaults or choose each option yourself?", + default_settings, + study_population_non_core + , + ask_yesno + ) + if (isTRUE(keep_study_population_defaults)) { + working <- .studyAgentResetSectionPaths(working, default_settings, study_population_non_core) + } else { + working <- .studyAgentCustomizeAnalyticSettings( + working, + study_population_non_core, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + } + + show_section("Time At Risk") + anchor_choices <- c("cohort start", "cohort end") + anchor_labels <- c("cohort start date", "cohort end date") + working <- .studyAgentSetNestedValue( + working, + "create_study_population.startAnchor", + ask_choice( + "Risk window start anchor", + choices = anchor_choices, + labels = anchor_labels, + default = .studyAgentGetNestedValue(working, "create_study_population.startAnchor") %||% anchor_choices[[1]] + ) + ) + working <- .studyAgentSetNestedValue( + working, + "create_study_population.riskWindowStart", + ask_integer( + "Risk window start (days)", + default = as.integer(.studyAgentGetNestedValue(working, "create_study_population.riskWindowStart")), + allow_negative = TRUE + ) + ) + working <- .studyAgentSetNestedValue( + working, + "create_study_population.endAnchor", + ask_choice( + "Risk window end anchor", + choices = anchor_choices, + labels = anchor_labels, + default = .studyAgentGetNestedValue(working, "create_study_population.endAnchor") %||% anchor_choices[[2]] + ) + ) + working <- .studyAgentSetNestedValue( + working, + "create_study_population.riskWindowEnd", + ask_integer( + "Risk window end (days)", + default = as.integer(.studyAgentGetNestedValue(working, "create_study_population.riskWindowEnd")), + allow_negative = TRUE + ) + ) + if (isTRUE(interactive)) { + tar_non_core <- c("create_study_population.minDaysAtRisk") + keep_tar_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining time-at-risk settings, keep the defaults or choose each option yourself?", + default_settings, + tar_non_core, + ask_yesno + ) + if (isTRUE(keep_tar_defaults)) { + working <- .studyAgentResetSectionPaths(working, default_settings, tar_non_core) + } else { + working <- .studyAgentCustomizeAnalyticSettings( + working, + tar_non_core, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + } + + show_section("Propensity Score Adjustment") + strategy_choices <- c("match_on_ps", "stratify_by_ps", "none") + strategy_labels <- c("Match on propensity score", "Stratify on propensity score", "None") + working <- .studyAgentSetNestedValue( + working, + "ps_adjustment.strategy", + ask_choice( + "PS adjustment strategy", + choices = strategy_choices, + labels = strategy_labels, + default = .studyAgentGetNestedValue(working, "ps_adjustment.strategy") %||% strategy_choices[[1]] + ) + ) + current_strategy <- .studyAgentGetNestedValue(working, "ps_adjustment.strategy") %||% "match_on_ps" + if (isTRUE(interactive)) { + if (identical(current_strategy, "none")) { + ps_default_paths <- c( + "ps_adjustment.trimmingStrategy", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization" + ) + keep_ps_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining propensity score adjustment settings, keep the defaults?", + default_settings, + ps_default_paths, + ask_yesno + ) + if (isTRUE(keep_ps_defaults)) { + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c( + ps_default_paths, + "ps_adjustment.trimmingPercent", + "ps_adjustment.equipoiseLowerBound", + "ps_adjustment.equipoiseUpperBound" + ) + ) + } else { + working <- .studyAgentCustomizeAnalyticSettings( + working, + ps_default_paths, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c( + "match_on_ps.caliper", + "match_on_ps.caliperScale", + "match_on_ps.maxRatio", + "stratify_by_ps.numberOfStrata", + "stratify_by_ps.baseSelection" + ) + ) + } else if (identical(current_strategy, "match_on_ps")) { + working <- .studyAgentSetNestedValue( + working, + "match_on_ps.maxRatio", + ask_integer( + "Maximum match ratio (0 means no maximum)", + default = as.integer(.studyAgentGetNestedValue(working, "match_on_ps.maxRatio")), + min_value = 0L, + allow_negative = FALSE + ) + ) + ps_default_paths <- c( + "ps_adjustment.trimmingStrategy", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization", + "match_on_ps.caliper", + "match_on_ps.caliperScale" + ) + keep_ps_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining propensity score adjustment settings, keep the defaults?", + default_settings, + ps_default_paths, + ask_yesno + ) + if (isTRUE(keep_ps_defaults)) { + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c( + ps_default_paths, + "ps_adjustment.trimmingPercent", + "ps_adjustment.equipoiseLowerBound", + "ps_adjustment.equipoiseUpperBound" + ) + ) + } else { + working <- .studyAgentCustomizeAnalyticSettings( + working, + ps_default_paths, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c("stratify_by_ps.numberOfStrata", "stratify_by_ps.baseSelection") + ) + } else if (identical(current_strategy, "stratify_by_ps")) { + working <- .studyAgentSetNestedValue( + working, + "stratify_by_ps.numberOfStrata", + ask_integer( + "Number of strata", + default = as.integer(.studyAgentGetNestedValue(working, "stratify_by_ps.numberOfStrata")), + min_value = 1L, + allow_negative = FALSE + ) + ) + working <- .studyAgentSetNestedValue( + working, + "stratify_by_ps.baseSelection", + .studyAgentGetNestedValue(default_settings, "stratify_by_ps.baseSelection") + ) + ps_default_paths <- c( + "ps_adjustment.trimmingStrategy", + "create_ps.maxCohortSizeForFitting", + "create_ps.errorOnHighCorrelation", + "create_ps.useRegularization", + "stratify_by_ps.baseSelection" + ) + keep_ps_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining propensity score adjustment settings, keep the defaults?", + default_settings, + ps_default_paths, + ask_yesno + ) + if (isTRUE(keep_ps_defaults)) { + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c( + ps_default_paths, + "ps_adjustment.trimmingPercent", + "ps_adjustment.equipoiseLowerBound", + "ps_adjustment.equipoiseUpperBound" + ) + ) + } else { + working <- .studyAgentCustomizeAnalyticSettings( + working, + ps_default_paths, + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + working <- .studyAgentResetSectionPaths( + working, + default_settings, + c("match_on_ps.caliper", "match_on_ps.caliperScale", "match_on_ps.maxRatio") + ) + } + } + + show_section("Outcome Model") + outcome_model_defaults <- .studyAgentOutcomeModelDefaults( + ps_strategy = current_strategy, + match_max_ratio = .studyAgentGetNestedValue(working, "match_on_ps.maxRatio"), + model_type = .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% default_settings$fit_outcome_model$modelType + ) + model_choices <- c("cox", "poisson", "logistic") + model_labels <- c("Cox proportional hazards", "Poisson regression", "Logistic regression") + working <- .studyAgentSetNestedValue( + working, + "fit_outcome_model.modelType", + ask_choice( + "Outcome model", + choices = model_choices, + labels = model_labels, + default = .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% model_choices[[1]] + ) + ) + outcome_model_defaults$modelType <- .studyAgentGetNestedValue(working, "fit_outcome_model.modelType") %||% outcome_model_defaults$modelType + outcome_defaults_for_display <- .studyAgentDeepMerge( + default_settings, + list(fit_outcome_model = outcome_model_defaults) + ) + keep_outcome_defaults <- !isTRUE(interactive) + if (isTRUE(interactive)) { + keep_outcome_defaults <- .studyAgentPromptKeepDefaults( + "For the remaining outcome model settings, keep the defaults or choose each option yourself?", + outcome_defaults_for_display, + setdiff(section_paths$outcome_model, "fit_outcome_model.modelType") + , + ask_yesno + ) + if (!isTRUE(keep_outcome_defaults)) { + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.stratified", isTRUE(outcome_model_defaults$stratified)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useCovariates", isTRUE(outcome_model_defaults$useCovariates)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.inversePtWeighting", isTRUE(outcome_model_defaults$inversePtWeighting)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useRegularization", isTRUE(outcome_model_defaults$useRegularization)) + working <- .studyAgentCustomizeAnalyticSettings( + working, + setdiff(section_paths$outcome_model, "fit_outcome_model.modelType"), + ask_yesno = ask_yesno, + ask_choice = ask_choice, + ask_integer = ask_integer, + ask_numeric = ask_numeric + ) + } + } + if (isTRUE(keep_outcome_defaults)) { + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.stratified", isTRUE(outcome_model_defaults$stratified)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useCovariates", isTRUE(outcome_model_defaults$useCovariates)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.inversePtWeighting", isTRUE(outcome_model_defaults$inversePtWeighting)) + working <- .studyAgentSetNestedValue(working, "fit_outcome_model.useRegularization", isTRUE(outcome_model_defaults$useRegularization)) + } + + if (isTRUE(interactive)) { + working$profile_name <- ask_text( + "Analytic settings profile name", + default = as.character(working$profile_name %||% default_settings$profile_name), + allow_blank = FALSE + ) + } + + customized_sections <- names(section_paths)[vapply(names(section_paths), function(section_name) { + paths <- section_paths[[section_name]] + any(vapply(paths, function(path) { + !identical( + .studyAgentGetNestedValue(working, path), + .studyAgentGetNestedValue(default_settings, path) + ) + }, logical(1))) + }, logical(1))] + working$customized_sections <- customized_sections + + list( + settings = working, + section_flow = names(section_paths), + customized_sections = customized_sections + ) +} + +runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-methods", + acpUrl = "http://127.0.0.1:8765", + studyIntent = NULL, + targetStatement = NULL, + comparatorStatement = NULL, + outcomeStatement = NULL, + targetCohortId = NULL, + comparatorCohortId = NULL, + outcomeCohortIds = NULL, + comparisonLabel = NULL, + topK = 20, + maxResults = 3, + candidateLimit = 10, + indexDir = Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index"), + negativeControlConceptSetId = NULL, + includeCovariateConceptSetId = NULL, + excludeCovariateConceptSetId = NULL, + analyticSettingsDescription = NULL, + analyticSettingsDescriptionPath = NULL, + incidenceOutputDir = "demo-strategus-cohort-incidence", + interactive = TRUE, + bannerPath = "ohdsi-logo-ascii.txt", + studyAgentBaseDir = Sys.getenv("STUDY_AGENT_BASE_DIR", ""), + reset = FALSE, + allowCache = TRUE, + promptOnCache = TRUE, + autoApplyImprovements = NA, + resume = FALSE, + remapCohortIds = TRUE, + cohortIdBase = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + + ensure_dir <- function(path) { + if (!dir.exists(path)) dir.create(path, recursive = TRUE, showWarnings = FALSE) + } + + normalize_dialogue_step <- .studyAgentSlashNormalizeCohortMethodsDialogueStep + + dialogue_step_label <- .studyAgentSlashCohortMethodsDialogueStepLabel + build_workflow_stage_context <- function(studyIntent, dialogue_state) { + .studyAgentSlashBuildCohortMethodsWorkflowStageContext( + study_intent = studyIntent, + dialogue_state = dialogue_state, + interactive = interactive + ) + } + + compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext + + dialogue_acp_client <- new.env(parent = emptyenv()) + dialogue_acp_client$client <- NULL + dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( + interactive = interactive, + study_intent_getter = function() studyIntent, + build_stage_context = build_workflow_stage_context, + call_dialogue = function(stage_context, message) { + if (!ensure_workflow_dialogue_client(acpUrl)) { + stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") + } + message("Calling ACP flow: workflow_context_dialogue") + .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) + }, + empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why is washout important here?" + ) + dialogue_state <- dialogue_session$state + set_dialogue_context <- dialogue_session$set_context + readline_with_dialogue <- dialogue_session$readline + + prompt_yesno <- function(prompt, default = TRUE) { + if (!isTRUE(interactive)) return(default) + suffix <- if (default) "[Y/n]" else "[y/N]" + resp <- tolower(trimws(readline_with_dialogue(sprintf("%s %s ", prompt, suffix)))) + if (resp == "") return(default) + if (resp %in% c("y", "yes")) return(TRUE) + if (resp %in% c("n", "no")) return(FALSE) + default + } + + maybe_use_cache <- function(path, label) { + if (!allowCache || !file.exists(path)) return(FALSE) + if (isTRUE(resume)) return(TRUE) + if (!promptOnCache) return(TRUE) + prompt_yesno(sprintf("Use cached %s at %s?", label, path), default = TRUE) + } + + read_json <- function(path) { + jsonlite::fromJSON(path, simplifyVector = FALSE) + } + + write_json <- function(x, path) { + jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE, na = "null") + } + + analysis_label_max_chars <- 50L + shorten_analysis_label <- function(value, max_chars = analysis_label_max_chars) { + value <- trimws(as.character(value %||% "")) + if (!nzchar(value)) return(value) + if (nchar(value, type = "chars") <= max_chars) return(value) + paste0(substr(value, 1L, max_chars - 3L), "...") + } + prompt_analysis_label <- function(label, default, max_chars = analysis_label_max_chars) { + default <- trimws(as.character(default %||% label)) + if (!nzchar(default)) default <- label + if (!isTRUE(interactive)) return(shorten_analysis_label(default, max_chars)) + + current <- default + repeat { + prompt <- if (nchar(current, type = "chars") <= max_chars) { + sprintf("%s analysis label [%s]: ", label, current) + } else { + sprintf( + "%s analysis label [%s]:\nLabel must be %s characters or fewer.\n%s analysis label: ", + label, + current, + max_chars, + label + ) + } + entered <- trimws(readline_with_dialogue(prompt)) + candidate <- if (nzchar(entered)) entered else if (nchar(current, type = "chars") <= max_chars) current else "" + if (!nzchar(candidate)) { + cat(sprintf("Analysis label must be %s characters or fewer. Please enter a shorter label.\n", max_chars)) + next + } + if (nchar(candidate, type = "chars") > max_chars) { + cat(sprintf("Analysis label must be %s characters or fewer. Please shorten it.\n", max_chars)) + current <- candidate + next + } + return(candidate) + } + } + + if (length(autoApplyImprovements) == 0 || is.na(autoApplyImprovements[[1]])) { + autoApplyImprovements <- !isTRUE(interactive) + } else { + autoApplyImprovements <- isTRUE(autoApplyImprovements) + } + + apply_action <- function(obj, action) { + path <- action$path %||% "" + value <- action$value + if (!nzchar(path)) return(obj) + segs <- strsplit(path, "/", fixed = TRUE)[[1]] + segs <- segs[segs != ""] + + set_in <- function(x, segs, value) { + if (length(segs) == 0) return(value) + seg <- segs[[1]] + name <- seg + idx <- NA_integer_ + if (grepl("\\[\\d+\\]$", seg)) { + name <- sub("\\[\\d+\\]$", "", seg) + idx <- as.integer(sub("^.*\\[(\\d+)\\]$", "\\1", seg)) + } + if (name != "") { + if (is.null(x[[name]])) x[[name]] <- list() + if (length(segs) == 1) { + if (!is.na(idx)) { + if (length(x[[name]]) < idx) { + while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() + } + x[[name]][[idx]] <- value + } else { + x[[name]] <- value + } + return(x) + } + if (!is.na(idx)) { + if (length(x[[name]]) < idx) { + while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() + } + x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value) + } else { + x[[name]] <- set_in(x[[name]], segs[-1], value) + } + return(x) + } + idx <- suppressWarnings(as.integer(seg)) + if (is.na(idx)) return(x) + if (idx == 0) idx <- 1 + if (length(x) < idx) { + while (length(x) < idx) x[[length(x) + 1]] <- list() + } + if (length(segs) == 1) { + x[[idx]] <- value + return(x) + } + x[[idx]] <- set_in(x[[idx]], segs[-1], value) + x + } + + set_in(obj, segs, value) + } + + is_mutating_improvement_action <- function(action) { + action_type <- tolower(trimws(as.character(action$type %||% "note"))) + action_type %in% c("set", "replace", "update") + } + + checkpoint_path <- function(label) { + file.path(output_dir, paste0("checkpoint_", label, ".json")) + } + + mark_checkpoint <- function(label, payload = list()) { + checkpoint <- list(step = label) + if (length(payload) > 0) checkpoint <- c(checkpoint, payload) + write_json(checkpoint, checkpoint_path(label)) + } + + checkpoint_label_for_role_advice <- function(role_label) { + role_slug <- tolower(gsub("[^A-Za-z0-9]+", "_", trimws(role_label))) + role_slug <- gsub("^_+|_+$", "", role_slug) + paste0(role_slug, "_advice") + } + + is_absolute_path <- function(path) { + grepl("^(/|[A-Za-z]:[\\\\/])", path) + } + + resolve_path <- function(path, base_dir = "") { + if (!nzchar(path)) return(path) + if (is_absolute_path(path)) return(path) + if (nzchar(base_dir)) return(file.path(base_dir, path)) + path + } + + parse_ids <- function(x) { + if (is.null(x)) return(integer(0)) + if (is.numeric(x) || is.integer(x)) return(as.integer(x)) + if (is.character(x)) { + pieces <- unlist(strsplit(paste(x, collapse = ","), "[,[:space:]]+")) + pieces <- pieces[nzchar(trimws(pieces))] + return(as.integer(pieces)) + } + integer(0) + } + + normalize_selected_ids <- function(value, label, allow_multiple = FALSE) { + ids <- unique(parse_ids(value)) + ids <- as.integer(ids[!is.na(ids)]) + if (!isTRUE(allow_multiple) && length(ids) > 1) { + stop(sprintf("%s must contain exactly one cohort ID.", label)) + } + ids + } + + collect_single_id <- function(value, label) { + ids <- parse_ids(value) + ids <- ids[!is.na(ids)] + if (length(ids) > 1) stop(sprintf("%s must contain exactly one cohort ID.", label)) + if (length(ids) == 1) return(as.integer(ids[[1]])) + if (!isTRUE(interactive)) stop(sprintf("Missing %s.", label)) + entered <- trimws(readline_with_dialogue(sprintf("%s cohort ID: ", label))) + ids <- parse_ids(entered) + ids <- ids[!is.na(ids)] + if (length(ids) != 1) stop(sprintf("%s must contain exactly one cohort ID.", label)) + as.integer(ids[[1]]) + } + + collect_multiple_ids <- function(value, label) { + ids <- parse_ids(value) + ids <- unique(ids[!is.na(ids)]) + if (length(ids) > 0) return(as.integer(ids)) + if (!isTRUE(interactive)) stop(sprintf("Missing %s.", label)) + entered <- trimws(readline_with_dialogue(sprintf("%s cohort IDs (comma-separated): ", label))) + ids <- parse_ids(entered) + ids <- unique(ids[!is.na(ids)]) + if (length(ids) == 0) stop(sprintf("%s must include at least one cohort ID.", label)) + as.integer(ids) + } + + collect_optional_single_id <- function(value, label, prompt = NULL) { + ids <- parse_ids(value) + ids <- unique(ids[!is.na(ids)]) + if (length(ids) > 1) stop(sprintf("%s must contain at most one ID.", label)) + if (length(ids) == 1) return(validate_positive_integer(ids[[1]], label)) + if (!isTRUE(interactive)) return(NULL) + entered <- trimws(readline_with_dialogue(prompt %||% sprintf("%s ID [optional]: ", label))) + if (!nzchar(entered)) return(NULL) + ids <- parse_ids(entered) + ids <- unique(ids[!is.na(ids)]) + if (length(ids) != 1) stop(sprintf("%s must contain at most one ID.", label)) + validate_positive_integer(ids[[1]], label) + } + + prompt_yesno_strict <- function(prompt, default = TRUE) { + if (!isTRUE(interactive)) return(default) + suffix <- if (default) "[Y/n]" else "[y/N]" + options <- list( + yes = c("y", "yes", "true", "t"), + no = c("n", "no", "false", "f") + ) + + repeat { + prompt_text <- trimws(as.character(prompt %||% "")) + rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s %s ", prompt_text, suffix) else sprintf("%s ", suffix) + entered <- tolower(trimws(readline_with_dialogue(rendered_prompt))) + if (entered == "") return(default) + if (entered %in% options$yes) return(TRUE) + if (entered %in% options$no) return(FALSE) + cat("Please answer with y/yes or n/no.\n") + } + } + + prompt_non_null_text <- function(prompt, default = NULL) { + if (!isTRUE(interactive)) return(default) + repeat { + default_value <- if (is.null(default)) "" else as.character(default) + entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, default_value))) + if (entered == "" && !is.null(default)) return(default) + if (entered == "") { + cat("A value is required.\n") + next + } + return(entered) + } + } + + prompt_bool <- function(prompt, default = TRUE) { + prompt_yesno_strict(prompt, default = default) + } + + prompt_integer <- function(prompt, default = NULL, allow_null = FALSE, must_be_positive = FALSE, allow_negative = TRUE) { + if (!isTRUE(interactive)) { + if (is.null(default)) return(NULL) + if (is.na(default) && allow_null) return(NULL) + return(as.integer(default)) + } + prompt_suffix <- if (is.null(default)) "" else sprintf(" [%s]", default) + repeat { + prompt_text <- trimws(as.character(prompt %||% "")) + rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s%s: ", prompt_text, prompt_suffix) else sprintf("%s: ", prompt_suffix) + entered <- trimws(readline_with_dialogue(rendered_prompt)) + if (entered == "") { + if (allow_null) return(NULL) + if (is.null(default)) { + cat("A value is required.\n") + next + } + return(as.integer(default)) + } + value <- suppressWarnings(as.integer(entered)) + if (is.na(value) || !is.finite(value)) { + cat("Please enter a valid integer.\n") + next + } + if (must_be_positive && value <= 0) { + cat("Please enter a positive integer.\n") + next + } + if (!allow_negative && value < 0) { + cat("Please enter a non-negative integer.\n") + next + } + return(value) + } + } + + prompt_numeric <- function(prompt, default = NULL, must_be_positive = TRUE) { + if (!isTRUE(interactive)) { + if (is.null(default)) return(NULL) + return(as.numeric(default)) + } + prompt_suffix <- if (is.null(default)) "" else sprintf(" [%s]", default) + repeat { + prompt_text <- trimws(as.character(prompt %||% "")) + rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s%s: ", prompt_text, prompt_suffix) else sprintf("%s: ", prompt_suffix) + entered <- trimws(readline_with_dialogue(rendered_prompt)) + if (entered == "") { + if (is.null(default)) { + cat("A value is required.\n") + next + } + return(as.numeric(default)) + } + value <- suppressWarnings(as.numeric(entered)) + if (is.na(value) || !is.finite(value)) { + cat("Please enter a valid number.\n") + next + } + if (must_be_positive && value <= 0) { + cat("Please enter a positive number.\n") + next + } + return(value) + } + } + + prompt_enum <- function(prompt, choices, default = NULL) { + normalized_choices <- tolower(trimws(choices)) + if (!isTRUE(interactive)) { + return(if (is.null(default)) choices[[1]] else default) + } + + if (!is.null(default)) { + default <- as.character(default) + default_norm <- tolower(trimws(default)) + default <- if (default_norm %in% normalized_choices) { + choices[[which(normalized_choices == default_norm)[1]]] + } else { + choices[[1]] + } + } else { + default <- choices[[1]] + } + + repeat { + prompt_text <- trimws(as.character(prompt %||% "")) + rendered_prompt <- if (nzchar(prompt_text)) sprintf("%s [%s]: ", prompt_text, default) else sprintf("[%s]: ", default) + entered <- trimws(readline_with_dialogue(rendered_prompt)) + if (entered == "") { + return(default) + } + entered_norm <- tolower(trimws(entered)) + match_index <- which(normalized_choices == entered_norm) + if (length(match_index) != 1) { + cat(sprintf("Please enter one of: %s\n", paste(choices, collapse = ", "))) + next + } + return(choices[[match_index[1]]]) + } + } + + collect_outcome_ids <- function(value) { + ids <- parse_ids(value) + ids <- unique(ids[!is.na(ids)]) + if (length(ids) > 0) return(as.integer(ids)) + if (!isTRUE(interactive)) stop("Missing Outcome.") + + collected <- integer(0) + repeat { + entered <- trimws(readline_with_dialogue("Outcome cohort ID: ")) + parsed <- parse_ids(entered) + parsed <- parsed[!is.na(parsed)] + if (length(parsed) != 1) { + cat("Please enter exactly one outcome cohort ID.\n") + next + } + + outcome_id <- as.integer(parsed[[1]]) + if (outcome_id %in% collected) { + cat(sprintf("Outcome cohort ID %s is already selected.\n", outcome_id)) + } else { + collected <- c(collected, outcome_id) + } + + add_another <- prompt_yesno("Add another outcome cohort id?", default = FALSE) + if (!isTRUE(add_another)) break + } + + if (length(collected) == 0) stop("Outcome must include at least one cohort ID.") + as.integer(collected) + } + + load_catalog <- function(index_dir) { + catalog_path <- file.path(index_dir, "catalog.jsonl") + if (!file.exists(catalog_path)) { + return(data.frame( + cohortId = integer(0), + name = character(0), + short_description = character(0), + stringsAsFactors = FALSE + )) + } + lines <- readLines(catalog_path, warn = FALSE) + lines <- lines[nzchar(trimws(lines))] + if (length(lines) == 0) { + return(data.frame( + cohortId = integer(0), + name = character(0), + short_description = character(0), + stringsAsFactors = FALSE + )) + } + parsed <- lapply(lines, function(line) jsonlite::fromJSON(line, simplifyVector = TRUE)) + parse_catalog_cohort_id <- function(x) { + direct <- suppressWarnings(as.integer(x$cohortId %||% NA_integer_)) + if (!is.na(direct)) return(direct) + phenotype_id <- as.character(x$phenotype_id %||% "") + if (grepl("^ohdsi:[0-9]+$", phenotype_id)) { + return(suppressWarnings(as.integer(sub("^ohdsi:", "", phenotype_id)))) + } + suppressWarnings(as.integer(phenotype_id)) + } + data.frame( + cohortId = vapply(parsed, parse_catalog_cohort_id, integer(1)), + name = vapply(parsed, function(x) x$cohortName %||% x$phenotype_name %||% x$name %||% "", character(1)), + short_description = vapply(parsed, function(x) x$short_description %||% "", character(1)), + stringsAsFactors = FALSE + ) + } + + recommendation_name <- function(rec) { + first_nonempty(rec$cohortName, rec$phenotype_name, rec$name, "") + } + + recommendation_identifier <- function(rec) { + first_nonempty( + as.character(rec$cohortId %||% ""), + as.character(rec$phenotype_id %||% ""), + as.character(rec$id %||% ""), + "" + ) + } + + recommendation_cohort_id <- function(rec) { + direct <- suppressWarnings(as.integer(rec$cohortId %||% NA_integer_)) + if (!is.na(direct)) return(direct) + phenotype_id <- as.character(rec$phenotype_id %||% "") + if (grepl("^ohdsi:[0-9]+$", phenotype_id)) { + return(suppressWarnings(as.integer(sub("^ohdsi:", "", phenotype_id)))) + } + suppressWarnings(as.integer(phenotype_id)) + } + + recommendation_is_ohdsi_computable <- function(rec) { + identifier <- recommendation_identifier(rec) + if (!nzchar(identifier)) return(FALSE) + if (grepl("^[0-9]+$", identifier)) return(TRUE) + grepl("^ohdsi:[0-9]+$", identifier) + } + + recommendation_id_label <- function(rec) { + cohort_id <- recommendation_cohort_id(rec) + if (!is.na(cohort_id)) return(as.character(cohort_id)) + identifier <- recommendation_identifier(rec) + if (nzchar(identifier)) return(identifier) + "?" + } + + unsupported_recommendation_message <- function(rec, role_label) { + identifier <- recommendation_identifier(rec) + if (!nzchar(identifier)) identifier <- "unknown" + sprintf( + paste( + "Selected %s phenotype %s (%s), but this workflow can only continue with a computable OHDSI cohort definition.", + "Descriptive phenotypes such as CIPHER recommendations are not yet convertible to executable cohort JSON in the shell.", + "Choose an OHDSI-backed phenotype for now." + ), + tolower(role_label), + recommendation_name(rec), + identifier + ) + } + + lookup_catalog_value <- function(cohort_id, catalog_df, field = "name", fallback = NULL) { + idx <- which(catalog_df$cohortId == as.integer(cohort_id))[1] + if (!is.na(idx)) { + value <- catalog_df[[field]][[idx]] + if (!is.null(value) && nzchar(trimws(value))) return(value) + } + fallback %||% sprintf("Cohort %s", cohort_id) + } + + format_cohort_selection_summary <- function(selected_ids, catalog_df) { + ids <- as.integer(unique(selected_ids[!is.na(selected_ids)])) + if (length(ids) == 0) return(NULL) + labels <- vapply(ids, function(id) { + sprintf( + "%s (ID %s)", + lookup_catalog_value(id, catalog_df, "name", sprintf("Cohort %s", id)), + id + ) + }, character(1)) + paste(labels, collapse = ", ") + } + + cache_label_with_selection <- function(label, selected_ids, catalog_df) { + selection_summary <- format_cohort_selection_summary(selected_ids, catalog_df) + if (is.null(selection_summary) || !nzchar(trimws(selection_summary))) return(label) + sprintf("%s [%s]", label, selection_summary) + } + + load_cached_role_selection <- function(map_path, role, role_dir) { + if (!file.exists(map_path) || !dir.exists(role_dir)) return(NULL) + payload <- tryCatch(read_json(map_path), error = function(e) NULL) + if (is.null(payload)) return(NULL) + mapping <- payload$mapping %||% payload + is_row_mapping <- is.list(mapping) && + length(mapping) > 0 && + is.list(mapping[[1]]) && + any(names(mapping[[1]]) %in% c("role", "original_id", "cohort_id")) + if (isTRUE(is_row_mapping)) { + roles <- vapply(mapping, function(item) as.character(item$role %||% NA_character_), character(1)) + original_ids <- vapply(mapping, function(item) as.integer(item$original_id %||% NA_integer_), integer(1)) + cohort_ids <- vapply(mapping, function(item) as.integer(item$cohort_id %||% NA_integer_), integer(1)) + } else { + roles <- as.character(unlist(mapping$role %||% character(0), use.names = FALSE)) + original_ids <- as.integer(unlist(mapping$original_id %||% integer(0), use.names = FALSE)) + cohort_ids <- as.integer(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE)) + } + if (!length(roles) || length(roles) != length(original_ids) || length(roles) != length(cohort_ids)) { + return(NULL) + } + keep <- which(roles == role & !is.na(original_ids) & !is.na(cohort_ids)) + if (length(keep) == 0) return(NULL) + selected_ids <- as.integer(unique(original_ids[keep])) + new_ids <- as.integer(cohort_ids[keep]) + cached_files <- file.path(role_dir, sprintf("%s.json", new_ids)) + if (!all(file.exists(cached_files))) return(NULL) + list(selected_ids = selected_ids, new_ids = new_ids) + } + + prompt_statement <- function(label, default = NULL) { + if (!isTRUE(interactive)) return(default) + default_value <- trimws(as.character(default %||% "")) + entered <- readline_with_dialogue(sprintf("%s statement [%s]: ", label, default_value)) + if (nzchar(trimws(entered))) trimws(entered) else default_value + } + + acp_timeout_seconds <- function(default = 180) { + timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) + if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default + timeout_seconds + } + + acp_client_is_ready <- function(client) { + .studyAgentSlashAcpIsConnected(client) + } + + create_acp_client <- function(url, token = NULL, check = TRUE) { + .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) + } + + ensure_workflow_dialogue_client <- function(url) { + if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) + TRUE + }, error = function(e) { + FALSE + }) + } + + ensure_acp_ready <- function(url) { + if (ensure_workflow_dialogue_client(url)) return(TRUE) + has_acp_state <- exists("acp_state", inherits = TRUE) + has_acp_connect <- exists("acp_connect", mode = "function", inherits = TRUE) + has_acp_post <- exists(".acp_post", mode = "function", inherits = TRUE) + if (!has_acp_state || !has_acp_connect || !has_acp_post) return(FALSE) + acp_state_value <- get("acp_state", inherits = TRUE) + if (!is.null(acp_state_value$url)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + acp_connect(url) + TRUE + }, error = function(e) { + FALSE + }) + } + + call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { + if (!acp_client_is_ready(dialogue_acp_client$client)) { + if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") + } + .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) + } + + collect_recommendation_selection <- function(recommendations, role_label, allow_multiple = FALSE) { + if (length(recommendations) == 0) return(integer(0)) + if (!isTRUE(interactive)) { + unsupported <- vapply(recommendations, function(rec) !isTRUE(recommendation_is_ohdsi_computable(rec)), logical(1)) + if (any(unsupported)) { + stop(unsupported_recommendation_message(recommendations[[which(unsupported)[1]]], role_label)) + } + if (isTRUE(allow_multiple)) { + return(as.integer(vapply(recommendations, recommendation_cohort_id, integer(1)))) + } + return(as.integer(recommendation_cohort_id(recommendations[[1]]))) + } + + labels <- vapply(seq_along(recommendations), function(i) { + rec <- recommendations[[i]] + sprintf("%s (ID %s)", recommendation_name(rec), recommendation_id_label(rec)) + }, character(1)) + picks <- utils::select.list( + labels, + multiple = isTRUE(allow_multiple), + title = sprintf("Select %s phenotype%s", tolower(role_label), if (isTRUE(allow_multiple)) "s" else "") + ) + if (!length(picks) || !any(nzchar(picks))) return(integer(0)) + selected_recs <- lapply(picks, function(label) { + idx <- which(labels == label)[1] + recommendations[[idx]] + }) + unsupported <- vapply(selected_recs, function(rec) !isTRUE(recommendation_is_ohdsi_computable(rec)), logical(1)) + if (any(unsupported)) { + stop(unsupported_recommendation_message(selected_recs[[which(unsupported)[1]]], role_label)) + } + selected_ids <- vapply(picks, function(label) { + idx <- which(labels == label)[1] + recommendation_cohort_id(recommendations[[idx]]) + }, numeric(1)) + as.integer(selected_ids[!is.na(selected_ids)]) + } + + run_role_recommendation <- function(role_label, + statement, + output_path, + top_k, + max_results, + candidate_limit, + allow_multiple = FALSE, + preferred_selected_ids = NULL, + preferred_selection_source = "manual_input", + cached_selected_ids = NULL, + selected_cache_label = NULL, + selected_cache_dir = NULL, + cohort_method_cache = NULL, + incidence_cache = NULL, + recommendation_role = NULL, + workflow_type = "cohort_methods", + exclude_metadata = NULL) { + role_key <- tolower(role_label) + recommendation_role <- tolower(trimws(as.character(recommendation_role %||% role_key))) + preferred_selected_ids <- normalize_selected_ids( + preferred_selected_ids, + sprintf("%s cohort ID%s", role_label, if (isTRUE(allow_multiple)) "s" else ""), + allow_multiple = allow_multiple + ) + if (length(preferred_selected_ids) > 0) { + return(list( + selected_ids = preferred_selected_ids, + selection_source = preferred_selection_source %||% "manual_input", + recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), + recommendation_source = "not_run", + used_cached_recommendation = FALSE, + used_cached_selection = FALSE, + used_window2 = FALSE, + used_advice = FALSE, + statement = statement + )) + } + selected_cache_ok <- !is.null(cohort_method_cache$selection$selected_ids) && + length(cohort_method_cache$selection$selected_ids) > 0 && + !is.null(cohort_method_cache$selection$cache_dir) && + dir.exists(cohort_method_cache$selection$cache_dir) + if (isTRUE(selected_cache_ok)) { + cached_selected_ids <- as.integer(unique(cohort_method_cache$selection$selected_ids)) + if (maybe_use_cache( + cohort_method_cache$selection$cache_dir, + cache_label_with_selection( + selected_cache_label %||% sprintf("%s cohort selection", role_key), + cached_selected_ids, + catalog_df + ) + )) { + return(list( + selected_ids = cached_selected_ids, + selection_source = "cohort_method_cached_selected_cohort", + recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), + recommendation_source = if (file.exists(output_path)) "cached_recommendation" else "cached_selected_cohort_only", + used_cached_recommendation = FALSE, + used_cached_selection = TRUE, + used_window2 = FALSE, + used_advice = FALSE, + statement = statement + )) + } + } + incidence_cache_ok <- !is.null(incidence_cache$selection$selected_ids) && + length(incidence_cache$selection$selected_ids) > 0 && + !is.null(incidence_cache$selection$cache_dir) && + dir.exists(incidence_cache$selection$cache_dir) + if (!isTRUE(selected_cache_ok) && isTRUE(incidence_cache_ok)) { + incidence_selected_ids <- as.integer(unique(incidence_cache$selection$selected_ids)) + if (maybe_use_cache( + incidence_cache$selection$cache_dir, + cache_label_with_selection( + incidence_cache$selection$label %||% sprintf("incidence %s cohort selection", role_key), + incidence_selected_ids, + catalog_df + ) + )) { + return(list( + selected_ids = incidence_selected_ids, + selection_source = "incidence_cached_selected_cohort", + recommendation_path = json_string_or_null(if (file.exists(output_path)) output_path else NULL), + recommendation_source = "incidence_cached_selected_cohort_only", + used_cached_recommendation = FALSE, + used_cached_selection = TRUE, + used_window2 = FALSE, + used_advice = FALSE, + statement = statement + )) + } + } + + set_dialogue_context( + paste0(role_key, "_recommendation"), + recommendation_role, + context = list( + statement = statement, + top_k = top_k, + max_results = max_results, + candidate_limit = candidate_limit, + workflow_type = workflow_type, + exclude_metadata = exclude_metadata + ) + ) + + recommendation_response <- NULL + recommendation_path <- output_path + used_cached_recommendation <- FALSE + used_window2 <- FALSE + used_advice <- FALSE + + if (maybe_use_cache(output_path, sprintf("%s recommendations", role_key))) { + recommendation_response <- read_json(output_path) + used_cached_recommendation <- TRUE + } else if (ensure_acp_ready(acpUrl)) { + body <- list( + study_intent = statement, + top_k = top_k, + max_results = max_results, + candidate_limit = candidate_limit, + recommendation_role = recommendation_role, + workflow_type = workflow_type, + exclude_metadata = exclude_metadata + ) + message(sprintf("Calling ACP flow: phenotype_recommendation (%s)", role_key)) + recommendation_response <- tryCatch( + call_shell_acp_flow("phenotype_recommendation", body), + error = function(e) { + list(status = "error", error = conditionMessage(e)) + } + ) + write_json(recommendation_response, output_path) + } + + recommendations_core <- recommendation_response$recommendations %||% recommendation_response + recommendations <- recommendations_core$phenotype_recommendations %||% list() + no_candidate_reason <- as.character(recommendation_response$fallback_reason %||% recommendations_core$fallback_reason %||% "") + + if (isTRUE(interactive) && length(recommendations) == 0 && !is.null(recommendation_response)) { + cat(sprintf(" +== %s Phenotype Recommendations == +", role_label)) + if (identical(no_candidate_reason, "no_direct_role_match")) { + cat("No sufficiently direct computable OHDSI phenotype match was found for this cohort statement. +") + cat("Enter a cohort ID manually if you want to continue with a known cohort definition. +") + } else if (identical(no_candidate_reason, "no_viable_candidates_after_rerank")) { + cat("No viable phenotype candidates were identified from the current search results. +") + cat("Enter a cohort ID manually if you want to continue with a known cohort definition. +") + } else { + cat("No phenotype recommendations were returned. +") + cat("Enter a cohort ID manually if you want to continue with a known cohort definition. +") + } + } + + if (isTRUE(interactive) && length(recommendations) > 0) { + cat(sprintf("\n== %s Phenotype Recommendations ==\n", role_label)) + for (i in seq_along(recommendations)) { + rec <- recommendations[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, recommendation_name(rec), recommendation_id_label(rec))) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + if (!isTRUE(recommendation_is_ohdsi_computable(rec))) { + cat(" Not directly computable in this workflow; descriptive phenotype conversion is not yet implemented.\n") + } + } + ok_any <- prompt_yesno(sprintf("Are any of these acceptable for the %s?", role_key), default = TRUE) + if (!ok_any && ensure_acp_ready(acpUrl)) { + widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) + if (isTRUE(widen)) { + used_window2 <- TRUE + recommendation_path <- file.path(dirname(output_path), sprintf("%s_window2.json", tools::file_path_sans_ext(basename(output_path)))) + body <- list( + study_intent = statement, + top_k = top_k, + max_results = max_results, + candidate_limit = candidate_limit, + candidate_offset = candidate_limit, + recommendation_role = recommendation_role, + workflow_type = workflow_type, + exclude_metadata = exclude_metadata + ) + message(sprintf("Calling ACP flow: phenotype_recommendation (%s window 2)", role_key)) + recommendation_response <- tryCatch( + call_shell_acp_flow("phenotype_recommendation", body), + error = function(e) { + list(status = "error", error = conditionMessage(e)) + } + ) + write_json(recommendation_response, recommendation_path) + recommendations_core <- recommendation_response$recommendations %||% recommendation_response + recommendations <- recommendations_core$phenotype_recommendations %||% list() + cat(sprintf("\n== %s Phenotype Recommendations (window 2) ==\n", role_label)) + for (i in seq_along(recommendations)) { + rec <- recommendations[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, recommendation_name(rec), recommendation_id_label(rec))) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + if (!isTRUE(recommendation_is_ohdsi_computable(rec))) { + cat(" Not directly computable in this workflow; descriptive phenotype conversion is not yet implemented.\n") + } + } + ok_any <- prompt_yesno(sprintf("Are any of these acceptable for the %s?", role_key), default = TRUE) + } + if (!ok_any) { + used_advice <- TRUE + message(sprintf("Calling ACP flow: phenotype_recommendation_advice (%s)", role_key)) + advice <- tryCatch( + call_shell_acp_flow("phenotype_recommendation_advice", list(study_intent = statement)), + error = function(e) { + list(status = "error", error = conditionMessage(e)) + } + ) + advice_core <- advice$advice %||% advice + cat("\n== Advisory guidance ==\n") + cat(advice_core$advice %||% "", "\n") + if (length(advice_core$next_steps %||% list()) > 0) { + cat("Next steps:\n") + for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) + } + if (length(advice_core$questions %||% list()) > 0) { + cat("Questions to clarify:\n") + for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) + } + checkpoint_label <- checkpoint_label_for_role_advice(role_label) + mark_checkpoint(checkpoint_label, list( + role_label = role_label, + role_key = role_key, + recommendations_path = recommendation_path, + statement = statement + )) + cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") + stop(sprintf( + "Stopping after %s advice. Resume with resume=TRUE once phenotypes are updated.", + role_key + )) + } + } + } + + selected_ids <- collect_recommendation_selection(recommendations, role_label, allow_multiple = allow_multiple) + selected_ids <- as.integer(unique(selected_ids[!is.na(selected_ids)])) + + list( + selected_ids = selected_ids, + selection_source = if (length(selected_ids) > 0) "recommendation" else "none", + recommendation_path = json_string_or_null(if (file.exists(recommendation_path)) recommendation_path else NULL), + recommendation_source = if (used_cached_recommendation) "cached_recommendation" else if (!is.null(recommendation_response)) "acp_flow" else "not_run", + used_cached_recommendation = isTRUE(used_cached_recommendation), + used_cached_selection = FALSE, + used_window2 = isTRUE(used_window2), + used_advice = isTRUE(used_advice), + statement = statement + ) + } + + resolve_index_definition_path <- function(source_id, index_def_dir) { + source_text <- trimws(as.character(source_id %||% "")) + candidates <- character(0) + if (nzchar(source_text)) { + candidates <- c(candidates, file.path(index_def_dir, sprintf("%s.json", source_text))) + if (grepl("^[0-9]+$", source_text)) { + candidates <- c(candidates, file.path(index_def_dir, sprintf("ohdsi__%s.json", source_text))) + } + if (grepl("^[A-Za-z0-9_]+:[A-Za-z0-9_.-]+$", source_text)) { + candidates <- c( + candidates, + file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", source_text, fixed = TRUE))) + ) + } + } + candidates <- unique(candidates[nzchar(candidates)]) + hit <- candidates[file.exists(candidates)][1] + if (length(hit) == 0 || is.na(hit) || !nzchar(hit)) return(NA_character_) + hit + } + + copy_cohort_json_multi <- function(source_id, dest_id, dest_dirs, index_def_dir) { + src <- resolve_index_definition_path(source_id, index_def_dir) + if (is.na(src) || !file.exists(src)) { + stop(sprintf("Cohort JSON not found for source %s in %s", source_id, index_def_dir)) + } + dests <- character(0) + for (dest_dir in dest_dirs) { + ensure_dir(dest_dir) + dest <- file.path(dest_dir, sprintf("%s.json", dest_id)) + file.copy(src, dest, overwrite = TRUE) + dests <- c(dests, dest) + } + dests + } + + run_role_improvements <- function(role_key, + role_label, + cohort_ids, + selected_role_dir, + patched_role_dir, + improvements_path, + role_statement = NULL) { + cohort_ids <- as.integer(cohort_ids) + cohort_ids <- cohort_ids[!is.na(cohort_ids)] + response_by_id <- list() + used_cache <- FALSE + applied_ids <- integer(0) + patched_paths <- character(0) + errors <- list() + flow_called <- FALSE + expected_meta <- list( + role = role_key, + cohort_ids = as.list(cohort_ids), + role_statement = role_statement %||% "", + study_intent = studyIntent + ) + + if (length(cohort_ids) == 0) { + return(list( + role = role_key, + status = "not_applicable", + cohort_ids = as.list(integer(0)), + improvements_path = improvements_path, + used_cache = FALSE, + flow_called = FALSE, + auto_apply = isTRUE(autoApplyImprovements), + applied = FALSE, + applied_ids = as.list(integer(0)), + patched_dir = patched_role_dir, + patched_paths = as.list(character(0)), + errors = list() + )) + } + + if (maybe_use_cache(improvements_path, sprintf("%s improvements", role_key))) { + cached_response <- read_json(improvements_path) + cached_meta <- cached_response$`_meta` %||% list() + cached_ids <- as.integer(unlist(cached_meta$cohort_ids %||% integer(0), use.names = FALSE)) + cache_valid <- identical(as.character(cached_meta$role %||% ""), role_key) && + identical(cached_ids, cohort_ids) && + identical(as.character(cached_meta$role_statement %||% ""), as.character(expected_meta$role_statement)) && + identical(as.character(cached_meta$study_intent %||% ""), as.character(studyIntent)) + if (isTRUE(cache_valid)) { + response_by_id <- cached_response[names(cached_response) != "_meta"] + used_cache <- TRUE + if (interactive) { + cat(sprintf("\nLoaded cached %s improvements from %s\n", role_key, improvements_path)) + } + } else if (interactive) { + cat(sprintf("\nIgnoring stale cached %s improvements at %s\n", role_key, improvements_path)) + } + } + if (!isTRUE(used_cache) && ensure_acp_ready(acpUrl)) { + for (cid in cohort_ids) { + cohort_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + cohort_obj$id <- cid + body <- list( + protocol_text = studyIntent, + role = role_key, + role_statement = role_statement %||% "", + cohorts = list(cohort_obj) + ) + message(sprintf("Calling ACP flow: phenotype_improvements (%s cohort %s)", role_key, cid)) + flow_called <- TRUE + response_by_id[[as.character(cid)]] <- tryCatch( + call_shell_acp_flow("phenotype_improvements", body), + error = function(e) { + err <- list( + status = "error", + error = conditionMessage(e), + flow = "phenotype_improvements", + role = role_key, + cohort_id = as.integer(cid) + ) + errors[[as.character(cid)]] <<- err + err + } + ) + } + response_with_meta <- c(list(`_meta` = expected_meta), response_by_id) + write_json(response_with_meta, improvements_path) + } else if (!isTRUE(used_cache)) { + errors[["acp"]] <- list( + status = "skipped", + error = "ACP bridge unavailable, ACP helpers not loaded, or ACP not connected.", + flow = "phenotype_improvements", + role = role_key + ) + write_json(c(list(`_meta` = expected_meta), response_by_id), improvements_path) + } + + for (cid in names(response_by_id)) { + if (identical(cid, "_meta")) next + set_dialogue_context( + paste0(role_key, "_improvements"), + role_key, + context = list( + role_statement = role_statement, + cohort_id = as.integer(cid), + study_intent = studyIntent + ) + ) + resp <- response_by_id[[cid]] + core <- resp$full_result %||% resp + items <- core$phenotype_improvements %||% list() + if (interactive) { + cat(sprintf("\n== Improvements for %s cohort %s ==\n", role_key, cid)) + for (item in items) { + cat(sprintf("- %s\n", item$summary %||% "(no summary)")) + if (!is.null(item$actions)) { + for (act in item$actions) { + cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) + } + } + } + if (length(items) == 0) { + cat(" No improvements returned for this cohort.\n") + } + } + if (length(items) == 0) next + + should_apply <- FALSE + if (interactive) { + should_apply <- prompt_yesno(sprintf("Apply improvements for %s cohort %s now?", role_key, cid), default = FALSE) + } else { + should_apply <- isTRUE(autoApplyImprovements) + } + if (!isTRUE(should_apply)) next + + cohort_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + mutation_count <- 0L + for (item in items) { + if (is.null(item$actions)) next + for (act in item$actions) { + if (is_mutating_improvement_action(act)) { + cohort_obj <- apply_action(cohort_obj, act) + mutation_count <- mutation_count + 1L + } + } + } + if (mutation_count == 0L) next + ensure_dir(patched_role_dir) + ensure_dir(patched_dir) + out_path <- file.path(patched_role_dir, sprintf("%s.json", cid)) + write_json(cohort_obj, out_path) + file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) + applied_ids <- c(applied_ids, as.integer(cid)) + patched_paths <- c(patched_paths, out_path) + if (interactive) { + cat(sprintf("Patched %s cohort saved: %s\n", role_key, out_path)) + } + } + + status <- if (length(errors) > 0 && length(response_by_id) == 0) { + "skipped" + } else if (length(errors) > 0) { + "completed_with_errors" + } else if (length(response_by_id) > 0) { + "completed" + } else { + "not_run" + } + + list( + role = role_key, + label = role_label, + status = status, + cohort_ids = as.list(cohort_ids), + improvements_path = improvements_path, + used_cache = isTRUE(used_cache), + flow_called = isTRUE(flow_called), + auto_apply = isTRUE(autoApplyImprovements), + applied = length(applied_ids) > 0, + applied_ids = as.list(as.integer(unique(applied_ids))), + patched_dir = patched_role_dir, + patched_paths = as.list(patched_paths), + errors = errors + ) + } + + skipped_role_improvements <- function(role_key, + role_label, + cohort_ids, + patched_role_dir, + improvements_path, + reason = "user_skipped") { + cohort_ids <- as.integer(cohort_ids) + cohort_ids <- cohort_ids[!is.na(cohort_ids)] + list( + role = role_key, + label = role_label, + status = "not_run", + reason = reason, + cohort_ids = as.list(cohort_ids), + improvements_path = improvements_path, + used_cache = FALSE, + flow_called = FALSE, + auto_apply = isTRUE(autoApplyImprovements), + applied = FALSE, + applied_ids = as.list(integer(0)), + patched_dir = patched_role_dir, + patched_paths = as.list(character(0)), + errors = list() + ) + } + + run_role_improvement_gate <- function(role_key, + role_label, + cohort_ids, + selected_role_dir, + patched_role_dir, + improvements_path, + role_statement = NULL) { + do_improvements <- TRUE + if (interactive) { + do_improvements <- prompt_yesno( + sprintf("Continue to %s phenotype improvements?", role_key), + default = TRUE + ) + if (isTRUE(do_improvements)) { + cat(sprintf("\n== %s phenotype improvements ==\n", role_label)) + } + } + result <- if (isTRUE(do_improvements)) { + run_role_improvements( + role_key = role_key, + role_label = role_label, + cohort_ids = cohort_ids, + selected_role_dir = selected_role_dir, + patched_role_dir = patched_role_dir, + improvements_path = improvements_path, + role_statement = role_statement + ) + } else { + skipped_role_improvements( + role_key = role_key, + role_label = role_label, + cohort_ids = cohort_ids, + patched_role_dir = patched_role_dir, + improvements_path = improvements_path + ) + } + result$prompt_choice <- isTRUE(do_improvements) + result + } + + clear_json_files <- function(dir_path) { + if (!dir.exists(dir_path)) return(invisible(FALSE)) + files <- list.files(dir_path, pattern = "\\.(json)$", full.names = TRUE) + if (length(files) > 0) unlink(files, force = TRUE) + invisible(TRUE) + } + clear_sql_files <- function(dir_path) { + sql_dir <- file.path(dir_path, "sql") + if (!dir.exists(sql_dir)) return(invisible(FALSE)) + files <- list.files(sql_dir, pattern = "\\.(sql)$", full.names = TRUE) + if (length(files) > 0) unlink(files, force = TRUE) + invisible(TRUE) + } + clear_patched_role_outputs <- function() { + clear_json_files(patched_dir) + clear_json_files(patched_target_dir) + clear_json_files(patched_comparator_dir) + clear_json_files(patched_outcome_dir) + clear_sql_files(patched_dir) + clear_sql_files(patched_target_dir) + clear_sql_files(patched_comparator_dir) + clear_sql_files(patched_outcome_dir) + } + + write_lines <- function(path, lines) { + writeLines(lines, con = path, useBytes = TRUE) + } + + assert_cohort_json_exists <- function(source_id, index_def_dir, label) { + src <- resolve_index_definition_path(source_id, index_def_dir) + if (is.na(src) || !file.exists(src)) { + stop(sprintf("%s cohort JSON not found for source %s in %s", label, source_id, index_def_dir)) + } + invisible(src) + } + + cohort_json_exists <- function(source_id, index_def_dir) { + src <- resolve_index_definition_path(source_id, index_def_dir) + !is.na(src) && file.exists(src) + } + + validate_positive_integer <- function(value, label) { + if (length(value) != 1 || is.na(value) || !is.finite(value) || value <= 0) { + stop(sprintf("%s must be a positive integer.", label)) + } + as.integer(value) + } + + json_int_or_null <- function(value) { + if (is.null(value)) return(NA_integer_) + as.integer(value) + } + + json_string_or_null <- function(value) { + if (is.null(value)) return(NA_character_) + as.character(value) + } + + deep_merge <- function(defaults, overrides) { + if (is.null(overrides)) return(defaults) + for (name in names(overrides)) { + override_value <- overrides[[name]] + default_value <- defaults[[name]] + if (is.list(default_value) && is.list(override_value) && !is.data.frame(override_value)) { + defaults[[name]] <- deep_merge(default_value, override_value) + } else if (!is.null(override_value)) { + defaults[[name]] <- override_value + } + } + defaults + } + + validate_choice <- function(value, choices, label) { + if (length(value) != 1 || is.na(value) || !value %in% choices) { + stop(sprintf("%s must be one of: %s", label, paste(choices, collapse = ", "))) + } + as.character(value) + } + + validate_integer_value <- function(value, label, min_value = NULL) { + parsed <- suppressWarnings(as.integer(value)) + if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { + stop(sprintf("%s must be an integer.", label)) + } + if (!is.null(min_value) && parsed < min_value) { + stop(sprintf("%s must be >= %s.", label, min_value)) + } + as.integer(parsed) + } + + validate_numeric_value <- function(value, label, min_value = NULL) { + parsed <- suppressWarnings(as.numeric(value)) + if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { + stop(sprintf("%s must be numeric.", label)) + } + if (!is.null(min_value) && parsed < min_value) { + stop(sprintf("%s must be >= %s.", label, min_value)) + } + parsed + } + + validate_logical_value <- function(value, label) { + if (length(value) != 1 || is.na(value) || !is.logical(value)) { + stop(sprintf("%s must be TRUE or FALSE.", label)) + } + isTRUE(value) + } + + normalize_analytic_settings <- function(settings) { + validate_date_or_blank <- function(value, label) { + .studyAgentDateStringOrEmpty(value, label) + } + + normalize_optional_concept_set_id <- function(value, label) { + if (is.null(value) || length(value) == 0) return(NA_integer_) + if (length(value) == 1 && is.na(value)) return(NA_integer_) + if (is.character(value) && length(value) == 1) { + normalized <- tolower(trimws(value)) + if (!nzchar(normalized) || normalized %in% c("na", "null", "none")) return(NA_integer_) + } + parsed <- suppressWarnings(as.integer(value)) + if (length(parsed) != 1 || is.na(parsed) || !is.finite(parsed)) { + stop(sprintf("%s must be a positive integer or blank.", label)) + } + if (parsed == 0L) return(NA_integer_) + validate_positive_integer(parsed, label) + } + + allowed_sections <- c( + "study_population", + "covariate_settings", + "time_at_risk", + "propensity_score_adjustment", + "outcome_model" + ) + section_aliases <- c(covariates = "covariate_settings") + + profile_name <- trimws(as.character(settings$profile_name %||% "")) + if (!nzchar(profile_name)) { + stop("analytic_settings.profile_name must be a non-empty string.") + } + + customized_sections <- as.character(unlist(settings$customized_sections %||% character(0), use.names = FALSE)) + aliased_sections <- unname(section_aliases[customized_sections]) + customized_sections <- ifelse(is.na(aliased_sections), customized_sections, aliased_sections) + customized_sections <- unique(customized_sections[nzchar(customized_sections)]) + invalid_sections <- setdiff(customized_sections, allowed_sections) + if (length(invalid_sections) > 0) { + stop(sprintf( + "analytic_settings.customized_sections contains unsupported values: %s", + paste(invalid_sections, collapse = ", ") + )) + } + + settings$profile_name <- profile_name + settings$source <- "manual_shell" + settings$customized_sections <- customized_sections + settings$get_db_cohort_method_data$studyStartDate <- validate_date_or_blank( + settings$get_db_cohort_method_data$studyStartDate, + "analytic_settings.get_db_cohort_method_data.studyStartDate" + ) + settings$get_db_cohort_method_data$studyEndDate <- validate_date_or_blank( + settings$get_db_cohort_method_data$studyEndDate, + "analytic_settings.get_db_cohort_method_data.studyEndDate" + ) + settings$get_db_cohort_method_data$firstExposureOnly <- validate_logical_value( + settings$get_db_cohort_method_data$firstExposureOnly, + "analytic_settings.get_db_cohort_method_data.firstExposureOnly" + ) + settings$get_db_cohort_method_data$washoutPeriod <- validate_integer_value( + settings$get_db_cohort_method_data$washoutPeriod, + "analytic_settings.get_db_cohort_method_data.washoutPeriod", + min_value = 0L + ) + settings$get_db_cohort_method_data$restrictToCommonPeriod <- validate_logical_value( + settings$get_db_cohort_method_data$restrictToCommonPeriod, + "analytic_settings.get_db_cohort_method_data.restrictToCommonPeriod" + ) + settings$get_db_cohort_method_data$removeDuplicateSubjects <- validate_choice( + settings$get_db_cohort_method_data$removeDuplicateSubjects, + c("keep all", "keep first", "remove all", "keep first, truncate to second"), + "analytic_settings.get_db_cohort_method_data.removeDuplicateSubjects" + ) + settings$create_study_population$removeDuplicateSubjects <- validate_choice( + settings$create_study_population$removeDuplicateSubjects, + c("keep all", "keep first", "remove all"), + "analytic_settings.create_study_population.removeDuplicateSubjects" + ) + settings$create_study_population$maxCohortSize <- validate_integer_value( + settings$create_study_population$maxCohortSize, + "analytic_settings.create_study_population.maxCohortSize", + min_value = 0L + ) + settings$create_study_population$removeSubjectsWithPriorOutcome <- validate_logical_value( + settings$create_study_population$removeSubjectsWithPriorOutcome, + "analytic_settings.create_study_population.removeSubjectsWithPriorOutcome" + ) + settings$create_study_population$priorOutcomeLookback <- validate_integer_value( + settings$create_study_population$priorOutcomeLookback, + "analytic_settings.create_study_population.priorOutcomeLookback", + min_value = 0L + ) + settings$create_study_population$minDaysAtRisk <- validate_integer_value( + settings$create_study_population$minDaysAtRisk, + "analytic_settings.create_study_population.minDaysAtRisk", + min_value = 0L + ) + settings$create_study_population$riskWindowStart <- validate_integer_value( + settings$create_study_population$riskWindowStart, + "analytic_settings.create_study_population.riskWindowStart" + ) + settings$create_study_population$startAnchor <- validate_choice( + settings$create_study_population$startAnchor, + c("cohort start", "cohort end"), + "analytic_settings.create_study_population.startAnchor" + ) + settings$create_study_population$riskWindowEnd <- validate_integer_value( + settings$create_study_population$riskWindowEnd, + "analytic_settings.create_study_population.riskWindowEnd" + ) + settings$create_study_population$endAnchor <- validate_choice( + settings$create_study_population$endAnchor, + c("cohort start", "cohort end"), + "analytic_settings.create_study_population.endAnchor" + ) + settings$create_study_population$censorAtNewRiskWindow <- validate_logical_value( + settings$create_study_population$censorAtNewRiskWindow, + "analytic_settings.create_study_population.censorAtNewRiskWindow" + ) + settings$create_ps$estimator <- validate_choice( + settings$create_ps$estimator, + c("att", "ate"), + "analytic_settings.create_ps.estimator" + ) + settings$create_ps$maxCohortSizeForFitting <- validate_integer_value( + settings$create_ps$maxCohortSizeForFitting, + "analytic_settings.create_ps.maxCohortSizeForFitting", + min_value = 0L + ) + settings$create_ps$errorOnHighCorrelation <- validate_logical_value( + settings$create_ps$errorOnHighCorrelation, + "analytic_settings.create_ps.errorOnHighCorrelation" + ) + settings$create_ps$useRegularization <- validate_logical_value( + settings$create_ps$useRegularization, + "analytic_settings.create_ps.useRegularization" + ) + settings$match_on_ps$caliper <- validate_numeric_value( + settings$match_on_ps$caliper, + "analytic_settings.match_on_ps.caliper", + min_value = 0 + ) + settings$match_on_ps$caliperScale <- validate_choice( + settings$match_on_ps$caliperScale, + c("propensity score", "standardized", "standardized logit"), + "analytic_settings.match_on_ps.caliperScale" + ) + settings$match_on_ps$maxRatio <- validate_integer_value( + settings$match_on_ps$maxRatio, + "analytic_settings.match_on_ps.maxRatio", + min_value = 0L + ) + settings$ps_adjustment$strategy <- validate_choice( + settings$ps_adjustment$strategy, + c("match_on_ps", "stratify_by_ps", "none"), + "analytic_settings.ps_adjustment.strategy" + ) + settings$ps_adjustment$trimmingStrategy <- validate_choice( + settings$ps_adjustment$trimmingStrategy, + c("none", "by_percent", "by_equipoise"), + "analytic_settings.ps_adjustment.trimmingStrategy" + ) + settings$ps_adjustment$trimmingPercent <- validate_numeric_value( + settings$ps_adjustment$trimmingPercent, + "analytic_settings.ps_adjustment.trimmingPercent", + min_value = 0 + ) + if (settings$ps_adjustment$trimmingPercent >= 50) { + stop("analytic_settings.ps_adjustment.trimmingPercent must be < 50.") + } + settings$ps_adjustment$equipoiseLowerBound <- validate_numeric_value( + settings$ps_adjustment$equipoiseLowerBound, + "analytic_settings.ps_adjustment.equipoiseLowerBound", + min_value = 0 + ) + settings$ps_adjustment$equipoiseUpperBound <- validate_numeric_value( + settings$ps_adjustment$equipoiseUpperBound, + "analytic_settings.ps_adjustment.equipoiseUpperBound", + min_value = 0 + ) + if (settings$ps_adjustment$equipoiseLowerBound >= settings$ps_adjustment$equipoiseUpperBound || + settings$ps_adjustment$equipoiseUpperBound > 1) { + stop("analytic_settings.ps_adjustment equipoise bounds must satisfy 0 <= lower < upper <= 1.") + } + settings$stratify_by_ps$numberOfStrata <- validate_integer_value( + settings$stratify_by_ps$numberOfStrata, + "analytic_settings.stratify_by_ps.numberOfStrata", + min_value = 1L + ) + settings$stratify_by_ps$baseSelection <- validate_choice( + settings$stratify_by_ps$baseSelection, + c("all", "target", "comparator"), + "analytic_settings.stratify_by_ps.baseSelection" + ) + settings$fit_outcome_model$modelType <- validate_choice( + settings$fit_outcome_model$modelType, + c("cox", "logistic", "poisson"), + "analytic_settings.fit_outcome_model.modelType" + ) + settings$fit_outcome_model$stratified <- validate_logical_value( + settings$fit_outcome_model$stratified, + "analytic_settings.fit_outcome_model.stratified" + ) + settings$fit_outcome_model$useCovariates <- validate_logical_value( + settings$fit_outcome_model$useCovariates, + "analytic_settings.fit_outcome_model.useCovariates" + ) + settings$fit_outcome_model$inversePtWeighting <- validate_logical_value( + settings$fit_outcome_model$inversePtWeighting, + "analytic_settings.fit_outcome_model.inversePtWeighting" + ) + settings$fit_outcome_model$useRegularization <- validate_logical_value( + settings$fit_outcome_model$useRegularization, + "analytic_settings.fit_outcome_model.useRegularization" + ) + settings$covariate_concept_sets$enabled <- validate_logical_value( + settings$covariate_concept_sets$enabled, + "analytic_settings.covariate_concept_sets.enabled" + ) + settings$covariate_concept_sets$include_all_concepts <- validate_logical_value( + settings$covariate_concept_sets$include_all_concepts, + "analytic_settings.covariate_concept_sets.include_all_concepts" + ) + + include_id <- settings$covariate_concept_sets$include_concept_set_id + exclude_id <- settings$covariate_concept_sets$exclude_concept_set_id + settings$covariate_concept_sets$include_concept_set_id <- normalize_optional_concept_set_id( + include_id, + "analytic_settings.covariate_concept_sets.include_concept_set_id" + ) + settings$covariate_concept_sets$exclude_concept_set_id <- normalize_optional_concept_set_id( + exclude_id, + "analytic_settings.covariate_concept_sets.exclude_concept_set_id" + ) + + settings + } + + collect_text_value <- function(value, prompt, default = "") { + current <- value %||% default + if (!isTRUE(interactive)) return(current) + entered <- readline_with_dialogue(sprintf("%s [%s]: ", prompt, current)) + if (nzchar(trimws(entered))) entered else current + } + + collect_choice_value <- function(value, label, choices, prompt = NULL, default = NULL) { + current <- value %||% default %||% choices[[1]] + if (!current %in% choices) current <- default %||% choices[[1]] + if (!isTRUE(interactive)) return(current) + + cat(sprintf("%s\n", prompt %||% label)) + for (i in seq_along(choices)) { + marker <- if (identical(choices[[i]], current)) " [default]" else "" + cat(sprintf(" %s. %s%s\n", i, choices[[i]], marker)) + } + + repeat { + entered <- trimws(readline_with_dialogue(sprintf("Select option [%s]: ", match(current, choices)))) + if (!nzchar(entered)) return(current) + option_idx <- suppressWarnings(as.integer(entered)) + if (!is.na(option_idx) && option_idx >= 1 && option_idx <= length(choices)) { + return(choices[[option_idx]]) + } + if (entered %in% choices) return(entered) + cat(sprintf("Please enter one of: %s\n", paste(seq_along(choices), collapse = ", "))) + } + } + + collect_integer_value <- function(value, label, prompt, default = NULL, min_value = NULL) { + current <- value %||% default + current <- validate_integer_value(current, label, min_value = min_value) + if (!isTRUE(interactive)) return(current) + + repeat { + entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, current))) + if (!nzchar(entered)) return(current) + parsed <- suppressWarnings(as.integer(entered)) + if (!is.na(parsed) && (is.null(min_value) || parsed >= min_value)) { + return(as.integer(parsed)) + } + if (is.null(min_value)) { + cat(sprintf("%s must be an integer.\n", label)) + } else { + cat(sprintf("%s must be an integer >= %s.\n", label, min_value)) + } + } + } + + collect_numeric_value <- function(value, label, prompt, default = NULL, min_value = NULL) { + current <- value %||% default + current <- validate_numeric_value(current, label, min_value = min_value) + if (!isTRUE(interactive)) return(current) + + repeat { + entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, format(current, trim = TRUE, scientific = FALSE)))) + if (!nzchar(entered)) return(current) + parsed <- suppressWarnings(as.numeric(entered)) + if (!is.na(parsed) && (is.null(min_value) || parsed >= min_value)) { + return(parsed) + } + if (is.null(min_value)) { + cat(sprintf("%s must be numeric.\n", label)) + } else { + cat(sprintf("%s must be numeric >= %s.\n", label, min_value)) + } + } + } + + review_analytic_settings_interactively <- function(settings) { + if (!isTRUE(interactive)) return(settings) + section_paths <- .studyAgentAnalyticSettingsSectionPaths() + repeat { + .studyAgentPrintFinalSettingsSummary(settings, section_paths) + if (prompt_yesno_strict("Use these analytic settings?", default = TRUE)) { + return(settings) + } + + review_path <- file.path(output_dir, "analytic_settings_review.txt") + .studyAgentWriteAnalyticSettingsReviewFile(settings, section_paths, review_path) + cat(sprintf("\nOpening analytic settings review file:\n %s\n", review_path)) + cat("Edit values after ':', save the file, close the editor, then return here.\n") + tryCatch( + utils::file.edit(review_path), + error = function(e) { + cat(sprintf("Could not open editor automatically: %s\n", conditionMessage(e))) + cat("Open the file manually, edit it, save it, then return here.\n") + } + ) + + repeat { + entered <- tolower(trimws(readline_with_dialogue("Press Enter after saving, or type 'r' to reopen the file: "))) + if (identical(entered, "r")) { + tryCatch( + utils::file.edit(review_path), + error = function(e) cat(sprintf("Could not open editor: %s\n", conditionMessage(e))) + ) + next + } + break + } + + parsed <- tryCatch( + normalize_analytic_settings(.studyAgentReadAnalyticSettingsReviewFile(review_path, settings)), + error = function(e) e + ) + if (inherits(parsed, "error")) { + cat(sprintf("\nAnalytic settings validation failed: %s\n", conditionMessage(parsed))) + cat("Please edit the review file and try again.\n") + next + } + settings <- parsed + } + } + + flatten_named_values <- function(x, prefix = NULL) { + if (is.list(x) && !is.data.frame(x)) { + pieces <- unlist( + lapply(names(x), function(name) { + key <- if (is.null(prefix) || !nzchar(prefix)) name else paste(prefix, name, sep = ".") + flatten_named_values(x[[name]], key) + }), + recursive = FALSE, + use.names = FALSE + ) + return(pieces) + } + + value <- if (length(x) == 0 || all(is.na(x))) { + "null" + } else if (length(x) > 1) { + paste(as.character(x), collapse = ", ") + } else { + as.character(x) + } + + stats::setNames(list(value), prefix %||% "value") + } + + shell_settings_from_acp_recommendation <- function(recommendation, defaults_snapshot) { + settings <- defaults_snapshot %||% list() + settings$profile_name <- as.character( + recommendation$profile_name %||% settings$profile_name %||% "Recommended from ACP" + ) + settings$source <- "acp_flow" + + study_population <- recommendation$study_population %||% list() + cohort_method_data_args <- study_population$cohortMethodDataArgs %||% list() + if (length(cohort_method_data_args) > 0) { + settings$get_db_cohort_method_data <- utils::modifyList( + settings$get_db_cohort_method_data %||% list(), + cohort_method_data_args + ) + } + + create_study_population <- study_population + create_study_population$cohortMethodDataArgs <- NULL + time_at_risk <- recommendation$time_at_risk %||% list() + if (length(create_study_population) > 0 || length(time_at_risk) > 0) { + settings$create_study_population <- utils::modifyList( + settings$create_study_population %||% list(), + c(create_study_population, time_at_risk) + ) + } + + ps <- recommendation$propensity_score_adjustment %||% list() + create_ps <- ps$createPsArgs %||% list() + if (length(create_ps) > 0) { + settings$create_ps <- utils::modifyList( + settings$create_ps %||% list(), + list( + maxCohortSizeForFitting = create_ps$maxCohortSizeForFitting %||% settings$create_ps$maxCohortSizeForFitting, + errorOnHighCorrelation = create_ps$errorOnHighCorrelation %||% settings$create_ps$errorOnHighCorrelation, + useRegularization = !is.null(create_ps$prior) + ) + ) + } + + trim_args <- ps$trimByPsArgs + match_args <- ps$matchOnPsArgs + stratify_args <- ps$stratifyByPsArgs + settings$ps_adjustment <- utils::modifyList( + settings$ps_adjustment %||% list(), + list( + strategy = if (!is.null(match_args)) { + "match_on_ps" + } else if (!is.null(stratify_args)) { + "stratify_by_ps" + } else { + "none" + }, + trimmingStrategy = if (is.null(trim_args)) { + "none" + } else if (!is.null(trim_args$equipoiseBounds)) { + "by_equipoise" + } else { + "by_percent" + }, + trimmingPercent = if (!is.null(trim_args$trimFraction)) { + as.numeric(trim_args$trimFraction) * 100 + } else { + settings$ps_adjustment$trimmingPercent %||% 5 + }, + equipoiseLowerBound = if (!is.null(trim_args$equipoiseBounds) && length(trim_args$equipoiseBounds) >= 1) { + as.numeric(trim_args$equipoiseBounds[[1]]) + } else { + settings$ps_adjustment$equipoiseLowerBound %||% 0.25 + }, + equipoiseUpperBound = if (!is.null(trim_args$equipoiseBounds) && length(trim_args$equipoiseBounds) >= 2) { + as.numeric(trim_args$equipoiseBounds[[2]]) + } else { + settings$ps_adjustment$equipoiseUpperBound %||% 0.75 + } + ) + ) + if (!is.null(match_args)) { + settings$match_on_ps <- utils::modifyList(settings$match_on_ps %||% list(), match_args) + } + if (!is.null(stratify_args)) { + settings$stratify_by_ps <- utils::modifyList(settings$stratify_by_ps %||% list(), stratify_args) + } + + outcome_model <- recommendation$outcome_model %||% list() + if (length(outcome_model) > 0) { + settings$fit_outcome_model <- utils::modifyList( + settings$fit_outcome_model %||% list(), + list( + modelType = outcome_model$modelType %||% settings$fit_outcome_model$modelType, + stratified = outcome_model$stratified %||% settings$fit_outcome_model$stratified, + useCovariates = outcome_model$useCovariates %||% settings$fit_outcome_model$useCovariates, + inversePtWeighting = outcome_model$inversePtWeighting %||% settings$fit_outcome_model$inversePtWeighting, + useRegularization = !is.null(outcome_model$prior) + ) + ) + } + + settings + } + + print_analytic_settings_recommendation_preview <- function(acp_response, + recommendation, + recommendation_path = NULL, + acp_response_path = NULL) { + response <- acp_response$response %||% list() + diagnostics <- response$diagnostics %||% acp_response$diagnostics %||% list() + source <- as.character(acp_response$source %||% recommendation$source %||% "unknown") + wrapper_status <- as.character(acp_response$status %||% "unknown") + flow_status <- as.character(response$status %||% wrapper_status) + + acp_success <- identical(source, "acp_flow") && identical(flow_status, "ok") + + if (isTRUE(acp_success)) { + cat("\nAnalytic settings recommendation from ACP\n") + failed_sections <- diagnostics$failed_sections %||% list() + if (length(failed_sections) > 0) { + cat(sprintf(" - Backfilled sections: %s\n", paste(unlist(failed_sections), collapse = ", "))) + } + + rationales <- response$section_rationales %||% acp_response$section_rationales %||% list() + if (length(rationales) > 0) { + cat("\nRationales\n") + for (section in names(rationales)) { + entry <- rationales[[section]] + section_title <- .studyAgentAnalyticSettingsSectionTitles()[[section]] %||% section + cat(sprintf( + "[%s] confidence=%s\n%s\n", + section_title, + entry$confidence %||% "?", + entry$rationale %||% "" + )) + } + } + } else { + cat("\nCohort Method analytic settings recommendation could not be generated by ACP.\n") + cat("Using the current default analytic settings for now.\n") + reason <- diagnostics$reason %||% response$error %||% acp_response$error %||% NULL + if (!is.null(reason) && nzchar(as.character(reason))) { + cat(sprintf("Reason: %s\n", as.character(reason))) + } else { + message <- acp_response$message %||% acp_response$error %||% diagnostics$reason %||% NULL + if (!is.null(message) && nzchar(as.character(message))) { + cat(sprintf("Reason: %s\n", as.character(message))) + } + } + detail_paths <- c( + if (!is.null(acp_response_path) && nzchar(as.character(acp_response_path))) as.character(acp_response_path) else NULL, + if (!is.null(recommendation_path) && nzchar(as.character(recommendation_path))) as.character(recommendation_path) else NULL + ) + if (length(detail_paths) > 0) { + cat("Details saved to:\n") + for (path in detail_paths) { + cat(sprintf(" - %s\n", path)) + } + } + } + } + + build_dummy_analytic_settings_recommendation <- function(description, defaults_snapshot, input_method = "typed_text") { + list( + mode = "free_text", + input_method = input_method, + source = "manual_shell", + status = "dummy_generated", + profile_name = "Recommended from free-text description", + raw_description = description, + study_population = "TODO: derive study population settings from free-text description", + time_at_risk = "TODO: derive time-at-risk settings from free-text description", + propensity_score_adjustment = "TODO: derive propensity score adjustment settings from free-text description", + outcome_model = "TODO: derive outcome model settings from free-text description", + deferred_inputs = list( + function_argument_description = "implemented", + description_file_path = "implemented", + interactive_typed_description = "implemented" + ), + defaults_snapshot = defaults_snapshot + ) + } + + call_cohort_methods_specifications_recommendation <- function(acp_url, + body, + defaults_snapshot, + input_method = "typed_text") { + flow_name <- "cohort_methods_specifications_recommendation" + dummy_recommendation <- build_dummy_analytic_settings_recommendation( + description = body$analytic_settings_description %||% body$study_description %||% "", + defaults_snapshot = defaults_snapshot, + input_method = input_method + ) + + ensure_connected <- function(url) { + has_acp_state <- exists("acp_state", inherits = TRUE) + has_acp_connect <- exists("acp_connect", mode = "function", inherits = TRUE) + if (!has_acp_state || !has_acp_connect) return(FALSE) + if (!is.null(get("acp_state", inherits = TRUE)$url)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + acp_connect(url) + TRUE + }, error = function(e) { + FALSE + }) + } + + use_acp <- ensure_connected(acp_url) + has_acp_post <- exists(".acp_post", mode = "function", inherits = TRUE) + if (!isTRUE(use_acp) || !has_acp_post) { + return(list( + flow = flow_name, + source = "stub_acp_placeholder", + status = "stub", + message = "ACP bridge unavailable, ACP helpers not loaded, or ACP not connected. Returning placeholder cohort methods specifications recommendation.", + request = body, + recommendation = dummy_recommendation + )) + } + + response <- tryCatch( + call_shell_acp_flow(flow_name, body, url = acp_url), + error = function(e) { + list( + flow = flow_name, + source = "stub_acp_placeholder", + status = "stub", + error = conditionMessage(e), + message = "ACP flow failed. Returning placeholder cohort methods specifications recommendation.", + request = body, + recommendation = dummy_recommendation + ) + } + ) + + if (is.list(response) && identical(response$source, "stub_acp_placeholder")) { + return(response) + } + + recommendation <- response$recommendation %||% + response$recommendations %||% + response$cohort_methods_specifications_recommendation %||% + dummy_recommendation + if (!is.list(recommendation)) recommendation <- dummy_recommendation + + list( + flow = flow_name, + source = "acp_flow", + status = "received", + request = body, + response = response, + recommendation = recommendation + ) + } + + study_base_dir <- "" + if (nzchar(studyAgentBaseDir)) { + study_base_dir <- normalizePath(studyAgentBaseDir, winslash = "/", mustWork = FALSE) + } + + if (!is.null(analyticSettingsDescription)) { + analyticSettingsDescription <- trimws(as.character(analyticSettingsDescription)) + if (!nzchar(analyticSettingsDescription)) analyticSettingsDescription <- NULL + } + if (!is.null(analyticSettingsDescriptionPath)) { + analyticSettingsDescriptionPath <- trimws(as.character(analyticSettingsDescriptionPath)) + if (!nzchar(analyticSettingsDescriptionPath)) analyticSettingsDescriptionPath <- NULL + } + + outputDir <- resolve_path(outputDir, study_base_dir) + outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) + if (isTRUE(reset) && dir.exists(outputDir)) { + ok <- TRUE + if (isTRUE(interactive)) { + ok <- prompt_yesno(sprintf("Delete existing output directory %s?", outputDir), default = FALSE) + } + if (ok) unlink(outputDir, recursive = TRUE, force = TRUE) + } + + base_dir <- outputDir + incidence_base_dir <- resolve_path(incidenceOutputDir, study_base_dir) + incidence_base_dir <- normalizePath(incidence_base_dir, winslash = "/", mustWork = FALSE) + index_dir <- resolve_path(indexDir, study_base_dir) + index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) + catalog_df <- load_catalog(index_dir) + analytic_settings_description_path_resolved <- if (is.null(analyticSettingsDescriptionPath)) { + NULL + } else { + normalizePath(resolve_path(analyticSettingsDescriptionPath, study_base_dir), winslash = "/", mustWork = FALSE) + } + if (!dir.exists(index_dir) && !is_absolute_path(indexDir) && !nzchar(studyAgentBaseDir)) { + alt <- file.path(getwd(), "OHDSI-Study-Agent", indexDir) + if (dir.exists(alt)) index_dir <- normalizePath(alt, winslash = "/", mustWork = FALSE) + } + index_def_dir <- file.path(index_dir, "definitions") + if (!dir.exists(index_def_dir)) stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) + + output_dir <- file.path(base_dir, "outputs") + selected_dir <- file.path(base_dir, "selected-cohorts") + patched_dir <- file.path(base_dir, "patched-cohorts") + selected_target_dir <- file.path(base_dir, "selected-target-cohorts") + selected_comparator_dir <- file.path(base_dir, "selected-comparator-cohorts") + selected_outcome_dir <- file.path(base_dir, "selected-outcome-cohorts") + patched_target_dir <- file.path(base_dir, "patched-target-cohorts") + patched_comparator_dir <- file.path(base_dir, "patched-comparator-cohorts") + patched_outcome_dir <- file.path(base_dir, "patched-outcome-cohorts") + concept_sets_dir <- file.path(base_dir, "concept-sets") + keeper_dir <- file.path(base_dir, "keeper-case-review") + analysis_settings_dir <- file.path(base_dir, "analysis-settings") + scripts_dir <- file.path(base_dir, "scripts") + cm_results_dir <- file.path(base_dir, "cm-results") + cm_diagnostics_dir <- file.path(base_dir, "cm-diagnostics") + cm_data_dir <- file.path(base_dir, "cm-data") + + dirs <- c( + output_dir, selected_dir, patched_dir, selected_target_dir, selected_comparator_dir, + selected_outcome_dir, patched_target_dir, patched_comparator_dir, patched_outcome_dir, + concept_sets_dir, + keeper_dir, analysis_settings_dir, scripts_dir, cm_results_dir, cm_diagnostics_dir, + cm_data_dir + ) + for (dir_path in dirs) ensure_dir(dir_path) + + manual_intent_path <- file.path(output_dir, "manual_intent.json") + manual_inputs_path <- file.path(output_dir, "manual_inputs.json") + cohort_methods_intent_split_path <- file.path(output_dir, "cohort_methods_intent_split.json") + cohort_roles_path <- file.path(output_dir, "cohort_roles.json") + cohort_id_map_path <- file.path(output_dir, "cohort_id_map.json") + incidence_cohort_id_map_path <- file.path(incidence_base_dir, "outputs", "cohort_id_map.json") + incidence_selected_target_dir <- file.path(incidence_base_dir, "selected-target-cohorts") + incidence_selected_outcome_dir <- file.path(incidence_base_dir, "selected-outcome-cohorts") + cm_comparisons_path <- file.path(output_dir, "cm_comparisons.json") + improvements_status_path <- file.path(output_dir, "improvements_status.json") + cm_evaluation_todo_path <- file.path(output_dir, "cm_evaluation_todo.json") + cm_defaults_path <- file.path(output_dir, "cm_analysis_defaults.json") + cm_analysis_json_path <- file.path(analysis_settings_dir, "cmAnalysis.json") + cm_analysis_template_path <- system.file("templates", "cmAnalysis_template.json", package = "slashOhdsiStrategusAssistant") + if (!nzchar(cm_analysis_template_path)) { + cm_analysis_template_path <- resolve_path("mcp_server/prompts/cohort_methods/cmAnalysis_template.json", study_base_dir) + cm_analysis_template_path <- normalizePath(cm_analysis_template_path, winslash = "/", mustWork = FALSE) + } + if (!file.exists(cm_analysis_template_path)) { + cm_analysis_template_path <- NA_character_ + } + cm_acp_specifications_recommendation_path <- file.path(output_dir, "cm_acp_specifications_recommendation.json") + cm_analytic_settings_recommendation_path <- file.path(output_dir, "cm_analytic_settings_recommendation.json") + cm_concept_set_selections_path <- file.path(output_dir, "cm_concept_set_selections.json") + recs_target_path <- file.path(output_dir, "recommendations_target.json") + recs_comparator_path <- file.path(output_dir, "recommendations_comparator.json") + recs_outcome_path <- file.path(output_dir, "recommendations_outcome.json") + improvements_target_path <- file.path(output_dir, "improvements_target.json") + improvements_comparator_path <- file.path(output_dir, "improvements_comparator.json") + improvements_outcome_path <- file.path(output_dir, "improvements_outcome.json") + state_path <- file.path(output_dir, "study_agent_state.json") + + cached_inputs <- NULL + cached_manual_intent <- NULL + cached_cm_target_selection <- load_cached_role_selection(cohort_id_map_path, "target", selected_target_dir) + cached_cm_comparator_selection <- load_cached_role_selection(cohort_id_map_path, "comparator", selected_comparator_dir) + cached_cm_outcome_selection <- load_cached_role_selection(cohort_id_map_path, "outcome", selected_outcome_dir) + cached_incidence_target_selection <- load_cached_role_selection(incidence_cohort_id_map_path, "target", incidence_selected_target_dir) + cached_incidence_outcome_selection <- load_cached_role_selection(incidence_cohort_id_map_path, "outcome", incidence_selected_outcome_dir) + + if (interactive) { + banner_path <- resolve_path(bannerPath, study_base_dir) + banner_path <- normalizePath(banner_path, winslash = "/", mustWork = FALSE) + if (!file.exists(banner_path) && !is_absolute_path(bannerPath) && !nzchar(studyAgentBaseDir)) { + alt <- file.path(getwd(), "OHDSI-Study-Agent", bannerPath) + if (file.exists(alt)) banner_path <- normalizePath(alt, winslash = "/", mustWork = FALSE) + } + if (file.exists(banner_path)) { + cat(paste(readLines(banner_path, warn = FALSE), collapse = "\n"), "\n") + } + cat("\nStudy Agent: Strategus CohortMethod shell\n") + } + + default_intent <- studyIntent %||% cached_inputs$study_intent %||% + "Compare a target exposure versus a comparator exposure on one or more outcomes using a cohort method design." + if (isTRUE(interactive)) { + set_dialogue_context("study_intent", context = list(default_intent = default_intent)) + entered <- readline_with_dialogue(sprintf("Study intent [%s]: ", default_intent)) + if (nzchar(trimws(entered))) { + studyIntent <- entered + } else { + studyIntent <- default_intent + } + } else if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) { + studyIntent <- default_intent + } + + nonempty_string <- function(value) { + !is.null(value) && length(value) > 0 && nzchar(trimws(as.character(value[[1]]))) + } + first_nonempty <- function(...) { + values <- list(...) + for (value in values) { + if (nonempty_string(value)) return(trimws(as.character(value[[1]]))) + } + NULL + } + normalize_statement_list <- function(value) { + if (is.null(value)) return(character(0)) + if (is.data.frame(value)) value <- unlist(value, recursive = TRUE, use.names = FALSE) + if (is.list(value) && !is.character(value)) value <- unlist(value, recursive = TRUE, use.names = FALSE) + values <- trimws(as.character(value)) + values <- values[!is.na(values) & nzchar(values)] + unique(values) + } + statement_dedupe_key <- function(value) { + key <- tolower(trimws(as.character(value))) + key <- gsub("[[:space:]]+", " ", key) + key <- gsub("[[:punct:]]+$", "", key) + key + } + dedupe_statement_list <- function(statements) { + statements <- normalize_statement_list(statements) + if (length(statements) == 0) return(character(0)) + keys <- statement_dedupe_key(statements) + statements[!duplicated(keys)] + } + combine_statement_list <- function(primary = NULL, ...) { + statements <- normalize_statement_list(primary) + for (value in list(...)) { + statements <- c(statements, normalize_statement_list(value)) + } + dedupe_statement_list(statements) + } + prompt_outcome_statements <- function(defaults) { + defaults <- dedupe_statement_list(defaults) + if (!isTRUE(interactive)) return(defaults) + if (length(defaults) > 1) { + cat("\nSuggested outcome statements:\n") + for (i in seq_along(defaults)) { + cat(sprintf(" %s. %s\n", i, defaults[[i]])) + } + default_selection <- paste(seq_along(defaults), collapse = ",") + use_manual_outcome <- FALSE + repeat { + entered <- trimws(readline_with_dialogue(sprintf( + "Keep outcome statements [%s] (comma-separated numbers, 0/none to enter manually, Enter keeps all): ", + default_selection + ))) + if (!nzchar(entered)) { + selected <- seq_along(defaults) + } else if (tolower(entered) %in% c("a", "all")) { + selected <- seq_along(defaults) + } else if (tolower(entered) %in% c("0", "n", "none")) { + selected <- integer(0) + use_manual_outcome <- TRUE + } else { + selected <- suppressWarnings(parse_ids(entered)) + selected <- unique(selected[!is.na(selected)]) + } + invalid <- setdiff(selected, seq_along(defaults)) + if (!isTRUE(use_manual_outcome) && (length(selected) == 0 || length(invalid) > 0)) { + cat(sprintf("Please enter one or more valid outcome numbers, such as 1 or 1,3, or 0/none to enter manually. Valid choices: %s\n", default_selection)) + next + } + defaults <- defaults[selected] + break + } + cat("Press Enter to keep each selected statement, or type an edited statement.\n") + } + if (length(defaults) == 0) { + entered <- prompt_statement("Outcome", default = "") + return(dedupe_statement_list(entered)) + } + resolved <- vapply(seq_along(defaults), function(i) { + label <- if (length(defaults) == 1) "Outcome" else sprintf("Outcome %s", i) + prompt_statement(label, default = defaults[[i]]) + }, character(1)) + repeat { + add_another <- prompt_yesno("Add another outcome statement?", default = FALSE) + if (!isTRUE(add_another)) break + next_label <- sprintf("Outcome %s", length(resolved) + 1L) + resolved <- c(resolved, prompt_statement(next_label, default = "")) + } + dedupe_statement_list(resolved) + } + summarize_intent_split_error <- function(split_core) { + error_text <- as.character(split_core$error %||% split_core$message %||% "") + if (!nzchar(trimws(error_text))) return("unknown error") + nested_text <- trimws(sub("^ACP error:\\s*", "", error_text)) + if (grepl("^\\{", nested_text)) { + nested <- tryCatch(jsonlite::fromJSON(nested_text, simplifyVector = FALSE), error = function(e) NULL) + if (is.list(nested)) { + diagnostics <- nested$diagnostics %||% list() + parts <- c( + nested$error %||% nested$status, + diagnostics$llm_status, + diagnostics$llm_error + ) + parts <- as.character(parts[!vapply(parts, is.null, logical(1))]) + parts <- parts[nzchar(trimws(parts))] + if (length(parts) > 0) return(paste(unique(parts), collapse = " / ")) + } + } + error_text + } + + target_statement_default <- first_nonempty( + targetStatement, + cached_manual_intent$target_statement, + cached_inputs$target_statement + ) + comparator_statement_default <- first_nonempty( + comparatorStatement, + cached_manual_intent$comparator_statement, + cached_inputs$comparator_statement + ) + outcome_statement_default <- first_nonempty( + outcomeStatement, + cached_manual_intent$outcome_statement, + cached_inputs$outcome_statement, + normalize_statement_list(cached_manual_intent$outcome_statements), + normalize_statement_list(cached_inputs$outcome_statements) + ) + outcome_statements_default <- combine_statement_list( + outcome_statement_default, + cached_manual_intent$outcome_statements, + cached_inputs$outcome_statements + ) + + explicit_target_ids_from_args <- normalize_selected_ids( + targetCohortId, + "Target cohort ID", + allow_multiple = FALSE + ) + explicit_comparator_ids_from_args <- normalize_selected_ids( + comparatorCohortId, + "Comparator cohort ID", + allow_multiple = FALSE + ) + explicit_outcome_ids_from_args <- normalize_selected_ids( + outcomeCohortIds, + "Outcome cohort IDs", + allow_multiple = TRUE + ) + all_cohort_ids_from_function_args <- length(explicit_target_ids_from_args) == 1 && + length(explicit_comparator_ids_from_args) == 1 && + length(explicit_outcome_ids_from_args) > 0 + skip_intent_split_and_recommendation <- FALSE + explicit_outcome_statements_from_args <- character(0) + if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) { + cat("\nAll target, comparator, and outcome cohort IDs were provided as function arguments:\n") + cat(sprintf(" Target: %s\n", format_cohort_selection_summary(explicit_target_ids_from_args, catalog_df))) + cat(sprintf(" Comparator: %s\n", format_cohort_selection_summary(explicit_comparator_ids_from_args, catalog_df))) + cat(sprintf(" Outcome: %s\n", format_cohort_selection_summary(explicit_outcome_ids_from_args, catalog_df))) + skip_intent_split_and_recommendation <- prompt_yesno( + "Skip study intent split, phenotype recommendation, and phenotype improvements, and use these cohort IDs directly?", + default = TRUE + ) + } + + cohort_id_statement <- function(role_label, ids) { + ids <- as.integer(ids[!is.na(ids)]) + if (length(ids) == 0) return(NULL) + labels <- vapply(ids, function(id) { + lookup_catalog_value(id, catalog_df, "name", sprintf("Cohort %s", id)) + }, character(1)) + if (length(ids) == 1) { + return(sprintf("%s cohort: %s (ID %s)", role_label, labels[[1]], ids[[1]])) + } + items <- sprintf("%s (ID %s)", labels, ids) + sprintf("%s cohorts: %s", role_label, paste(items, collapse = "; ")) + } + + if (isTRUE(skip_intent_split_and_recommendation)) { + target_statement_default <- first_nonempty( + target_statement_default, + cohort_id_statement("Target", explicit_target_ids_from_args) + ) + comparator_statement_default <- first_nonempty( + comparator_statement_default, + cohort_id_statement("Comparator", explicit_comparator_ids_from_args) + ) + explicit_outcome_statements_from_args <- vapply(explicit_outcome_ids_from_args, function(id) { + cohort_id_statement("Outcome", as.integer(id)) + }, character(1)) + outcome_statement_default <- first_nonempty( + outcome_statement_default, + explicit_outcome_statements_from_args + ) + outcome_statements_default <- combine_statement_list( + outcome_statement_default, + outcome_statements_default, + explicit_outcome_statements_from_args + ) + } + + cohort_methods_intent_split_source <- "not_run" + cohort_methods_intent_split_status <- "not_run" + cohort_methods_intent_split_response <- NULL + have_all_statement_defaults <- nonempty_string(target_statement_default) && + nonempty_string(comparator_statement_default) && + nonempty_string(outcome_statement_default) + + if (isTRUE(skip_intent_split_and_recommendation)) { + cohort_methods_intent_split_source <- "skipped_explicit_cohort_ids" + cohort_methods_intent_split_status <- "skipped" + if (isTRUE(interactive)) { + cat("\nSkipping study intent split, phenotype recommendation, and phenotype improvements for explicit cohort IDs.\n") + } + } else if (!isTRUE(have_all_statement_defaults)) { + if (isTRUE(interactive)) { + cat("\n== Step 1: Parse study intent into target/comparator/outcome statements ==\n") + } + if (maybe_use_cache(cohort_methods_intent_split_path, "cohort-methods intent split")) { + cohort_methods_intent_split_response <- read_json(cohort_methods_intent_split_path) + cohort_methods_intent_split_source <- "cached" + } else if (ensure_acp_ready(acpUrl)) { + if (isTRUE(interactive)) { + cat("Calling ACP flow: cohort_methods_intent_split\n") + } else { + message("Calling ACP flow: cohort_methods_intent_split") + } + cohort_methods_intent_split_response <- tryCatch( + call_shell_acp_flow("cohort_methods_intent_split", list(study_intent = studyIntent)), + error = function(e) { + list(status = "error", error = conditionMessage(e)) + } + ) + write_json(cohort_methods_intent_split_response, cohort_methods_intent_split_path) + cohort_methods_intent_split_source <- "acp_flow" + } + + split_core <- cohort_methods_intent_split_response$intent_split %||% cohort_methods_intent_split_response + if (!is.null(split_core) && is.null(split_core$error)) { + cohort_methods_intent_split_status <- as.character(split_core$status %||% "ok") + target_statement_default <- first_nonempty(target_statement_default, split_core$target_statement) + comparator_statement_default <- first_nonempty(comparator_statement_default, split_core$comparator_statement) + outcome_statement_default <- first_nonempty( + outcome_statement_default, + split_core$outcome_statement, + normalize_statement_list(split_core$outcome_statements) + ) + outcome_statements_default <- combine_statement_list( + outcome_statement_default, + outcome_statements_default, + split_core$outcome_statement, + split_core$outcome_statements + ) + if (isTRUE(interactive)) { + rationale <- as.character(split_core$rationale %||% "") + if (nzchar(rationale)) { + cat("\nSuggested rationale:\n") + cat(rationale, "\n") + } + if (length(split_core$questions %||% list()) > 0) { + cat("Questions to clarify:\n") + for (q in split_core$questions) cat(sprintf(" - %s\n", q)) + } + } + } else if (!is.null(split_core$error)) { + cohort_methods_intent_split_status <- "error" + if (isTRUE(interactive)) { + cat("\nACP cohort_methods_intent_split failed:\n") + cat(sprintf(" %s\n", summarize_intent_split_error(split_core))) + cat("Proceeding with manual target/comparator/outcome statement entry.\n") + } + } + } else { + cohort_methods_intent_split_source <- "manual_or_cached_statements" + cohort_methods_intent_split_status <- "not_needed" + } + + if (!isTRUE(interactive) && + identical(cohort_methods_intent_split_status, "needs_clarification") && + (!nonempty_string(target_statement_default) || + !nonempty_string(comparator_statement_default) || + !nonempty_string(outcome_statement_default))) { + stop("Cohort methods intent split needs clarification. Provide targetStatement, comparatorStatement, and outcomeStatement for non-interactive execution.") + } + + outcome_statements_default <- combine_statement_list(outcome_statement_default, outcome_statements_default) + + if (isTRUE(skip_intent_split_and_recommendation)) { + targetStatement <- target_statement_default + comparatorStatement <- comparator_statement_default + outcomeStatements <- outcome_statements_default + outcomeStatement <- first_nonempty(outcomeStatements) + } else { + set_dialogue_context("intent_split", "target", context = list( + target_statement = target_statement_default, + comparator_statement = comparator_statement_default, + outcome_statements = outcome_statements_default + )) + targetStatement <- prompt_statement("Target", default = target_statement_default) + set_dialogue_context("intent_split", "comparator", context = list( + target_statement = targetStatement, + comparator_statement = comparator_statement_default, + outcome_statements = outcome_statements_default + )) + comparatorStatement <- prompt_statement("Comparator", default = comparator_statement_default) + set_dialogue_context("intent_split", "outcome", context = list( + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statements = outcome_statements_default + )) + outcomeStatements <- prompt_outcome_statements(outcome_statements_default) + outcomeStatement <- first_nonempty(outcomeStatements) + } + + if (!nonempty_string(targetStatement) || !nonempty_string(comparatorStatement) || !nonempty_string(outcomeStatement)) { + stop( + "Missing target, comparator, or outcome cohort statement. ", + "Provide explicit targetStatement/comparatorStatement/outcomeStatement, ", + "reuse a valid cache, or run ACP with /flows/cohort_methods_intent_split available." + ) + } + + validate_target_id <- function(target_id) { + if (!cohort_json_exists(target_id, index_def_dir)) { + return(sprintf("Target cohort ID %s was not found in %s. Please enter a valid target cohort ID.", target_id, index_def_dir)) + } + NULL + } + validate_comparator_id <- function(comparator_id, target_id) { + if (target_id == comparator_id) { + return("Target and comparator cohort IDs must be different.") + } + if (!cohort_json_exists(comparator_id, index_def_dir)) { + return(sprintf("Comparator cohort ID %s was not found in %s. Please enter a valid comparator cohort ID.", comparator_id, index_def_dir)) + } + NULL + } + validate_outcome_ids <- function(outcome_ids, target_id, comparator_id) { + if (any(outcome_ids %in% c(target_id, comparator_id))) { + return("Outcome cohort IDs must be distinct from the target and comparator cohort IDs.") + } + missing_outcomes <- outcome_ids[!vapply(outcome_ids, cohort_json_exists, logical(1), index_def_dir = index_def_dir)] + if (length(missing_outcomes) > 0) { + return(sprintf( + "Outcome cohort ID(s) %s were not found in %s. Please enter valid outcome cohort IDs.", + paste(missing_outcomes, collapse = ", "), + index_def_dir + )) + } + NULL + } + validate_manual_ids <- function(target_id, comparator_id, outcome_ids) { + target_error <- validate_target_id(target_id) + if (!is.null(target_error)) return(target_error) + comparator_error <- validate_comparator_id(comparator_id, target_id) + if (!is.null(comparator_error)) return(comparator_error) + validate_outcome_ids(outcome_ids, target_id, comparator_id) + } + patched_outputs_cleared <- FALSE + ensure_patched_outputs_cleared <- function() { + if (!isTRUE(patched_outputs_cleared)) { + clear_patched_role_outputs() + patched_outputs_cleared <<- TRUE + } + } + improvements_results <- list() + use_function_argument_ids_for_selection <- !( + isTRUE(all_cohort_ids_from_function_args) && + isTRUE(interactive) && + !isTRUE(skip_intent_split_and_recommendation) + ) + preferred_target_ids <- if (isTRUE(use_function_argument_ids_for_selection)) targetCohortId else NULL + preferred_comparator_ids <- if (isTRUE(use_function_argument_ids_for_selection)) comparatorCohortId else NULL + + target_rec <- run_role_recommendation( + role_label = "Target", + statement = targetStatement, + output_path = recs_target_path, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + allow_multiple = FALSE, + preferred_selected_ids = preferred_target_ids, + preferred_selection_source = "function_argument", + cached_selected_ids = cached_inputs$target_cohort_id %||% NULL, + selected_cache_label = "target cohort selection", + selected_cache_dir = selected_target_dir, + cohort_method_cache = list( + selection = list( + selected_ids = cached_cm_target_selection$selected_ids %||% NULL, + cache_dir = selected_target_dir + ) + ), + incidence_cache = list( + selection = list( + selected_ids = cached_incidence_target_selection$selected_ids %||% NULL, + cache_dir = incidence_selected_target_dir, + label = "incidence target cohort selection" + ) + ), + recommendation_role = "target", + workflow_type = "cohort_methods", + exclude_metadata = list(executable_definition_status = list("codes_only")) + ) + + targetCohortId <- if (length(target_rec$selected_ids) > 0) { + as.integer(target_rec$selected_ids[[1]]) + } else { + collect_single_id(targetCohortId %||% cached_inputs$target_cohort_id, "Target") + } + if (!length(target_rec$selected_ids)) target_rec$selection_source <- "manual_input" + target_validation_error <- validate_target_id(targetCohortId) + while (!is.null(target_validation_error) && isTRUE(interactive)) { + cat(sprintf("%s\n", target_validation_error)) + targetCohortId <- collect_single_id(NULL, "Target") + target_rec$selection_source <- "manual_input" + target_rec$selected_ids <- as.integer(targetCohortId) + target_validation_error <- validate_target_id(targetCohortId) + } + if (!is.null(target_validation_error)) { + stop(target_validation_error) + } + selected_target_id <- as.integer(targetCohortId) + + default_cohort_id_base_ids <- suppressWarnings(as.integer(c( + targetCohortId, + comparatorCohortId, + outcomeCohortIds, + cached_inputs$target_cohort_id %||% NULL, + cached_inputs$comparator_cohort_id %||% NULL, + cached_inputs$outcome_cohort_ids %||% NULL, + catalog_df$cohortId + ))) + default_cohort_id_base_ids <- default_cohort_id_base_ids[!is.na(default_cohort_id_base_ids)] + default_cohort_id_base <- if (length(default_cohort_id_base_ids) > 0) { + max(default_cohort_id_base_ids, na.rm = TRUE) + 1000L + } else { + 1000L + } + use_mapping <- isTRUE(remapCohortIds) + if (isTRUE(interactive)) { + use_mapping <- prompt_yesno("Map cohort IDs to a new range (avoid collisions)?", default = isTRUE(remapCohortIds)) + } + if (use_mapping) { + cohortIdBase <- cohortIdBase %||% cached_inputs$cohort_id_base %||% default_cohort_id_base + cohortIdBase <- suppressWarnings(as.integer(cohortIdBase)) + if (isTRUE(interactive)) { + entered <- trimws(readline_with_dialogue(sprintf("Cohort ID base [%s]: ", cohortIdBase))) + if (nzchar(entered)) cohortIdBase <- suppressWarnings(as.integer(entered)) + } + cohortIdBase <- validate_positive_integer(cohortIdBase, "cohortIdBase") + } else { + cohortIdBase <- NA_integer_ + } + + next_id <- cohortIdBase + map_ids <- function(ids) { + if (!use_mapping) return(as.integer(ids)) + new_ids <- seq.int(next_id, length.out = length(ids)) + next_id <<- max(new_ids) + 1L + as.integer(new_ids) + } + new_target_id <- map_ids(selected_target_id) + copy_cohort_json_multi(selected_target_id, new_target_id, c(selected_target_dir, selected_dir), index_def_dir) + ensure_patched_outputs_cleared() + improvements_results$target <- if (isTRUE(skip_intent_split_and_recommendation)) { + skipped_role_improvements( + role_key = "target", + role_label = "Target", + cohort_ids = new_target_id, + patched_role_dir = patched_target_dir, + improvements_path = improvements_target_path, + reason = "explicit_cohort_ids_skip_confirmed" + ) + } else { + run_role_improvement_gate( + role_key = "target", + role_label = "Target", + cohort_ids = new_target_id, + selected_role_dir = selected_target_dir, + patched_role_dir = patched_target_dir, + improvements_path = improvements_target_path, + role_statement = targetStatement + ) + } + + comparator_rec <- run_role_recommendation( + role_label = "Comparator", + statement = comparatorStatement, + output_path = recs_comparator_path, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + allow_multiple = FALSE, + preferred_selected_ids = preferred_comparator_ids, + preferred_selection_source = "function_argument", + cached_selected_ids = cached_inputs$comparator_cohort_id %||% NULL, + selected_cache_label = "comparator cohort selection", + selected_cache_dir = selected_comparator_dir, + cohort_method_cache = list( + selection = list( + selected_ids = cached_cm_comparator_selection$selected_ids %||% NULL, + cache_dir = selected_comparator_dir + ) + ), + incidence_cache = list( + selection = list( + selected_ids = NULL, + cache_dir = NULL, + label = NULL + ) + ), + recommendation_role = "comparator", + workflow_type = "cohort_methods", + exclude_metadata = list(executable_definition_status = list("codes_only")) + ) + + comparatorCohortId <- if (length(comparator_rec$selected_ids) > 0) { + as.integer(comparator_rec$selected_ids[[1]]) + } else { + collect_single_id(comparatorCohortId %||% cached_inputs$comparator_cohort_id, "Comparator") + } + if (!length(comparator_rec$selected_ids)) comparator_rec$selection_source <- "manual_input" + comparator_validation_error <- validate_comparator_id(comparatorCohortId, targetCohortId) + while (!is.null(comparator_validation_error) && isTRUE(interactive)) { + cat(sprintf("%s\n", comparator_validation_error)) + comparatorCohortId <- collect_single_id(NULL, "Comparator") + comparator_rec$selection_source <- "manual_input" + comparator_rec$selected_ids <- as.integer(comparatorCohortId) + comparator_validation_error <- validate_comparator_id(comparatorCohortId, targetCohortId) + } + if (!is.null(comparator_validation_error)) { + stop(comparator_validation_error) + } + selected_comparator_id <- as.integer(comparatorCohortId) + new_comparator_id <- map_ids(selected_comparator_id) + copy_cohort_json_multi(selected_comparator_id, new_comparator_id, c(selected_comparator_dir, selected_dir), index_def_dir) + improvements_results$comparator <- if (isTRUE(skip_intent_split_and_recommendation)) { + skipped_role_improvements( + role_key = "comparator", + role_label = "Comparator", + cohort_ids = new_comparator_id, + patched_role_dir = patched_comparator_dir, + improvements_path = improvements_comparator_path, + reason = "explicit_cohort_ids_skip_confirmed" + ) + } else { + run_role_improvement_gate( + role_key = "comparator", + role_label = "Comparator", + cohort_ids = new_comparator_id, + selected_role_dir = selected_comparator_dir, + patched_role_dir = patched_comparator_dir, + improvements_path = improvements_comparator_path, + role_statement = comparatorStatement + ) + } + + cached_input_outcome_ids <- normalize_selected_ids( + cached_inputs$outcome_cohort_ids %||% NULL, + "cached outcome cohort IDs", + allow_multiple = TRUE + ) + preferred_outcome_ids <- normalize_selected_ids( + if (isTRUE(use_function_argument_ids_for_selection)) outcomeCohortIds else NULL, + "Outcome cohort IDs", + allow_multiple = TRUE + ) + preferred_outcome_source <- "function_argument" + if (length(preferred_outcome_ids) == 0 && + length(outcomeStatements) <= 1 && + length(cached_input_outcome_ids) > 0) { + preferred_outcome_ids <- cached_input_outcome_ids + preferred_outcome_source <- "cached_manual_input" + } + outcome_recommendation_path <- function(i) { + if (identical(as.integer(i), 1L)) return(recs_outcome_path) + file.path(output_dir, sprintf("recommendations_outcome_%s.json", as.integer(i))) + } + run_per_outcome_recommendations <- length(outcomeStatements) > 1 && + length(preferred_outcome_ids) == 0 + if (isTRUE(run_per_outcome_recommendations)) { + outcome_recs <- lapply(seq_along(outcomeStatements), function(i) { + run_role_recommendation( + role_label = if (identical(as.integer(i), 1L)) "Outcome" else sprintf("Outcome %s", i), + statement = outcomeStatements[[i]], + output_path = outcome_recommendation_path(i), + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + allow_multiple = FALSE, + preferred_selected_ids = NULL, + preferred_selection_source = "function_argument", + cached_selected_ids = NULL, + selected_cache_label = NULL, + selected_cache_dir = NULL, + cohort_method_cache = NULL, + incidence_cache = NULL, + recommendation_role = "outcome", + workflow_type = "cohort_methods", + exclude_metadata = list(executable_definition_status = list("codes_only")) + ) + }) + } else { + outcome_recs <- list(run_role_recommendation( + role_label = "Outcome", + statement = outcomeStatement, + output_path = recs_outcome_path, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + allow_multiple = TRUE, + preferred_selected_ids = preferred_outcome_ids, + preferred_selection_source = preferred_outcome_source, + cached_selected_ids = cached_inputs$outcome_cohort_ids %||% NULL, + selected_cache_label = "outcome cohort selections", + selected_cache_dir = selected_outcome_dir, + cohort_method_cache = list( + selection = list( + selected_ids = cached_cm_outcome_selection$selected_ids %||% NULL, + cache_dir = selected_outcome_dir + ) + ), + incidence_cache = list( + selection = list( + selected_ids = cached_incidence_outcome_selection$selected_ids %||% NULL, + cache_dir = incidence_selected_outcome_dir, + label = "incidence outcome cohort selection" + ) + ), + recommendation_role = "outcome", + workflow_type = "cohort_methods", + exclude_metadata = list(executable_definition_status = list("codes_only")) + )) + } + outcome_recommendations <- lapply(seq_along(outcome_recs), function(i) { + rec <- outcome_recs[[i]] + list( + outcome_index = as.integer(i), + statement = rec$statement %||% outcomeStatements[[min(i, length(outcomeStatements))]], + selected_ids = as.list(as.integer(rec$selected_ids)), + path = json_string_or_null(rec$recommendation_path), + source = rec$recommendation_source, + selection_source = rec$selection_source, + used_cached_recommendation = isTRUE(rec$used_cached_recommendation), + used_cached_selection = isTRUE(rec$used_cached_selection), + used_window2 = isTRUE(rec$used_window2), + used_advice = isTRUE(rec$used_advice) + ) + }) + outcome_rec <- outcome_recs[[1]] + outcome_selected_ids_by_rec <- lapply(outcome_recs, function(rec) as.integer(rec$selected_ids)) + outcome_selected_statements_by_rec <- lapply(seq_along(outcome_recs), function(i) { + rec <- outcome_recs[[i]] + ids <- outcome_selected_ids_by_rec[[i]] + if (length(ids) == 0) return(character(0)) + rep( + rec$statement %||% outcomeStatements[[min(i, length(outcomeStatements))]], + length(ids) + ) + }) + outcome_selected_ids_flat <- as.integer(unlist(outcome_selected_ids_by_rec, use.names = FALSE)) + outcome_selected_statements_flat <- as.character(unlist(outcome_selected_statements_by_rec, use.names = FALSE)) + valid_outcome_selection <- !is.na(outcome_selected_ids_flat) + outcome_selected_ids_flat <- outcome_selected_ids_flat[valid_outcome_selection] + outcome_selected_statements_flat <- outcome_selected_statements_flat[valid_outcome_selection] + unique_outcome_selection <- !duplicated(outcome_selected_ids_flat) + outcome_rec$selected_ids <- as.integer(outcome_selected_ids_flat[unique_outcome_selection]) + outcome_selected_statements <- as.character(outcome_selected_statements_flat[unique_outcome_selection]) + if (length(outcome_recs) > 1) { + outcome_rec$recommendation_source <- "per_outcome" + outcome_rec$used_cached_recommendation <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_cached_recommendation), logical(1))) + outcome_rec$used_cached_selection <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_cached_selection), logical(1))) + outcome_rec$used_window2 <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_window2), logical(1))) + outcome_rec$used_advice <- any(vapply(outcome_recs, function(rec) isTRUE(rec$used_advice), logical(1))) + } + + outcomeCohortIds <- if (length(outcome_rec$selected_ids) > 0) { + as.integer(outcome_rec$selected_ids) + } else { + collect_outcome_ids(outcomeCohortIds %||% cached_inputs$outcome_cohort_ids) + } + if (!length(outcome_rec$selected_ids)) outcome_rec$selection_source <- "manual_input" + outcomeStatementsForSelectedCohorts <- if ( + isTRUE(skip_intent_split_and_recommendation) && + length(explicit_outcome_statements_from_args) == length(outcomeCohortIds) + ) { + as.character(explicit_outcome_statements_from_args) + } else if (length(outcome_selected_statements) == length(outcomeCohortIds)) { + as.character(outcome_selected_statements) + } else { + vapply( + seq_along(outcomeCohortIds), + function(i) outcomeStatements[[min(i, length(outcomeStatements))]], + character(1) + ) + } + outcome_validation_error <- validate_outcome_ids(outcomeCohortIds, targetCohortId, comparatorCohortId) + while (!is.null(outcome_validation_error) && isTRUE(interactive)) { + cat(sprintf("%s\n", outcome_validation_error)) + outcomeCohortIds <- collect_outcome_ids(NULL) + outcome_rec$selection_source <- "manual_input" + outcome_rec$selected_ids <- as.integer(outcomeCohortIds) + outcomeStatementsForSelectedCohorts <- vapply( + seq_along(outcomeCohortIds), + function(i) outcomeStatements[[min(i, length(outcomeStatements))]], + character(1) + ) + outcome_validation_error <- validate_outcome_ids(outcomeCohortIds, targetCohortId, comparatorCohortId) + } + if (!is.null(outcome_validation_error)) { + stop(outcome_validation_error) + } + validation_error <- validate_manual_ids(targetCohortId, comparatorCohortId, outcomeCohortIds) + if (!is.null(validation_error)) { + stop(validation_error) + } + selected_outcome_ids <- as.integer(outcomeCohortIds) + new_outcome_ids <- map_ids(selected_outcome_ids) + + for (i in seq_along(selected_outcome_ids)) { + copy_cohort_json_multi(selected_outcome_ids[[i]], new_outcome_ids[[i]], c(selected_outcome_dir, selected_dir), index_def_dir) + } + improvements_results$outcome <- if (isTRUE(skip_intent_split_and_recommendation)) { + skipped_role_improvements( + role_key = "outcome", + role_label = "Outcome", + cohort_ids = new_outcome_ids, + patched_role_dir = patched_outcome_dir, + improvements_path = improvements_outcome_path, + reason = "explicit_cohort_ids_skip_confirmed" + ) + } else { + run_role_improvement_gate( + role_key = "outcome", + role_label = "Outcome", + cohort_ids = new_outcome_ids, + selected_role_dir = selected_outcome_dir, + patched_role_dir = patched_outcome_dir, + improvements_path = improvements_outcome_path, + role_statement = paste(unique(outcomeStatementsForSelectedCohorts), collapse = "\n") + ) + } + do_target_improvements <- isTRUE(improvements_results$target$prompt_choice) + do_comparator_improvements <- isTRUE(improvements_results$comparator$prompt_choice) + do_outcome_improvements <- isTRUE(improvements_results$outcome$prompt_choice) + + target_original_name <- lookup_catalog_value(targetCohortId, catalog_df, "name", sprintf("Target cohort %s", targetCohortId)) + comparator_original_name <- lookup_catalog_value(comparatorCohortId, catalog_df, "name", sprintf("Comparator cohort %s", comparatorCohortId)) + outcome_original_names <- vapply( + outcomeCohortIds, + function(id) lookup_catalog_value(id, catalog_df, "name", sprintf("Outcome cohort %s", id)), + character(1) + ) + target_name <- prompt_analysis_label( + "Target", + cached_inputs$target_analysis_label %||% cached_inputs$target_name %||% target_original_name + ) + comparator_name <- prompt_analysis_label( + "Comparator", + cached_inputs$comparator_analysis_label %||% cached_inputs$comparator_name %||% comparator_original_name + ) + outcome_names <- vapply(seq_along(outcome_original_names), function(i) { + cached_labels <- cached_inputs$outcome_analysis_labels %||% cached_inputs$outcome_names %||% NULL + default_label <- if (!is.null(cached_labels) && length(cached_labels) >= i) cached_labels[[i]] else outcome_original_names[[i]] + prompt_analysis_label(sprintf("Outcome %s", i), default_label) + }, character(1)) + target_desc <- lookup_catalog_value(targetCohortId, catalog_df, "short_description", "") + comparator_desc <- lookup_catalog_value(comparatorCohortId, catalog_df, "short_description", "") + outcome_descs <- vapply( + outcomeCohortIds, + function(id) lookup_catalog_value(id, catalog_df, "short_description", ""), + character(1) + ) + + comparisonLabel <- comparisonLabel %||% cached_inputs$comparison_label + if (is.null(comparisonLabel) || !nzchar(trimws(comparisonLabel))) { + comparisonLabel <- sprintf("%s vs %s", target_name, comparator_name) + } + comparisonLabel <- prompt_analysis_label("Comparison", comparisonLabel) + + cached_analytic_settings <- cached_inputs$analytic_settings %||% list() + cached_analytics <- if (is.null(cached_analytic_settings)) list() else cached_analytic_settings + cached_covariate_settings <- cached_analytics$covariate_concept_sets %||% list() + + negative_control_enabled <- isTRUE(cached_inputs$negative_control_enabled) || + !is.null(negativeControlConceptSetId %||% cached_inputs$negative_control_concept_set_id) + if (isTRUE(interactive)) { + negative_control_enabled <- prompt_yesno( + "Add a negative control concept set selection?", + default = negative_control_enabled + ) + } + if (isTRUE(negative_control_enabled)) { + negativeControlConceptSetId <- collect_optional_single_id( + negativeControlConceptSetId %||% cached_inputs$negative_control_concept_set_id, + "Negative control concept set", + "Negative control concept set ID: " + ) + if (is.null(negativeControlConceptSetId)) { + stop("Negative control concept set ID is required when negative control concept set selection is enabled.") + } + } else { + negativeControlConceptSetId <- NULL + } + + cached_include_covariate_id <- cached_covariate_settings$include_concept_set_id %||% + cached_inputs$covariate_include_concept_set_id + cached_exclude_covariate_id <- cached_covariate_settings$exclude_concept_set_id %||% + cached_inputs$covariate_exclude_concept_set_id + cached_include_all_covariates <- cached_covariate_settings$include_all_concepts %||% + cached_inputs$covariate_include_all_concepts + covariate_enabled <- isTRUE(cached_covariate_settings$enabled %||% cached_inputs$covariate_concept_sets_enabled) || + !is.null(includeCovariateConceptSetId %||% excludeCovariateConceptSetId %||% + cached_include_covariate_id %||% cached_exclude_covariate_id) + if (isTRUE(interactive)) { + covariate_enabled <- prompt_yesno( + "Add covariate concept set selections?", + default = covariate_enabled + ) + } + include_all_covariates <- isTRUE(cached_include_all_covariates) || !isTRUE(covariate_enabled) + if (isTRUE(covariate_enabled)) { + includeCovariateConceptSetId <- collect_optional_single_id( + includeCovariateConceptSetId %||% cached_include_covariate_id, + "Covariate include concept set", + "Covariate include concept set ID [optional; leave blank if you want to include all concepts or only set an exclude concept set]: " + ) + excludeCovariateConceptSetId <- collect_optional_single_id( + excludeCovariateConceptSetId %||% cached_exclude_covariate_id, + "Covariate exclude concept set", + "Covariate exclude concept set ID [optional]: " + ) + include_all_covariates <- is.null(includeCovariateConceptSetId) + } else { + includeCovariateConceptSetId <- NULL + excludeCovariateConceptSetId <- NULL + } + + default_analytic_settings <- .studyAgentDefaultCohortMethodAnalyticSettings( + covariate_enabled = covariate_enabled + ) + + cached_get_db <- cached_analytics$get_db_cohort_method_data %||% list() + cached_study_pop <- cached_analytics$create_study_population %||% list() + cached_ps <- cached_analytics$create_ps %||% list() + cached_ps_adjustment <- cached_analytics$ps_adjustment %||% list() + cached_match <- cached_analytics$match_on_ps %||% list() + cached_stratify <- cached_analytics$stratify_by_ps %||% list() + cached_outcome_model <- cached_analytics$fit_outcome_model %||% list() + cached_covariates <- cached_analytics$covariate_concept_sets %||% list() + + merge_or_default <- function(default_value, cache_value) { + if (is.null(cache_value) || (is.numeric(cache_value) && length(cache_value) == 0)) { + default_value + } else { + cache_value + } + } + + effective_analytic_settings <- list( + profile_name = merge_or_default(default_analytic_settings$profile_name, cached_analytics$profile_name), + source = "manual_shell", + customized_sections = character(0), + get_db_cohort_method_data = list( + studyStartDate = merge_or_default( + default_analytic_settings$get_db_cohort_method_data$studyStartDate, + cached_get_db$studyStartDate + ), + studyEndDate = merge_or_default( + default_analytic_settings$get_db_cohort_method_data$studyEndDate, + cached_get_db$studyEndDate + ), + firstExposureOnly = merge_or_default( + default_analytic_settings$get_db_cohort_method_data$firstExposureOnly, + cached_get_db$firstExposureOnly + ), + washoutPeriod = as.integer(merge_or_default( + default_analytic_settings$get_db_cohort_method_data$washoutPeriod, + cached_get_db$washoutPeriod + )), + restrictToCommonPeriod = isTRUE(cached_get_db$restrictToCommonPeriod %||% default_analytic_settings$get_db_cohort_method_data$restrictToCommonPeriod), + removeDuplicateSubjects = merge_or_default( + default_analytic_settings$get_db_cohort_method_data$removeDuplicateSubjects, + cached_get_db$removeDuplicateSubjects + ) + ), + create_study_population = list( + maxCohortSize = as.integer(merge_or_default( + default_analytic_settings$create_study_population$maxCohortSize, + cached_study_pop$maxCohortSize + )), + removeDuplicateSubjects = merge_or_default( + default_analytic_settings$create_study_population$removeDuplicateSubjects, + cached_study_pop$removeDuplicateSubjects + ), + removeSubjectsWithPriorOutcome = isTRUE(cached_study_pop$removeSubjectsWithPriorOutcome %||% + default_analytic_settings$create_study_population$removeSubjectsWithPriorOutcome), + priorOutcomeLookback = as.integer(merge_or_default( + default_analytic_settings$create_study_population$priorOutcomeLookback, + cached_study_pop$priorOutcomeLookback + )), + minDaysAtRisk = as.integer(merge_or_default( + default_analytic_settings$create_study_population$minDaysAtRisk, + cached_study_pop$minDaysAtRisk + )), + riskWindowStart = as.integer(merge_or_default( + default_analytic_settings$create_study_population$riskWindowStart, + cached_study_pop$riskWindowStart + )), + startAnchor = merge_or_default( + default_analytic_settings$create_study_population$startAnchor, + cached_study_pop$startAnchor + ), + riskWindowEnd = as.integer(merge_or_default( + default_analytic_settings$create_study_population$riskWindowEnd, + cached_study_pop$riskWindowEnd + )), + endAnchor = merge_or_default( + default_analytic_settings$create_study_population$endAnchor, + cached_study_pop$endAnchor + ), + censorAtNewRiskWindow = isTRUE(cached_study_pop$censorAtNewRiskWindow %||% + default_analytic_settings$create_study_population$censorAtNewRiskWindow) + ), + create_ps = list( + estimator = merge_or_default( + default_analytic_settings$create_ps$estimator, + cached_ps$estimator + ), + maxCohortSizeForFitting = as.integer(merge_or_default( + default_analytic_settings$create_ps$maxCohortSizeForFitting, + cached_ps$maxCohortSizeForFitting + )), + errorOnHighCorrelation = isTRUE(cached_ps$errorOnHighCorrelation %||% default_analytic_settings$create_ps$errorOnHighCorrelation), + useRegularization = isTRUE(cached_ps$useRegularization %||% default_analytic_settings$create_ps$useRegularization) + ), + ps_adjustment = list( + strategy = merge_or_default( + default_analytic_settings$ps_adjustment$strategy, + cached_ps_adjustment$strategy + ), + trimmingStrategy = merge_or_default( + default_analytic_settings$ps_adjustment$trimmingStrategy, + cached_ps_adjustment$trimmingStrategy + ), + trimmingPercent = as.numeric(merge_or_default( + default_analytic_settings$ps_adjustment$trimmingPercent, + cached_ps_adjustment$trimmingPercent + )), + equipoiseLowerBound = as.numeric(merge_or_default( + default_analytic_settings$ps_adjustment$equipoiseLowerBound, + cached_ps_adjustment$equipoiseLowerBound + )), + equipoiseUpperBound = as.numeric(merge_or_default( + default_analytic_settings$ps_adjustment$equipoiseUpperBound, + cached_ps_adjustment$equipoiseUpperBound + )) + ), + match_on_ps = list( + caliper = as.numeric(merge_or_default( + default_analytic_settings$match_on_ps$caliper, + cached_match$caliper + )), + caliperScale = merge_or_default( + default_analytic_settings$match_on_ps$caliperScale, + cached_match$caliperScale + ), + maxRatio = as.integer(merge_or_default( + default_analytic_settings$match_on_ps$maxRatio, + cached_match$maxRatio + )) + ), + stratify_by_ps = list( + numberOfStrata = as.integer(merge_or_default( + default_analytic_settings$stratify_by_ps$numberOfStrata, + cached_stratify$numberOfStrata + )), + baseSelection = merge_or_default( + default_analytic_settings$stratify_by_ps$baseSelection, + cached_stratify$baseSelection + ) + ), + fit_outcome_model = list( + modelType = merge_or_default( + default_analytic_settings$fit_outcome_model$modelType, + cached_outcome_model$modelType + ), + stratified = isTRUE(cached_outcome_model$stratified %||% default_analytic_settings$fit_outcome_model$stratified), + useCovariates = isTRUE(cached_outcome_model$useCovariates %||% default_analytic_settings$fit_outcome_model$useCovariates), + inversePtWeighting = isTRUE(cached_outcome_model$inversePtWeighting %||% default_analytic_settings$fit_outcome_model$inversePtWeighting), + useRegularization = isTRUE(cached_outcome_model$useRegularization %||% default_analytic_settings$fit_outcome_model$useRegularization) + ), + covariate_concept_sets = list( + enabled = isTRUE(cached_covariates$enabled %||% covariate_enabled), + include_all_concepts = isTRUE(cached_covariates$include_all_concepts %||% include_all_covariates %||% TRUE), + include_concept_set_id = json_int_or_null(merge_or_default(cached_covariates$include_concept_set_id, includeCovariateConceptSetId)), + exclude_concept_set_id = json_int_or_null(merge_or_default(cached_covariates$exclude_concept_set_id, excludeCovariateConceptSetId)) + ) + ) + + has_function_argument_description <- !is.null(analyticSettingsDescription) || !is.null(analytic_settings_description_path_resolved) + cached_mode <- as.character(cached_inputs$analytic_settings_mode %||% if (has_function_argument_description) "free_text" else "step_by_step") + analytic_settings_mode <- if (isTRUE(interactive)) { + mode_default <- if (has_function_argument_description || identical(cached_mode, "free_text")) "free-text" else "step-by-step" + cat("\nHow would you like to configure analytic settings?\n") + cat(" 1. Step-by-step\n") + cat(" Walk through the required analytic settings sections in order.\n") + cat(" In the current stage, the shell walks the section flow and shows the OHDSI defaults for the remaining sub-settings.\n") + cat(" 2. Free-text\n") + cat(" Describe the analytic settings you want in natural language.\n") + cat(" The shell will create a dummy recommendation JSON, show the proposed key/value pairs, and ask you to confirm.\n") + mode_choice <- collect_choice_value( + value = mode_default, + label = "Analytic settings configuration mode", + choices = c("step-by-step", "free-text"), + prompt = "Choose analytic settings mode by number.", + default = mode_default + ) + if (identical(mode_choice, "free-text")) "free_text" else "step_by_step" + } else if (has_function_argument_description || + (identical(cached_mode, "free_text") && + (nzchar(trimws(as.character(cached_inputs$analytic_settings_description %||% ""))) || + nzchar(trimws(as.character(cached_inputs$analytic_settings_description_path %||% "")))))) { + "free_text" + } else { + "step_by_step" + } + analytic_settings_selection_source <- if (isTRUE(interactive)) "manual_prompt" else if (!is.null(cached_inputs$analytic_settings_mode)) "cached" else "default_non_interactive" + analytic_settings_input_method <- if (identical(analytic_settings_mode, "free_text")) { + as.character(cached_inputs$analytic_settings_input_method %||% "typed_text") + } else { + "step_by_step" + } + analytic_settings_description <- cached_inputs$analytic_settings_description %||% NULL + analytic_settings_description_path <- cached_inputs$analytic_settings_description_path %||% NULL + analytic_settings_recommendation_source <- as.character(cached_inputs$analytic_settings_recommendation_source %||% if (identical(analytic_settings_mode, "free_text")) "pending" else "not_applicable") + analytic_settings_acp_response_path <- json_string_or_null(cached_inputs$analytic_settings_acp_response_path) + analytic_settings_recommendation_path <- json_string_or_null(cached_inputs$analytic_settings_recommendation_path) + analytic_settings_recommendation_status <- as.character(cached_inputs$analytic_settings_recommendation_status %||% if (identical(analytic_settings_mode, "free_text")) "pending" else "not_applicable") + analytic_settings_confirmed <- isTRUE(cached_inputs$analytic_settings_confirmed %||% FALSE) + analytic_settings_section_flow <- c("study_population", "time_at_risk", "propensity_score_adjustment", "outcome_model") + + effective_analytic_settings$covariate_concept_sets$include_all_concepts <- isTRUE(!isTRUE(covariate_enabled)) || + isTRUE(include_all_covariates) + + if (identical(analytic_settings_mode, "step_by_step")) { + if (isTRUE(interactive)) { + cat("\nAnalytic settings mode: step-by-step\n") + cat("The shell will collect each required section in order and ask for the analytic settings profile name last.\n") + } + + set_dialogue_context( + "analytic_settings_step_by_step", + "analytic_settings", + context = list( + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statements = outcomeStatements, + comparison_label = comparisonLabel + ) + ) + + step_by_step_io <- list( + section_header = function(label) { + set_dialogue_context( + "analytic_settings_step_by_step", + "analytic_settings", + context = list( + section = label, + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statements = outcomeStatements, + comparison_label = comparisonLabel + ) + ) + cat(sprintf("\n[%s]\n", label)) + }, + text = function(prompt, default = "", allow_blank = FALSE) { + entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, default))) + if (!nzchar(entered)) { + if (isTRUE(allow_blank)) return(default) + return(default) + } + entered + }, + yesno = function(prompt, default = TRUE) { + prompt_yesno_strict(prompt, default = default) + }, + choice = function(prompt, choices, default, labels = choices) { + default_index <- match(default, choices) + if (is.na(default_index)) default_index <- 1L + selected_label <- collect_choice_value( + value = labels[[default_index]], + label = prompt, + choices = labels, + prompt = prompt, + default = labels[[default_index]] + ) + choices[[match(selected_label, labels)]] + }, + integer = function(prompt, default, min_value = NULL, allow_negative = TRUE) { + repeat { + value <- prompt_integer( + prompt = prompt, + default = default, + allow_null = FALSE, + must_be_positive = FALSE, + allow_negative = allow_negative + ) + if (!is.null(min_value) && value < min_value) { + cat(sprintf("Please enter an integer >= %s.\n", min_value)) + next + } + return(value) + } + }, + numeric = function(prompt, default, min_value = NULL) { + repeat { + value <- prompt_numeric( + prompt = prompt, + default = default, + must_be_positive = FALSE + ) + if (!is.null(min_value) && value < min_value) { + cat(sprintf("Please enter a number >= %s.\n", min_value)) + next + } + return(value) + } + } + ) + step_by_step_result <- .studyAgentCollectStepByStepAnalyticSettings( + default_settings = default_analytic_settings, + seed_settings = effective_analytic_settings, + interactive = interactive, + io = step_by_step_io + ) + effective_analytic_settings <- step_by_step_result$settings + analytic_settings_section_flow <- step_by_step_result$section_flow + + analytic_settings_description <- NULL + analytic_settings_description_path <- NULL + analytic_settings_recommendation_source <- "not_applicable" + analytic_settings_acp_response_path <- NA_character_ + analytic_settings_recommendation_path <- NA_character_ + analytic_settings_recommendation_status <- "not_applicable" + analytic_settings_confirmed <- TRUE + } else { + repeat { + if (!is.null(analyticSettingsDescription)) { + analytic_settings_input_method <- "function_argument_text" + analytic_settings_description <- analyticSettingsDescription + analytic_settings_description_path <- NULL + } else if (!is.null(analytic_settings_description_path_resolved)) { + if (!file.exists(analytic_settings_description_path_resolved)) { + stop(sprintf("Analytic settings description file not found: %s", analytic_settings_description_path_resolved)) + } + file_lines <- readLines(analytic_settings_description_path_resolved, warn = FALSE) + analytic_settings_description <- trimws(paste(file_lines, collapse = "\n")) + if (!nzchar(analytic_settings_description)) { + stop(sprintf("Analytic settings description file is empty: %s", analytic_settings_description_path_resolved)) + } + analytic_settings_input_method <- "function_argument_path" + analytic_settings_description_path <- analytic_settings_description_path_resolved + } else if (!is.null(analytic_settings_description) && nzchar(trimws(as.character(analytic_settings_description)))) { + analytic_settings_description <- trimws(as.character(analytic_settings_description)) + analytic_settings_input_method <- as.character(cached_inputs$analytic_settings_input_method %||% "typed_text") + } else if (!is.null(analytic_settings_description_path) && nzchar(trimws(as.character(analytic_settings_description_path)))) { + cached_description_path <- normalizePath(resolve_path(as.character(analytic_settings_description_path), study_base_dir), winslash = "/", mustWork = FALSE) + if (!file.exists(cached_description_path)) { + stop(sprintf("Cached analytic settings description file not found: %s", cached_description_path)) + } + file_lines <- readLines(cached_description_path, warn = FALSE) + analytic_settings_description <- trimws(paste(file_lines, collapse = "\n")) + if (!nzchar(analytic_settings_description)) { + stop(sprintf("Cached analytic settings description file is empty: %s", cached_description_path)) + } + analytic_settings_input_method <- "cached_path" + analytic_settings_description_path <- cached_description_path + } else if (isTRUE(interactive)) { + analytic_settings_description <- prompt_non_null_text( + "Study description for analytic settings", + default = analytic_settings_description + ) + analytic_settings_input_method <- "typed_text" + analytic_settings_description_path <- NULL + } else { + stop("Free-text analytic settings mode requires `analyticSettingsDescription`, `analyticSettingsDescriptionPath`, or a cached description in non-interactive runs.") + } + + acp_request_body <- list( + study_intent = studyIntent, + study_description = analytic_settings_description, + analytic_settings_description = analytic_settings_description + ) + if (isTRUE(interactive)) { + cat("Calling ACP flow: cohort_methods_specifications_recommendation\n") + } else { + message("Calling ACP flow: cohort_methods_specifications_recommendation") + } + + ensure_acp_ready(acpUrl) + shell_suggestion_response <- tryCatch( + suggestCohortMethodSpecs( + studyIntent = acp_request_body$study_intent, + analyticSettingsDescription = acp_request_body$analytic_settings_description, + interactive = FALSE + ), + error = function(e) { + list( + source = "stub_no_acp", + status = "stub", + error = conditionMessage(e), + message = "ACP flow failed. Returning placeholder cohort methods specifications recommendation.", + request = acp_request_body + ) + } + ) + recommendation <- shell_suggestion_response$recommendation %||% + shell_suggestion_response$response$recommendation %||% + shell_suggestion_response$response$recommendations %||% + shell_suggestion_response$response$cohort_methods_specifications_recommendation %||% + shell_suggestion_response$cohort_methods_specifications_recommendation %||% + build_dummy_analytic_settings_recommendation( + description = acp_request_body$analytic_settings_description, + defaults_snapshot = effective_analytic_settings, + input_method = analytic_settings_input_method + ) + if (is.null(recommendation$defaults_snapshot) || length(recommendation$defaults_snapshot) == 0) { + recommendation$defaults_snapshot <- effective_analytic_settings + } + recommendation_source <- as.character( + if (identical(shell_suggestion_response$source, "stub_no_acp")) "stub_acp_placeholder" + else shell_suggestion_response$source %||% "acp_flow" + ) + acp_specifications_response <- list( + flow = "cohort_methods_specifications_recommendation", + source = recommendation_source, + status = shell_suggestion_response$status %||% "received", + request = acp_request_body, + response = shell_suggestion_response, + recommendation = recommendation + ) + + write_json(acp_specifications_response, cm_acp_specifications_recommendation_path) + analytic_settings_acp_response_path <- cm_acp_specifications_recommendation_path + analytic_settings_recommendation_source <- as.character(acp_specifications_response$source %||% "unknown") + analytic_settings_recommendation <- recommendation + write_json(analytic_settings_recommendation, cm_analytic_settings_recommendation_path) + analytic_settings_recommendation_path <- cm_analytic_settings_recommendation_path + + if (isTRUE(interactive)) { + print_analytic_settings_recommendation_preview( + acp_response = acp_specifications_response, + recommendation = analytic_settings_recommendation, + recommendation_path = analytic_settings_recommendation_path, + acp_response_path = analytic_settings_acp_response_path + ) + analytic_settings_confirmed <- FALSE + } else { + analytic_settings_confirmed <- isTRUE(cached_inputs$analytic_settings_confirmed %||% TRUE) + } + + if (identical(analytic_settings_recommendation_source, "acp_flow") && + identical(as.character(acp_specifications_response$response$status %||% acp_specifications_response$status %||% "unknown"), "ok")) { + recommendation_defaults_snapshot <- analytic_settings_recommendation$defaults_snapshot + if (is.null(recommendation_defaults_snapshot) || length(recommendation_defaults_snapshot) == 0) { + recommendation_defaults_snapshot <- effective_analytic_settings + } + effective_analytic_settings <- shell_settings_from_acp_recommendation( + analytic_settings_recommendation, + recommendation_defaults_snapshot + ) + } else { + effective_analytic_settings$profile_name <- as.character( + analytic_settings_recommendation$profile_name %||% effective_analytic_settings$profile_name + ) + } + analytic_settings_flow_status <- as.character( + acp_specifications_response$response$status %||% acp_specifications_response$status %||% "unknown" + ) + analytic_settings_recommendation_status <- if ( + identical(analytic_settings_recommendation_source, "acp_flow") && + identical(analytic_settings_flow_status, "ok") + ) { + if (isTRUE(analytic_settings_confirmed)) "confirmed_via_acp" else "received_from_acp" + } else if (identical(analytic_settings_recommendation_source, "acp_flow")) { + if (isTRUE(analytic_settings_confirmed)) "confirmed_acp_fallback" else "received_acp_fallback" + } else { + if (isTRUE(analytic_settings_confirmed)) "stub_fallback" else "stub_generated" + } + break + } + } + + if (isTRUE(interactive)) { + effective_analytic_settings <- review_analytic_settings_interactively(effective_analytic_settings) + analytic_settings_confirmed <- TRUE + } + + effective_analytic_settings$customized_sections <- names(.studyAgentAnalyticSettingsSectionPaths())[vapply( + names(.studyAgentAnalyticSettingsSectionPaths()), + function(section_name) { + paths <- .studyAgentAnalyticSettingsSectionPaths()[[section_name]] + any(vapply(paths, function(path) { + !identical( + .studyAgentGetNestedValue(effective_analytic_settings, path), + .studyAgentGetNestedValue(default_analytic_settings, path) + ) + }, logical(1))) + }, + logical(1) + )] + + effective_analytic_settings <- normalize_analytic_settings(effective_analytic_settings) + covariate_enabled <- isTRUE(effective_analytic_settings$covariate_concept_sets$enabled) + include_all_covariates <- isTRUE(effective_analytic_settings$covariate_concept_sets$include_all_concepts) + includeCovariateConceptSetId <- if (is.na(effective_analytic_settings$covariate_concept_sets$include_concept_set_id)) { + NULL + } else { + as.integer(effective_analytic_settings$covariate_concept_sets$include_concept_set_id) + } + excludeCovariateConceptSetId <- if (is.na(effective_analytic_settings$covariate_concept_sets$exclude_concept_set_id)) { + NULL + } else { + as.integer(effective_analytic_settings$covariate_concept_sets$exclude_concept_set_id) + } + + manual_intent <- list( + source = cohort_methods_intent_split_source, + intent_split_status = cohort_methods_intent_split_status, + intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), + explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), + skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), + skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), + skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), + skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statement = outcomeStatement, + outcome_statements = as.list(outcomeStatements) + ) + write_json(manual_intent, manual_intent_path) + + manual_inputs <- list( + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statement = outcomeStatement, + outcome_statements = as.list(outcomeStatements), + cohort_methods_intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), + cohort_methods_intent_split_source = cohort_methods_intent_split_source, + cohort_methods_intent_split_status = cohort_methods_intent_split_status, + explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), + skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), + skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), + skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), + skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", + use_function_argument_ids_for_selection = isTRUE(use_function_argument_ids_for_selection), + comparison_label = comparisonLabel, + target_cohort_id = as.integer(targetCohortId), + comparator_cohort_id = as.integer(comparatorCohortId), + outcome_cohort_ids = as.integer(outcomeCohortIds), + outcome_cohort_statements = as.list(outcomeStatementsForSelectedCohorts), + target_recommendation = list( + statement = targetStatement, + path = json_string_or_null(target_rec$recommendation_path), + source = target_rec$recommendation_source, + selection_source = target_rec$selection_source, + used_cached_recommendation = isTRUE(target_rec$used_cached_recommendation), + used_cached_selection = isTRUE(target_rec$used_cached_selection), + used_window2 = isTRUE(target_rec$used_window2), + used_advice = isTRUE(target_rec$used_advice) + ), + comparator_recommendation = list( + statement = comparatorStatement, + path = json_string_or_null(comparator_rec$recommendation_path), + source = comparator_rec$recommendation_source, + selection_source = comparator_rec$selection_source, + used_cached_recommendation = isTRUE(comparator_rec$used_cached_recommendation), + used_cached_selection = isTRUE(comparator_rec$used_cached_selection), + used_window2 = isTRUE(comparator_rec$used_window2), + used_advice = isTRUE(comparator_rec$used_advice) + ), + outcome_recommendation = list( + statement = outcomeStatement, + path = json_string_or_null(outcome_rec$recommendation_path), + source = outcome_rec$recommendation_source, + selection_source = outcome_rec$selection_source, + used_cached_recommendation = isTRUE(outcome_rec$used_cached_recommendation), + used_cached_selection = isTRUE(outcome_rec$used_cached_selection), + used_window2 = isTRUE(outcome_rec$used_window2), + used_advice = isTRUE(outcome_rec$used_advice) + ), + outcome_recommendations = outcome_recommendations, + negative_control_enabled = isTRUE(negative_control_enabled), + negative_control_concept_set_id = json_int_or_null(negativeControlConceptSetId), + covariate_concept_sets_enabled = isTRUE(covariate_enabled), + covariate_include_all_concepts = isTRUE(include_all_covariates), + covariate_include_concept_set_id = json_int_or_null(includeCovariateConceptSetId), + covariate_exclude_concept_set_id = json_int_or_null(excludeCovariateConceptSetId), + target_name = target_name, + comparator_name = comparator_name, + outcome_names = as.list(outcome_names), + target_original_name = target_original_name, + comparator_original_name = comparator_original_name, + outcome_original_names = as.list(outcome_original_names), + target_analysis_label = target_name, + comparator_analysis_label = comparator_name, + outcome_analysis_labels = as.list(outcome_names), + target_description = target_desc, + comparator_description = comparator_desc, + outcome_descriptions = as.list(outcome_descs), + customized_sections = as.list(effective_analytic_settings$customized_sections), + analytic_settings_mode = analytic_settings_mode, + analytic_settings_selection_source = analytic_settings_selection_source, + analytic_settings_input_method = analytic_settings_input_method, + analytic_settings_description = json_string_or_null(analytic_settings_description), + analytic_settings_description_path = json_string_or_null(analytic_settings_description_path), + analytic_settings_recommendation_source = analytic_settings_recommendation_source, + analytic_settings_acp_response_path = json_string_or_null(analytic_settings_acp_response_path), + analytic_settings_recommendation_path = json_string_or_null(analytic_settings_recommendation_path), + analytic_settings_recommendation_status = analytic_settings_recommendation_status, + analytic_settings_confirmed = isTRUE(analytic_settings_confirmed), + analytic_settings_section_flow = as.list(analytic_settings_section_flow), + cm_analysis_json_path = cm_analysis_json_path, + cm_analysis_template_path = json_string_or_null(cm_analysis_template_path), + remap_cohort_ids = use_mapping, + cohort_id_base = cohortIdBase + ) + manual_inputs$analytic_settings <- effective_analytic_settings + write_json(manual_inputs, manual_inputs_path) + + improvements_applied <- any(vapply(improvements_results, function(x) isTRUE(x$applied), logical(1))) + if (isTRUE(improvements_applied)) { + ensure_complete_patched_role <- function(selected_role_dir, patched_role_dir, cohort_ids) { + ensure_dir(patched_role_dir) + ensure_dir(patched_dir) + for (cid in as.integer(cohort_ids)) { + role_path <- file.path(patched_role_dir, sprintf("%s.json", cid)) + selected_path <- file.path(selected_role_dir, sprintf("%s.json", cid)) + combined_path <- file.path(patched_dir, sprintf("%s.json", cid)) + if (!file.exists(role_path) && file.exists(selected_path)) { + file.copy(selected_path, role_path, overwrite = TRUE) + } + source_path <- if (file.exists(role_path)) role_path else selected_path + if (file.exists(source_path)) { + file.copy(source_path, combined_path, overwrite = TRUE) + } + } + } + ensure_complete_patched_role(selected_target_dir, patched_target_dir, new_target_id) + ensure_complete_patched_role(selected_comparator_dir, patched_comparator_dir, new_comparator_id) + ensure_complete_patched_role(selected_outcome_dir, patched_outcome_dir, new_outcome_ids) + } + improvements_cache_used <- vapply(improvements_results, function(x) isTRUE(x$used_cache), logical(1)) + improvements_flow_called <- vapply(improvements_results, function(x) isTRUE(x$flow_called), logical(1)) + + cohort_map <- data.frame( + original_id = c(selected_target_id, selected_comparator_id, selected_outcome_ids), + cohort_id = c(new_target_id, new_comparator_id, new_outcome_ids), + role = c("target", "comparator", rep("outcome", length(new_outcome_ids))), + cohort_name = c(target_name, comparator_name, outcome_names), + original_cohort_name = c(target_original_name, comparator_original_name, outcome_original_names), + short_description = c(target_desc, comparator_desc, outcome_descs), + stringsAsFactors = FALSE + ) + write_json(list(mapping = cohort_map), cohort_id_map_path) + + write_json( + list( + comparison_label = comparisonLabel, + targets = as.integer(new_target_id), + comparators = as.integer(new_comparator_id), + outcomes = as.integer(new_outcome_ids) + ), + cohort_roles_path + ) + + cm_comparisons <- list( + comparisons = list( + list( + comparison_id = 1L, + label = comparisonLabel, + study_intent = studyIntent, + target = list( + source_id = as.integer(selected_target_id), + cohort_id = as.integer(new_target_id), + name = target_name, + original_name = target_original_name + ), + comparator = list( + source_id = as.integer(selected_comparator_id), + cohort_id = as.integer(new_comparator_id), + name = comparator_name, + original_name = comparator_original_name + ), + outcomes = lapply(seq_along(new_outcome_ids), function(i) { + list( + source_id = as.integer(selected_outcome_ids[[i]]), + cohort_id = as.integer(new_outcome_ids[[i]]), + name = outcome_names[[i]], + original_name = outcome_original_names[[i]], + statement = outcomeStatementsForSelectedCohorts[[i]] + ) + }) + ) + ) + ) + write_json(cm_comparisons, cm_comparisons_path) + + role_statuses <- vapply(improvements_results, function(x) as.character(x$status %||% "not_run"), character(1)) + improvements_status_value <- if (all(role_statuses %in% c("completed", "not_applicable"))) { + "completed" + } else if (all(role_statuses %in% c("skipped", "not_applicable"))) { + "skipped" + } else if (any(role_statuses %in% c("completed", "completed_with_errors"))) { + "partial" + } else { + "not_run" + } + improvements_status <- list( + status = improvements_status_value, + flow = "phenotype_improvements", + applies_to = c("target", "comparator", "outcome"), + auto_apply = isTRUE(autoApplyImprovements), + applied = isTRUE(improvements_applied), + combined_patched_dir = patched_dir, + role_artifacts = list( + target = improvements_target_path, + comparator = improvements_comparator_path, + outcome = improvements_outcome_path + ), + roles = improvements_results + ) + write_json(improvements_status, improvements_status_path) + + cm_evaluation_todo <- list( + status = "todo", + items = list( + list( + name = "negative_controls", + status = if (isTRUE(negative_control_enabled)) "dummy_selected" else "todo", + enabled = isTRUE(negative_control_enabled), + concept_set_id = json_int_or_null(negativeControlConceptSetId), + source = json_string_or_null(if (isTRUE(negative_control_enabled)) "manual_shell" else NULL) + ), + list(name = "positive_control_synthesis", status = "todo"), + list(name = "empirical_calibration", status = "todo") + ), + note = "Current stage only scaffolds CohortMethod execution for outcomes of interest." + ) + write_json(cm_evaluation_todo, cm_evaluation_todo_path) + + create_dummy_concept_set <- function(path, concept_set_id, label) { + if (is.null(concept_set_id)) return(NULL) + payload <- list( + conceptSetId = as.integer(concept_set_id), + name = sprintf("Dummy %s %s", label, concept_set_id), + expression = list(items = list()), + note = "Placeholder only. Replace this dummy concept set with real concept set content in a later stage." + ) + write_json(payload, path) + path + } + + negative_control_path <- create_dummy_concept_set( + file.path(concept_sets_dir, "negative_control_concept_set.json"), + negativeControlConceptSetId, + "negative control concept set" + ) + covariate_include_path <- create_dummy_concept_set( + file.path(concept_sets_dir, "covariate_include_concept_set.json"), + includeCovariateConceptSetId, + "covariate include concept set" + ) + covariate_exclude_path <- create_dummy_concept_set( + file.path(concept_sets_dir, "covariate_exclude_concept_set.json"), + excludeCovariateConceptSetId, + "covariate exclude concept set" + ) + + cm_concept_set_selections <- list( + negative_control = list( + enabled = isTRUE(negative_control_enabled), + concept_set_id = json_int_or_null(negativeControlConceptSetId), + artifact_path = json_string_or_null(negative_control_path), + status = if (isTRUE(negative_control_enabled)) "dummy_selected" else "not_selected" + ), + covariates = list( + enabled = isTRUE(covariate_enabled), + include_all_concepts = isTRUE(include_all_covariates), + include = list( + concept_set_id = json_int_or_null(includeCovariateConceptSetId), + artifact_path = json_string_or_null(covariate_include_path) + ), + exclude = list( + concept_set_id = json_int_or_null(excludeCovariateConceptSetId), + artifact_path = json_string_or_null(covariate_exclude_path) + ), + status = if (isTRUE(covariate_enabled)) "dummy_selected" else "not_selected" + ), + note = "Concept set IDs are manual placeholders in the current R-only stage." + ) + write_json(cm_concept_set_selections, cm_concept_set_selections_path) + + cm_defaults <- list( + analysis_id = 1L, + description = effective_analytic_settings$profile_name, + profile_name = effective_analytic_settings$profile_name, + source = "manual_shell", + mode = analytic_settings_mode, + input_method = analytic_settings_input_method, + recommendation_path = json_string_or_null(analytic_settings_recommendation_path), + customized_sections = effective_analytic_settings$customized_sections, + get_db_cohort_method_data = effective_analytic_settings$get_db_cohort_method_data, + create_study_population = effective_analytic_settings$create_study_population, + create_ps = effective_analytic_settings$create_ps, + ps_adjustment = effective_analytic_settings$ps_adjustment, + match_on_ps = effective_analytic_settings$match_on_ps, + stratify_by_ps = effective_analytic_settings$stratify_by_ps, + fit_outcome_model = effective_analytic_settings$fit_outcome_model, + covariate_concept_sets = effective_analytic_settings$covariate_concept_sets + ) + cm_defaults$covariate_concept_sets$enabled <- isTRUE(effective_analytic_settings$covariate_concept_sets$enabled) + cm_defaults$covariate_concept_sets$note <- "Placeholder only. Dummy concept set IDs are captured for future concept set materialization." + cm_defaults$get_db_cohort_method_data$removeDuplicateSubjects <- as.character(cm_defaults$get_db_cohort_method_data$removeDuplicateSubjects) + cm_defaults$create_study_population$removeDuplicateSubjects <- as.character(cm_defaults$create_study_population$removeDuplicateSubjects) + cm_defaults$cm_analysis_json_path <- cm_analysis_json_path + write_json(cm_defaults, cm_defaults_path) + + cm_analysis_template <- .studyAgentLoadCmAnalysisTemplate(cm_analysis_template_path) + cm_analysis_json <- .studyAgentBuildCmAnalysisJson( + settings = effective_analytic_settings, + template = cm_analysis_template + ) + write_json(cm_analysis_json, cm_analysis_json_path) + + cohort_rows <- list( + data.frame( + atlas_id = selected_target_id, + cohort_id = new_target_id, + cohort_name = target_name, + cohort_type = "target", + logic_description = if (nzchar(target_desc)) target_desc else "Manual target cohort selection", + generate_stats = TRUE, + stringsAsFactors = FALSE + ), + data.frame( + atlas_id = selected_comparator_id, + cohort_id = new_comparator_id, + cohort_name = comparator_name, + cohort_type = "comparator", + logic_description = if (nzchar(comparator_desc)) comparator_desc else "Manual comparator cohort selection", + generate_stats = TRUE, + stringsAsFactors = FALSE + ) + ) + if (length(new_outcome_ids) > 0) { + for (i in seq_along(new_outcome_ids)) { + cohort_rows[[length(cohort_rows) + 1]] <- data.frame( + atlas_id = selected_outcome_ids[[i]], + cohort_id = new_outcome_ids[[i]], + cohort_name = outcome_names[[i]], + cohort_type = "outcome", + logic_description = if (nzchar(outcome_descs[[i]])) outcome_descs[[i]] else "Manual outcome cohort selection", + generate_stats = TRUE, + stringsAsFactors = FALSE + ) + } + } + cohort_df <- do.call(rbind, cohort_rows) + cohort_csv <- file.path(selected_dir, "Cohorts.csv") + write.csv(cohort_df, cohort_csv, row.names = FALSE) + + state <- list( + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statement = outcomeStatement, + outcome_statements = as.list(outcomeStatements), + outcome_cohort_statements = as.list(outcomeStatementsForSelectedCohorts), + comparison_label = comparisonLabel, + target_analysis_label = target_name, + comparator_analysis_label = comparator_name, + outcome_analysis_labels = as.list(outcome_names), + target_original_name = target_original_name, + comparator_original_name = comparator_original_name, + outcome_original_names = as.list(outcome_original_names), + output_dir = output_dir, + selected_dir = selected_dir, + patched_dir = patched_dir, + selected_target_dir = selected_target_dir, + selected_comparator_dir = selected_comparator_dir, + selected_outcome_dir = selected_outcome_dir, + patched_target_dir = patched_target_dir, + patched_comparator_dir = patched_comparator_dir, + patched_outcome_dir = patched_outcome_dir, + keeper_dir = keeper_dir, + analysis_settings_dir = analysis_settings_dir, + scripts_dir = scripts_dir, + cm_results_dir = cm_results_dir, + cm_diagnostics_dir = cm_diagnostics_dir, + cm_data_dir = cm_data_dir, + manual_intent_path = manual_intent_path, + manual_inputs_path = manual_inputs_path, + cohort_methods_intent_split_path = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), + cohort_id_map_path = cohort_id_map_path, + cohort_roles_path = cohort_roles_path, + cm_comparisons_path = cm_comparisons_path, + improvements_status_path = improvements_status_path, + improvements_target_path = improvements_target_path, + improvements_comparator_path = improvements_comparator_path, + improvements_outcome_path = improvements_outcome_path, + improvements_paths = list( + target = improvements_target_path, + comparator = improvements_comparator_path, + outcome = improvements_outcome_path, + status = improvements_status_path + ), + improvements_cache_used = as.list(improvements_cache_used), + improvements_flow_called = as.list(improvements_flow_called), + improvements_applied = isTRUE(improvements_applied), + improvements_auto_apply = isTRUE(autoApplyImprovements), + improvements_prompt_choices = list( + target = isTRUE(do_target_improvements), + comparator = isTRUE(do_comparator_improvements), + outcome = isTRUE(do_outcome_improvements) + ), + improvements_results = improvements_results, + cm_evaluation_todo_path = cm_evaluation_todo_path, + cm_defaults_path = cm_defaults_path, + cm_analysis_json_path = cm_analysis_json_path, + cm_analysis_template_path = json_string_or_null(cm_analysis_template_path), + cm_acp_specifications_recommendation_path = json_string_or_null(analytic_settings_acp_response_path), + cm_analytic_settings_recommendation_path = json_string_or_null(analytic_settings_recommendation_path), + cm_concept_set_selections_path = cm_concept_set_selections_path, + cohort_csv = cohort_csv, + used_cached_inputs = !is.null(cached_inputs), + cohort_methods_intent_split_source = cohort_methods_intent_split_source, + cohort_methods_intent_split_status = cohort_methods_intent_split_status, + explicit_cohort_ids_supplied = isTRUE(all_cohort_ids_from_function_args), + skip_intent_split_and_recommendation = isTRUE(skip_intent_split_and_recommendation), + skip_phenotype_improvements = isTRUE(skip_intent_split_and_recommendation), + skip_reason = json_string_or_null(if (isTRUE(skip_intent_split_and_recommendation)) "all_cohort_ids_provided" else NULL), + skip_prompt_source = if (isTRUE(all_cohort_ids_from_function_args) && isTRUE(interactive)) "interactive_user_choice" else "not_prompted", + use_function_argument_ids_for_selection = isTRUE(use_function_argument_ids_for_selection), + resume_enabled = isTRUE(resume), + remap_cohort_ids = use_mapping, + cohort_id_base = cohortIdBase, + analytic_settings_mode = analytic_settings_mode, + analytic_settings_selection_source = analytic_settings_selection_source, + analytic_settings_input_method = analytic_settings_input_method, + analytic_settings_description = json_string_or_null(analytic_settings_description), + analytic_settings_description_path = json_string_or_null(analytic_settings_description_path), + analytic_settings_recommendation_source = analytic_settings_recommendation_source, + analytic_settings_acp_response_path = json_string_or_null(analytic_settings_acp_response_path), + analytic_settings_recommendation_status = analytic_settings_recommendation_status, + analytic_settings_confirmed = isTRUE(analytic_settings_confirmed), + analytic_settings_section_flow = as.list(analytic_settings_section_flow), + analytic_settings_profile_name = effective_analytic_settings$profile_name, + analytic_settings_customized_sections = as.character(effective_analytic_settings$customized_sections), + analytic_settings = effective_analytic_settings, + negative_control_enabled = isTRUE(negative_control_enabled), + negative_control_concept_set_id = json_int_or_null(negativeControlConceptSetId), + covariate_concept_sets_enabled = isTRUE(covariate_enabled), + covariate_include_all_concepts = isTRUE(include_all_covariates), + covariate_include_concept_set_id = json_int_or_null(includeCovariateConceptSetId), + covariate_exclude_concept_set_id = json_int_or_null(excludeCovariateConceptSetId), + target_recommendation_path = json_string_or_null(target_rec$recommendation_path), + comparator_recommendation_path = json_string_or_null(comparator_rec$recommendation_path), + outcome_recommendation_path = json_string_or_null(outcome_rec$recommendation_path), + outcome_recommendation_paths = as.list(vapply( + outcome_recommendations, + function(rec) as.character(rec$path %||% NA_character_), + character(1) + )), + target_recommendation_source = target_rec$recommendation_source, + comparator_recommendation_source = comparator_rec$recommendation_source, + outcome_recommendation_source = outcome_rec$recommendation_source, + target_selection_source = target_rec$selection_source, + comparator_selection_source = comparator_rec$selection_source, + outcome_selection_source = outcome_rec$selection_source, + target_used_cached_recommendation = isTRUE(target_rec$used_cached_recommendation), + comparator_used_cached_recommendation = isTRUE(comparator_rec$used_cached_recommendation), + outcome_used_cached_recommendation = isTRUE(outcome_rec$used_cached_recommendation), + target_used_cached_selection = isTRUE(target_rec$used_cached_selection), + comparator_used_cached_selection = isTRUE(comparator_rec$used_cached_selection), + outcome_used_cached_selection = isTRUE(outcome_rec$used_cached_selection), + outcome_recommendations = outcome_recommendations, + target_ids = as.integer(new_target_id), + comparator_ids = as.integer(new_comparator_id), + outcome_ids = as.integer(new_outcome_ids) + ) + write_json(state, state_path) + + package_root <- resolve_path("R/slashOhdsiStrategusAssistant", study_base_dir) + if (!dir.exists(package_root)) { + alt <- file.path(getwd(), "R", "slashOhdsiStrategusAssistant") + if (dir.exists(alt)) package_root <- alt + } + package_root <- normalizePath(package_root, winslash = "/", mustWork = FALSE) + + script_header <- c( + "# Generated by the slashOhdsiStrategusAssistant cohort-methods workflow shell", + "# Edit values as needed and run in order.", + "# Current stage: manual shell output with ACP/MCP status artifacts.", + "" + ) + package_loader_lines <- c( + sprintf("package_root <- '%s'", package_root), + "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", + " if (requireNamespace('devtools', quietly = TRUE) && dir.exists(package_root)) {", + " devtools::load_all(package_root)", + " } else {", + " stop('slashOhdsiStrategusAssistant is not installed and devtools::load_all(package_root) is unavailable: ', package_root)", + " }", + "}", + "library(slashOhdsiStrategusAssistant)" + ) + + script_02 <- c( + script_header, + "library(jsonlite)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", + "selected_comparator_dir <- file.path(base_dir, 'selected-comparator-cohorts')", + "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "patched_target_dir <- file.path(base_dir, 'patched-target-cohorts')", + "patched_comparator_dir <- file.path(base_dir, 'patched-comparator-cohorts')", + "patched_outcome_dir <- file.path(base_dir, 'patched-outcome-cohorts')", + "dir.create(patched_dir, recursive = TRUE, showWarnings = FALSE)", + "for (dir_path in c(patched_dir, patched_target_dir, patched_comparator_dir, patched_outcome_dir)) {", + " if (dir.exists(dir_path)) unlink(list.files(dir_path, pattern = '\\\\.(json)$', full.names = TRUE), force = TRUE)", + " sql_dir <- file.path(dir_path, 'sql')", + " if (dir.exists(sql_dir)) unlink(list.files(sql_dir, pattern = '\\\\.(sql)$', full.names = TRUE), force = TRUE)", + "}", + "", + "apply_action <- function(obj, action) {", + " path <- action$path %||% ''", + " value <- action$value", + " if (!nzchar(path)) return(obj)", + " segs <- strsplit(path, '/', fixed = TRUE)[[1]]", + " segs <- segs[segs != '']", + " set_in <- function(x, segs, value) {", + " if (length(segs) == 0) return(value)", + " seg <- segs[[1]]", + " name <- seg", + " idx <- NA_integer_", + " if (grepl('\\\\[\\\\d+\\\\]$', seg)) {", + " name <- sub('\\\\[\\\\d+\\\\]$', '', seg)", + " idx <- as.integer(sub('^.*\\\\[(\\\\d+)\\\\]$', '\\\\1', seg))", + " }", + " if (name != '') {", + " if (is.null(x[[name]])) x[[name]] <- list()", + " if (length(segs) == 1) {", + " if (!is.na(idx)) {", + " while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", + " x[[name]][[idx]] <- value", + " } else {", + " x[[name]] <- value", + " }", + " return(x)", + " }", + " if (!is.na(idx)) {", + " while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", + " x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value)", + " } else {", + " x[[name]] <- set_in(x[[name]], segs[-1], value)", + " }", + " return(x)", + " }", + " idx <- suppressWarnings(as.integer(seg))", + " if (is.na(idx)) return(x)", + " if (idx == 0) idx <- 1", + " while (length(x) < idx) x[[length(x) + 1]] <- list()", + " if (length(segs) == 1) {", + " x[[idx]] <- value", + " return(x)", + " }", + " x[[idx]] <- set_in(x[[idx]], segs[-1], value)", + " x", + " }", + " set_in(obj, segs, value)", + "}", + "is_mutating_improvement_action <- function(action) {", + " action_type <- tolower(trimws(as.character(action$type %||% 'note')))", + " action_type %in% c('set', 'replace', 'update')", + "}", + "", + "apply_for_role <- function(improvements_path, selected_role_dir, patched_role_dir) {", + " if (!file.exists(improvements_path)) return(invisible(FALSE))", + " improvements <- jsonlite::fromJSON(improvements_path, simplifyVector = FALSE)", + " dir.create(patched_role_dir, recursive = TRUE, showWarnings = FALSE)", + " applied <- FALSE", + " for (cid in names(improvements)) {", + " resp <- improvements[[cid]]", + " core <- resp$full_result %||% resp", + " items <- core$phenotype_improvements %||% list()", + " selected_path <- file.path(selected_role_dir, sprintf('%s.json', cid))", + " if (!file.exists(selected_path)) next", + " cohort_obj <- jsonlite::fromJSON(selected_path, simplifyVector = FALSE)", + " mutation_count <- 0L", + " if (length(items) > 0) {", + " for (item in items) {", + " if (is.null(item$actions)) next", + " for (act in item$actions) {", + " if (is_mutating_improvement_action(act)) {", + " cohort_obj <- apply_action(cohort_obj, act)", + " mutation_count <- mutation_count + 1L", + " }", + " }", + " }", + " }", + " if (mutation_count == 0L) next", + " applied <- TRUE", + " out_path <- file.path(patched_role_dir, sprintf('%s.json', cid))", + " jsonlite::write_json(cohort_obj, out_path, pretty = TRUE, auto_unbox = TRUE, na = 'null')", + " file.copy(out_path, file.path(patched_dir, sprintf('%s.json', cid)), overwrite = TRUE)", + " }", + " invisible(applied)", + "}", + "", + "apply_for_role(file.path(output_dir, 'improvements_target.json'), selected_target_dir, patched_target_dir)", + "apply_for_role(file.path(output_dir, 'improvements_comparator.json'), selected_comparator_dir, patched_comparator_dir)", + "apply_for_role(file.path(output_dir, 'improvements_outcome.json'), selected_outcome_dir, patched_outcome_dir)", + "for (path in list.files(selected_dir, pattern = '\\\\.(json)$', full.names = TRUE)) {", + " dest <- file.path(patched_dir, basename(path))", + " if (!file.exists(dest)) file.copy(path, dest, overwrite = TRUE)", + "}", + "" + ) + write_lines(file.path(scripts_dir, "02_apply_improvements.R"), script_02) + + script_03 <- c( + script_header, + "library(Strategus)", + "library(CohortGenerator)", + "library(DatabaseConnector)", + "library(dplyr)", + "library(CirceR)", + "library(SqlRender)", + "", + package_loader_lines, + "library(jsonlite)", + "library(ParallelLogger)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(cohort_json_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "dbms <- connectionDetails$dbms %||% 'postgresql'", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", + "executionSettings_cohorts <- exec$executionSettings", + "cdmDatabaseSchema <- exec$cdmDatabaseSchema", + "workDatabaseSchema <- exec$workDatabaseSchema", + "resultsDatabaseSchema <- exec$resultsDatabaseSchema", + "vocabularyDatabaseSchema <- exec$vocabularyDatabaseSchema", + "cohortTable <- exec$cohortTable", + "cohortIdFieldName <- exec$cohortIdFieldName", + "dir.create(exec$workFolder, recursive = TRUE, showWarnings = FALSE)", + "dir.create(exec$resultsFolder, recursive = TRUE, showWarnings = FALSE)", + "", + "cohort_settings <- read.csv(cohort_csv, stringsAsFactors = FALSE)", + "if (nrow(cohort_settings) > 0) {", + " id_col <- if ('cohort_id' %in% names(cohort_settings)) 'cohort_id' else 'cohortId'", + " for (i in seq_len(nrow(cohort_settings))) {", + " cohort_id <- cohort_settings[[id_col]][i]", + " sql_path <- file.path(sql_dir, sprintf('%s.sql', cohort_id))", + " if (!file.exists(sql_path)) {", + " json_path <- file.path(cohort_json_dir, sprintf('%s.json', cohort_id))", + " if (!file.exists(json_path)) stop('Missing cohort JSON: ', json_path)", + " json_text <- readChar(json_path, nchars = file.info(json_path)$size, useBytes = TRUE)", + " cohort_expression <- CirceR::cohortExpressionFromJson(json_text)", + " generateOptions <- CirceR::createGenerateOptions(", + " cohortIdFieldName = cohortIdFieldName,", + " cdmSchema = cdmDatabaseSchema,", + " targetTable = paste0(workDatabaseSchema, '.', cohortTable),", + " resultSchema = resultsDatabaseSchema,", + " vocabularySchema = vocabularyDatabaseSchema,", + " generateStats = TRUE", + " )", + " sql <- CirceR::buildCohortQuery(cohort_expression, generateOptions)", + " sql <- SqlRender::render(sql)", + " sql <- SqlRender::translate(sql, targetDialect = dbms)", + " writeLines(sql, sql_path, useBytes = TRUE)", + " }", + " }", + "}", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "cohortGeneratorModuleSpecifications <- cgModule$createModuleSpecifications(generateStats = TRUE)", + "", + "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", + " addSharedResources(cohortDefinitionSharedResource) %>%", + " addModuleSpecifications(cohortGeneratorModuleSpecifications)", + "", + "execute(", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = executionSettings_cohorts,", + " connectionDetails = connectionDetails", + ")", + "" + ) + write_lines(file.path(scripts_dir, "03_generate_cohorts.R"), script_03) + + script_04 <- c( + script_header, + "library(Keeper)", + "library(jsonlite)", + "library(DatabaseConnector)", + "", + package_loader_lines, + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "keeper_dir <- file.path(base_dir, 'keeper-case-review')", + "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", + "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'), simplifyVector = TRUE)$mapping", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", + "databaseId <- ''", + "cdmDatabaseSchema <- exec$cdmDatabaseSchema", + "cohortDatabaseSchema <- exec$workDatabaseSchema", + "cohortTable <- exec$cohortTable", + "", + "# TODO: Replace these placeholder concept vectors with study-specific Keeper settings.", + "keeperConcepts <- list(", + " doi = integer(0),", + " symptoms = integer(0),", + " comorbidities = integer(0),", + " drugs = integer(0),", + " diagnosticProcedures = integer(0),", + " measurements = integer(0),", + " alternativeDiagnosis = integer(0),", + " treatmentProcedures = integer(0),", + " complications = integer(0)", + ")", + "", + "for (i in seq_len(nrow(id_map))) {", + " cid <- id_map$cohort_id[i]", + " role <- id_map$role[i]", + " cohort_name <- id_map$cohort_name[i]", + " role_dir <- file.path(keeper_dir, role)", + " dir.create(role_dir, recursive = TRUE, showWarnings = FALSE)", + " keeper <- createKeeper(", + " connectionDetails = connectionDetails,", + " databaseId = databaseId,", + " cdmDatabaseSchema = cdmDatabaseSchema,", + " cohortDatabaseSchema = cohortDatabaseSchema,", + " cohortTable = cohortTable,", + " cohortDefinitionId = cid,", + " cohortName = cohort_name,", + " sampleSize = 100,", + " assignNewId = TRUE,", + " useAncestor = TRUE,", + " doi = keeperConcepts$doi,", + " symptoms = keeperConcepts$symptoms,", + " comorbidities = keeperConcepts$comorbidities,", + " drugs = keeperConcepts$drugs,", + " diagnosticProcedures = keeperConcepts$diagnosticProcedures,", + " measurements = keeperConcepts$measurements,", + " alternativeDiagnosis = keeperConcepts$alternativeDiagnosis,", + " treatmentProcedures = keeperConcepts$treatmentProcedures,", + " complications = keeperConcepts$complications", + " )", + " out_path <- file.path(role_dir, sprintf('%s.csv', cid))", + " write.csv(keeper, out_path, row.names = FALSE)", + "}", + "", + "# TODO: When ACP is implemented for cohort methods, add optional LLM-based Keeper row review here.", + "" + ) + write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) + + script_05 <- c( + script_header, + "library(Strategus)", + "library(CohortDiagnostics)", + "library(CohortGenerator)", + "library(DatabaseConnector)", + "library(dplyr)", + "", + package_loader_lines, + "", + sprintf("base_dir <- '%s'", base_dir), + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(cohort_json_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", + "executionSettings_diagnostics <- exec$executionSettings", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "", + "cdModule <- CohortDiagnosticsModule$new()", + "cohortDiagnosticsModuleSpecifications <- cdModule$createModuleSpecifications(", + " runInclusionStatistics = TRUE,", + " runIncludedSourceConcepts = TRUE,", + " runOrphanConcepts = TRUE,", + " runTimeSeries = FALSE,", + " runVisitContext = TRUE,", + " runBreakdownIndexEvents = TRUE,", + " runIncidenceRate = TRUE,", + " runCohortRelationship = TRUE,", + " runTemporalCohortCharacterization = TRUE", + ")", + "", + "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", + " addSharedResources(cohortDefinitionSharedResource) %>%", + " addModuleSpecifications(cohortDiagnosticsModuleSpecifications)", + "", + "execute(", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = executionSettings_diagnostics,", + " connectionDetails = connectionDetails", + ")", + "" + ) + write_lines(file.path(scripts_dir, "05_diagnostics.R"), script_05) + + script_06 <- c( + script_header, + "library(Strategus)", + "library(CohortGenerator)", + "library(CohortIncidence)", + "library(jsonlite)", + "library(ParallelLogger)", + "", + package_loader_lines, + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "analysis_settings_dir <- file.path(base_dir, 'analysis-settings')", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "defaults <- jsonlite::fromJSON(file.path(output_dir, 'cm_analysis_defaults.json'), simplifyVector = TRUE)", + "conceptSetSelections <- jsonlite::fromJSON(file.path(output_dir, 'cm_concept_set_selections.json'), simplifyVector = FALSE)", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(cohort_json_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "getDbDefaults <- defaults$get_db_cohort_method_data", + "studyPopulationDefaults <- defaults$create_study_population", + "psDefaults <- defaults$create_ps", + "psAdjustmentDefaults <- defaults$ps_adjustment %||% list()", + "matchDefaults <- defaults$match_on_ps", + "stratifyDefaults <- defaults$stratify_by_ps %||% list()", + "outcomeModelDefaults <- defaults$fit_outcome_model", + "covariateConceptDefaults <- defaults$covariate_concept_sets %||% list()", + "comparison_payload <- jsonlite::fromJSON(file.path(output_dir, 'cm_comparisons.json'), simplifyVector = FALSE)", + "comparisons <- comparison_payload$comparisons %||% list()", + "if (length(comparisons) == 0) stop('No comparisons found in cm_comparisons.json')", + "comparison <- comparisons[[1]]", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "lookup_cohort_name <- function(cohort_id, fallback = NULL) {", + " row <- cohortDefinitionSet[as.integer(cohortDefinitionSet$cohortId) == as.integer(cohort_id), , drop = FALSE]", + " if (nrow(row) > 0 && 'cohortName' %in% names(row) && nzchar(as.character(row$cohortName[1]))) {", + " return(as.character(row$cohortName[1]))", + " }", + " fallback %||% sprintf('Cohort %s', cohort_id)", + "}", + "to_ci_anchor <- function(anchor) {", + " anchor <- tolower(trimws(as.character(anchor %||% 'cohort start')))", + " if (identical(anchor, 'cohort end')) 'end' else 'start'", + "}", + "", + "analyticSettingsProfile <- defaults$profile_name %||% 'Analytic Setting 1'", + "psAdjustmentStrategy <- psAdjustmentDefaults$strategy %||% 'match_on_ps'", + "psTrimmingStrategy <- psAdjustmentDefaults$trimmingStrategy %||% 'none'", + "psTrimmingPercent <- as.numeric(psAdjustmentDefaults$trimmingPercent %||% 5)", + "if (is.na(psTrimmingPercent)) psTrimmingPercent <- 5", + "equipoiseLowerBound <- as.numeric(psAdjustmentDefaults$equipoiseLowerBound %||% 0.25)", + "equipoiseUpperBound <- as.numeric(psAdjustmentDefaults$equipoiseUpperBound %||% 0.75)", + "if (is.na(equipoiseLowerBound)) equipoiseLowerBound <- 0.25", + "if (is.na(equipoiseUpperBound)) equipoiseUpperBound <- 0.75", + "matchMaxRatio <- as.integer(matchDefaults$maxRatio %||% 1L)", + "if (is.na(matchMaxRatio)) matchMaxRatio <- 1L", + "derivedOutcomeStratified <- if (identical(psAdjustmentStrategy, 'stratify_by_ps')) {", + " TRUE", + "} else if (identical(psAdjustmentStrategy, 'match_on_ps')) {", + " matchMaxRatio != 1L", + "} else {", + " FALSE", + "}", + "", + "target_id <- as.numeric(comparison$target$cohort_id %||% NA_real_)", + "comparator_id <- as.numeric(comparison$comparator$cohort_id %||% NA_real_)", + "outcome_ids <- vapply(comparison$outcomes %||% list(), function(x) as.numeric(x$cohort_id %||% NA_real_), numeric(1))", + "if (is.na(target_id)) stop('Missing target cohort ID in cm_comparisons.json')", + "if (is.na(comparator_id)) stop('Missing comparator cohort ID in cm_comparisons.json')", + "if (length(outcome_ids) == 0) stop('Missing outcome cohort IDs in cm_comparisons.json')", + "target_name <- lookup_cohort_name(target_id, comparison$target$name %||% 'Target')", + "comparator_name <- lookup_cohort_name(comparator_id, comparison$comparator$name %||% 'Comparator')", + "outcome_names <- vapply(comparison$outcomes %||% list(), function(x) {", + " oid <- as.numeric(x$cohort_id %||% NA_real_)", + " lookup_cohort_name(oid, x$name %||% sprintf('Outcome %s', oid))", + "}, character(1))", + "", + "negativeControlConceptSet <- conceptSetSelections$negative_control %||% list()", + "covariateConceptSelections <- conceptSetSelections$covariates %||% list()", + "includedConceptSetId <- as.integer(covariateConceptDefaults$include_concept_set_id %||% covariateConceptSelections$include$concept_set_id %||% NA_integer_)", + "excludedConceptSetId <- as.integer(covariateConceptDefaults$exclude_concept_set_id %||% covariateConceptSelections$exclude$concept_set_id %||% NA_integer_)", + "includedCovariateConceptIds <- numeric(0)", + "excludedCovariateConceptIds <- numeric(0)", + "if (!is.na(includedConceptSetId)) message('TODO: Replace dummy covariate include concept set ', includedConceptSetId, ' with actual concept IDs before production use.')", + "if (!is.na(excludedConceptSetId)) message('TODO: Replace dummy covariate exclude concept set ', excludedConceptSetId, ' with actual concept IDs before production use.')", + "if (isTRUE(negativeControlConceptSet$enabled %||% FALSE)) message('TODO: Negative control concept set selected as dummy placeholder: ', negativeControlConceptSet$concept_set_id %||% NA_integer_)", + "", + "# Shared cohort definitions are included so downstream modules can resolve cohort metadata.", + "# Cohort generation itself is intentionally not included here; run 03_generate_cohorts.R first.", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "", + "# Characterization module: one characterization configuration for target and comparator cohorts.", + "characterizationTargetIds <- as.numeric(unique(c(target_id, comparator_id)))", + "characterizationModule <- CharacterizationModule$new()", + "characterizationModuleSpecifications <- characterizationModule$createModuleSpecifications(", + " targetIds = characterizationTargetIds,", + " outcomeIds = as.numeric(outcome_ids),", + " limitToFirstInNDays = as.numeric(rep(if (isTRUE(getDbDefaults$firstExposureOnly %||% TRUE)) 99999 else 0, length(characterizationTargetIds))),", + " minPriorObservation = as.numeric(getDbDefaults$washoutPeriod %||% 0),", + " outcomeWashoutDays = as.numeric(rep(as.numeric(studyPopulationDefaults$priorOutcomeLookback %||% 99999), length(outcome_ids))),", + " riskWindowStart = as.numeric(studyPopulationDefaults$riskWindowStart %||% 0),", + " startAnchor = studyPopulationDefaults$startAnchor %||% 'cohort start',", + " riskWindowEnd = as.numeric(studyPopulationDefaults$riskWindowEnd %||% 0),", + " endAnchor = studyPopulationDefaults$endAnchor %||% 'cohort end',", + " mode = 'CohortIncidence'", + ")", + "", + "# CohortIncidence module: one incidence analysis across target/comparator cohorts and outcomes.", + "ciTargets <- list(", + " CohortIncidence::createCohortRef(id = target_id, name = target_name),", + " CohortIncidence::createCohortRef(id = comparator_id, name = comparator_name)", + ")", + "ciOutcomes <- lapply(seq_along(outcome_ids), function(i) {", + " CohortIncidence::createOutcomeDef(", + " id = as.numeric(outcome_ids[[i]]),", + " name = outcome_names[[i]],", + " cohortId = as.numeric(outcome_ids[[i]]),", + " cleanWindow = as.numeric(studyPopulationDefaults$priorOutcomeLookback %||% 99999)", + " )", + "})", + "ciTar <- CohortIncidence::createTimeAtRiskDef(", + " id = 1,", + " startWith = to_ci_anchor(studyPopulationDefaults$startAnchor %||% 'cohort start'),", + " startOffset = as.numeric(studyPopulationDefaults$riskWindowStart %||% 0),", + " endWith = to_ci_anchor(studyPopulationDefaults$endAnchor %||% 'cohort end'),", + " endOffset = as.numeric(studyPopulationDefaults$riskWindowEnd %||% 0)", + ")", + "ciAnalysis <- CohortIncidence::createIncidenceAnalysis(", + " targets = c(target_id, comparator_id),", + " outcomes = outcome_ids,", + " tars = c(1)", + ")", + "ciDesign <- CohortIncidence::createIncidenceDesign(", + " targetDefs = ciTargets,", + " outcomeDefs = ciOutcomes,", + " tars = list(ciTar),", + " analysisList = list(ciAnalysis),", + " strataSettings = CohortIncidence::createStrataSettings(byYear = TRUE, byGender = TRUE)", + ")", + "ciModule <- CohortIncidenceModule$new()", + "cohortIncidenceModuleSpecifications <- ciModule$createModuleSpecifications(", + " irDesign = ciDesign$toList()", + ")", + "", + "# CohortMethod module: one comparative analysis for the selected target/comparator/outcomes.", + "priorOutcomeLookback <- studyPopulationDefaults$priorOutcomeLookback %||% 99999L", + "riskWindowStart <- studyPopulationDefaults$riskWindowStart %||% 0L", + "startAnchor <- studyPopulationDefaults$startAnchor %||% 'cohort start'", + "riskWindowEnd <- studyPopulationDefaults$riskWindowEnd %||% 0L", + "endAnchor <- studyPopulationDefaults$endAnchor %||% 'cohort end'", + "outcomes <- lapply(outcome_ids, function(outcome_id) {", + " CohortMethod::createOutcome(", + " outcomeId = outcome_id,", + " outcomeOfInterest = TRUE,", + " priorOutcomeLookback = priorOutcomeLookback,", + " riskWindowStart = riskWindowStart,", + " startAnchor = startAnchor,", + " riskWindowEnd = riskWindowEnd,", + " endAnchor = endAnchor", + " )", + "})", + "", + "targetComparatorOutcomesList <- list(", + " CohortMethod::createTargetComparatorOutcomes(", + " targetId = target_id,", + " comparatorId = comparator_id,", + " outcomes = outcomes,", + " excludedCovariateConceptIds = excludedCovariateConceptIds,", + " includedCovariateConceptIds = includedCovariateConceptIds", + " )", + ")", + "", + "covariateSettings <- FeatureExtraction::createDefaultCovariateSettings()", + "getDbCohortMethodDataArgs <- CohortMethod::createGetDbCohortMethodDataArgs(", + " removeDuplicateSubjects = getDbDefaults$removeDuplicateSubjects,", + " firstExposureOnly = getDbDefaults$firstExposureOnly,", + " washoutPeriod = getDbDefaults$washoutPeriod,", + " restrictToCommonPeriod = getDbDefaults$restrictToCommonPeriod,", + " studyStartDate = getDbDefaults$studyStartDate %||% '',", + " studyEndDate = getDbDefaults$studyEndDate %||% '',", + " maxCohortSize = studyPopulationDefaults$maxCohortSize %||% getDbDefaults$maxCohortSize %||% 0,", + " covariateSettings = covariateSettings", + ")", + "createStudyPopulationArgs <- CohortMethod::createCreateStudyPopulationArgs(", + " removeSubjectsWithPriorOutcome = studyPopulationDefaults$removeSubjectsWithPriorOutcome,", + " priorOutcomeLookback = studyPopulationDefaults$priorOutcomeLookback,", + " minDaysAtRisk = studyPopulationDefaults$minDaysAtRisk,", + " riskWindowStart = studyPopulationDefaults$riskWindowStart,", + " startAnchor = studyPopulationDefaults$startAnchor,", + " riskWindowEnd = studyPopulationDefaults$riskWindowEnd,", + " endAnchor = studyPopulationDefaults$endAnchor,", + " censorAtNewRiskWindow = studyPopulationDefaults$censorAtNewRiskWindow", + ")", + "psPrior <- if (isTRUE(psDefaults$useRegularization %||% TRUE)) {", + " Cyclops::createPrior(priorType = 'laplace', exclude = c(0), useCrossValidation = TRUE)", + "} else {", + " Cyclops::createPrior(priorType = 'none')", + "}", + "createPsArgs <- if (identical(psAdjustmentStrategy, 'none') && identical(psTrimmingStrategy, 'none')) NULL else CohortMethod::createCreatePsArgs(", + " estimator = psDefaults$estimator,", + " maxCohortSizeForFitting = psDefaults$maxCohortSizeForFitting,", + " errorOnHighCorrelation = isTRUE(psDefaults$errorOnHighCorrelation %||% FALSE),", + " prior = psPrior", + ")", + "trimByPsArgs <- if (identical(psTrimmingStrategy, 'by_percent')) {", + " CohortMethod::createTrimByPsArgs(", + " trimFraction = psTrimmingPercent / 100,", + " trimMethod = 'symmetric'", + " )", + "} else if (identical(psTrimmingStrategy, 'by_equipoise')) {", + " CohortMethod::createTrimByPsArgs(", + " equipoiseBounds = c(equipoiseLowerBound, equipoiseUpperBound)", + " )", + "} else {", + " NULL", + "}", + "matchOnPsArgs <- if (identical(psAdjustmentStrategy, 'match_on_ps')) CohortMethod::createMatchOnPsArgs(", + " caliper = matchDefaults$caliper,", + " caliperScale = matchDefaults$caliperScale,", + " maxRatio = matchDefaults$maxRatio", + ") else NULL", + "stratifyByPsArgs <- if (identical(psAdjustmentStrategy, 'stratify_by_ps')) CohortMethod::createStratifyByPsArgs(", + " numberOfStrata = stratifyDefaults$numberOfStrata,", + " baseSelection = stratifyDefaults$baseSelection", + ") else NULL", + "outcomeModelPrior <- if (isTRUE(outcomeModelDefaults$useRegularization %||% TRUE)) {", + " Cyclops::createPrior(priorType = 'laplace', useCrossValidation = TRUE)", + "} else {", + " Cyclops::createPrior(priorType = 'none')", + "}", + "fitOutcomeModelArgs <- CohortMethod::createFitOutcomeModelArgs(", + " modelType = outcomeModelDefaults$modelType,", + " stratified = outcomeModelDefaults$stratified %||% derivedOutcomeStratified,", + " useCovariates = isTRUE(outcomeModelDefaults$useCovariates %||% FALSE),", + " inversePtWeighting = isTRUE(outcomeModelDefaults$inversePtWeighting %||% FALSE),", + " prior = outcomeModelPrior", + ")", + "", + "cmAnalysisList <- list(", + " CohortMethod::createCmAnalysis(", + " analysisId = as.integer(defaults$analysis_id %||% 1L),", + " description = analyticSettingsProfile %||% comparison$label %||% 'Default cohort method analysis',", + " getDbCohortMethodDataArgs = getDbCohortMethodDataArgs,", + " createStudyPopulationArgs = createStudyPopulationArgs,", + " createPsArgs = createPsArgs,", + " trimByPsArgs = trimByPsArgs,", + " matchOnPsArgs = matchOnPsArgs,", + " stratifyByPsArgs = stratifyByPsArgs,", + " fitOutcomeModelArgs = fitOutcomeModelArgs", + " )", + ")", + "cmAnalysesSpecifications <- CohortMethod::createCmAnalysesSpecifications(", + " cmAnalysisList = cmAnalysisList,", + " targetComparatorOutcomesList = targetComparatorOutcomesList,", + " analysesToExclude = NULL,", + " refitPsForEveryOutcome = FALSE,", + " refitPsForEveryStudyPopulation = TRUE,", + " cmDiagnosticThresholds = CohortMethod::createCmDiagnosticThresholds()", + ")", + "cmModule <- CohortMethodModule$new()", + "cohortMethodModuleSpecifications <- cmModule$createModuleSpecifications(", + " cmAnalysesSpecifications = cmAnalysesSpecifications$toList()", + ")", + "", + "analysisSpecifications <- Strategus::createEmptyAnalysisSpecifications()", + "analysisSpecifications <- Strategus::addSharedResources(analysisSpecifications, cohortDefinitionSharedResource)", + "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, characterizationModuleSpecifications)", + "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, cohortIncidenceModuleSpecifications)", + "analysisSpecifications <- Strategus::addModuleSpecifications(analysisSpecifications, cohortMethodModuleSpecifications)", + "analysis_spec_path <- file.path(analysis_settings_dir, 'analysisSpecification.json')", + "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)", + "", + "jsonlite::write_json(", + " list(", + " comparison_label = comparison$label %||% '',", + " target_id = target_id,", + " comparator_id = comparator_id,", + " outcome_ids = as.list(outcome_ids),", + " analysis_specification_path = analysis_spec_path,", + " modules = c('CharacterizationModule', 'CohortIncidenceModule', 'CohortMethodModule'),", + " defaults_path = file.path(output_dir, 'cm_analysis_defaults.json'),", + " cm_analysis_json_path = file.path(analysis_settings_dir, 'cmAnalysis.json'),", + " concept_set_selections_path = file.path(output_dir, 'cm_concept_set_selections.json'),", + " negative_control_concept_set_id = negativeControlConceptSet$concept_set_id %||% NULL,", + " study_start_date = getDbDefaults$studyStartDate %||% '',", + " study_end_date = getDbDefaults$studyEndDate %||% '',", + " ps_adjustment_strategy = psAdjustmentStrategy,", + " ps_trimming_strategy = psTrimmingStrategy,", + " covariate_include_all_concepts = covariateConceptDefaults$include_all_concepts %||% covariateConceptSelections$include_all_concepts %||% TRUE,", + " covariate_include_concept_set_id = if (is.na(includedConceptSetId)) NULL else includedConceptSetId,", + " covariate_exclude_concept_set_id = if (is.na(excludedConceptSetId)) NULL else excludedConceptSetId,", + " analytic_settings_profile_name = defaults$profile_name %||% NULL,", + " analytic_settings_customized_sections = defaults$customized_sections %||% character(0),", + " TODO = 'Replace dummy concept set selections with actual concept definitions and concept IDs when ACP/MCP support is implemented.'", + " ),", + " file.path(output_dir, 'cm_analysis_state.json'),", + " pretty = TRUE,", + " auto_unbox = TRUE", + ")", + "", + "# Execute the just-created Strategus specification.", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", + "", + "result <- Strategus::execute(", + " connectionDetails = connectionDetails,", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = exec$executionSettings", + ")", + "", + "result_path <- file.path(analysis_settings_dir, 'strategus_execute_result.rds')", + "saveRDS(result, result_path)", + "message('Strategus execution result saved to: ', result_path)", + "" + ) + write_lines(file.path(scripts_dir, "06_cm_spec.R"), script_06) + + if (interactive) { + cat("\n== Session Summary ==\n") + cat(sprintf("Study intent: %s\n", studyIntent)) + cat(sprintf("Comparison: %s\n", comparisonLabel)) + cat(sprintf("Target: %s (atlas %s -> cohort %s)\n", target_name, selected_target_id, new_target_id)) + cat(sprintf("Comparator: %s (atlas %s -> cohort %s)\n", comparator_name, selected_comparator_id, new_comparator_id)) + cat("Outcomes:\n") + for (i in seq_along(new_outcome_ids)) { + cat(sprintf(" - %s (atlas %s -> cohort %s)\n", outcome_names[[i]], selected_outcome_ids[[i]], new_outcome_ids[[i]])) + } + if (isTRUE(negative_control_enabled)) { + cat(sprintf("Negative control concept set: %s\n", negativeControlConceptSetId)) + } + if (isTRUE(covariate_enabled)) { + include_label <- if (is.null(includeCovariateConceptSetId)) "all concepts" else as.character(includeCovariateConceptSetId) + exclude_label <- if (is.null(excludeCovariateConceptSetId)) "none" else as.character(excludeCovariateConceptSetId) + cat(sprintf("Covariate concept sets: include=%s, exclude=%s\n", include_label, exclude_label)) + } + cat(sprintf("Cohort ID remap: %s\n", if (isTRUE(use_mapping)) sprintf("enabled (base %s)", cohortIdBase) else "disabled")) + cat(sprintf("Analytic settings mode: %s\n", analytic_settings_mode)) + cat(sprintf("Analytic settings profile: %s\n", effective_analytic_settings$profile_name)) + section_label <- if (length(effective_analytic_settings$customized_sections) == 0) { + "defaults only" + } else { + paste(as.character(effective_analytic_settings$customized_sections), collapse = ", ") + } + cat(sprintf("Customized analytic sections: %s\n", section_label)) + if (identical(analytic_settings_mode, "free_text")) { + cat(sprintf("Analytic settings description: %s\n", analytic_settings_description)) + cat(sprintf("Analytic settings recommendation source: %s\n", analytic_settings_recommendation_source)) + cat(sprintf("Analytic settings recommendation: %s (%s)\n", analytic_settings_recommendation_path, analytic_settings_recommendation_status)) + if (!is.na(analytic_settings_acp_response_path)) { + cat(sprintf("ACP specifications response: %s\n", analytic_settings_acp_response_path)) + } + } + cat("Generated scripts:\n") + cat(" - 02_apply_improvements.R\n") + cat(" - 03_generate_cohorts.R\n") + cat(" - 04_keeper_review.R\n") + cat(" - 05_diagnostics.R\n") + cat(" - 06_cm_spec.R\n") + cat("Status/TODO artifacts:\n") + cat(sprintf(" - %s\n", improvements_status_path)) + cat(sprintf(" - %s\n", cm_evaluation_todo_path)) + } + + invisible(list( + output_dir = output_dir, + scripts_dir = scripts_dir, + cohort_methods_intent_split = json_string_or_null(if (file.exists(cohort_methods_intent_split_path)) cohort_methods_intent_split_path else NULL), + manual_intent = manual_intent_path, + manual_inputs = manual_inputs_path, + cm_comparisons = cm_comparisons_path, + improvements_status = improvements_status_path, + improvements_target = improvements_target_path, + improvements_comparator = improvements_comparator_path, + improvements_outcome = improvements_outcome_path, + cm_concept_set_selections = cm_concept_set_selections_path, + cm_analysis_json = cm_analysis_json_path, + cohort_csv = cohort_csv, + state = state_path + )) +} diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R new file mode 100644 index 0000000..fd903a0 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -0,0 +1,1617 @@ +#' Interactive shell to generate Strategus CohortIncidence scripts +#' @param outputDir directory where scripts and artifacts will be written +#' @param acpUrl ACP base URL +#' @param studyIntent study intent text +#' @param topK number of candidates retrieved from MCP search +#' @param maxResults max phenotypes to show +#' @param candidateLimit max candidates to pass to LLM +#' @param indexDir phenotype index directory (contains definitions/) +#' @param interactive whether to prompt for inputs +#' @param bannerPath optional path to ASCII banner +#' @param studyAgentBaseDir base directory to resolve relative paths (outputDir, indexDir, bannerPath) +#' @param reset when TRUE, delete outputDir before running +#' @param allowCache reuse cached artifacts when present +#' @param promptOnCache prompt before using cached artifacts +#' @param autoApplyImprovements when TRUE, apply improvements without prompting (defaults to TRUE for non-interactive) +#' @param resume when TRUE, resume from last checkpoint if present +#' @return invisible list with output paths +#' @export +runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incidence", + acpUrl = "http://127.0.0.1:8765", + studyIntent = NULL, + topK = 20, + maxResults = 20, + candidateLimit = 20, + indexDir = Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index"), + interactive = TRUE, + bannerPath = "ohdsi-logo-ascii.txt", + studyAgentBaseDir = Sys.getenv("STUDY_AGENT_BASE_DIR", ""), + reset = FALSE, + allowCache = TRUE, + promptOnCache = TRUE, + autoApplyImprovements = NA, + resume = FALSE) { + `%||%` <- function(x, y) if (is.null(x)) y else x + + ensure_dir <- function(path) { + if (!dir.exists(path)) dir.create(path, recursive = TRUE) + } + + normalize_dialogue_step <- .studyAgentSlashNormalizeIncidenceDialogueStep + + dialogue_step_label <- .studyAgentSlashIncidenceDialogueStepLabel + compact_dialogue_context <- .studyAgentSlashCompactWorkflowDialogueContext + + dialogue_acp_client <- new.env(parent = emptyenv()) + dialogue_acp_client$client <- NULL + build_workflow_stage_context <- function(studyIntent, dialogue_state) { + .studyAgentSlashBuildIncidenceWorkflowStageContext( + study_intent = studyIntent, + dialogue_state = dialogue_state, + interactive = interactive + ) + } + acp_timeout_seconds <- function(default = 180) { + timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", as.character(default))) + if (is.na(timeout_seconds) || timeout_seconds <= 0) timeout_seconds <- default + timeout_seconds + } + + acp_client_is_ready <- function(client) { + .studyAgentSlashAcpIsConnected(client) + } + + create_acp_client <- function(url, token = NULL, check = TRUE) { + .studyAgentSlashCreateAcpClient(url = url, token = token, check = check) + } + + ensure_workflow_dialogue_client <- function(url) { + if (acp_client_is_ready(dialogue_acp_client$client)) return(TRUE) + if (is.null(url) || !nzchar(trimws(url))) return(FALSE) + tryCatch({ + dialogue_acp_client$client <- create_acp_client(url = url, check = TRUE) + TRUE + }, error = function(e) { + FALSE + }) + } + + call_shell_acp_flow <- function(flow_name, body, url = acpUrl) { + if (!acp_client_is_ready(dialogue_acp_client$client)) { + if (!ensure_workflow_dialogue_client(url)) stop("ACP bridge unavailable.") + } + .studyAgentSlashCallAcpFlow(dialogue_acp_client$client, flow_name = flow_name, body = body) + } + + dialogue_session <- .studyAgentSlashNewWorkflowDialogueSession( + interactive = interactive, + study_intent_getter = function() studyIntent, + build_stage_context = build_workflow_stage_context, + call_dialogue = function(stage_context, message) { + if (!ensure_workflow_dialogue_client(acpUrl)) { + stop("ACP bridge unavailable. Connect ACP before using /ohdsi.") + } + message("Calling ACP flow: workflow_context_dialogue") + .studyAgentSlashWorkflowContextDialogue(dialogue_acp_client$client, stage_context, message) + }, + empty_question_message = "Enter a question after /ohdsi. Example: /ohdsi why are these candidates weak here?" + ) + dialogue_state <- dialogue_session$state + set_dialogue_context <- dialogue_session$set_context + readline_with_dialogue <- dialogue_session$readline + + prompt_yesno <- function(prompt, default = TRUE) { + if (!isTRUE(interactive)) return(default) + suffix <- if (default) "[Y/n]" else "[y/N]" + resp <- tolower(trimws(readline_with_dialogue(sprintf("%s %s ", prompt, suffix)))) + if (resp == "") return(default) + if (resp %in% c("y", "yes")) return(TRUE) + if (resp %in% c("n", "no")) return(FALSE) + default + } + + maybe_use_cache <- function(path, label) { + if (!allowCache || !file.exists(path)) return(FALSE) + if (!promptOnCache) return(TRUE) + prompt_yesno(sprintf("Use cached %s at %s?", label, path), default = TRUE) + } + + if (is.na(autoApplyImprovements)) { + autoApplyImprovements <- !isTRUE(interactive) + } + + read_json <- function(path) { + jsonlite::fromJSON(path, simplifyVector = FALSE) + } + + write_json <- function(x, path) { + jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE) + } + + acp_try <- function(path, body, label) { + repeat { + resp <- NULL + err <- NULL + flow_name <- sub("^/flows/", "", as.character(path)) + resp <- tryCatch( + call_shell_acp_flow(flow_name, body), + error = function(e) { + err <<- e + NULL + } + ) + if (is.null(err)) return(resp) + msg <- conditionMessage(err) + if (!isTRUE(interactive)) stop(msg) + retry <- prompt_yesno(sprintf("ACP call failed (%s). Try again?", msg), default = TRUE) + if (!retry) { + mark_checkpoint(label, list(path = path, error = msg)) + stop(sprintf("Stopping after ACP error. Resume with resume=TRUE once ready. (%s)", label)) + } + } + } + + checkpoint_path <- function(label) { + file.path(output_dir, paste0("checkpoint_", label, ".json")) + } + + mark_checkpoint <- function(label, payload = list()) { + checkpoint <- list(step = label) + if (length(payload) > 0) checkpoint <- c(checkpoint, payload) + write_json(checkpoint, checkpoint_path(label)) + } + + has_checkpoint <- function(label) { + file.exists(checkpoint_path(label)) + } + + is_absolute_path <- function(path) { + grepl("^(/|[A-Za-z]:[\\\\/])", path) + } + + resolve_path <- function(path, base_dir = "") { + if (!nzchar(path)) return(path) + if (is_absolute_path(path)) return(path) + if (nzchar(base_dir)) return(file.path(base_dir, path)) + path + } + + phenotype_definition_path <- function(phenotype_id, index_def_dir) { + file.path(index_def_dir, sprintf("%s.json", gsub(":", "__", phenotype_id, fixed = TRUE))) + } + + stop_if_unsupported_selected <- function(phenotype_ids, role_label) { + unsupported <- phenotype_ids[!grepl("^ohdsi:", phenotype_ids %||% character(0))] + if (length(unsupported) > 0) { + stop( + sprintf( + paste0( + "Selected %s phenotype(s) include non-OHDSI ids (%s). ", + "This demo workflow does not yet support converting non-OHDSI phenotype definitions ", + "into computable OHDSI cohort definitions. Please re-run and choose an OHDSI phenotype." + ), + role_label, + paste(unique(unsupported), collapse = ", ") + ) + ) + } + } + + copy_cohort_json_multi <- function(source_id, dest_id, dest_dirs, index_def_dir) { + src <- phenotype_definition_path(source_id, index_def_dir) + if (!file.exists(src)) stop(sprintf("Cohort JSON not found: %s", src)) + dests <- character(0) + for (dest_dir in dest_dirs) { + ensure_dir(dest_dir) + dest <- file.path(dest_dir, sprintf("%s.json", dest_id)) + file.copy(src, dest, overwrite = TRUE) + dests <- c(dests, dest) + } + dests + } + + apply_action <- function(obj, action) { + path <- action$path %||% "" + value <- action$value + if (!nzchar(path)) return(obj) + segs <- strsplit(path, "/", fixed = TRUE)[[1]] + segs <- segs[segs != ""] + + set_in <- function(x, segs, value) { + if (length(segs) == 0) return(value) + seg <- segs[[1]] + name <- seg + idx <- NA_integer_ + if (grepl("\\[\\d+\\]$", seg)) { + name <- sub("\\[\\d+\\]$", "", seg) + idx <- as.integer(sub("^.*\\[(\\d+)\\]$", "\\1", seg)) + } + if (name != "") { + if (is.null(x[[name]])) x[[name]] <- list() + if (length(segs) == 1) { + if (!is.na(idx)) { + if (length(x[[name]]) < idx) { + while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- NULL + } + x[[name]][[idx]] <- value + } else { + x[[name]] <- value + } + return(x) + } + if (!is.na(idx)) { + if (length(x[[name]]) < idx) { + while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list() + } + x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value) + } else { + x[[name]] <- set_in(x[[name]], segs[-1], value) + } + return(x) + } + idx <- suppressWarnings(as.integer(seg)) + if (is.na(idx)) return(x) + if (idx == 0) idx <- 1 + if (length(x) < idx) { + while (length(x) < idx) x[[length(x) + 1]] <- list() + } + if (length(segs) == 1) { + x[[idx]] <- value + return(x) + } + x[[idx]] <- set_in(x[[idx]], segs[-1], value) + x + } + + set_in(obj, segs, value) + } + + study_base_dir <- "" + if (nzchar(studyAgentBaseDir)) { + study_base_dir <- normalizePath(studyAgentBaseDir, winslash = "/", mustWork = FALSE) + } + outputDir <- resolve_path(outputDir, study_base_dir) + outputDir <- normalizePath(outputDir, winslash = "/", mustWork = FALSE) + if (isTRUE(reset) && dir.exists(outputDir)) { + ok <- TRUE + if (isTRUE(interactive)) { + ok <- prompt_yesno(sprintf("Delete existing output directory %s?", outputDir), default = FALSE) + } + if (ok) { + unlink(outputDir, recursive = TRUE, force = TRUE) + } + } + base_dir <- outputDir + index_dir <- resolve_path(indexDir, study_base_dir) + index_dir <- normalizePath(index_dir, winslash = "/", mustWork = FALSE) + if (!dir.exists(index_dir) && !is_absolute_path(indexDir) && !nzchar(studyAgentBaseDir)) { + alt <- file.path(getwd(), "OHDSI-Study-Agent", indexDir) + if (dir.exists(alt)) index_dir <- normalizePath(alt, winslash = "/", mustWork = FALSE) + } + index_def_dir <- file.path(index_dir, "definitions") + if (!dir.exists(index_def_dir)) stop(sprintf("Missing phenotype index definitions folder: %s", index_def_dir)) + + output_dir <- file.path(base_dir, "outputs") + selected_dir <- file.path(base_dir, "selected-cohorts") + patched_dir <- file.path(base_dir, "patched-cohorts") + selected_target_dir <- file.path(base_dir, "selected-target-cohorts") + selected_outcome_dir <- file.path(base_dir, "selected-outcome-cohorts") + patched_target_dir <- file.path(base_dir, "patched-target-cohorts") + patched_outcome_dir <- file.path(base_dir, "patched-outcome-cohorts") + keeper_dir <- file.path(base_dir, "keeper-case-review") + analysis_settings_dir <- file.path(base_dir, "analysis-settings") + scripts_dir <- file.path(base_dir, "scripts") + + ensure_dir(output_dir) + ensure_dir(selected_dir) + ensure_dir(patched_dir) + ensure_dir(selected_target_dir) + ensure_dir(selected_outcome_dir) + ensure_dir(patched_target_dir) + ensure_dir(patched_outcome_dir) + ensure_dir(keeper_dir) + ensure_dir(analysis_settings_dir) + ensure_dir(scripts_dir) + + if (interactive) { + banner_path <- resolve_path(bannerPath, study_base_dir) + banner_path <- normalizePath(banner_path, winslash = "/", mustWork = FALSE) + if (!file.exists(banner_path) && !is_absolute_path(bannerPath) && !nzchar(studyAgentBaseDir)) { + alt <- file.path(getwd(), "OHDSI-Study-Agent", bannerPath) + if (file.exists(alt)) banner_path <- normalizePath(alt, winslash = "/", mustWork = FALSE) + } + if (file.exists(banner_path)) { + cat(paste(readLines(banner_path, warn = FALSE), collapse = "\n"), "\n") + } + cat("\nStudy Agent: Strategus CohortIncidence shell\n") + } + + default_intent <- studyIntent %||% "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" + if (interactive) { + set_dialogue_context("study_intent", context = list(default_intent = default_intent)) + entered <- readline_with_dialogue(sprintf("Study intent [%s]: ", default_intent)) + if (nzchar(trimws(entered))) studyIntent <- entered else studyIntent <- default_intent + } else { + if (is.null(studyIntent) || !nzchar(trimws(studyIntent))) studyIntent <- default_intent + } + + if (interactive) { + cat("\nConnecting to ACP...\n") + } + acp_connect(acpUrl) + + intent_split_path <- file.path(output_dir, "intent_split.json") + intent_response <- NULL + if (interactive) { + cat("\n== Step 1: Parse study intent into target/outcome statements ==\n") + } + set_dialogue_context("intent_split", context = list(study_intent = studyIntent)) + if (maybe_use_cache(intent_split_path, "intent split")) { + intent_response <- read_json(intent_split_path) + } else { + message("Calling ACP flow: phenotype_intent_split") + intent_response <- acp_try("/flows/phenotype_intent_split", list(study_intent = studyIntent), "intent_split") + write_json(intent_response, intent_split_path) + } + intent_core <- intent_response$intent_split %||% intent_response + target_statement <- intent_core$target_statement %||% "" + outcome_statement <- intent_core$outcome_statement %||% "" + rationale <- intent_core$rationale %||% "" + if (interactive) { + if (nzchar(rationale)) { + cat("\nSuggested rationale:\n") + cat(rationale, "\n") + } + if (length(intent_core$questions %||% list()) > 0) { + cat("Questions to clarify:\n") + for (q in intent_core$questions) cat(sprintf(" - %s\n", q)) + } + set_dialogue_context("intent_split", "target", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) + inp <- readline_with_dialogue(sprintf("Target cohort statement [%s]: ", target_statement)) + if (nzchar(trimws(inp))) target_statement <- inp + set_dialogue_context("intent_split", "outcome", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement)) + inp <- readline_with_dialogue(sprintf("Outcome cohort statement [%s]: ", outcome_statement)) + if (nzchar(trimws(inp))) outcome_statement <- inp + } + if (!nzchar(trimws(target_statement))) stop("Missing target cohort statement.") + if (!nzchar(trimws(outcome_statement))) stop("Missing outcome cohort statement.") + + recs_target_path <- file.path(output_dir, "recommendations_target.json") + recs_outcome_path <- file.path(output_dir, "recommendations_outcome.json") + used_cached_recs_target <- FALSE + used_cached_recs_outcome <- FALSE + used_window2_target <- FALSE + used_window2_outcome <- FALSE + used_advice_target <- FALSE + used_advice_outcome <- FALSE + rec_response_target <- NULL + rec_response_outcome <- NULL + + do_target_recs <- !isTRUE(resume) || !has_checkpoint("target_advice") + if (interactive && !do_target_recs) { + cat("\n== Step 2: Target phenotype recommendations (resumed) ==\n") + } + if (do_target_recs) { + if (interactive) { + cat("\n== Step 2: Target phenotype recommendations ==\n") + } + set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) + if (maybe_use_cache(recs_target_path, "target recommendations")) { + rec_response_target <- read_json(recs_target_path) + used_cached_recs_target <- TRUE + } else { + message("Calling ACP flow: phenotype_recommendation (target)") + body <- list( + study_intent = target_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit + ) + rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation") + write_json(rec_response_target, recs_target_path) + } + } else if (file.exists(recs_target_path)) { + rec_response_target <- read_json(recs_target_path) + used_cached_recs_target <- TRUE + } else { + do_target_recs <- TRUE + message("No cached target recommendations found; rerunning target recommendations.") + body <- list( + study_intent = target_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit + ) + rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation_resume") + write_json(rec_response_target, recs_target_path) + } + + recs_core_target <- rec_response_target$recommendations %||% rec_response_target + recommendations_target <- recs_core_target$phenotype_recommendations %||% list() + if (length(recommendations_target) == 0) stop("No target phenotype recommendations returned.") + + cat("\n== Target Phenotype Recommendations ==\n") + for (i in seq_along(recommendations_target)) { + rec <- recommendations_target[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + } + + if (interactive) { + set_dialogue_context("target_recommendation", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) + ok_any <- prompt_yesno("Are any of these acceptable for the target?", default = TRUE) + if (!ok_any) { + widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) + if (widen) { + message("Generating additional recommendations (next window)...") + used_window2_target <- TRUE + body <- list( + study_intent = target_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + candidate_offset = candidateLimit + ) + rec_response_target <- acp_try("/flows/phenotype_recommendation", body, "target_recommendation_window2") + recs_target_path <- file.path(output_dir, "recommendations_target_window2.json") + write_json(rec_response_target, recs_target_path) + + recs_core_target <- rec_response_target$recommendations %||% rec_response_target + recommendations_target <- recs_core_target$phenotype_recommendations %||% list() + cat("\n== Target Phenotype Recommendations (window 2) ==\n") + for (i in seq_along(recommendations_target)) { + rec <- recommendations_target[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + } + ok_any <- prompt_yesno("Are any of these acceptable?", default = TRUE) + } + if (!ok_any) { + message("Generating advisory guidance (this may take a moment)...") + advice <- acp_try("/flows/phenotype_recommendation_advice", list(study_intent = studyIntent), "target_advice_call") + used_advice_target <- TRUE + advice_core <- advice$advice %||% advice + cat("\n== Advisory guidance ==\n") + cat(advice_core$advice %||% "", "\n") + if (length(advice_core$next_steps %||% list()) > 0) { + cat("Next steps:\n") + for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) + } + if (length(advice_core$questions %||% list()) > 0) { + cat("Questions to clarify:\n") + for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) + } + mark_checkpoint("target_advice", list(recommendations_path = recs_target_path)) + cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") + stop("Stopping after target advice. Resume with resume=TRUE once phenotypes are updated.") + } + } + } + + if (interactive) { + set_dialogue_context("target_selection", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, outcome_statement = outcome_statement)) + if (!prompt_yesno("Continue to target cohort selection?", default = TRUE)) { + return(invisible(list(output_dir = output_dir, recommendations = recs_target_path))) + } + cat("\n== Step 3: Select target cohorts ==\n") + } + + selected_ids_target <- NULL + if (interactive) { + labels <- vapply(seq_along(recommendations_target), function(i) { + rec <- recommendations_target[[i]] + sprintf("%s (ID %s)", rec$phenotype_name %||% "", rec$phenotype_id %||% "?") + }, character(1)) + picks <- utils::select.list(labels, multiple = FALSE, title = "Select target phenotype") + if (nzchar(picks)) { + idx <- which(labels == picks)[1] + selected_ids_target <- recommendations_target[[idx]]$phenotype_id + } + } else { + selected_ids_target <- recommendations_target[[1]]$phenotype_id + } + selected_ids_target <- as.character(selected_ids_target) + if (length(selected_ids_target) == 0) stop("No target cohort selected.") + + use_mapping <- FALSE + if (interactive) { + set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) + use_mapping <- prompt_yesno("Map cohort IDs to a new range (avoid collisions)?", default = TRUE) + } + cohort_id_base <- NA_integer_ + next_id <- NA_integer_ + if (use_mapping) { + cohort_id_base <- sample(10000:50000, 1) + if (interactive) { + msg <- sprintf("Enter cohort ID base (10000-50000) or press Enter to use %s: ", cohort_id_base) + set_dialogue_context("incidence_design_setup", context = list(study_intent = studyIntent, target_statement = target_statement, outcome_statement = outcome_statement, selected_target_ids = as.list(selected_ids_target %||% list()), selected_outcome_ids = as.list(selected_ids_outcome %||% list()), suggested_cohort_id_base = cohort_id_base)) + inp <- trimws(readline_with_dialogue(msg)) + if (nzchar(inp)) cohort_id_base <- as.integer(inp) + } + next_id <- cohort_id_base + } + + map_ids <- function(ids) { + if (!use_mapping) return(ids) + new <- seq(next_id, length.out = length(ids)) + next_id <<- max(new) + 1 + new + } + + stop_if_unsupported_selected(selected_ids_target, "target") + + new_ids_target <- map_ids(selected_ids_target) + + copy_cohort_json_multi(selected_ids_target, new_ids_target, c(selected_target_dir, selected_dir), index_def_dir) + + do_target_improvements <- TRUE + if (interactive) { + set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, selected_target_ids = as.list(selected_ids_target %||% list()))) + do_target_improvements <- prompt_yesno("Continue to target phenotype improvements?", default = TRUE) + if (do_target_improvements) { + cat("\n== Step 4: Target phenotype improvements ==\n") + } + } + + improvements_target_path <- file.path(output_dir, "improvements_target.json") + imp_response_target <- list() + improvements_applied <- FALSE + used_cached_improvements_target <- FALSE + if (isTRUE(do_target_improvements)) { + if (maybe_use_cache(improvements_target_path, "target improvements")) { + imp_response_target <- read_json(improvements_target_path) + used_cached_improvements_target <- TRUE + if (interactive) { + cat(sprintf("\nLoaded cached target improvements from %s\n", improvements_target_path)) + } + } else { + cohort_obj <- read_json(file.path(selected_target_dir, sprintf("%s.json", new_ids_target))) + cohort_obj$id <- new_ids_target + body <- list( + protocol_text = studyIntent, + cohorts = list(cohort_obj) + ) + message(sprintf("Calling ACP flow: phenotype_improvements (target cohort %s)", new_ids_target)) + resp <- acp_try("/flows/phenotype_improvements", body, "target_improvements") + imp_response_target[[as.character(new_ids_target)]] <- resp + write_json(imp_response_target, improvements_target_path) + } + + if (interactive) { + for (cid in names(imp_response_target)) { + resp <- imp_response_target[[cid]] + core <- resp$full_result %||% resp + items <- core$phenotype_improvements %||% list() + cat(sprintf("\n== Improvements for target cohort %s ==\n", cid)) + for (item in items) { + cat(sprintf("- %s\n", item$summary %||% "(no summary)")) + if (!is.null(item$actions)) { + for (act in item$actions) { + cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) + } + } + } + if (length(items) == 0) { + cat(" No improvements returned for this cohort.\n") + next + } + set_dialogue_context("target_improvements", "target", context = list(study_intent = studyIntent, role_statement = target_statement, target_statement = target_statement, cohort_id = as.integer(cid), selected_target_ids = as.list(selected_ids_target %||% list()))) + if (prompt_yesno(sprintf("Apply improvements for target cohort %s now?", cid), default = FALSE)) { + cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + for (item in items) { + if (is.null(item$actions)) next + for (act in item$actions) { + cohort_obj <- apply_action(cohort_obj, act) + } + } + ensure_dir(patched_target_dir) + ensure_dir(patched_dir) + out_path <- file.path(patched_target_dir, sprintf("%s.json", cid)) + write_json(cohort_obj, out_path) + file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) + improvements_applied <- TRUE + cat(sprintf("Patched target cohort saved: %s\n", out_path)) + } + } + } + if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { + for (cid in names(imp_response_target)) { + resp <- imp_response_target[[cid]] + core <- resp$full_result %||% resp + items <- core$phenotype_improvements %||% list() + if (length(items) == 0) next + cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + for (item in items) { + if (is.null(item$actions)) next + for (act in item$actions) { + cohort_obj <- apply_action(cohort_obj, act) + } + } + ensure_dir(patched_target_dir) + ensure_dir(patched_dir) + out_path <- file.path(patched_target_dir, sprintf("%s.json", cid)) + write_json(cohort_obj, out_path) + file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) + improvements_applied <- TRUE + } + } + } + + do_outcome_recs <- !isTRUE(resume) || !has_checkpoint("outcome_advice") + if (interactive && !do_outcome_recs) { + cat("\n== Step 5: Outcome phenotype recommendations (resumed) ==\n") + } + if (do_outcome_recs) { + if (interactive) { + cat("\n== Step 5: Outcome phenotype recommendations ==\n") + } + set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) + if (maybe_use_cache(recs_outcome_path, "outcome recommendations")) { + rec_response_outcome <- read_json(recs_outcome_path) + used_cached_recs_outcome <- TRUE + } else { + message("Calling ACP flow: phenotype_recommendation (outcome)") + body <- list( + study_intent = outcome_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit + ) + rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation") + write_json(rec_response_outcome, recs_outcome_path) + } + } else if (file.exists(recs_outcome_path)) { + rec_response_outcome <- read_json(recs_outcome_path) + used_cached_recs_outcome <- TRUE + } else { + do_outcome_recs <- TRUE + message("No cached outcome recommendations found; rerunning outcome recommendations.") + body <- list( + study_intent = outcome_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit + ) + rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation_resume") + write_json(rec_response_outcome, recs_outcome_path) + } + + recs_core_outcome <- rec_response_outcome$recommendations %||% rec_response_outcome + recommendations_outcome <- recs_core_outcome$phenotype_recommendations %||% list() + if (length(recommendations_outcome) == 0) stop("No outcome phenotype recommendations returned.") + + cat("\n== Outcome Phenotype Recommendations ==\n") + for (i in seq_along(recommendations_outcome)) { + rec <- recommendations_outcome[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + } + + if (interactive) { + set_dialogue_context("outcome_recommendation", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, top_k = topK, max_results = maxResults, candidate_limit = candidateLimit)) + ok_any <- prompt_yesno("Are any of these acceptable for the outcomes?", default = TRUE) + if (!ok_any) { + widen <- prompt_yesno("Widen candidate pool and try again?", default = TRUE) + if (widen) { + message("Generating additional recommendations (next window)...") + used_window2_outcome <- TRUE + body <- list( + study_intent = outcome_statement, + top_k = topK, + max_results = maxResults, + candidate_limit = candidateLimit, + candidate_offset = candidateLimit + ) + rec_response_outcome <- acp_try("/flows/phenotype_recommendation", body, "outcome_recommendation_window2") + recs_outcome_path <- file.path(output_dir, "recommendations_outcome_window2.json") + write_json(rec_response_outcome, recs_outcome_path) + + recs_core_outcome <- rec_response_outcome$recommendations %||% rec_response_outcome + recommendations_outcome <- recs_core_outcome$phenotype_recommendations %||% list() + cat("\n== Outcome Phenotype Recommendations (window 2) ==\n") + for (i in seq_along(recommendations_outcome)) { + rec <- recommendations_outcome[[i]] + cat(sprintf("%d. %s (ID %s)\n", i, rec$phenotype_name %||% "", rec$phenotype_id %||% "?")) + if (!is.null(rec$justification)) cat(sprintf(" %s\n", rec$justification)) + } + ok_any <- prompt_yesno("Are any of these acceptable?", default = TRUE) + } + if (!ok_any) { + message("Generating advisory guidance (this may take a moment)...") + advice <- acp_try("/flows/phenotype_recommendation_advice", list(study_intent = studyIntent), "outcome_advice_call") + used_advice_outcome <- TRUE + advice_core <- advice$advice %||% advice + cat("\n== Advisory guidance ==\n") + cat(advice_core$advice %||% "", "\n") + if (length(advice_core$next_steps %||% list()) > 0) { + cat("Next steps:\n") + for (step in advice_core$next_steps) cat(sprintf(" - %s\n", step)) + } + if (length(advice_core$questions %||% list()) > 0) { + cat("Questions to clarify:\n") + for (q in advice_core$questions) cat(sprintf(" - %s\n", q)) + } + mark_checkpoint("outcome_advice", list(recommendations_path = recs_outcome_path)) + cat("\nHint: rerun with resume=TRUE after updating phenotypes to continue.\n") + stop("Stopping after outcome advice. Resume with resume=TRUE once phenotypes are updated.") + } + } + } + + if (interactive) { + set_dialogue_context("outcome_selection", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement)) + if (!prompt_yesno("Continue to outcome cohort selection?", default = TRUE)) { + return(invisible(list(output_dir = output_dir, recommendations = recs_outcome_path))) + } + cat("\n== Step 6: Select outcome cohorts ==\n") + } + + selected_ids_outcome <- NULL + if (interactive) { + labels <- vapply(seq_along(recommendations_outcome), function(i) { + rec <- recommendations_outcome[[i]] + sprintf("%s (ID %s)", rec$phenotype_name %||% "", rec$phenotype_id %||% "?") + }, character(1)) + picks <- utils::select.list(labels, multiple = TRUE, title = "Select outcome phenotypes") + selected_ids_outcome <- vapply(picks, function(label) { + idx <- which(labels == label)[1] + recommendations_outcome[[idx]]$phenotype_id %||% NA_character_ + }, character(1)) + } else { + if (length(recommendations_outcome) >= 2) { + selected_ids_outcome <- vapply(recommendations_outcome[-1], function(r) r$phenotype_id %||% NA_character_, character(1)) + } else { + selected_ids_outcome <- vapply(recommendations_outcome, function(r) r$phenotype_id %||% NA_character_, character(1)) + } + } + selected_ids_outcome <- as.character(selected_ids_outcome) + if (length(selected_ids_outcome) == 0) stop("No outcome cohorts selected.") + + stop_if_unsupported_selected(selected_ids_outcome, "outcome") + + new_ids_outcome <- map_ids(selected_ids_outcome) + + for (i in seq_along(new_ids_outcome)) { + copy_cohort_json_multi(selected_ids_outcome[[i]], new_ids_outcome[[i]], c(selected_outcome_dir, selected_dir), index_def_dir) + } + + do_outcome_improvements <- TRUE + if (interactive) { + set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) + do_outcome_improvements <- prompt_yesno("Continue to outcome phenotype improvements?", default = TRUE) + if (do_outcome_improvements) { + cat("\n== Step 7: Outcome phenotype improvements ==\n") + } + } + + improvements_outcome_path <- file.path(output_dir, "improvements_outcome.json") + imp_response_outcome <- list() + used_cached_improvements_outcome <- FALSE + if (isTRUE(do_outcome_improvements)) { + if (maybe_use_cache(improvements_outcome_path, "outcome improvements")) { + imp_response_outcome <- read_json(improvements_outcome_path) + used_cached_improvements_outcome <- TRUE + if (interactive) { + cat(sprintf("\nLoaded cached outcome improvements from %s\n", improvements_outcome_path)) + } + } else { + for (i in seq_along(new_ids_outcome)) { + cid <- new_ids_outcome[[i]] + cohort_obj <- read_json(file.path(selected_outcome_dir, sprintf("%s.json", cid))) + cohort_obj$id <- cid + body <- list( + protocol_text = studyIntent, + cohorts = list(cohort_obj) + ) + message(sprintf("Calling ACP flow: phenotype_improvements (outcome cohort %s)", cid)) + resp <- acp_try("/flows/phenotype_improvements", body, "outcome_improvements") + imp_response_outcome[[as.character(cid)]] <- resp + } + write_json(imp_response_outcome, improvements_outcome_path) + } + + if (interactive) { + for (cid in names(imp_response_outcome)) { + resp <- imp_response_outcome[[cid]] + core <- resp$full_result %||% resp + items <- core$phenotype_improvements %||% list() + cat(sprintf("\n== Improvements for outcome cohort %s ==\n", cid)) + for (item in items) { + cat(sprintf("- %s\n", item$summary %||% "(no summary)")) + if (!is.null(item$actions)) { + for (act in item$actions) { + cat(sprintf(" action: %s %s\n", act$type %||% "set", act$path %||% "")) + } + } + } + if (length(items) == 0) { + cat(" No improvements returned for this cohort.\n") + next + } + set_dialogue_context("outcome_improvements", "outcome", context = list(study_intent = studyIntent, role_statement = outcome_statement, target_statement = target_statement, outcome_statement = outcome_statement, cohort_id = as.integer(cid), selected_outcome_ids = as.list(selected_ids_outcome %||% list()))) + if (prompt_yesno(sprintf("Apply improvements for outcome cohort %s now?", cid), default = FALSE)) { + cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + for (item in items) { + if (is.null(item$actions)) next + for (act in item$actions) { + cohort_obj <- apply_action(cohort_obj, act) + } + } + ensure_dir(patched_outcome_dir) + ensure_dir(patched_dir) + out_path <- file.path(patched_outcome_dir, sprintf("%s.json", cid)) + write_json(cohort_obj, out_path) + file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) + improvements_applied <- TRUE + cat(sprintf("Patched outcome cohort saved: %s\n", out_path)) + } + } + } + if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { + for (cid in names(imp_response_outcome)) { + resp <- imp_response_outcome[[cid]] + core <- resp$full_result %||% resp + items <- core$phenotype_improvements %||% list() + if (length(items) == 0) next + cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) + cohort_obj <- read_json(cohort_path) + for (item in items) { + if (is.null(item$actions)) next + for (act in item$actions) { + cohort_obj <- apply_action(cohort_obj, act) + } + } + ensure_dir(patched_outcome_dir) + ensure_dir(patched_dir) + out_path <- file.path(patched_outcome_dir, sprintf("%s.json", cid)) + write_json(cohort_obj, out_path) + file.copy(out_path, file.path(patched_dir, sprintf("%s.json", cid)), overwrite = TRUE) + improvements_applied <- TRUE + } + } + } + + id_map <- data.frame( + original_id = c(selected_ids_target, selected_ids_outcome), + cohort_id = c(new_ids_target, new_ids_outcome), + role = c(rep("target", length(new_ids_target)), rep("outcome", length(new_ids_outcome))), + stringsAsFactors = FALSE + ) + write_json(list(mapping = id_map), file.path(output_dir, "cohort_id_map.json")) + + roles_path <- file.path(output_dir, "cohort_roles.json") + target_ids <- as.integer(new_ids_target) + outcome_ids <- as.integer(new_ids_outcome) + write_json(list(targets = target_ids, outcomes = outcome_ids), roles_path) + if (length(target_ids) == 0) { + stop("No target cohort assigned. Update cohort_roles.json and re-run.") + } + + cohort_csv <- file.path(selected_dir, "Cohorts.csv") + cohort_rows <- list() + if (length(new_ids_target) > 0) { + for (i in seq_along(new_ids_target)) { + cid <- selected_ids_target[[i]] + new_id <- new_ids_target[[i]] + rec <- recommendations_target[[which(vapply(recommendations_target, function(r) r$phenotype_id == cid, logical(1)))]] + cohort_rows[[length(cohort_rows) + 1]] <- data.frame( + atlas_id = cid, + cohort_id = new_id, + cohort_name = rec$phenotype_name %||% paste0("Cohort ", new_id), + logic_description = rec$justification %||% NA_character_, + generate_stats = TRUE, + stringsAsFactors = FALSE + ) + } + } + if (length(new_ids_outcome) > 0) { + for (i in seq_along(new_ids_outcome)) { + cid <- selected_ids_outcome[[i]] + new_id <- new_ids_outcome[[i]] + rec <- recommendations_outcome[[which(vapply(recommendations_outcome, function(r) r$phenotype_id == cid, logical(1)))]] + cohort_rows[[length(cohort_rows) + 1]] <- data.frame( + atlas_id = cid, + cohort_id = new_id, + cohort_name = rec$phenotype_name %||% paste0("Cohort ", new_id), + logic_description = rec$justification %||% NA_character_, + generate_stats = TRUE, + stringsAsFactors = FALSE + ) + } + } + cohort_df <- do.call(rbind, cohort_rows) + write.csv(cohort_df, cohort_csv, row.names = FALSE) + + + state <- list( + study_intent = studyIntent, + target_statement = target_statement, + outcome_statement = outcome_statement, + output_dir = output_dir, + selected_dir = selected_dir, + patched_dir = patched_dir, + selected_target_dir = selected_target_dir, + selected_outcome_dir = selected_outcome_dir, + patched_target_dir = patched_target_dir, + patched_outcome_dir = patched_outcome_dir, + keeper_dir = keeper_dir, + analysis_settings_dir = analysis_settings_dir, + index_def_dir = index_def_dir, + intent_split_path = intent_split_path, + recommendations_target_path = recs_target_path, + recommendations_outcome_path = recs_outcome_path, + improvements_target_path = improvements_target_path, + improvements_outcome_path = improvements_outcome_path, + cohort_csv = cohort_csv, + cohort_id_map = id_map, + cohort_id_base = cohort_id_base, + cohort_roles_path = roles_path, + target_ids = target_ids, + outcome_ids = outcome_ids, + resume_enabled = resume, + checkpoint_target_advice = has_checkpoint("target_advice"), + checkpoint_outcome_advice = has_checkpoint("outcome_advice"), + used_cached_recommendations_target = used_cached_recs_target, + used_cached_recommendations_outcome = used_cached_recs_outcome, + used_cached_improvements_target = used_cached_improvements_target, + used_cached_improvements_outcome = used_cached_improvements_outcome, + used_window2_target = used_window2_target, + used_window2_outcome = used_window2_outcome, + used_advisory_flow_target = used_advice_target, + used_advisory_flow_outcome = used_advice_outcome, + improvements_applied = improvements_applied + ) + state_path <- file.path(output_dir, "study_agent_state.json") + write_json(state, state_path) + + # ---- Generate scripts ---- + if (interactive) { + cat("\n== Step 8: Generate scripts ==\n") + } + write_lines <- function(path, lines) { + writeLines(lines, con = path, useBytes = TRUE) + } + + script_header <- c( + "# Generated by the slashOhdsiStrategusAssistant incidence workflow shell", + "# Edit values as needed and run in order.", + if (improvements_applied) "# NOTE: improvements were already applied in the shell run; this script is a portable record." + else "# NOTE: improvements not applied yet; see 02_apply_improvements.R.", + "" + ) + + # 01 - select + script_01 <- c( + script_header, + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "phenotype_definition_path <- function(phenotype_id, index_def_dir) {", + " file.path(index_def_dir, sprintf('%s.json', gsub(':', '__', phenotype_id, fixed = TRUE)))", + "}", + "stop_if_unsupported_selected <- function(phenotype_ids, role_label) {", + " unsupported <- phenotype_ids[!grepl('^ohdsi:', phenotype_ids %||% character(0))]", + " if (length(unsupported) > 0) stop(sprintf('Selected %s phenotype(s) include non-OHDSI ids (%s). This demo workflow does not yet support converting non-OHDSI phenotype definitions into computable OHDSI cohort definitions. Please re-run and choose an OHDSI phenotype.', role_label, paste(unique(unsupported), collapse = ', ')))", + "}", + "copy_cohort_json <- function(source_id, dest_id, dest_dirs, index_def_dir) {", + " src <- phenotype_definition_path(source_id, index_def_dir)", + " if (!file.exists(src)) stop('Cohort JSON not found: ', src)", + " for (dest_dir in dest_dirs) {", + " dir.create(dest_dir, recursive = TRUE, showWarnings = FALSE)", + " dest <- file.path(dest_dir, sprintf('%s.json', dest_id))", + " file.copy(src, dest, overwrite = TRUE)", + " }", + "}", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + sprintf("index_def_dir <- '%s'", index_def_dir), + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", + "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", + "dir.create(selected_dir, recursive = TRUE, showWarnings = FALSE)", + "dir.create(selected_target_dir, recursive = TRUE, showWarnings = FALSE)", + "dir.create(selected_outcome_dir, recursive = TRUE, showWarnings = FALSE)", + "recs_target <- jsonlite::fromJSON(file.path(output_dir, 'recommendations_target.json'), simplifyVector = FALSE)", + "recs_outcome <- jsonlite::fromJSON(file.path(output_dir, 'recommendations_outcome.json'), simplifyVector = FALSE)", + "items_target <- (recs_target$recommendations %||% recs_target)$phenotype_recommendations %||% list()", + "items_outcome <- (recs_outcome$recommendations %||% recs_outcome)$phenotype_recommendations %||% list()", + "labels_target <- vapply(seq_along(items_target), function(i) sprintf('%s (ID %s)', items_target[[i]]$phenotype_name %||% '', items_target[[i]]$phenotype_id %||% '?'), character(1))", + "labels_outcome <- vapply(seq_along(items_outcome), function(i) sprintf('%s (ID %s)', items_outcome[[i]]$phenotype_name %||% '', items_outcome[[i]]$phenotype_id %||% '?'), character(1))", + "target_pick <- utils::select.list(labels_target, multiple = FALSE, title = 'Select target phenotype')", + "target_ids <- if (nzchar(target_pick)) (items_target[[which(labels_target == target_pick)[1]]]$phenotype_id %||% '') else character(0)", + "outcome_picks <- utils::select.list(labels_outcome, multiple = TRUE, title = 'Select outcome phenotypes')", + "outcome_ids <- vapply(outcome_picks, function(label) items_outcome[[which(labels_outcome == label)[1]]]$phenotype_id %||% NA_character_, character(1))", + "if (length(target_ids) == 0) stop('No target cohort selected.')", + "if (length(outcome_ids) == 0) stop('No outcome cohorts selected.')", + "resp <- tolower(trimws(readline('Map cohort IDs to a new range (avoid collisions)? [Y/n]: ')))", + "use_mapping <- !(resp %in% c('n', 'no'))", + "cohort_id_base <- NA_integer_", + "next_id <- NA_integer_", + "if (use_mapping) {", + " cohort_id_base <- sample(10000:50000, 1)", + " inp <- trimws(readline(sprintf('Enter cohort ID base (10000-50000) or press Enter to use %s: ', cohort_id_base)))", + " if (nzchar(inp)) cohort_id_base <- as.integer(inp)", + " next_id <- cohort_id_base", + "}", + "map_ids <- function(ids) {", + " if (!use_mapping) return(ids)", + " new <- seq(next_id, length.out = length(ids))", + " next_id <<- max(new) + 1", + " new", + "}", + "stop_if_unsupported_selected(target_ids, 'target')", + "new_ids_target <- map_ids(target_ids)", + "stop_if_unsupported_selected(outcome_ids, 'outcome')", + "new_ids_outcome <- map_ids(outcome_ids)", + "for (i in seq_along(target_ids)) copy_cohort_json(target_ids[[i]], new_ids_target[[i]], c(selected_target_dir, selected_dir), index_def_dir)", + "for (i in seq_along(outcome_ids)) copy_cohort_json(outcome_ids[[i]], new_ids_outcome[[i]], c(selected_outcome_dir, selected_dir), index_def_dir)", + "id_map <- data.frame(", + " original_id = c(target_ids, outcome_ids),", + " cohort_id = c(new_ids_target, new_ids_outcome),", + " role = c(rep('target', length(new_ids_target)), rep('outcome', length(new_ids_outcome))),", + " stringsAsFactors = FALSE", + ")", + "jsonlite::write_json(list(mapping = id_map), file.path(output_dir, 'cohort_id_map.json'), pretty = TRUE, auto_unbox = TRUE)", + "jsonlite::write_json(list(targets = new_ids_target, outcomes = new_ids_outcome), file.path(output_dir, 'cohort_roles.json'), pretty = TRUE, auto_unbox = TRUE)", + "cohort_rows <- list()", + "for (i in seq_along(new_ids_target)) {", + " cid <- target_ids[[i]]", + " new_id <- new_ids_target[[i]]", + " rec <- items_target[[which(vapply(items_target, function(r) r$phenotype_id == cid, logical(1)))[1]]]", + " cohort_rows[[length(cohort_rows) + 1]] <- data.frame(atlas_id = cid, cohort_id = new_id, cohort_name = rec$phenotype_name %||% paste0('Cohort ', new_id), logic_description = rec$justification %||% NA_character_, generate_stats = TRUE, stringsAsFactors = FALSE)", + "}", + "for (i in seq_along(new_ids_outcome)) {", + " cid <- outcome_ids[[i]]", + " new_id <- new_ids_outcome[[i]]", + " rec <- items_outcome[[which(vapply(items_outcome, function(r) r$phenotype_id == cid, logical(1)))[1]]]", + " cohort_rows[[length(cohort_rows) + 1]] <- data.frame(atlas_id = cid, cohort_id = new_id, cohort_name = rec$phenotype_name %||% paste0('Cohort ', new_id), logic_description = rec$justification %||% NA_character_, generate_stats = TRUE, stringsAsFactors = FALSE)", + "}", + "cohort_df <- do.call(rbind, cohort_rows)", + "write.csv(cohort_df, file.path(selected_dir, 'Cohorts.csv'), row.names = FALSE)", + "" + ) + write_lines(file.path(scripts_dir, "01_recommend_and_select.R"), script_01) + + # 02 - apply improvements + script_02 <- c( + script_header, + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "apply_action <- function(obj, action) {", + " path <- action$path %||% ''", + " value <- action$value", + " if (!nzchar(path)) return(obj)", + " segs <- strsplit(path, '/', fixed = TRUE)[[1]]", + " segs <- segs[segs != '']", + " set_in <- function(x, segs, value) {", + " if (length(segs) == 0) return(value)", + " seg <- segs[[1]]", + " name <- seg", + " idx <- NA_integer_", + " if (grepl('\\\\[\\\\d+\\\\]$', seg)) {", + " name <- sub('\\\\[\\\\d+\\\\]$', '', seg)", + " idx <- as.integer(sub('^.*\\\\[(\\\\d+)\\\\]$', '\\\\1', seg))", + " }", + " if (name != '') {", + " if (is.null(x[[name]])) x[[name]] <- list()", + " if (length(segs) == 1) {", + " if (!is.na(idx)) {", + " if (length(x[[name]]) < idx) while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- NULL", + " x[[name]][[idx]] <- value", + " } else {", + " x[[name]] <- value", + " }", + " return(x)", + " }", + " if (!is.na(idx)) {", + " if (length(x[[name]]) < idx) while (length(x[[name]]) < idx) x[[name]][[length(x[[name]]) + 1]] <- list()", + " x[[name]][[idx]] <- set_in(x[[name]][[idx]], segs[-1], value)", + " } else {", + " x[[name]] <- set_in(x[[name]], segs[-1], value)", + " }", + " return(x)", + " }", + " idx <- suppressWarnings(as.integer(seg))", + " if (is.na(idx)) return(x)", + " if (idx == 0) idx <- 1", + " if (length(x) < idx) while (length(x) < idx) x[[length(x) + 1]] <- list()", + " if (length(segs) == 1) { x[[idx]] <- value; return(x) }", + " x[[idx]] <- set_in(x[[idx]], segs[-1], value)", + " x", + " }", + " set_in(obj, segs, value)", + "}", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "selected_target_dir <- file.path(base_dir, 'selected-target-cohorts')", + "selected_outcome_dir <- file.path(base_dir, 'selected-outcome-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "patched_target_dir <- file.path(base_dir, 'patched-target-cohorts')", + "patched_outcome_dir <- file.path(base_dir, 'patched-outcome-cohorts')", + "dir.create(patched_dir, recursive = TRUE, showWarnings = FALSE)", + "dir.create(patched_target_dir, recursive = TRUE, showWarnings = FALSE)", + "dir.create(patched_outcome_dir, recursive = TRUE, showWarnings = FALSE)", + "improvements_target_path <- file.path(output_dir, 'improvements_target.json')", + "improvements_outcome_path <- file.path(output_dir, 'improvements_outcome.json')", + "improvements_target <- if (file.exists(improvements_target_path)) jsonlite::fromJSON(improvements_target_path, simplifyVector = FALSE) else list()", + "improvements_outcome <- if (file.exists(improvements_outcome_path)) jsonlite::fromJSON(improvements_outcome_path, simplifyVector = FALSE) else list()", + "apply_for_role <- function(improvements, selected_role_dir, patched_role_dir) {", + " for (cid in names(improvements)) {", + " resp <- improvements[[cid]]", + " core <- resp$full_result %||% resp", + " items <- core$phenotype_improvements %||% list()", + " if (length(items) == 0) next", + " cohort_path <- file.path(selected_role_dir, sprintf('%s.json', cid))", + " cohort_obj <- jsonlite::fromJSON(cohort_path, simplifyVector = FALSE)", + " for (item in items) {", + " if (is.null(item$actions)) next", + " for (act in item$actions) cohort_obj <- apply_action(cohort_obj, act)", + " }", + " out_path <- file.path(patched_role_dir, sprintf('%s.json', cid))", + " jsonlite::write_json(cohort_obj, out_path, pretty = TRUE, auto_unbox = TRUE)", + " file.copy(out_path, file.path(patched_dir, sprintf('%s.json', cid)), overwrite = TRUE)", + " }", + "}", + "apply_for_role(improvements_target, selected_target_dir, patched_target_dir)", + "apply_for_role(improvements_outcome, selected_outcome_dir, patched_outcome_dir)", + "" + ) + write_lines(file.path(scripts_dir, "02_apply_improvements.R"), script_02) + + # 03 - generate cohorts + script_03 <- c( + script_header, + "library(Strategus)", + "library(CohortGenerator)", + "library(DatabaseConnector)", + "library(dplyr)", + "library(CirceR)", + "library(SqlRender)", + "", + "# loads the Strategus workflow assistant package when working from the repo", + "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", + " if (requireNamespace('devtools', quietly = TRUE)) {", + " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant')", + " } else {", + " stop('slashOhdsiStrategusAssistant is not installed and devtools::load_all is unavailable.')", + " }", + "}", + "library(slashOhdsiStrategusAssistant)", + "library(jsonlite)", + "library(ParallelLogger)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(selected_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "dbms <- connectionDetails$dbms %||% 'postgresql'", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings()", + "executionSettings_cohorts <- exec$executionSettings", + "cdmDatabaseSchema <- exec$cdmDatabaseSchema", + "workDatabaseSchema <- exec$workDatabaseSchema", + "resultsDatabaseSchema <- exec$resultsDatabaseSchema", + "vocabularyDatabaseSchema <- exec$vocabularyDatabaseSchema", + "cohortTable <- exec$cohortTable", + "cohortIdFieldName <- exec$cohortIdFieldName", + "dir.create(exec$workFolder, recursive = TRUE, showWarnings = FALSE)", + "dir.create(exec$resultsFolder, recursive = TRUE, showWarnings = FALSE)", + "", + "cohort_settings <- read.csv(cohort_csv, stringsAsFactors = FALSE)", + "if (nrow(cohort_settings) > 0) {", + " id_col <- if ('cohort_id' %in% names(cohort_settings)) 'cohort_id' else 'cohortId'", + " for (i in seq_len(nrow(cohort_settings))) {", + " cohort_id <- cohort_settings[[id_col]][i]", + " sql_path <- file.path(sql_dir, sprintf('%s.sql', cohort_id))", + " if (!file.exists(sql_path)) {", + " json_path <- file.path(cohort_json_dir, sprintf('%s.json', cohort_id))", + " if (!file.exists(json_path)) stop('Missing cohort JSON: ', json_path)", + " json_text <- readChar(json_path, nchars = file.info(json_path)$size, useBytes = TRUE)", + " cohort_expression <- CirceR::cohortExpressionFromJson(json_text)", + " generateOptions <- CirceR::createGenerateOptions(", + " cohortIdFieldName = cohortIdFieldName,", + " cdmSchema = cdmDatabaseSchema,", + " targetTable = paste0(workDatabaseSchema, '.', cohortTable),", + " resultSchema = resultsDatabaseSchema,", + " vocabularySchema = vocabularyDatabaseSchema,", + " generateStats = TRUE", + " )", + " sql <- CirceR::buildCohortQuery(cohort_expression, generateOptions)", + " sql <- SqlRender::render(sql)", + " sql <- SqlRender::translate(sql, targetDialect = dbms)", + " writeLines(sql, sql_path, useBytes = TRUE)", + " }", + " }", + "}", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "cohortGeneratorModuleSpecifications <- cgModule$createModuleSpecifications(generateStats = TRUE)", + "", + "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", + " addSharedResources(cohortDefinitionSharedResource) %>%", + " addModuleSpecifications(cohortGeneratorModuleSpecifications)", + "", + "execute(", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = executionSettings_cohorts,", + " connectionDetails = connectionDetails", + ")", + "" + ) + write_lines(file.path(scripts_dir, "03_generate_cohorts.R"), script_03) + + # 04 - Keeper review + script_04 <- c( + script_header, + "library(Keeper)", + "library(jsonlite)", + "library(DatabaseConnector)", + "", + "# loads the Strategus workflow assistant package when working from the repo", + "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", + " if (requireNamespace('devtools', quietly = TRUE)) {", + " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant')", + " } else {", + " stop('slashOhdsiStrategusAssistant is not installed and devtools::load_all is unavailable.')", + " }", + "}", + "library(slashOhdsiStrategusAssistant)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "keeper_dir <- file.path(base_dir, 'keeper-case-review')", + "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", + "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'))$mapping", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings()", + "# TODO: fill in databaseId (used by Keeper for labeling outputs)", + "databaseId <- ''", + "cdmDatabaseSchema <- exec$cdmDatabaseSchema", + "cohortDatabaseSchema <- exec$workDatabaseSchema", + "cohortTable <- exec$cohortTable", + "", + "for (cid in id_map$cohort_id) {", + " keeper <- createKeeper(", + " connectionDetails = connectionDetails,", + " databaseId = databaseId,", + " cdmDatabaseSchema = cdmDatabaseSchema,", + " cohortDatabaseSchema = cohortDatabaseSchema,", + " cohortTable = cohortTable,", + " cohortDefinitionId = cid,", + " cohortName = paste('Cohort', cid),", + " sampleSize = 100,", + " assignNewId = TRUE,", + " useAncestor = TRUE,", + " doi = c(4202064, 192671, 2108878, 2108900, 2002608),", + " symptoms = c(4103703, 443530, 4245614, 28779),", + " comorbidities = c(81893, 201606, 313217, 318800, 432585, 4027663, 4180790, 4212540, + 40481531, 42535737, 46271022),", + " drugs = c(904453, 906780, 923645, 929887, 948078, 953076, 961047, 985247, 992956, + 997276, 1102917, 1113648, 1115008, 1118045, 1118084, 1124300, 1126128, + 1136980, 1146810, 1150345, 1153928, 1177480, 1178663, 1185922, 1195492, + 1236607, 1303425, 1313200, 1353766, 1507835, 1522957, 1721543, 1746940, + 1777806, 19044727, 19119253, 36863425),", + " diagnosticProcedures = c(4087381, 4143985, 4294382, 42872565, 45888171, 46257627),", + " measurements = c(3000905, 3000963, 3003458, 3012471, 3016251, 3018677, 3020416, + 3022217, 3023314, 3024929, 3034426),", + " alternativeDiagnosis = c(24966, 76725, 195562, 316457, 318800, 4096682),", + " treatmentProcedures = c(0),", + " complications = c(132797, 196152, 439777, 4192647)", + " )", + " out_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", + " write.csv(keeper, out_path, row.names = FALSE)", + "}", + "# Optional: if ACP is available, use phenotype_validation_review on rows from keeper_dir.", + "# Uncomment to enable:", + "if (!requireNamespace('slashOhdsiAcpClient', quietly = TRUE) && requireNamespace('devtools', quietly = TRUE)) {", + " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiAcpClient')", + "}", + "if (requireNamespace('slashOhdsiAcpClient', quietly = TRUE) || 'slashOhdsiAcpClient' %in% loadedNamespaces()) {", + " acp_client <- slashOhdsiAcpClient::acp_client('http://127.0.0.1:8765', check = FALSE)", + " roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", + " intent <- jsonlite::fromJSON(file.path(output_dir, 'intent_split.json'), simplifyVector = TRUE)", + " cohort_type <- utils::select.list(c('target', 'outcome'), title = 'Keeper review for which cohort type?')", + " if (!nzchar(cohort_type)) stop('No cohort type selected.')", + " get_intent_field <- function(obj, field) {", + " if (!is.null(obj$intent_split) && !is.null(obj$intent_split[[field]])) return(obj$intent_split[[field]])", + " obj[[field]]", + " }", + " default_disease <- if (cohort_type == 'target') get_intent_field(intent, 'target_statement') else get_intent_field(intent, 'outcome_statement')", + " disease_name <- readline(sprintf('Disease name [%s]: ', default_disease))", + " if (!nzchar(trimws(disease_name))) disease_name <- default_disease", + " sample_n <- as.integer(readline('How many cases per cohort to review? [5]: '))", + " if (is.na(sample_n) || sample_n <= 0) sample_n <- 5", + " random_pick <- tolower(trimws(readline('Randomly sample cases? [Y/n]: ')))", + " random_pick <- !(random_pick %in% c('n', 'no'))", + " write_output <- tolower(trimws(readline('Write LLM review rows to file? [Y/n]: ')))", + " write_output <- !(write_output %in% c('n', 'no'))", + " cohort_ids <- if (cohort_type == 'target') roles$targets else roles$outcomes", + " for (cid in cohort_ids) {", + " keeper_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", + " keeper_rows <- read.csv(keeper_path, stringsAsFactors = FALSE)", + " if (nrow(keeper_rows) == 0) next", + " n <- min(sample_n, nrow(keeper_rows))", + " idx <- if (random_pick) sample(seq_len(nrow(keeper_rows)), n) else seq_len(n)", + " selected <- keeper_rows[idx, , drop = FALSE]", + " results <- list()", + " for (i in seq_len(nrow(selected))) {", + " row_payload <- as.list(selected[i, , drop = FALSE])", + " row_payload <- lapply(row_payload, function(x) if (length(x) == 0) NA else x)", + " resp <- slashOhdsiAcpClient::acp_call_flow(", + " acp_client,", + " 'phenotype_validation_review',", + " list(keeper_row = row_payload, disease_name = disease_name)", + " )", + " if (!is.null(resp$status) && resp$status == 'error') {", + " out <- c(row_payload, list(label = NA, rationale = NA, acp_error = resp$error %||% 'acp_error'))", + " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", + " next", + " }", + " label <- resp$label", + " rationale <- resp$rationale", + " if (is.null(label) && !is.null(resp$full_result$label)) label <- resp$full_result$label", + " if (is.null(rationale) && !is.null(resp$full_result$rationale)) rationale <- resp$full_result$rationale", + " if (is.null(label) && !is.null(resp$result$label)) label <- resp$result$label", + " if (is.null(rationale) && !is.null(resp$result$rationale)) rationale <- resp$result$rationale", + " if (is.null(label)) label <- NA", + " if (is.null(rationale)) rationale <- NA", + " out <- c(row_payload, list(label = label, rationale = rationale))", + " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", + " }", + " if (length(results) > 0) {", + " out_df <- do.call(rbind, results)", + " if (write_output) {", + " out_path <- file.path(keeper_dir, sprintf('%s_llm_review.csv', cid))", + " write.csv(out_df, out_path, row.names = FALSE)", + " }", + " print(out_df)", + " }", + " }", + " }", + "" + ) + write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) + + # 05 - diagnostics + script_05 <- c( + script_header, + "library(Strategus)", + "library(CohortDiagnostics)", + "library(CohortGenerator)", + "library(DatabaseConnector)", + "library(dplyr)", + "", + "# loads the Strategus workflow assistant package when working from the repo", + "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", + " if (requireNamespace('devtools', quietly = TRUE)) {", + " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant')", + " } else {", + " stop('slashOhdsiStrategusAssistant is not installed and devtools::load_all is unavailable.')", + " }", + "}", + "library(slashOhdsiStrategusAssistant)", + "library(jsonlite)", + "library(ParallelLogger)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(selected_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path='')", + "executionSettings_diagnostics <- exec$executionSettings", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "", + "cdModule <- CohortDiagnosticsModule$new()", + "cohortDiagnosticsModuleSpecifications <- cdModule$createModuleSpecifications(", + " runInclusionStatistics = TRUE,", + " runIncludedSourceConcepts = TRUE,", + " runOrphanConcepts = TRUE,", + " runTimeSeries = FALSE,", + " runVisitContext = TRUE,", + " runBreakdownIndexEvents = TRUE,", + " runIncidenceRate = TRUE,", + " runCohortRelationship = TRUE,", + " runTemporalCohortCharacterization = TRUE", + ")", + "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", + " addSharedResources(cohortDefinitionSharedResource) %>%", + " addModuleSpecifications(cohortDiagnosticsModuleSpecifications)", + "", + " execute(", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = executionSettings_diagnostics,", + " connectionDetails = connectionDetails", + " )", + "" + ) + write_lines(file.path(scripts_dir, "05_diagnostics.R"), script_05) + + # 06 - incidence spec + script_06 <- c( + script_header, + "library(Strategus)", + "library(CohortGenerator)", + "library(CohortIncidence)", + "library(DatabaseConnector)", + "library(dplyr)", + "", + "# loads the Strategus workflow assistant package when working from the repo", + "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", + " if (requireNamespace('devtools', quietly = TRUE)) {", + " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant')", + " } else {", + " stop('slashOhdsiStrategusAssistant is not installed and devtools::load_all is unavailable.')", + " }", + "}", + "library(slashOhdsiStrategusAssistant)", + "library(jsonlite)", + "library(ParallelLogger)", + "`%||%` <- function(x, y) if (is.null(x)) y else x", + "", + sprintf("base_dir <- '%s'", base_dir), + "output_dir <- file.path(base_dir, 'outputs')", + "analysis_settings_dir <- file.path(base_dir, 'analysis-settings')", + "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", + "selected_dir <- file.path(base_dir, 'selected-cohorts')", + "patched_dir <- file.path(base_dir, 'patched-cohorts')", + "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", + "cohort_json_dir <- if (length(list.files(patched_dir, pattern = '\\\\.(json)$')) > 0) patched_dir else selected_dir", + "sql_dir <- file.path(selected_dir, 'sql')", + "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", + "", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path='')", + "executionSettings_incidence <- exec$executionSettings", + "", + "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", + " settingsFileName = cohort_csv,", + " jsonFolder = cohort_json_dir,", + " sqlFolder = sql_dir", + ")", + "", + "roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", + "target_ids <- as.character(roles$targets %||% character(0))", + "outcome_ids <- as.character(roles$outcomes %||% character(0))", + "if (length(target_ids) == 0) stop('No target cohorts defined in cohort_roles.json')", + "if (length(outcome_ids) == 0) stop('No outcome cohorts defined in cohort_roles.json')", + "cgModule <- CohortGeneratorModule$new()", + "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", + " cohortDefinitionSet = cohortDefinitionSet", + ")", + "targets <- lapply(target_ids, function(id) {", + " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", + " CohortIncidence::createCohortRef(id = id, name = row$cohortName[1])", + "})", + "outcomes <- lapply(outcome_ids, function(id) {", + " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", + " CohortIncidence::createOutcomeDef(id = id, name = row$cohortName[1])", + "})", + "", + "tars <- list(", + " CohortIncidence::createTimeAtRiskDef(id = 1, startWith = 'start', endWith = 'end'),", + " CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)", + ")", + "", + "analysis1 <- CohortIncidence::createIncidenceAnalysis(", + " targets = sapply(targets, function(x) x$id),", + " outcomes = sapply(outcomes, function(x) x$id),", + " tars = c(1, 2)", + ")", + "", + "irDesign <- CohortIncidence::createIncidenceDesign(", + " targetDefs = targets,", + " outcomeDefs = outcomes,", + " tars = tars,", + " analysisList = list(analysis1),", + " strataSettings = CohortIncidence::createStrataSettings(byYear = TRUE, byGender = TRUE)", + ")", + "", + "ciModule <- CohortIncidenceModule$new()", + "cohortIncidenceModuleSpecifications <- ciModule$createModuleSpecifications(", + " irDesign = irDesign$toList()", + ")", + "", + "analysisSpecifications <- createEmptyAnalysisSpecifications() %>%", + " addSharedResources(cohortDefinitionSharedResource) %>%", + " addModuleSpecifications(cohortIncidenceModuleSpecifications)", + "analysis_spec_path <- file.path(analysis_settings_dir, 'analysisSpecification.json')", + "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)", + "", + " execute(", + " analysisSpecifications = analysisSpecifications,", + " executionSettings = executionSettings_incidence,", + " connectionDetails = connectionDetails", + " )", + "" + ) + write_lines(file.path(scripts_dir, "06_incidence_spec.R"), script_06) + + if (interactive) { + cat("\n== Session Summary ==\n") + cat("Target cohort statement:\n") + cat(sprintf(" %s\n", target_statement)) + cat("Outcome cohort statement:\n") + cat(sprintf(" %s\n", outcome_statement)) + cat("Target cohorts:\n") + for (i in seq_along(new_ids_target)) { + rec <- recommendations_target[[which(vapply(recommendations_target, function(r) r$phenotype_id == selected_ids_target[[i]], logical(1)))]] + cat(sprintf(" - %s (atlas %s -> cohort %s)\n", rec$phenotype_name %||% "", selected_ids_target[[i]], new_ids_target[[i]])) + } + cat("Outcome cohorts:\n") + for (i in seq_along(new_ids_outcome)) { + rec <- recommendations_outcome[[which(vapply(recommendations_outcome, function(r) r$phenotype_id == selected_ids_outcome[[i]], logical(1)))]] + cat(sprintf(" - %s (atlas %s -> cohort %s)\n", rec$phenotype_name %||% "", selected_ids_outcome[[i]], new_ids_outcome[[i]])) + } + cat("JSON outputs:\n") + cat(sprintf(" - Selected target cohorts: %s\n", selected_target_dir)) + cat(sprintf(" - Selected outcome cohorts: %s\n", selected_outcome_dir)) + cat(sprintf(" - Selected cohorts (combined): %s\n", selected_dir)) + if (improvements_applied) { + cat(sprintf(" - Patched target cohorts: %s\n", patched_target_dir)) + cat(sprintf(" - Patched outcome cohorts: %s\n", patched_outcome_dir)) + cat(sprintf(" - Patched cohorts (combined): %s\n", patched_dir)) + } else { + cat(" - Patched cohorts: (not applied)\n") + } + cat("Scripts written:\n") + cat(sprintf(" - %s\n", scripts_dir)) + cat("Recommended run order (if you want to re-run outside the shell):\n") + cat(" 1) Rscript scripts/03_generate_cohorts.R\n") + cat(" 2) Rscript scripts/04_keeper_review.R\n") + cat(" 3) Rscript scripts/05_diagnostics.R\n") + cat(" 4) Rscript scripts/06_incidence_spec.R\n") + cat("Notes:\n") + if (improvements_applied) { + cat(" - Improvements were already applied in this session; scripts are a portable record.\n") + } else { + cat(" - Improvements were not applied; see scripts/02_apply_improvements.R if desired.\n") + } + cat(sprintf("Session state saved to %s\n", state_path)) + } + message("Study agent shell complete. Scripts written to: ", scripts_dir) + invisible(list( + output_dir = output_dir, + scripts_dir = scripts_dir, + intent_split = intent_split_path, + recommendations_target = recs_target_path, + recommendations_outcome = recs_outcome_path, + improvements_target = improvements_target_path, + improvements_outcome = improvements_outcome_path, + cohort_csv = cohort_csv + )) +} diff --git a/R/slashOhdsiStrategusAssistant/README.md b/R/slashOhdsiStrategusAssistant/README.md index 04959df..86ac5fa 100644 --- a/R/slashOhdsiStrategusAssistant/README.md +++ b/R/slashOhdsiStrategusAssistant/README.md @@ -5,8 +5,16 @@ This package is the workflow layer for the slash-ohdsi R refactor. It owns: - workflow-stage context construction -- Strategus shell orchestration +- interactive Strategus shell entrypoints - checkpointing and artifact layout - generated Strategus assets +- Strategus DB and execution-settings helpers + +Primary entrypoints: + +- `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` +- `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` +- `slashOhdsiStrategusAssistant::createStrategusConnectionDetails()` +- `slashOhdsiStrategusAssistant::createStrategusExecutionSettings()` It depends on `slashOhdsiAcpClient` for ACP calls. diff --git a/R/slashOhdsiStrategusAssistant/inst/templates/cmAnalysis_template.json b/R/slashOhdsiStrategusAssistant/inst/templates/cmAnalysis_template.json new file mode 100644 index 0000000..ac3b96b --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/inst/templates/cmAnalysis_template.json @@ -0,0 +1,65 @@ +{ + "description": "", + "getDbCohortMethodDataArgs": { + "studyStartDate": "", + "studyEndDate": "", + "firstExposureOnly": false, + "removeDuplicateSubjects": "keep all", + "restrictToCommonPeriod": false, + "washoutPeriod": 365, + "maxCohortSize": 0 + }, + "createStudyPopArgs": { + "removeSubjectsWithPriorOutcome": true, + "priorOutcomeLookback": 99999, + "minDaysAtRisk": 1, + "riskWindowStart": 1, + "startAnchor": "cohort start", + "riskWindowEnd": 0, + "endAnchor": "cohort end", + "censorAtNewRiskWindow": false + }, + "trimByPsArgs": null, + "matchOnPsArgs": { + "maxRatio": 1, + "caliper": 0.2, + "caliperScale": "standardized logit" + }, + "stratifyByPsArgs": null, + "createPsArgs": { + "maxCohortSizeForFitting": 250000, + "errorOnHighCorrelation": true, + "prior": { + "priorType": "laplace", + "useCrossValidation": true + }, + "control": { + "tolerance": 2e-7, + "cvType": "auto", + "fold": 10, + "cvRepetitions": 10, + "noiseLevel": "silent", + "resetCoefficients": true, + "startingVariance": 0.01 + } + }, + "fitOutcomeModelArgs": { + "modelType": "cox", + "stratified": false, + "useCovariates": false, + "inversePtWeighting": false, + "prior": { + "priorType": "laplace", + "useCrossValidation": true + }, + "control": { + "tolerance": 2e-7, + "cvType": "auto", + "fold": 10, + "cvRepetitions": 10, + "noiseLevel": "quiet", + "resetCoefficients": true, + "startingVariance": 0.01 + } + } +} diff --git a/README.md b/README.md index 056b9dd..0dc3f90 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The project separates orchestration from deterministic tooling: - `acp_agent/`: ACP server that exposes the flow endpoints and handles LLM orchestration - `mcp_server/`: MCP server that exposes retrieval, prompt, vocabulary, and Keeper tools - `core/`: pure validation and business logic shared by ACP and MCP -- `R/OHDSIAssistant/`: R-side shell for the Strategus incidence workflow +- `R/slashOhdsiStrategusAssistant/`: R-side Strategus workflow package and canonical shell entrypoints ## What Problems This Solves @@ -98,7 +98,7 @@ Use this when you need a defensible starting cohort definition for a target or o 3. Review returned candidates and diagnostics 4. If needed, call `phenotype_recommendation_advice` for next-step guidance 5. Optionally call `phenotype_improvements` on a selected cohort -6. If you are working in R, continue through `runStrategusIncidenceShell()` +6. If you are working in R, continue through `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` ### Workflow B: Go from clinical event to keeper-assisted validation review diff --git a/docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md b/docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md index 82c8a30..67af23a 100644 --- a/docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md +++ b/docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md @@ -12,7 +12,7 @@ The flow endpoint is: One available R wrapper is: ```r -OHDSIAssistant::suggestCohortMethodSpecs() +slashOhdsiStrategusAssistant::suggestCohortMethodSpecs() ``` ## Purpose diff --git a/docs/COHORT_METHODS_WORKFLOW.md b/docs/COHORT_METHODS_WORKFLOW.md index 26ba68d..1070df9 100644 --- a/docs/COHORT_METHODS_WORKFLOW.md +++ b/docs/COHORT_METHODS_WORKFLOW.md @@ -1,6 +1,6 @@ **Cohort Methods Workflow** -This document captures the current cohort-methods workflow implemented by `OHDSIAssistant::runStrategusCohortMethodsShell()` and how it fits into a broader Strategus execution pipeline. +This document captures the current cohort-methods workflow implemented by `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` and how it fits into a broader Strategus execution pipeline. ## Shell Workflow (Target/Comparator/Outcome + Analytic Settings) diff --git a/docs/INCIDENCE_WORKFLOW.md b/docs/INCIDENCE_WORKFLOW.md index c0abfa6..8f3b871 100644 --- a/docs/INCIDENCE_WORKFLOW.md +++ b/docs/INCIDENCE_WORKFLOW.md @@ -1,7 +1,7 @@ **Incidence Workflow** This document captures the current incidence-rate workflow implemented by -`OHDSIAssistant::runStrategusIncidenceShell()` and how it fits into a broader +`slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` and how it fits into a broader Strategus execution pipeline. ## Shell Workflow (Target/Outcome Orchestration) diff --git a/docs/STRATEGUS_COHORT_METHODS_SHELL.md b/docs/STRATEGUS_COHORT_METHODS_SHELL.md index eff9c89..9891812 100644 --- a/docs/STRATEGUS_COHORT_METHODS_SHELL.md +++ b/docs/STRATEGUS_COHORT_METHODS_SHELL.md @@ -7,11 +7,11 @@ Current stage scope: - The shell can configure one effective analytic-settings profile through `step_by_step` prompts or `free_text` ACP recommendation. - The shell writes reproducible R scripts, a Strategus analysis specification, and a merged CohortMethod execution script. -This shell is provided as `OHDSIAssistant::runStrategusCohortMethodsShell()`. +This shell is provided as `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()`. ## Running -Usage examples for `OHDSIAssistant::runStrategusCohortMethodsShell()` live in the R package README: `R/OHDSIAssistant/README.md`. +Usage examples for `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` live in the R package README: `R/slashOhdsiStrategusAssistant/README.md`. Workflow diagrams live in `docs/COHORT_METHODS_WORKFLOW.md`. diff --git a/docs/STRATEGUS_SHELL.md b/docs/STRATEGUS_SHELL.md index 849a5de..fc33c3f 100644 --- a/docs/STRATEGUS_SHELL.md +++ b/docs/STRATEGUS_SHELL.md @@ -1,7 +1,7 @@ **Strategus Incidence Shell (R)** This document summarizes the interactive Strategus incidence shell provided by -`OHDSIAssistant::runStrategusIncidenceShell()`. The shell is intended for +`slashOhdsiStrategusAssistant::runStrategusIncidenceShell()`. The shell is intended for interactive use in R/RStudio and guides users through phenotype selection and script generation for a CohortIncidence analysis. diff --git a/tests/test_cohort_methods_generated_scripts.py b/tests/test_cohort_methods_generated_scripts.py index 1bc2edb..d941ee5 100644 --- a/tests/test_cohort_methods_generated_scripts.py +++ b/tests/test_cohort_methods_generated_scripts.py @@ -5,8 +5,8 @@ import pytest -SOURCE = Path("R/OHDSIAssistant/R/strategus_cohort_methods_shell.R") -EXECUTION_SETTINGS_SOURCE = Path("R/OHDSIAssistant/R/execution_settings.R") +SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") +EXECUTION_SETTINGS_SOURCE = Path("R/slashOhdsiStrategusAssistant/R/execution_settings.R") def _generated_script_block(source: str, script_name: str, filename: str) -> str: @@ -55,8 +55,8 @@ def test_generated_cm_spec_builds_and_executes_strategus_analysis_specification( assert "cmAnalysesSpecifications = cmAnalysesSpecifications$toList()" in block assert "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)" in block assert "result <- Strategus::execute(" in block - assert "connectionDetails <- OHDSIAssistant::createStrategusConnectionDetails(path = db_details_path)" in block - assert "exec <- OHDSIAssistant::createStrategusExecutionSettings(path = execution_settings_path)" in block + assert "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)" in block + assert "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)" in block assert "CohortMethod::runCmAnalyses(" not in block assert "CohortMethod::loadCmAnalysisList(" not in block assert "CohortMethod::loadTargetComparatorOutcomesList(" not in block diff --git a/tests/test_cohort_methods_shell_recommendation_support.py b/tests/test_cohort_methods_shell_recommendation_support.py index 3c480e6..3fd3b97 100644 --- a/tests/test_cohort_methods_shell_recommendation_support.py +++ b/tests/test_cohort_methods_shell_recommendation_support.py @@ -1,6 +1,6 @@ from pathlib import Path -SOURCE = Path("R/OHDSIAssistant/R/strategus_cohort_methods_shell.R") +SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") def test_shell_supports_namespaced_recommendation_ids_and_blocks_unsupported_selection() -> None: From 22f49655f6696a0af482d4f9c8f8e2a4b7866613 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 11:06:50 +0000 Subject: [PATCH 06/20] Completed the work needed to remove OHDSIAssistant and replace with the 2 new R packages. Updated the R test scripts and added a new one --- CODING_AGENT_README.md | 3 +- CohortMethod_HANDOFF.md | 8 +- R/OHDSIAssistant/DESCRIPTION | 12 -- R/OHDSIAssistant/NAMESPACE | 18 --- R/OHDSIAssistant/R/acp_client.R | 11 -- R/OHDSIAssistant/R/cohort_methods_workflow.R | 16 --- R/OHDSIAssistant/R/concept_set_actions.R | 32 ----- R/OHDSIAssistant/R/db_details.R | 22 ---- R/OHDSIAssistant/R/execution_settings.R | 22 ---- R/OHDSIAssistant/R/lintStudyDesign.R | 26 ---- R/OHDSIAssistant/R/ops_llm_actions.R | 16 --- R/OHDSIAssistant/R/phenotype_workflow.R | 104 --------------- R/OHDSIAssistant/R/rules_local_fallback.R | 1 - .../R/strategus_cohort_methods_shell.R | 10 -- .../R/strategus_incidence_shell.R | 10 -- R/OHDSIAssistant/R/utils_json.R | 1 - R/OHDSIAssistant/README.md | 26 ---- R/OHDSIAssistant/inst/assistant/.gitkeep | 1 - R/OHDSIAssistant/man/.gitkeep | 1 - R/slashOhdsiAcpClient/R/flows.R | 33 ++++- docs/R_PACKAGE_ARCHITECTURE_PLAN.md | 18 +-- scripts/demo_ohdsi_dialogue.R | 124 ++++++++++++++++++ scripts/demo_setup.R | 73 +++++++++++ scripts/test_cohort_critique_general_design.R | 36 +++-- scripts/test_concept_sets_review.R | 38 +++--- scripts/test_phenotype_improvements.R | 48 ++++--- scripts/test_phenotype_recommendations.R | 82 +++++++----- scripts/test_phenotype_validation_review.R | 30 +++-- .../test_strategus_incidence_plus_keeper.R | 100 ++++++++------ ...est_r_workflow_context_dialogue_wrapper.py | 28 ++++ 30 files changed, 472 insertions(+), 478 deletions(-) delete mode 100644 R/OHDSIAssistant/DESCRIPTION delete mode 100644 R/OHDSIAssistant/NAMESPACE delete mode 100644 R/OHDSIAssistant/R/acp_client.R delete mode 100644 R/OHDSIAssistant/R/cohort_methods_workflow.R delete mode 100644 R/OHDSIAssistant/R/concept_set_actions.R delete mode 100644 R/OHDSIAssistant/R/db_details.R delete mode 100644 R/OHDSIAssistant/R/execution_settings.R delete mode 100644 R/OHDSIAssistant/R/lintStudyDesign.R delete mode 100644 R/OHDSIAssistant/R/ops_llm_actions.R delete mode 100644 R/OHDSIAssistant/R/phenotype_workflow.R delete mode 100644 R/OHDSIAssistant/R/rules_local_fallback.R delete mode 100644 R/OHDSIAssistant/R/strategus_cohort_methods_shell.R delete mode 100644 R/OHDSIAssistant/R/strategus_incidence_shell.R delete mode 100644 R/OHDSIAssistant/R/utils_json.R delete mode 100644 R/OHDSIAssistant/README.md delete mode 100644 R/OHDSIAssistant/inst/assistant/.gitkeep delete mode 100644 R/OHDSIAssistant/man/.gitkeep create mode 100644 scripts/demo_ohdsi_dialogue.R create mode 100644 scripts/demo_setup.R create mode 100644 tests/test_r_workflow_context_dialogue_wrapper.py diff --git a/CODING_AGENT_README.md b/CODING_AGENT_README.md index 1d3385d..2c496f0 100644 --- a/CODING_AGENT_README.md +++ b/CODING_AGENT_README.md @@ -16,7 +16,8 @@ The top-level [README.md](/ai-agent/HadesProject/OHDSI-Study-Agent/README.md:1) - `acp_agent/`: ACP server and user-facing flow orchestration - `mcp_server/`: MCP tool server, prompt bundles, retrieval, vocabulary, and Keeper tooling - `core/`: pure deterministic logic and validation -- `R/OHDSIAssistant/`: R Strategus shell +- `R/slashOhdsiAcpClient/`: R ACP client package and thin flow/action wrappers +- `R/slashOhdsiStrategusAssistant/`: R Strategus workflow package and shell entrypoints - `docs/`: primary documentation, including testing, roadmap, Keeper specs, and moved legacy docs - `demo/`: sample artifacts and cohort JSON files diff --git a/CohortMethod_HANDOFF.md b/CohortMethod_HANDOFF.md index e2dadae..e460aad 100644 --- a/CohortMethod_HANDOFF.md +++ b/CohortMethod_HANDOFF.md @@ -2,7 +2,7 @@ ## What Was Implemented We added the current CohortMethod shell path around -`OHDSIAssistant::runStrategusCohortMethodsShell()`. In broad terms, the work now +`slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()`. In broad terms, the work now supports: - CohortMethod-specific intent splitting from one study-intent sentence into @@ -29,8 +29,8 @@ supports: - MCP tool: - `cohort_methods_intent_split` - MCP tool: `cohort_methods_prompt_bundle` -- R helper: `OHDSIAssistant::suggestCohortMethodSpecs()` -- R shell integration: `runStrategusCohortMethodsShell()` +- R helper: `slashOhdsiStrategusAssistant::suggestCohortMethodSpecs()` +- R shell integration: `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` - Prompt/template assets: - CohortMethod intent-split overview/spec/schema assets. - CohortMethod cmAnalysis template and instruction assets under @@ -58,7 +58,7 @@ supports: - `mcp_server/prompts/phenotype/spec_cohort_methods_intent_split.md` - `mcp_server/prompts/phenotype/output_schema_cohort_methods_intent_split.json` - R usage examples: - - `R/OHDSIAssistant/README.md` + - `R/slashOhdsiStrategusAssistant/README.md` - Test and smoke-test commands: - `docs/TESTING.md` diff --git a/R/OHDSIAssistant/DESCRIPTION b/R/OHDSIAssistant/DESCRIPTION deleted file mode 100644 index ed8938d..0000000 --- a/R/OHDSIAssistant/DESCRIPTION +++ /dev/null @@ -1,12 +0,0 @@ -Package: OHDSIAssistant -Type: Package -Title: Assistant Prototype for OHDSI Study Design Linting -Version: 0.0.1 -Authors@R: person("Your","Name", email="you@example.org", role=c("aut","cre")) -Description: Compatibility layer that forwards legacy OHDSIAssistant entrypoints to slashOhdsiAcpClient and slashOhdsiStrategusAssistant. -License: MIT + file LICENSE -Encoding: UTF-8 -Depends: R (>= 4.2) -Imports: jsonlite, httr, utils, slashOhdsiAcpClient, slashOhdsiStrategusAssistant -Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 diff --git a/R/OHDSIAssistant/NAMESPACE b/R/OHDSIAssistant/NAMESPACE deleted file mode 100644 index c87e423..0000000 --- a/R/OHDSIAssistant/NAMESPACE +++ /dev/null @@ -1,18 +0,0 @@ -export(acp_connect) -export(lintStudyDesign) -export(proposeIncludeDescendantsPatch) -export(previewConceptSetPatch) -export(applyConceptSetPatch) -export(applyLLMActionsConceptSet) -export(suggestPhenotypes) -export(pullPhenotypeDefinitions) -export(reviewPhenotypes) -export(selectPhenotypeRecommendations) -export(selectPhenotypeImprovements) -export(runStrategusIncidenceShell) -export(readStrategusDbDetails) -export(createStrategusConnectionDetails) -export(readStrategusExecutionSettings) -export(createStrategusExecutionSettings) -export(runStrategusCohortMethodsShell) -export(suggestCohortMethodSpecs) diff --git a/R/OHDSIAssistant/R/acp_client.R b/R/OHDSIAssistant/R/acp_client.R deleted file mode 100644 index 250639d..0000000 --- a/R/OHDSIAssistant/R/acp_client.R +++ /dev/null @@ -1,11 +0,0 @@ -#' Connect to ACP bridge -#' @param url e.g. "http://127.0.0.1:8765" -#' @param token optional bearer token -#' @return invisible TRUE -#' @export -acp_connect <- function(url = "http://127.0.0.1:8765", token = NULL) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use acp_connect().") - } - slashOhdsiAcpClient::acp_connect(url = url, token = token) -} diff --git a/R/OHDSIAssistant/R/cohort_methods_workflow.R b/R/OHDSIAssistant/R/cohort_methods_workflow.R deleted file mode 100644 index ae13075..0000000 --- a/R/OHDSIAssistant/R/cohort_methods_workflow.R +++ /dev/null @@ -1,16 +0,0 @@ -#' Suggest cohort method study specifications from a free-text description (compatibility wrapper) -#' @inheritParams slashOhdsiStrategusAssistant::suggestCohortMethodSpecs -#' @return list response from ACP flow or local stub -#' @export -suggestCohortMethodSpecs <- function(studyIntent, - analyticSettingsDescription, - interactive = TRUE) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to use suggestCohortMethodSpecs().") - } - slashOhdsiStrategusAssistant::suggestCohortMethodSpecs( - studyIntent = studyIntent, - analyticSettingsDescription = analyticSettingsDescription, - interactive = interactive - ) -} diff --git a/R/OHDSIAssistant/R/concept_set_actions.R b/R/OHDSIAssistant/R/concept_set_actions.R deleted file mode 100644 index 9352d50..0000000 --- a/R/OHDSIAssistant/R/concept_set_actions.R +++ /dev/null @@ -1,32 +0,0 @@ -#' Compatibility wrapper for concept set patch proposal -#' @inheritParams slashOhdsiAcpClient::proposeIncludeDescendantsPatch -#' @export -proposeIncludeDescendantsPatch <- function(conceptSetRef) { - slashOhdsiAcpClient::proposeIncludeDescendantsPatch(conceptSetRef) -} - -#' Compatibility wrapper for concept set patch preview -#' @inheritParams slashOhdsiAcpClient::previewConceptSetPatch -#' @export -previewConceptSetPatch <- function(conceptSetRef, patch) { - slashOhdsiAcpClient::previewConceptSetPatch(conceptSetRef, patch) -} - -#' Compatibility wrapper for concept set patch application -#' @inheritParams slashOhdsiAcpClient::applyConceptSetPatch -#' @export -applyConceptSetPatch <- function(conceptSetRef, - patch, - backup = TRUE, - outputPath = NULL, - useActions = NULL, - overwrite = TRUE) { - slashOhdsiAcpClient::applyConceptSetPatch( - conceptSetRef = conceptSetRef, - patch = patch, - backup = backup, - outputPath = outputPath, - useActions = useActions, - overwrite = overwrite - ) -} diff --git a/R/OHDSIAssistant/R/db_details.R b/R/OHDSIAssistant/R/db_details.R deleted file mode 100644 index ab10f0c..0000000 --- a/R/OHDSIAssistant/R/db_details.R +++ /dev/null @@ -1,22 +0,0 @@ -#' Read Strategus database details from JSON (compatibility wrapper) -#' @inheritParams slashOhdsiStrategusAssistant::readStrategusDbDetails -#' @return list of db settings -#' @export -readStrategusDbDetails <- function(path = file.path(getwd(), "strategus-db-details.json")) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to use readStrategusDbDetails().") - } - slashOhdsiStrategusAssistant::readStrategusDbDetails(path = path) -} - -#' Create DatabaseConnector connectionDetails from strategus-db-details.json (compatibility wrapper) -#' @inheritParams slashOhdsiStrategusAssistant::createStrategusConnectionDetails -#' @return DatabaseConnector connectionDetails object -#' @export -createStrategusConnectionDetails <- function(path = file.path(getwd(), "strategus-db-details.json"), - dbDetails = NULL) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to use createStrategusConnectionDetails().") - } - slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = path, dbDetails = dbDetails) -} diff --git a/R/OHDSIAssistant/R/execution_settings.R b/R/OHDSIAssistant/R/execution_settings.R deleted file mode 100644 index e279b5c..0000000 --- a/R/OHDSIAssistant/R/execution_settings.R +++ /dev/null @@ -1,22 +0,0 @@ -#' Read Strategus execution settings from JSON (compatibility wrapper) -#' @inheritParams slashOhdsiStrategusAssistant::readStrategusExecutionSettings -#' @return list of execution settings -#' @export -readStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json")) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to use readStrategusExecutionSettings().") - } - slashOhdsiStrategusAssistant::readStrategusExecutionSettings(path = path) -} - -#' Create Strategus execution settings from JSON (compatibility wrapper) -#' @inheritParams slashOhdsiStrategusAssistant::createStrategusExecutionSettings -#' @return list with executionSettings and resolved values -#' @export -createStrategusExecutionSettings <- function(path = file.path(getwd(), "strategus-execution-settings.json"), - settings = NULL) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to use createStrategusExecutionSettings().") - } - slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = path, settings = settings) -} diff --git a/R/OHDSIAssistant/R/lintStudyDesign.R b/R/OHDSIAssistant/R/lintStudyDesign.R deleted file mode 100644 index 851197f..0000000 --- a/R/OHDSIAssistant/R/lintStudyDesign.R +++ /dev/null @@ -1,26 +0,0 @@ -#' Compatibility wrapper for study design linting -#' @inheritParams slashOhdsiAcpClient::lintStudyDesign -#' @export -lintStudyDesign <- function(studyProtocol, - studyPackage = ".", - lintTasks = c("concept-sets-review", "cohort-critique-general-design"), - apply = FALSE, - interactive = TRUE, - streamThoughts = TRUE, - handleActions = FALSE, - applyActions = FALSE, - overwriteActions = FALSE, - backupActions = TRUE) { - slashOhdsiAcpClient::lintStudyDesign( - studyProtocol = studyProtocol, - studyPackage = studyPackage, - lintTasks = lintTasks, - apply = apply, - interactive = interactive, - streamThoughts = streamThoughts, - handleActions = handleActions, - applyActions = applyActions, - overwriteActions = overwriteActions, - backupActions = backupActions - ) -} diff --git a/R/OHDSIAssistant/R/ops_llm_actions.R b/R/OHDSIAssistant/R/ops_llm_actions.R deleted file mode 100644 index 4c9a26e..0000000 --- a/R/OHDSIAssistant/R/ops_llm_actions.R +++ /dev/null @@ -1,16 +0,0 @@ -#' Compatibility wrapper for ACP concept-set LLM actions -#' @inheritParams slashOhdsiAcpClient::applyLLMActionsConceptSet -#' @export -applyLLMActionsConceptSet <- function(conceptSetRef, - actions, - preview = TRUE, - overwrite = FALSE, - backup = TRUE) { - slashOhdsiAcpClient::applyLLMActionsConceptSet( - conceptSetRef = conceptSetRef, - actions = actions, - preview = preview, - overwrite = overwrite, - backup = backup - ) -} diff --git a/R/OHDSIAssistant/R/phenotype_workflow.R b/R/OHDSIAssistant/R/phenotype_workflow.R deleted file mode 100644 index a3ba6fb..0000000 --- a/R/OHDSIAssistant/R/phenotype_workflow.R +++ /dev/null @@ -1,104 +0,0 @@ -#' Suggest phenotypes for a study protocol (compatibility wrapper) -#' @inheritParams slashOhdsiAcpClient::suggestPhenotypes -#' @return list response from ACP flow or local stub -#' @export -suggestPhenotypes <- function(protocolPath = NULL, - studyIntent = NULL, - topK = 20, - maxResults = 3, - candidateLimit = 10, - interactive = TRUE) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use suggestPhenotypes().") - } - slashOhdsiAcpClient::suggestPhenotypes( - protocolPath = protocolPath, - studyIntent = studyIntent, - topK = topK, - maxResults = maxResults, - candidateLimit = candidateLimit, - interactive = interactive - ) -} - -#' Pull phenotype definitions to a local folder (compatibility wrapper) -#' @inheritParams slashOhdsiAcpClient::pullPhenotypeDefinitions -#' @return character vector of written file paths -#' @export -pullPhenotypeDefinitions <- function(cohortIds, - outputDir = ".", - overwrite = FALSE) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use pullPhenotypeDefinitions().") - } - slashOhdsiAcpClient::pullPhenotypeDefinitions( - cohortIds = cohortIds, - outputDir = outputDir, - overwrite = overwrite - ) -} - -#' Review phenotype definitions for improvements (compatibility wrapper) -#' @inheritParams slashOhdsiAcpClient::reviewPhenotypes -#' @return list response from ACP flow or local stub -#' @export -reviewPhenotypes <- function(protocolPath, - cohortJsonPaths, - characterizationPaths = NULL, - interactive = TRUE, - apply = FALSE, - select = NULL, - outputDir = NULL) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use reviewPhenotypes().") - } - slashOhdsiAcpClient::reviewPhenotypes( - protocolPath = protocolPath, - cohortJsonPaths = cohortJsonPaths, - characterizationPaths = characterizationPaths, - interactive = interactive, - apply = apply, - select = select, - outputDir = outputDir - ) -} - -#' Select phenotype recommendations (compatibility wrapper) -#' @inheritParams slashOhdsiAcpClient::selectPhenotypeRecommendations -#' @return character vector of chosen phenotype ids -#' @export -selectPhenotypeRecommendations <- function(recommendations, - select = NULL, - interactive = interactive()) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use selectPhenotypeRecommendations().") - } - slashOhdsiAcpClient::selectPhenotypeRecommendations( - recommendations = recommendations, - select = select, - interactive = interactive - ) -} - -#' Select phenotype improvements (compatibility wrapper) -#' @inheritParams slashOhdsiAcpClient::selectPhenotypeImprovements -#' @return list with selected improvements and written file paths -#' @export -selectPhenotypeImprovements <- function(improvements, - cohortJsonPaths, - select = NULL, - apply = FALSE, - outputDir = NULL, - interactive = interactive()) { - if (!requireNamespace("slashOhdsiAcpClient", quietly = TRUE)) { - stop("slashOhdsiAcpClient must be installed or loaded to use selectPhenotypeImprovements().") - } - slashOhdsiAcpClient::selectPhenotypeImprovements( - improvements = improvements, - cohortJsonPaths = cohortJsonPaths, - select = select, - apply = apply, - outputDir = outputDir, - interactive = interactive - ) -} diff --git a/R/OHDSIAssistant/R/rules_local_fallback.R b/R/OHDSIAssistant/R/rules_local_fallback.R deleted file mode 100644 index a91fd30..0000000 --- a/R/OHDSIAssistant/R/rules_local_fallback.R +++ /dev/null @@ -1 +0,0 @@ -# Compatibility note: local fallback review logic moved to slashOhdsiAcpClient. diff --git a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R b/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R deleted file mode 100644 index 0faf76b..0000000 --- a/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R +++ /dev/null @@ -1,10 +0,0 @@ -#' Compatibility wrapper for the Strategus cohort-methods shell -#' @param ... forwarded to slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell -#' @return invisible list with output paths -#' @export -runStrategusCohortMethodsShell <- function(...) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to run the Strategus cohort-methods shell.") - } - slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell(...) -} diff --git a/R/OHDSIAssistant/R/strategus_incidence_shell.R b/R/OHDSIAssistant/R/strategus_incidence_shell.R deleted file mode 100644 index f60bef1..0000000 --- a/R/OHDSIAssistant/R/strategus_incidence_shell.R +++ /dev/null @@ -1,10 +0,0 @@ -#' Compatibility wrapper for the Strategus incidence shell -#' @param ... forwarded to slashOhdsiStrategusAssistant::runStrategusIncidenceShell -#' @return invisible list with output paths -#' @export -runStrategusIncidenceShell <- function(...) { - if (!requireNamespace("slashOhdsiStrategusAssistant", quietly = TRUE)) { - stop("slashOhdsiStrategusAssistant must be installed or loaded to run the Strategus incidence shell.") - } - slashOhdsiStrategusAssistant::runStrategusIncidenceShell(...) -} diff --git a/R/OHDSIAssistant/R/utils_json.R b/R/OHDSIAssistant/R/utils_json.R deleted file mode 100644 index 2339aeb..0000000 --- a/R/OHDSIAssistant/R/utils_json.R +++ /dev/null @@ -1 +0,0 @@ -# Compatibility note: JSON utility helpers moved to slashOhdsiAcpClient. diff --git a/R/OHDSIAssistant/README.md b/R/OHDSIAssistant/README.md deleted file mode 100644 index 6480e26..0000000 --- a/R/OHDSIAssistant/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# OHDSIAssistant - -This package is now a compatibility layer during the R refactor. - -Canonical packages: - -- `slashOhdsiAcpClient` for ACP connectivity and phenotype-oriented helper APIs -- `slashOhdsiStrategusAssistant` for Strategus workflows, shell entrypoints, and Strategus helper utilities - -Compatibility exports still available here forward to those packages, including: - -- `acp_connect()` -- `suggestPhenotypes()` -- `reviewPhenotypes()` -- `runStrategusIncidenceShell()` -- `runStrategusCohortMethodsShell()` -- `suggestCohortMethodSpecs()` - -Preferred entrypoints going forward: - -- `slashOhdsiAcpClient::acp_connect()` -- `slashOhdsiAcpClient::suggestPhenotypes()` -- `slashOhdsiAcpClient::reviewPhenotypes()` -- `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` -- `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` -- `slashOhdsiStrategusAssistant::suggestCohortMethodSpecs()` diff --git a/R/OHDSIAssistant/inst/assistant/.gitkeep b/R/OHDSIAssistant/inst/assistant/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/R/OHDSIAssistant/inst/assistant/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/R/OHDSIAssistant/man/.gitkeep b/R/OHDSIAssistant/man/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/R/OHDSIAssistant/man/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/R/slashOhdsiAcpClient/R/flows.R b/R/slashOhdsiAcpClient/R/flows.R index f360ec1..76f1b59 100644 --- a/R/slashOhdsiAcpClient/R/flows.R +++ b/R/slashOhdsiAcpClient/R/flows.R @@ -91,16 +91,39 @@ acp_suggest_cohort_method_specs <- function(client, #' @param stage_context workflow-stage context object #' @param message latest user message #' @return parsed ACP response -#' @export -acp_workflow_context_dialogue <- function(client, stage_context, message) { +.flatten_workflow_context_dialogue_payload <- function(stage_context, message) { if (!is.list(stage_context)) stop("stage_context must be a list.") if (is.null(message) || !nzchar(trimws(as.character(message)))) { stop("Provide a non-empty message.") } - body <- list( - workflow_stage_context = stage_context, - message = trimws(as.character(message)) + + dialogue <- stage_context$dialogue %||% list() + current_context <- stage_context$legacy_context %||% list() + if (!is.list(current_context)) current_context <- list() + + current_context$contract_version <- current_context$contract_version %||% stage_context$contract_version %||% NULL + current_context$step_label <- current_context$step_label %||% stage_context$step_label %||% NULL + current_context$entities <- current_context$entities %||% stage_context$entities %||% list() + current_context$available_artifacts <- current_context$available_artifacts %||% stage_context$available_artifacts %||% list() + current_context$prior_questions <- current_context$prior_questions %||% dialogue$prior_questions %||% list() + current_context$prior_answers <- current_context$prior_answers %||% dialogue$prior_answers %||% list() + current_context$constraints <- current_context$constraints %||% stage_context$constraints %||% list() + + current_role <- current_context$active_role %||% (stage_context$entities %||% list())$active_role %||% "" + + list( + user_prompt = trimws(as.character(message)), + study_intent = trimws(as.character(stage_context$user_goal %||% "")), + workflow_type = trimws(as.character(stage_context$workflow_type %||% "")), + current_step = trimws(as.character(stage_context$current_step %||% "")), + current_role = trimws(as.character(current_role)), + current_context = .normalize_acp_body(current_context) ) +} + +#' @export +acp_workflow_context_dialogue <- function(client, stage_context, message) { + body <- .flatten_workflow_context_dialogue_payload(stage_context = stage_context, message = message) acp_call_flow(client, "workflow_context_dialogue", body) } diff --git a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md index 1543405..8649457 100644 --- a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md +++ b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md @@ -4,26 +4,28 @@ This document expands sprint item 3 in `CURRENT-SPRINT-PLAN.md` into a concrete architecture plan for the R side of the project. -The current `R/OHDSIAssistant/` package mixes three responsibilities: +The split described here is now complete: `slashOhdsiAcpClient` owns ACP connectivity and thin wrappers, `slashOhdsiStrategusAssistant` owns Strategus workflows and shell entrypoints, and the legacy combined package has been removed. The remaining sections are kept as architectural context for how the split was designed. + +The original combined R package mixed three responsibilities: - ACP transport and call/response helpers - thin R wrappers around ACP flows and actions - high-level Strategus workflow orchestration and interactive shells -That coupling is already visible in the current code: +That coupling is visible in the split code that replaced it: -- [`R/OHDSIAssistant/R/acp_client.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/acp_client.R) owns connection state and raw POST behavior -- [`R/OHDSIAssistant/R/phenotype_workflow.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/phenotype_workflow.R), [`R/OHDSIAssistant/R/cohort_methods_workflow.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/cohort_methods_workflow.R), [`R/OHDSIAssistant/R/lintStudyDesign.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/lintStudyDesign.R), and [`R/OHDSIAssistant/R/concept_set_actions.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/concept_set_actions.R) are flow/action wrappers but still depend directly on the transport internals -- [`R/OHDSIAssistant/R/strategus_incidence_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/strategus_incidence_shell.R) and [`R/OHDSIAssistant/R/strategus_cohort_methods_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/OHDSIAssistant/R/strategus_cohort_methods_shell.R) directly call ACP endpoints while also owning interactive workflow state, checkpoints, artifact layout, and script generation +- [`R/slashOhdsiAcpClient/R/client.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/client.R) owns connection state and raw POST behavior +- [`R/slashOhdsiAcpClient/R/compatibility_api.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/compatibility_api.R), [`R/slashOhdsiAcpClient/R/lint_and_concept_sets.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/lint_and_concept_sets.R), and [`R/slashOhdsiAcpClient/R/actions_and_lint.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/actions_and_lint.R) now own the thin flow/action wrappers +- [`R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R) and [`R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R) own interactive workflow state, checkpoints, artifact layout, and script generation -The package split should isolate those concerns before more `/ohdsi` dialogue work, incidence-shell extension, and concept-set generation integration are added. +The package split isolated those concerns before more `/ohdsi` dialogue work, incidence-shell extension, and concept-set generation integration are added. ## Goals 1. Create a small ACP-focused R package with a stable, testable HTTP interface and typed request/response helpers. 2. Move Strategus shells and workflow orchestration into a separate higher-level package that depends on the ACP package rather than internal transport functions. 3. Define one small workflow-stage contract that all shells use when asking ACP for contextual dialogue or recommendations. -4. Keep the migration incremental so the existing `OHDSIAssistant` package can continue to work during the transition. +4. Keep the migration incremental enough that downstream code can adopt the split in stages during the transition. ## Non-Goals @@ -291,7 +293,7 @@ Deliverable: ### Phase 5: Compatibility cleanup -- This is a clean refactor that can replace the deprecated old combined exports from `OHDSIAssistant` +- remove the deprecated combined-package exports entirely - update README and examples - add package-focused tests diff --git a/scripts/demo_ohdsi_dialogue.R b/scripts/demo_ohdsi_dialogue.R new file mode 100644 index 0000000..fa24abe --- /dev/null +++ b/scripts/demo_ohdsi_dialogue.R @@ -0,0 +1,124 @@ +### Demo: shell-equivalent `/ohdsi` workflow dialogue + +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. +## This does not launch the full shell. It exercises the same exported dialogue +## helpers that `runStrategusIncidenceShell()` and `runStrategusCohortMethodsShell()` +## use for `/ohdsi`. + +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) + +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = TRUE) +client <- connect_study_agent_acp() + +`%||%` <- function(a, b) if (is.null(a)) b else a + +run_ohdsi_dialogue_demo <- function(workflow = c("incidence", "cohort_methods"), + study_intent, + step, + role = "", + context = list(), + question) { + workflow <- match.arg(workflow) + build_stage_context <- switch( + workflow, + incidence = slashOhdsiStrategusAssistant::build_incidence_workflow_stage_context, + cohort_methods = slashOhdsiStrategusAssistant::build_cohort_methods_workflow_stage_context + ) + + dialogue <- slashOhdsiStrategusAssistant::new_workflow_dialogue_session( + interactive = TRUE, + study_intent_getter = function() study_intent, + build_stage_context = function(studyIntent, dialogue_state) { + build_stage_context( + study_intent = studyIntent, + dialogue_state = dialogue_state, + interactive = TRUE + ) + }, + call_dialogue = function(stage_context, message) { + slashOhdsiAcpClient::acp_workflow_context_dialogue( + client = client, + stage_context = stage_context, + message = message + ) + }, + render_response = slashOhdsiStrategusAssistant::render_workflow_dialogue_response + ) + + dialogue$set_context(step = step, role = role, context = context) + + stage_context <- build_stage_context( + study_intent = study_intent, + dialogue_state = dialogue$state, + interactive = TRUE + ) + + cat(sprintf("\n== %s /ohdsi demo ==\n", gsub("_", " ", workflow, fixed = TRUE))) + cat(sprintf("Slash command: /ohdsi %s\n", question)) + cat("Stage context sent to ACP:\n") + cat(jsonlite::toJSON(stage_context, pretty = TRUE, auto_unbox = TRUE, null = "null"), "\n") + + validation_response <- slashOhdsiAcpClient::acp_workflow_context_dialogue( + client = client, + stage_context = stage_context, + message = question + ) + + if (!identical(validation_response$status %||% "", "ok")) { + stop(sprintf( + "workflow_context_dialogue failed for %s: %s", + workflow, + validation_response$error %||% "unknown error" + )) + } + + cat("Validated direct ACP call. Replaying through the shell-equivalent `/ohdsi` handler...\n") + handled <- dialogue$handle_command(paste("/ohdsi", question)) + if (!isTRUE(handled$handled)) { + stop("The /ohdsi command was not handled by the dialogue session.") + } + + invisible(validation_response) +} + +run_ohdsi_dialogue_demo( + workflow = "incidence", + study_intent = "What is the incidence of hospitalized gastrointestinal bleeding after starting tofacitinib?", + step = "target_recommendation", + role = "target", + context = list( + target_statement = "New users of tofacitinib", + outcome_statement = "Hospitalized gastrointestinal bleeding" + ), + question = "why are these candidate target cohorts weak here?" +) + +run_ohdsi_dialogue_demo( + workflow = "cohort_methods", + study_intent = "What is the risk of GI bleed in new users of celecoxib compared with diclofenac?", + step = "analytic_settings_step_by_step", + role = "comparator", + context = list( + target_statement = "New users of celecoxib", + comparator_statement = "New users of diclofenac", + outcome_statements = list("Hospitalized gastrointestinal bleeding"), + selected_target_ids = c("ohdsi:demo-target"), + selected_comparator_ids = c("ohdsi:demo-comparator"), + selected_outcome_ids = c("ohdsi:demo-outcome"), + analysis_settings_path = repo_file("demo", "strategus-execution-settings.json") + ), + question = "why is washout important here?" +) diff --git a/scripts/demo_setup.R b/scripts/demo_setup.R new file mode 100644 index 0000000..3b50ef0 --- /dev/null +++ b/scripts/demo_setup.R @@ -0,0 +1,73 @@ +### Shared setup helpers for manual R demos under `scripts/` + +locate_study_agent_repo_root <- function(start = getwd()) { + start <- normalizePath(start, winslash = "/", mustWork = FALSE) + parent <- dirname(start) + candidates <- unique(c( + start, + parent, + list.dirs(start, recursive = FALSE, full.names = TRUE), + list.dirs(parent, recursive = FALSE, full.names = TRUE) + )) + + for (candidate in candidates) { + if ( + dir.exists(file.path(candidate, "R", "slashOhdsiAcpClient")) && + dir.exists(file.path(candidate, "R", "slashOhdsiStrategusAssistant")) && + dir.exists(file.path(candidate, "scripts")) + ) { + return(normalizePath(candidate, winslash = "/", mustWork = TRUE)) + } + } + + stop("Could not locate the StudyAgent repo root. Run this script from the repo root or invoke it by path.") +} + +set_study_agent_repo_root <- function(start = getwd()) { + root <- locate_study_agent_repo_root(start = start) + options(study_agent.repo_root = root) + root +} + +study_agent_repo_root <- function() { + root <- getOption("study_agent.repo_root", NULL) + if (is.null(root) || !dir.exists(root)) { + root <- set_study_agent_repo_root() + } + root +} + +repo_file <- function(...) { + file.path(study_agent_repo_root(), ...) +} + +load_study_agent_package <- function(package_name, quiet = TRUE) { + package_dir <- repo_file("R", package_name) + if (requireNamespace("devtools", quietly = TRUE)) { + devtools::load_all(package_dir, quiet = quiet) + return(invisible(TRUE)) + } + if (!requireNamespace(package_name, quietly = TRUE)) { + stop(sprintf( + "Package '%s' is not installed and devtools is unavailable to load '%s'.", + package_name, + package_dir + )) + } + invisible(TRUE) +} + +load_study_agent_r_packages <- function(include_strategus = FALSE, quiet = TRUE) { + load_study_agent_package("slashOhdsiAcpClient", quiet = quiet) + if (isTRUE(include_strategus)) { + load_study_agent_package("slashOhdsiStrategusAssistant", quiet = quiet) + } + invisible(TRUE) +} + +connect_study_agent_acp <- function(acp_url = Sys.getenv("ACP_URL", "http://127.0.0.1:8765")) { + acp_url <- trimws(as.character(if (is.null(acp_url)) "" else acp_url)) + if (!nzchar(acp_url)) stop("Set ACP_URL or pass a non-empty ACP URL.") + slashOhdsiAcpClient::acp_connect(acp_url) + slashOhdsiAcpClient::acp_get_default_client() +} diff --git a/scripts/test_cohort_critique_general_design.R b/scripts/test_cohort_critique_general_design.R index 8069113..305695a 100644 --- a/scripts/test_cohort_critique_general_design.R +++ b/scripts/test_cohort_critique_general_design.R @@ -1,22 +1,30 @@ -### Demo: `cohort-critique-general-design` (ACP flow) +### Demo: `cohort_critique_general_design` (ACP flow) -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. -# Import the R thin api to the ACP server/bridge -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = FALSE) +client <- connect_study_agent_acp() -############################################################ +cohort_path <- repo_file("demo", "cohort_definition.json") -cohort_path <- "OHDSI-Study-Agent/demo/cohort_definition.json" -cohort <- jsonlite::fromJSON(cohort_path, simplifyVector = FALSE) - -body <- list( - cohort = cohort +resp <- slashOhdsiAcpClient::acp_lint_cohort_general_design( + client = client, + cohort_path = cohort_path ) - -resp <- OHDSIAssistant:::`.acp_post`("/flows/cohort_critique_general_design", body) cat("\n== Cohort Critique (ACP flow) ==\n") print(resp) diff --git a/scripts/test_concept_sets_review.R b/scripts/test_concept_sets_review.R index b2ccd45..514f854 100644 --- a/scripts/test_concept_sets_review.R +++ b/scripts/test_concept_sets_review.R @@ -1,25 +1,33 @@ -### Demo: `concept-sets-review` (ACP flow) +### Demo: `concept_sets_review` (ACP flow) -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. -# Import the R thin api to the ACP server/bridge -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = FALSE) +client <- connect_study_agent_acp() -############################################################ - -concept_set_path <- "OHDSI-Study-Agent/demo/concept_set.json" -protocol_path <- "OHDSI-Study-Agent/demo/protocol.md" +protocol_path <- repo_file("demo", "protocol.md") +concept_set_path <- repo_file("demo", "concept_set.json") study_intent <- paste(readLines(protocol_path, warn = FALSE), collapse = " ") -concept_set <- jsonlite::fromJSON(concept_set_path, simplifyVector = FALSE) -body <- list( - concept_set = concept_set, +resp <- slashOhdsiAcpClient::acp_lint_concept_sets( + client = client, + concept_set_path = concept_set_path, study_intent = study_intent ) - -resp <- OHDSIAssistant:::`.acp_post`("/flows/concept_sets_review", body) cat("\n== Concept Sets Review (ACP flow) ==\n") print(resp) diff --git a/scripts/test_phenotype_improvements.R b/scripts/test_phenotype_improvements.R index e3841e5..f630025 100644 --- a/scripts/test_phenotype_improvements.R +++ b/scripts/test_phenotype_improvements.R @@ -1,26 +1,32 @@ ### Demo: `phenotype_improvements` (ACP flow) -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! - -# Import the R thin api to the ACP server/bridge -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") - -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -############################################################ - -protocol_path <- "OHDSI-Study-Agent/demo/protocol.md" -cohort_path <- "OHDSI-Study-Agent/demo/1197_Acute_gastrointestinal_bleeding.json" - -protocol_text <- paste(readLines(protocol_path, warn = FALSE), collapse = "\n") -cohort <- jsonlite::fromJSON(cohort_path, simplifyVector = FALSE) - -body <- list( - protocol_text = protocol_text, - cohorts = list(cohort) +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. + +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) + +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = FALSE) +client <- connect_study_agent_acp() + +protocol_path <- repo_file("demo", "protocol.md") +cohort_path <- repo_file("demo", "324_Pain.json") + +resp <- slashOhdsiAcpClient::acp_review_phenotypes( + client = client, + protocol_path = protocol_path, + cohort_paths = cohort_path ) - -resp <- OHDSIAssistant:::`.acp_post`("/flows/phenotype_improvements", body) cat("\n== Phenotype Improvements (ACP flow) ==\n") print(resp) diff --git a/scripts/test_phenotype_recommendations.R b/scripts/test_phenotype_recommendations.R index daffd16..4752a67 100755 --- a/scripts/test_phenotype_recommendations.R +++ b/scripts/test_phenotype_recommendations.R @@ -1,33 +1,55 @@ -### Demo: `phenotype_recommendations` - -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! - -# Import the R thin api to the ACP server/bridge -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") - -Sys.setenv(PHENOTYPE_INDEX_DIR="OHDSI-Study-Agent/data/phenotype_index_cipher_omop") - -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -############################################################ - -## -- `phenotype_recommendations` (ACP flow) -protocol <- "OHDSI-Study-Agent/demo/protocol.md" -study_dir <- "OHDSI-Study-Agent/demo" - -rec <- OHDSIAssistant::suggestPhenotypes(protocolPath = protocol, maxResults = 10, candidateLimit = 20, interactive = TRUE) -core <- rec$recommendations %||% rec -ids <- OHDSIAssistant::selectPhenotypeRecommendations(core$phenotype_recommendations, select = NULL, interactive = interactive()) -# this will write the JSON for the selected cohort definitions to a folder - -## -- `phenotype_improvements` - depends on ids having been chosen above +### Demo: `phenotype_recommendation` plus follow-up improvements + +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. + +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) + +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = FALSE) +invisible(connect_study_agent_acp()) + +Sys.setenv(PHENOTYPE_INDEX_DIR = repo_file("data", "phenotype_index_cipher_omop")) + +protocol <- repo_file("demo", "protocol.md") +study_dir <- repo_file("demo") + +rec <- slashOhdsiAcpClient::suggestPhenotypes( + protocolPath = protocol, + maxResults = 10, + candidateLimit = 20, + interactive = TRUE +) + +core <- if (!is.null(rec$recommendations)) rec$recommendations else rec +ids <- slashOhdsiAcpClient::selectPhenotypeRecommendations( + core$phenotype_recommendations, + select = NULL, + interactive = interactive() +) + +paths <- character(0) if (length(ids)) { - paths <- OHDSIAssistant::pullPhenotypeDefinitions(ids, outputDir = study_dir, overwrite = TRUE) -} + paths <- slashOhdsiAcpClient::pullPhenotypeDefinitions( + ids, + outputDir = study_dir, + overwrite = TRUE + ) +} if (length(paths)) { - OHDSIAssistant::reviewPhenotypes(protocol, paths, interactive = TRUE) - # To persist improvement notes next to the cohort JSONs, set apply=TRUE: - # OHDSIAssistant::reviewPhenotypes(protocol, paths, interactive = TRUE, apply = TRUE, select = "all") -} + slashOhdsiAcpClient::reviewPhenotypes(protocol, paths, interactive = TRUE) + # To persist improvement notes next to the cohort JSONs, set apply = TRUE: + # slashOhdsiAcpClient::reviewPhenotypes(protocol, paths, interactive = TRUE, apply = TRUE, select = "all") +} diff --git a/scripts/test_phenotype_validation_review.R b/scripts/test_phenotype_validation_review.R index 01860e0..32445b5 100644 --- a/scripts/test_phenotype_validation_review.R +++ b/scripts/test_phenotype_validation_review.R @@ -1,14 +1,24 @@ ### Demo: `phenotype_validation_review` (ACP flow) -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. -# Import the R thin api to the ACP server/bridge -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") - -############################################################ +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = FALSE) +client <- connect_study_agent_acp() keeper_row <- list( age = 44, @@ -33,6 +43,10 @@ body <- list( keeper_row = keeper_row ) -resp <- OHDSIAssistant:::`.acp_post`("/flows/phenotype_validation_review", body) +resp <- slashOhdsiAcpClient::acp_call_flow( + client = client, + flow_name = "phenotype_validation_review", + body = body +) cat("\n== Phenotype Validation Review (ACP flow) ==\n") print(resp) diff --git a/scripts/test_strategus_incidence_plus_keeper.R b/scripts/test_strategus_incidence_plus_keeper.R index 7dbad46..44ac8f7 100644 --- a/scripts/test_strategus_incidence_plus_keeper.R +++ b/scripts/test_strategus_incidence_plus_keeper.R @@ -1,48 +1,62 @@ -### Demo: `phenotype_improvements` (ACP flow) +### Demo: `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` + +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. +## `scripts/demo_ohdsi_dialogue.R` is the quickest non-interactive `/ohdsi` smoke test. +## +## Useful `/ohdsi` prompts to try once the shell reaches phenotype recommendation steps: +## /ohdsi why are these candidate target cohorts weak here? +## /ohdsi what would make this outcome definition more defensible? + +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) + +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = TRUE) -## !!!!NOTE!!!! run this from a directory above the OHDSI-Study-Agent where an .renv has the HADES packages loaded !!!!NOTE!!!! - -## !!!!NOTE!!!! `study_agent_acp` should be running under OHDSI-Study-Agent an listening on port 8765 !!!!NOTE!!!! - -### CLEAN UP FROM LAST RUN? -# Uncomment to reset the state of the output folder -# Or add `reset = TRUE ` to the function call -unlink("OHDSI-Study-Agent/demo-strategus-cohort-incidence", recursive = TRUE, force = TRUE) - -# Import the R thin api to the ACP server/bridge Sys.setenv(ACP_TIMEOUT = "280") -devtools::load_all("OHDSI-Study-Agent/R/OHDSIAssistant") - -# confirm the ACP server/bridge is running -OHDSIAssistant::acp_connect("http://127.0.0.1:8765") +invisible(connect_study_agent_acp()) -## Run an interactive agent "shell" +### CLEAN UP FROM LAST RUN? +# Uncomment to reset the state of the output folder. +# unlink(repo_file("demo-strategus-cohort-incidence"), recursive = TRUE, force = TRUE) -## (NO RELEVANT PHENOTYPE TEST) First enter this study intent which does not really return relevant phenotype definitions: +## (NO RELEVANT PHENOTYPE TEST) First enter this study intent, which should not return strong phenotype matches: ## "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" -OHDSIAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - studyAgentBaseDir = "OHDSI-Study-Agent", - indexDir="data/phenotype_index_cipher_omop/" - ) - - -## (RELEVANT PHENOTYPE TEST) Run the study agent with a study intent that does have relevant phenotype definitions: -OHDSIAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - studyAgentBaseDir = "OHDSI-Study-Agent", - indexDir="data/phenotype_index_cipher_omop/", - studyIntent = "What is the risk of GI bleed in new users of tofacitinib compared to new users of ruxolitinib?" - ) - - -## Use this to just resume and regenerate the output scripts from cached results -OHDSIAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - studyAgentBaseDir = "OHDSI-Study-Agent", - resume = TRUE, - allowCache = TRUE, - promptOnCache = FALSE, - interactive = FALSE, - indexDir="data/phenotype_index_cipher_omop/" - ) +slashOhdsiStrategusAssistant::runStrategusIncidenceShell( + outputDir = "demo-strategus-cohort-incidence", + acpUrl = "http://127.0.0.1:8765", + studyAgentBaseDir = repo_root, + indexDir = "data/phenotype_index_cipher_omop" +) + +## (RELEVANT PHENOTYPE TEST) This intent should yield stronger phenotype candidates: +slashOhdsiStrategusAssistant::runStrategusIncidenceShell( + outputDir = "demo-strategus-cohort-incidence", + acpUrl = "http://127.0.0.1:8765", + studyAgentBaseDir = repo_root, + indexDir = "data/phenotype_index_cipher_omop", + studyIntent = "What is the risk of GI bleed in new users of tofacitinib compared to new users of ruxolitinib?" +) + +## Use this to resume from cached artifacts and regenerate output scripts. +slashOhdsiStrategusAssistant::runStrategusIncidenceShell( + outputDir = "demo-strategus-cohort-incidence", + acpUrl = "http://127.0.0.1:8765", + studyAgentBaseDir = repo_root, + resume = TRUE, + allowCache = TRUE, + promptOnCache = FALSE, + interactive = FALSE, + indexDir = "data/phenotype_index_cipher_omop" +) diff --git a/tests/test_r_workflow_context_dialogue_wrapper.py b/tests/test_r_workflow_context_dialogue_wrapper.py new file mode 100644 index 0000000..7df1662 --- /dev/null +++ b/tests/test_r_workflow_context_dialogue_wrapper.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +FLOWS_SOURCE = Path("R/slashOhdsiAcpClient/R/flows.R") +DEMO_SOURCE = Path("scripts/demo_ohdsi_dialogue.R") + + +def test_r_workflow_context_dialogue_wrapper_flattens_stage_context() -> None: + source = FLOWS_SOURCE.read_text(encoding="utf-8") + + assert ".flatten_workflow_context_dialogue_payload <- function(stage_context, message)" in source + assert 'user_prompt = trimws(as.character(message))' in source + assert 'study_intent = trimws(as.character(stage_context$user_goal %||% ""))' in source + assert 'workflow_type = trimws(as.character(stage_context$workflow_type %||% ""))' in source + assert 'current_step = trimws(as.character(stage_context$current_step %||% ""))' in source + assert 'current_role = trimws(as.character(current_role))' in source + assert 'current_context = .normalize_acp_body(current_context)' in source + assert "workflow_stage_context =" not in source + + +def test_ohdsi_demo_script_exercises_shell_equivalent_handler() -> None: + source = DEMO_SOURCE.read_text(encoding="utf-8") + + assert 'slashOhdsiStrategusAssistant::new_workflow_dialogue_session(' in source + assert 'slashOhdsiAcpClient::acp_workflow_context_dialogue(' in source + assert 'handled <- dialogue$handle_command(paste("/ohdsi", question))' in source + assert 'workflow = "incidence"' in source + assert 'workflow = "cohort_methods"' in source From 7409abf99c1c31b04bfad1687d2ada4d2990f315 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 12:01:17 +0000 Subject: [PATCH 07/20] Fixed a bug where selected_ids_outcome was referenced in Step 3 of the incidence shell before it had been initialized --- .../R/strategus_incidence_shell.R | 1 + tests/test_incidence_shell_selection_state.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/test_incidence_shell_selection_state.py diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R index fd903a0..336a8c9 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -497,6 +497,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } selected_ids_target <- NULL + selected_ids_outcome <- character(0) if (interactive) { labels <- vapply(seq_along(recommendations_target), function(i) { rec <- recommendations_target[[i]] diff --git a/tests/test_incidence_shell_selection_state.py b/tests/test_incidence_shell_selection_state.py new file mode 100644 index 0000000..ae69134 --- /dev/null +++ b/tests/test_incidence_shell_selection_state.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") + + +def test_outcome_selection_state_is_initialized_before_target_mapping_prompt() -> None: + source = SOURCE.read_text(encoding="utf-8") + + init = source.index("selected_ids_outcome <- character(0)") + first_target_prompt = source.index( + 'selected_outcome_ids = as.list(selected_ids_outcome %||% list())' + ) + + assert init < first_target_prompt From 9287a96e713a693cb97f3d83253abff138a4bad7 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 12:31:12 +0000 Subject: [PATCH 08/20] fixed a bug with cohort ID remapping --- .../R/strategus_incidence_shell.R | 69 ++++++++++++++++--- tests/test_incidence_shell_selection_state.py | 16 +++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R index 336a8c9..39b9ce9 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -197,6 +197,30 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } } + default_cohort_id_from_source <- function(source_id) { + source_id <- as.character(source_id %||% "") + if (!nzchar(source_id)) return(NA_integer_) + if (grepl("^ohdsi:[0-9]+$", source_id)) { + return(suppressWarnings(as.integer(sub("^ohdsi:", "", source_id)))) + } + suppressWarnings(as.integer(source_id)) + } + + default_cohort_ids_from_sources <- function(source_ids, role_label = "selected") { + source_ids <- as.character(source_ids %||% character(0)) + if (length(source_ids) == 0) return(integer(0)) + derived <- vapply(source_ids, default_cohort_id_from_source, integer(1)) + if (any(is.na(derived))) { + bad <- source_ids[is.na(derived)] + stop(sprintf( + "Could not derive numeric cohort IDs for %s phenotype(s): %s", + role_label, + paste(unique(bad), collapse = ", ") + )) + } + as.integer(derived) + } + copy_cohort_json_multi <- function(source_id, dest_id, dest_dirs, index_def_dir) { src <- phenotype_definition_path(source_id, index_def_dir) if (!file.exists(src)) stop(sprintf("Cohort JSON not found: %s", src)) @@ -533,12 +557,20 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide } map_ids <- function(ids) { - if (!use_mapping) return(ids) + if (!use_mapping) return(default_cohort_ids_from_sources(ids, role_label = "selected")) new <- seq(next_id, length.out = length(ids)) next_id <<- max(new) + 1 new } + extract_phenotype_improvement_items <- function(resp, cohort_label) { + core <- resp$full_result %||% resp + if (!is.null(core$error) && nzchar(trimws(as.character(core$error)))) { + stop(sprintf("ACP returned an error for %s phenotype improvements: %s", cohort_label, core$error)) + } + core$phenotype_improvements %||% list() + } + stop_if_unsupported_selected(selected_ids_target, "target") new_ids_target <- map_ids(selected_ids_target) @@ -581,8 +613,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (interactive) { for (cid in names(imp_response_target)) { resp <- imp_response_target[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() + items <- extract_phenotype_improvement_items(resp, sprintf("target cohort %s", cid)) cat(sprintf("\n== Improvements for target cohort %s ==\n", cid)) for (item in items) { cat(sprintf("- %s\n", item$summary %||% "(no summary)")) @@ -619,8 +650,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { for (cid in names(imp_response_target)) { resp <- imp_response_target[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() + items <- extract_phenotype_improvement_items(resp, sprintf("target cohort %s", cid)) if (length(items) == 0) next cohort_path <- file.path(selected_target_dir, sprintf("%s.json", cid)) cohort_obj <- read_json(cohort_path) @@ -816,8 +846,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (interactive) { for (cid in names(imp_response_outcome)) { resp <- imp_response_outcome[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() + items <- extract_phenotype_improvement_items(resp, sprintf("outcome cohort %s", cid)) cat(sprintf("\n== Improvements for outcome cohort %s ==\n", cid)) for (item in items) { cat(sprintf("- %s\n", item$summary %||% "(no summary)")) @@ -854,8 +883,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide if (!isTRUE(interactive) && isTRUE(autoApplyImprovements)) { for (cid in names(imp_response_outcome)) { resp <- imp_response_outcome[[cid]] - core <- resp$full_result %||% resp - items <- core$phenotype_improvements %||% list() + items <- extract_phenotype_improvement_items(resp, sprintf("outcome cohort %s", cid)) if (length(items) == 0) next cohort_path <- file.path(selected_outcome_dir, sprintf("%s.json", cid)) cohort_obj <- read_json(cohort_path) @@ -1035,8 +1063,26 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide " if (nzchar(inp)) cohort_id_base <- as.integer(inp)", " next_id <- cohort_id_base", "}", + "default_cohort_id <- function(source_id) {", + " source_id <- as.character(source_id %||% '')", + " if (!nzchar(source_id)) return(NA_integer_)", + " if (grepl('^ohdsi:[0-9]+$', source_id)) {", + " return(suppressWarnings(as.integer(sub('^ohdsi:', '', source_id))))", + " }", + " suppressWarnings(as.integer(source_id))", + "}", + "default_cohort_ids <- function(ids, role_label = 'selected') {", + " ids <- as.character(ids %||% character(0))", + " if (length(ids) == 0) return(integer(0))", + " derived <- vapply(ids, default_cohort_id, integer(1))", + " if (any(is.na(derived))) {", + " bad <- ids[is.na(derived)]", + " stop(sprintf('Could not derive numeric cohort IDs for %s phenotype(s): %s', role_label, paste(unique(bad), collapse = ', ')))", + " }", + " as.integer(derived)", + "}", "map_ids <- function(ids) {", - " if (!use_mapping) return(ids)", + " if (!use_mapping) return(default_cohort_ids(ids, role_label = 'selected'))", " new <- seq(next_id, length.out = length(ids))", " next_id <<- max(new) + 1", " new", @@ -1141,6 +1187,9 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide " for (cid in names(improvements)) {", " resp <- improvements[[cid]]", " core <- resp$full_result %||% resp", + " if (!is.null(core$error) && nzchar(trimws(as.character(core$error)))) {", + " stop(sprintf('ACP returned an error for phenotype improvements on cohort %s: %s', cid, core$error))", + " }", " items <- core$phenotype_improvements %||% list()", " if (length(items) == 0) next", " cohort_path <- file.path(selected_role_dir, sprintf('%s.json', cid))", diff --git a/tests/test_incidence_shell_selection_state.py b/tests/test_incidence_shell_selection_state.py index ae69134..7d05053 100644 --- a/tests/test_incidence_shell_selection_state.py +++ b/tests/test_incidence_shell_selection_state.py @@ -13,3 +13,19 @@ def test_outcome_selection_state_is_initialized_before_target_mapping_prompt() - ) assert init < first_target_prompt + + +def test_default_id_mapping_uses_numeric_suffix_for_ohdsi_ids() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert "default_cohort_id_from_source <- function(source_id)" in source + assert "grepl(\"^ohdsi:[0-9]+$\", source_id)" in source + assert "sub(\"^ohdsi:\", \"\", source_id)" in source + assert "if (!use_mapping) return(default_cohort_ids_from_sources(ids, role_label = \"selected\"))" in source + + +def test_improvement_errors_are_not_silently_treated_as_empty_results() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert "extract_phenotype_improvement_items <- function(resp, cohort_label)" in source + assert "ACP returned an error for %s phenotype improvements: %s" in source From 6729e614519c219a76329478f87ed46c1c8e93b2 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 13:58:23 +0000 Subject: [PATCH 09/20] Added a ported manual launcher at scripts/demo_strategus_cohort_method --- scripts/demo_strategus_cohort_method.R | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 scripts/demo_strategus_cohort_method.R diff --git a/scripts/demo_strategus_cohort_method.R b/scripts/demo_strategus_cohort_method.R new file mode 100644 index 0000000..47e22e6 --- /dev/null +++ b/scripts/demo_strategus_cohort_method.R @@ -0,0 +1,63 @@ +### Demo: `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` + +## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. +## If you launch from a parent `renv` project, use the same `.Rprofile` pattern that +## already worked for `scripts/demo_ohdsi_dialogue.R`. +## +## Useful `/ohdsi` prompts to try during analytic-settings and phenotype review steps: +## /ohdsi why is washout important here? +## /ohdsi what is weak about this comparator cohort? +## /ohdsi what should I double-check before accepting these analytic settings? + +script_dir <- local({ + cmd_args <- commandArgs(trailingOnly = FALSE) + file_arg <- grep("^--file=", cmd_args, value = TRUE) + if (length(file_arg) > 0) { + return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) + } + frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) + if (length(frame_files) > 0) { + return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) + } + normalizePath("scripts", winslash = "/", mustWork = FALSE) +}) + +source(file.path(script_dir, "demo_setup.R")) +repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) +load_study_agent_r_packages(include_strategus = TRUE) + +Sys.setenv(ACP_TIMEOUT = "180") +Sys.setenv(PHENOTYPE_INDEX_DIR = repo_file("data", "phenotype_index_cipher_omop")) +invisible(connect_study_agent_acp()) + +### CLEAN UP FROM LAST RUN? +# Uncomment to reset the state of the output folder. +# unlink(repo_file("demo-strategus-cohort-method"), recursive = TRUE, force = TRUE) +# +# If you already ran `scripts/test_strategus_incidence_plus_keeper.R`, this shell can +# reuse cached target and outcome artifacts from `demo-strategus-cohort-incidence`. +slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell( + outputDir = "demo-strategus-cohort-method", + acpUrl = "http://127.0.0.1:8765", + studyAgentBaseDir = repo_root, + indexDir = "data/phenotype_index_cipher_omop", + incidenceOutputDir = "demo-strategus-cohort-incidence", + studyIntent = paste( + "Compare sitagliptin new users vs glipizide new users for acute myocardial infarction.", + "Use a 365-day washout, intent-to-treat follow-up, 1:1 propensity score matching", + "on standardized logit with a caliper of 0.2, and a Cox model." + ) +) + +## Use this to resume from cached artifacts and regenerate output scripts. +# slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell( +# outputDir = "demo-strategus-cohort-method", +# acpUrl = "http://127.0.0.1:8765", +# studyAgentBaseDir = repo_root, +# indexDir = "data/phenotype_index_cipher_omop", +# incidenceOutputDir = "demo-strategus-cohort-incidence", +# resume = TRUE, +# allowCache = TRUE, +# promptOnCache = FALSE, +# interactive = FALSE +# ) From 2d553f5eaac083a56a71c0e58e4306251af91296 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 17:27:25 +0000 Subject: [PATCH 10/20] fixed a HADES API compatibility problem --- .../R/strategus_cohort_methods_shell.R | 151 +++++-- docs/KEEPER-EXPANSION-PLAN.md | 413 ++++++++---------- .../test_cohort_methods_generated_scripts.py | 108 ++++- ...rt_methods_shell_recommendation_support.py | 12 + 4 files changed, 383 insertions(+), 301 deletions(-) diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R index 437ea90..20aaeaf 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R @@ -1777,13 +1777,30 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me path } + parse_single_cohort_id <- function(x) { + ids <- parse_ids(x) + ids <- ids[!is.na(ids)] + if (length(ids) == 0) return(NA_integer_) + as.integer(ids[[1]]) + } + parse_ids <- function(x) { if (is.null(x)) return(integer(0)) if (is.numeric(x) || is.integer(x)) return(as.integer(x)) + if (is.list(x)) { + return(parse_ids(unlist(x, use.names = FALSE))) + } if (is.character(x)) { pieces <- unlist(strsplit(paste(x, collapse = ","), "[,[:space:]]+")) pieces <- pieces[nzchar(trimws(pieces))] - return(as.integer(pieces)) + return(vapply(pieces, function(piece) { + piece <- trimws(as.character(piece)) + if (!nzchar(piece)) return(NA_integer_) + if (grepl("^ohdsi:[0-9]+$", piece)) { + return(suppressWarnings(as.integer(sub("^ohdsi:", "", piece)))) + } + suppressWarnings(as.integer(piece)) + }, integer(1))) } integer(0) } @@ -2135,12 +2152,12 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me any(names(mapping[[1]]) %in% c("role", "original_id", "cohort_id")) if (isTRUE(is_row_mapping)) { roles <- vapply(mapping, function(item) as.character(item$role %||% NA_character_), character(1)) - original_ids <- vapply(mapping, function(item) as.integer(item$original_id %||% NA_integer_), integer(1)) - cohort_ids <- vapply(mapping, function(item) as.integer(item$cohort_id %||% NA_integer_), integer(1)) + original_ids <- vapply(mapping, function(item) parse_single_cohort_id(item$original_id %||% NA_integer_), integer(1)) + cohort_ids <- vapply(mapping, function(item) parse_single_cohort_id(item$cohort_id %||% NA_integer_), integer(1)) } else { roles <- as.character(unlist(mapping$role %||% character(0), use.names = FALSE)) - original_ids <- as.integer(unlist(mapping$original_id %||% integer(0), use.names = FALSE)) - cohort_ids <- as.integer(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE)) + original_ids <- parse_ids(unlist(mapping$original_id %||% integer(0), use.names = FALSE)) + cohort_ids <- parse_ids(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE)) } if (!length(roles) || length(roles) != length(original_ids) || length(roles) != length(cohort_ids)) { return(NULL) @@ -5852,6 +5869,21 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", "", "`%||%` <- function(x, y) if (is.null(x)) y else x", + "package_version_or_na <- function(package_name) {", + " if (!requireNamespace(package_name, quietly = TRUE)) return(NA_character_)", + " as.character(utils::packageVersion(package_name))", + "}", + "call_with_supported_args <- function(fn, args) {", + " formal_names <- names(formals(fn)) %||% character(0)", + " if (!('...' %in% formal_names)) {", + " args <- args[names(args) %in% formal_names]", + " }", + " do.call(fn, args)", + "}", + "has_exported_function <- function(package_name, function_name) {", + " function_name %in% getNamespaceExports(package_name)", + "}", + "message('Using Strategus ', package_version_or_na('Strategus'), ' and CohortMethod ', package_version_or_na('CohortMethod'))", "defaults <- jsonlite::fromJSON(file.path(output_dir, 'cm_analysis_defaults.json'), simplifyVector = TRUE)", "conceptSetSelections <- jsonlite::fromJSON(file.path(output_dir, 'cm_concept_set_selections.json'), simplifyVector = FALSE)", "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", @@ -5940,7 +5972,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me "# Characterization module: one characterization configuration for target and comparator cohorts.", "characterizationTargetIds <- as.numeric(unique(c(target_id, comparator_id)))", "characterizationModule <- CharacterizationModule$new()", - "characterizationModuleSpecifications <- characterizationModule$createModuleSpecifications(", + "characterizationArgs <- list(", " targetIds = characterizationTargetIds,", " outcomeIds = as.numeric(outcome_ids),", " limitToFirstInNDays = as.numeric(rep(if (isTRUE(getDbDefaults$firstExposureOnly %||% TRUE)) 99999 else 0, length(characterizationTargetIds))),", @@ -5952,6 +5984,13 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me " endAnchor = studyPopulationDefaults$endAnchor %||% 'cohort end',", " mode = 'CohortIncidence'", ")", + "characterizationFormals <- names(formals(characterizationModule$createModuleSpecifications)) %||% character(0)", + "if (!('limitToFirstInNDays' %in% characterizationFormals)) message('CharacterizationModule compatibility: omitting limitToFirstInNDays')", + "if (!('mode' %in% characterizationFormals)) message('CharacterizationModule compatibility: omitting mode')", + "characterizationModuleSpecifications <- call_with_supported_args(", + " characterizationModule$createModuleSpecifications,", + " characterizationArgs", + ")", "", "# CohortIncidence module: one incidence analysis across target/comparator cohorts and outcomes.", "ciTargets <- list(", @@ -6050,17 +6089,24 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me " errorOnHighCorrelation = isTRUE(psDefaults$errorOnHighCorrelation %||% FALSE),", " prior = psPrior", ")", - "trimByPsArgs <- if (identical(psTrimmingStrategy, 'by_percent')) {", - " CohortMethod::createTrimByPsArgs(", + "trimByPsArgs <- NULL", + "trimByPsToEquipoiseArgs <- NULL", + "if (identical(psTrimmingStrategy, 'by_percent')) {", + " trimByPsArgs <- CohortMethod::createTrimByPsArgs(", " trimFraction = psTrimmingPercent / 100,", " trimMethod = 'symmetric'", " )", "} else if (identical(psTrimmingStrategy, 'by_equipoise')) {", - " CohortMethod::createTrimByPsArgs(", - " equipoiseBounds = c(equipoiseLowerBound, equipoiseUpperBound)", - " )", - "} else {", - " NULL", + " if (has_exported_function('CohortMethod', 'createTrimByPsToEquipoiseArgs')) {", + " message('CohortMethod compatibility: using createTrimByPsToEquipoiseArgs for equipoise trimming')", + " trimByPsToEquipoiseArgs <- CohortMethod::createTrimByPsToEquipoiseArgs(", + " bounds = c(equipoiseLowerBound, equipoiseUpperBound)", + " )", + " } else {", + " trimByPsArgs <- CohortMethod::createTrimByPsArgs(", + " equipoiseBounds = c(equipoiseLowerBound, equipoiseUpperBound)", + " )", + " }", "}", "matchOnPsArgs <- if (identical(psAdjustmentStrategy, 'match_on_ps')) CohortMethod::createMatchOnPsArgs(", " caliper = matchDefaults$caliper,", @@ -6084,31 +6130,64 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me " prior = outcomeModelPrior", ")", "", - "cmAnalysisList <- list(", - " CohortMethod::createCmAnalysis(", - " analysisId = as.integer(defaults$analysis_id %||% 1L),", - " description = analyticSettingsProfile %||% comparison$label %||% 'Default cohort method analysis',", - " getDbCohortMethodDataArgs = getDbCohortMethodDataArgs,", - " createStudyPopulationArgs = createStudyPopulationArgs,", - " createPsArgs = createPsArgs,", - " trimByPsArgs = trimByPsArgs,", - " matchOnPsArgs = matchOnPsArgs,", - " stratifyByPsArgs = stratifyByPsArgs,", - " fitOutcomeModelArgs = fitOutcomeModelArgs", - " )", - ")", - "cmAnalysesSpecifications <- CohortMethod::createCmAnalysesSpecifications(", - " cmAnalysisList = cmAnalysisList,", - " targetComparatorOutcomesList = targetComparatorOutcomesList,", - " analysesToExclude = NULL,", - " refitPsForEveryOutcome = FALSE,", - " refitPsForEveryStudyPopulation = TRUE,", - " cmDiagnosticThresholds = CohortMethod::createCmDiagnosticThresholds()", + "cmAnalysisArgs <- list(", + " analysisId = as.integer(defaults$analysis_id %||% 1L),", + " description = analyticSettingsProfile %||% comparison$label %||% 'Default cohort method analysis',", + " getDbCohortMethodDataArgs = getDbCohortMethodDataArgs,", + " createStudyPopulationArgs = createStudyPopulationArgs,", + " createPsArgs = createPsArgs,", + " trimByPsArgs = trimByPsArgs,", + " trimByPsToEquipoiseArgs = trimByPsToEquipoiseArgs,", + " matchOnPsArgs = matchOnPsArgs,", + " stratifyByPsArgs = stratifyByPsArgs,", + " fitOutcomeModelArgs = fitOutcomeModelArgs", ")", + "cmAnalysisFormals <- names(formals(CohortMethod::createCmAnalysis)) %||% character(0)", + "if ('createStudyPopulationArgs' %in% cmAnalysisFormals) {", + " cmAnalysisArgs$createStudyPopulationArgs <- createStudyPopulationArgs", + "} else if ('createStudyPopArgs' %in% cmAnalysisFormals) {", + " message('CohortMethod compatibility: using legacy createStudyPopArgs name')", + " cmAnalysisArgs$createStudyPopArgs <- createStudyPopulationArgs", + " cmAnalysisArgs$createStudyPopulationArgs <- NULL", + "} else {", + " stop('Unsupported CohortMethod::createCmAnalysis signature: expected createStudyPopulationArgs or createStudyPopArgs')", + "}", + "cmAnalysisList <- list(call_with_supported_args(CohortMethod::createCmAnalysis, cmAnalysisArgs))", + "cmDiagnosticThresholds <- CohortMethod::createCmDiagnosticThresholds()", "cmModule <- CohortMethodModule$new()", - "cohortMethodModuleSpecifications <- cmModule$createModuleSpecifications(", - " cmAnalysesSpecifications = cmAnalysesSpecifications$toList()", - ")", + "cmModuleFormals <- names(formals(cmModule$createModuleSpecifications)) %||% character(0)", + "if ('cmAnalysesSpecifications' %in% cmModuleFormals) {", + " if (!has_exported_function('CohortMethod', 'createCmAnalysesSpecifications')) {", + " stop('Installed Strategus expects cmAnalysesSpecifications, but CohortMethod does not export createCmAnalysesSpecifications')", + " }", + " cmAnalysesSpecifications <- CohortMethod::createCmAnalysesSpecifications(", + " cmAnalysisList = cmAnalysisList,", + " targetComparatorOutcomesList = targetComparatorOutcomesList,", + " analysesToExclude = NULL,", + " refitPsForEveryOutcome = FALSE,", + " refitPsForEveryStudyPopulation = TRUE,", + " cmDiagnosticThresholds = cmDiagnosticThresholds", + " )", + " cohortMethodModuleSpecifications <- call_with_supported_args(", + " cmModule$createModuleSpecifications,", + " list(cmAnalysesSpecifications = cmAnalysesSpecifications)", + " )", + "} else if (all(c('cmAnalysisList', 'targetComparatorOutcomesList') %in% cmModuleFormals)) {", + " message('CohortMethodModule compatibility: using legacy module specification signature')", + " cohortMethodModuleSpecifications <- call_with_supported_args(", + " cmModule$createModuleSpecifications,", + " list(", + " cmAnalysisList = cmAnalysisList,", + " targetComparatorOutcomesList = targetComparatorOutcomesList,", + " analysesToExclude = NULL,", + " refitPsForEveryOutcome = FALSE,", + " refitPsForEveryStudyPopulation = TRUE,", + " cmDiagnosticThresholds = cmDiagnosticThresholds", + " )", + " )", + "} else {", + " stop('Unsupported CohortMethodModule$createModuleSpecifications signature in installed Strategus')", + "}", "", "analysisSpecifications <- Strategus::createEmptyAnalysisSpecifications()", "analysisSpecifications <- Strategus::addSharedResources(analysisSpecifications, cohortDefinitionSharedResource)", diff --git a/docs/KEEPER-EXPANSION-PLAN.md b/docs/KEEPER-EXPANSION-PLAN.md index b725505..e86f38c 100644 --- a/docs/KEEPER-EXPANSION-PLAN.md +++ b/docs/KEEPER-EXPANSION-PLAN.md @@ -1,242 +1,171 @@ -**Keeper Expansion Plan** - -The Keeper expansion should be implemented as three linked capabilities: - -1. concept-set generation -2. profile extraction from OMOP CDM -3. patient-row adjudication - -The end-to-end workflow should be: - -1. user provides a clinical event of interest -2. `/flows/keeper_concept_sets_generate` generates Keeper input concept sets -3. user reviews and approves those concept sets -4. `/flows/keeper_profiles_generate` extracts Keeper-style patient review rows from OMOP CDM using the approved concept sets -5. `/flows/phenotype_validation_review` evaluates one or more sanitized review rows with the LLM - -This keeps the architecture aligned with Keeper’s actual separation between concept-set generation, profile construction, and case review. - -**Flows** -`/flows/keeper_concept_sets_generate` -- Purpose: generate Keeper input concept sets equivalent to Keeper’s `generateKeeperConceptSets()`. -- Input: - - `phenotype` - - optional domain subset - - provider overrides for vocabulary search and Phoebe - - tuning controls like search limits and min record count -- Output: - - flat Keeper-style concept table with: - - `conceptId` - - `conceptName` - - `vocabularyId` - - `conceptSetName` - - `target` - - structured `keeper_concept_sets` - - diagnostics per domain and per step -- LLM use: yes -- Patient data: none - -`/flows/keeper_profiles_generate` -- Purpose: generate Keeper-style patient review rows from OMOP CDM using approved concept sets, analogous to Keeper’s `generateKeeper()`. -- Input: - - OMOP connection/config reference - - cohort source/table details - - cohort definition id and/or sampled person ids - - approved `keeper_concept_sets` - - sampling controls - - descendant inclusion flag -- Output: - - Keeper review rows suitable for downstream review - - optional table-oriented output for CSV/UI consumption - - extraction metadata and counts -- LLM use: no -- Patient data: yes, but only deterministic local processing - -`/flows/phenotype_validation_review` -- Purpose: adjudicate whether a sanitized patient review row supports the event of interest. -- Input: - - `disease_name` - - one or more Keeper review rows -- Output: - - `label` - - `rationale` - - diagnostics -- LLM use: yes -- Patient data: sanitized only - -**MCP Tools** -Prompt/config tools: -- `keeper_concept_set_bundle` -- `keeper_prompt_bundle` -- `keeper_build_prompt` -- `keeper_parse_response` - -Vocabulary/provider tools: -- `vocab_search_standard` -- `phoebe_related_concepts` -- `vocab_filter_standard_concepts` -- `vocab_remove_descendants` -- `vocab_add_nonchildren` -- `vocab_fetch_concepts` - -Profile extraction tools: -- `keeper_profile_extract` -- `keeper_profile_to_rows` -- `keeper_sanitize_profile_row` - -`keeper_profile_extract` is the key new deterministic MCP tool. It should use `OMOP_Alchemy` as the primary OMOP CDM access layer. This tool should query the cohort/sample and construct review evidence across Keeper-relevant categories using approved concept sets. It should not involve the LLM. - -**Use of OMOP_Alchemy** -All OMOP-backed MCP tools should use `OMOP_Alchemy` first, especially for: -- `Concept` -- `Concept_Ancestor` -- `Concept_Relationship` -- `Condition_Occurrence` -- `Condition_Era` -- `Drug_Era` -- `Procedure_Occurrence` -- `Measurement` -- `Death` -- `Person` -- `Visit_Occurrence` -- `Observation_Period` -- `Cohort` -- `CDM_Source` - -This should be wrapped in a small internal DB/session utility module inside Study Agent so MCP tools can consistently: -- create engines/sessions -- resolve schema/table configuration -- run common OMOP lookups -- normalize outputs - -Hecate or other external search services should remain optional provider layers for vector search and Phoebe-like recommendations. Returned concept IDs should be validated and enriched against local OMOP vocabulary using `OMOP_Alchemy`. - -**Keeper Domain Model** -Study Agent should mirror Keeper’s concept-set generation categories, not downstream review fields. The canonical concept-set categories are: -- `doi` -- `alternativeDiagnosis` -- `symptoms` -- `drugs` -- `diagnosticProcedures` -- `measurements` -- `treatmentProcedures` -- `complications` - -The review rows extracted later may populate fields such as presentation, prior disease, prior drugs, post disease, post drugs, and death. Those are profile-extraction outputs, not separate concept-generation domains. - -**PHI-Safe Data Boundaries** -This must be a hard architectural rule: - -No raw row-level patient data containing direct or indirect PII/PHI may ever be sent to an LLM. - -The boundary should be: -- concept-set generation: no patient data involved -- profile extraction: patient data allowed, deterministic local processing only -- validation review: sanitized rows only - -Required behavior: -- raw extracted review rows may exist only inside deterministic MCP processing or local persisted outputs -- any LLM-facing path must pass through a fail-closed sanitization gate -- `keeper_build_prompt` must only accept sanitized row payloads -- if sanitization fails, the row must not be sent to the LLM - -The sanitization policy should explicitly strip or transform: -- person ids -- visit ids -- MRNs and account numbers -- exact dates/timestamps -- addresses and locations -- provider/site identifiers if sensitive -- exact ages where bucketing is required -- free-text fields that may contain identifiers - -Allowed LLM payloads should be limited to review-safe abstractions such as: -- age bucket -- generalized visit context -- concept names -- relative timing if needed -- scrubbed measurement summaries - -**Audit And Governance** -This should be treated as a first-class requirement for organizations using cloud/commercial LLMs. - -For every outbound LLM call, Study Agent should record: -- initiating user or service identity -- timestamp -- flow name and version -- MCP tool/template versions used -- model, provider, and endpoint -- whether the call was local/self-hosted or external/cloud -- sanitization status and sanitization tool/version -- policy decision: allowed or blocked -- hash of sanitized prompt payload -- hash of model response -- dataset/cohort/concept-set artifact identifiers - -It must not log: -- raw unsanitized patient rows -- secrets -- direct identifiers - -Recommended audit modes: -- `strict_metadata_only` -- `sealed_payload_logging` - -Recommended governance controls: -- provider allowlist -- outbound egress policy -- configurable retention for audit records -- reproducibility via config snapshot and template version capture -- optional approval workflow before external callouts - -**Implementation Phases** -1. Prompt/config foundation -- add Keeper concept-set prompt assets under `mcp_server/prompts/keeper_concept_sets/` -- add domain config file matching Keeper prompt-set structure - -2. Vocabulary/provider tooling -- implement normalized vocab/Phoebe MCP tools -- support `hecate_api` first -- add `generic_search_api` and DB-backed fallback modes - -3. OMOP DB access layer -- add internal Study Agent session/config wrapper around `OMOP_Alchemy` - -4. Concept-set generation -- implement `/flows/keeper_concept_sets_generate` -- add domain-level tests and one smoke path - -5. Profile extraction -- implement `keeper_profile_extract` -- implement `/flows/keeper_profiles_generate` -- ensure no LLM use in this flow - -6. Sanitization hardening -- formalize `keeper_sanitize_profile_row` -- enforce sanitized-only prompt building - -7. Integrated case review -- update `phenotype_validation_review` to support one or more sanitized Keeper rows -- add smoke test for: - - concept sets generate - - profiles generate - - phenotype validation review - -8. Audit/governance layer -- add ACP-side LLM audit logging -- add metadata capture for sanitization and outbound egress - -**Summary** -The correct Study Agent design is: - -- `keeper_concept_sets_generate` produces approved concept sets -- `keeper_profiles_generate` uses those concept sets plus OMOP CDM data to build review rows -- `phenotype_validation_review` evaluates sanitized rows only -- `OMOP_Alchemy` is the primary OMOP access layer -- all LLM egress is sanitized, auditable, and policy-controlled - -The next concrete step should be to define the JSON interfaces for: -- `/flows/keeper_concept_sets_generate` -- `/flows/keeper_profiles_generate` -- the `keeper_profile_row` schema -- the audit metadata envelope for outbound LLM calls + Target Design + + Do not run the Keeper R package from the Strategus shells. + + Use the Keeper 2.0 subfolder only as a semantic reference for: + + - concept-set domain names + - row field names + - expected review workflow shape + + The actual R runtime path should be: + + 1. keeper_concept_sets_generate + 2. user review/approval of concept sets + 3. keeper_profiles_generate + 4. row-by-row phenotype_validation_review + 5. /ohdsi available during concept-set generation and row-review steps + + Plan + + 1. Complete the R ACP client surface. + + - Expand R/slashOhdsiAcpClient/R/flows.R:138 so acp_keeper_concept_sets_generate() matches the full ACP contract: vocab_search_provider, phoebe_provider, min_record_count, and the + existing fields. + - Add acp_keeper_profiles_generate(...). + - Add acp_phenotype_validation_review(...). + - Add matching runtime passthrough helpers in R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R:1 so the workflow package stays on the public ACP seam. + + 2. Add one shared R helper module for Keeper workflow orchestration. + + - Create a shared helper file in R/slashOhdsiStrategusAssistant/R/, not duplicated shell logic. + - Responsibilities: + - derive default phenotype labels from selected cohort names/statements + - call the three ACP flows + - persist raw concept-set responses, approved concept sets, generated rows, and per-row review results + - implement row selection/review loops + - print concise summaries and surface ACP errors clearly + - This helper should be reused by both cohort-method and incidence shells. + + 3. Wire Keeper stages into the interactive shells. + + - Insert an optional Keeper phase after cohort selection/improvements are finalized and before final script generation. + - Recommended flow per selected role/cohort: + - choose which roles to review + - generate concept sets + - accept/edit/rerun concept sets + - generate Keeper rows + - review rows one by one + - Default behavior should probably be: + - outcomes first + - target/comparator optional + - Use the existing stage names already reserved in R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R:3: + - keeper_concept_set_generation + - keeper_case_review + - Update R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R:1 so /ohdsi has proper step labels for these stages. + + 4. Keep /ohdsi safe during Keeper work. + + - /ohdsi during Keeper stages should send only workflow metadata, not patient row contents. + - Good stage context for /ohdsi: + - phenotype name + - role + - cohort id + - concept-set artifact paths + - row count + - current row index + - review status + - Do not embed Keeper row payloads into workflow_context_dialogue. Row-specific adjudication should go only through phenotype_validation_review. + + 5. Replace generated 04_keeper_review.R in both shells. + + - Remove library(Keeper), DatabaseConnector, and createKeeper(...) from the generated script path in both shells. + - New 04_keeper_review.R should: + - read selected cohort ids from cohort_id_map.json + - read schema/table info from strategus-execution-settings.json + - call ACP wrappers only + - write JSON artifacts under keeper-case-review/ + - optionally write convenience CSV summaries for human scanning + - It should not require databaseId, and it should no longer depend on strategus-db-details.json unless you deliberately keep that for unrelated reasons. + + 6. Define the Keeper artifact layout explicitly. + + - Keep keeper-case-review/, but make it structured: + - keeper-case-review/concept-sets-generated/ + - keeper-case-review/concept-sets-approved/ + - keeper-case-review/rows/ + - keeper-case-review/reviews/ + - Persist shell summary state in a new output artifact such as outputs/keeper_review_state.json, and echo the important paths in outputs/study_agent_state.json. + - In cohort methods, keep these separate from the existing concept-sets/ directory, which is already being used for negative-control/covariate placeholder material. + + 7. Land this in low-risk slices. + + - Slice 1: + - ACP wrappers + - shared Keeper helper + - direct demo scripts for the three flows + - Slice 2: + - replace generated 04_keeper_review.R in both shells + - add artifact/state persistence + - Slice 3: + - inline interactive Keeper phase in both shells + - /ohdsi Keeper-stage wiring + - resume/cache behavior + + Testing Plan + + 1. R wrapper tests. + + - Add source-level tests for new wrappers in R/slashOhdsiAcpClient/R/flows.R. + - Verify request-field coverage for all three Keeper flows. + + 2. Generated-script regression tests. + + - Extend tests/test_cohort_methods_generated_scripts.py:1. + - Add an incidence counterpart if needed. + - Assert: + - no library(Keeper) + - no createKeeper( + - no DatabaseConnector + - presence of ACP wrapper calls and JSON artifact writes + + 3. Shell workflow/state tests. + + - Add shell regression tests for: + - Keeper stage insertion + - study_agent_state.json Keeper fields + - /ohdsi stage mapping during Keeper concept-set generation and case review + - resume using approved concept sets / saved rows + + 4. ACP-side contract tests. + + - Reuse existing ACP flow coverage in tests/test_acp_server.py:430. + - Add only what is missing on the R integration boundary; the server flows themselves already have basic coverage. + + 5. Manual demos. + + - Add: + - scripts/demo_keeper_concept_sets_generate.R + - scripts/demo_keeper_profiles_generate.R + - scripts/demo_keeper_review_row.R + - optional end-to-end scripts/demo_keeper_review_pipeline.R + - Reuse scripts/test_phenotype_validation_review.R:1 as the seed for the row-review demo. + + 6. Live manual checklist. + + - Run 03_generate_cohorts.R. + - Run new 04_keeper_review.R. + - Confirm: + - concept-set generation artifacts exist + - approved concept sets are consumed + - row files are generated + - individual row reviews save correctly + - /ohdsi works in Keeper stages + - ACP failure modes are readable: + - omop_db_engine_unconfigured + - phi_detected + - zero rows returned + - row index out of range + + Recommended Decisions Before Coding + + - Default Keeper review scope: outcomes only by default, target/comparator optional. + - Enforce remove_pii = TRUE for shell-driven review paths. + - Keep 04_keeper_review.R as one script to avoid renumbering downstream docs/scripts. + - Treat the vendored Keeper/ folder as reference-only, not runtime. + + Main Risk + + The only real architectural trap is letting generic /ohdsi dialogue see row contents. Avoid that, and the migration is mostly plumbing plus workflow/state work rather than a hard + redesign. + diff --git a/tests/test_cohort_methods_generated_scripts.py b/tests/test_cohort_methods_generated_scripts.py index d941ee5..727b729 100644 --- a/tests/test_cohort_methods_generated_scripts.py +++ b/tests/test_cohort_methods_generated_scripts.py @@ -47,12 +47,18 @@ def test_generated_cm_spec_builds_and_executes_strategus_analysis_specification( assert "outcomeIds = as.numeric(outcome_ids)" in block assert "outcomeWashoutDays = as.numeric(" in block assert "maxCohortSize = studyPopulationDefaults$maxCohortSize" in block + assert "call_with_supported_args <- function(" in block + assert "characterizationFormals <- names(formals(characterizationModule$createModuleSpecifications))" in block assert "createStudyPopulationArgs <- CohortMethod::createCreateStudyPopulationArgs(" in block assert "removeSubjectsWithPriorOutcome = studyPopulationDefaults$removeSubjectsWithPriorOutcome" in block assert "useRegularization =" not in block assert "prior = outcomeModelPrior" in block - assert "CohortMethod::createCmAnalysesSpecifications(" in block - assert "cmAnalysesSpecifications = cmAnalysesSpecifications$toList()" in block + assert "cmAnalysisFormals <- names(formals(CohortMethod::createCmAnalysis))" in block + assert "if ('createStudyPopulationArgs' %in% cmAnalysisFormals)" in block + assert "else if ('createStudyPopArgs' %in% cmAnalysisFormals)" in block + assert "if ('cmAnalysesSpecifications' %in% cmModuleFormals)" in block + assert "else if (all(c('cmAnalysisList', 'targetComparatorOutcomesList') %in% cmModuleFormals))" in block + assert "cmAnalysesSpecifications = cmAnalysesSpecifications$toList()" not in block assert "ParallelLogger::saveSettingsToJson(analysisSpecifications, analysis_spec_path)" in block assert "result <- Strategus::execute(" in block assert "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)" in block @@ -75,8 +81,16 @@ def test_characterization_spec_accepts_generated_numeric_types() -> None: """ if (!requireNamespace('Strategus', quietly = TRUE)) quit(status = 42) library(Strategus) + `%||%` <- function(x, y) if (is.null(x)) y else x + call_with_supported_args <- function(fn, args) { + formal_names <- names(formals(fn)) %||% character(0) + if (!('...' %in% formal_names)) { + args <- args[names(args) %in% formal_names] + } + do.call(fn, args) + } module <- CharacterizationModule$new() - spec <- module$createModuleSpecifications( + spec <- call_with_supported_args(module$createModuleSpecifications, list( targetIds = as.numeric(c(1, 2)), outcomeIds = as.numeric(c(3)), limitToFirstInNDays = as.numeric(c(99999, 99999)), @@ -87,8 +101,8 @@ def test_characterization_spec_accepts_generated_numeric_types() -> None: riskWindowEnd = as.numeric(0), endAnchor = 'cohort end', mode = 'CohortIncidence' - ) - stopifnot(identical(spec$module, 'CharacterizationModule')) + )) + stopifnot(length(spec) > 0) """ ) assert result.returncode == 0, result.stderr @@ -124,8 +138,21 @@ def test_cohort_method_spec_accepts_generated_argument_shape() -> None: """ if (!requireNamespace('CohortMethod', quietly = TRUE) || !requireNamespace('FeatureExtraction', quietly = TRUE) || - !requireNamespace('Cyclops', quietly = TRUE)) quit(status = 42) + !requireNamespace('Cyclops', quietly = TRUE) || + !requireNamespace('Strategus', quietly = TRUE)) quit(status = 42) library(CohortMethod) + library(Strategus) + `%||%` <- function(x, y) if (is.null(x)) y else x + call_with_supported_args <- function(fn, args) { + formal_names <- names(formals(fn)) %||% character(0) + if (!('...' %in% formal_names)) { + args <- args[names(args) %in% formal_names] + } + do.call(fn, args) + } + has_exported_function <- function(package_name, function_name) { + function_name %in% getNamespaceExports(package_name) + } target_id <- as.numeric(1) comparator_id <- as.numeric(2) outcome_ids <- as.numeric(3) @@ -177,24 +204,59 @@ def test_cohort_method_spec_accepts_generated_argument_shape() -> None: inversePtWeighting = FALSE, prior = outcomeModelPrior ) - cmAnalysisList <- list( - CohortMethod::createCmAnalysis( - analysisId = 1, - description = 'test', - getDbCohortMethodDataArgs = getDbArgs, - createStudyPopulationArgs = studyPopulationArgs, - createPsArgs = NULL, - trimByPsArgs = NULL, - matchOnPsArgs = NULL, - stratifyByPsArgs = NULL, - fitOutcomeModelArgs = fitOutcomeModelArgs - ) - ) - spec <- CohortMethod::createCmAnalysesSpecifications( - cmAnalysisList = cmAnalysisList, - targetComparatorOutcomesList = targetComparatorOutcomesList, - cmDiagnosticThresholds = CohortMethod::createCmDiagnosticThresholds() + cmAnalysisArgs <- list( + analysisId = 1, + description = 'test', + getDbCohortMethodDataArgs = getDbArgs, + createStudyPopulationArgs = studyPopulationArgs, + createPsArgs = NULL, + trimByPsArgs = NULL, + trimByPsToEquipoiseArgs = NULL, + matchOnPsArgs = NULL, + stratifyByPsArgs = NULL, + fitOutcomeModelArgs = fitOutcomeModelArgs ) + cmAnalysisFormals <- names(formals(CohortMethod::createCmAnalysis)) %||% character(0) + if ('createStudyPopulationArgs' %in% cmAnalysisFormals) { + cmAnalysisArgs$createStudyPopulationArgs <- studyPopulationArgs + } else if ('createStudyPopArgs' %in% cmAnalysisFormals) { + cmAnalysisArgs$createStudyPopArgs <- studyPopulationArgs + cmAnalysisArgs$createStudyPopulationArgs <- NULL + } else { + stop('Unsupported CohortMethod::createCmAnalysis signature') + } + cmAnalysisList <- list(call_with_supported_args(CohortMethod::createCmAnalysis, cmAnalysisArgs)) + cmDiagnosticThresholds <- CohortMethod::createCmDiagnosticThresholds() + cmModule <- CohortMethodModule$new() + cmModuleFormals <- names(formals(cmModule$createModuleSpecifications)) %||% character(0) + if ('cmAnalysesSpecifications' %in% cmModuleFormals) { + if (!has_exported_function('CohortMethod', 'createCmAnalysesSpecifications')) { + stop('Expected createCmAnalysesSpecifications export') + } + cmAnalysesSpecifications <- CohortMethod::createCmAnalysesSpecifications( + cmAnalysisList = cmAnalysisList, + targetComparatorOutcomesList = targetComparatorOutcomesList, + cmDiagnosticThresholds = cmDiagnosticThresholds + ) + spec <- call_with_supported_args( + cmModule$createModuleSpecifications, + list(cmAnalysesSpecifications = cmAnalysesSpecifications) + ) + } else if (all(c('cmAnalysisList', 'targetComparatorOutcomesList') %in% cmModuleFormals)) { + spec <- call_with_supported_args( + cmModule$createModuleSpecifications, + list( + cmAnalysisList = cmAnalysisList, + targetComparatorOutcomesList = targetComparatorOutcomesList, + analysesToExclude = NULL, + refitPsForEveryOutcome = FALSE, + refitPsForEveryStudyPopulation = TRUE, + cmDiagnosticThresholds = cmDiagnosticThresholds + ) + ) + } else { + stop('Unsupported CohortMethodModule signature') + } stopifnot(length(spec) > 0) """ ) diff --git a/tests/test_cohort_methods_shell_recommendation_support.py b/tests/test_cohort_methods_shell_recommendation_support.py index 3fd3b97..5223a0b 100644 --- a/tests/test_cohort_methods_shell_recommendation_support.py +++ b/tests/test_cohort_methods_shell_recommendation_support.py @@ -27,3 +27,15 @@ def test_shell_resolves_namespaced_source_definition_filenames() -> None: assert 'sprintf("ohdsi__%s.json", source_text)' in source assert 'gsub(":", "__", source_text, fixed = TRUE)' in source assert 'src <- resolve_index_definition_path(source_id, index_def_dir)' in source + + +def test_shell_normalizes_namespaced_cached_and_manual_cohort_ids() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert 'parse_single_cohort_id <- function(x)' in source + assert 'if (grepl("^ohdsi:[0-9]+$", piece)) {' in source + assert 'sub("^ohdsi:", "", piece)' in source + assert 'parse_single_cohort_id(item$original_id %||% NA_integer_)' in source + assert 'parse_single_cohort_id(item$cohort_id %||% NA_integer_)' in source + assert 'original_ids <- parse_ids(unlist(mapping$original_id %||% integer(0), use.names = FALSE))' in source + assert 'cohort_ids <- parse_ids(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE))' in source From 35a6ecb825235280d236d2aa1bfff8c14af7cc47 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 16:16:53 -0500 Subject: [PATCH 11/20] Brings full keeper functionality back in but using the ACP and with /ohdsi support and interactive features --- R/slashOhdsiAcpClient/NAMESPACE | 2 + R/slashOhdsiAcpClient/R/flows.R | 101 +++- R/slashOhdsiStrategusAssistant/NAMESPACE | 1 + .../R/keeper_review_workflow.R | 562 ++++++++++++++++++ .../R/slash_ohdsi_runtime.R | 54 ++ .../R/strategus_cohort_methods_shell.R | 184 ++++-- .../R/strategus_incidence_shell.R | 240 ++++---- .../R/workflow_dialogue_mapping.R | 4 + acp_agent/study_agent_acp/demo_shell.py | 2 +- scripts/demo_strategus_cohort_method.R | 15 +- .../test_strategus_incidence_plus_keeper.R | 50 +- ...test_keeper_dialogue_integration_static.py | 75 +++ tests/test_keeper_generated_scripts.py | 45 ++ tests/test_keeper_r_wrappers_static.py | 46 ++ 14 files changed, 1169 insertions(+), 212 deletions(-) create mode 100644 R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R create mode 100644 tests/test_keeper_dialogue_integration_static.py create mode 100644 tests/test_keeper_generated_scripts.py create mode 100644 tests/test_keeper_r_wrappers_static.py diff --git a/R/slashOhdsiAcpClient/NAMESPACE b/R/slashOhdsiAcpClient/NAMESPACE index c417f95..111c3d1 100644 --- a/R/slashOhdsiAcpClient/NAMESPACE +++ b/R/slashOhdsiAcpClient/NAMESPACE @@ -7,9 +7,11 @@ export(acp_concept_set_edit) export(acp_execute_llm_actions_concept_set) export(acp_is_connected) export(acp_keeper_concept_sets_generate) +export(acp_keeper_profiles_generate) export(acp_lint_cohort_general_design) export(acp_lint_concept_sets) export(acp_phenotype_recommendation_advice) +export(acp_phenotype_validation_review) export(acp_review_phenotypes) export(acp_suggest_cohort_method_specs) export(acp_suggest_phenotypes) diff --git a/R/slashOhdsiAcpClient/R/flows.R b/R/slashOhdsiAcpClient/R/flows.R index 76f1b59..846a42b 100644 --- a/R/slashOhdsiAcpClient/R/flows.R +++ b/R/slashOhdsiAcpClient/R/flows.R @@ -130,26 +130,119 @@ acp_workflow_context_dialogue <- function(client, stage_context, message) { #' Call keeper concept set generation flow #' @param client ACP client object #' @param phenotype phenotype label -#' @param domain_keys character vector of domain keys +#' @param domain_keys optional character vector of domain keys +#' @param vocab_search_provider optional vocabulary search provider override +#' @param phoebe_provider optional related-concepts provider override #' @param candidate_limit candidate limit +#' @param min_record_count optional minimum record count filter #' @param include_diagnostics whether to request diagnostics #' @return parsed ACP response #' @export acp_keeper_concept_sets_generate <- function(client, phenotype, - domain_keys, + domain_keys = NULL, + vocab_search_provider = NULL, + phoebe_provider = NULL, candidate_limit = 5, + min_record_count = NULL, include_diagnostics = TRUE) { if (is.null(phenotype) || !nzchar(trimws(as.character(phenotype)))) { stop("Provide a non-empty phenotype.") } domain_keys <- as.character(domain_keys %||% character(0)) - if (!length(domain_keys)) stop("Provide at least one domain key.") body <- list( phenotype = trimws(as.character(phenotype)), - domain_keys = as.list(domain_keys), candidate_limit = candidate_limit, include_diagnostics = isTRUE(include_diagnostics) ) + if (length(domain_keys)) body$domain_keys <- as.list(domain_keys) + if (!is.null(vocab_search_provider) && nzchar(trimws(as.character(vocab_search_provider)))) { + body$vocab_search_provider <- trimws(as.character(vocab_search_provider)) + } + if (!is.null(phoebe_provider) && nzchar(trimws(as.character(phoebe_provider)))) { + body$phoebe_provider <- trimws(as.character(phoebe_provider)) + } + if (!is.null(min_record_count)) body$min_record_count <- as.numeric(min_record_count) acp_call_flow(client, "keeper_concept_sets_generate", body) } + +#' Call keeper profile generation flow +#' @param client ACP client object +#' @param cohort_database_schema cohort results schema +#' @param cohort_table cohort table name +#' @param cohort_definition_id cohort definition ID to sample from +#' @param cdm_database_schema CDM schema +#' @param keeper_concept_sets list of normalized Keeper concept-set rows +#' @param sample_size requested sample size +#' @param person_ids optional character vector of person IDs to restrict to +#' @param phenotype_name optional phenotype label for output metadata +#' @param use_descendants whether to expand descendant concepts +#' @param remove_pii whether to strip PII from generated rows +#' @return parsed ACP response +#' @export +acp_keeper_profiles_generate <- function(client, + cohort_database_schema, + cohort_table, + cohort_definition_id, + cdm_database_schema, + keeper_concept_sets, + sample_size = 20, + person_ids = NULL, + phenotype_name = NULL, + use_descendants = TRUE, + remove_pii = TRUE) { + if (is.null(cohort_database_schema) || !nzchar(trimws(as.character(cohort_database_schema)))) { + stop("Provide a non-empty cohort_database_schema.") + } + if (is.null(cohort_table) || !nzchar(trimws(as.character(cohort_table)))) { + stop("Provide a non-empty cohort_table.") + } + if (is.null(cdm_database_schema) || !nzchar(trimws(as.character(cdm_database_schema)))) { + stop("Provide a non-empty cdm_database_schema.") + } + cohort_definition_id <- suppressWarnings(as.integer(cohort_definition_id)) + if (is.na(cohort_definition_id)) stop("Provide a numeric cohort_definition_id.") + if (!is.list(keeper_concept_sets) || !length(keeper_concept_sets)) { + stop("Provide a non-empty keeper_concept_sets list.") + } + person_ids <- as.character(person_ids %||% character(0)) + + body <- list( + cohort_database_schema = trimws(as.character(cohort_database_schema)), + cohort_table = trimws(as.character(cohort_table)), + cohort_definition_id = cohort_definition_id, + cdm_database_schema = trimws(as.character(cdm_database_schema)), + sample_size = as.integer(sample_size), + person_ids = as.list(person_ids), + keeper_concept_sets = keeper_concept_sets, + use_descendants = isTRUE(use_descendants), + remove_pii = isTRUE(remove_pii) + ) + if (!is.null(phenotype_name) && nzchar(trimws(as.character(phenotype_name)))) { + body$phenotype_name <- trimws(as.character(phenotype_name)) + } + acp_call_flow(client, "keeper_profiles_generate", body) +} + +#' Call phenotype validation review flow +#' @param client ACP client object +#' @param disease_name disease or phenotype name +#' @param keeper_row sanitized Keeper-style review row +#' @return parsed ACP response +#' @export +acp_phenotype_validation_review <- function(client, disease_name, keeper_row) { + if (is.null(disease_name) || !nzchar(trimws(as.character(disease_name)))) { + stop("Provide a non-empty disease_name.") + } + if (!is.list(keeper_row) || !length(keeper_row)) { + stop("Provide keeper_row as a non-empty list.") + } + acp_call_flow( + client, + "phenotype_validation_review", + list( + disease_name = trimws(as.character(disease_name)), + keeper_row = keeper_row + ) + ) +} diff --git a/R/slashOhdsiStrategusAssistant/NAMESPACE b/R/slashOhdsiStrategusAssistant/NAMESPACE index 4f2324c..5cfc54c 100644 --- a/R/slashOhdsiStrategusAssistant/NAMESPACE +++ b/R/slashOhdsiStrategusAssistant/NAMESPACE @@ -16,6 +16,7 @@ export(createStrategusConnectionDetails) export(createStrategusExecutionSettings) export(readStrategusDbDetails) export(readStrategusExecutionSettings) +export(runKeeperReviewWorkflow) export(runStrategusIncidenceShell) export(runStrategusCohortMethodsShell) export(suggestCohortMethodSpecs) diff --git a/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R b/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R new file mode 100644 index 0000000..1baa7a2 --- /dev/null +++ b/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R @@ -0,0 +1,562 @@ +#' Run ACP-based Keeper review workflow for selected cohorts +#' @param base_dir workflow base directory +#' @param execution_settings_path path to strategus-execution-settings.json +#' @param cohort_id_map_path path to outputs/cohort_id_map.json +#' @param cohort_roles_path optional path to outputs/cohort_roles.json +#' @param intent_path optional path to intent split JSON used to infer phenotype labels +#' @param acp_url ACP base URL +#' @param acp_timeout_seconds ACP HTTP timeout in seconds; defaults to ACP_TIMEOUT or 300 +#' @param review_roles cohort roles to process, defaults to outcome-first +#' @param role_phenotypes optional named overrides by role or cohort id +#' @param domain_keys Keeper concept-set domains to request +#' @param candidate_limit ACP concept candidate limit +#' @param min_record_count optional minimum record count filter +#' @param sample_size requested profile sample size per cohort +#' @param review_row_limit maximum number of generated rows to review per cohort +#' @param include_diagnostics whether to request ACP diagnostics +#' @param use_descendants whether profile generation should include descendants +#' @param remove_pii whether to enforce PII removal for generated rows +#' @param auto_approve_generated when TRUE, seed approved concept sets from generated output when no approved file exists +#' @param overwrite_approved_concept_sets when TRUE, replace approved concept sets with the current generated concept sets +#' @param reuse_generated_concept_sets when TRUE, reuse existing generated concept-set artifacts +#' @param reuse_rows when TRUE, reuse existing generated Keeper row artifacts +#' @param resume_reviews when TRUE, continue from saved review artifacts instead of restarting +#' @param review_row_selection optional review row indices or range string such as "1-3,5"; overrides the default first-N selection +#' @return invisible list summarizing artifact paths and per-cohort status +#' @export +runKeeperReviewWorkflow <- function(base_dir, + execution_settings_path = file.path(base_dir, "strategus-execution-settings.json"), + cohort_id_map_path = file.path(base_dir, "outputs", "cohort_id_map.json"), + cohort_roles_path = file.path(base_dir, "outputs", "cohort_roles.json"), + intent_path = NULL, + acp_url = Sys.getenv("ACP_URL", "http://127.0.0.1:8765"), + acp_timeout_seconds = as.numeric(Sys.getenv("ACP_TIMEOUT", "300")), + review_roles = c("outcome"), + role_phenotypes = NULL, + stage_callback = NULL, + domain_keys = c( + "doi", + "alternativeDiagnosis", + "symptoms", + "drugs", + "diagnosticProcedures", + "measurements", + "treatmentProcedures", + "complications" + ), + candidate_limit = 50, + min_record_count = NULL, + sample_size = 20, + review_row_limit = 5, + include_diagnostics = TRUE, + use_descendants = TRUE, + remove_pii = TRUE, + auto_approve_generated = TRUE, + overwrite_approved_concept_sets = FALSE, + reuse_generated_concept_sets = TRUE, + reuse_rows = TRUE, + resume_reviews = TRUE, + review_row_selection = NULL) { + `%||%` <- function(x, y) if (is.null(x)) y else x + + ensure_dir <- function(path) { + if (!dir.exists(path)) dir.create(path, recursive = TRUE, showWarnings = FALSE) + } + + read_json <- function(path, simplify = FALSE) { + jsonlite::fromJSON(path, simplifyVector = simplify) + } + + write_json <- function(x, path) { + jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE, null = "null") + } + + as_named_record <- function(x) { + if (!is.list(x)) return(list(value = x)) + x + } + + records_to_data_frame <- function(records) { + records <- Filter(Negate(is.null), lapply(records, as_named_record)) + if (!length(records)) return(data.frame()) + keys <- unique(unlist(lapply(records, names), use.names = FALSE)) + rows <- lapply(records, function(rec) { + values <- lapply(keys, function(key) { + value <- rec[[key]] + if (is.null(value)) return(NA_character_) + if (length(value) == 0) return(NA_character_) + if (is.list(value)) return(jsonlite::toJSON(value, auto_unbox = TRUE, null = "null")) + if (length(value) > 1) return(jsonlite::toJSON(value, auto_unbox = TRUE, null = "null")) + as.character(value) + }) + stats::setNames(as.list(values), keys) + }) + as.data.frame(do.call(rbind, lapply(rows, function(x) as.data.frame(x, stringsAsFactors = FALSE))), stringsAsFactors = FALSE) + } + + read_mapping <- function(path) { + payload <- read_json(path, simplify = TRUE) + mapping <- payload$mapping %||% payload + if (is.null(mapping) || NROW(mapping) == 0) { + stop("No cohort mapping found in: ", path) + } + as.data.frame(mapping, stringsAsFactors = FALSE) + } + + intent_core <- function(path) { + if (is.null(path) || !nzchar(path) || !file.exists(path)) return(list()) + payload <- read_json(path, simplify = FALSE) + payload$intent_split %||% payload + } + + infer_phenotype_name <- function(role, cohort_id, cohort_name, intent_payload, overrides = NULL) { + override <- NULL + if (is.list(overrides) && !is.null(overrides[[as.character(cohort_id)]])) { + override <- overrides[[as.character(cohort_id)]] + } else if (is.list(overrides) && !is.null(overrides[[role]])) { + override <- overrides[[role]] + } else if (!is.null(overrides) && !is.list(overrides)) { + named_overrides <- overrides + if (!is.null(names(named_overrides))) { + override <- named_overrides[[as.character(cohort_id)]] %||% named_overrides[[role]] + } + } + if (!is.null(override) && nzchar(trimws(as.character(override)))) { + return(trimws(as.character(override))) + } + field <- switch( + as.character(role), + target = "target_statement", + comparator = "comparator_statement", + outcome = "outcome_statement", + NULL + ) + if (!is.null(field) && !is.null(intent_payload[[field]]) && nzchar(trimws(as.character(intent_payload[[field]])))) { + return(trimws(as.character(intent_payload[[field]]))) + } + if (!is.null(cohort_name) && nzchar(trimws(as.character(cohort_name)))) { + return(trimws(as.character(cohort_name))) + } + sprintf("Cohort %s", cohort_id) + } + + extract_concept_sets <- function(payload) { + payload$concept_sets %||% payload$result$concept_sets %||% payload$full_result$concept_sets %||% list() + } + + extract_rows <- function(payload) { + payload$rows %||% payload$result$rows %||% payload$full_result$rows %||% list() + } + + extract_review_value <- function(payload, field) { + payload[[field]] %||% payload$result[[field]] %||% payload$full_result[[field]] %||% NULL + } + + extract_reviews <- function(payload) { + payload$reviews %||% payload$result$reviews %||% payload$full_result$reviews %||% list() + } + + parse_row_selection <- function(selection, total_rows, default_limit) { + total_rows <- suppressWarnings(as.integer(total_rows %||% 0L)) + default_limit <- suppressWarnings(as.integer(default_limit %||% 0L)) + if (is.na(total_rows) || total_rows <= 0L) return(integer(0)) + + default_indices <- if (!is.na(default_limit) && default_limit > 0L) { + seq_len(min(total_rows, default_limit)) + } else { + integer(0) + } + + if (is.null(selection) || length(selection) == 0L) return(default_indices) + + if (is.character(selection) && length(selection) == 1L) { + selection_text <- trimws(selection) + if (!nzchar(selection_text)) return(default_indices) + if (tolower(selection_text) %in% c("all", "*")) return(seq_len(total_rows)) + parts <- trimws(strsplit(selection_text, ",", fixed = TRUE)[[1]]) + parsed <- integer(0) + for (part in parts[nzchar(parts)]) { + if (grepl("^[0-9]+-[0-9]+$", part)) { + bounds <- as.integer(strsplit(part, "-", fixed = TRUE)[[1]]) + if (length(bounds) == 2L && !anyNA(bounds)) { + lo <- min(bounds) + hi <- max(bounds) + parsed <- c(parsed, seq.int(lo, hi)) + } + } else { + parsed <- c(parsed, suppressWarnings(as.integer(part))) + } + } + indices <- parsed + } else { + indices <- suppressWarnings(as.integer(unlist(selection, use.names = FALSE))) + } + + indices <- indices[!is.na(indices)] + indices <- indices[indices >= 1L & indices <= total_rows] + indices <- unique(indices) + if (!length(indices)) return(integer(0)) + indices + } + + safe_call <- function(expr) { + tryCatch(expr, error = function(e) { + list(status = "error", error = conditionMessage(e)) + }) + } + + emit_stage <- function(step, role = "", context = list()) { + if (!is.function(stage_callback)) return(invisible(NULL)) + stage_callback( + step = as.character(step %||% ""), + role = as.character(role %||% ""), + context = compact_workflow_dialogue_context(context %||% list()) + ) + invisible(NULL) + } + + previous_acp_timeout <- Sys.getenv("ACP_TIMEOUT", unset = NA_character_) + if (is.na(acp_timeout_seconds) || acp_timeout_seconds <= 0) acp_timeout_seconds <- 300 + Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds)) + on.exit({ + if (is.na(previous_acp_timeout)) Sys.unsetenv("ACP_TIMEOUT") + else Sys.setenv(ACP_TIMEOUT = previous_acp_timeout) + }, add = TRUE) + + update_study_agent_state <- function(state_path, keeper_state_path, keeper_dir, summary) { + state <- if (file.exists(state_path)) read_json(state_path, simplify = FALSE) else list() + state$keeper_review_state_path <- keeper_state_path + state$keeper_review_artifacts <- list( + keeper_dir = keeper_dir, + concept_sets_generated = file.path(keeper_dir, "concept-sets-generated"), + concept_sets_approved = file.path(keeper_dir, "concept-sets-approved"), + rows = file.path(keeper_dir, "rows"), + reviews = file.path(keeper_dir, "reviews") + ) + state$keeper_review_summary <- summary + write_json(state, state_path) + } + + if (is.null(base_dir) || !nzchar(trimws(as.character(base_dir)))) { + stop("Provide a non-empty base_dir.") + } + if (!file.exists(execution_settings_path)) { + stop("Execution settings file not found: ", execution_settings_path) + } + if (!file.exists(cohort_id_map_path)) { + stop("Cohort id map not found: ", cohort_id_map_path) + } + + base_dir <- normalizePath(base_dir, winslash = "/", mustWork = FALSE) + execution_settings_path <- normalizePath(execution_settings_path, winslash = "/", mustWork = FALSE) + cohort_id_map_path <- normalizePath(cohort_id_map_path, winslash = "/", mustWork = FALSE) + if (!is.null(intent_path) && nzchar(intent_path)) { + intent_path <- normalizePath(intent_path, winslash = "/", mustWork = FALSE) + } + if (!is.null(cohort_roles_path) && file.exists(cohort_roles_path)) { + cohort_roles_path <- normalizePath(cohort_roles_path, winslash = "/", mustWork = FALSE) + } + + output_dir <- file.path(base_dir, "outputs") + keeper_dir <- file.path(base_dir, "keeper-case-review") + generated_dir <- file.path(keeper_dir, "concept-sets-generated") + approved_dir <- file.path(keeper_dir, "concept-sets-approved") + rows_dir <- file.path(keeper_dir, "rows") + reviews_dir <- file.path(keeper_dir, "reviews") + for (path in c(output_dir, keeper_dir, generated_dir, approved_dir, rows_dir, reviews_dir)) ensure_dir(path) + + exec <- readStrategusExecutionSettings(execution_settings_path) + id_map <- read_mapping(cohort_id_map_path) + intent_payload <- intent_core(intent_path) + available_roles <- unique(as.character(id_map$role %||% character(0))) + review_roles <- as.character(review_roles %||% character(0)) + if (!length(review_roles)) { + review_roles <- if ("outcome" %in% available_roles) "outcome" else available_roles + } + selected_map <- id_map[as.character(id_map$role) %in% review_roles, , drop = FALSE] + if (nrow(selected_map) == 0) { + stop("No cohorts matched review_roles in cohort_id_map.json") + } + + client <- .studyAgentSlashCreateAcpClient(url = acp_url, check = TRUE) + summary_rows <- vector("list", nrow(selected_map)) + + for (i in seq_len(nrow(selected_map))) { + role <- as.character(selected_map$role[[i]] %||% "") + cohort_id <- suppressWarnings(as.integer(selected_map$cohort_id[[i]])) + cohort_name <- as.character(selected_map$cohort_name[[i]] %||% sprintf("Cohort %s", cohort_id)) + phenotype_name <- infer_phenotype_name(role, cohort_id, cohort_name, intent_payload, role_phenotypes) + prefix <- sprintf("%s_%s", role, cohort_id) + + generated_path <- file.path(generated_dir, sprintf("%s_concept_sets.json", prefix)) + approved_path <- file.path(approved_dir, sprintf("%s_concept_sets.json", prefix)) + rows_path <- file.path(rows_dir, sprintf("%s_rows.json", prefix)) + rows_csv_path <- file.path(rows_dir, sprintf("%s_rows.csv", prefix)) + review_path <- file.path(reviews_dir, sprintf("%s_reviews.json", prefix)) + review_csv_path <- file.path(reviews_dir, sprintf("%s_reviews.csv", prefix)) + + emit_stage( + "keeper_concept_set_generation", + role = role, + context = list( + phenotype_name = phenotype_name, + cohort_id = cohort_id, + cohort_name = cohort_name, + review_roles = as.list(review_roles), + domain_keys = as.list(domain_keys), + review_status = "generating_concept_sets", + generated_concept_sets_path = generated_path, + approved_concept_sets_path = approved_path + ) + ) + + generated_source <- "generated" + if (isTRUE(reuse_generated_concept_sets) && file.exists(generated_path)) { + generated_payload <- read_json(generated_path, simplify = FALSE) + generated_source <- "reused" + } else { + generated_payload <- safe_call( + .studyAgentSlashAcpKeeperConceptSetsGenerate( + client = client, + phenotype = phenotype_name, + domain_keys = domain_keys, + candidate_limit = candidate_limit, + min_record_count = min_record_count, + include_diagnostics = include_diagnostics + ) + ) + write_json(generated_payload, generated_path) + } + generated_concept_sets <- extract_concept_sets(generated_payload) + + approved_source <- "reused" + if (isTRUE(overwrite_approved_concept_sets)) { + approved_source <- "overwritten_from_generated" + approved_payload <- list( + status = "seeded_from_generated", + role = role, + cohort_definition_id = cohort_id, + phenotype_name = phenotype_name, + concept_sets = generated_concept_sets, + source_generated_artifact = generated_path, + replaced_existing_approved = isTRUE(file.exists(approved_path)) + ) + write_json(approved_payload, approved_path) + } else if (file.exists(approved_path)) { + approved_payload <- read_json(approved_path, simplify = FALSE) + } else if (isTRUE(auto_approve_generated)) { + approved_source <- "seeded_from_generated" + approved_payload <- list( + status = "seeded_from_generated", + role = role, + cohort_definition_id = cohort_id, + phenotype_name = phenotype_name, + concept_sets = generated_concept_sets, + source_generated_artifact = generated_path + ) + write_json(approved_payload, approved_path) + } else { + approved_source <- "missing" + approved_payload <- list( + status = "missing_approved_concept_sets", + role = role, + cohort_definition_id = cohort_id, + phenotype_name = phenotype_name, + concept_sets = list() + ) + } + approved_concept_sets <- extract_concept_sets(approved_payload) + + rows_source <- "generated" + rows_payload <- if (length(approved_concept_sets)) { + if (isTRUE(reuse_rows) && file.exists(rows_path)) { + rows_source <- "reused" + read_json(rows_path, simplify = FALSE) + } else { + payload <- safe_call( + .studyAgentSlashAcpKeeperProfilesGenerate( + client = client, + cohort_database_schema = exec$workDatabaseSchema, + cohort_table = exec$cohortTable, + cohort_definition_id = cohort_id, + cdm_database_schema = exec$cdmDatabaseSchema, + keeper_concept_sets = approved_concept_sets, + sample_size = sample_size, + phenotype_name = phenotype_name, + use_descendants = use_descendants, + remove_pii = remove_pii + ) + ) + write_json(payload, rows_path) + payload + } + } else { + rows_source <- "missing_approved_concept_sets" + list(status = "error", error = "no approved concept sets", rows = list()) + } + row_records <- extract_rows(rows_payload) + row_df <- records_to_data_frame(row_records) + if (nrow(row_df) > 0) utils::write.csv(row_df, rows_csv_path, row.names = FALSE) + + review_source <- "generated" + review_records <- list() + if (isTRUE(resume_reviews) && file.exists(review_path)) { + existing_review_payload <- read_json(review_path, simplify = FALSE) + review_records <- extract_reviews(existing_review_payload) + if (length(review_records) > 0) review_source <- "resumed" + } + selected_row_indices <- parse_row_selection(review_row_selection, length(row_records), review_row_limit) + if (length(selected_row_indices) > 0) { + reviewed_indices <- unique(vapply(review_records, function(rec) { + suppressWarnings(as.integer(rec$row_index %||% NA_integer_)) + }, integer(1))) + reviewed_indices <- reviewed_indices[!is.na(reviewed_indices)] + pending_row_indices <- selected_row_indices[!selected_row_indices %in% reviewed_indices] + emit_stage( + "keeper_case_review", + role = role, + context = list( + phenotype_name = phenotype_name, + cohort_id = cohort_id, + cohort_name = cohort_name, + review_roles = as.list(review_roles), + generated_concept_sets_path = generated_path, + approved_concept_sets_path = approved_path, + rows_path = rows_path, + reviews_path = review_path, + row_count = length(row_records), + reviewed_row_count = length(review_records), + review_row_limit = as.integer(review_row_limit), + selected_row_indices = as.list(selected_row_indices), + pending_row_indices = as.list(pending_row_indices), + review_status = if (length(pending_row_indices)) "reviewing_rows" else "all_selected_rows_already_reviewed" + ) + ) + for (row_index in pending_row_indices) { + emit_stage( + "keeper_case_review", + role = role, + context = list( + phenotype_name = phenotype_name, + cohort_id = cohort_id, + cohort_name = cohort_name, + review_roles = as.list(review_roles), + generated_concept_sets_path = generated_path, + approved_concept_sets_path = approved_path, + rows_path = rows_path, + reviews_path = review_path, + row_count = length(row_records), + current_row_index = row_index, + reviewed_row_count = length(review_records), + review_row_limit = as.integer(review_row_limit), + selected_row_indices = as.list(selected_row_indices), + pending_row_indices = as.list(pending_row_indices), + review_status = "reviewing_rows" + ) + ) + keeper_row <- row_records[[row_index]] + review_payload <- safe_call( + .studyAgentSlashAcpPhenotypeValidationReview( + client = client, + disease_name = phenotype_name, + keeper_row = keeper_row + ) + ) + review_records[[length(review_records) + 1]] <- c( + list( + row_index = row_index, + role = role, + cohort_definition_id = cohort_id, + phenotype_name = phenotype_name, + label = extract_review_value(review_payload, "label"), + rationale = extract_review_value(review_payload, "rationale"), + mode = extract_review_value(review_payload, "mode"), + error = review_payload$error %||% NULL + ), + keeper_row + ) + } + } else if (length(row_records) > 0) { + emit_stage( + "keeper_case_review", + role = role, + context = list( + phenotype_name = phenotype_name, + cohort_id = cohort_id, + cohort_name = cohort_name, + review_roles = as.list(review_roles), + generated_concept_sets_path = generated_path, + approved_concept_sets_path = approved_path, + rows_path = rows_path, + reviews_path = review_path, + row_count = length(row_records), + reviewed_row_count = length(review_records), + review_row_limit = as.integer(review_row_limit), + selected_row_indices = list(), + pending_row_indices = list(), + review_status = "no_rows_selected_for_review" + ) + ) + } + + review_payload_out <- list( + role = role, + cohort_definition_id = cohort_id, + phenotype_name = phenotype_name, + reviewed_row_count = length(review_records), + review_row_limit = as.integer(review_row_limit), + reviews = review_records + ) + write_json(review_payload_out, review_path) + review_df <- records_to_data_frame(review_records) + if (nrow(review_df) > 0) utils::write.csv(review_df, review_csv_path, row.names = FALSE) + + summary_rows[[i]] <- list( + role = role, + cohort_definition_id = cohort_id, + cohort_name = cohort_name, + phenotype_name = phenotype_name, + generated_concept_sets_path = generated_path, + approved_concept_sets_path = approved_path, + rows_path = rows_path, + reviews_path = review_path, + generated_concept_set_count = length(generated_concept_sets), + approved_concept_set_count = length(approved_concept_sets), + row_count = length(row_records), + reviewed_row_count = length(review_records), + selected_row_indices = as.list(selected_row_indices), + generated_concept_sets_source = generated_source, + approved_concept_sets_source = approved_source, + rows_source = rows_source, + reviews_source = review_source, + concept_generation_status = generated_payload$status %||% "ok", + row_generation_status = rows_payload$status %||% "ok" + ) + } + + keeper_state <- list( + base_dir = base_dir, + execution_settings_path = execution_settings_path, + cohort_id_map_path = cohort_id_map_path, + acp_url = acp_url, + acp_timeout_seconds = as.numeric(acp_timeout_seconds), + cohort_roles_path = if (!is.null(cohort_roles_path) && file.exists(cohort_roles_path)) cohort_roles_path else NULL, + intent_path = if (!is.null(intent_path) && file.exists(intent_path)) intent_path else NULL, + keeper_dir = keeper_dir, + review_roles = as.list(review_roles), + domain_keys = as.list(domain_keys), + sample_size = as.integer(sample_size), + review_row_limit = as.integer(review_row_limit), + review_row_selection = if (is.null(review_row_selection)) NULL else as.character(review_row_selection), + overwrite_approved_concept_sets = isTRUE(overwrite_approved_concept_sets), + reuse_generated_concept_sets = isTRUE(reuse_generated_concept_sets), + reuse_rows = isTRUE(reuse_rows), + resume_reviews = isTRUE(resume_reviews), + cohorts = summary_rows + ) + keeper_state_path <- file.path(output_dir, "keeper_review_state.json") + write_json(keeper_state, keeper_state_path) + update_study_agent_state(file.path(output_dir, "study_agent_state.json"), keeper_state_path, keeper_dir, summary_rows) + + invisible(c(keeper_state, list(keeper_review_state_path = keeper_state_path))) +} diff --git a/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R b/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R index a6706e6..2dfaae2 100644 --- a/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R +++ b/R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R @@ -10,6 +10,60 @@ slashOhdsiAcpClient::acp_call_flow(client = client, flow_name = flow_name, body = body) } +.studyAgentSlashAcpKeeperConceptSetsGenerate <- function(client, + phenotype, + domain_keys = NULL, + vocab_search_provider = NULL, + phoebe_provider = NULL, + candidate_limit = 5, + min_record_count = NULL, + include_diagnostics = TRUE) { + slashOhdsiAcpClient::acp_keeper_concept_sets_generate( + client = client, + phenotype = phenotype, + domain_keys = domain_keys, + vocab_search_provider = vocab_search_provider, + phoebe_provider = phoebe_provider, + candidate_limit = candidate_limit, + min_record_count = min_record_count, + include_diagnostics = include_diagnostics + ) +} + +.studyAgentSlashAcpKeeperProfilesGenerate <- function(client, + cohort_database_schema, + cohort_table, + cohort_definition_id, + cdm_database_schema, + keeper_concept_sets, + sample_size = 20, + person_ids = NULL, + phenotype_name = NULL, + use_descendants = TRUE, + remove_pii = TRUE) { + slashOhdsiAcpClient::acp_keeper_profiles_generate( + client = client, + cohort_database_schema = cohort_database_schema, + cohort_table = cohort_table, + cohort_definition_id = cohort_definition_id, + cdm_database_schema = cdm_database_schema, + keeper_concept_sets = keeper_concept_sets, + sample_size = sample_size, + person_ids = person_ids, + phenotype_name = phenotype_name, + use_descendants = use_descendants, + remove_pii = remove_pii + ) +} + +.studyAgentSlashAcpPhenotypeValidationReview <- function(client, disease_name, keeper_row) { + slashOhdsiAcpClient::acp_phenotype_validation_review( + client = client, + disease_name = disease_name, + keeper_row = keeper_row + ) +} + .studyAgentSlashNewWorkflowStageContext <- function(...) { new_workflow_stage_context(...) } diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R index 20aaeaf..0a0b7b7 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R @@ -5487,6 +5487,93 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me ) write_json(state, state_path) + keeper_review_state_path <- file.path(output_dir, "keeper_review_state.json") + keeper_review_roles <- character(0) + keeper_acp_timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", "300")) + keeper_reuse_generated_artifacts <- TRUE + keeper_overwrite_approved_concept_sets <- FALSE + keeper_resume_reviews <- TRUE + keeper_review_row_selection <- NULL + keeper_review_ran <- FALSE + keeper_review_result <- NULL + + if (isTRUE(interactive)) { + run_keeper_review_now <- prompt_yesno("Run ACP-based Keeper review now?", default = FALSE) + if (isTRUE(run_keeper_review_now)) { + entered_roles <- trimws(readline_with_dialogue("Keeper review roles [outcome]: ")) + keeper_review_roles <- if (!nzchar(entered_roles)) "outcome" else trimws(strsplit(entered_roles, ",", fixed = TRUE)[[1]]) + keeper_review_roles <- keeper_review_roles[nzchar(keeper_review_roles)] + keeper_review_roles <- intersect(keeper_review_roles, c("outcome", "target", "comparator")) + if (!length(keeper_review_roles)) keeper_review_roles <- "outcome" + keeper_reuse_generated_artifacts <- prompt_yesno("Reuse existing Keeper generated artifacts?", default = TRUE) + keeper_overwrite_approved_concept_sets <- prompt_yesno("Replace approved concept sets with current generated output?", default = FALSE) + keeper_resume_reviews <- prompt_yesno("Resume existing Keeper row reviews?", default = TRUE) + entered_row_selection <- trimws(readline_with_dialogue("Keeper row selection [default first N or e.g. 1-3,5]: ")) + keeper_review_row_selection <- if (!nzchar(entered_row_selection)) NULL else entered_row_selection + + stage_callback <- function(step, role = "", context = list()) { + safe_context <- c( + list( + study_intent = studyIntent, + target_statement = targetStatement, + comparator_statement = comparatorStatement, + outcome_statements = as.list(outcomeStatementsForSelectedCohorts), + selected_target_ids = as.list(new_target_id), + selected_comparator_ids = as.list(new_comparator_id), + selected_outcome_ids = as.list(new_outcome_ids), + keeper_review_state_path = keeper_review_state_path, + acp_timeout_seconds = keeper_acp_timeout_seconds + ), + context + ) + set_dialogue_context(step, role, context = safe_context) + } + + stage_callback( + "keeper_concept_set_generation", + role = keeper_review_roles[[1]], + context = list(review_roles = as.list(keeper_review_roles), review_status = "starting") + ) + + keeper_review_result <- tryCatch( + runKeeperReviewWorkflow( + base_dir = base_dir, + execution_settings_path = file.path(base_dir, "strategus-execution-settings.json"), + cohort_id_map_path = cohort_id_map_path, + cohort_roles_path = cohort_roles_path, + intent_path = cohort_methods_intent_split_path, + acp_timeout_seconds = keeper_acp_timeout_seconds, + review_roles = keeper_review_roles, + overwrite_approved_concept_sets = keeper_overwrite_approved_concept_sets, + reuse_generated_concept_sets = keeper_reuse_generated_artifacts, + reuse_rows = keeper_reuse_generated_artifacts, + resume_reviews = keeper_resume_reviews, + review_row_selection = keeper_review_row_selection, + stage_callback = stage_callback + ), + error = function(e) e + ) + + if (inherits(keeper_review_result, "error")) { + cat(sprintf("Keeper review failed: %s\n", conditionMessage(keeper_review_result))) + } else { + keeper_review_ran <- TRUE + cat(sprintf("Keeper review state saved to: %s\n", keeper_review_state_path)) + } + set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path)) + } + } + + state$keeper_review_state_path <- keeper_review_state_path + state$keeper_review_roles <- as.list(keeper_review_roles) + state$keeper_acp_timeout_seconds <- as.numeric(keeper_acp_timeout_seconds) + state$keeper_reuse_generated_artifacts <- isTRUE(keeper_reuse_generated_artifacts) + state$keeper_overwrite_approved_concept_sets <- isTRUE(keeper_overwrite_approved_concept_sets) + state$keeper_resume_reviews <- isTRUE(keeper_resume_reviews) + state$keeper_review_row_selection <- keeper_review_row_selection + state$keeper_review_ran <- isTRUE(keeper_review_ran) + write_json(state, state_path) + package_root <- resolve_path("R/slashOhdsiStrategusAssistant", study_base_dir) if (!dir.exists(package_root)) { alt <- file.path(getwd(), "R", "slashOhdsiStrategusAssistant") @@ -5721,71 +5808,58 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me script_04 <- c( script_header, - "library(Keeper)", "library(jsonlite)", - "library(DatabaseConnector)", "", package_loader_lines, "", sprintf("base_dir <- '%s'", base_dir), "output_dir <- file.path(base_dir, 'outputs')", - "keeper_dir <- file.path(base_dir, 'keeper-case-review')", - "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", - "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'), simplifyVector = TRUE)$mapping", - "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", - "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", - "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", - "databaseId <- ''", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "cohortDatabaseSchema <- exec$workDatabaseSchema", - "cohortTable <- exec$cohortTable", + "cohort_id_map_path <- file.path(output_dir, 'cohort_id_map.json')", + "cohort_roles_path <- file.path(output_dir, 'cohort_roles.json')", + "intent_path <- file.path(output_dir, 'cohort_methods_intent_split.json')", "", - "# TODO: Replace these placeholder concept vectors with study-specific Keeper settings.", - "keeperConcepts <- list(", - " doi = integer(0),", - " symptoms = integer(0),", - " comorbidities = integer(0),", - " drugs = integer(0),", - " diagnosticProcedures = integer(0),", - " measurements = integer(0),", - " alternativeDiagnosis = integer(0),", - " treatmentProcedures = integer(0),", - " complications = integer(0)", + "# Edit these defaults as needed before running the ACP-based Keeper workflow.", + "review_roles <- c('outcome')", + "domain_keys <- c(", + " 'doi', 'alternativeDiagnosis', 'symptoms', 'drugs',", + " 'diagnosticProcedures', 'measurements', 'treatmentProcedures', 'complications'", ")", + "candidate_limit <- 50", + "sample_size <- 20", + "review_row_limit <- 5", + "acp_timeout_seconds <- as.numeric(Sys.getenv('ACP_TIMEOUT', '300'))", + "Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds))", + "reuse_generated_concept_sets <- TRUE", + "overwrite_approved_concept_sets <- FALSE", + "reuse_rows <- TRUE", + "resume_reviews <- TRUE", + "review_row_selection <- NULL # e.g. '1-3,5'", + "acp_url <- Sys.getenv('ACP_URL', 'http://127.0.0.1:8765')", "", - "for (i in seq_len(nrow(id_map))) {", - " cid <- id_map$cohort_id[i]", - " role <- id_map$role[i]", - " cohort_name <- id_map$cohort_name[i]", - " role_dir <- file.path(keeper_dir, role)", - " dir.create(role_dir, recursive = TRUE, showWarnings = FALSE)", - " keeper <- createKeeper(", - " connectionDetails = connectionDetails,", - " databaseId = databaseId,", - " cdmDatabaseSchema = cdmDatabaseSchema,", - " cohortDatabaseSchema = cohortDatabaseSchema,", - " cohortTable = cohortTable,", - " cohortDefinitionId = cid,", - " cohortName = cohort_name,", - " sampleSize = 100,", - " assignNewId = TRUE,", - " useAncestor = TRUE,", - " doi = keeperConcepts$doi,", - " symptoms = keeperConcepts$symptoms,", - " comorbidities = keeperConcepts$comorbidities,", - " drugs = keeperConcepts$drugs,", - " diagnosticProcedures = keeperConcepts$diagnosticProcedures,", - " measurements = keeperConcepts$measurements,", - " alternativeDiagnosis = keeperConcepts$alternativeDiagnosis,", - " treatmentProcedures = keeperConcepts$treatmentProcedures,", - " complications = keeperConcepts$complications", - " )", - " out_path <- file.path(role_dir, sprintf('%s.csv', cid))", - " write.csv(keeper, out_path, row.names = FALSE)", - "}", - "", - "# TODO: When ACP is implemented for cohort methods, add optional LLM-based Keeper row review here.", + "result <- slashOhdsiStrategusAssistant::runKeeperReviewWorkflow(", + " base_dir = base_dir,", + " execution_settings_path = execution_settings_path,", + " cohort_id_map_path = cohort_id_map_path,", + " cohort_roles_path = cohort_roles_path,", + " intent_path = intent_path,", + " acp_url = acp_url,", + " acp_timeout_seconds = acp_timeout_seconds,", + " review_roles = review_roles,", + " domain_keys = domain_keys,", + " candidate_limit = candidate_limit,", + " sample_size = sample_size,", + " review_row_limit = review_row_limit,", + " overwrite_approved_concept_sets = overwrite_approved_concept_sets,", + " reuse_generated_concept_sets = reuse_generated_concept_sets,", + " reuse_rows = reuse_rows,", + " resume_reviews = resume_reviews,", + " review_row_selection = review_row_selection,", + " remove_pii = TRUE", + ")", + "keeper_state_path <- file.path(output_dir, 'keeper_review_state.json')", + "message('Keeper review state saved to: ', keeper_state_path)", + "print(result)", "" ) write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R index 39b9ce9..d0842ec 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -996,6 +996,91 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide state_path <- file.path(output_dir, "study_agent_state.json") write_json(state, state_path) + keeper_review_state_path <- file.path(output_dir, "keeper_review_state.json") + keeper_review_roles <- character(0) + keeper_acp_timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", "300")) + keeper_reuse_generated_artifacts <- TRUE + keeper_overwrite_approved_concept_sets <- FALSE + keeper_resume_reviews <- TRUE + keeper_review_row_selection <- NULL + keeper_review_ran <- FALSE + keeper_review_result <- NULL + + if (isTRUE(interactive)) { + run_keeper_review_now <- prompt_yesno("Run ACP-based Keeper review now?", default = FALSE) + if (isTRUE(run_keeper_review_now)) { + entered_roles <- trimws(readline_with_dialogue("Keeper review roles [outcome]: ")) + keeper_review_roles <- if (!nzchar(entered_roles)) "outcome" else trimws(strsplit(entered_roles, ",", fixed = TRUE)[[1]]) + keeper_review_roles <- keeper_review_roles[nzchar(keeper_review_roles)] + keeper_review_roles <- intersect(keeper_review_roles, c("outcome", "target")) + if (!length(keeper_review_roles)) keeper_review_roles <- "outcome" + keeper_reuse_generated_artifacts <- prompt_yesno("Reuse existing Keeper generated artifacts?", default = TRUE) + keeper_overwrite_approved_concept_sets <- prompt_yesno("Replace approved concept sets with current generated output?", default = FALSE) + keeper_resume_reviews <- prompt_yesno("Resume existing Keeper row reviews?", default = TRUE) + entered_row_selection <- trimws(readline_with_dialogue("Keeper row selection [default first N or e.g. 1-3,5]: ")) + keeper_review_row_selection <- if (!nzchar(entered_row_selection)) NULL else entered_row_selection + + stage_callback <- function(step, role = "", context = list()) { + safe_context <- c( + list( + study_intent = studyIntent, + target_statement = target_statement, + outcome_statement = outcome_statement, + selected_target_ids = as.list(target_ids), + selected_outcome_ids = as.list(outcome_ids), + keeper_review_state_path = keeper_review_state_path, + acp_timeout_seconds = keeper_acp_timeout_seconds + ), + context + ) + set_dialogue_context(step, role, context = safe_context) + } + + stage_callback( + "keeper_concept_set_generation", + role = keeper_review_roles[[1]], + context = list(review_roles = as.list(keeper_review_roles), review_status = "starting") + ) + + keeper_review_result <- tryCatch( + runKeeperReviewWorkflow( + base_dir = base_dir, + execution_settings_path = file.path(base_dir, "strategus-execution-settings.json"), + cohort_id_map_path = file.path(output_dir, "cohort_id_map.json"), + cohort_roles_path = roles_path, + intent_path = intent_split_path, + acp_timeout_seconds = keeper_acp_timeout_seconds, + review_roles = keeper_review_roles, + overwrite_approved_concept_sets = keeper_overwrite_approved_concept_sets, + reuse_generated_concept_sets = keeper_reuse_generated_artifacts, + reuse_rows = keeper_reuse_generated_artifacts, + resume_reviews = keeper_resume_reviews, + review_row_selection = keeper_review_row_selection, + stage_callback = stage_callback + ), + error = function(e) e + ) + + if (inherits(keeper_review_result, "error")) { + cat(sprintf("Keeper review failed: %s\n", conditionMessage(keeper_review_result))) + } else { + keeper_review_ran <- TRUE + cat(sprintf("Keeper review state saved to: %s\n", keeper_review_state_path)) + } + set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path)) + } + } + + state$keeper_review_state_path <- keeper_review_state_path + state$keeper_review_roles <- as.list(keeper_review_roles) + state$keeper_acp_timeout_seconds <- as.numeric(keeper_acp_timeout_seconds) + state$keeper_reuse_generated_artifacts <- isTRUE(keeper_reuse_generated_artifacts) + state$keeper_overwrite_approved_concept_sets <- isTRUE(keeper_overwrite_approved_concept_sets) + state$keeper_resume_reviews <- isTRUE(keeper_resume_reviews) + state$keeper_review_row_selection <- keeper_review_row_selection + state$keeper_review_ran <- isTRUE(keeper_review_ran) + write_json(state, state_path) + # ---- Generate scripts ---- if (interactive) { cat("\n== Step 8: Generate scripts ==\n") @@ -1309,9 +1394,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide # 04 - Keeper review script_04 <- c( script_header, - "library(Keeper)", "library(jsonlite)", - "library(DatabaseConnector)", "", "# loads the Strategus workflow assistant package when working from the repo", "if (!requireNamespace('slashOhdsiStrategusAssistant', quietly = TRUE)) {", @@ -1322,120 +1405,55 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide " }", "}", "library(slashOhdsiStrategusAssistant)", - "`%||%` <- function(x, y) if (is.null(x)) y else x", "", sprintf("base_dir <- '%s'", base_dir), "output_dir <- file.path(base_dir, 'outputs')", - "keeper_dir <- file.path(base_dir, 'keeper-case-review')", - "dir.create(keeper_dir, recursive = TRUE, showWarnings = FALSE)", - "id_map <- jsonlite::fromJSON(file.path(output_dir, 'cohort_id_map.json'))$mapping", - "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "cohort_id_map_path <- file.path(output_dir, 'cohort_id_map.json')", + "cohort_roles_path <- file.path(output_dir, 'cohort_roles.json')", + "intent_path <- file.path(output_dir, 'intent_split.json')", "", - "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings()", - "# TODO: fill in databaseId (used by Keeper for labeling outputs)", - "databaseId <- ''", - "cdmDatabaseSchema <- exec$cdmDatabaseSchema", - "cohortDatabaseSchema <- exec$workDatabaseSchema", - "cohortTable <- exec$cohortTable", + "# Edit these defaults as needed before running the ACP-based Keeper workflow.", + "review_roles <- c('outcome')", + "domain_keys <- c(", + " 'doi', 'alternativeDiagnosis', 'symptoms', 'drugs',", + " 'diagnosticProcedures', 'measurements', 'treatmentProcedures', 'complications'", + ")", + "candidate_limit <- 50", + "sample_size <- 20", + "review_row_limit <- 5", + "acp_timeout_seconds <- as.numeric(Sys.getenv('ACP_TIMEOUT', '300'))", + "Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds))", + "reuse_generated_concept_sets <- TRUE", + "overwrite_approved_concept_sets <- FALSE", + "reuse_rows <- TRUE", + "resume_reviews <- TRUE", + "review_row_selection <- NULL # e.g. '1-3,5'", + "acp_url <- Sys.getenv('ACP_URL', 'http://127.0.0.1:8765')", "", - "for (cid in id_map$cohort_id) {", - " keeper <- createKeeper(", - " connectionDetails = connectionDetails,", - " databaseId = databaseId,", - " cdmDatabaseSchema = cdmDatabaseSchema,", - " cohortDatabaseSchema = cohortDatabaseSchema,", - " cohortTable = cohortTable,", - " cohortDefinitionId = cid,", - " cohortName = paste('Cohort', cid),", - " sampleSize = 100,", - " assignNewId = TRUE,", - " useAncestor = TRUE,", - " doi = c(4202064, 192671, 2108878, 2108900, 2002608),", - " symptoms = c(4103703, 443530, 4245614, 28779),", - " comorbidities = c(81893, 201606, 313217, 318800, 432585, 4027663, 4180790, 4212540, - 40481531, 42535737, 46271022),", - " drugs = c(904453, 906780, 923645, 929887, 948078, 953076, 961047, 985247, 992956, - 997276, 1102917, 1113648, 1115008, 1118045, 1118084, 1124300, 1126128, - 1136980, 1146810, 1150345, 1153928, 1177480, 1178663, 1185922, 1195492, - 1236607, 1303425, 1313200, 1353766, 1507835, 1522957, 1721543, 1746940, - 1777806, 19044727, 19119253, 36863425),", - " diagnosticProcedures = c(4087381, 4143985, 4294382, 42872565, 45888171, 46257627),", - " measurements = c(3000905, 3000963, 3003458, 3012471, 3016251, 3018677, 3020416, - 3022217, 3023314, 3024929, 3034426),", - " alternativeDiagnosis = c(24966, 76725, 195562, 316457, 318800, 4096682),", - " treatmentProcedures = c(0),", - " complications = c(132797, 196152, 439777, 4192647)", - " )", - " out_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", - " write.csv(keeper, out_path, row.names = FALSE)", - "}", - "# Optional: if ACP is available, use phenotype_validation_review on rows from keeper_dir.", - "# Uncomment to enable:", - "if (!requireNamespace('slashOhdsiAcpClient', quietly = TRUE) && requireNamespace('devtools', quietly = TRUE)) {", - " devtools::load_all('OHDSI-Study-Agent/R/slashOhdsiAcpClient')", - "}", - "if (requireNamespace('slashOhdsiAcpClient', quietly = TRUE) || 'slashOhdsiAcpClient' %in% loadedNamespaces()) {", - " acp_client <- slashOhdsiAcpClient::acp_client('http://127.0.0.1:8765', check = FALSE)", - " roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", - " intent <- jsonlite::fromJSON(file.path(output_dir, 'intent_split.json'), simplifyVector = TRUE)", - " cohort_type <- utils::select.list(c('target', 'outcome'), title = 'Keeper review for which cohort type?')", - " if (!nzchar(cohort_type)) stop('No cohort type selected.')", - " get_intent_field <- function(obj, field) {", - " if (!is.null(obj$intent_split) && !is.null(obj$intent_split[[field]])) return(obj$intent_split[[field]])", - " obj[[field]]", - " }", - " default_disease <- if (cohort_type == 'target') get_intent_field(intent, 'target_statement') else get_intent_field(intent, 'outcome_statement')", - " disease_name <- readline(sprintf('Disease name [%s]: ', default_disease))", - " if (!nzchar(trimws(disease_name))) disease_name <- default_disease", - " sample_n <- as.integer(readline('How many cases per cohort to review? [5]: '))", - " if (is.na(sample_n) || sample_n <= 0) sample_n <- 5", - " random_pick <- tolower(trimws(readline('Randomly sample cases? [Y/n]: ')))", - " random_pick <- !(random_pick %in% c('n', 'no'))", - " write_output <- tolower(trimws(readline('Write LLM review rows to file? [Y/n]: ')))", - " write_output <- !(write_output %in% c('n', 'no'))", - " cohort_ids <- if (cohort_type == 'target') roles$targets else roles$outcomes", - " for (cid in cohort_ids) {", - " keeper_path <- file.path(keeper_dir, sprintf('%s.csv', cid))", - " keeper_rows <- read.csv(keeper_path, stringsAsFactors = FALSE)", - " if (nrow(keeper_rows) == 0) next", - " n <- min(sample_n, nrow(keeper_rows))", - " idx <- if (random_pick) sample(seq_len(nrow(keeper_rows)), n) else seq_len(n)", - " selected <- keeper_rows[idx, , drop = FALSE]", - " results <- list()", - " for (i in seq_len(nrow(selected))) {", - " row_payload <- as.list(selected[i, , drop = FALSE])", - " row_payload <- lapply(row_payload, function(x) if (length(x) == 0) NA else x)", - " resp <- slashOhdsiAcpClient::acp_call_flow(", - " acp_client,", - " 'phenotype_validation_review',", - " list(keeper_row = row_payload, disease_name = disease_name)", - " )", - " if (!is.null(resp$status) && resp$status == 'error') {", - " out <- c(row_payload, list(label = NA, rationale = NA, acp_error = resp$error %||% 'acp_error'))", - " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", - " next", - " }", - " label <- resp$label", - " rationale <- resp$rationale", - " if (is.null(label) && !is.null(resp$full_result$label)) label <- resp$full_result$label", - " if (is.null(rationale) && !is.null(resp$full_result$rationale)) rationale <- resp$full_result$rationale", - " if (is.null(label) && !is.null(resp$result$label)) label <- resp$result$label", - " if (is.null(rationale) && !is.null(resp$result$rationale)) rationale <- resp$result$rationale", - " if (is.null(label)) label <- NA", - " if (is.null(rationale)) rationale <- NA", - " out <- c(row_payload, list(label = label, rationale = rationale))", - " results[[length(results) + 1]] <- as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)", - " }", - " if (length(results) > 0) {", - " out_df <- do.call(rbind, results)", - " if (write_output) {", - " out_path <- file.path(keeper_dir, sprintf('%s_llm_review.csv', cid))", - " write.csv(out_df, out_path, row.names = FALSE)", - " }", - " print(out_df)", - " }", - " }", - " }", + "result <- slashOhdsiStrategusAssistant::runKeeperReviewWorkflow(", + " base_dir = base_dir,", + " execution_settings_path = execution_settings_path,", + " cohort_id_map_path = cohort_id_map_path,", + " cohort_roles_path = cohort_roles_path,", + " intent_path = intent_path,", + " acp_url = acp_url,", + " acp_timeout_seconds = acp_timeout_seconds,", + " review_roles = review_roles,", + " domain_keys = domain_keys,", + " candidate_limit = candidate_limit,", + " sample_size = sample_size,", + " review_row_limit = review_row_limit,", + " overwrite_approved_concept_sets = overwrite_approved_concept_sets,", + " reuse_generated_concept_sets = reuse_generated_concept_sets,", + " reuse_rows = reuse_rows,", + " resume_reviews = resume_reviews,", + " review_row_selection = review_row_selection,", + " remove_pii = TRUE", + ")", + "keeper_state_path <- file.path(output_dir, 'keeper_review_state.json')", + "message('Keeper review state saved to: ', keeper_state_path)", + "print(result)", "" ) write_lines(file.path(scripts_dir, "04_keeper_review.R"), script_04) diff --git a/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R index c9c62e7..05a9886 100644 --- a/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R +++ b/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R @@ -29,6 +29,8 @@ incidence_dialogue_step_label <- function(step, role = "") { target_selection = "Target selection", outcome_selection = "Outcome selection", phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + keeper_concept_set_generation = if (nzchar(role_label)) paste0(role_label, "Keeper concept-set generation") else "Keeper concept-set generation", + keeper_case_review = if (nzchar(role_label)) paste0(role_label, "Keeper case review") else "Keeper case review", incidence_design_setup = "Incidence design setup", time_at_risk_configuration = "Time-at-risk configuration", workflow_summary = "Workflow summary", @@ -104,6 +106,8 @@ cohort_methods_dialogue_step_label <- function(step, role = "") { comparator_selection = "Comparator selection", outcome_selection = "Outcome selection", phenotype_review = if (nzchar(role_label)) paste0(role_label, "phenotype review") else "Phenotype review", + keeper_concept_set_generation = if (nzchar(role_label)) paste0(role_label, "Keeper concept-set generation") else "Keeper concept-set generation", + keeper_case_review = if (nzchar(role_label)) paste0(role_label, "Keeper case review") else "Keeper case review", analytic_settings_collection = "Analytic settings collection", cohort_method_spec_recommendation = "Cohort method specification recommendation", cohort_method_spec_confirmation = "Cohort method specification confirmation", diff --git a/acp_agent/study_agent_acp/demo_shell.py b/acp_agent/study_agent_acp/demo_shell.py index b4b1b36..06e1fe0 100644 --- a/acp_agent/study_agent_acp/demo_shell.py +++ b/acp_agent/study_agent_acp/demo_shell.py @@ -94,7 +94,7 @@ def _build_parser( @dataclass class ACPClient: base_url: str - timeout: int = 120 + timeout: int = 360 def get(self, path: str) -> Dict[str, Any]: request = urllib.request.Request(f"{self.base_url}{path}", method="GET") diff --git a/scripts/demo_strategus_cohort_method.R b/scripts/demo_strategus_cohort_method.R index 47e22e6..8209fdd 100644 --- a/scripts/demo_strategus_cohort_method.R +++ b/scripts/demo_strategus_cohort_method.R @@ -9,18 +9,7 @@ ## /ohdsi what is weak about this comparator cohort? ## /ohdsi what should I double-check before accepting these analytic settings? -script_dir <- local({ - cmd_args <- commandArgs(trailingOnly = FALSE) - file_arg <- grep("^--file=", cmd_args, value = TRUE) - if (length(file_arg) > 0) { - return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) - } - frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) - if (length(frame_files) > 0) { - return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) - } - normalizePath("scripts", winslash = "/", mustWork = FALSE) -}) +script_dir = "OHDSI-Study-Agent/scripts/" source(file.path(script_dir, "demo_setup.R")) repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) @@ -32,7 +21,7 @@ invisible(connect_study_agent_acp()) ### CLEAN UP FROM LAST RUN? # Uncomment to reset the state of the output folder. -# unlink(repo_file("demo-strategus-cohort-method"), recursive = TRUE, force = TRUE) +#unlink(repo_file("demo-strategus-cohort-method"), recursive = TRUE, force = TRUE) # # If you already ran `scripts/test_strategus_incidence_plus_keeper.R`, this shell can # reuse cached target and outcome artifacts from `demo-strategus-cohort-incidence`. diff --git a/scripts/test_strategus_incidence_plus_keeper.R b/scripts/test_strategus_incidence_plus_keeper.R index 44ac8f7..4bfbb66 100644 --- a/scripts/test_strategus_incidence_plus_keeper.R +++ b/scripts/test_strategus_incidence_plus_keeper.R @@ -4,21 +4,15 @@ ## `scripts/demo_ohdsi_dialogue.R` is the quickest non-interactive `/ohdsi` smoke test. ## ## Useful `/ohdsi` prompts to try once the shell reaches phenotype recommendation steps: -## /ohdsi why are these candidate target cohorts weak here? -## /ohdsi what would make this outcome definition more defensible? - -script_dir <- local({ - cmd_args <- commandArgs(trailingOnly = FALSE) - file_arg <- grep("^--file=", cmd_args, value = TRUE) - if (length(file_arg) > 0) { - return(dirname(normalizePath(sub("^--file=", "", file_arg[[1]]), winslash = "/", mustWork = FALSE))) - } - frame_files <- Filter(Negate(is.null), lapply(sys.frames(), function(x) x$ofile)) - if (length(frame_files) > 0) { - return(dirname(normalizePath(frame_files[[length(frame_files)]], winslash = "/", mustWork = FALSE))) - } - normalizePath("scripts", winslash = "/", mustWork = FALSE) -}) +## /ohdsi what should I do if none of the candidate cohorts are relevant? +## /ohdsi what happens if I accept the phenotype improvement recommendations? +## /ohdsi how should I specify TAR so that denominators are coherent across strata? + +### CLEAN UP FROM LAST RUN? +# Uncomment to reset the state of the output folder. +unlink("OHDSI-Study-Agent/demo-strategus-cohort-incidence", recursive = TRUE, force = TRUE) + +script_dir = "OHDSI-Study-Agent/scripts/" source(file.path(script_dir, "demo_setup.R")) repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) @@ -27,19 +21,6 @@ load_study_agent_r_packages(include_strategus = TRUE) Sys.setenv(ACP_TIMEOUT = "280") invisible(connect_study_agent_acp()) -### CLEAN UP FROM LAST RUN? -# Uncomment to reset the state of the output folder. -# unlink(repo_file("demo-strategus-cohort-incidence"), recursive = TRUE, force = TRUE) - -## (NO RELEVANT PHENOTYPE TEST) First enter this study intent, which should not return strong phenotype matches: -## "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" -slashOhdsiStrategusAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - acpUrl = "http://127.0.0.1:8765", - studyAgentBaseDir = repo_root, - indexDir = "data/phenotype_index_cipher_omop" -) - ## (RELEVANT PHENOTYPE TEST) This intent should yield stronger phenotype candidates: slashOhdsiStrategusAssistant::runStrategusIncidenceShell( outputDir = "demo-strategus-cohort-incidence", @@ -49,6 +30,8 @@ slashOhdsiStrategusAssistant::runStrategusIncidenceShell( studyIntent = "What is the risk of GI bleed in new users of tofacitinib compared to new users of ruxolitinib?" ) + +############ ## Use this to resume from cached artifacts and regenerate output scripts. slashOhdsiStrategusAssistant::runStrategusIncidenceShell( outputDir = "demo-strategus-cohort-incidence", @@ -60,3 +43,14 @@ slashOhdsiStrategusAssistant::runStrategusIncidenceShell( interactive = FALSE, indexDir = "data/phenotype_index_cipher_omop" ) + + + +## (NO RELEVANT PHENOTYPE TEST) First enter this study intent, which should not return strong phenotype matches: +## "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" +slashOhdsiStrategusAssistant::runStrategusIncidenceShell( + outputDir = "demo-strategus-cohort-incidence", + acpUrl = "http://127.0.0.1:8765", + studyAgentBaseDir = repo_root, + indexDir = "data/phenotype_index_cipher_omop" +) diff --git a/tests/test_keeper_dialogue_integration_static.py b/tests/test_keeper_dialogue_integration_static.py new file mode 100644 index 0000000..0c862df --- /dev/null +++ b/tests/test_keeper_dialogue_integration_static.py @@ -0,0 +1,75 @@ +from pathlib import Path + + +MAPPING_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R") +HELPER_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R") +COHORT_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") +INCIDENCE_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") + + +def test_keeper_stage_labels_exist_for_both_shells() -> None: + source = MAPPING_SOURCE.read_text(encoding="utf-8") + + assert 'keeper_concept_set_generation = if (nzchar(role_label)) paste0(role_label, "Keeper concept-set generation") else "Keeper concept-set generation"' in source + assert 'keeper_case_review = if (nzchar(role_label)) paste0(role_label, "Keeper case review") else "Keeper case review"' in source + + +def test_keeper_helper_emits_metadata_only_stage_callbacks() -> None: + source = HELPER_SOURCE.read_text(encoding="utf-8") + + assert "acp_timeout_seconds = as.numeric(Sys.getenv(\"ACP_TIMEOUT\", \"300\"))" in source + assert "previous_acp_timeout <- Sys.getenv(\"ACP_TIMEOUT\", unset = NA_character_)" in source + assert "Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds))" in source + assert "acp_timeout_seconds = as.numeric(acp_timeout_seconds)" in source + assert "stage_callback = NULL" in source + assert "overwrite_approved_concept_sets = FALSE" in source + assert "resume_reviews = TRUE" in source + assert "review_row_selection = NULL" in source + assert "parse_row_selection <- function(selection, total_rows, default_limit)" in source + assert 'tolower(selection_text) %in% c("all", "*")' in source + assert 'selected_row_indices <- parse_row_selection(review_row_selection, length(row_records), review_row_limit)' in source + assert 'pending_row_indices <- selected_row_indices[!selected_row_indices %in% reviewed_indices]' in source + assert 'approved_source <- "overwritten_from_generated"' in source + assert 'approved_concept_sets_source = approved_source' in source + assert 'selected_row_indices = as.list(selected_row_indices)' in source + assert 'pending_row_indices = as.list(pending_row_indices)' in source + assert "keeper_row = keeper_row" in source + assert "emit_stage(" in source + + +def _assert_shell_keeper_controls(source: str) -> None: + assert 'keeper_acp_timeout_seconds <- as.numeric(Sys.getenv("ACP_TIMEOUT", "300"))' in source + assert 'prompt_yesno("Run ACP-based Keeper review now?", default = FALSE)' in source + assert 'readline_with_dialogue("Keeper review roles [outcome]: ")' in source + assert 'prompt_yesno("Reuse existing Keeper generated artifacts?", default = TRUE)' in source + assert 'prompt_yesno("Replace approved concept sets with current generated output?", default = FALSE)' in source + assert 'prompt_yesno("Resume existing Keeper row reviews?", default = TRUE)' in source + assert 'readline_with_dialogue("Keeper row selection [default first N or e.g. 1-3,5]: ")' in source + assert 'runKeeperReviewWorkflow(' in source + assert 'acp_timeout_seconds = keeper_acp_timeout_seconds' in source + assert 'overwrite_approved_concept_sets = keeper_overwrite_approved_concept_sets' in source + assert 'reuse_generated_concept_sets = keeper_reuse_generated_artifacts' in source + assert 'reuse_rows = keeper_reuse_generated_artifacts' in source + assert 'resume_reviews = keeper_resume_reviews' in source + assert 'review_row_selection = keeper_review_row_selection' in source + assert 'state$keeper_acp_timeout_seconds <- as.numeric(keeper_acp_timeout_seconds)' in source + assert 'state$keeper_reuse_generated_artifacts <- isTRUE(keeper_reuse_generated_artifacts)' in source + assert 'state$keeper_overwrite_approved_concept_sets <- isTRUE(keeper_overwrite_approved_concept_sets)' in source + assert 'state$keeper_resume_reviews <- isTRUE(keeper_resume_reviews)' in source + assert 'state$keeper_review_row_selection <- keeper_review_row_selection' in source + + +def test_cohort_method_shell_offers_inline_keeper_phase() -> None: + source = COHORT_SOURCE.read_text(encoding="utf-8") + _assert_shell_keeper_controls(source) + assert 'intent_path = cohort_methods_intent_split_path' in source + assert 'stage_callback = stage_callback' in source + assert 'set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path))' in source + + +def test_incidence_shell_offers_inline_keeper_phase() -> None: + source = INCIDENCE_SOURCE.read_text(encoding="utf-8") + _assert_shell_keeper_controls(source) + assert 'intent_path = intent_split_path' in source + assert 'stage_callback = stage_callback' in source + assert 'set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path))' in source diff --git a/tests/test_keeper_generated_scripts.py b/tests/test_keeper_generated_scripts.py new file mode 100644 index 0000000..b8432e1 --- /dev/null +++ b/tests/test_keeper_generated_scripts.py @@ -0,0 +1,45 @@ +from pathlib import Path + + +COHORT_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") +INCIDENCE_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") + + +def _generated_script_block(source: str, script_name: str, filename: str) -> str: + start = source.index(f"{script_name} <- c(") + end = source.index(f'write_lines(file.path(scripts_dir, "{filename}")', start) + return source[start:end] + + +def _assert_keeper_script_contract(block: str, intent_fragment: str) -> None: + assert "runKeeperReviewWorkflow(" in block + assert intent_fragment in block + assert "keeper_review_state.json" in block + assert "acp_timeout_seconds <- as.numeric(Sys.getenv('ACP_TIMEOUT', '300'))" in block + assert "Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds))" in block + assert "acp_timeout_seconds = acp_timeout_seconds" in block + assert "reuse_generated_concept_sets <- TRUE" in block + assert "overwrite_approved_concept_sets <- FALSE" in block + assert "reuse_rows <- TRUE" in block + assert "resume_reviews <- TRUE" in block + assert "review_row_selection <- NULL # e.g. '1-3,5'" in block + assert "overwrite_approved_concept_sets = overwrite_approved_concept_sets" in block + assert "reuse_generated_concept_sets = reuse_generated_concept_sets" in block + assert "reuse_rows = reuse_rows" in block + assert "resume_reviews = resume_reviews" in block + assert "review_row_selection = review_row_selection" in block + assert "library(Keeper)" not in block + assert "library(DatabaseConnector)" not in block + assert "createKeeper(" not in block + assert "databaseId" not in block + assert "strategus-db-details.json" not in block + + +def test_cohort_method_generated_keeper_script_uses_acp_helper_only() -> None: + block = _generated_script_block(COHORT_SOURCE.read_text(encoding="utf-8"), "script_04", "04_keeper_review.R") + _assert_keeper_script_contract(block, "cohort_methods_intent_split.json") + + +def test_incidence_generated_keeper_script_uses_acp_helper_only() -> None: + block = _generated_script_block(INCIDENCE_SOURCE.read_text(encoding="utf-8"), "script_04", "04_keeper_review.R") + _assert_keeper_script_contract(block, "intent_split.json") diff --git a/tests/test_keeper_r_wrappers_static.py b/tests/test_keeper_r_wrappers_static.py new file mode 100644 index 0000000..4887d71 --- /dev/null +++ b/tests/test_keeper_r_wrappers_static.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FLOWS_SOURCE = REPO_ROOT / "R/slashOhdsiAcpClient/R/flows.R" +RUNTIME_SOURCE = REPO_ROOT / "R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R" +NAMESPACE_SOURCE = REPO_ROOT / "R/slashOhdsiAcpClient/NAMESPACE" + + +def test_keeper_concept_set_wrapper_covers_full_r_contract() -> None: + source = FLOWS_SOURCE.read_text(encoding="utf-8") + + assert "acp_keeper_concept_sets_generate <- function(" in source + assert "domain_keys = NULL" in source + assert "vocab_search_provider = NULL" in source + assert "phoebe_provider = NULL" in source + assert "min_record_count = NULL" in source + assert 'acp_call_flow(client, "keeper_concept_sets_generate", body)' in source + assert 'body$vocab_search_provider <- trimws(as.character(vocab_search_provider))' in source + assert 'body$phoebe_provider <- trimws(as.character(phoebe_provider))' in source + assert 'body$min_record_count <- as.numeric(min_record_count)' in source + + +def test_keeper_profile_and_review_wrappers_are_exposed() -> None: + source = FLOWS_SOURCE.read_text(encoding="utf-8") + namespace = NAMESPACE_SOURCE.read_text(encoding="utf-8") + + assert "acp_keeper_profiles_generate <- function(" in source + assert 'acp_call_flow(client, "keeper_profiles_generate", body)' in source + assert "keeper_concept_sets = keeper_concept_sets" in source + assert "person_ids = as.list(person_ids)" in source + assert "acp_phenotype_validation_review <- function(" in source + assert '"phenotype_validation_review"' in source + assert "export(acp_keeper_profiles_generate)" in namespace + assert "export(acp_phenotype_validation_review)" in namespace + + +def test_strategus_runtime_exposes_keeper_passthrough_helpers() -> None: + source = RUNTIME_SOURCE.read_text(encoding="utf-8") + + assert ".studyAgentSlashAcpKeeperConceptSetsGenerate <- function(" in source + assert "slashOhdsiAcpClient::acp_keeper_concept_sets_generate(" in source + assert ".studyAgentSlashAcpKeeperProfilesGenerate <- function(" in source + assert "slashOhdsiAcpClient::acp_keeper_profiles_generate(" in source + assert ".studyAgentSlashAcpPhenotypeValidationReview <- function(" in source + assert "slashOhdsiAcpClient::acp_phenotype_validation_review(" in source From ed306408e8b5667ed8616235ac78f805ac29b220 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Mon, 11 May 2026 16:46:28 -0500 Subject: [PATCH 12/20] Added TAR selection to incidence shell --- .../R/strategus_incidence_shell.R | 260 +++++++++++++++++- tests/test_incidence_shell_selection_state.py | 36 ++- 2 files changed, 285 insertions(+), 11 deletions(-) diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R index d0842ec..85a0d83 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -128,6 +128,207 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide jsonlite::write_json(x, path, pretty = TRUE, auto_unbox = TRUE) } + default_time_at_risk_settings <- function() { + list( + time_at_risk_defs = list( + list(id = 1L, name = "During exposure", startWith = "start", startOffset = 0L, endWith = "end", endOffset = 0L), + list(id = 2L, name = "365 days after cohort start", startWith = "start", startOffset = 0L, endWith = "start", endOffset = 365L) + ), + analysis_tar_ids = c(1L, 2L), + strata_settings = list(byYear = TRUE, byGender = TRUE, byAge = FALSE, ageBreaks = c(18L, 45L, 65L)) + ) + } + + normalize_time_at_risk_settings <- function(settings) { + settings <- settings %||% list() + defs <- settings$time_at_risk_defs %||% settings$tars %||% list() + if (!is.list(defs) || length(defs) == 0) defs <- default_time_at_risk_settings()$time_at_risk_defs + + normalized_defs <- lapply(seq_along(defs), function(i) { + item <- defs[[i]] %||% list() + id <- suppressWarnings(as.integer(item$id %||% i)) + if (is.na(id) || id <= 0L) stop(sprintf("time_at_risk_defs[%s].id must be a positive integer.", i)) + start_with <- tolower(trimws(as.character(item$startWith %||% "start"))) + end_with <- tolower(trimws(as.character(item$endWith %||% "end"))) + if (!start_with %in% c("start", "end")) stop(sprintf("time_at_risk_defs[%s].startWith must be 'start' or 'end'.", i)) + if (!end_with %in% c("start", "end")) stop(sprintf("time_at_risk_defs[%s].endWith must be 'start' or 'end'.", i)) + start_offset <- suppressWarnings(as.integer(item$startOffset %||% 0L)) + end_offset <- suppressWarnings(as.integer(item$endOffset %||% 0L)) + if (is.na(start_offset)) stop(sprintf("time_at_risk_defs[%s].startOffset must be an integer.", i)) + if (is.na(end_offset)) stop(sprintf("time_at_risk_defs[%s].endOffset must be an integer.", i)) + name <- trimws(as.character(item$name %||% sprintf("TAR %s", id))) + if (!nzchar(name)) name <- sprintf("TAR %s", id) + list( + id = as.integer(id), + name = name, + startWith = start_with, + startOffset = as.integer(start_offset), + endWith = end_with, + endOffset = as.integer(end_offset) + ) + }) + + ids <- vapply(normalized_defs, function(item) as.integer(item$id), integer(1)) + if (length(unique(ids)) != length(ids)) stop("time_at_risk_defs ids must be unique.") + + analysis_tar_ids <- suppressWarnings(as.integer(unlist(settings$analysis_tar_ids %||% ids, use.names = FALSE))) + analysis_tar_ids <- unique(analysis_tar_ids[!is.na(analysis_tar_ids)]) + if (length(analysis_tar_ids) == 0) analysis_tar_ids <- ids + if (!all(analysis_tar_ids %in% ids)) stop("analysis_tar_ids must reference defined time_at_risk_defs ids.") + + strata <- settings$strata_settings %||% list() + by_year <- isTRUE(strata$byYear %||% TRUE) + by_gender <- isTRUE(strata$byGender %||% TRUE) + by_age <- isTRUE(strata$byAge %||% FALSE) + age_breaks <- suppressWarnings(as.integer(unlist(strata$ageBreaks %||% c(18L, 45L, 65L), use.names = FALSE))) + age_breaks <- unique(age_breaks[!is.na(age_breaks)]) + age_breaks <- sort(age_breaks) + if (isTRUE(by_age) && length(age_breaks) == 0) stop("strata_settings.ageBreaks must contain at least one integer when byAge is TRUE.") + + list( + time_at_risk_defs = normalized_defs, + analysis_tar_ids = as.integer(analysis_tar_ids), + strata_settings = list( + byYear = by_year, + byGender = by_gender, + byAge = by_age, + ageBreaks = as.integer(age_breaks) + ) + ) + } + + print_time_at_risk_settings <- function(settings) { + settings <- normalize_time_at_risk_settings(settings) + cat("\nCurrent time-at-risk settings\n") + for (item in settings$time_at_risk_defs) { + cat(sprintf( + " - TAR %s [%s]: start=%s %+d days, end=%s %+d days\n", + item$id, + item$name, + item$startWith, + item$startOffset, + item$endWith, + item$endOffset + )) + } + cat(sprintf(" Analysis TAR ids: %s\n", paste(settings$analysis_tar_ids, collapse = ", "))) + strata <- settings$strata_settings + cat(sprintf( + " Strata: byYear=%s, byGender=%s, byAge=%s%s\n", + strata$byYear, + strata$byGender, + strata$byAge, + if (isTRUE(strata$byAge) && length(strata$ageBreaks) > 0) paste0(", ageBreaks=", paste(strata$ageBreaks, collapse = ",")) else "" + )) + } + + collect_time_at_risk_settings <- function(seed_settings, + study_intent, + target_statement, + outcome_statement, + target_ids, + outcome_ids) { + settings <- normalize_time_at_risk_settings(seed_settings) + + set_dialogue_context( + "time_at_risk_configuration", + context = list( + study_intent = study_intent, + target_statement = target_statement, + outcome_statement = outcome_statement, + selected_target_ids = as.list(target_ids %||% list()), + selected_outcome_ids = as.list(outcome_ids %||% list()), + time_at_risk_settings = settings, + denominator_guidance = "Denominators depend on cohort entry logic, TAR definitions, and chosen strata settings." + ) + ) + + if (isTRUE(interactive)) cat("\n== Step 8: Configure time at risk ==\n") + print_time_at_risk_settings(settings) + if (!isTRUE(interactive)) return(settings) + if (prompt_yesno("Use these time-at-risk and strata settings?", default = TRUE)) return(settings) + + prompt_integer_value <- function(prompt, current, min_value = NULL) { + repeat { + entered <- trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, current))) + if (!nzchar(entered)) return(as.integer(current)) + parsed <- suppressWarnings(as.integer(entered)) + if (!is.na(parsed) && (is.null(min_value) || parsed >= min_value)) return(as.integer(parsed)) + cat("Please enter a valid integer.\n") + } + } + + prompt_choice_value <- function(prompt, current, choices) { + repeat { + entered <- tolower(trimws(readline_with_dialogue(sprintf("%s [%s]: ", prompt, current)))) + if (!nzchar(entered)) return(current) + if (entered %in% choices) return(entered) + cat(sprintf("Please enter one of: %s\n", paste(choices, collapse = ", "))) + } + } + + prompt_text_value <- function(prompt, current) { + entered <- readline_with_dialogue(sprintf("%s [%s]: ", prompt, current)) + if (!nzchar(trimws(entered))) current else trimws(entered) + } + + tar_count <- prompt_integer_value("Number of time-at-risk definitions", length(settings$time_at_risk_defs), min_value = 1L) + defs <- vector("list", tar_count) + for (i in seq_len(tar_count)) { + current <- settings$time_at_risk_defs[[min(i, length(settings$time_at_risk_defs))]] %||% list( + id = i, + name = sprintf("TAR %s", i), + startWith = "start", + startOffset = 0L, + endWith = "end", + endOffset = 0L + ) + cat(sprintf("\nTAR %s\n", i)) + defs[[i]] <- list( + id = prompt_integer_value(" TAR id", current$id, min_value = 1L), + name = prompt_text_value(" TAR label", current$name %||% sprintf("TAR %s", i)), + startWith = prompt_choice_value(" startWith (start/end)", current$startWith %||% "start", c("start", "end")), + startOffset = prompt_integer_value(" startOffset (days)", current$startOffset %||% 0L), + endWith = prompt_choice_value(" endWith (start/end)", current$endWith %||% "end", c("start", "end")), + endOffset = prompt_integer_value(" endOffset (days)", current$endOffset %||% 0L) + ) + } + + default_analysis_ids <- paste(vapply(defs, function(item) as.integer(item$id), integer(1)), collapse = ",") + analysis_ids_text <- trimws(readline_with_dialogue(sprintf("Analysis TAR ids (comma-separated) [%s]: ", default_analysis_ids))) + analysis_ids <- if (!nzchar(analysis_ids_text)) { + suppressWarnings(as.integer(strsplit(default_analysis_ids, ",", fixed = TRUE)[[1]])) + } else { + suppressWarnings(as.integer(trimws(strsplit(analysis_ids_text, ",", fixed = TRUE)[[1]]))) + } + + strata_settings <- settings$strata_settings + by_year <- prompt_yesno("Stratify incidence by calendar year?", default = isTRUE(strata_settings$byYear)) + by_gender <- prompt_yesno("Stratify incidence by gender?", default = isTRUE(strata_settings$byGender)) + by_age <- prompt_yesno("Stratify incidence by age?", default = isTRUE(strata_settings$byAge)) + age_breaks_default <- paste(strata_settings$ageBreaks %||% c(18L, 45L, 65L), collapse = ",") + age_breaks <- strata_settings$ageBreaks %||% c(18L, 45L, 65L) + if (isTRUE(by_age)) { + age_breaks_text <- trimws(readline_with_dialogue(sprintf("Age breaks (comma-separated integers) [%s]: ", age_breaks_default))) + if (nzchar(age_breaks_text)) { + age_breaks <- suppressWarnings(as.integer(trimws(strsplit(age_breaks_text, ",", fixed = TRUE)[[1]]))) + } + } + + settings <- normalize_time_at_risk_settings(list( + time_at_risk_defs = defs, + analysis_tar_ids = analysis_ids, + strata_settings = list( + byYear = by_year, + byGender = by_gender, + byAge = by_age, + ageBreaks = age_breaks + ) + )) + print_time_at_risk_settings(settings) + settings + } + acp_try <- function(path, body, label) { repeat { resp <- NULL @@ -954,6 +1155,21 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cohort_df <- do.call(rbind, cohort_rows) write.csv(cohort_df, cohort_csv, row.names = FALSE) + time_at_risk_settings_path <- file.path(analysis_settings_dir, "time_at_risk_settings.json") + seed_time_at_risk_settings <- if (file.exists(time_at_risk_settings_path)) { + tryCatch(read_json(time_at_risk_settings_path), error = function(e) default_time_at_risk_settings()) + } else { + default_time_at_risk_settings() + } + incidence_time_at_risk <- collect_time_at_risk_settings( + seed_settings = seed_time_at_risk_settings, + study_intent = studyIntent, + target_statement = target_statement, + outcome_statement = outcome_statement, + target_ids = target_ids, + outcome_ids = outcome_ids + ) + write_json(incidence_time_at_risk, time_at_risk_settings_path) state <- list( study_intent = studyIntent, @@ -968,6 +1184,8 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide patched_outcome_dir = patched_outcome_dir, keeper_dir = keeper_dir, analysis_settings_dir = analysis_settings_dir, + time_at_risk_settings_path = time_at_risk_settings_path, + incidence_time_at_risk = incidence_time_at_risk, index_def_dir = index_def_dir, intent_split_path = intent_split_path, recommendations_target_path = recs_target_path, @@ -1083,7 +1301,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide # ---- Generate scripts ---- if (interactive) { - cat("\n== Step 8: Generate scripts ==\n") + cat("\n== Step 9: Generate scripts ==\n") } write_lines <- function(path, lines) { writeLines(lines, con = path, useBytes = TRUE) @@ -1555,6 +1773,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide "output_dir <- file.path(base_dir, 'outputs')", "analysis_settings_dir <- file.path(base_dir, 'analysis-settings')", "dir.create(analysis_settings_dir, recursive = TRUE, showWarnings = FALSE)", + "time_at_risk_settings_path <- file.path(analysis_settings_dir, 'time_at_risk_settings.json')", "selected_dir <- file.path(base_dir, 'selected-cohorts')", "patched_dir <- file.path(base_dir, 'patched-cohorts')", "cohort_csv <- file.path(selected_dir, 'Cohorts.csv')", @@ -1589,24 +1808,44 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", " CohortIncidence::createOutcomeDef(id = id, name = row$cohortName[1])", "})", - "", - "tars <- list(", - " CohortIncidence::createTimeAtRiskDef(id = 1, startWith = 'start', endWith = 'end'),", - " CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)", - ")", - "", + "tar_settings <- jsonlite::fromJSON(time_at_risk_settings_path, simplifyVector = FALSE)", + "tar_defs <- tar_settings$time_at_risk_defs %||% list()", + "if (length(tar_defs) == 0) stop('No time-at-risk definitions found in time_at_risk_settings.json')", + "tars <- lapply(tar_defs, function(def) {", + " CohortIncidence::createTimeAtRiskDef(", + " id = as.numeric(def$id %||% NA),", + " startWith = as.character(def$startWith %||% 'start'),", + " startOffset = as.numeric(def$startOffset %||% 0),", + " endWith = as.character(def$endWith %||% 'end'),", + " endOffset = as.numeric(def$endOffset %||% 0)", + " )", + "})", + "analysis_tar_ids <- as.numeric(unlist(tar_settings$analysis_tar_ids %||% lapply(tar_defs, function(def) def$id), use.names = FALSE))", + "analysis_tar_ids <- analysis_tar_ids[!is.na(analysis_tar_ids)]", + "if (length(analysis_tar_ids) == 0) stop('No analysis TAR ids found in time_at_risk_settings.json')", + "strata_args <- tar_settings$strata_settings %||% list()", + "strata_args$byYear <- isTRUE(strata_args$byYear %||% TRUE)", + "strata_args$byGender <- isTRUE(strata_args$byGender %||% TRUE)", + "strata_args$byAge <- isTRUE(strata_args$byAge %||% FALSE)", + "age_breaks <- suppressWarnings(as.numeric(unlist(strata_args$ageBreaks %||% numeric(0), use.names = FALSE)))", + "age_breaks <- age_breaks[!is.na(age_breaks)]", + "if (isTRUE(strata_args$byAge) && length(age_breaks) > 0) {", + " strata_args$ageBreaks <- age_breaks", + "} else {", + " strata_args$ageBreaks <- NULL", + "}", + "strataSettings <- do.call(CohortIncidence::createStrataSettings, strata_args)", "analysis1 <- CohortIncidence::createIncidenceAnalysis(", " targets = sapply(targets, function(x) x$id),", " outcomes = sapply(outcomes, function(x) x$id),", - " tars = c(1, 2)", + " tars = analysis_tar_ids", ")", - "", "irDesign <- CohortIncidence::createIncidenceDesign(", " targetDefs = targets,", " outcomeDefs = outcomes,", " tars = tars,", " analysisList = list(analysis1),", - " strataSettings = CohortIncidence::createStrataSettings(byYear = TRUE, byGender = TRUE)", + " strataSettings = strataSettings", ")", "", "ciModule <- CohortIncidenceModule$new()", @@ -1649,6 +1888,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide cat(sprintf(" - Selected target cohorts: %s\n", selected_target_dir)) cat(sprintf(" - Selected outcome cohorts: %s\n", selected_outcome_dir)) cat(sprintf(" - Selected cohorts (combined): %s\n", selected_dir)) + cat(sprintf(" - Time-at-risk settings: %s\n", time_at_risk_settings_path)) if (improvements_applied) { cat(sprintf(" - Patched target cohorts: %s\n", patched_target_dir)) cat(sprintf(" - Patched outcome cohorts: %s\n", patched_outcome_dir)) diff --git a/tests/test_incidence_shell_selection_state.py b/tests/test_incidence_shell_selection_state.py index 7d05053..d533588 100644 --- a/tests/test_incidence_shell_selection_state.py +++ b/tests/test_incidence_shell_selection_state.py @@ -1,7 +1,7 @@ from pathlib import Path -SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") +SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") def test_outcome_selection_state_is_initialized_before_target_mapping_prompt() -> None: @@ -29,3 +29,37 @@ def test_improvement_errors_are_not_silently_treated_as_empty_results() -> None: assert "extract_phenotype_improvement_items <- function(resp, cohort_label)" in source assert "ACP returned an error for %s phenotype improvements: %s" in source + +def test_time_at_risk_configuration_context_and_state_are_persisted() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert 'set_dialogue_context(' in source + assert '"time_at_risk_configuration"' in source + assert 'denominator_guidance = "Denominators depend on cohort entry logic, TAR definitions, and chosen strata settings."' in source + assert 'time_at_risk_settings_path <- file.path(analysis_settings_dir, "time_at_risk_settings.json")' in source + assert 'incidence_time_at_risk <- collect_time_at_risk_settings(' in source + assert 'write_json(incidence_time_at_risk, time_at_risk_settings_path)' in source + assert 'time_at_risk_settings_path = time_at_risk_settings_path' in source + assert 'incidence_time_at_risk = incidence_time_at_risk' in source + + +def test_generated_incidence_script_uses_persisted_time_at_risk_settings() -> None: + source = SOURCE.read_text(encoding="utf-8") + + start = source.index('script_06 <- c(') + end = source.index('write_lines(file.path(scripts_dir, "06_incidence_spec.R")', start) + block = source[start:end] + + assert "time_at_risk_settings_path <- file.path(analysis_settings_dir, 'time_at_risk_settings.json')" in block + assert "tar_settings <- jsonlite::fromJSON(time_at_risk_settings_path, simplifyVector = FALSE)" in block + assert "tar_defs <- tar_settings$time_at_risk_defs %||% list()" in block + assert "CohortIncidence::createTimeAtRiskDef(" in block + assert "analysis_tar_ids <- as.numeric(unlist(tar_settings$analysis_tar_ids %||% lapply(tar_defs, function(def) def$id), use.names = FALSE))" in block + assert "strata_args <- tar_settings$strata_settings %||% list()" in block + assert 'strataSettings <- do.call(CohortIncidence::createStrataSettings, strata_args)' in block + assert 'tars = analysis_tar_ids' in block + assert "CohortIncidence::createTimeAtRiskDef(id = 1, startWith = 'start', endWith = 'end')" not in block + assert "CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)" not in block + assert 'tars = c(1, 2)' not in block + assert 'createStrataSettings(byYear = TRUE, byGender = TRUE)' not in block + From 4b29a90a1ad0f9751d8ddfb54646d846683220b6 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 04:27:11 -0500 Subject: [PATCH 13/20] Overall cleanup of the project files. Move to AGENTS.md from ad hoc agent guidance doc --- AGENTS.md | 170 ++++++++++++++ CODING_AGENT_README.md | 220 ------------------ CohortMethod_HANDOFF.md | 76 ------ R/slashOhdsiStrategusAssistant/README.md | 8 + README.md | 27 +-- docs/COHORT_METHODS_WORKFLOW.md | 66 +++--- docs/INCIDENCE_WORKFLOW.md | 35 +-- docs/KEEPER-EXPANSION-PLAN.md | 171 -------------- docs/KEEPER_INTERFACE_SPEC.md | 2 +- docs/R_PACKAGE_ARCHITECTURE_PLAN.md | 6 +- docs/STRATEGUS_COHORT_METHODS_SHELL.md | 146 ++---------- docs/STRATEGUS_SHELL.md | 47 ++-- scripts/demo_setup.R | 13 ++ scripts/demo_strategus_cohort_method.R | 5 +- .../test_strategus_incidence_plus_keeper.R | 52 ++--- tests/_repo_paths.py | 15 ++ .../test_cohort_methods_generated_scripts.py | 7 +- ...rt_methods_shell_recommendation_support.py | 5 +- tests/test_incidence_shell_selection_state.py | 4 +- ...test_keeper_dialogue_integration_static.py | 11 +- tests/test_keeper_generated_scripts.py | 7 +- ...est_r_workflow_context_dialogue_wrapper.py | 7 +- 22 files changed, 380 insertions(+), 720 deletions(-) create mode 100644 AGENTS.md delete mode 100644 CODING_AGENT_README.md delete mode 100644 CohortMethod_HANDOFF.md delete mode 100644 docs/KEEPER-EXPANSION-PLAN.md create mode 100644 tests/_repo_paths.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c54b1ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# AGENTS.md + +## Project overview + +This repository builds an agent-style interface for OHDSI study design work. The clearest implemented stories are: + +- phenotype recommendation and improvement for target/outcome cohort selection +- Keeper-assisted concept generation, profile extraction, and row adjudication for phenotype validation +- R Strategus shells for incidence and cohort-method workflows + +Do not describe the repo as if the full future service catalog is already implemented. The top-level [README.md](README.md) reflects the intended public product story. + +## Repo layout + +- `acp_agent/`: ACP server and user-facing flow orchestration +- `mcp_server/`: MCP tool server, prompt bundles, retrieval, vocabulary, and Keeper tooling +- `core/`: deterministic logic and validation +- `R/slashOhdsiAcpClient/`: R ACP client package and thin flow/action wrappers +- `R/slashOhdsiStrategusAssistant/`: R Strategus workflow package and shell entrypoints +- `docs/`: primary documentation and architecture notes +- `scripts/`: manual R demos and test entrypoints +- `tests/`: Python tests, smoke tests, and static assertions + +## Setup commands + +Python environment: + +```bash +pip install -e ".[dev]" +``` + +Recommended local runtime setup is MCP over HTTP plus ACP over HTTP: + +```bash +export MCP_TRANSPORT=http +export MCP_HOST=127.0.0.1 +export MCP_PORT=8790 +export MCP_PATH=/mcp +study-agent-mcp +``` + +Then: + +```bash +export STUDY_AGENT_MCP_URL="http://127.0.0.1:8790/mcp" +export STUDY_AGENT_HOST=127.0.0.1 +export STUDY_AGENT_PORT=8765 +study-agent-acp +``` + +Useful R-side manual entrypoints: + +- `scripts/test_strategus_incidence_plus_keeper.R` +- `scripts/demo_strategus_cohort_method.R` +- `scripts/demo_ohdsi_dialogue.R` + +## Testing instructions + +Useful broad docs and commands: + +- [docs/TESTING.md](docs/TESTING.md) +- `pytest -m acp` +- `pytest -m mcp` + +Small targeted checks that are cheap to run: + +```bash +pytest -q tests/test_demo_shell.py +python -m study_agent_acp.demo_shell --help +``` + +For R-shell and generated-script changes, prefer focused static tests first. Relevant examples: + +- `tests/test_incidence_shell_selection_state.py` +- `tests/test_keeper_generated_scripts.py` +- `tests/test_keeper_dialogue_integration_static.py` +- `tests/test_cohort_methods_generated_scripts.py` +- `tests/test_r_workflow_context_dialogue_wrapper.py` + +If you change R demo scripts, run parse checks with `Rscript -e "parse(file='...')"` before finishing. + +## Code style and implementation guidance + +- Prefer documenting implemented flows over speculative services. +- When updating docs, check whether files have moved under `docs/` before adding new references. +- The demo shell is intentionally thin; keep it as an ACP client unless there is a strong reason to duplicate ACP logic. +- For user-facing shell improvements, prefer low-complexity terminal upgrades first before introducing a heavier TUI dependency. +- Do not assume recommendation responses are a flat list; ACP wraps them in a `recommendations` object. +- For static tests that read repo files, prefer the helper in `tests/_repo_paths.py` so tests can resolve the repo root from `STUDY_AGENT_REPO_ROOT` or from the test tree. + +## Security and data handling + +- No PHI/PII should be sent to LLMs. +- `phenotype_validation_review` must go through Keeper sanitization before prompt construction. +- `keeper_profiles_generate` is deterministic only; any downstream LLM use still requires the sanitization gate. +- Treat row-level data handling as safety-critical. Preserve fail-closed behavior. + +## Key flows and components + +Implemented ACP flows include: + +- `phenotype_recommendation` +- `phenotype_recommendation_advice` +- `phenotype_improvements` +- `phenotype_intent_split` +- `cohort_methods_intent_split` +- `concept_sets_review` +- `cohort_critique_general_design` +- `keeper_concept_sets_generate` +- `keeper_profiles_generate` +- `phenotype_validation_review` + +Sources of truth: + +- [docs/SERVICE_REGISTRY.yaml](docs/SERVICE_REGISTRY.yaml) +- [acp_agent/study_agent_acp/server.py](acp_agent/study_agent_acp/server.py) +- [R/slashOhdsiStrategusAssistant/README.md](R/slashOhdsiStrategusAssistant/README.md) + +## Important environment variables + +LLM: + +- `LLM_API_URL` +- `LLM_API_KEY` +- `LLM_MODEL` +- `LLM_TIMEOUT` +- `LLM_LOG` +- `LLM_USE_RESPONSES` + +Embeddings / phenotype retrieval: + +- `EMBED_URL` +- `EMBED_MODEL` +- `EMBED_API_KEY` +- `PHENOTYPE_INDEX_DIR` +- `PHENOTYPE_DENSE_WEIGHT` +- `PHENOTYPE_SPARSE_WEIGHT` + +ACP / MCP: + +- `STUDY_AGENT_HOST` +- `STUDY_AGENT_PORT` +- `STUDY_AGENT_MCP_URL` +- `STUDY_AGENT_MCP_COMMAND` +- `STUDY_AGENT_MCP_ARGS` +- `STUDY_AGENT_MCP_TIMEOUT` +- `ACP_TIMEOUT` + +Demo shell: + +- `STUDY_AGENT_DEMO_ACP_URL` +- `STUDY_AGENT_DEMO_OUTPUT_DIR` + +## High-value docs + +- [README.md](README.md) +- [docs/TESTING.md](docs/TESTING.md) +- [docs/PHENOTYPE_RECOMMENDATION_DESIGN.md](docs/PHENOTYPE_RECOMMENDATION_DESIGN.md) +- [docs/PHENOTYPE_VALIDATION_REVIEW.md](docs/PHENOTYPE_VALIDATION_REVIEW.md) +- [docs/KEEPER_INTERFACE_SPEC.md](docs/KEEPER_INTERFACE_SPEC.md) +- [docs/STRATEGUS_SHELL.md](docs/STRATEGUS_SHELL.md) +- [docs/INCIDENCE_WORKFLOW.md](docs/INCIDENCE_WORKFLOW.md) +- [docs/ROADMAP.md](docs/ROADMAP.md) +- [docs/R_PACKAGE_ARCHITECTURE_PLAN.md](docs/R_PACKAGE_ARCHITECTURE_PLAN.md) + +## Practical notes + +- The worktree may contain unrelated scratch files such as editor temp files or `demo-shell-output/`; do not delete them unless asked. +- The user may validate R workflows from a parent `renv` one level above the repo. Do not modify or replace that parent `renv`. +- For shell test scripts, prefer the shared helper in `scripts/demo_setup.R` rather than ad hoc destructive reset logic. diff --git a/CODING_AGENT_README.md b/CODING_AGENT_README.md deleted file mode 100644 index 2c496f0..0000000 --- a/CODING_AGENT_README.md +++ /dev/null @@ -1,220 +0,0 @@ -# Coding Agent Summary (OHDSI-Study-Agent) - -This file is a concise handoff for future coding-agent sessions. - -## Current Product Story - -The clearest implemented stories in this repo are: - -- phenotype recommendation and improvement for target/outcome cohort selection -- Keeper-assisted concept generation, profile extraction, and row adjudication for phenotype validation - -The top-level [README.md](/ai-agent/HadesProject/OHDSI-Study-Agent/README.md:1) now reflects that narrower scope. Do not describe the repo as if the full future service catalog is already implemented. - -## Repo Layout - -- `acp_agent/`: ACP server and user-facing flow orchestration -- `mcp_server/`: MCP tool server, prompt bundles, retrieval, vocabulary, and Keeper tooling -- `core/`: pure deterministic logic and validation -- `R/slashOhdsiAcpClient/`: R ACP client package and thin flow/action wrappers -- `R/slashOhdsiStrategusAssistant/`: R Strategus workflow package and shell entrypoints -- `docs/`: primary documentation, including testing, roadmap, Keeper specs, and moved legacy docs -- `demo/`: sample artifacts and cohort JSON files - -Recent doc moves to remember: - -- `docs/TEST-RUN.md` -- `docs/GIT-GUIDE.md` -- `docs/KEEPER-EXPANSION-PLAN.md` - -## Architecture - -### ACP - -- HTTP server in `acp_agent/study_agent_acp/server.py` -- exposes `/health`, `/tools`, `/tools/call`, `/services`, and flow endpoints under `/flows/*` -- orchestrates MCP tool calls and LLM calls -- safe-summary wrapping happens in ACP when using `/tools/call` - -### MCP - -- tool contracts and deterministic outputs live under `mcp_server/study_agent_mcp/tools/` -- can run via stdio or HTTP -- owns phenotype index access, vocabulary search tools, Keeper prompt bundles, and Keeper extraction tooling - -### Core - -- reusable validation and filtering logic in `core/study_agent_core/` -- no network or IO assumptions - -## Key Safety Rule - -- No PHI/PII should be sent to LLMs. -- `phenotype_validation_review` must go through Keeper sanitization before prompt construction. -- `keeper_profiles_generate` is deterministic only; downstream LLM use still requires the sanitization gate. - -## Implemented ACP Flows - -The currently exposed ACP flow endpoints are: - -- `phenotype_recommendation` -- `phenotype_recommendation_advice` -- `phenotype_improvements` -- `phenotype_intent_split` -- `cohort_methods_intent_split` -- `concept_sets_review` -- `cohort_critique_general_design` -- `keeper_concept_sets_generate` -- `keeper_profiles_generate` -- `phenotype_validation_review` - -For the source of truth, check: - -- [docs/SERVICE_REGISTRY.yaml](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/SERVICE_REGISTRY.yaml:1) -- [acp_agent/study_agent_acp/server.py](/ai-agent/HadesProject/OHDSI-Study-Agent/acp_agent/study_agent_acp/server.py:1) - -## Demo Shell - -There is now a small ACP-backed demo CLI: - -- entrypoint: `study-agent-demo-shell` -- module: [acp_agent/study_agent_acp/demo_shell.py](/ai-agent/HadesProject/OHDSI-Study-Agent/acp_agent/study_agent_acp/demo_shell.py:1) -- output dir default: `./demo-shell-output/` -- history file: `demo-shell-output/.study-agent-demo-history` - -Current slash commands: - -- `/phenotype-intent-split` -- `/phenotype-recommend` -- `/vocab-search-standard` -- `/vocab-phoebe-related` -- `/keeper-generate-concepts` -- `/keeper-review-row` -- `/services` -- `/help` -- `/quit` - -Important limitation: - -- `/keeper-review-row` currently reviews a JSON row or a row selected from a JSON `rows[]` payload on disk. -- it can use a Keeper concepts file to infer `disease_name`, but the ACP review flow itself still only consumes `disease_name` plus `keeper_row` -- there is not yet a matching `/keeper-generate-profiles` shell command - -If future work extends the shell, adding `/keeper-generate-profiles` is the most natural next step. - -## Packaging / Entry Points - -Current console scripts from [pyproject.toml](/ai-agent/HadesProject/OHDSI-Study-Agent/pyproject.toml:1): - -- `study-agent-mcp` -- `study-agent-acp` -- `study-agent-demo-shell` - -After changing entrypoints or package layout, rerun: - -```bash -pip install -e . -``` - -## Common Run Setup - -Recommended cross-platform-stable local setup is MCP over HTTP plus ACP over HTTP: - -```bash -export MCP_TRANSPORT=http -export MCP_HOST=127.0.0.1 -export MCP_PORT=8790 -export MCP_PATH=/mcp -study-agent-mcp -``` - -Then: - -```bash -export STUDY_AGENT_MCP_URL="http://127.0.0.1:8790/mcp" -export STUDY_AGENT_HOST=127.0.0.1 -export STUDY_AGENT_PORT=8765 -study-agent-acp -``` - -Then: - -```bash -study-agent-demo-shell -``` - -## Important Environment Variables - -LLM: - -- `LLM_API_URL` -- `LLM_API_KEY` -- `LLM_MODEL` -- `LLM_TIMEOUT` -- `LLM_LOG` -- `LLM_USE_RESPONSES` - -Embeddings / phenotype retrieval: - -- `EMBED_URL` -- `EMBED_MODEL` -- `EMBED_API_KEY` -- `PHENOTYPE_INDEX_DIR` -- `PHENOTYPE_DENSE_WEIGHT` -- `PHENOTYPE_SPARSE_WEIGHT` - -ACP / MCP: - -- `STUDY_AGENT_HOST` -- `STUDY_AGENT_PORT` -- `STUDY_AGENT_MCP_URL` -- `STUDY_AGENT_MCP_COMMAND` -- `STUDY_AGENT_MCP_ARGS` -- `STUDY_AGENT_MCP_TIMEOUT` -- `ACP_TIMEOUT` - -Demo shell: - -- `STUDY_AGENT_DEMO_ACP_URL` -- `STUDY_AGENT_DEMO_OUTPUT_DIR` - -Vocabulary / Phoebe providers: - -- `VOCAB_SEARCH_PROVIDER` -- `VOCAB_SEARCH_URL` -- `PHOEBE_PROVIDER` - -## Testing - -Useful current tests/docs: - -- [docs/TESTING.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/TESTING.md:1) -- [tests/test_demo_shell.py](/ai-agent/HadesProject/OHDSI-Study-Agent/tests/test_demo_shell.py:1) -- pytest markers: `core`, `acp`, `mcp` - -Small targeted checks that are cheap to run: - -```bash -pytest -q tests/test_demo_shell.py -python -m study_agent_acp.demo_shell --help -``` - -## High-Value Docs - -- [README.md](/ai-agent/HadesProject/OHDSI-Study-Agent/README.md:1) -- [docs/TESTING.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/TESTING.md:1) -- [docs/PHENOTYPE_RECOMMENDATION_DESIGN.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/PHENOTYPE_RECOMMENDATION_DESIGN.md:1) -- [docs/PHENOTYPE_VALIDATION_REVIEW.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/PHENOTYPE_VALIDATION_REVIEW.md:1) -- [docs/KEEPER_INTERFACE_SPEC.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/KEEPER_INTERFACE_SPEC.md:1) -- [docs/STRATEGUS_SHELL.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/STRATEGUS_SHELL.md:1) -- [docs/INCIDENCE_WORKFLOW.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/INCIDENCE_WORKFLOW.md:1) -- [docs/ROADMAP.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/ROADMAP.md:1) - -## Practical Notes For Future Coding Agents - -- Prefer documenting implemented flows over speculative services. -- When updating docs, check whether files have moved under `docs/` before adding new references. -- The demo shell is intentionally thin; keep it as an ACP client unless there is a strong reason to duplicate ACP logic. -- For user-facing shell improvements, prefer low-complexity terminal upgrades first, such as readline/history/completion, before introducing a larger TUI dependency. -- Do not assume recommendation responses are a flat list; ACP wraps them in a `recommendations` object. -- The worktree may contain unrelated scratch files such as editor temp files or `demo-shell-output/`; do not delete them unless asked. diff --git a/CohortMethod_HANDOFF.md b/CohortMethod_HANDOFF.md deleted file mode 100644 index e460aad..0000000 --- a/CohortMethod_HANDOFF.md +++ /dev/null @@ -1,76 +0,0 @@ - -## What Was Implemented - -We added the current CohortMethod shell path around -`slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()`. In broad terms, the work now -supports: - -- CohortMethod-specific intent splitting from one study-intent sentence into - target, comparator, and one or more outcome statements. - - We kept the existing `phenotype_intent_split` flow unchanged and added a - separate `cohort_methods_intent_split` flow. Please decide whether these - should remain separate or be consolidated later. -- Phenotype recommendation and optional improvement for target, comparator, and - outcome cohorts. -- Negative-control and covariate concept-set selection remain future work. This - step currently implements only the scaffold; it should be completed once ACP - flows for suggesting negative controls and covariate concept sets are - available. -- CohortMethod analytic-settings collection through either `step_by_step` prompts - or `free_text` ACP recommendation. -- Generation of reproducible output artifacts, CohortMethod analytic-settings - JSON, and Strategus R scripts including `scripts/06_cm_spec.R` - -### Main ACP/MCP/core additions: - -- ACP flow endpoint: - - `/flows/cohort_methods_intent_split` - - ACP flow endpoint: `/flows/cohort_methods_specifications_recommendation` -- MCP tool: - - `cohort_methods_intent_split` - - MCP tool: `cohort_methods_prompt_bundle` -- R helper: `slashOhdsiStrategusAssistant::suggestCohortMethodSpecs()` -- R shell integration: `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` -- Prompt/template assets: - - CohortMethod intent-split overview/spec/schema assets. - - CohortMethod cmAnalysis template and instruction assets under - `mcp_server/prompts/cohort_methods/`. - -## Read These For Details - -- Shell workflow, output layout, generated scripts, current boundaries, and - analytic-settings prompt details: - - `docs/STRATEGUS_COHORT_METHODS_SHELL.md` -- End-to-end workflow diagrams: - - `docs/COHORT_METHODS_WORKFLOW.md` -- Free-text analytic-settings recommendation flow, endpoint shape, response - shape, and projection from cmAnalysis-style specs: - - `docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md` -- CohortMethod ACP/MCP service registry entries: - - `docs/SERVICE_REGISTRY.yaml` -- CohortMethod cmAnalysis prompt/template assets: - - `mcp_server/prompts/cohort_methods/cmAnalysis_template.json` - - `mcp_server/prompts/cohort_methods/CM_ANALYSIS_TEMPLATE.md` - - `mcp_server/prompts/cohort_methods/instruction_cohort_methods_specs.md` - - `mcp_server/prompts/cohort_methods/output_style_cohort_methods_specs.md` -- CohortMethod intent-split prompt/schema assets: - - `mcp_server/prompts/phenotype/overview_cohort_methods_intent_split.md` - - `mcp_server/prompts/phenotype/spec_cohort_methods_intent_split.md` - - `mcp_server/prompts/phenotype/output_schema_cohort_methods_intent_split.json` -- R usage examples: - - `R/slashOhdsiStrategusAssistant/README.md` -- Test and smoke-test commands: - - `docs/TESTING.md` - -## Remaining TODO - - -Future CohortMethod coverage: - -- Replace dummy negative-control and covariate concept-set artifacts with real - ACP/MCP-backed workflows. - - The current placeholder path cannot exclude high-correlation covariates, which can cause script `06_cm_spec.R` to fail when `errorOnHighCorrelation` is enabled. -- Properly implement `scripts/04_keeper_review.R` for CohortMethod outputs. -- Support multiple CohortMethod analyses -- Add validation for cohort-method analytic-settings recommendations before - they are accepted into the shell/generated scripts. diff --git a/R/slashOhdsiStrategusAssistant/README.md b/R/slashOhdsiStrategusAssistant/README.md index 86ac5fa..001673e 100644 --- a/R/slashOhdsiStrategusAssistant/README.md +++ b/R/slashOhdsiStrategusAssistant/README.md @@ -8,13 +8,21 @@ It owns: - interactive Strategus shell entrypoints - checkpointing and artifact layout - generated Strategus assets +- ACP-based Keeper workflow orchestration for generated scripts - Strategus DB and execution-settings helpers Primary entrypoints: - `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` - `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()` +- `slashOhdsiStrategusAssistant::runKeeperReviewWorkflow()` - `slashOhdsiStrategusAssistant::createStrategusConnectionDetails()` - `slashOhdsiStrategusAssistant::createStrategusExecutionSettings()` +Current shell details: + +- the incidence shell persists explicit TAR and strata settings to `analysis-settings/time_at_risk_settings.json` +- both Strategus shells can run or generate ACP-based Keeper review with `keeper_review_state.json` reuse/resume artifacts +- generated Keeper scripts expose `ACP_TIMEOUT`, concept-set reuse/overwrite controls, and explicit row selection controls + It depends on `slashOhdsiAcpClient` for ACP calls. diff --git a/README.md b/README.md index 0dc3f90..3ee0789 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ This repo addresses those bottlenecks by combining: - phenotype retrieval from an indexed phenotype library - constrained LLM ranking or critique with deterministic validation - Keeper-oriented tooling for concept generation, OMOP profile extraction, and row-level adjudication using sanitized summaries only -- an R shell that turns selected cohorts into a reproducible Strategus incidence workflow +- R shells that turn selected cohorts into reproducible Strategus incidence and cohort-method workflows At no point should raw row-level patient data be sent directly to an LLM. @@ -55,13 +55,13 @@ Related implemented flows: - `concept_sets_review` - `cohort_critique_general_design` -This same recommendation path is already wired into the R Strategus incidence shell for target/outcome selection. +This same recommendation path is already wired into the R Strategus incidence shell and the cohort-method shell. Primary references: -- [docs/PHENOTYPE_RECOMMENDATION_DESIGN.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/PHENOTYPE_RECOMMENDATION_DESIGN.md) -- [docs/STRATEGUS_SHELL.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/STRATEGUS_SHELL.md) -- [docs/INCIDENCE_WORKFLOW.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/INCIDENCE_WORKFLOW.md) +- [docs/PHENOTYPE_RECOMMENDATION_DESIGN.md](docs/PHENOTYPE_RECOMMENDATION_DESIGN.md) +- [docs/STRATEGUS_SHELL.md](docs/STRATEGUS_SHELL.md) +- [docs/INCIDENCE_WORKFLOW.md](docs/INCIDENCE_WORKFLOW.md) ### 2. Keeper-Assisted Phenotype Validation @@ -80,12 +80,13 @@ Current characteristics: - concept generation can use Hecate-backed, generic-search, or DB-backed vocabulary tooling - profile extraction is deterministic only and does not call an LLM - downstream adjudication is constrained by fail-closed sanitization and a small label set +- the R Strategus shells now generate ACP-based `04_keeper_review.R` scripts that persist Keeper workflow state for reuse and resume Primary references: -- [docs/KEEPER_INTERFACE_SPEC.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/KEEPER_INTERFACE_SPEC.md) -- [docs/PHENOTYPE_VALIDATION_REVIEW.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/PHENOTYPE_VALIDATION_REVIEW.md) -- [docs/TESTING.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/TESTING.md) +- [docs/KEEPER_INTERFACE_SPEC.md](docs/KEEPER_INTERFACE_SPEC.md) +- [docs/PHENOTYPE_VALIDATION_REVIEW.md](docs/PHENOTYPE_VALIDATION_REVIEW.md) +- [docs/TESTING.md](docs/TESTING.md) ## End-To-End Workflows @@ -173,7 +174,7 @@ export LLM_MODEL= This has been tested with [Open webui](https://docs.openwebui.com/), with locally hosted models, and [LLM Shim](https://github.com/dbmi-pitt/llm-shim) with access to cloud services (tested with openai and bedrock models) and an embedding model serviced using the HugginFace Text Embedding Interface service. -If you want phenotype retrieval, you also need an indexed phenotype library. See [docs/PHENOTYPE_INDEXING.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/PHENOTYPE_INDEXING.md). +If you want phenotype retrieval, you also need an indexed phenotype library. See [docs/PHENOTYPE_INDEXING.md](docs/PHENOTYPE_INDEXING.md). Current indexing workflow: @@ -227,10 +228,10 @@ curl -s -X POST http://127.0.0.1:8765/flows/phenotype_validation_review \ ## Where To Go Next -- Installation, smoke tests, and provider-specific examples: [docs/TESTING.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/TESTING.md) -- Implemented service inventory: [docs/SERVICE_REGISTRY.yaml](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/SERVICE_REGISTRY.yaml) +- Installation, smoke tests, and provider-specific examples: [docs/TESTING.md](docs/TESTING.md) +- Implemented service inventory: [docs/SERVICE_REGISTRY.yaml](docs/SERVICE_REGISTRY.yaml) - Docker setup: see `compose.yaml` and `.env.example` -- ACP and MCP component details: [acp_agent/README.md](/ai-agent/HadesProject/OHDSI-Study-Agent/acp_agent/README.md), [mcp_server/README.md](/ai-agent/HadesProject/OHDSI-Study-Agent/mcp_server/README.md) +- ACP and MCP component details: [acp_agent/README.md](acp_agent/README.md), [mcp_server/README.md](mcp_server/README.md) ## Contributing @@ -252,7 +253,7 @@ Active expansion areas: - more phenotype authoring support beyond recommendation and improvement - broader study-design critique and cohort authoring services -For the broader future-service catalog, see [docs/ROADMAP.md](/ai-agent/HadesProject/OHDSI-Study-Agent/docs/ROADMAP.md). +For the broader future-service catalog, see [docs/ROADMAP.md](docs/ROADMAP.md). ## What Remains Experimental diff --git a/docs/COHORT_METHODS_WORKFLOW.md b/docs/COHORT_METHODS_WORKFLOW.md index 1070df9..888d9a5 100644 --- a/docs/COHORT_METHODS_WORKFLOW.md +++ b/docs/COHORT_METHODS_WORKFLOW.md @@ -65,8 +65,9 @@ flowchart TD AJ --> AT["Confirm Analytic Settings"] AS --> AT - AT --> AU["Write Outputs + Generate Scripts 02-06"] - AU --> AV["End"] + AT --> AU["Optional inline ACP Keeper review"] + AU --> AV["Write Outputs + Generate Scripts 02-06"] + AV --> AW["End"] ``` ## Strategus Execution Context @@ -81,30 +82,39 @@ flowchart TD E --> F["Cohort Table in CDM"] C --> G["04_keeper_review.R"] - G --> H["Keeper Case Review"] - H --> I["Optional: refine phenotypes"] - I --> B - - C --> J["05_diagnostics.R"] - J --> K["CohortDiagnostics"] - - C --> L["outputs/cm_analysis_defaults.json"] - C --> M["analysis-settings/cmAnalysis.json"] - C --> N["outputs/cm_comparisons.json"] - C --> O["selected or patched cohort definitions"] - - L --> P["06_cm_spec.R"] - M --> P - N --> P - O --> P - F --> P - K --> P - - P --> Q["analysis-settings/analysisSpecification.json"] - Q --> R["Shared Cohort Resource"] - Q --> S["CharacterizationModule Spec"] - Q --> T["CohortIncidenceModule Spec"] - Q --> U["CohortMethodModule Spec"] - Q --> V["Strategus::execute"] - V --> W["CohortMethod Results + Strategus Execute Result"] + G --> H["ACP Keeper flow"] + H --> I["Concept-set generation"] + H --> J["Keeper profile extraction"] + H --> K["Phenotype validation review"] + K --> L["Optional phenotype refinement"] + L --> B + + C --> M["05_diagnostics.R"] + M --> N["CohortDiagnostics"] + + C --> O["outputs/cm_analysis_defaults.json"] + C --> P["analysis-settings/cmAnalysis.json"] + C --> Q["outputs/cm_comparisons.json"] + C --> R["selected or patched cohort definitions"] + + O --> S["06_cm_spec.R"] + P --> S + Q --> S + R --> S + F --> S + N --> S + + S --> T["analysis-settings/analysisSpecification.json"] + T --> U["Shared Cohort Resource"] + T --> V["CharacterizationModule Spec"] + T --> W["CohortIncidenceModule Spec"] + T --> X["CohortMethodModule Spec"] + T --> Y["Strategus::execute"] + Y --> Z["CohortMethod Results + Strategus Execute Result"] ``` + +## Current Explicit Limitations + +- Negative-control and covariate concept-set workflows are still placeholder-based. +- Cohort-method generation currently materializes only the first comparison from `cm_comparisons.json`. +- ACP analytic-settings recommendations are converted into shell settings, but a dedicated recommendation validation layer is still pending. diff --git a/docs/INCIDENCE_WORKFLOW.md b/docs/INCIDENCE_WORKFLOW.md index 8f3b871..46ee132 100644 --- a/docs/INCIDENCE_WORKFLOW.md +++ b/docs/INCIDENCE_WORKFLOW.md @@ -45,8 +45,10 @@ flowchart TD AC --> AE[Write Outputs + Roles + Cohorts.csv] AD --> AE - AE --> AF[Generate Scripts 01–06] - AF --> AG[End] + AE --> AF[Configure TAR + Strata Settings] + AF --> AG[Optional Inline ACP Keeper Review] + AG --> AH[Generate Scripts 01-06] + AH --> AI[End] ``` ## Strategus Execution Context @@ -54,24 +56,29 @@ flowchart TD ```mermaid flowchart TD A[Study Intent] --> B[runStrategusIncidenceShell] - B --> C[Outputs: cohorts + roles + scripts] + B --> C[Outputs: cohorts + roles + TAR settings + scripts] C --> D[03_generate_cohorts.R] D --> E[CohortGenerator] E --> F[Cohort Table in CDM] C --> G[04_keeper_review.R] - G --> H[Keeper Case Review] - H --> I[Optional: refine phenotypes] - I --> B + G --> H[ACP Keeper flow] + H --> I[Concept-set generation] + H --> J[Keeper profile extraction] + H --> K[Phenotype validation review] + K --> L[Optional phenotype refinement] + L --> B - C --> J[05_diagnostics.R] - J --> K[CohortDiagnostics] + C --> M[05_diagnostics.R] + M --> N[CohortDiagnostics] - C --> L[06_incidence_spec.R] - L --> M[CohortIncidence Spec JSON] + C --> O[analysis-settings/time_at_risk_settings.json] + C --> P[06_incidence_spec.R] + O --> P + E --> P + N --> P - E --> L - K --> L - M --> N[Strategus Execution] - N --> O[Incidence Rate Results] + P --> Q[CohortIncidence Spec JSON] + Q --> R[Strategus Execution] + R --> S[Incidence Rate Results] ``` diff --git a/docs/KEEPER-EXPANSION-PLAN.md b/docs/KEEPER-EXPANSION-PLAN.md deleted file mode 100644 index e86f38c..0000000 --- a/docs/KEEPER-EXPANSION-PLAN.md +++ /dev/null @@ -1,171 +0,0 @@ - Target Design - - Do not run the Keeper R package from the Strategus shells. - - Use the Keeper 2.0 subfolder only as a semantic reference for: - - - concept-set domain names - - row field names - - expected review workflow shape - - The actual R runtime path should be: - - 1. keeper_concept_sets_generate - 2. user review/approval of concept sets - 3. keeper_profiles_generate - 4. row-by-row phenotype_validation_review - 5. /ohdsi available during concept-set generation and row-review steps - - Plan - - 1. Complete the R ACP client surface. - - - Expand R/slashOhdsiAcpClient/R/flows.R:138 so acp_keeper_concept_sets_generate() matches the full ACP contract: vocab_search_provider, phoebe_provider, min_record_count, and the - existing fields. - - Add acp_keeper_profiles_generate(...). - - Add acp_phenotype_validation_review(...). - - Add matching runtime passthrough helpers in R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R:1 so the workflow package stays on the public ACP seam. - - 2. Add one shared R helper module for Keeper workflow orchestration. - - - Create a shared helper file in R/slashOhdsiStrategusAssistant/R/, not duplicated shell logic. - - Responsibilities: - - derive default phenotype labels from selected cohort names/statements - - call the three ACP flows - - persist raw concept-set responses, approved concept sets, generated rows, and per-row review results - - implement row selection/review loops - - print concise summaries and surface ACP errors clearly - - This helper should be reused by both cohort-method and incidence shells. - - 3. Wire Keeper stages into the interactive shells. - - - Insert an optional Keeper phase after cohort selection/improvements are finalized and before final script generation. - - Recommended flow per selected role/cohort: - - choose which roles to review - - generate concept sets - - accept/edit/rerun concept sets - - generate Keeper rows - - review rows one by one - - Default behavior should probably be: - - outcomes first - - target/comparator optional - - Use the existing stage names already reserved in R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R:3: - - keeper_concept_set_generation - - keeper_case_review - - Update R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R:1 so /ohdsi has proper step labels for these stages. - - 4. Keep /ohdsi safe during Keeper work. - - - /ohdsi during Keeper stages should send only workflow metadata, not patient row contents. - - Good stage context for /ohdsi: - - phenotype name - - role - - cohort id - - concept-set artifact paths - - row count - - current row index - - review status - - Do not embed Keeper row payloads into workflow_context_dialogue. Row-specific adjudication should go only through phenotype_validation_review. - - 5. Replace generated 04_keeper_review.R in both shells. - - - Remove library(Keeper), DatabaseConnector, and createKeeper(...) from the generated script path in both shells. - - New 04_keeper_review.R should: - - read selected cohort ids from cohort_id_map.json - - read schema/table info from strategus-execution-settings.json - - call ACP wrappers only - - write JSON artifacts under keeper-case-review/ - - optionally write convenience CSV summaries for human scanning - - It should not require databaseId, and it should no longer depend on strategus-db-details.json unless you deliberately keep that for unrelated reasons. - - 6. Define the Keeper artifact layout explicitly. - - - Keep keeper-case-review/, but make it structured: - - keeper-case-review/concept-sets-generated/ - - keeper-case-review/concept-sets-approved/ - - keeper-case-review/rows/ - - keeper-case-review/reviews/ - - Persist shell summary state in a new output artifact such as outputs/keeper_review_state.json, and echo the important paths in outputs/study_agent_state.json. - - In cohort methods, keep these separate from the existing concept-sets/ directory, which is already being used for negative-control/covariate placeholder material. - - 7. Land this in low-risk slices. - - - Slice 1: - - ACP wrappers - - shared Keeper helper - - direct demo scripts for the three flows - - Slice 2: - - replace generated 04_keeper_review.R in both shells - - add artifact/state persistence - - Slice 3: - - inline interactive Keeper phase in both shells - - /ohdsi Keeper-stage wiring - - resume/cache behavior - - Testing Plan - - 1. R wrapper tests. - - - Add source-level tests for new wrappers in R/slashOhdsiAcpClient/R/flows.R. - - Verify request-field coverage for all three Keeper flows. - - 2. Generated-script regression tests. - - - Extend tests/test_cohort_methods_generated_scripts.py:1. - - Add an incidence counterpart if needed. - - Assert: - - no library(Keeper) - - no createKeeper( - - no DatabaseConnector - - presence of ACP wrapper calls and JSON artifact writes - - 3. Shell workflow/state tests. - - - Add shell regression tests for: - - Keeper stage insertion - - study_agent_state.json Keeper fields - - /ohdsi stage mapping during Keeper concept-set generation and case review - - resume using approved concept sets / saved rows - - 4. ACP-side contract tests. - - - Reuse existing ACP flow coverage in tests/test_acp_server.py:430. - - Add only what is missing on the R integration boundary; the server flows themselves already have basic coverage. - - 5. Manual demos. - - - Add: - - scripts/demo_keeper_concept_sets_generate.R - - scripts/demo_keeper_profiles_generate.R - - scripts/demo_keeper_review_row.R - - optional end-to-end scripts/demo_keeper_review_pipeline.R - - Reuse scripts/test_phenotype_validation_review.R:1 as the seed for the row-review demo. - - 6. Live manual checklist. - - - Run 03_generate_cohorts.R. - - Run new 04_keeper_review.R. - - Confirm: - - concept-set generation artifacts exist - - approved concept sets are consumed - - row files are generated - - individual row reviews save correctly - - /ohdsi works in Keeper stages - - ACP failure modes are readable: - - omop_db_engine_unconfigured - - phi_detected - - zero rows returned - - row index out of range - - Recommended Decisions Before Coding - - - Default Keeper review scope: outcomes only by default, target/comparator optional. - - Enforce remove_pii = TRUE for shell-driven review paths. - - Keep 04_keeper_review.R as one script to avoid renumbering downstream docs/scripts. - - Treat the vendored Keeper/ folder as reference-only, not runtime. - - Main Risk - - The only real architectural trap is letting generic /ohdsi dialogue see row contents. Avoid that, and the migration is mostly plumbing plus workflow/state work rather than a hard - redesign. - diff --git a/docs/KEEPER_INTERFACE_SPEC.md b/docs/KEEPER_INTERFACE_SPEC.md index b51ed2e..70a706e 100644 --- a/docs/KEEPER_INTERFACE_SPEC.md +++ b/docs/KEEPER_INTERFACE_SPEC.md @@ -1,6 +1,6 @@ # Keeper Interface Spec -This document captures the first concrete interface layer for the Keeper expansion described in [KEEPER-EXPANSION-PLAN.md](/ai-agent/HadesProject/OHDSI-Study-Agent/KEEPER-EXPANSION-PLAN.md). +This document captures the current concrete interface layer for the ACP-based Keeper workflow used by the OHDSI Study Agent. ## Flows diff --git a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md index 8649457..6dd6cdc 100644 --- a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md +++ b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md @@ -14,9 +14,9 @@ The original combined R package mixed three responsibilities: That coupling is visible in the split code that replaced it: -- [`R/slashOhdsiAcpClient/R/client.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/client.R) owns connection state and raw POST behavior -- [`R/slashOhdsiAcpClient/R/compatibility_api.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/compatibility_api.R), [`R/slashOhdsiAcpClient/R/lint_and_concept_sets.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/lint_and_concept_sets.R), and [`R/slashOhdsiAcpClient/R/actions_and_lint.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiAcpClient/R/actions_and_lint.R) now own the thin flow/action wrappers -- [`R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R) and [`R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R`](/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R) own interactive workflow state, checkpoints, artifact layout, and script generation +- [`R/slashOhdsiAcpClient/R/client.R`](../R/slashOhdsiAcpClient/R/client.R) owns connection state and raw POST behavior +- [`R/slashOhdsiAcpClient/R/compatibility_api.R`](../R/slashOhdsiAcpClient/R/compatibility_api.R), [`R/slashOhdsiAcpClient/R/lint_and_concept_sets.R`](../R/slashOhdsiAcpClient/R/lint_and_concept_sets.R), and [`R/slashOhdsiAcpClient/R/actions_and_lint.R`](../R/slashOhdsiAcpClient/R/actions_and_lint.R) now own the thin flow/action wrappers +- [`R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R) and [`R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R) own interactive workflow state, checkpoints, artifact layout, and script generation The package split isolated those concerns before more `/ohdsi` dialogue work, incidence-shell extension, and concept-set generation integration are added. diff --git a/docs/STRATEGUS_COHORT_METHODS_SHELL.md b/docs/STRATEGUS_COHORT_METHODS_SHELL.md index 9891812..8d7bbb2 100644 --- a/docs/STRATEGUS_COHORT_METHODS_SHELL.md +++ b/docs/STRATEGUS_COHORT_METHODS_SHELL.md @@ -6,6 +6,7 @@ Current stage scope: - The shell can derive target/comparator/outcome statements from a study intent. - The shell can configure one effective analytic-settings profile through `step_by_step` prompts or `free_text` ACP recommendation. - The shell writes reproducible R scripts, a Strategus analysis specification, and a merged CohortMethod execution script. +- The Keeper review step is now ACP-based and does not call the legacy Keeper R package. This shell is provided as `slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell()`. @@ -30,15 +31,15 @@ Workflow diagrams live in `docs/COHORT_METHODS_WORKFLOW.md`. identifier fields. 4. Optional cohort ID remap step to avoid collisions (`remapCohortIds`). 5. Copy cohort JSON definitions from `indexDir/definitions` into selected cohort folders. -6. Optional negative control and covariate concept-set IDs are captured as placeholders. +6. Optional negative control and covariate concept-set IDs are still captured as placeholders. 7. Configure one analytic-settings profile through `step_by_step`, `free_text`, or cached/function-argument inputs. Analytic settings are always collected in this stage and confirmed before finalization. -8. Generate scripts in `scripts/` for cohort generation, keeper review, diagnostics, and - CohortMethod spec/execution. +8. Optionally run ACP-based Keeper review inline with reuse/resume controls. +9. Generate scripts in `scripts/` for cohort generation, Keeper review, diagnostics, and CohortMethod spec/execution. ## Analytic Settings -The cohort methods shell now resolves a single effective analytic-settings profile. This remains +The cohort methods shell resolves a single effective analytic-settings profile. This remains prompt/cache/free-text-driven only; there is no public function argument that accepts a complete analytic-settings object in this stage. @@ -62,16 +63,10 @@ For traceability: - `outputs/manual_inputs.json` stores the effective `analytic_settings` block plus the `customized_sections` array, the selected analytic-settings mode, and any free-text metadata. -- `outputs/cm_analytic_settings_recommendation.json` is written only for `free_text` mode in the - current stage. It stores the shell-facing recommendation derived from the ACP response or, if ACP - is unavailable, from the local fallback. -- `outputs/cm_acp_specifications_recommendation.json` is written for `free_text` mode and stores - the ACP flow request/response wrapper used to derive the shell-facing recommendation. -- `outputs/study_agent_state.json` echoes `analytic_settings_profile_name` and - `analytic_settings_customized_sections`, plus analytic-settings mode / confirmation summary. -- `analysis-settings/cmAnalysis.json` stores the template-shaped CohortMethod-oriented contract - artifact. The generated `06_cm_spec.R` currently still reads `outputs/cm_analysis_defaults.json` - as its execution settings source. +- `outputs/cm_analytic_settings_recommendation.json` is written only for `free_text` mode. It stores the shell-facing recommendation derived from the ACP response or, if ACP is unavailable, from the local fallback. +- `outputs/cm_acp_specifications_recommendation.json` is written for `free_text` mode and stores the ACP flow request/response wrapper used to derive the shell-facing recommendation. +- `outputs/study_agent_state.json` echoes analytic-settings summary information plus the Keeper control settings used during the shell session. +- `analysis-settings/cmAnalysis.json` stores the template-shaped CohortMethod-oriented contract artifact. ## Output Layout @@ -116,46 +111,9 @@ The following directories are created under `outputDir`: - `improvements_status.json` - `cm_evaluation_todo.json` - `cm_analysis_state.json` (written by `scripts/06_cm_spec.R`) +- `keeper_review_state.json` (written by inline or standalone ACP Keeper review) - `study_agent_state.json` -`cm_analysis_defaults.json` stores the effective analytic-settings profile used by the generated -`06_cm_spec.R` when projecting shell settings into Strategus module specifications. - -`manual_inputs.json` is the cache/resume-friendly shell artifact for the same run. It includes the -effective `analytic_settings` object plus `customized_sections`. - -`06_cm_spec.R` reads the expanded analytic-settings schema and uses it directly when constructing -the CohortMethod module settings: - -- `getDbCohortMethodDataArgs` -- `createStudyPopulationArgs` -- `createPsArgs` -- `trimByPsArgs` -- `matchOnPsArgs` -- `stratifyByPsArgs` -- `fitOutcomeModelArgs` - -It also writes `analysis-settings/analysisSpecification.json`, a Strategus specification containing: - -- a shared cohort-definition resource -- `CharacterizationModule` -- `CohortIncidenceModule` -- `CohortMethodModule` - -The generated script uses `CohortGeneratorModule$new()` only to create the shared cohort-definition -resource. The generated Strategus specification intentionally does not add a cohort-generation module -specification or a `CohortDiagnosticsModule` specification because cohort generation and diagnostics -are handled by `03_generate_cohorts.R` and `05_diagnostics.R`. - -`scripts/06_cm_spec.R` also writes: - -- `outputs/cm_analysis_state.json` -- `analysis-settings/strategus_execute_result.rds` - -The same `06_cm_spec.R` script then executes the just-created specification with -`Strategus::execute()`. There is no separate `07_cm_run_analyses.R` in the merged Strategus -CohortMethod flow. - ## Generated Scripts - `scripts/02_apply_improvements.R` @@ -170,16 +128,16 @@ Generated scripts that connect to the database expect these site-specific files - `strategus-db-details.json` - `strategus-execution-settings.json` -The scripts still contain placeholders for values that are not captured in those files yet, such as -`databaseId` for Keeper/export steps. +Current Keeper specifics: + +- `scripts/04_keeper_review.R` uses `runKeeperReviewWorkflow(...)` and ACP flows instead of the legacy Keeper R package. +- The script records state in `outputs/keeper_review_state.json`. +- The default generated script exposes `ACP_TIMEOUT`, concept-set reuse/overwrite, row reuse/resume, and explicit row selection controls such as `1-3,5`. +- Manual editing of `keeper-case-review/concept-sets-approved/*.json` is consumable, but the concept-set approve/edit/rerun UX is still incomplete. ## Current Boundaries -- `phenotype_improvements` is wired for target, comparator, and outcome cohorts. The shell writes - role-specific improvement artifacts after prompting whether to run improvements for each role, - can apply mutating actions (`set`, `replace`, `update`), keeps advisory `note` actions as - recommendations, and keeps `patched-cohorts/` complete for downstream scripts when any mutating - improvement is applied. +- `phenotype_improvements` is wired for target, comparator, and outcome cohorts. The shell writes role-specific improvement artifacts after prompting whether to run improvements for each role, can apply mutating actions (`set`, `replace`, `update`), keeps advisory `note` actions as recommendations, and keeps `patched-cohorts/` complete for downstream scripts when any mutating improvement is applied. - Remaining deferred integration points: - comparator reuse lookup - phenotype index search for suggestion workflows @@ -188,71 +146,11 @@ The scripts still contain placeholders for values that are not captured in those - positive control synthesis - empirical calibration configuration - detailed covariate feature-group selection beyond the current default-plus-include/exclude model -- TODO: implement ACP/MCP support for negative control and covariate concept-set workflows, then - update the shell to use those tools instead of writing dummy placeholder concept-set artifacts. -- Covariate concept-set include/exclude is not fully implemented yet. Because the generated - CohortMethod scripts cannot currently materialize exclude covariate concepts, high-correlation - covariates may remain in the model and cause `06_cm_spec.R` to fail when - `errorOnHighCorrelation` is enabled. -- Atlas / CohortMethod settings partially supported but still needing broader validation: - - `minDaysAtRisk` - - PS trimming (`none`, percent trimming, and equipoise bounds) - - `inversePtWeighting` passed through to `fitOutcomeModelArgs` -- Evaluation settings from section 12.7.3 remain deferred as well. -- Multiple analytic-settings profiles, multi-comparison support, and broader CohortMethod branching - remain for a later stage. -- Script TODO comments document where these extensions are expected. +- TODO: implement ACP/MCP support for negative control and covariate concept-set workflows, then update the shell to use those tools instead of writing dummy placeholder concept-set artifacts. +- Covariate concept-set include/exclude is not fully implemented yet. Because the generated CohortMethod scripts cannot currently materialize exclude covariate concepts, high-correlation covariates may remain in the model and cause `06_cm_spec.R` to fail when `errorOnHighCorrelation` is enabled. +- Analytic-settings recommendations are mapped into shell settings before script generation, but there is not yet a dedicated validation layer for ACP recommendation payloads. +- Multiple analytic-settings profiles and multi-comparison support remain for a later stage. ## Notes -- This stage is designed as a bridge: it combines ACP/MCP-assisted intent split, phenotype - recommendation/improvement, and analytic-settings recommendation with reproducible Strategus - script generation. - -## Analytic Settings Prompt Details - -Current `step_by_step` section flow: - -- `study_population` -- `time_at_risk` -- `propensity_score_adjustment` -- `outcome_model` - -User-facing `step_by_step` prompts follow the ATLAS section grouping: - -- ask only the section's core settings directly -- then offer a keep-defaults step for the remaining hidden/default settings -- if the user declines defaults, ask each remaining exposed setting one by one -- show short setting names only in default summaries and final summaries -- show detailed per-setting descriptions only in the one-by-one customization path - -Exception for propensity score adjustment: - -- first ask the strategy: `match_on_ps`, `stratify_by_ps`, or `none` -- if `match_on_ps`, ask only `maxRatio` -- if `stratify_by_ps`, ask only `numberOfStrata` -- after that, show the remaining PS defaults and ask whether to keep them -- if the user declines defaults, ask the exposed remaining PS settings one by one -- exposed PS trimming settings in that remaining-defaults path: `trimmingStrategy` with `none`, `by_percent`, or `by_equipoise` -- if trimming is customized to `by_percent`, the shell asks for the trimming percent -- if trimming is customized to `by_equipoise`, the shell asks for the lower and upper equipoise bounds -- match defaults currently exposed in that remaining-defaults path: `maxCohortSizeForFitting`, `errorOnHighCorrelation`, `useRegularization`, `caliper`, `caliperScale` -- stratify defaults currently exposed in that remaining-defaults path: `maxCohortSizeForFitting`, `errorOnHighCorrelation`, `useRegularization`, `baseSelection` -- hidden internal defaults such as `create_ps.estimator` still remain persisted but are not directly prompted - -Current execution defaults and persisted artifacts use these effective analytic-settings fields: - -- `profile_name` -- Study population: `studyStartDate`, `studyEndDate`, `maxCohortSize`, `firstExposureOnly`, `washoutPeriod`, `restrictToCommonPeriod`, `removeDuplicateSubjects`, `censorAtNewRiskWindow`, `removeSubjectsWithPriorOutcome`, `priorOutcomeLookback` -- Covariate settings: current default covariate behavior, include-all state, include concept-set selection, exclude concept-set selection -- Time-at-risk: `minDaysAtRisk`, `riskWindowStart`, `startAnchor`, `riskWindowEnd`, `endAnchor` -- Propensity score adjustment: `strategy`, `trimmingStrategy`, `trimmingPercent`, `equipoiseLowerBound`, `equipoiseUpperBound`, `estimator`, `maxCohortSizeForFitting`, `errorOnHighCorrelation`, `useRegularization`, matching `caliper`, matching `caliperScale`, matching `maxRatio`, stratification `numberOfStrata`, stratification `baseSelection` -- Outcome model: `modelType`, `stratified`, `useCovariates`, `inversePtWeighting`, `useRegularization` - -Important current default behavior: - -- Matching defaults follow CohortMethod defaults, including `maxRatio = 1`, `caliper = 0.2`, and `caliperScale = "standardized logit"`. -- PS fitting defaults exposed in the shell include `maxCohortSizeForFitting = 250000`, `errorOnHighCorrelation = FALSE`, and `useRegularization = TRUE`. -- PS trimming defaults exposed in the shell include `trimmingStrategy = none`, `trimmingPercent = 5`, and equipoise bounds `c(0.25, 0.75)`. -- Time-at-risk defaults follow CohortMethod defaults, including `riskWindowStart = 0` and `censorAtNewRiskWindow = FALSE`. -- Outcome-model defaults are partially dynamic: `stratified = FALSE` for no PS adjustment or one-to-one matching, `stratified = TRUE` for variable-ratio matching and PS stratification, `useCovariates = FALSE`, `inversePtWeighting = FALSE`, and `useRegularization = TRUE`. +- This stage is designed as a bridge: it combines ACP/MCP-assisted intent split, phenotype recommendation/improvement, analytic-settings recommendation, and ACP-based Keeper review with reproducible Strategus script generation. diff --git a/docs/STRATEGUS_SHELL.md b/docs/STRATEGUS_SHELL.md index fc33c3f..26c87f0 100644 --- a/docs/STRATEGUS_SHELL.md +++ b/docs/STRATEGUS_SHELL.md @@ -2,51 +2,56 @@ This document summarizes the interactive Strategus incidence shell provided by `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()`. The shell is intended for -interactive use in R/RStudio and guides users through phenotype selection and -script generation for a CohortIncidence analysis. +interactive use in R/RStudio and guides users through phenotype selection, +improvement review, time-at-risk configuration, ACP-based Keeper review, and script +generation for a CohortIncidence analysis. ## What the shell does -- Prompts for a study intent (with a default). +- Prompts for a study intent. - Calls `phenotype_intent_split` to derive target and outcome cohort statements. - Calls `phenotype_recommendation` separately for target and outcome cohorts. - Lets the user select accepted target/outcome phenotypes and optionally remap cohort IDs. -- Calls `phenotype_improvements` for each selected cohort (target then outcome) and lets - the user apply improvements immediately. -- Writes reproducible scripts for cohort generation, Keeper review, diagnostics, - and incidence analysis. +- Calls `phenotype_improvements` for each selected cohort and lets the user apply improvements immediately. +- Captures explicit time-at-risk and strata settings for the incidence analysis. +- Optionally runs ACP-based Keeper review inline or writes a standalone Keeper script. +- Writes reproducible scripts for recommendation replay, cohort generation, Keeper review, diagnostics, and incidence analysis. - Saves session state to `outputs/study_agent_state.json` for traceability. ## Output folder layout Default output directory: `demo-strategus-cohort-incidence/` -- `outputs/`: intent split, recommendations, improvements, roles, and session state. +- `outputs/`: intent split, recommendations, improvements, roles, Keeper state, and session state. - `selected-cohorts/`: combined selected cohort JSON + `Cohorts.csv`. - `selected-target-cohorts/`: target cohort JSON. - `selected-outcome-cohorts/`: outcome cohort JSON. - `patched-cohorts/`: combined improved cohort JSON (if applied). - `patched-target-cohorts/`: improved target cohort JSON (if applied). - `patched-outcome-cohorts/`: improved outcome cohort JSON (if applied). -- `keeper-case-review/`: Keeper outputs and review artifacts. -- `analysis-settings/`: analysis specification JSON. -- `scripts/`: generated R scripts (01–06). +- `keeper-case-review/`: ACP Keeper artifacts, generated/approved concept sets, sampled rows, and review outputs. +- `analysis-settings/`: analysis artifacts including `time_at_risk_settings.json` and the generated Strategus specification. +- `scripts/`: generated R scripts (`01` through `06`). ## Generated scripts The shell writes scripts under `scripts/` for reproducibility: -1. `03_generate_cohorts.R` -2. `04_keeper_review.R` -3. `05_diagnostics.R` -4. `06_incidence_spec.R` +1. `01_recommend_and_select.R` +2. `02_apply_improvements.R` +3. `03_generate_cohorts.R` +4. `04_keeper_review.R` +5. `05_diagnostics.R` +6. `06_incidence_spec.R` -Scripts include database connection initialization using -`strategus-db-details.json` in the working directory. +Current runtime expectations: + +- `03_generate_cohorts.R`, `05_diagnostics.R`, and `06_incidence_spec.R` expect site-specific Strategus connection/execution settings files under `outputDir`. +- `04_keeper_review.R` uses the ACP-based Keeper workflow helper and writes `outputs/keeper_review_state.json`. +- `06_incidence_spec.R` reads `analysis-settings/time_at_risk_settings.json` instead of hard-coding TAR definitions. ## Notes -- If improvements were applied during the shell session, the scripts are marked - as a portable record (no need to re-apply). -- If the initial recommendations are not acceptable, the shell can request a - second window of candidates and then fall back to advisory guidance. +- If improvements were applied during the shell session, the scripts are a portable record and do not need to re-apply the same changes. +- The shell exposes a `/ohdsi` dialogue step for `time_at_risk_configuration`, so users can ask denominator-design questions while configuring TAR and strata settings. +- If the initial phenotype recommendations are not acceptable, the shell can request a second window of candidates and then fall back to advisory guidance. diff --git a/scripts/demo_setup.R b/scripts/demo_setup.R index 3b50ef0..10c4d6e 100644 --- a/scripts/demo_setup.R +++ b/scripts/demo_setup.R @@ -71,3 +71,16 @@ connect_study_agent_acp <- function(acp_url = Sys.getenv("ACP_URL", "http://127. slashOhdsiAcpClient::acp_connect(acp_url) slashOhdsiAcpClient::acp_get_default_client() } + +reset_demo_output_dir <- function(path, prompt = interactive(), default = FALSE) { + path <- normalizePath(path, winslash = "/", mustWork = FALSE) + if (!dir.exists(path)) return(invisible(FALSE)) + if (isTRUE(prompt)) { + suffix <- if (isTRUE(default)) "[Y/n]" else "[y/N]" + answer <- tolower(trimws(readline(sprintf("Delete existing output directory '%s'? %s ", path, suffix)))) + confirmed <- if (!nzchar(answer)) isTRUE(default) else answer %in% c("y", "yes") + if (!confirmed) return(invisible(FALSE)) + } + unlink(path, recursive = TRUE, force = TRUE) + invisible(TRUE) +} diff --git a/scripts/demo_strategus_cohort_method.R b/scripts/demo_strategus_cohort_method.R index 8209fdd..805a7bd 100644 --- a/scripts/demo_strategus_cohort_method.R +++ b/scripts/demo_strategus_cohort_method.R @@ -19,9 +19,8 @@ Sys.setenv(ACP_TIMEOUT = "180") Sys.setenv(PHENOTYPE_INDEX_DIR = repo_file("data", "phenotype_index_cipher_omop")) invisible(connect_study_agent_acp()) -### CLEAN UP FROM LAST RUN? -# Uncomment to reset the state of the output folder. -#unlink(repo_file("demo-strategus-cohort-method"), recursive = TRUE, force = TRUE) +### Optional reset from a prior run. +# reset_demo_output_dir(repo_file("demo-strategus-cohort-method"), prompt = TRUE) # # If you already ran `scripts/test_strategus_incidence_plus_keeper.R`, this shell can # reuse cached target and outcome artifacts from `demo-strategus-cohort-incidence`. diff --git a/scripts/test_strategus_incidence_plus_keeper.R b/scripts/test_strategus_incidence_plus_keeper.R index 4bfbb66..36ddb0e 100644 --- a/scripts/test_strategus_incidence_plus_keeper.R +++ b/scripts/test_strategus_incidence_plus_keeper.R @@ -1,23 +1,22 @@ -### Demo: `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` +### Demo / test: `slashOhdsiStrategusAssistant::runStrategusIncidenceShell()` ## Run this from the repo root with ACP listening on `http://127.0.0.1:8765`. ## `scripts/demo_ohdsi_dialogue.R` is the quickest non-interactive `/ohdsi` smoke test. ## -## Useful `/ohdsi` prompts to try once the shell reaches phenotype recommendation steps: -## /ohdsi what should I do if none of the candidate cohorts are relevant? +## Useful `/ohdsi` prompts to try once the shell reaches phenotype recommendation and TAR steps: +## /ohdsi what should I do if none of the candidate cohorts are relevant? ## /ohdsi what happens if I accept the phenotype improvement recommendations? -## /ohdsi how should I specify TAR so that denominators are coherent across strata? +## /ohdsi how should I specify TAR so that denominators are coherent across strata? -### CLEAN UP FROM LAST RUN? -# Uncomment to reset the state of the output folder. -unlink("OHDSI-Study-Agent/demo-strategus-cohort-incidence", recursive = TRUE, force = TRUE) - -script_dir = "OHDSI-Study-Agent/scripts/" +script_dir = "OHDSI-Study-Agent/scripts/" source(file.path(script_dir, "demo_setup.R")) repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) load_study_agent_r_packages(include_strategus = TRUE) +## Optional reset from a prior run. +# reset_demo_output_dir(repo_file("demo-strategus-cohort-incidence"), prompt = TRUE) + Sys.setenv(ACP_TIMEOUT = "280") invisible(connect_study_agent_acp()) @@ -30,27 +29,24 @@ slashOhdsiStrategusAssistant::runStrategusIncidenceShell( studyIntent = "What is the risk of GI bleed in new users of tofacitinib compared to new users of ruxolitinib?" ) - ############ ## Use this to resume from cached artifacts and regenerate output scripts. -slashOhdsiStrategusAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - acpUrl = "http://127.0.0.1:8765", - studyAgentBaseDir = repo_root, - resume = TRUE, - allowCache = TRUE, - promptOnCache = FALSE, - interactive = FALSE, - indexDir = "data/phenotype_index_cipher_omop" -) - - +# slashOhdsiStrategusAssistant::runStrategusIncidenceShell( +# outputDir = "demo-strategus-cohort-incidence", +# acpUrl = "http://127.0.0.1:8765", +# studyAgentBaseDir = repo_root, +# resume = TRUE, +# allowCache = TRUE, +# promptOnCache = FALSE, +# interactive = FALSE, +# indexDir = "data/phenotype_index_cipher_omop" +# ) ## (NO RELEVANT PHENOTYPE TEST) First enter this study intent, which should not return strong phenotype matches: ## "What is the risk of GI bleed in new users of Celecoxib compared to new users of Diclofenac?" -slashOhdsiStrategusAssistant::runStrategusIncidenceShell( - outputDir = "demo-strategus-cohort-incidence", - acpUrl = "http://127.0.0.1:8765", - studyAgentBaseDir = repo_root, - indexDir = "data/phenotype_index_cipher_omop" -) +# slashOhdsiStrategusAssistant::runStrategusIncidenceShell( +# outputDir = "demo-strategus-cohort-incidence", +# acpUrl = "http://127.0.0.1:8765", +# studyAgentBaseDir = repo_root, +# indexDir = "data/phenotype_index_cipher_omop" +# ) diff --git a/tests/_repo_paths.py b/tests/_repo_paths.py new file mode 100644 index 0000000..153e8dd --- /dev/null +++ b/tests/_repo_paths.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def repo_root() -> Path: + env_root = os.environ.get("STUDY_AGENT_REPO_ROOT", "").strip() + if env_root: + return Path(env_root).expanduser().resolve() + return Path(__file__).resolve().parents[1] + + +def repo_path(*parts: str) -> Path: + return repo_root().joinpath(*parts) diff --git a/tests/test_cohort_methods_generated_scripts.py b/tests/test_cohort_methods_generated_scripts.py index 727b729..a7c58c2 100644 --- a/tests/test_cohort_methods_generated_scripts.py +++ b/tests/test_cohort_methods_generated_scripts.py @@ -4,10 +4,11 @@ import pytest +from _repo_paths import repo_path -SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") -EXECUTION_SETTINGS_SOURCE = Path("R/slashOhdsiStrategusAssistant/R/execution_settings.R") +SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_cohort_methods_shell.R") +EXECUTION_SETTINGS_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "execution_settings.R") def _generated_script_block(source: str, script_name: str, filename: str) -> str: start = source.index(f"{script_name} <- c(") @@ -260,4 +261,4 @@ def test_cohort_method_spec_accepts_generated_argument_shape() -> None: stopifnot(length(spec) > 0) """ ) - assert result.returncode == 0, result.stderr + assert result.returncode == 0, result.stderr \ No newline at end of file diff --git a/tests/test_cohort_methods_shell_recommendation_support.py b/tests/test_cohort_methods_shell_recommendation_support.py index 5223a0b..269bc89 100644 --- a/tests/test_cohort_methods_shell_recommendation_support.py +++ b/tests/test_cohort_methods_shell_recommendation_support.py @@ -1,7 +1,8 @@ from pathlib import Path -SOURCE = Path("R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") +from _repo_paths import repo_path +SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_cohort_methods_shell.R") def test_shell_supports_namespaced_recommendation_ids_and_blocks_unsupported_selection() -> None: source = SOURCE.read_text(encoding="utf-8") @@ -38,4 +39,4 @@ def test_shell_normalizes_namespaced_cached_and_manual_cohort_ids() -> None: assert 'parse_single_cohort_id(item$original_id %||% NA_integer_)' in source assert 'parse_single_cohort_id(item$cohort_id %||% NA_integer_)' in source assert 'original_ids <- parse_ids(unlist(mapping$original_id %||% integer(0), use.names = FALSE))' in source - assert 'cohort_ids <- parse_ids(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE))' in source + assert 'cohort_ids <- parse_ids(unlist(mapping$cohort_id %||% integer(0), use.names = FALSE))' in source \ No newline at end of file diff --git a/tests/test_incidence_shell_selection_state.py b/tests/test_incidence_shell_selection_state.py index d533588..cb1a9f4 100644 --- a/tests/test_incidence_shell_selection_state.py +++ b/tests/test_incidence_shell_selection_state.py @@ -1,8 +1,9 @@ from pathlib import Path +from _repo_paths import repo_path -SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") +SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_incidence_shell.R") def test_outcome_selection_state_is_initialized_before_target_mapping_prompt() -> None: source = SOURCE.read_text(encoding="utf-8") @@ -62,4 +63,3 @@ def test_generated_incidence_script_uses_persisted_time_at_risk_settings() -> No assert "CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)" not in block assert 'tars = c(1, 2)' not in block assert 'createStrataSettings(byYear = TRUE, byGender = TRUE)' not in block - diff --git a/tests/test_keeper_dialogue_integration_static.py b/tests/test_keeper_dialogue_integration_static.py index 0c862df..04239cf 100644 --- a/tests/test_keeper_dialogue_integration_static.py +++ b/tests/test_keeper_dialogue_integration_static.py @@ -1,11 +1,12 @@ from pathlib import Path +from _repo_paths import repo_path -MAPPING_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R") -HELPER_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R") -COHORT_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") -INCIDENCE_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") +MAPPING_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "workflow_dialogue_mapping.R") +HELPER_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "keeper_review_workflow.R") +COHORT_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_cohort_methods_shell.R") +INCIDENCE_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_incidence_shell.R") def test_keeper_stage_labels_exist_for_both_shells() -> None: source = MAPPING_SOURCE.read_text(encoding="utf-8") @@ -72,4 +73,4 @@ def test_incidence_shell_offers_inline_keeper_phase() -> None: _assert_shell_keeper_controls(source) assert 'intent_path = intent_split_path' in source assert 'stage_callback = stage_callback' in source - assert 'set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path))' in source + assert 'set_dialogue_context("workflow_summary", context = list(study_intent = studyIntent, keeper_review_state_path = keeper_review_state_path))' in source \ No newline at end of file diff --git a/tests/test_keeper_generated_scripts.py b/tests/test_keeper_generated_scripts.py index b8432e1..fa4709e 100644 --- a/tests/test_keeper_generated_scripts.py +++ b/tests/test_keeper_generated_scripts.py @@ -1,9 +1,10 @@ from pathlib import Path +from _repo_paths import repo_path -COHORT_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R") -INCIDENCE_SOURCE = Path("/ai-agent/HadesProject/OHDSI-Study-Agent/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R") +COHORT_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_cohort_methods_shell.R") +INCIDENCE_SOURCE = repo_path("R", "slashOhdsiStrategusAssistant", "R", "strategus_incidence_shell.R") def _generated_script_block(source: str, script_name: str, filename: str) -> str: start = source.index(f"{script_name} <- c(") @@ -42,4 +43,4 @@ def test_cohort_method_generated_keeper_script_uses_acp_helper_only() -> None: def test_incidence_generated_keeper_script_uses_acp_helper_only() -> None: block = _generated_script_block(INCIDENCE_SOURCE.read_text(encoding="utf-8"), "script_04", "04_keeper_review.R") - _assert_keeper_script_contract(block, "intent_split.json") + _assert_keeper_script_contract(block, "intent_split.json") \ No newline at end of file diff --git a/tests/test_r_workflow_context_dialogue_wrapper.py b/tests/test_r_workflow_context_dialogue_wrapper.py index 7df1662..e9f14de 100644 --- a/tests/test_r_workflow_context_dialogue_wrapper.py +++ b/tests/test_r_workflow_context_dialogue_wrapper.py @@ -1,9 +1,10 @@ from pathlib import Path +from _repo_paths import repo_path -FLOWS_SOURCE = Path("R/slashOhdsiAcpClient/R/flows.R") -DEMO_SOURCE = Path("scripts/demo_ohdsi_dialogue.R") +FLOWS_SOURCE = repo_path("R", "slashOhdsiAcpClient", "R", "flows.R") +DEMO_SOURCE = repo_path("scripts", "demo_ohdsi_dialogue.R") def test_r_workflow_context_dialogue_wrapper_flattens_stage_context() -> None: source = FLOWS_SOURCE.read_text(encoding="utf-8") @@ -25,4 +26,4 @@ def test_ohdsi_demo_script_exercises_shell_equivalent_handler() -> None: assert 'slashOhdsiAcpClient::acp_workflow_context_dialogue(' in source assert 'handled <- dialogue$handle_command(paste("/ohdsi", question))' in source assert 'workflow = "incidence"' in source - assert 'workflow = "cohort_methods"' in source + assert 'workflow = "cohort_methods"' in source \ No newline at end of file From 4fe14ae4f73dda821f595bcc07c992a54f5e2b5f Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 04:37:01 -0500 Subject: [PATCH 14/20] cleanup of project dependencies in environment.yml and pyproject.toml files --- environment.yml | 3 +-- pyproject.toml | 14 +++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/environment.yml b/environment.yml index 86e2266..8466227 100644 --- a/environment.yml +++ b/environment.yml @@ -17,5 +17,4 @@ dependencies: - sqlalchemy - ruff - requests - - ruff - - sqlalchemy + diff --git a/pyproject.toml b/pyproject.toml index c08db21..8694ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,22 @@ build-backend = "setuptools.build_meta" [project] name = "study-agent" -version = "0.0.0" +version = "0.1.0" description = "OHDSI Study Agent" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ + "anyio>=4.12", + "faiss-cpu>=1.13", + "flask>=3.1" "mcp>=1.0.0", + "numpy>=2.4", + "omop-alchemy>=0.6", "pydantic>=2.0.0", - "PyYAML>=6.0", + "PyYAML>=6.0", + "requests>=2.33", + "sqlalchemy>=2.0", + ] [project.optional-dependencies] From 3a4a50bed1a878994022e71467dc301fe581a115 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 05:12:52 -0500 Subject: [PATCH 15/20] updated .env.example and improved coherence across dependency tracking files --- .env.example | 79 +++++++++++++++++++++++++++++++++++++++++++------- Dockerfile | 1 + README.md | 6 ++-- pyproject.toml | 2 +- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 07e2ad0..bf7f14e 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,72 @@ -# EMBED_URL= -# EMBED_MODEL= +LLM_LOG=1 +LLM_MODEL= +LLM_LOG_RESPONSE=1 +LLM_USE_RESPONSES=0 -LLM_API_URL= -LLM_API_KEY= +LLM_API_KEY= +LLM_API_URL=/v1/chat/completions LLM_MODEL= -# LLM_TIMEOUT=240 -# LLM_CANDIDATE_LIMIT=10 -# LLM_LOG=1 -# LLM_DRY_RUN=0 -# LLM_USE_RESPONSES=0 -# MCP_LOG_LEVEL=INFO +EMBED_API_KEY= +EMBED_MODEL= +EMBED_URL=/ollama/api/embed +EMBED_TIMEOUT=60 + +MCP_TRANSPORT=http +MCP_HOST=127.0.0.1 +MCP_PORT=8790 +MCP_PATH=/mcp + +PHENOTYPE_INDEX_DIR=$PWD/data/phenotype_index + +STUDY_AGENT_LOG_DIR=$PWD/study-agent-logs +ACP_LOG_LEVEL=DEBUG +MCP_LOG_LEVEL=DEBUG + +STUDY_AGENT_HOST=127.0.0.1 +STUDY_AGENT_PORT=8765 +STUDY_AGENT_ALLOW_CORE_FALLBACK=0 +STUDY_AGENT_DEBUG=1 +STUDY_AGENT_MCP_ONESHOT=1 +STUDY_AGENT_MCP_URL="http://127.0.0.1:8790/mcp" +STUDY_AGENT_MCP_CWD=$PWD + + +ACP_TIMEOUT=180 +LLM_TIMEOUT=120 +STUDY_AGENT_MCP_TIMEOUT=80 +EMBED_TIMEOUT=60 + +STUDY_AGENT_REWRITE_CONTAINER_HOSTS=0 + +#VOCAB_SEARCH_PROVIDER=generic_search_api +#VOCAB_SEARCH_URL="http://127.0.0.1:30080/search +#VOCAB_SEARCH_QUERY_PREFIX="Instruction: retrieve the concepts most related to the query. Query: " +#PHOEBE_PROVIDER= +#PHOEBE_URL_TEMPLATE= + +PHOEBE_RELATIONSHIP_IDS="Lexical via source,Patient context" +PHOEBE_MAX_CONCEPTS_PER_RELATIONSHIP=100 +PHOEBE_MAX_CONCEPTS=300 + +VOCAB_SEARCH_PROVIDER=hecate_api +VOCAB_SEARCH_URL="https://hecate.pantheon-hds.com/api/search_standard" + +# If you want to use hecate for phoebe +PHOEBE_PROVIDER=hecate_api +PHOEBE_URL_TEMPLATE="https://hecate.pantheon-hds.com/api/concepts/{concept_id}/phoebe" + +# if you have concept_recommended -- see https://forums.ohdsi.org/t/phoebe-2-0/17410 +#PHOEBE_PROVIDER=db +#PHOEBE_DB_TABLE=concept_recommended +#OMOP_DB_ENGINE='postgresql://:@:/' # URL encoded pword +#VOCAB_DATABASE_SCHEMA=vocabulary +#VOCAB_CONCEPT_TABLE=concept + +# case causal review use with PV Copilot (not yet publicly released -- ping Rich Boyce with questions or to trial) +#PV_COPILOT_BASE_URL +#PV_COPILOT_TOKEN +#PV_COPILOT_API_TOKEN +#PV_COPILOT_BEARER_TOKEN +#PV_COPILOT_PORT +#PV_COPILOT_SCHEME=http \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e20ec82..51bbf6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY --chown=$MAMBA_USER:$MAMBA_USER core/ ./core/ COPY --chown=$MAMBA_USER:$MAMBA_USER mcp_server/ ./mcp_server/ COPY --chown=$MAMBA_USER:$MAMBA_USER acp_agent/ ./acp_agent/ COPY --chown=$MAMBA_USER:$MAMBA_USER docs/ ./docs/ +COPY --chown=$MAMBA_USER:$MAMBA_USER README.md ./ COPY --chown=$MAMBA_USER:$MAMBA_USER pyproject.toml ./ RUN micromamba run -n base pip install --no-cache-dir -e . diff --git a/README.md b/README.md index 3ee0789..b6d4a0b 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ uv lock uv run pytest ``` -The repo does not currently require `uv`, and Docker still builds from `environment.yml` plus an editable install. +The repo does not currently require `uv`. Docker builds the runtime in two layers: `environment.yml` provides the Micromamba/Conda base environment, and then `pyproject.toml` is used by `pip install -e .` to install the Python package and console entrypoints inside that environment. ### Start MCP over HTTP @@ -182,7 +182,7 @@ Current indexing workflow: 2. Optionally enable LLM-derived retrieval keywords during that build. 3. Build `dense.index` separately when embedding infrastructure is available, either during the main build with `--build-dense` or later with `--build-dense --dense-only`. -The retrieval layer reads from `PHENOTYPE_INDEX_DIR`, which should point to the built output directory. The source phenotype files do not need to live under that directory. +The retrieval layer reads from `PHENOTYPE_INDEX_DIR`, which should point to the built output directory. The source phenotype files do not need to live under that directory. In the default Docker/Compose setup, the index is expected on the host at `./data/phenotype_index` and is mounted into the container at `/data/phenotype_index`. If you set `PHENOTYPE_INDEX_DIR` in `.env`, make sure the mounted volume path is updated to match; otherwise the container will still only see the default mounted index location. ## Minimal Examples @@ -230,7 +230,7 @@ curl -s -X POST http://127.0.0.1:8765/flows/phenotype_validation_review \ - Installation, smoke tests, and provider-specific examples: [docs/TESTING.md](docs/TESTING.md) - Implemented service inventory: [docs/SERVICE_REGISTRY.yaml](docs/SERVICE_REGISTRY.yaml) -- Docker setup: see `compose.yaml` and `.env.example` +- Docker setup: see `compose.yaml` and `.env.example`. The default containerized phenotype index path is `./data/phenotype_index` on the host, mounted to `/data/phenotype_index` in the container. - ACP and MCP component details: [acp_agent/README.md](acp_agent/README.md), [mcp_server/README.md](mcp_server/README.md) ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 8694ccf..21ecb74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.12" dependencies = [ "anyio>=4.12", "faiss-cpu>=1.13", - "flask>=3.1" + "flask>=3.1", "mcp>=1.0.0", "numpy>=2.4", "omop-alchemy>=0.6", From ba7638ad47c32bc486502b3bc3167d48e461e4a0 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 05:18:12 -0500 Subject: [PATCH 16/20] File renaming to distinguish smoke test ran with `doit` from unit tests ran with `pytest` --- docs/GIT-GUIDE.md | 74 ------------------- dodo.py | 12 +-- ...st.py => smoke_case_causal_review_flow.py} | 0 ...y => smoke_cohort_methods_intent_split.py} | 0 ....py => smoke_cohort_methods_specs_flow.py} | 0 ..._smoke_test.py => smoke_phenotype_flow.py} | 0 ...est.py => smoke_phenotype_intent_split.py} | 0 ... smoke_phenotype_recommendation_advice.py} | 0 8 files changed, 6 insertions(+), 80 deletions(-) delete mode 100644 docs/GIT-GUIDE.md rename tests/{case_causal_review_flow_smoke_test.py => smoke_case_causal_review_flow.py} (100%) rename tests/{cohort_methods_intent_split_smoke_test.py => smoke_cohort_methods_intent_split.py} (100%) rename tests/{cohort_methods_specs_flow_smoke_test.py => smoke_cohort_methods_specs_flow.py} (100%) rename tests/{phenotype_flow_smoke_test.py => smoke_phenotype_flow.py} (100%) rename tests/{phenotype_intent_split_smoke_test.py => smoke_phenotype_intent_split.py} (100%) rename tests/{phenotype_recommendation_advice_smoke_test.py => smoke_phenotype_recommendation_advice.py} (100%) diff --git a/docs/GIT-GUIDE.md b/docs/GIT-GUIDE.md deleted file mode 100644 index 55a96c3..0000000 --- a/docs/GIT-GUIDE.md +++ /dev/null @@ -1,74 +0,0 @@ -# Quick‑start command cheat sheet - -## Set up remotes (run once) -``` -git remote add upstream https://github.com//.git -git remote -v -``` - -## Import public → VA sync branch -``` -git fetch upstream main -git checkout -B sync/public upstream/main -git push origin sync/public -``` - - -## Export curated commits VA → public (direct push) -``` -git checkout -B export/public va/main -``` - -## Cherry-pick only the commits you want public: -``` -git cherry-pick ... -``` - -## Push curated branch to public main via explicit refspec: -``` -git push upstream export/public:main -``` - -## Patch‑based export (air‑gap friendly) -``` -# On VA side: generate patches for commits to export -git format-patch ..export/public -o patches/ -# On a machine with outbound access: apply and push -git am patches/*.patch -git push upstream main -``` - -## Import public → VA sync branch then Export curated commits VA → public -Assumptions -- Default branch name: main -- No Git LFS -- Exports are manual (you’ll choose which commits to publish) -- GitHub Actions are enabled (optional automation not required for these commands) - -### 1) Import public → VA sync branch -``` -# Add public remote (run once) -git remote add upstream https://github.com//.git - -# Fetch latest from GitHub.com public repo -git fetch upstream main - -# Create/refresh the VA mirror branch to exactly match upstream/main -git checkout -B sync/public upstream/main - -# Publish/refresh the sync branch inside VA GitHub Enterprise -git push origin sync/public -``` - -### 2) Export curated commits VA → public -``` -# Start (or refresh) your export branch from internal work -git checkout -B export/public va/main - -# Curate the export: cherry-pick only the commits that are allowed to go public -git cherry-pick ... - -# Push the curated export branch to public main via explicit refspec -# (local export/public → remote main). This sends only what you intend. -git push upstream export/public:main -``` \ No newline at end of file diff --git a/dodo.py b/dodo.py index a082890..007e833 100644 --- a/dodo.py +++ b/dodo.py @@ -340,7 +340,7 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running phenotype flow smoke test...") - subprocess.run(["python", "tests/phenotype_flow_smoke_test.py"], check=True, env=env) + subprocess.run(["python", "tests/smoke_phenotype_flow.py"], check=True, env=env) print(f"ACP logs: {acp_stdout} {acp_stderr}") finally: print("Stopping ACP...") @@ -390,7 +390,7 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running cohort-methods-specs flow smoke test...") - subprocess.run(["python", "tests/cohort_methods_specs_flow_smoke_test.py"], check=True, env=env) + subprocess.run(["python", "tests/smoke_cohort_methods_specs_flow.py"], check=True, env=env) print(f"ACP logs: {acp_stdout} {acp_stderr}") finally: print("Stopping ACP...") @@ -440,7 +440,7 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running phenotype intent split flow smoke test...") - subprocess.run(["python", "tests/phenotype_intent_split_smoke_test.py"], check=True, env=env) + subprocess.run(["python", "tests/smoke_phenotype_intent_split.py"], check=True, env=env) print(f"ACP logs: {acp_stdout} {acp_stderr}") finally: print("Stopping ACP...") @@ -490,7 +490,7 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running cohort methods intent split flow smoke test...") - subprocess.run(["python", "tests/cohort_methods_intent_split_smoke_test.py"], check=True, env=env) + subprocess.run(["python", "tests/smoke_cohort_methods_intent_split.py"], check=True, env=env) print(f"ACP logs: {acp_stdout} {acp_stderr}") finally: print("Stopping ACP...") @@ -603,7 +603,7 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running phenotype recommendation advice flow smoke test...") - subprocess.run(["python", "tests/phenotype_recommendation_advice_smoke_test.py"], check=True, env=env) + subprocess.run(["python", "tests/smoke_phenotype_recommendation_advice.py"], check=True, env=env) finally: print("Stopping ACP...") acp_proc.terminate() @@ -893,7 +893,7 @@ def _run_smoke() -> None: def task_smoke_case_causal_review_flow(): def _run_smoke() -> None: print("Running case causal review flow smoke test...") - subprocess.run(["python", "tests/case_causal_review_flow_smoke_test.py"], check=True) + subprocess.run(["python", "tests/smoke_case_causal_review_flow.py"], check=True) return { "actions": [_run_smoke], diff --git a/tests/case_causal_review_flow_smoke_test.py b/tests/smoke_case_causal_review_flow.py similarity index 100% rename from tests/case_causal_review_flow_smoke_test.py rename to tests/smoke_case_causal_review_flow.py diff --git a/tests/cohort_methods_intent_split_smoke_test.py b/tests/smoke_cohort_methods_intent_split.py similarity index 100% rename from tests/cohort_methods_intent_split_smoke_test.py rename to tests/smoke_cohort_methods_intent_split.py diff --git a/tests/cohort_methods_specs_flow_smoke_test.py b/tests/smoke_cohort_methods_specs_flow.py similarity index 100% rename from tests/cohort_methods_specs_flow_smoke_test.py rename to tests/smoke_cohort_methods_specs_flow.py diff --git a/tests/phenotype_flow_smoke_test.py b/tests/smoke_phenotype_flow.py similarity index 100% rename from tests/phenotype_flow_smoke_test.py rename to tests/smoke_phenotype_flow.py diff --git a/tests/phenotype_intent_split_smoke_test.py b/tests/smoke_phenotype_intent_split.py similarity index 100% rename from tests/phenotype_intent_split_smoke_test.py rename to tests/smoke_phenotype_intent_split.py diff --git a/tests/phenotype_recommendation_advice_smoke_test.py b/tests/smoke_phenotype_recommendation_advice.py similarity index 100% rename from tests/phenotype_recommendation_advice_smoke_test.py rename to tests/smoke_phenotype_recommendation_advice.py From af48edb12bb768f85b78fce07338bbf2c66190d9 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 05:29:12 -0500 Subject: [PATCH 17/20] reorganizing docs for sanity --- docs/R_PACKAGE_ARCHITECTURE_PLAN.md | 413 +++++------------- ...md => R_STRATEGUS_COHORT_METHODS_SHELL.md} | 0 ...TRATEGUS_SHELL.md => R_STRATEGUS_SHELL.md} | 0 ...d => SPEC_COHORT_METHOD_RECOMMENDATION.md} | 0 ...RFACE_SPEC.md => SPEC_KEEPER_INTERFACE.md} | 0 docs/TEST-RUN.md | 143 ------ ...WORKFLOW.md => WORKFLOW_COHORT_METHODS.md} | 0 ...ENCE_WORKFLOW.md => WORKFLOW_INCIDENCE.md} | 0 ...d => WORKFLOW_PHENOTYPE_RECOMMENDATION.md} | 0 9 files changed, 119 insertions(+), 437 deletions(-) rename docs/{STRATEGUS_COHORT_METHODS_SHELL.md => R_STRATEGUS_COHORT_METHODS_SHELL.md} (100%) rename docs/{STRATEGUS_SHELL.md => R_STRATEGUS_SHELL.md} (100%) rename docs/{COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md => SPEC_COHORT_METHOD_RECOMMENDATION.md} (100%) rename docs/{KEEPER_INTERFACE_SPEC.md => SPEC_KEEPER_INTERFACE.md} (100%) delete mode 100644 docs/TEST-RUN.md rename docs/{COHORT_METHODS_WORKFLOW.md => WORKFLOW_COHORT_METHODS.md} (100%) rename docs/{INCIDENCE_WORKFLOW.md => WORKFLOW_INCIDENCE.md} (100%) rename docs/{PHENOTYPE_RECOMMENDATION_DESIGN.md => WORKFLOW_PHENOTYPE_RECOMMENDATION.md} (100%) diff --git a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md index 6dd6cdc..52d223f 100644 --- a/docs/R_PACKAGE_ARCHITECTURE_PLAN.md +++ b/docs/R_PACKAGE_ARCHITECTURE_PLAN.md @@ -1,196 +1,138 @@ -# R Package Architecture Plan +# R Package Architecture ## Purpose -This document expands sprint item 3 in `CURRENT-SPRINT-PLAN.md` into a concrete architecture plan for the R side of the project. +This document describes the implemented R-side architecture for the OHDSI Study Agent. +The former combined R package has been retired. The current split is: -The split described here is now complete: `slashOhdsiAcpClient` owns ACP connectivity and thin wrappers, `slashOhdsiStrategusAssistant` owns Strategus workflows and shell entrypoints, and the legacy combined package has been removed. The remaining sections are kept as architectural context for how the split was designed. +- `slashOhdsiAcpClient`: ACP connectivity plus thin flow/action wrappers +- `slashOhdsiStrategusAssistant`: Strategus workflows, shell orchestration, dialogue context, and generated script assets -The original combined R package mixed three responsibilities: +This is now current-state documentation rather than a migration plan. -- ACP transport and call/response helpers -- thin R wrappers around ACP flows and actions -- high-level Strategus workflow orchestration and interactive shells +## Implemented Package Split -That coupling is visible in the split code that replaced it: +### `slashOhdsiAcpClient` -- [`R/slashOhdsiAcpClient/R/client.R`](../R/slashOhdsiAcpClient/R/client.R) owns connection state and raw POST behavior -- [`R/slashOhdsiAcpClient/R/compatibility_api.R`](../R/slashOhdsiAcpClient/R/compatibility_api.R), [`R/slashOhdsiAcpClient/R/lint_and_concept_sets.R`](../R/slashOhdsiAcpClient/R/lint_and_concept_sets.R), and [`R/slashOhdsiAcpClient/R/actions_and_lint.R`](../R/slashOhdsiAcpClient/R/actions_and_lint.R) now own the thin flow/action wrappers -- [`R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R) and [`R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R) own interactive workflow state, checkpoints, artifact layout, and script generation +Primary responsibility: -The package split isolated those concerns before more `/ohdsi` dialogue work, incidence-shell extension, and concept-set generation integration are added. +- manage ACP connection configuration +- perform HTTP calls to ACP flows/actions +- expose thin exported wrappers around ACP capabilities +- normalize request payloads and basic response handling -## Goals +Key files: -1. Create a small ACP-focused R package with a stable, testable HTTP interface and typed request/response helpers. -2. Move Strategus shells and workflow orchestration into a separate higher-level package that depends on the ACP package rather than internal transport functions. -3. Define one small workflow-stage contract that all shells use when asking ACP for contextual dialogue or recommendations. -4. Keep the migration incremental enough that downstream code can adopt the split in stages during the transition. +- [`R/slashOhdsiAcpClient/R/client.R`](../R/slashOhdsiAcpClient/R/client.R) +- [`R/slashOhdsiAcpClient/R/flows.R`](../R/slashOhdsiAcpClient/R/flows.R) +- [`R/slashOhdsiAcpClient/R/compatibility_api.R`](../R/slashOhdsiAcpClient/R/compatibility_api.R) +- [`R/slashOhdsiAcpClient/R/lint_and_concept_sets.R`](../R/slashOhdsiAcpClient/R/lint_and_concept_sets.R) +- [`R/slashOhdsiAcpClient/R/actions_and_lint.R`](../R/slashOhdsiAcpClient/R/actions_and_lint.R) -## Non-Goals +Current role boundaries: -- Rewriting the ACP server API -- Reworking the generated Strategus script content unless required by the package split -- Solving future Atlas integration now beyond defining the contract seam +- no Strategus shell state +- no local artifact layout decisions +- no generated-script ownership +- no workflow progression logic beyond wrapper arguments -## Proposed Package Split +Representative exported seam: -### Package A: ACP client package +- `acp_connect()` +- `acp_get_default_client()` +- `acp_is_connected()` +- `acp_call_flow()` +- `acp_workflow_context_dialogue()` +- `acp_suggest_cohort_method_specs()` +- `acp_keeper_concept_sets_generate()` +- `acp_keeper_profiles_generate()` +- `acp_phenotype_validation_review()` -Working name: `slashOhdsiAcpClient` +### `slashOhdsiStrategusAssistant` -Responsibility: +Primary responsibility: -- manage ACP connection configuration -- perform authenticated HTTP requests -- expose thin, documented wrappers around ACP flows and actions -- normalize request payloads and response parsing -- provide error handling, timeout handling, and optional retry helpers +- own user-facing Strategus shells +- collect inputs and manage checkpoints +- maintain local artifact layout under workflow output directories +- construct workflow-stage dialogue context for `/ohdsi` +- interpret ACP responses in workflow context +- generate Strategus-ready scripts and analysis assets +- provide a shared ACP-based Keeper review helper for generated scripts and inline shell execution -This package should not: +Key files: -- own interactive shell state -- write Strategus project folders or scripts -- decide workflow progression -- embed stage-specific assumptions about cohort methods vs incidence workflows beyond request payload fields +- [`R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R) +- [`R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R`](../R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R) +- [`R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R`](../R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R) +- [`R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R`](../R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R) +- [`R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R`](../R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R) +- [`R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R`](../R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R) +- [`R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R`](../R/slashOhdsiStrategusAssistant/R/cohort_methods_specs.R) +- [`R/slashOhdsiStrategusAssistant/R/db_details.R`](../R/slashOhdsiStrategusAssistant/R/db_details.R) +- [`R/slashOhdsiStrategusAssistant/R/execution_settings.R`](../R/slashOhdsiStrategusAssistant/R/execution_settings.R) +- [`R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R`](../R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R) -### Package B: Strategus workflow package +Representative public seam: -Working name: `slashOhdsiStrategusAssistant` +- `runStrategusIncidenceShell()` +- `runStrategusCohortMethodsShell()` +- `runKeeperReviewWorkflow()` +- `new_workflow_dialogue_session()` +- `build_incidence_workflow_stage_context()` +- `build_cohort_methods_workflow_stage_context()` +- `render_workflow_dialogue_response()` +- `createStrategusConnectionDetails()` +- `createStrategusExecutionSettings()` -Responsibility: +## Runtime Boundary Between Packages -- own user-facing workflow shells -- collect inputs, manage checkpoints, and maintain local artifact layout -- interpret ACP responses in workflow context -- generate Strategus-ready scripts and analysis assets -- decide when to call dialogue, recommendation, improvement, or concept-set flows +The workflow package does not construct raw ACP HTTP requests directly in shell code. +Instead it calls public wrappers from `slashOhdsiAcpClient`, either directly or through the +small runtime bridge in [`slash_ohdsi_runtime.R`](../R/slashOhdsiStrategusAssistant/R/slash_ohdsi_runtime.R). -This package should depend on Package A only through exported functions and response objects. +That bridge currently serves two purposes: -## Proposed Ownership Mapping +- keep shell code dependent on a small local seam instead of scattered package calls +- provide a stable place to expand ACP-backed runtime helpers such as Keeper flows and workflow dialogue -### Move into ACP client package +## Implemented Workflow Dialogue Contract -- `R/acp_client.R` -- `R/ops_llm_actions.R` -- thin ACP wrappers now embedded in: - - `R/phenotype_workflow.R` - - `R/cohort_methods_workflow.R` - - `R/lintStudyDesign.R` - - `R/concept_set_actions.R` +The shells now share a workflow-stage context contract for `/ohdsi` dialogue. +The structure is built in the workflow package and passed through the ACP client wrapper. -Target exports in the ACP client package should look more like: - -- `acp_connect()` -- `acp_is_connected()` -- `acp_call_flow(flow_name, body)` -- `acp_call_action(action_name, body)` -- `acp_suggest_phenotypes(...)` -- `acp_review_phenotypes(...)` -- `acp_suggest_cohort_method_specs(...)` -- `acp_workflow_context_dialogue(...)` -- `acp_keeper_concept_sets_generate(...)` -- `acp_lint_study_design(...)` - -The important change is not only moving code. The workflow package must stop depending on unexported transport internals such as `.acp_post` and `acp_state`. - -### Keep or move into Strategus workflow package - -- `R/strategus_incidence_shell.R` -- `R/strategus_cohort_methods_shell.R` -- `R/db_details.R` -- `R/execution_settings.R` -- `R/utils_json.R` -- local script-generation helpers -- selection helpers that are meaningful only in workflow context -- artifact copy/apply helpers for selected cohorts and patched cohorts - -### Likely split between both packages - -Current files that mix concerns should be decomposed: - -- `R/phenotype_workflow.R` - - ACP client package: request/response wrappers - - workflow package: interactive selection and local definition-pull orchestration if still needed there -- `R/cohort_methods_workflow.R` - - ACP client package: spec recommendation call - - workflow package: any shell-facing summary rendering or default reconciliation -- `R/lintStudyDesign.R` - - ACP client package if retained as a generic ACP consumer - - workflow package only if it remains positioned as a Strategus-shell utility -- `R/concept_set_actions.R` - - ACP client package for generic concept-set action calls - - workflow package only for shell-local convenience wrappers if required - -## Workflow Stage Contract - -This is the key foundation for items 1, 2, and 4. - -The workflow package should pass a single small object into the ACP client package whenever context-aware dialogue or stage-specific recommendations are needed. - -Proposed R shape: - -```r -workflow_stage_context <- list( - workflow_type = "strategus_cohort_methods", # or "strategus_incidence" - current_step = "target_selection", # or others shown in the "Shared stages" and workflow-sepecific sections below - step_label = "Target cohort selection", # and others mentioned below - user_goal = studyIntent, - entities = list( - target = NULL, - comparator = NULL, - outcomes = list() - ), - available_artifacts = list( - protocol_path = NULL, - selected_target_ids = list(), - selected_comparator_ids = list(), - selected_outcome_ids = list(), - analysis_settings_path = NULL, - concept_set_paths = list() - ), - dialogue = list( - prior_questions = list(), - prior_answers = list(), # each item could be an identifiers of a JSONL record and a short ~50 character summary that the LLM could use to request more details if needed so context does not grow - last_user_message = NULL - ), - constraints = list( - interactive = TRUE, - allow_recommendations = TRUE, - allow_generation = FALSE - ) -) -``` - -Required fields for the first pass: +Current top-level context shape includes: - `workflow_type` - `current_step` +- `step_label` - `user_goal` - -Recommended fields for the first pass: - - `entities` - `available_artifacts` -- `dialogue$last_user_message` +- `dialogue` - `constraints` +- `legacy_context` + +The exact population varies by workflow and step, but the important implemented behavior is: -Rules for the contract: +- incidence and cohort-method shells both emit normalized stage context +- `/ohdsi` dialogue uses that context instead of ad hoc free-form shell strings +- the workflow package owns stage labels and context shaping +- the ACP client wrapper flattens and forwards the resulting payload -1. `current_step` must be a small controlled vocabulary owned by the workflow package. -2. ACP client functions should forward the object without embedding shell-specific branching into the transport layer. -3. The contract must be versionable. Add `contract_version = 1L` when this is implemented. -4. The same top-level fields must work for cohort-method, incidence shells, and other worflow shells, with some fields empty when irrelevant. +Relevant files: -## Controlled Vocabulary for `current_step` +- [`R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R`](../R/slashOhdsiStrategusAssistant/R/workflow_stage_context.R) +- [`R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R`](../R/slashOhdsiStrategusAssistant/R/workflow_dialogue_mapping.R) +- [`R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R`](../R/slashOhdsiStrategusAssistant/R/workflow_dialogue.R) +- [`R/slashOhdsiAcpClient/R/flows.R`](../R/slashOhdsiAcpClient/R/flows.R) -Use a stable step vocabulary now so item 2 does not invent a second shape later. +## Implemented Stage Vocabulary -Shared stages: +Shared stages in current use include: - `study_intent_capture` -- `intent_split` # incidence analysis splits to "target" and "outcome" while cohort method adds "comparator" +- `intent_split` - `target_selection` - `outcome_selection` - `phenotype_review` @@ -198,167 +140,50 @@ Shared stages: - `keeper_case_review` - `workflow_summary` -Cohort-method-specific stages: -- `comparator_selection` +Cohort-method-specific stages include: + +- `comparator_selection` - `analytic_settings_collection` - `cohort_method_spec_recommendation` - `cohort_method_spec_confirmation` -Incidence-specific stages: +Incidence-specific stages include: - `incidence_design_setup` - `time_at_risk_configuration` -These labels do not need to be identical to UI labels. They should be stable machine-facing identifiers. - -## API Shape Between Packages - -The workflow package should not build raw endpoint paths. Instead it should call exported ACP package functions such as: - -```r -client <- acp_client(url = acpUrl, token = NULL) - -resp <- acp_workflow_context_dialogue( - client = client, - stage_context = workflow_stage_context, - message = user_message -) -``` - -Preferred client pattern: - -- explicit client object returned from `acp_client()` or similar constructor -- no hidden global mutable state as the primary interface -- No need for a compatibility bridge for `acp_connect()` during migration - this can be a clean refactor - -Why this matters: - -- shells can hold their own client handle -- tests can inject mock clients -- multiple ACP endpoints can be targeted in one R session if needed -- the workflow package stops depending on package-global side effects - -## Migration Plan - -### Phase 0: Document and freeze the seam - -- create this design note -- agree on package names or temporary names -- agree on the `workflow_stage_context` fields and `current_step` vocabulary - -Deliverable: - -- approved architecture note and contract - -### Phase 1: Extract the ACP client surface without changing behavior - -- create a new package directory for the ACP client -- move `acp_connect()` and `.acp_post()` behavior behind exported client functions -- add wrappers for the currently used flows and actions -- keep response shapes unchanged where possible - -Deliverable: - -- workflow code can call exported ACP helpers without using `.acp_post` - -### Phase 2: Refactor shells to consume the ACP package - -- replace direct `.acp_post()` usage in both Strategus shells -- replace checks against `acp_state$url` with explicit ACP client availability checks -- route all `/ohdsi` or stage-aware dialogue calls through `acp_workflow_context_dialogue()` - -Deliverable: - -- both shells depend only on the ACP package public API - -### Phase 3: Introduce the stage-context contract - -- define helper constructors in the workflow package for stage context objects -- update cohort-method workflow dialogue calls to use the contract -- extend `runStrategusIncidenceShell()` to use the same contract and `current_step` semantics - -Deliverable: - -- one shared stage-context payload across both shells - -### Phase 4: Integrate `keeper_concept_sets_generate` through the new seam - -- add a thin ACP wrapper in the ACP client package -- add workflow-package integration points for concept-set generation near covariate concept-set selection -- keep the function reusable outside shells - -Deliverable: - -- item 4 can land without reintroducing cross-layer coupling - -### Phase 5: Compatibility cleanup - -- remove the deprecated combined-package exports entirely -- update README and examples -- add package-focused tests - -Deliverable: - -- clear public surface and lower maintenance cost - -## Immediate Implementation Recommendations - -These are the concrete next code tasks that should happen first. - -1. Introduce an ACP client object and exported flow wrappers before any new incidence-shell `/ohdsi` work. -2. Extract a shared helper that builds `workflow_stage_context` objects from shell state. -3. Replace direct `.acp_post("/flows/workflow_context_dialogue", ...)` usage in the cohort-method shell with a wrapper call. -4. Extend the incidence shell to use the same wrapper and controlled `current_step` labels. -5. Only after that, add `keeper_concept_sets_generate` as another wrapper plus workflow insertion point. - -## Risks - -- If the split is attempted by moving files first without introducing a real public ACP API, the new package boundary will be cosmetic only. -- If the workflow-stage contract is too large, it will become a second shell implementation rather than a stable interface. -- If `current_step` labels diverge between shells, item 1 and item 2 will create parallel dialogue logic that is harder to maintain than the current state. - -## Testing Strategy - -Add tests at both package levels. - -ACP client package: - -- request payload tests -- response parsing tests -- error and timeout handling tests -- mock transport tests for each exported wrapper - -Workflow package: - -- stage-context builder tests -- shell checkpoint/resume tests -- integration tests that verify correct ACP wrapper calls per workflow stage -- regression tests for generated artifact layout and scripts - -## How This Unblocks the Other Sprint Items +These identifiers are machine-facing workflow markers owned by the Strategus workflow package. -Item 1: +## Generated Artifact Ownership -- stage-aware dialogue UX becomes a workflow concern backed by one shared context contract +The current package ownership of generated workflow artifacts is: -Item 2: +- `slashOhdsiStrategusAssistant` owns output directory layout, checkpoints, JSON artifacts, and generated R scripts +- `slashOhdsiAcpClient` owns only the ACP request/response seam and does not own filesystem artifacts -- incidence-shell `/ohdsi` support can reuse the same ACP wrapper and `current_step` vocabulary +Important generated-script facts in the current architecture: -Item 4: +- both Strategus shells generate ACP-based `04_keeper_review.R` +- the generated Keeper script calls `runKeeperReviewWorkflow()` and no longer uses the legacy Keeper R package +- the incidence shell persists TAR and strata settings to `analysis-settings/time_at_risk_settings.json` +- the cohort-method shell persists analytic-settings artifacts and comparison artifacts used by `06_cm_spec.R` -- `keeper_concept_sets_generate` becomes a thin ACP wrapper plus a workflow insertion point, not another direct shell-to-endpoint special case +## Current Architecture Benefits -Item 5: +The implemented split now gives the project: -- follow-on workflow features can add new stages or wrappers without deep edits to transport code +- a smaller ACP client seam that can be reused outside the Strategus shells +- clearer separation between transport/wrapper code and workflow orchestration +- easier static testing of generated-script contracts and workflow dialogue mapping +- a consistent place to add new ACP-backed helpers without reintroducing the original combined-package coupling -## Recommended Decision +## Remaining Gaps -Proceed with a two-package split, but implement it as an API-first extraction instead of a file-move-first refactor. +This architecture is implemented, but several workflow capabilities remain incomplete: -The first concrete coding milestone should be: +- cohort-method negative-control and covariate concept-set workflows are still placeholder-based +- ACP analytic-settings recommendations are mapped into shell settings without a dedicated validation layer yet +- cohort-method generation still materializes only the first comparison from `cm_comparisons.json` +- ACP-based Keeper concept-set approve/edit/rerun UX is still incomplete even though the runtime seam is in place -- a new ACP client package surface -- a shared `workflow_stage_context` helper -- refactoring the cohort-method shell to use those two abstractions before adding any more shell features +Those are workflow/product gaps, not package-split gaps. diff --git a/docs/STRATEGUS_COHORT_METHODS_SHELL.md b/docs/R_STRATEGUS_COHORT_METHODS_SHELL.md similarity index 100% rename from docs/STRATEGUS_COHORT_METHODS_SHELL.md rename to docs/R_STRATEGUS_COHORT_METHODS_SHELL.md diff --git a/docs/STRATEGUS_SHELL.md b/docs/R_STRATEGUS_SHELL.md similarity index 100% rename from docs/STRATEGUS_SHELL.md rename to docs/R_STRATEGUS_SHELL.md diff --git a/docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md b/docs/SPEC_COHORT_METHOD_RECOMMENDATION.md similarity index 100% rename from docs/COHORT_METHODS_SPECIFICATIONS_RECOMMENDATION_DESIGN.md rename to docs/SPEC_COHORT_METHOD_RECOMMENDATION.md diff --git a/docs/KEEPER_INTERFACE_SPEC.md b/docs/SPEC_KEEPER_INTERFACE.md similarity index 100% rename from docs/KEEPER_INTERFACE_SPEC.md rename to docs/SPEC_KEEPER_INTERFACE.md diff --git a/docs/TEST-RUN.md b/docs/TEST-RUN.md deleted file mode 100644 index f3f0b68..0000000 --- a/docs/TEST-RUN.md +++ /dev/null @@ -1,143 +0,0 @@ -## Environment - -export EMBED_MODEL=bedrock:amazon.titan-embed-text-v2:0 -export EMBED_API_KEY=none -export EMBED_URL=http://127.0.0.1:8000/v1/embeddings -export EMBED_TIMEOUT=60 -export LLM_API_KEY=none -export LLM_API_URL=http://127.0.0.1:8000/api/chat/completions -export LLM_MODEL=bedrock:us.anthropic.claude-sonnet-4-6 -export LLM_TIMEOUT=120 -export LLM_USE_RESPONSES=0 -export VOCAB_SEARCH_PROVIDER=hecate_api -export VOCAB_SEARCH_URL="https://hecate.pantheon-hds.com/api/search_standard" - -export PHOEBE_PROVIDER=db -export PHOEBE_RELATIONSHIP_IDS="Lexical via source,Patient context" -export PHOEBE_MAX_CONCEPTS_PER_RELATIONSHIP=10 -export PHOEBE_MAX_CONCEPTS=20 - -export export OMOP_DB_ENGINE='postgresql://****:****@localhost:6432/gsph_pace' -export VOCAB_DATABASE_SCHEMA=vocabulary -export PHOEBE_DB_TABLE=concept_recommended -export VOCAB_CONCEPT_TABLE=concept - -## Embedding preparation using the bedrock-hosted model - -python mcp_server/scripts/build_phenotype_index.py \ - --metadata-csv /ai-agent/HadesProject/OHDSI-Study-Agent/data/Cohorts.csv \ - --definitions-dir /ai-agent/HadesProject/OHDSI-Study-Agent/data/cohorts \ - --output-dir /ai-agent/HadesProject/OHDSI-Study-Agent/data/phenotype_index \ - --build-dense - -## First run, small sample -1) test search for suggested phenotype definition -export PHENOTYPE_INDEX_DIR=/ai-agent/HadesProject/OHDSI-Study-Agent/data/phenotype_index/ -curl -s -X POST http://127.0.0.1:8765/flows/phenotype_recommendation -H 'Content-Type: application/json' -d '{"study_intent":"Identify patients who experience an adverse event of intracranial bleeding", "top_k":20, "max_results":10,"candidate_limit":10}' | python -m json.tool - -2) Obtain the concept sets for alternative diagnoses for intracranial bleeding - -``` -curl -s -X POST http://127.0.0.1:8765/flows/keeper_concept_sets_generate -H 'Content-Type: application/json' -d '{"phenotype":"Intracranial bleeding","domain_keys":["alternativeDiagnosis"],"candidate_limit":10,"include_diagnostics":true}' > /tmp/keeper_concept_sets.json - -jq '.concept_sets' /tmp/keeper_concept_sets.json > /tmp/keeper_concept_sets_only.json - -CONCEPT_SETS_JSON="$(cat /tmp/keeper_concept_sets_only.json)" -``` - - -3) after loading the suggested phenotype from step 1 into Atlas and setting the CONCEPT_SETS_JSON variable in the last step, check that this returns a result and that the information in the result matches the Atlas patient profile - -``` -curl -s -X POST http://127.0.0.1:8765/flows/keeper_profiles_generate -H 'Content-Type: application/json' -d "{ - \"cdm_database_schema\": \"cdm_pace_cases\", - \"cohort_database_schema\": \"webapi_results_cases\", - \"cohort_table\": \"cohort\", - \"cohort_definition_id\": 217, - \"sample_size\": 5, - \"person_ids\": [\"8444311562\"], - \"phenotype_name\": \"Intracranial bleeding\", - \"use_descendants\": true, - \"remove_pii\": true, - \"keeper_concept_sets\": $CONCEPT_SETS_JSON - }" | python -m json.tool -``` - -4) sample of 5 - -``` -curl -s -X POST http://127.0.0.1:8765/flows/keeper_profiles_generate \ - -H 'Content-Type: application/json' \ - -d "{ - \"cdm_database_schema\": \"cdm_pace_cases\", - \"cohort_database_schema\": \"webapi_results_cases\", - \"cohort_table\": \"cohort\", - \"cohort_definition_id\": 217, - \"sample_size\": 5, - \"phenotype_name\": \"Intracranial bleeding\", - \"use_descendants\": true, - \"remove_pii\": true, - \"keeper_concept_sets\": $CONCEPT_SETS_JSON - }" | python -m json.tool -``` - -## a more expansive sample and full workflow - -1) build concepts across several keeper concept categories and save to a file - -``` -curl -s -X POST http://127.0.0.1:8765/flows/keeper_concept_sets_generate \ - -H 'Content-Type: application/json' \ - -d '{ - "phenotype": "Intracranial bleeding", - "domain_keys": ["doi", "alternativeDiagnosis", "symptoms", "drugs", "diagnosticProcedures", "measurements"], - "candidate_limit": 10, - "include_diagnostics": true - }' > /tmp/keeper_concept_sets_full.json -``` - -2) If this runs, move the keeper_concept_sets_full.json to sandbox in this folder and then run `keeper_profiles_generate`. That is the first flow that consumes the generated `concept_sets` and tests whether they work against your cohort/CDM data. - -``` -jq '{ - cdm_database_schema: "cdm_pace_cases", - cohort_database_schema: "webapi_results_cases", - cohort_table: "cohort", - cohort_definition_id: 217, - sample_size: 5, - phenotype_name: "Intracranial bleeding", - use_descendants: true, - remove_pii: true, - keeper_concept_sets: .concept_sets -}' ./sandbox/keeper_concept_sets_full.json > ./sandbox/keeper_profiles_payload.json - -curl -s -X POST http://127.0.0.1:8765/flows/keeper_profiles_generate \ - -H 'Content-Type: application/json' \ - --data-binary @./sandbox/keeper_profiles_payload.json | python -m json.tool -``` - - -3) Save the JSON portion of the output from the command above to ./sandbox/sample-keeper-profiles.json. Then, use `jq` to grab one `rows[]` entry and build the next payload. - -```bash -jq '.rows[0]' ./sandbox/sample-keeper-profiles.json -``` - -To send it directly to `phenotype_validation_review`, build another payload file: - -```bash -jq '{ - disease_name: "Intracranial bleeding", - keeper_row: .rows[0] -}' ./sandbox/sample-keeper-profiles.json > ./sandbox/phenotype_validation_payload.json -``` - -Then post it: - -```bash -curl -s -X POST http://127.0.0.1:8765/flows/phenotype_validation_review \ - -H 'Content-Type: application/json' \ - --data-binary @./sandbox/phenotype_validation_payload.json | python -m json.tool -``` - -4) Change the row number above to select a different review row, then rerun `phenotype_validation_review`. diff --git a/docs/COHORT_METHODS_WORKFLOW.md b/docs/WORKFLOW_COHORT_METHODS.md similarity index 100% rename from docs/COHORT_METHODS_WORKFLOW.md rename to docs/WORKFLOW_COHORT_METHODS.md diff --git a/docs/INCIDENCE_WORKFLOW.md b/docs/WORKFLOW_INCIDENCE.md similarity index 100% rename from docs/INCIDENCE_WORKFLOW.md rename to docs/WORKFLOW_INCIDENCE.md diff --git a/docs/PHENOTYPE_RECOMMENDATION_DESIGN.md b/docs/WORKFLOW_PHENOTYPE_RECOMMENDATION.md similarity index 100% rename from docs/PHENOTYPE_RECOMMENDATION_DESIGN.md rename to docs/WORKFLOW_PHENOTYPE_RECOMMENDATION.md From 3241c22d8727557a1cc1fce03c7f78c3d3a563cd Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 05:45:44 -0500 Subject: [PATCH 18/20] further clean up of docs and removal of dangerous template db details file --- demo/324_Pain.json | 419 ------------ demo/strategus-db-details.json | 9 - demo/strategus-execution-settings.json | 10 - demo/test_git_event_cohort.json | 208 ------ docs/R_STRATEGUS_COHORT_METHODS_SHELL.md | 32 +- ...HELL.md => R_STRATEGUS_INCIDENCE_SHELL.md} | 33 + docs/TESTING.md | 6 +- .../1197_Acute_gastrointestinal_bleeding.json | 630 ++++++++++++++++++ {demo => scripts}/cohort_definition.json | 0 {demo => scripts}/concept_set.json | 0 {demo => scripts}/protocol.md | 0 11 files changed, 696 insertions(+), 651 deletions(-) delete mode 100644 demo/324_Pain.json delete mode 100644 demo/strategus-db-details.json delete mode 100644 demo/strategus-execution-settings.json delete mode 100644 demo/test_git_event_cohort.json rename docs/{R_STRATEGUS_SHELL.md => R_STRATEGUS_INCIDENCE_SHELL.md} (80%) create mode 100644 scripts/1197_Acute_gastrointestinal_bleeding.json rename {demo => scripts}/cohort_definition.json (100%) rename {demo => scripts}/concept_set.json (100%) rename {demo => scripts}/protocol.md (100%) diff --git a/demo/324_Pain.json b/demo/324_Pain.json deleted file mode 100644 index 4e0ab17..0000000 --- a/demo/324_Pain.json +++ /dev/null @@ -1,419 +0,0 @@ -{ - "cdmVersionRange" : ">=5.0.0", - "PrimaryCriteria" : { - "CriteriaList" : [ - { - "ConditionOccurrence" : { - "CodesetId" : 0, - "ConditionTypeExclude" : false - } - }, - { - "Observation" : { - "CodesetId" : 0, - "ObservationTypeExclude" : false - } - } - ], - "ObservationWindow" : { - "PriorDays" : 0, - "PostDays" : 0 - }, - "PrimaryCriteriaLimit" : { - "Type" : "All" - } - }, - "ConceptSets" : [ - { - "id" : 0, - "name" : "Pain or Ache", - "expression" : { - "items" : [ - { - "concept" : { - "CONCEPT_ID" : 38000975, - "CONCEPT_NAME" : "Headaches w MCC", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "102", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "DRG", - "CONCEPT_CLASS_ID" : "MS-DRG" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4137649, - "CONCEPT_NAME" : "Site of referred pain", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "263597005", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Attribute" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4240653, - "CONCEPT_NAME" : "Back pain complicating pregnancy", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "91957002", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 35611566, - "CONCEPT_NAME" : "Bilateral lower limb atherosclerosis pain at rest co-occurrent and due to atherosclerosis", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "12236991000119103", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4170962, - "CONCEPT_NAME" : "Pain / sensation finding", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "276435006", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 38000976, - "CONCEPT_NAME" : "Headaches w/o MCC", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "103", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "DRG", - "CONCEPT_CLASS_ID" : "MS-DRG" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 3005359, - "CONCEPT_NAME" : "Chronic pain [CCC]", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "28153-5", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : "Survey" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4264836, - "CONCEPT_NAME" : "Pain / sensation observable", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "364624006", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Observable Entity" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4128327, - "CONCEPT_NAME" : "Period pain present", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "289900009", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4301067, - "CONCEPT_NAME" : "Temporomandibular joint-pain-dysfunction syndrome", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "386207004", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 40761360, - "CONCEPT_NAME" : "Chest pain", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "58259-3", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : "Clinical Observation" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4202478, - "CONCEPT_NAME" : "Pain and sensation symptom findings", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "308927000", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4269430, - "CONCEPT_NAME" : "Feature of present pain intensity", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "364635001", - "DOMAIN_ID" : "Observation", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Observable Entity" - }, - "isExcluded" : true, - "includeDescendants" : true, - "includeMapped" : false - } - ] - } - } - ], - "QualifiedLimit" : { - "Type" : "First" - }, - "ExpressionLimit" : { - "Type" : "All" - }, - "InclusionRules" : [ - { - "name" : "No evidence of no pain", - "expression" : { - "Type" : "ALL", - "CriteriaList" : [ - { - "Criteria" : { - "Observation" : { - "CodesetId" : 0, - "ObservationTypeExclude" : false, - "ValueAsNumber" : { - "Value" : 0, - "Op" : "eq" - } - } - }, - "StartWindow" : { - "Start" : { - "Days" : 0, - "Coeff" : -1 - }, - "End" : { - "Days" : 0, - "Coeff" : 1 - }, - "UseIndexEnd" : false, - "UseEventEnd" : false - }, - "RestrictVisit" : false, - "IgnoreObservationPeriod" : false, - "Occurrence" : { - "Type" : 0, - "Count" : 0, - "IsDistinct" : false - } - }, - { - "Criteria" : { - "Observation" : { - "CodesetId" : 0, - "ObservationTypeExclude" : false, - "ValueAsConcept" : [ - { - "CONCEPT_ID" : 9189, - "CONCEPT_NAME" : "Negative", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "260385009", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : null - }, - { - "CONCEPT_ID" : 45877013, - "CONCEPT_NAME" : "0 - absent", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "LA16631-6", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : null - }, - { - "CONCEPT_ID" : 21498964, - "CONCEPT_NAME" : "None / absent", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "LA24816-3", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : null - }, - { - "CONCEPT_ID" : 45884086, - "CONCEPT_NAME" : "Absent", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "LA9634-2", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : null - }, - { - "CONCEPT_ID" : 36309158, - "CONCEPT_NAME" : "Not detected/negative", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "LA13549-3", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : null - }, - { - "CONCEPT_ID" : 45878583, - "CONCEPT_NAME" : "Negative", - "STANDARD_CONCEPT" : null, - "STANDARD_CONCEPT_CAPTION" : "Unknown", - "INVALID_REASON" : null, - "INVALID_REASON_CAPTION" : "Unknown", - "CONCEPT_CODE" : "LA6577-6", - "DOMAIN_ID" : "Meas Value", - "VOCABULARY_ID" : "LOINC", - "CONCEPT_CLASS_ID" : null - } - ] - } - }, - "StartWindow" : { - "Start" : { - "Days" : 0, - "Coeff" : -1 - }, - "End" : { - "Days" : 0, - "Coeff" : 1 - }, - "UseIndexEnd" : false, - "UseEventEnd" : false - }, - "RestrictVisit" : false, - "IgnoreObservationPeriod" : false, - "Occurrence" : { - "Type" : 0, - "Count" : 0, - "IsDistinct" : false - } - } - ], - "DemographicCriteriaList" : [], - "Groups" : [] - } - } - ], - "EndStrategy" : { - "DateOffset" : { - "DateField" : "EndDate", - "Offset" : 30 - } - }, - "CensoringCriteria" : [], - "CollapseSettings" : { - "CollapseType" : "ERA", - "EraPad" : 0 - }, - "CensorWindow" : {} -} diff --git a/demo/strategus-db-details.json b/demo/strategus-db-details.json deleted file mode 100644 index 722fa2a..0000000 --- a/demo/strategus-db-details.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "dbms": "postgresql", - "DB_SERVER": "localhost", - "DB_PORT": "5432", - "DB_USER": "ohdsi", - "DB_PASS": "change_me", - "DB_DRIVER_PATH": "", - "extraSettings": "sslmode=disable" -} diff --git a/demo/strategus-execution-settings.json b/demo/strategus-execution-settings.json deleted file mode 100644 index 03a9709..0000000 --- a/demo/strategus-execution-settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "cdmDatabaseSchema": "cdm_schema", - "workDatabaseSchema": "work_schema", - "resultsDatabaseSchema": "results_schema", - "vocabularyDatabaseSchema": "vocab_schema", - "cohortTable": "cohort", - "workFolder": "demo-strategus-cohort-incidence/work", - "resultsFolder": "demo-strategus-cohort-incidence/results", - "cohortIdFieldName": "cohort_definition_id" -} diff --git a/demo/test_git_event_cohort.json b/demo/test_git_event_cohort.json deleted file mode 100644 index 1e6ea18..0000000 --- a/demo/test_git_event_cohort.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "cdmVersionRange" : ">=5.0.0", - "PrimaryCriteria" : { - "CriteriaList" : [ - { - "ConditionOccurrence" : { - "CodesetId" : 0, - "ConditionTypeExclude" : false - } - }, - { - "ProcedureOccurrence" : { - "CodesetId" : 0, - "ProcedureTypeExclude" : false - } - } - ], - "ObservationWindow" : { - "PriorDays" : 0, - "PostDays" : 0 - }, - "PrimaryCriteriaLimit" : { - "Type" : "All" - } - }, - "ConceptSets" : [ - { - "id" : 0, - "name" : "Gastrointestinal hemorrhage", - "expression" : { - "items" : [ - { - "concept" : { - "CONCEPT_ID" : 4112183, - "CONCEPT_NAME" : "Esophageal varices with bleeding, associated with another disorder", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "195475003", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4103703, - "CONCEPT_NAME" : "Melena", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "2901004", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 192671, - "CONCEPT_NAME" : "Gastrointestinal hemorrhage", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "74474003", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 4245614, - "CONCEPT_NAME" : "Bleeding from anus", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "6072007", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 2108900, - "CONCEPT_NAME" : "Esophagogastroduodenoscopy, flexible, transoral; with control of bleeding, any method", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "43255", - "DOMAIN_ID" : "Procedure", - "VOCABULARY_ID" : "CPT4", - "CONCEPT_CLASS_ID" : "CPT4" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 28779, - "CONCEPT_NAME" : "Bleeding esophageal varices", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "17709002", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 2002608, - "CONCEPT_NAME" : "Control of hemorrhage and suture of ulcer of stomach or duodenum", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "44.4", - "DOMAIN_ID" : "Procedure", - "VOCABULARY_ID" : "ICD9Proc", - "CONCEPT_CLASS_ID" : "3-dig nonbill code" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 443530, - "CONCEPT_NAME" : "Hematochezia", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "405729008", - "DOMAIN_ID" : "Condition", - "VOCABULARY_ID" : "SNOMED", - "CONCEPT_CLASS_ID" : "Clinical Finding" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - }, - { - "concept" : { - "CONCEPT_ID" : 2108878, - "CONCEPT_NAME" : "Esophagoscopy, flexible, transoral; with control of bleeding, any method", - "STANDARD_CONCEPT" : "S", - "STANDARD_CONCEPT_CAPTION" : "Standard", - "INVALID_REASON" : "V", - "INVALID_REASON_CAPTION" : "Valid", - "CONCEPT_CODE" : "43227", - "DOMAIN_ID" : "Procedure", - "VOCABULARY_ID" : "CPT4", - "CONCEPT_CLASS_ID" : "CPT4" - }, - "isExcluded" : false, - "includeDescendants" : true, - "includeMapped" : false - } - ] - } - } - ], - "QualifiedLimit" : { - "Type" : "All" - }, - "ExpressionLimit" : { - "Type" : "All" - }, - "InclusionRules" : [], - "EndStrategy" : { - "DateOffset" : { - "DateField" : "EndDate", - "Offset" : 30 - } - }, - "CensoringCriteria" : [], - "CollapseSettings" : { - "CollapseType" : "ERA", - "EraPad" : 0 - }, - "CensorWindow" : {} -} diff --git a/docs/R_STRATEGUS_COHORT_METHODS_SHELL.md b/docs/R_STRATEGUS_COHORT_METHODS_SHELL.md index 8d7bbb2..d6e5715 100644 --- a/docs/R_STRATEGUS_COHORT_METHODS_SHELL.md +++ b/docs/R_STRATEGUS_COHORT_METHODS_SHELL.md @@ -122,11 +122,39 @@ The following directories are created under `outputDir`: - `scripts/05_diagnostics.R` - `scripts/06_cm_spec.R` + Generated scripts that connect to the database expect these site-specific files at the root of `outputDir`: -- `strategus-db-details.json` -- `strategus-execution-settings.json` +- Template `strategus-db-details.json` + +``` +{ + "dbms": "postgresql", + "DB_SERVER": "localhost", + "DB_PORT": "5432", + "DB_USER": "ohdsi", + "DB_PASS": "change_me", + "DB_DRIVER_PATH": "", + "extraSettings": "sslmode=disable" +} +``` + +- Template `strategus-execution-settings.json` + +``` +{ + "cdmDatabaseSchema": "cdm_schema", + "workDatabaseSchema": "work_schema", + "resultsDatabaseSchema": "results_schema", + "vocabularyDatabaseSchema": "vocab_schema", + "cohortTable": "cohort", + "workFolder": "demo-strategus-cohort-incidence/work", + "resultsFolder": "demo-strategus-cohort-incidence/results", + "cohortIdFieldName": "cohort_definition_id" +} +``` + Current Keeper specifics: diff --git a/docs/R_STRATEGUS_SHELL.md b/docs/R_STRATEGUS_INCIDENCE_SHELL.md similarity index 80% rename from docs/R_STRATEGUS_SHELL.md rename to docs/R_STRATEGUS_INCIDENCE_SHELL.md index 26c87f0..34d0f87 100644 --- a/docs/R_STRATEGUS_SHELL.md +++ b/docs/R_STRATEGUS_INCIDENCE_SHELL.md @@ -50,6 +50,39 @@ Current runtime expectations: - `04_keeper_review.R` uses the ACP-based Keeper workflow helper and writes `outputs/keeper_review_state.json`. - `06_incidence_spec.R` reads `analysis-settings/time_at_risk_settings.json` instead of hard-coding TAR definitions. +Generated scripts that connect to the database expect these site-specific files at the root of +`outputDir`: + +- Template `strategus-db-details.json` + +``` +{ + "dbms": "postgresql", + "DB_SERVER": "localhost", + "DB_PORT": "5432", + "DB_USER": "ohdsi", + "DB_PASS": "change_me", + "DB_DRIVER_PATH": "", + "extraSettings": "sslmode=disable" +} +``` + +- Template `strategus-execution-settings.json` + +``` +{ + "cdmDatabaseSchema": "cdm_schema", + "workDatabaseSchema": "work_schema", + "resultsDatabaseSchema": "results_schema", + "vocabularyDatabaseSchema": "vocab_schema", + "cohortTable": "cohort", + "workFolder": "demo-strategus-cohort-incidence/work", + "resultsFolder": "demo-strategus-cohort-incidence/results", + "cohortIdFieldName": "cohort_definition_id" +} +``` + + ## Notes - If improvements were applied during the shell session, the scripts are a portable record and do not need to re-apply the same changes. diff --git a/docs/TESTING.md b/docs/TESTING.md index 0771b95..b5e505f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -418,7 +418,7 @@ Using file paths: ```bash curl -s -X POST http://127.0.0.1:8765/flows/phenotype_improvements \ -H 'Content-Type: application/json' \ - -d '{"protocol_path":"demo/protocol.md","cohort_paths":["demo/1197_Acute_gastrointestinal_bleeding.json"]}' + -d '{"protocol_path":"scripts/protocol.md","cohort_paths":["scripts/1197_Acute_gastrointestinal_bleeding.json"]}' ``` Concept sets review: @@ -442,11 +442,11 @@ Using file paths: ```bash curl -s -X POST http://127.0.0.1:8765/flows/concept_sets_review \ -H 'Content-Type: application/json' \ - -d '{"concept_set_path":"demo/concept_set.json","study_intent":"Example intent"}' + -d '{"concept_set_path":"scripts/concept_set.json","study_intent":"Example intent"}' curl -s -X POST http://127.0.0.1:8765/flows/cohort_critique_general_design \ -H 'Content-Type: application/json' \ - -d '{"cohort_path":"demo/cohort_definition.json"}' + -d '{"cohort_path":"scripts/cohort_definition.json"}' ``` Phenotype validation review (single patient): diff --git a/scripts/1197_Acute_gastrointestinal_bleeding.json b/scripts/1197_Acute_gastrointestinal_bleeding.json new file mode 100644 index 0000000..ac818a4 --- /dev/null +++ b/scripts/1197_Acute_gastrointestinal_bleeding.json @@ -0,0 +1,630 @@ +{ + "cdmVersionRange" : ">=5.0.0", + "PrimaryCriteria" : { + "CriteriaList" : [ + { + "ConditionOccurrence" : { + "CodesetId" : 3, + "ConditionTypeExclude" : false + } + }, + { + "ProcedureOccurrence" : { + "CodesetId" : 3, + "ProcedureTypeExclude" : false + } + } + ], + "ObservationWindow" : { + "PriorDays" : 0, + "PostDays" : 0 + }, + "PrimaryCriteriaLimit" : { + "Type" : "All" + } + }, + "ConceptSets" : [ + { + "id" : 3, + "name" : "Gastrointestinal hemorrhage GI bleeding", + "expression" : { + "items" : [ + { + "concept" : { + "CONCEPT_ID" : 4112183, + "CONCEPT_NAME" : "Esophageal varices with bleeding, associated with another disorder", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "195475003", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4103703, + "CONCEPT_NAME" : "Melena", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "2901004", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 40482685, + "CONCEPT_NAME" : "Angiodysplasia of duodenum", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "442267002", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 192671, + "CONCEPT_NAME" : "Gastrointestinal hemorrhage", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "74474003", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 2108900, + "CONCEPT_NAME" : "Esophagogastroduodenoscopy, flexible, transoral; with control of bleeding, any method", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "43255", + "DOMAIN_ID" : "Procedure", + "VOCABULARY_ID" : "CPT4", + "CONCEPT_CLASS_ID" : "CPT4" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 28779, + "CONCEPT_NAME" : "Bleeding esophageal varices", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "17709002", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 2002608, + "CONCEPT_NAME" : "Control of hemorrhage and suture of ulcer of stomach or duodenum", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "44.4", + "DOMAIN_ID" : "Procedure", + "VOCABULARY_ID" : "ICD9Proc", + "CONCEPT_CLASS_ID" : "3-dig nonbill code" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 443530, + "CONCEPT_NAME" : "Hematochezia", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "405729008", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 2108878, + "CONCEPT_NAME" : "Esophagoscopy, flexible, transoral; with control of bleeding, any method", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "43227", + "DOMAIN_ID" : "Procedure", + "VOCABULARY_ID" : "CPT4", + "CONCEPT_CLASS_ID" : "CPT4" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4027663, + "CONCEPT_NAME" : "Peptic ulcer", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "13200003", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 198798, + "CONCEPT_NAME" : "Dieulafoy's vascular malformation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "109558001", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : false, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4138962, + "CONCEPT_NAME" : "Acute duodenal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "32490005", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4195231, + "CONCEPT_NAME" : "Acute gastric ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "67964002", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4147683, + "CONCEPT_NAME" : "Acute gastrojejunal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "30514008", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4163865, + "CONCEPT_NAME" : "Acute peptic ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "45485004", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 195584, + "CONCEPT_NAME" : "Acute peptic ulcer without hemorrhage AND without perforation but with obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "58085004", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4222896, + "CONCEPT_NAME" : "Chronic duodenal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "40214005", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4296611, + "CONCEPT_NAME" : "Chronic gastric ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "76796008", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 200769, + "CONCEPT_NAME" : "Chronic gastric ulcer without hemorrhage, without perforation AND without obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "1567007", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4177387, + "CONCEPT_NAME" : "Chronic gastrojejunal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "4269005", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 434400, + "CONCEPT_NAME" : "Chronic gastrojejunal ulcer without hemorrhage AND without perforation but with obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "56579005", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 438795, + "CONCEPT_NAME" : "Chronic gastrojejunal ulcer without hemorrhage, without perforation AND without obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "41626001", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4204555, + "CONCEPT_NAME" : "Chronic peptic ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "5492000", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 24973, + "CONCEPT_NAME" : "Chronic peptic ulcer without hemorrhage AND without perforation but with obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "12384004", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 23808, + "CONCEPT_NAME" : "Chronic peptic ulcer without hemorrhage, without perforation AND without obstruction", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "60400003", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4209746, + "CONCEPT_NAME" : "Duodenal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "56776001", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4248429, + "CONCEPT_NAME" : "Gastric ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "73481001", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4101104, + "CONCEPT_NAME" : "Gastrojejunal ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "2783007", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 197925, + "CONCEPT_NAME" : "Hemorrhage of rectum and anus", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "266464001", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + }, + { + "concept" : { + "CONCEPT_ID" : 4291028, + "CONCEPT_NAME" : "Peptic ulcer without hemorrhage AND without perforation", + "STANDARD_CONCEPT" : "S", + "STANDARD_CONCEPT_CAPTION" : "Standard", + "INVALID_REASON" : "V", + "INVALID_REASON_CAPTION" : "Valid", + "CONCEPT_CODE" : "37442009", + "DOMAIN_ID" : "Condition", + "VOCABULARY_ID" : "SNOMED", + "CONCEPT_CLASS_ID" : "Clinical Finding" + }, + "isExcluded" : true, + "includeDescendants" : true, + "includeMapped" : false + } + ] + } + } + ], + "QualifiedLimit" : { + "Type" : "All" + }, + "ExpressionLimit" : { + "Type" : "All" + }, + "InclusionRules" : [ + { + "name" : "45 day washout period ", + "expression" : { + "Type" : "ALL", + "CriteriaList" : [ + { + "Criteria" : { + "ConditionOccurrence" : { + "CodesetId" : 3, + "ConditionTypeExclude" : false + } + }, + "StartWindow" : { + "Start" : { + "Days" : 45, + "Coeff" : -1 + }, + "End" : { + "Days" : 1, + "Coeff" : -1 + }, + "UseIndexEnd" : false, + "UseEventEnd" : false + }, + "RestrictVisit" : false, + "IgnoreObservationPeriod" : false, + "Occurrence" : { + "Type" : 0, + "Count" : 0, + "IsDistinct" : false + } + }, + { + "Criteria" : { + "ProcedureOccurrence" : { + "CodesetId" : 3, + "ProcedureTypeExclude" : false + } + }, + "StartWindow" : { + "Start" : { + "Days" : 45, + "Coeff" : -1 + }, + "End" : { + "Days" : 1, + "Coeff" : -1 + }, + "UseIndexEnd" : false, + "UseEventEnd" : false + }, + "RestrictVisit" : false, + "IgnoreObservationPeriod" : false, + "Occurrence" : { + "Type" : 0, + "Count" : 0, + "IsDistinct" : false + } + } + ], + "DemographicCriteriaList" : [], + "Groups" : [] + } + } + ], + "EndStrategy" : { + "DateOffset" : { + "DateField" : "StartDate", + "Offset" : 7 + } + }, + "CensoringCriteria" : [], + "CollapseSettings" : { + "CollapseType" : "ERA", + "EraPad" : 0 + }, + "CensorWindow" : {} +} diff --git a/demo/cohort_definition.json b/scripts/cohort_definition.json similarity index 100% rename from demo/cohort_definition.json rename to scripts/cohort_definition.json diff --git a/demo/concept_set.json b/scripts/concept_set.json similarity index 100% rename from demo/concept_set.json rename to scripts/concept_set.json diff --git a/demo/protocol.md b/scripts/protocol.md similarity index 100% rename from demo/protocol.md rename to scripts/protocol.md From c05fad498b5d33a537a65fccede3c2aa48798723 Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 10:49:01 -0500 Subject: [PATCH 19/20] Bug fixes found while testing R workflow --- AGENTS.md | 11 ++- .../R/keeper_review_workflow.R | 14 +-- .../R/strategus_cohort_methods_shell.R | 43 ++++++++- .../R/strategus_incidence_shell.R | 92 +++++++++++++++---- dodo.py | 17 +++- mcp_server/study_agent_mcp/retrieval/index.py | 5 + scripts/demo_strategus_cohort_method.R | 4 +- .../test_strategus_incidence_plus_keeper.R | 2 +- tests/test_incidence_shell_selection_state.py | 41 +++++++++ 9 files changed, 189 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c54b1ba..f6c126b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ If you change R demo scripts, run parse checks with `Rscript -e "parse(file='... ## Security and data handling -- No PHI/PII should be sent to LLMs. +- No PHI/PII should *EVER* be sent to LLMs. - `phenotype_validation_review` must go through Keeper sanitization before prompt construction. - `keeper_profiles_generate` is deterministic only; any downstream LLM use still requires the sanitization gate. - Treat row-level data handling as safety-critical. Preserve fail-closed behavior. @@ -155,11 +155,12 @@ Demo shell: - [README.md](README.md) - [docs/TESTING.md](docs/TESTING.md) -- [docs/PHENOTYPE_RECOMMENDATION_DESIGN.md](docs/PHENOTYPE_RECOMMENDATION_DESIGN.md) +- [docs/WORKFLOW_PHENOTYPE_RECOMMENDATION.md](docs/WORKFLOW_PHENOTYPE_RECOMMENDATION.md) - [docs/PHENOTYPE_VALIDATION_REVIEW.md](docs/PHENOTYPE_VALIDATION_REVIEW.md) -- [docs/KEEPER_INTERFACE_SPEC.md](docs/KEEPER_INTERFACE_SPEC.md) -- [docs/STRATEGUS_SHELL.md](docs/STRATEGUS_SHELL.md) -- [docs/INCIDENCE_WORKFLOW.md](docs/INCIDENCE_WORKFLOW.md) +- [docs/SPEC_KEEPER_INTERFACE.md](docs/SPEC_KEEPER_INTERFACE.md) +- [docs/R_STRATEGUS_INCIDENCE_SHELL.md](docs/R_STRATEGUS_INCIDENCE_SHELL.md) +- [docs/R_STRATEGUS_COHORT_METHODS_SHELL.md](docs/R_STRATEGUS_COHORT_METHODS_SHELL.md) +- [docs/WORKFLOW_INCIDENCE.md](docs/WORKFLOW_INCIDENCE.md) - [docs/ROADMAP.md](docs/ROADMAP.md) - [docs/R_PACKAGE_ARCHITECTURE_PLAN.md](docs/R_PACKAGE_ARCHITECTURE_PLAN.md) diff --git a/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R b/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R index 1baa7a2..c1057af 100644 --- a/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R +++ b/R/slashOhdsiStrategusAssistant/R/keeper_review_workflow.R @@ -36,17 +36,17 @@ runKeeperReviewWorkflow <- function(base_dir, stage_callback = NULL, domain_keys = c( "doi", - "alternativeDiagnosis", - "symptoms", + #"alternativeDiagnosis", + #"symptoms", "drugs", - "diagnosticProcedures", - "measurements", - "treatmentProcedures", + #"diagnosticProcedures", + #"measurements", + #"treatmentProcedures", "complications" ), - candidate_limit = 50, + candidate_limit = 10, min_record_count = NULL, - sample_size = 20, + sample_size = 5, review_row_limit = 5, include_diagnostics = TRUE, use_descendants = TRUE, diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R index 0a0b7b7..699c067 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_cohort_methods_shell.R @@ -5485,6 +5485,47 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me comparator_ids = as.integer(new_comparator_id), outcome_ids = as.integer(new_outcome_ids) ) + seed_strategus_runtime_templates <- function(base_dir) { + db_details_path <- file.path(base_dir, "strategus-db-details.json") + execution_settings_path <- file.path(base_dir, "strategus-execution-settings.json") + + if (!file.exists(db_details_path)) { + write_json(list( + dbms = "postgresql", + DB_SERVER = "", + DB_PORT = "5432", + DB_USER = "", + DB_PASS = "", + DB_DRIVER_PATH = "", + extraSettings = "sslmode=disable" + ), db_details_path) + } + + if (!file.exists(execution_settings_path)) { + write_json(list( + cdmDatabaseSchema = "", + workDatabaseSchema = "", + resultsDatabaseSchema = "", + vocabularyDatabaseSchema = "", + cohortTable = "cohort", + workFolder = file.path(base_dir, "work"), + resultsFolder = file.path(base_dir, "results"), + cohortIdFieldName = "cohort_definition_id", + maxCores = 4 + ), execution_settings_path) + } + + list( + db_details_path = db_details_path, + execution_settings_path = execution_settings_path + ) + } + + runtime_template_paths <- seed_strategus_runtime_templates(base_dir) + db_details_path <- runtime_template_paths$db_details_path + execution_settings_path <- runtime_template_paths$execution_settings_path + state$strategus_db_details_path <- db_details_path + state$strategus_execution_settings_path <- execution_settings_path write_json(state, state_path) keeper_review_state_path <- file.path(output_dir, "keeper_review_state.json") @@ -5538,7 +5579,7 @@ runStrategusCohortMethodsShell <- function(outputDir = "demo-strategus-cohort-me keeper_review_result <- tryCatch( runKeeperReviewWorkflow( base_dir = base_dir, - execution_settings_path = file.path(base_dir, "strategus-execution-settings.json"), + execution_settings_path = execution_settings_path, cohort_id_map_path = cohort_id_map_path, cohort_roles_path = cohort_roles_path, intent_path = cohort_methods_intent_split_path, diff --git a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R index 85a0d83..d80039d 100644 --- a/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R +++ b/R/slashOhdsiStrategusAssistant/R/strategus_incidence_shell.R @@ -21,7 +21,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide studyIntent = NULL, topK = 20, maxResults = 20, - candidateLimit = 20, + candidateLimit = 5, indexDir = Sys.getenv("PHENOTYPE_INDEX_DIR", "data/phenotype_index"), interactive = TRUE, bannerPath = "ohdsi-logo-ascii.txt", @@ -1212,6 +1212,48 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide improvements_applied = improvements_applied ) state_path <- file.path(output_dir, "study_agent_state.json") + + seed_strategus_runtime_templates <- function(base_dir) { + db_details_path <- file.path(base_dir, "strategus-db-details.json") + execution_settings_path <- file.path(base_dir, "strategus-execution-settings.json") + + if (!file.exists(db_details_path)) { + write_json(list( + dbms = "postgresql", + DB_SERVER = "", + DB_PORT = "5432", + DB_USER = "", + DB_PASS = "", + DB_DRIVER_PATH = "", + extraSettings = "sslmode=disable" + ), db_details_path) + } + + if (!file.exists(execution_settings_path)) { + write_json(list( + cdmDatabaseSchema = "", + workDatabaseSchema = "", + resultsDatabaseSchema = "", + vocabularyDatabaseSchema = "", + cohortTable = "cohort", + workFolder = file.path(base_dir, "work"), + resultsFolder = file.path(base_dir, "results"), + cohortIdFieldName = "cohort_definition_id", + maxCores = 4 + ), execution_settings_path) + } + + list( + db_details_path = db_details_path, + execution_settings_path = execution_settings_path + ) + } + + runtime_template_paths <- seed_strategus_runtime_templates(base_dir) + db_details_path <- runtime_template_paths$db_details_path + execution_settings_path <- runtime_template_paths$execution_settings_path + state$strategus_db_details_path <- db_details_path + state$strategus_execution_settings_path <- execution_settings_path write_json(state, state_path) keeper_review_state_path <- file.path(output_dir, "keeper_review_state.json") @@ -1225,7 +1267,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide keeper_review_result <- NULL if (isTRUE(interactive)) { - run_keeper_review_now <- prompt_yesno("Run ACP-based Keeper review now?", default = FALSE) + run_keeper_review_now <- prompt_yesno(paste("Run Keeper review now? (first edit db/execution conf ", db_details_path, ",", execution_settings_path, ") [y/N]"), default = FALSE) if (isTRUE(run_keeper_review_now)) { entered_roles <- trimws(readline_with_dialogue("Keeper review roles [outcome]: ")) keeper_review_roles <- if (!nzchar(entered_roles)) "outcome" else trimws(strsplit(entered_roles, ",", fixed = TRUE)[[1]]) @@ -1263,7 +1305,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide keeper_review_result <- tryCatch( runKeeperReviewWorkflow( base_dir = base_dir, - execution_settings_path = file.path(base_dir, "strategus-execution-settings.json"), + execution_settings_path = execution_settings_path, cohort_id_map_path = file.path(output_dir, "cohort_id_map.json"), cohort_roles_path = roles_path, intent_path = intent_split_path, @@ -1544,9 +1586,11 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide "sql_dir <- file.path(selected_dir, 'sql')", "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", "", - "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", "dbms <- connectionDetails$dbms %||% 'postgresql'", - "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings()", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", "executionSettings_cohorts <- exec$executionSettings", "cdmDatabaseSchema <- exec$cdmDatabaseSchema", "workDatabaseSchema <- exec$workDatabaseSchema", @@ -1634,13 +1678,12 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide "# Edit these defaults as needed before running the ACP-based Keeper workflow.", "review_roles <- c('outcome')", "domain_keys <- c(", - " 'doi', 'alternativeDiagnosis', 'symptoms', 'drugs',", - " 'diagnosticProcedures', 'measurements', 'treatmentProcedures', 'complications'", - ")", - "candidate_limit <- 50", - "sample_size <- 20", + " 'doi', 'drugs'", + ") # NOTE: you could also add 'alternativeDiagnosis', 'symptoms', 'diagnosticProcedures', 'measurements', 'treatmentProcedures', and 'complications' but need to increase the ACP_TIMOUT env variable 3-5 minutes per domain", + "candidate_limit <- 5", + "sample_size <- 5", "review_row_limit <- 5", - "acp_timeout_seconds <- as.numeric(Sys.getenv('ACP_TIMEOUT', '300'))", + "acp_timeout_seconds <- as.numeric(Sys.getenv('ACP_TIMEOUT', '600'))", "Sys.setenv(ACP_TIMEOUT = as.character(acp_timeout_seconds))", "reuse_generated_concept_sets <- TRUE", "overwrite_approved_concept_sets <- FALSE", @@ -1658,7 +1701,7 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide " acp_url = acp_url,", " acp_timeout_seconds = acp_timeout_seconds,", " review_roles = review_roles,", - " domain_keys = domain_keys,", + " domain_keys = domain_keys, # NOTE: full set of options are as follows but set the ACP_TIMOUT to be > 10 minutes before attempting all of them: doi, alternativeDiagnosis, symptoms, drugs, diagnosticProcedures, measurements, treatmentProcedures, complications", " candidate_limit = candidate_limit,", " sample_size = sample_size,", " review_row_limit = review_row_limit,", @@ -1707,8 +1750,10 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide "sql_dir <- file.path(selected_dir, 'sql')", "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", "", - "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", - "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path='')", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", "executionSettings_diagnostics <- exec$executionSettings", "", "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", @@ -1781,8 +1826,10 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide "sql_dir <- file.path(selected_dir, 'sql')", "dir.create(sql_dir, recursive = TRUE, showWarnings = FALSE)", "", - "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path='')", - "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path='')", + "db_details_path <- file.path(base_dir, 'strategus-db-details.json')", + "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')", + "connectionDetails <- slashOhdsiStrategusAssistant::createStrategusConnectionDetails(path = db_details_path)", + "exec <- slashOhdsiStrategusAssistant::createStrategusExecutionSettings(path = execution_settings_path)", "executionSettings_incidence <- exec$executionSettings", "", "cohortDefinitionSet <- CohortGenerator::getCohortDefinitionSet(", @@ -1792,21 +1839,26 @@ runStrategusIncidenceShell <- function(outputDir = "demo-strategus-cohort-incide ")", "", "roles <- jsonlite::fromJSON(file.path(output_dir, 'cohort_roles.json'), simplifyVector = TRUE)", - "target_ids <- as.character(roles$targets %||% character(0))", - "outcome_ids <- as.character(roles$outcomes %||% character(0))", + "target_ids <- suppressWarnings(as.integer(unlist(roles$targets %||% integer(0), use.names = FALSE)))", + "outcome_ids <- suppressWarnings(as.integer(unlist(roles$outcomes %||% integer(0), use.names = FALSE)))", + "target_ids <- target_ids[!is.na(target_ids)]", + "outcome_ids <- outcome_ids[!is.na(outcome_ids)]", "if (length(target_ids) == 0) stop('No target cohorts defined in cohort_roles.json')", "if (length(outcome_ids) == 0) stop('No outcome cohorts defined in cohort_roles.json')", + "cohortDefinitionSet$cohortId <- suppressWarnings(as.integer(cohortDefinitionSet$cohortId))", "cgModule <- CohortGeneratorModule$new()", "cohortDefinitionSharedResource <- cgModule$createCohortSharedResourceSpecifications(", " cohortDefinitionSet = cohortDefinitionSet", ")", "targets <- lapply(target_ids, function(id) {", " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", - " CohortIncidence::createCohortRef(id = id, name = row$cohortName[1])", + " if (nrow(row) == 0) stop('Target cohort id not found in Cohorts.csv: ', id)", + " CohortIncidence::createCohortRef(id = as.integer(id), name = row$cohortName[1])", "})", "outcomes <- lapply(outcome_ids, function(id) {", " row <- cohortDefinitionSet[cohortDefinitionSet$cohortId == id, ]", - " CohortIncidence::createOutcomeDef(id = id, name = row$cohortName[1])", + " if (nrow(row) == 0) stop('Outcome cohort id not found in Cohorts.csv: ', id)", + " CohortIncidence::createOutcomeDef(id = as.integer(id), name = row$cohortName[1])", "})", "tar_settings <- jsonlite::fromJSON(time_at_risk_settings_path, simplifyVector = FALSE)", "tar_defs <- tar_settings$time_at_risk_defs %||% list()", diff --git a/dodo.py b/dodo.py index 007e833..a948961 100644 --- a/dodo.py +++ b/dodo.py @@ -5,6 +5,7 @@ import socket import subprocess import time +import urllib.error import urllib.request from urllib.parse import urlparse @@ -714,9 +715,12 @@ def _run_smoke() -> None: require_mcp = bool(env.get("STUDY_AGENT_MCP_URL") or env.get("STUDY_AGENT_MCP_COMMAND")) _wait_for_acp("http://127.0.0.1:8765/health", timeout_s=30, require_mcp=require_mcp) print("Running cohort critique flow smoke test...") + cohort_path = os.path.join(os.path.dirname(__file__), "scripts", "cohort_definition.json") + with open(cohort_path, "r", encoding="utf-8") as handle: + cohort = json.load(handle) payload = json.dumps( { - "cohort_path": "demo/cohort_definition.json", + "cohort": cohort, } ).encode("utf-8") req = urllib.request.Request( @@ -725,9 +729,14 @@ def _run_smoke() -> None: method="POST", ) req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=int(env.get("ACP_TIMEOUT", "180"))) as response: - body = response.read().decode("utf-8") - print(body) + try: + with urllib.request.urlopen(req, timeout=int(env.get("ACP_TIMEOUT", "180"))) as response: + body = response.read().decode("utf-8") + print(body) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + print(error_body) + raise print(f"ACP logs: {acp_stdout} {acp_stderr}") finally: print("Stopping ACP...") diff --git a/mcp_server/study_agent_mcp/retrieval/index.py b/mcp_server/study_agent_mcp/retrieval/index.py index 1d7a075..437c1c0 100644 --- a/mcp_server/study_agent_mcp/retrieval/index.py +++ b/mcp_server/study_agent_mcp/retrieval/index.py @@ -265,8 +265,13 @@ def search( { "phenotype_id": row.get("phenotype_id"), "source_dataset": row.get("source_dataset"), + "source_record_type": row.get("source_record_type"), "name": row.get("name"), "short_description": row.get("short_description"), + "primary_clinical_topic": row.get("primary_clinical_topic") or "", + "phenotype_role": row.get("phenotype_role") or "unknown", + "care_setting_scope": row.get("care_setting_scope") or "unspecified", + "population_scope": row.get("population_scope") or "", "tags": row.get("tags") or [], "signals": row.get("signals") or [], "executable_definition_status": row.get("executable_definition_status"), diff --git a/scripts/demo_strategus_cohort_method.R b/scripts/demo_strategus_cohort_method.R index 805a7bd..d2ba046 100644 --- a/scripts/demo_strategus_cohort_method.R +++ b/scripts/demo_strategus_cohort_method.R @@ -20,7 +20,7 @@ Sys.setenv(PHENOTYPE_INDEX_DIR = repo_file("data", "phenotype_index_cipher_omop" invisible(connect_study_agent_acp()) ### Optional reset from a prior run. -# reset_demo_output_dir(repo_file("demo-strategus-cohort-method"), prompt = TRUE) +#reset_demo_output_dir(repo_file("demo-strategus-cohort-method"), prompt = TRUE) # # If you already ran `scripts/test_strategus_incidence_plus_keeper.R`, this shell can # reuse cached target and outcome artifacts from `demo-strategus-cohort-incidence`. @@ -31,7 +31,7 @@ slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell( indexDir = "data/phenotype_index_cipher_omop", incidenceOutputDir = "demo-strategus-cohort-incidence", studyIntent = paste( - "Compare sitagliptin new users vs glipizide new users for acute myocardial infarction.", + "Compare olanzapine new users vs risperidone new users for acute myocardial infarction.", "Use a 365-day washout, intent-to-treat follow-up, 1:1 propensity score matching", "on standardized logit with a caliper of 0.2, and a Cox model." ) diff --git a/scripts/test_strategus_incidence_plus_keeper.R b/scripts/test_strategus_incidence_plus_keeper.R index 36ddb0e..c0bfce3 100644 --- a/scripts/test_strategus_incidence_plus_keeper.R +++ b/scripts/test_strategus_incidence_plus_keeper.R @@ -15,7 +15,7 @@ repo_root <- set_study_agent_repo_root(start = dirname(script_dir)) load_study_agent_r_packages(include_strategus = TRUE) ## Optional reset from a prior run. -# reset_demo_output_dir(repo_file("demo-strategus-cohort-incidence"), prompt = TRUE) +reset_demo_output_dir(repo_file("demo-strategus-cohort-incidence"), prompt = TRUE) Sys.setenv(ACP_TIMEOUT = "280") invisible(connect_study_agent_acp()) diff --git a/tests/test_incidence_shell_selection_state.py b/tests/test_incidence_shell_selection_state.py index cb1a9f4..0d35723 100644 --- a/tests/test_incidence_shell_selection_state.py +++ b/tests/test_incidence_shell_selection_state.py @@ -56,6 +56,11 @@ def test_generated_incidence_script_uses_persisted_time_at_risk_settings() -> No assert "tar_defs <- tar_settings$time_at_risk_defs %||% list()" in block assert "CohortIncidence::createTimeAtRiskDef(" in block assert "analysis_tar_ids <- as.numeric(unlist(tar_settings$analysis_tar_ids %||% lapply(tar_defs, function(def) def$id), use.names = FALSE))" in block + assert "target_ids <- suppressWarnings(as.integer(unlist(roles$targets %||% integer(0), use.names = FALSE)))" in block + assert "outcome_ids <- suppressWarnings(as.integer(unlist(roles$outcomes %||% integer(0), use.names = FALSE)))" in block + assert "cohortDefinitionSet$cohortId <- suppressWarnings(as.integer(cohortDefinitionSet$cohortId))" in block + assert "CohortIncidence::createCohortRef(id = as.integer(id), name = row$cohortName[1])" in block + assert "CohortIncidence::createOutcomeDef(id = as.integer(id), name = row$cohortName[1])" in block assert "strata_args <- tar_settings$strata_settings %||% list()" in block assert 'strataSettings <- do.call(CohortIncidence::createStrataSettings, strata_args)' in block assert 'tars = analysis_tar_ids' in block @@ -63,3 +68,39 @@ def test_generated_incidence_script_uses_persisted_time_at_risk_settings() -> No assert "CohortIncidence::createTimeAtRiskDef(id = 2, startWith = 'start', endWith = 'start', endOffset = 365)" not in block assert 'tars = c(1, 2)' not in block assert 'createStrataSettings(byYear = TRUE, byGender = TRUE)' not in block + + +def test_shell_seeds_runtime_templates_and_generated_scripts_use_them() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert 'seed_strategus_runtime_templates <- function(base_dir)' in source + assert 'strategus-db-details.json' in source + assert 'strategus-execution-settings.json' in source + assert 'execution_settings_path = execution_settings_path,' in source + + script03_start = source.index('script_03 <- c(') + script03_end = source.index('write_lines(file.path(scripts_dir, "03_generate_cohorts.R")', script03_start) + script03 = source[script03_start:script03_end] + assert "db_details_path <- file.path(base_dir, 'strategus-db-details.json')" in script03 + assert "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')" in script03 + assert "createStrategusConnectionDetails(path = db_details_path)" in script03 + assert "createStrategusExecutionSettings(path = execution_settings_path)" in script03 + assert "" not in script03 + + script05_start = source.index('script_05 <- c(') + script05_end = source.index('write_lines(file.path(scripts_dir, "05_diagnostics.R")', script05_start) + script05 = source[script05_start:script05_end] + assert "db_details_path <- file.path(base_dir, 'strategus-db-details.json')" in script05 + assert "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')" in script05 + assert "createStrategusConnectionDetails(path = db_details_path)" in script05 + assert "createStrategusExecutionSettings(path = execution_settings_path)" in script05 + assert "" not in script05 + + script06_start = source.index('script_06 <- c(') + script06_end = source.index('write_lines(file.path(scripts_dir, "06_incidence_spec.R")', script06_start) + script06 = source[script06_start:script06_end] + assert "db_details_path <- file.path(base_dir, 'strategus-db-details.json')" in script06 + assert "execution_settings_path <- file.path(base_dir, 'strategus-execution-settings.json')" in script06 + assert "createStrategusConnectionDetails(path = db_details_path)" in script06 + assert "createStrategusExecutionSettings(path = execution_settings_path)" in script06 + assert "" not in script06 From e1f8f61b3166770946160700e9d71dc8b3296ebb Mon Sep 17 00:00:00 2001 From: rkboyce Date: Tue, 12 May 2026 11:24:17 -0500 Subject: [PATCH 20/20] small change to demo script --- scripts/demo_strategus_cohort_method.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/demo_strategus_cohort_method.R b/scripts/demo_strategus_cohort_method.R index d2ba046..285a4ea 100644 --- a/scripts/demo_strategus_cohort_method.R +++ b/scripts/demo_strategus_cohort_method.R @@ -31,7 +31,7 @@ slashOhdsiStrategusAssistant::runStrategusCohortMethodsShell( indexDir = "data/phenotype_index_cipher_omop", incidenceOutputDir = "demo-strategus-cohort-incidence", studyIntent = paste( - "Compare olanzapine new users vs risperidone new users for acute myocardial infarction.", + "Compare thiazide or thiazide-like diuretic new users vs angiotensin-converting enzyme inhibitors new users for acute myocardial infarction.", "Use a 365-day washout, intent-to-treat follow-up, 1:1 propensity score matching", "on standardized logit with a caliper of 0.2, and a Cox model." )