| """ |
| Input validation and output post-processing. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
|
|
|
|
| |
| |
| |
|
|
| MIN_SYMPTOM_LENGTH = 10 |
| MAX_FIELD_LENGTH = 4000 |
|
|
|
|
| def validate_inputs( |
| symptoms: str, |
| notes: str = "", |
| medications: str = "", |
| ) -> list[str]: |
| """ |
| Return a list of human-readable error strings. |
| Empty list means inputs are valid. |
| """ |
| errors: list[str] = [] |
|
|
| if not symptoms or not symptoms.strip(): |
| errors.append("Please describe your symptoms before generating a report.") |
| elif len(symptoms.strip()) < MIN_SYMPTOM_LENGTH: |
| errors.append( |
| f"Symptom description is too short (minimum {MIN_SYMPTOM_LENGTH} characters). " |
| "Please provide more detail." |
| ) |
|
|
| for label, value in [("Symptoms", symptoms), ("Notes", notes), ("Medications", medications)]: |
| if value and len(value) > MAX_FIELD_LENGTH: |
| errors.append( |
| f"{label} field exceeds the maximum length of {MAX_FIELD_LENGTH} characters. " |
| "Please shorten your input." |
| ) |
|
|
| return errors |
|
|
|
|
| |
| |
| |
|
|
| def parse_output(raw: str) -> str: |
| """ |
| Light cleanup on raw LLM output: |
| - Strip leading/trailing whitespace |
| - Remove any accidental repeated blank lines (> 2 in a row) |
| - Ensure the output is non-empty with a fallback message |
| """ |
| if not raw or not raw.strip(): |
| return "_No output generated. Check that the model is running and try again._" |
|
|
| |
| cleaned = re.sub(r"\n{3,}", "\n\n", raw.strip()) |
|
|
| return cleaned |
|
|
|
|
| REPORT_SECTION_FALLBACK = ( |
| "_The model returned text that could not be formatted into the appointment prep " |
| "sections. Please try Generate again._" |
| ) |
| SECTION_OUTPUT_FALLBACK = ( |
| "_The model returned text that could not be formatted for this section. " |
| "Please try Generate again._" |
| ) |
|
|
| _REASONING_PREFIX_RE = re.compile( |
| r"^\s*(?:<think>|<unused\d+>\s*)?(?:thought\b|thinking\b|analysis\b|reasoning\b)", |
| flags=re.IGNORECASE, |
| ) |
| _FINAL_REPORT_RE = re.compile( |
| r"(?:^|\n)\s*(?:final(?:\s+answer)?|answer|report)\s*:?\s*(TIMELINE\s*:)", |
| flags=re.IGNORECASE, |
| ) |
| _TIMELINE_RE = re.compile(r"(?:^|\n)\s*TIMELINE\s*:", flags=re.IGNORECASE) |
|
|
|
|
| def _strip_reasoning_blocks(text: str) -> str: |
| """Remove common hidden-reasoning wrappers before parsing patient output.""" |
| cleaned = re.sub(r"(?is)<think>.*?</think>\s*", "", text).strip() |
| if not _REASONING_PREFIX_RE.search(cleaned): |
| timeline_match = _TIMELINE_RE.search(cleaned) |
| if timeline_match: |
| return cleaned[timeline_match.start() :].strip() |
| return cleaned |
|
|
| final_match = _FINAL_REPORT_RE.search(cleaned) |
| if final_match: |
| return cleaned[final_match.start(1) :].strip() |
|
|
| timeline_matches = list(_TIMELINE_RE.finditer(cleaned)) |
| if len(timeline_matches) > 1: |
| return cleaned[timeline_matches[-1].start() :].strip() |
|
|
| |
| |
| return "" |
|
|
|
|
| def _limit_list_items(text: str, max_items: int) -> str: |
| lines = text.splitlines() |
| item_count = 0 |
| kept: list[str] = [] |
| for line in lines: |
| if re.match(r"\s*(?:[-*]\s+|\d+[.)]\s+)", line): |
| item_count += 1 |
| if item_count > max_items: |
| continue |
| elif item_count > max_items and not line.strip(): |
| continue |
| elif item_count > max_items: |
| continue |
| kept.append(line) |
| return "\n".join(kept).strip() |
|
|
|
|
| def clean_section_output( |
| raw: str, |
| *, |
| max_items: int | None = None, |
| required_suffix: str = "", |
| ) -> str: |
| """Clean one section response without exposing hidden reasoning text.""" |
| cleaned = parse_output(raw) |
| if cleaned.startswith("_No output generated"): |
| return cleaned |
|
|
| cleaned = re.sub(r"(?is)<think>.*?</think>\s*", "", cleaned).strip() |
| if _REASONING_PREFIX_RE.search(cleaned): |
| final_match = re.search( |
| r"(?:^|\n)\s*(?:final(?:\s+answer)?|answer|report)\s*:?\s*(.+)", |
| cleaned, |
| flags=re.IGNORECASE | re.DOTALL, |
| ) |
| if not final_match: |
| return SECTION_OUTPUT_FALLBACK |
| cleaned = final_match.group(1).strip() |
|
|
| cleaned = re.sub( |
| r"(?i)^\s*(?:TIMELINE|QUESTIONS|RELEVANT(?:_|\s+)INFO)\s*:\s*", |
| "", |
| cleaned, |
| ).strip() |
|
|
| cleaned = re.sub(r"(?m)^\s*\*\*[^*\n]{1,80}:?\*\*\s*\n+", "", cleaned).strip() |
| first_list_item = re.search(r"(?m)^\s*(?:[-*]\s+|\d+[.)]\s+)", cleaned) |
| if first_list_item and first_list_item.start() > 0: |
| preamble = cleaned[: first_list_item.start()].strip() |
| if len(preamble) <= 240: |
| cleaned = cleaned[first_list_item.start() :].strip() |
|
|
| lines = cleaned.splitlines() |
| if len(lines) > 1: |
| last_line = lines[-1].strip() |
| if last_line and not re.search(r"[.!?*_)]\s*$", last_line): |
| cleaned = "\n".join(lines[:-1]).strip() |
|
|
| if max_items is not None: |
| cleaned = _limit_list_items(cleaned, max_items) |
|
|
| if required_suffix and required_suffix.lower() not in cleaned.lower(): |
| cleaned = f"{cleaned.rstrip()}\n{required_suffix}".strip() |
|
|
| return cleaned or SECTION_OUTPUT_FALLBACK |
|
|
|
|
| def parse_prep_report(raw: str) -> tuple[str, str, str]: |
| """Parse a combined report into timeline, questions, and relevant info.""" |
| cleaned = _strip_reasoning_blocks(parse_output(raw)) |
| if cleaned.startswith("_No output generated"): |
| return cleaned, cleaned, cleaned |
| if not cleaned: |
| return REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK |
|
|
| markers = { |
| "timeline": r"TIMELINE\s*:", |
| "questions": r"QUESTIONS\s*:", |
| "relevant": r"RELEVANT(?:_|\s+)INFO\s*:", |
| } |
| matches = { |
| name: re.search(pattern, cleaned, flags=re.IGNORECASE) |
| for name, pattern in markers.items() |
| } |
|
|
| if not all(matches.values()): |
| return REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK |
|
|
| timeline_match = matches["timeline"] |
| questions_match = matches["questions"] |
| relevant_match = matches["relevant"] |
| assert timeline_match and questions_match and relevant_match |
|
|
| if not (timeline_match.start() <= questions_match.start() <= relevant_match.start()): |
| return REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK, REPORT_SECTION_FALLBACK |
|
|
| ordered = sorted( |
| [ |
| ("timeline", timeline_match), |
| ("questions", questions_match), |
| ("relevant", relevant_match), |
| ], |
| key=lambda item: item[1].start(), |
| ) |
| positions = {name: (match.end(), None) for name, match in ordered} |
| for index, (name, _match) in enumerate(ordered[:-1]): |
| next_start = ordered[index + 1][1].start() |
| positions[name] = (positions[name][0], next_start) |
| last_name = ordered[-1][0] |
| positions[last_name] = (positions[last_name][0], len(cleaned)) |
|
|
| sections = {} |
| for name, (start, end) in positions.items(): |
| value = cleaned[start:end].strip() |
| sections[name] = value or REPORT_SECTION_FALLBACK |
|
|
| return sections["timeline"], sections["questions"], sections["relevant"] |
|
|
|
|
| |
| |
| |
|
|
| def truncate_for_context(text: str, max_chars: int = 2000) -> str: |
| """Truncate a field to avoid blowing the model's context window.""" |
| if len(text) <= max_chars: |
| return text |
| return text[:max_chars] + "\n\n[... truncated for context window ...]" |
|
|