import re import time import logging from ai import GeminiProvider logger = logging.getLogger(__name__) # Inline trigger: BLIGHT: (single colon, case-insensitive) INLINE_PATTERN = re.compile(r"^BLIGHT:(?!:)\s+(.+)$", re.MULTILINE | re.IGNORECASE) # Document-scope trigger: BLIGHT:: (double colon, case-insensitive) DOCUMENT_PATTERN = re.compile(r"^BLIGHT::\s+(.+)$", re.MULTILINE | re.IGNORECASE) FAILED_TEMPLATE = "\n" # Matches any BLIGHT: trigger in AI output that could cause a processing loop. _SANITIZE_PATTERN = re.compile(r"BLIGHT:", re.IGNORECASE) _MAX_RETRIES = 3 _RETRY_DELAYS = [1, 2, 4] # seconds between attempts _provider = GeminiProvider() def process_document(content: str) -> tuple[str, bool]: """Scan content for BLIGHT triggers and process each one. Inline triggers (BLIGHT:) are processed first in document order, each replacing only the trigger line. Document-scope triggers (BLIGHT::) are processed next in document order, each replacing the entire file content and operating on the result of the previous. Returns: (updated_content, changed) where changed is True if any triggers were found and the content was modified. """ has_inline = bool(INLINE_PATTERN.search(content)) has_document = bool(DOCUMENT_PATTERN.search(content)) if not has_inline and not has_document: return content, False changed = False # --- Pass 1: inline triggers --- # Re-search after each replacement since string length may change. inline_count = len(INLINE_PATTERN.findall(content)) for _ in range(inline_count): match = INLINE_PATTERN.search(content) if not match: break instruction = match.group(1).strip() logger.info("Processing inline trigger: %s", instruction) replacement = _call_with_retry(content, instruction, document_scope=False) content = content[:match.start()] + replacement + content[match.end():] changed = True # --- Pass 2: document-scope triggers --- # Each trigger operates on the result of the previous. doc_count = len(DOCUMENT_PATTERN.findall(content)) for _ in range(doc_count): match = DOCUMENT_PATTERN.search(content) if not match: break instruction = match.group(1).strip() logger.info("Processing document-scope trigger: %s", instruction) # Remove the trigger line before passing to AI so it doesn't appear # in the rewritten document. Also consume the trailing newline that # follows the trigger line, if present. trigger_start, trigger_end = match.start(), match.end() if trigger_end < len(content) and content[trigger_end] == "\n": trigger_end += 1 content_without_trigger = content[:trigger_start] + content[trigger_end:] result = _call_with_retry(content_without_trigger, instruction, document_scope=True) if result.startswith("