Spaces:
Runtime error
Runtime error
| """Command-line interface for running assessments and exporting reports.""" | |
| import json | |
| from datetime import datetime | |
| from pathlib import Path | |
| import hydra | |
| from hydra.utils import to_absolute_path | |
| from omegaconf import DictConfig | |
| from sentinel.config import AppConfig, ModelConfig, ResourcePaths | |
| from sentinel.factory import SentinelFactory | |
| from sentinel.models import ( | |
| ConversationResponse, | |
| InitialAssessment, | |
| ) | |
| from sentinel.reporting import generate_excel_report, generate_pdf_report | |
| from sentinel.risk_models import RISK_MODELS | |
| from sentinel.user_input import ( | |
| Demographics, | |
| FamilyMemberCancer, | |
| FemaleSpecific, | |
| Lifestyle, | |
| PersonalMedicalHistory, | |
| UserInput, | |
| ) | |
| from sentinel.utils import load_user_file | |
| # Color codes for terminal output | |
| class Colors: | |
| """ANSI color codes for terminal output formatting.""" | |
| HEADER = "\033[95m" | |
| OKBLUE = "\033[94m" | |
| OKCYAN = "\033[96m" | |
| OKGREEN = "\033[92m" | |
| WARNING = "\033[93m" | |
| FAIL = "\033[91m" | |
| ENDC = "\033[0m" | |
| BOLD = "\033[1m" | |
| UNDERLINE = "\033[4m" | |
| def _get_input(prompt: str, optional: bool = False) -> str: | |
| """Get a line of input from the user. | |
| Args: | |
| prompt: Message to display to the user. | |
| optional: If True, allow empty input to be returned as an empty string. | |
| Returns: | |
| The raw string entered by the user (may be empty if optional). | |
| """ | |
| suffix = " (optional, press Enter to skip)" if optional else "" | |
| return input(f"{Colors.OKCYAN}{prompt}{suffix}:{Colors.ENDC} ") | |
| def _get_int_input(prompt: str, optional: bool = False) -> int | None: | |
| """Get an integer from the user. | |
| Args: | |
| prompt: Message to display to the user. | |
| optional: If True, allow empty input and return None. | |
| Returns: | |
| The parsed integer value, or None if optional and left empty. | |
| """ | |
| while True: | |
| val = _get_input(prompt, optional) | |
| if not val and optional: | |
| return None | |
| try: | |
| return int(val) | |
| except (ValueError, TypeError): | |
| print(f"{Colors.WARNING}Please enter a valid number.{Colors.ENDC}") | |
| def collect_user_input() -> UserInput: | |
| """Collect user profile data interactively. | |
| Returns: | |
| UserInput: Structured demographics, lifestyle, and clinical data | |
| assembled from CLI prompts. | |
| """ | |
| print( | |
| f"\n{Colors.HEADER}{Colors.BOLD}=== User Information Collection ==={Colors.ENDC}" | |
| ) | |
| print("Please provide the following details for your assessment.") | |
| # --- DEMOGRAPHICS --- | |
| print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Demographics ---{Colors.ENDC}") | |
| age = _get_int_input("Age") | |
| sex = _get_input("Biological Sex (e.g., Male, Female)") | |
| ethnicity = _get_input("Ethnicity", optional=True) | |
| demographics = Demographics(age=age, sex=sex, ethnicity=ethnicity) | |
| # --- LIFESTYLE --- | |
| print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Lifestyle ---{Colors.ENDC}") | |
| smoking_status = _get_input("Smoking Status (e.g., never, former, current)") | |
| smoking_pack_years = ( | |
| _get_int_input("Smoking Pack-Years", optional=True) | |
| if smoking_status in ["former", "current"] | |
| else None | |
| ) | |
| alcohol_consumption = _get_input( | |
| "Alcohol Consumption (e.g., none, light, moderate, heavy)" | |
| ) | |
| dietary_habits = _get_input("Dietary Habits", optional=True) | |
| physical_activity_level = _get_input("Physical Activity Level", optional=True) | |
| lifestyle = Lifestyle( | |
| smoking_status=smoking_status, | |
| smoking_pack_years=smoking_pack_years, | |
| alcohol_consumption=alcohol_consumption, | |
| dietary_habits=dietary_habits, | |
| physical_activity_level=physical_activity_level, | |
| ) | |
| # --- PERSONAL MEDICAL HISTORY --- | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}--- Personal Medical History ---{Colors.ENDC}" | |
| ) | |
| mutations = _get_input("Known genetic mutations (comma-separated)", optional=True) | |
| cancers = _get_input("Previous cancers (comma-separated)", optional=True) | |
| illnesses = _get_input( | |
| "Chronic illnesses (e.g., IBD, comma-separated)", optional=True | |
| ) | |
| personal_medical_history = PersonalMedicalHistory( | |
| known_genetic_mutations=[m.strip() for m in mutations.split(",")] | |
| if mutations | |
| else [], | |
| previous_cancers=[c.strip() for c in cancers.split(",")] if cancers else [], | |
| chronic_illnesses=[i.strip() for i in illnesses.split(",")] | |
| if illnesses | |
| else [], | |
| ) | |
| # --- CLINICAL OBSERVATIONS --- | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}--- Clinical Observations / Test Results (Optional) ---{Colors.ENDC}" | |
| ) | |
| clinical_observations = [] | |
| while True: | |
| add_test = _get_input( | |
| "Add a clinical observation or test result? (y/N)" | |
| ).lower() | |
| if add_test not in ["y", "yes"]: | |
| break | |
| test_name = _get_input("Test/Observation Name") | |
| value = _get_input("Value") | |
| unit = _get_input("Unit (e.g., ng/mL, or N/A)") | |
| reference_range = _get_input("Reference Range", optional=True) | |
| date = _get_input("Date of Test (YYYY-MM-DD)", optional=True) | |
| clinical_observations.append( | |
| { | |
| "test_name": test_name, | |
| "value": value, | |
| "unit": unit, | |
| "reference_range": reference_range or None, | |
| "date": date or None, | |
| } | |
| ) | |
| # --- FAMILY HISTORY --- | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}--- Family History of Cancer ---{Colors.ENDC}" | |
| ) | |
| family_history = [] | |
| while True: | |
| add_relative = _get_input("Add a family member with cancer? (y/N)").lower() | |
| if add_relative not in ["y", "yes"]: | |
| break | |
| relative = _get_input("Relative (e.g., mother, sister)") | |
| cancer_type = _get_input("Cancer Type") | |
| age_at_diagnosis = _get_int_input("Age at Diagnosis", optional=True) | |
| family_history.append( | |
| FamilyMemberCancer( | |
| relative=relative, | |
| cancer_type=cancer_type, | |
| age_at_diagnosis=age_at_diagnosis, | |
| ) | |
| ) | |
| # --- FEMALE-SPECIFIC --- | |
| female_specific = None | |
| if sex.lower() == "female": | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}--- Female-Specific Information ---{Colors.ENDC}" | |
| ) | |
| age_at_first_period = _get_int_input("Age at first period", optional=True) | |
| age_at_menopause = _get_int_input("Age at menopause", optional=True) | |
| num_live_births = _get_int_input("Number of live births", optional=True) | |
| age_at_first_live_birth = _get_int_input( | |
| "Age at first live birth", optional=True | |
| ) | |
| hormone_therapy_use = _get_input("Hormone therapy use", optional=True) | |
| female_specific = FemaleSpecific( | |
| age_at_first_period=age_at_first_period, | |
| age_at_menopause=age_at_menopause, | |
| num_live_births=num_live_births, | |
| age_at_first_live_birth=age_at_first_live_birth, | |
| hormone_therapy_use=hormone_therapy_use, | |
| ) | |
| # --- CURRENT CONCERNS --- | |
| print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Current Concerns ---{Colors.ENDC}") | |
| current_concerns_or_symptoms = _get_input( | |
| "Current symptoms or health concerns", optional=True | |
| ) | |
| return UserInput( | |
| demographics=demographics, | |
| lifestyle=lifestyle, | |
| family_history=family_history, | |
| personal_medical_history=personal_medical_history, | |
| female_specific=female_specific, | |
| current_concerns_or_symptoms=current_concerns_or_symptoms, | |
| clinical_observations=clinical_observations, | |
| ) | |
| def format_risk_assessment(response: InitialAssessment, dev_mode: bool = False) -> None: | |
| """Pretty-print an initial risk assessment payload. | |
| Args: | |
| response (InitialAssessment): Parsed result returned by the assessment | |
| chain. | |
| dev_mode (bool): Flag enabling verbose debugging output. | |
| """ | |
| # In dev mode, show everything | |
| if dev_mode: | |
| print( | |
| f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: RAW MODEL OUTPUT ---{Colors.ENDC}" | |
| ) | |
| # Use model_dump instead of model_dump_json for direct printing | |
| print(json.dumps(response.model_dump(), indent=2)) | |
| print( | |
| f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: PARSED & VALIDATED PYDANTIC OBJECT ---{Colors.ENDC}" | |
| ) | |
| if response.thinking: | |
| print( | |
| f"{Colors.OKCYAN}{Colors.BOLD}π€ Chain of Thought (`<think>` block):{Colors.ENDC}" | |
| ) | |
| print(response.thinking) | |
| print(f"{Colors.WARNING}{Colors.BOLD}{'-' * 30}{Colors.ENDC}") | |
| if response.reasoning: | |
| print( | |
| f"{Colors.OKCYAN}{Colors.BOLD}π§ Reasoning (`<reasoning>` block):{Colors.ENDC}" | |
| ) | |
| print(response.reasoning) | |
| print(f"{Colors.WARNING}{Colors.BOLD}{'-' * 30}{Colors.ENDC}") | |
| print(f"{Colors.OKCYAN}{Colors.BOLD}Full Pydantic Object:{Colors.ENDC}") | |
| # return | |
| print( | |
| f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: FORMATTED MODEL OUTPUT ---{Colors.ENDC}" | |
| ) | |
| # User-friendly formatting | |
| print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 60}") | |
| print("π₯ CANCER RISK ASSESSMENT REPORT") | |
| print(f"{'=' * 60}{Colors.ENDC}") | |
| # Display the primary user-facing response first | |
| if response.response: | |
| print(f"\n{Colors.OKCYAN}{Colors.BOLD}π€ BiOS:{Colors.ENDC}") | |
| print(response.response) | |
| # Then display the structured summary and details | |
| print(f"\n{Colors.OKBLUE}{Colors.BOLD}π OVERALL SUMMARY{Colors.ENDC}") | |
| if response.overall_risk_score is not None: | |
| print( | |
| f"{Colors.OKCYAN}Overall Risk Score: {Colors.BOLD}{response.overall_risk_score}/100{Colors.ENDC}" | |
| ) | |
| if response.overall_summary: | |
| print(f"{Colors.OKCYAN}{response.overall_summary}{Colors.ENDC}") | |
| # Risk assessments | |
| risk_assessments = response.risk_assessments | |
| if risk_assessments: | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}π― DETAILED RISK ASSESSMENTS{Colors.ENDC}" | |
| ) | |
| print(f"{Colors.OKBLUE}{'β' * 40}{Colors.ENDC}") | |
| for i, assessment in enumerate(risk_assessments, 1): | |
| cancer_type = assessment.cancer_type | |
| risk_level = assessment.risk_level | |
| explanation = assessment.explanation | |
| # Color code risk levels | |
| if risk_level is None: | |
| risk_color = Colors.ENDC | |
| elif risk_level <= 2: | |
| risk_color = Colors.OKGREEN | |
| elif risk_level == 3: | |
| risk_color = Colors.WARNING | |
| else: # 4-5 | |
| risk_color = Colors.FAIL | |
| print(f"\n{Colors.BOLD}{i}. {cancer_type.upper()}{Colors.ENDC}") | |
| print( | |
| f" ποΈ Risk Level: {risk_color}{Colors.BOLD}{risk_level or 'N/A'}{Colors.ENDC}" | |
| ) | |
| print(f" π Explanation: {explanation}") | |
| # Optional fields | |
| if assessment.recommended_steps: | |
| print(" π Recommended Steps:") | |
| if isinstance(assessment.recommended_steps, list): | |
| for step in assessment.recommended_steps: | |
| print(f" β’ {step}") | |
| else: | |
| print(f" β’ {assessment.recommended_steps}") | |
| if assessment.lifestyle_advice: | |
| print(f" π Lifestyle Advice: {assessment.lifestyle_advice}") | |
| if i < len(risk_assessments): | |
| print(f" {Colors.OKBLUE}{'β' * 40}{Colors.ENDC}") | |
| # Diagnostic recommendations | |
| dx_recommendations = response.dx_recommendations | |
| if dx_recommendations: | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}π¬ DIAGNOSTIC RECOMMENDATIONS{Colors.ENDC}" | |
| ) | |
| print(f"{Colors.OKBLUE}{'β' * 40}{Colors.ENDC}") | |
| for i, dx_rec in enumerate(dx_recommendations, 1): | |
| test_name = dx_rec.test_name | |
| frequency = dx_rec.frequency | |
| rationale = dx_rec.rationale | |
| recommendation_level = dx_rec.recommendation_level | |
| level_text = "" | |
| if recommendation_level is not None: | |
| level_map = { | |
| 1: "Unsuitable", | |
| 2: "Unnecessary", | |
| 3: "Optional", | |
| 4: "Recommended", | |
| 5: "Critical - Do not skip", | |
| } | |
| level_text = f" ({level_map.get(recommendation_level, 'Unknown')})" | |
| print(f"\n{Colors.BOLD}{i}. {test_name.upper()}{Colors.ENDC}") | |
| if recommendation_level is not None: | |
| print( | |
| f" β Recommendation Level: {Colors.BOLD}{recommendation_level}/5{level_text}{Colors.ENDC}" | |
| ) | |
| print(f" π Frequency: {Colors.OKGREEN}{frequency}{Colors.ENDC}") | |
| print(f" π Rationale: {rationale}") | |
| if dx_rec.applicable_guideline: | |
| print(f" π Applicable Guideline: {dx_rec.applicable_guideline}") | |
| if i < len(dx_recommendations): | |
| print(f" {Colors.OKBLUE}{'β' * 40}{Colors.ENDC}") | |
| print( | |
| f"\n{Colors.WARNING}β οΈ IMPORTANT: This assessment does not replace professional medical advice.{Colors.ENDC}" | |
| ) | |
| print(f"{Colors.HEADER}{'=' * 60}{Colors.ENDC}") | |
| def format_followup_response( | |
| response: ConversationResponse, dev_mode: bool = False | |
| ) -> None: | |
| """Display follow-up conversation output. | |
| Args: | |
| response (ConversationResponse): Conversation exchange returned by the | |
| LLM chain. | |
| dev_mode (bool): Flag enabling verbose debugging output. | |
| """ | |
| if dev_mode: | |
| print( | |
| f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: RAW MODEL OUTPUT ---{Colors.ENDC}" | |
| ) | |
| # Use model_dump instead of model_dump_json for direct printing | |
| print(json.dumps(response.model_dump(), indent=2)) | |
| print( | |
| f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: PARSED RESPONSE ---{Colors.ENDC}" | |
| ) | |
| if response.thinking: | |
| print(f"\n{Colors.OKCYAN}{Colors.BOLD}π€ Chain of Thought:{Colors.ENDC}") | |
| print(f"{Colors.OKCYAN}{response.thinking}{Colors.ENDC}") | |
| print(f"\n{Colors.OKCYAN}{Colors.BOLD}π€ BiOS:{Colors.ENDC}") | |
| print(f"{response.response}") | |
| def main(cfg: DictConfig) -> None: | |
| """Entry point for the CLI tool invoked via Hydra. | |
| Args: | |
| cfg (DictConfig): Hydra configuration containing model, knowledge base, | |
| and runtime settings. | |
| """ | |
| print( | |
| f"{Colors.HEADER}{Colors.BOLD}Welcome to the Cancer Risk Assessment Tool{Colors.ENDC}" | |
| ) | |
| print( | |
| f"{Colors.OKBLUE}This tool provides preliminary cancer risk assessments based on your input.{Colors.ENDC}\n" | |
| ) | |
| dev_mode = cfg.dev_mode | |
| if dev_mode: | |
| print( | |
| f"{Colors.WARNING}π§ Running in developer mode - raw JSON output enabled{Colors.ENDC}" | |
| ) | |
| else: | |
| print( | |
| f"{Colors.OKGREEN}π€ Running in user mode - formatted output enabled{Colors.ENDC}" | |
| ) | |
| model = cfg.model.model_name | |
| provider = cfg.model.provider | |
| print(f"{Colors.OKBLUE}π€ Using model: {model} from {provider}{Colors.ENDC}") | |
| # Create ResourcePaths with resolved absolute paths | |
| knowledge_base_paths = ResourcePaths( | |
| persona=Path(to_absolute_path("prompts/persona/default.md")), | |
| instruction_assessment=Path( | |
| to_absolute_path("prompts/instruction/assessment.md") | |
| ), | |
| instruction_conversation=Path( | |
| to_absolute_path("prompts/instruction/conversation.md") | |
| ), | |
| output_format_assessment=Path( | |
| to_absolute_path("configs/output_format/assessment.yaml") | |
| ), | |
| output_format_conversation=Path( | |
| to_absolute_path("configs/output_format/conversation.yaml") | |
| ), | |
| cancer_modules_dir=Path( | |
| to_absolute_path("configs/knowledge_base/cancer_modules") | |
| ), | |
| dx_protocols_dir=Path(to_absolute_path("configs/knowledge_base/dx_protocols")), | |
| ) | |
| # Create AppConfig from Hydra config | |
| app_config = AppConfig( | |
| model=ModelConfig(provider=cfg.model.provider, model_name=cfg.model.model_name), | |
| knowledge_base_paths=knowledge_base_paths, | |
| selected_cancer_modules=list(cfg.knowledge_base.cancer_modules), | |
| selected_dx_protocols=list(cfg.knowledge_base.dx_protocols), | |
| ) | |
| # Create factory and conversation manager | |
| factory = SentinelFactory(app_config) | |
| conversation = factory.create_conversation_manager() | |
| if cfg.user_file: | |
| print(f"{Colors.OKBLUE}π Loading user data from: {cfg.user_file}{Colors.ENDC}") | |
| user = load_user_file(cfg.user_file) | |
| else: | |
| user = collect_user_input() | |
| print(f"\n{Colors.OKCYAN}π Running risk scoring tools...{Colors.ENDC}") | |
| risks_scores = [] | |
| for model in RISK_MODELS: | |
| try: | |
| risk_score = model().run(user) | |
| # Handle models that return multiple scores (e.g., QCancer) | |
| if isinstance(risk_score, list): | |
| risks_scores.extend(risk_score) | |
| else: | |
| risks_scores.append(risk_score) | |
| except ValueError as e: | |
| # Skip models that aren't applicable or have validation errors | |
| print(f"{Colors.WARNING}β οΈ Skipping {model().name}: {e!s}{Colors.ENDC}") | |
| continue | |
| for risk_score in risks_scores: | |
| # Format output based on whether cancer type is specified | |
| if risk_score.cancer_type and risk_score.cancer_type not in [ | |
| "multiple", | |
| "Multiple Cancer Sites", | |
| ]: | |
| display = ( | |
| f"{risk_score.name} ({risk_score.cancer_type}): {risk_score.score}" | |
| ) | |
| else: | |
| display = f"{risk_score.name}: {risk_score.score}" | |
| print(f"{Colors.OKCYAN}π {display}{Colors.ENDC}") | |
| print(f"\n{Colors.OKGREEN}π Analyzing your information...{Colors.ENDC}") | |
| response = None | |
| try: | |
| response = conversation.initial_assessment(user, risk_scores=risks_scores) | |
| format_risk_assessment(response, dev_mode) | |
| except Exception as e: | |
| print(f"{Colors.FAIL}β Error generating assessment: {e}{Colors.ENDC}") | |
| return | |
| if response: | |
| export_choice = input( | |
| f"\n{Colors.OKCYAN}Export full report to a file? (pdf/excel/both/N):{Colors.ENDC} " | |
| ).lower() | |
| if export_choice in ["pdf", "excel", "both"]: | |
| output_dir = Path("outputs") | |
| output_dir.mkdir(exist_ok=True) | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| base_filename = f"Cancer_Risk_Report_{timestamp}" | |
| if export_choice in ["pdf", "both"]: | |
| pdf_filename = output_dir / f"{base_filename}.pdf" | |
| try: | |
| print(f"{Colors.OKCYAN}Generating PDF report...{Colors.ENDC}") | |
| generate_pdf_report(response, user, str(pdf_filename)) | |
| print( | |
| f"{Colors.OKGREEN}β Successfully generated {pdf_filename}{Colors.ENDC}" | |
| ) | |
| except Exception as e: | |
| print( | |
| f"{Colors.FAIL}β Error generating PDF report: {e}{Colors.ENDC}" | |
| ) | |
| if export_choice in ["excel", "both"]: | |
| excel_filename = output_dir / f"{base_filename}.xlsx" | |
| try: | |
| print(f"{Colors.OKCYAN}Generating Excel report...{Colors.ENDC}") | |
| generate_excel_report(response, user, str(excel_filename)) | |
| print( | |
| f"{Colors.OKGREEN}β Successfully generated {excel_filename}{Colors.ENDC}" | |
| ) | |
| except Exception as e: | |
| print( | |
| f"{Colors.FAIL}β Error generating Excel report: {e}{Colors.ENDC}" | |
| ) | |
| # Follow-up conversation loop | |
| print( | |
| f"\n{Colors.OKBLUE}{Colors.BOLD}π¬ You can now ask follow-up questions. Type 'quit' to exit.{Colors.ENDC}" | |
| ) | |
| while True: | |
| q = input(f"\n{Colors.BOLD}You: {Colors.ENDC}") | |
| if q.lower() in {"quit", "exit", "q"}: | |
| print( | |
| f"{Colors.OKGREEN}π Thank you for using the Cancer Risk Assessment Tool!{Colors.ENDC}" | |
| ) | |
| break | |
| if not q.strip(): | |
| continue | |
| try: | |
| text = conversation.follow_up(q) | |
| format_followup_response(text, dev_mode) | |
| except Exception as e: | |
| print(f"{Colors.FAIL}β Error: {e}{Colors.ENDC}") | |
| if __name__ == "__main__": | |
| main() | |