medical-appt-prep / src /processor.py
bisonnetworking's picture
Pin Gradio client asset
4b091c5 verified
Raw
History Blame Contribute Delete
8.1 kB
"""
Input validation and output post-processing.
"""
from __future__ import annotations
import re
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# Output post-processing
# ---------------------------------------------------------------------------
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._"
# Collapse 3+ consecutive blank lines to 2
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()
# A reasoning-prefixed response without a clear final report should not be
# shown to the user as medical prep content.
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"]
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
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 ...]"