im-error-check / backend.py
Vrda's picture
Fix DeepSeek empty responses by raising max_tokens/timeout
f371904 verified
Raw History Blame Contribute Delete
30.3 kB
"""
Internal Medicine Discharge Letter Error-Check — Backend
Prospective study: AI-assisted error detection in ED discharge letters
Flow:
1. Receive Croatian discharge letter from doctor
2. Translate to English (Gemini 3.1 Flash Lite)
3. Run concurrent error-detection analysis:
- DeepSeek Reasoner (via DeepSeek API)
- GPT-OSS-120B (via Groq)
4. Parse structured output and return errors + suggestions
"""
import os
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
from dotenv import load_dotenv
from google import genai
from openai import OpenAI
from groq import Groq
BASE_DIR = Path(__file__).resolve().parent
MEDICATION_GLOSSARY_FILE = BASE_DIR / "medication_glossary.json"
load_dotenv(dotenv_path=BASE_DIR / ".env")
# ---------------------------------------------------------------------------
# API clients
# ---------------------------------------------------------------------------
def get_gemini_client() -> genai.Client:
key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
return genai.Client(api_key=key)
def get_deepseek_client() -> OpenAI:
return OpenAI(
api_key=os.environ.get("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com",
)
def get_groq_client() -> Groq:
return Groq(api_key=os.environ.get("GROQ_API_KEY_OSS"))
# Reasoning tokens count against max_tokens. 8192 was too small for long
# discharge letters: DeepSeek filled the budget with reasoning and returned
# empty content with finish_reason=length.
DEEPSEEK_TIMEOUT_SECONDS = 240
DEEPSEEK_MAX_TOKENS = 32768
DEEPSEEK_MAX_ATTEMPTS = 2
DEEPSEEK_RETRY_SLEEP_SECONDS = 2
def _log_deepseek(event: str, **kwargs):
parts = [f"{key}={value!r}" for key, value in kwargs.items()]
suffix = f" | {' | '.join(parts)}" if parts else ""
print(f"[DeepSeek] {event}{suffix}", flush=True)
def _deepseek_response_meta(response) -> dict:
choice = response.choices[0]
message = choice.message
content = message.content or ""
reasoning = getattr(message, "reasoning_content", "") or ""
usage = getattr(response, "usage", None)
meta = {
"finish_reason": getattr(choice, "finish_reason", None),
"content_len": len(content),
"reasoning_len": len(reasoning),
}
if usage is not None:
meta["prompt_tokens"] = getattr(usage, "prompt_tokens", None)
meta["completion_tokens"] = getattr(usage, "completion_tokens", None)
details = getattr(usage, "completion_tokens_details", None)
if details is not None:
meta["reasoning_tokens"] = getattr(details, "reasoning_tokens", None)
return meta
# ---------------------------------------------------------------------------
# Medication glossary lookup
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def load_medication_glossary() -> tuple[dict[str, Any], ...]:
"""Load local medication glossary once per process."""
try:
with open(MEDICATION_GLOSSARY_FILE, "r", encoding="utf-8") as f:
glossary = json.load(f)
except FileNotFoundError:
return ()
entries = glossary.get("entries", [])
if not isinstance(entries, list):
return ()
return tuple(entry for entry in entries if isinstance(entry, dict))
def _entry_aliases(entry: dict[str, Any]) -> list[str]:
aliases = entry.get("aliases") or []
if not isinstance(aliases, list):
aliases = []
names = [entry.get("name", ""), *aliases]
unique = []
seen = set()
for name in names:
if not isinstance(name, str) or not name.strip():
continue
key = name.casefold()
if key in seen:
continue
seen.add(key)
unique.append(name.strip())
return unique
def find_medication_matches(
text: str,
max_entries: int = 20,
) -> list[dict[str, Any]]:
"""Find glossary medications mentioned in the original letter."""
matches: dict[str, dict[str, Any]] = {}
for entry in load_medication_glossary():
best_match = None
for alias in sorted(_entry_aliases(entry), key=len, reverse=True):
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
match = pattern.search(text)
if match and (best_match is None or match.start() < best_match.start()):
best_match = match
if best_match is None:
continue
key = str(entry.get("name", "")).casefold()
matches[key] = {
"entry": entry,
"matched_alias": best_match.group(0),
"position": best_match.start(),
}
ordered = sorted(matches.values(), key=lambda item: item["position"])
return ordered[:max_entries]
def build_medication_lookup_context(text: str, max_entries: int = 20) -> str:
"""Build a compact prompt context from locally matched medication names."""
matches = find_medication_matches(text, max_entries=max_entries)
if not matches:
return (
"No local medication glossary matches found. For unlisted medication "
"names, do not infer active ingredients unless explicit in the letter."
)
lines = []
for match in matches:
entry = match["entry"]
active = entry.get("active_ingredients") or []
if isinstance(active, list) and active:
active_text = " + ".join(str(item) for item in active)
else:
active_text = "active ingredient not specified"
details = [
f"{entry.get('name', match['matched_alias'])}: {active_text}",
]
if entry.get("class"):
details.append(f"class: {entry['class']}")
if entry.get("form_route"):
details.append(f"form/route: {entry['form_route']}")
notes = entry.get("notes") or []
if isinstance(notes, list) and notes:
details.append("notes: " + " ".join(str(note) for note in notes))
lines.append("- " + "; ".join(details))
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
TRANSLATION_PROMPT = """You are a medical translator. Translate the following Croatian clinical discharge letter to English.
Preserve ALL medical terminology, values, units, drug names, dosages, and clinical details exactly.
Medication names are proper nouns: copy brand names, generic names, combination products, and abbreviations verbatim. Do not translate, normalize, autocorrect, substitute, or infer active ingredients for Croatian/EU drug names.
Output ONLY the English translation, nothing else.
Croatian text:
{text}"""
ERROR_CHECK_SYSTEM_PROMPT = """You are a senior attending physician in internal medicine with extensive emergency department experience and a reputation as a meticulous clinician-educator. You are reviewing emergency department discharge letters with the goal of improving patient safety, care continuity, and documentation quality for complex internal medicine patients (often older adults with multimorbidity and polypharmacy).
Your task: Carefully analyze the discharge letter and identify UP TO 3 ERRORS and UP TO 2 IMPROVEMENT SUGGESTIONS. The goal is precision and high clinical value, not forcing findings.
ERRORS are factual, clinical, or documentation mistakes present in the letter, such as:
- Medication errors (wrong drug, wrong dose, unrecognized interactions, contraindications, failure to reconcile with home medications)
- Diagnostic errors (indicated but missing diagnostic procedures, incorrect or unsupported diagnosis given the documented findings)
- Dosing errors (inappropriate for age, weight, renal function, or comorbidities when this information is available in the letter)
- Lab/vital sign interpretation or follow-up errors (misinterpreted or unaddressed abnormal results)
- Documentation errors (inconsistencies, contradictions, or unclear clinical reasoning) — but NOT routine triage/template artifacts (see OUT OF SCOPE below)
- Omissions (critical missing information that should be documented for safety or continuity)
- Disposition safety issues (inadequate rationale for discharge or missing risk stratification elements)
SUGGESTIONS are high-value quality improvements (NOT errors) such as:
- Documentation completeness or clarity
- Clinical workflow or patient safety enhancements
- Follow-up care specificity and actionability
- Patient education / health literacy improvements
- Continuity of care / PCP handoff quality
- Medication safety enhancements
INTERNAL ANALYSIS PROCESS (perform this mentally before outputting JSON):
1. Summarize the case in 1-2 sentences: chief complaint/presentation, key findings/workup, diagnosis, treatment/disposition, and follow-up plan.
2. Systematically review high-priority areas for internal medicine ED discharges: medication reconciliation and new prescriptions (interactions, duplications, renal dosing, changes from home meds); specificity and actionability of follow-up + red flags/return precautions; clarity and health-literacy appropriateness of patient instructions; whether all significant findings/abnormal labs/vitals are addressed or explained; clinical reasoning and rationale for disposition (why safe to discharge); communication of diagnosis and plan to patient and future providers.
- For medication review, always verify drug names, doses, and schedules against the ORIGINAL CROATIAN LETTER. Treat Croatian/EU medication brand names as exact strings; do not reinterpret unfamiliar names, silently correct spelling, or assume active ingredients unless they are explicit in the text, listed in the medication lookup context, or you are highly certain.
3. For every potential issue, ask: "Is this a genuine error or high-impact improvement based solely on the text provided? Would addressing it meaningfully reduce risk of adverse event, bounce-back, or poor continuity?"
4. Select only the most important items (max 3 errors, max 2 suggestions). Prioritize patient safety, continuity of care, and high-likelihood impact. It is acceptable (and often correct) to report 0 errors.
5. For each selected item, extract the exact quote and formulate precise, clinically sophisticated language.
OUT OF SCOPE — DO NOT REPORT (ignore these entirely):
These are common, low-clinical-impact documentation/template artifacts in ED discharge letters and are NOT the target of this review. Do not flag them as errors or suggestions:
- Implausible or default triage/vital-sign template values that are clearly not clinically relied upon (e.g., GCS=0 in an otherwise alert patient, GCS=0 with normal exam, placeholder zeros)
- Minor triage field label quirks or copy-paste artifacts (e.g., RR used where BP is meant, duplicated triage blocks) unless they directly affect clinical decision-making documented in the letter
- Spelling/grammar typos, formatting inconsistencies, or duplicated boilerplate text that do not change clinical meaning
- Missing weight/height when not relevant to the documented treatment decisions
- Standard administrative fields left blank or auto-filled
- Do NOT report as an error that the discharge letter does not explicitly say "continue previous/chronic/home therapy" when no medication changes are documented. In Croatian ED discharge letters, continuation of previously prescribed chronic therapy is assumed ("podrazumijeva se") unless the letter explicitly stops, changes, duplicates, contradicts, or creates a clear safety issue involving that therapy.
CRITICAL RULES:
- Only report genuine errors you are confident about based solely on the provided discharge letter text. Do NOT fabricate, speculate, or infer from information not present in the letter.
- Do NOT force yourself to find 3 errors or 2 suggestions. Fewer (or zero) is better than weak or questionable items.
- When uncertain, prefer returning no error rather than a speculative one.
- You may still provide 0-2 high-value improvement suggestions even when errors is empty.
- Be specific: always quote the relevant part of the letter.
- For suggestions, the suggested_rewrite must be concrete, immediately usable (copy-paste ready), professional in tone for an ED discharge letter, and use plain language where it is patient-facing.
- Prioritize items with clear potential to improve patient safety, reduce readmission/bounce-back risk, or enhance continuity for complex IM patients.
- Base analysis only on what is documented. Flag omissions only when the letter's own presentation makes the missing element clearly important.
- Ignore OUT OF SCOPE template/documentation artifacts (e.g., GCS=0, triage placeholders). Focus on clinically meaningful medication, diagnostic, safety, and continuity issues.
- Medication-specific rule: do not report medication errors from guessed drug identities. If a medication appears in the medication lookup context, use that identity as authoritative. If a medication name is unlisted, may have been mistranslated, or is unfamiliar, quote the original name verbatim and either avoid the finding or frame it only as a low-priority clarification suggestion.
You MUST respond in the following JSON format and NOTHING else. Do not add any explanatory text before or after the JSON.
{
"errors": [
{
"description": "Clear, precise description of the error",
"category": "medication_error|diagnostic_error|dosing_error|documentation_error|lab_interpretation_error|contraindication|omission|disposition_safety|other",
"severity": "low|medium|high|critical",
"quote": "Exact quote from the letter where the error appears (or 'N/A' for pure omissions)",
"why_it_matters": "One-sentence clinical explanation of why this is an error and its potential impact on safety, outcome, or continuity."
}
],
"suggestions": [
{
"description": "Clear description of the improvement suggestion",
"category": "documentation_quality|clinical_workflow|patient_safety|continuity_of_care|patient_education|followup_quality|medication_safety|other",
"quote": "Exact quote from the letter (or 'N/A' if adding entirely new content)",
"suggested_rewrite": "Exactly how you would have written it differently — the full improved text you recommend (copy-paste ready)",
"why_recommended": "One-sentence explanation of why this change would meaningfully improve care quality, safety, or continuity.",
"priority": "high|medium|low"
}
],
"summary": {
"overall_assessment": "One or two sentences summarizing the overall quality and key issues of the discharge letter.",
"safety_risk": "low|moderate|high",
"documentation_quality": "excellent|good|adequate|needs_improvement|poor"
}
}
Here is an example of the expected output format when no errors are present:
{
"errors": [],
"suggestions": [
{
"description": "Make the follow-up plan more explicit, time-bound, and actionable with clear red flags for this patient with dyspnea and possible early heart failure exacerbation.",
"category": "followup_quality",
"quote": "Follow up with primary care in 1 week.",
"suggested_rewrite": "Please follow up with your primary care physician within 7 days for clinical reassessment and repeat labs (including BNP and renal function). Bring all your medications to the appointment. Return to the emergency department immediately or call 911 if you develop worsening shortness of breath, chest pain, new swelling in your legs, or fever. You may also contact the 24-hour nurse advice line at (555) 123-4567 for questions.",
"why_recommended": "Vague follow-up is a common contributor to preventable bounce-backs in heart failure and dyspnea patients; specific instructions with red flags improve patient safety and care continuity.",
"priority": "high"
}
],
"summary": {
"overall_assessment": "No clear clinical or documentation errors identified. The letter is generally appropriate but would benefit from more specific and actionable follow-up instructions tailored to the presentation.",
"safety_risk": "low",
"documentation_quality": "good"
}
}"""
ERROR_CHECK_USER_PROMPT = """Analyze the following internal medicine emergency department discharge letter for errors and quality issues.
Use the English translation for clinical reasoning, but use the original Croatian text as the source of truth for medication names, doses, schedules, and exact wording of treatment instructions.
ORIGINAL CROATIAN DISCHARGE LETTER:
{original_text}
ENGLISH TRANSLATION:
{clinical_text}
MEDICATION LOOKUP CONTEXT:
{medication_lookup_context}
Respond with the JSON format specified in your instructions.
Remember:
- Perform the internal analysis process first.
- Up to 3 errors, up to 2 suggestions.
- Only report genuine, high-confidence issues based solely on the provided text.
- If no clear errors are present, return "errors": [] and optionally provide high-value suggestions.
- Prioritize patient safety, continuity of care, and high-impact improvements for complex internal medicine patients.
- Ignore routine triage/template documentation artifacts (e.g., GCS=0, placeholder vitals, minor triage copy-paste errors) — they are out of scope.
- Do not flag missing explicit continuation of chronic/home therapy as an error unless the letter explicitly changes, stops, duplicates, contradicts, or creates a clear safety issue involving that therapy.
- Verify medication names against the original Croatian text before reporting any medication error.
- Use the medication lookup context as authoritative for listed medications; do not replace listed identities with guessed active ingredients.
- For unlisted Croatian/EU brand names, do not infer active ingredients unless they are explicit in the text or you are highly certain."""
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ParsedError:
description: str
category: str
severity: str
quote: str
why_it_matters: str = ""
@dataclass
class ParsedSuggestion:
description: str
category: str
quote: str = ""
suggested_rewrite: str = ""
why_recommended: str = ""
priority: str = ""
@dataclass
class ParsedSummary:
overall_assessment: str = ""
safety_risk: str = ""
documentation_quality: str = ""
@dataclass
class ModelResult:
model_name: str
raw_response: str
errors: list = field(default_factory=list)
suggestions: list = field(default_factory=list)
summary: ParsedSummary = field(default_factory=ParsedSummary)
success: bool = True
error_message: Optional[str] = None
latency_seconds: float = 0.0
@dataclass
class AnalysisResponse:
original_text: str
translated_text: str
model_a_result: ModelResult
model_b_result: ModelResult
translation_latency: float = 0.0
medication_lookup_context: str = ""
# ---------------------------------------------------------------------------
# Translation
# ---------------------------------------------------------------------------
def translate_to_english(text: str) -> str:
client = get_gemini_client()
response = client.models.generate_content(
model="gemini-3.1-flash-lite-preview",
contents=TRANSLATION_PROMPT.format(text=text),
)
return response.text
# ---------------------------------------------------------------------------
# JSON parsing helper
# ---------------------------------------------------------------------------
def parse_model_json(raw: str) -> dict:
"""Extract JSON from model response, handling markdown code fences."""
text = raw.strip()
if text.startswith("```"):
first_newline = text.index("\n")
last_fence = text.rfind("```")
text = text[first_newline + 1 : last_fence].strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}") + 1
if start != -1 and end > start:
return json.loads(text[start:end])
raise
# ---------------------------------------------------------------------------
# Model calls
# ---------------------------------------------------------------------------
def _parse_summary(raw_summary) -> ParsedSummary:
if isinstance(raw_summary, dict):
return ParsedSummary(
overall_assessment=raw_summary.get("overall_assessment", ""),
safety_risk=raw_summary.get("safety_risk", ""),
documentation_quality=raw_summary.get("documentation_quality", ""),
)
if isinstance(raw_summary, str):
return ParsedSummary(overall_assessment=raw_summary)
return ParsedSummary()
def _parse_to_result(model_label: str, raw: str, latency: float) -> ModelResult:
parsed = parse_model_json(raw)
errors = [
ParsedError(
description=e.get("description", ""),
category=e.get("category", "other"),
severity=e.get("severity", "medium"),
quote=e.get("quote", ""),
why_it_matters=e.get("why_it_matters", ""),
)
for e in parsed.get("errors", [])
]
suggestions = [
ParsedSuggestion(
description=s.get("description", ""),
category=s.get("category", "other"),
quote=s.get("quote", ""),
suggested_rewrite=s.get("suggested_rewrite", ""),
why_recommended=s.get("why_recommended", ""),
priority=s.get("priority", ""),
)
for s in parsed.get("suggestions", [])
]
return ModelResult(
model_name=model_label,
raw_response=raw,
errors=errors,
suggestions=suggestions,
summary=_parse_summary(parsed.get("summary")),
success=True,
latency_seconds=round(latency, 2),
)
def call_model_a(
clinical_text: str,
original_text: str | None = None,
medication_lookup_context: str | None = None,
) -> ModelResult:
"""DeepSeek Reasoner via DeepSeek API."""
start = time.time()
client = get_deepseek_client()
last_error = None
for attempt in range(1, DEEPSEEK_MAX_ATTEMPTS + 1):
attempt_start = time.time()
try:
_log_deepseek("attempt_start", attempt=attempt)
response = client.chat.completions.create(
model="deepseek-reasoner",
messages=[
{"role": "system", "content": ERROR_CHECK_SYSTEM_PROMPT},
{
"role": "user",
"content": ERROR_CHECK_USER_PROMPT.format(
clinical_text=clinical_text,
original_text=original_text or clinical_text,
medication_lookup_context=(
medication_lookup_context
or build_medication_lookup_context(
original_text or clinical_text
)
),
),
},
],
max_tokens=DEEPSEEK_MAX_TOKENS,
timeout=DEEPSEEK_TIMEOUT_SECONDS,
)
meta = _deepseek_response_meta(response)
_log_deepseek("attempt_response", attempt=attempt, **meta)
raw = response.choices[0].message.content or ""
if not raw.strip():
raise ValueError(
"DeepSeek returned an empty response body "
f"(finish_reason={meta['finish_reason']}, "
f"reasoning_len={meta['reasoning_len']}, "
f"completion_tokens={meta.get('completion_tokens')}, "
f"reasoning_tokens={meta.get('reasoning_tokens')}, "
f"max_tokens={DEEPSEEK_MAX_TOKENS}). "
"If finish_reason is 'length', the reasoning budget consumed "
"the output allowance before a final JSON answer was written."
)
if meta.get("finish_reason") == "length":
_log_deepseek(
"truncated_warning",
attempt=attempt,
content_len=meta["content_len"],
completion_tokens=meta.get("completion_tokens"),
reasoning_tokens=meta.get("reasoning_tokens"),
)
result = _parse_to_result("DeepSeek Reasoner", raw, time.time() - start)
_log_deepseek(
"attempt_success",
attempt=attempt,
elapsed_total=round(time.time() - start, 2),
errors_found=len(result.errors),
suggestions_found=len(result.suggestions),
)
return result
except Exception as exc:
last_error = exc
_log_deepseek(
"attempt_failed",
attempt=attempt,
elapsed_attempt=round(time.time() - attempt_start, 2),
error_type=type(exc).__name__,
error=str(exc),
)
if attempt < DEEPSEEK_MAX_ATTEMPTS:
time.sleep(DEEPSEEK_RETRY_SLEEP_SECONDS)
return ModelResult(
model_name="DeepSeek Reasoner",
raw_response="",
success=False,
error_message=(
f"{last_error} after {DEEPSEEK_MAX_ATTEMPTS} attempts"
if last_error
else "DeepSeek failed for an unknown reason."
),
latency_seconds=round(time.time() - start, 2),
)
def call_model_b(
clinical_text: str,
original_text: str | None = None,
medication_lookup_context: str | None = None,
) -> ModelResult:
"""GPT-OSS-120B via Groq."""
start = time.time()
try:
client = get_groq_client()
response = client.chat.completions.create(
model="openai/gpt-oss-120b",
messages=[
{"role": "system", "content": ERROR_CHECK_SYSTEM_PROMPT},
{
"role": "user",
"content": ERROR_CHECK_USER_PROMPT.format(
clinical_text=clinical_text,
original_text=original_text or clinical_text,
medication_lookup_context=(
medication_lookup_context
or build_medication_lookup_context(
original_text or clinical_text
)
),
),
},
],
temperature=0.2,
max_tokens=4096,
)
raw = response.choices[0].message.content
return _parse_to_result("GPT-OSS-120B", raw, time.time() - start)
except Exception as exc:
return ModelResult(
model_name="GPT-OSS-120B",
raw_response="",
success=False,
error_message=str(exc),
latency_seconds=round(time.time() - start, 2),
)
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def run_error_check(croatian_text: str) -> AnalysisResponse:
"""Full pipeline: translate, then run both models concurrently."""
t0 = time.time()
english_text = translate_to_english(croatian_text)
translation_latency = round(time.time() - t0, 2)
medication_lookup_context = build_medication_lookup_context(croatian_text)
with ThreadPoolExecutor(max_workers=2) as pool:
future_a = pool.submit(
call_model_a,
english_text,
croatian_text,
medication_lookup_context,
)
future_b = pool.submit(
call_model_b,
english_text,
croatian_text,
medication_lookup_context,
)
result_a = future_a.result()
result_b = future_b.result()
return AnalysisResponse(
original_text=croatian_text,
translated_text=english_text,
model_a_result=result_a,
model_b_result=result_b,
translation_latency=translation_latency,
medication_lookup_context=medication_lookup_context,
)
# ---------------------------------------------------------------------------
# CLI test
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sample = """Bolesnik 68 godina, dolazi zbog bolova u prsištu.
Dijagnoza: STEMI prednje stijenke.
Terapija: Aspirin 100mg, Klopidogrel 75mg, Ramipril 5mg, Atorvastatin 40mg.
Preporučen kontrolni pregled za 7 dana."""
print("=" * 60)
print("ERROR CHECK TEST")
print("=" * 60)
result = run_error_check(sample)
print(f"\nTranslation ({result.translation_latency}s):")
print(result.translated_text)
for r in [result.model_a_result, result.model_b_result]:
print(f"\n{'=' * 60}")
print(f"{r.model_name} ({r.latency_seconds}s):")
if r.success:
print(f"Summary: {r.summary.overall_assessment}")
if r.summary.safety_risk:
print(f" Safety risk: {r.summary.safety_risk}")
if r.summary.documentation_quality:
print(f" Documentation quality: {r.summary.documentation_quality}")
for i, e in enumerate(r.errors, 1):
print(f" Error {i}: [{e.category}/{e.severity}] {e.description}")
for i, s in enumerate(r.suggestions, 1):
print(f" Suggestion {i}: [{s.category}] {s.description}")
else:
print(f"ERROR: {r.error_message}")