Add BLIGHT:: document-scope triggers and case-insensitive matching
- Split TRIGGER_PATTERN into INLINE_PATTERN (BLIGHT:) and DOCUMENT_PATTERN (BLIGHT::), both case-insensitive - Inline triggers replace only the trigger line (existing behaviour) - Document-scope triggers replace the entire file; multiple BLIGHT:: triggers in one file are processed sequentially, each seeing the previous result - Updated FAILED_TEMPLATE to two-line format with BLIGHT_FAILED and BLIGHT_ERROR - Added complete_document() to AIProvider ABC and GeminiProvider with a dedicated system prompt instructing the model to return the full document Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
19
ai/base.py
19
ai/base.py
@@ -4,13 +4,14 @@ from abc import ABC, abstractmethod
|
|||||||
class AIProvider(ABC):
|
class AIProvider(ABC):
|
||||||
"""Base class for all AI provider implementations.
|
"""Base class for all AI provider implementations.
|
||||||
|
|
||||||
To add a new provider, subclass this and implement `complete`, then
|
To add a new provider, subclass this and implement `complete` and
|
||||||
instantiate your provider in `processor.py` instead of GeminiProvider.
|
`complete_document`, then instantiate your provider in `processor.py`
|
||||||
|
instead of GeminiProvider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def complete(self, document: str, instruction: str) -> str:
|
def complete(self, document: str, instruction: str) -> str:
|
||||||
"""Process an instruction in the context of a full document.
|
"""Process an inline instruction in the context of a full document.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: The full markdown document text (for context).
|
document: The full markdown document text (for context).
|
||||||
@@ -19,3 +20,15 @@ class AIProvider(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
The text to insert in place of the trigger line.
|
The text to insert in place of the trigger line.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def complete_document(self, document: str, instruction: str) -> str:
|
||||||
|
"""Apply a document-scope instruction and return the full rewritten document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document: The full markdown document text.
|
||||||
|
instruction: The BLIGHT:: instruction extracted from the trigger line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The full rewritten document as a string.
|
||||||
|
"""
|
||||||
|
|||||||
29
ai/gemini.py
29
ai/gemini.py
@@ -2,7 +2,7 @@ import google.generativeai as genai
|
|||||||
import config
|
import config
|
||||||
from .base import AIProvider
|
from .base import AIProvider
|
||||||
|
|
||||||
_SYSTEM_PROMPT = (
|
_INLINE_SYSTEM_PROMPT = (
|
||||||
"You are an inline document assistant. "
|
"You are an inline document assistant. "
|
||||||
"The user will provide a markdown document and a specific instruction. "
|
"The user will provide a markdown document and a specific instruction. "
|
||||||
"Your response must contain ONLY the text to be inserted into the document — "
|
"Your response must contain ONLY the text to be inserted into the document — "
|
||||||
@@ -11,13 +11,26 @@ _SYSTEM_PROMPT = (
|
|||||||
"Respond as if your output will be dropped directly into the middle of a document."
|
"Respond as if your output will be dropped directly into the middle of a document."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_DOCUMENT_SYSTEM_PROMPT = (
|
||||||
|
"You are a document editing assistant. "
|
||||||
|
"The user will provide a markdown document and a specific instruction. "
|
||||||
|
"Apply the instruction to the entire document and return the full rewritten document. "
|
||||||
|
"Your response must contain ONLY the rewritten document — "
|
||||||
|
"no preamble, no explanation, no meta-commentary, no markdown code fences. "
|
||||||
|
"Preserve the document's structure and formatting unless the instruction says otherwise."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GeminiProvider(AIProvider):
|
class GeminiProvider(AIProvider):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
genai.configure(api_key=config.GEMINI_API_KEY)
|
genai.configure(api_key=config.GEMINI_API_KEY)
|
||||||
self._model = genai.GenerativeModel(
|
self._inline_model = genai.GenerativeModel(
|
||||||
model_name="gemini-2.5-flash-lite",
|
model_name="gemini-2.5-flash-lite",
|
||||||
system_instruction=_SYSTEM_PROMPT,
|
system_instruction=_INLINE_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
self._document_model = genai.GenerativeModel(
|
||||||
|
model_name="gemini-2.5-flash-lite",
|
||||||
|
system_instruction=_DOCUMENT_SYSTEM_PROMPT,
|
||||||
)
|
)
|
||||||
|
|
||||||
def complete(self, document: str, instruction: str) -> str:
|
def complete(self, document: str, instruction: str) -> str:
|
||||||
@@ -25,5 +38,13 @@ class GeminiProvider(AIProvider):
|
|||||||
f"DOCUMENT:\n\n{document}\n\n"
|
f"DOCUMENT:\n\n{document}\n\n"
|
||||||
f"INSTRUCTION: {instruction}"
|
f"INSTRUCTION: {instruction}"
|
||||||
)
|
)
|
||||||
response = self._model.generate_content(prompt)
|
response = self._inline_model.generate_content(prompt)
|
||||||
|
return response.text.strip()
|
||||||
|
|
||||||
|
def complete_document(self, document: str, instruction: str) -> str:
|
||||||
|
prompt = (
|
||||||
|
f"DOCUMENT:\n\n{document}\n\n"
|
||||||
|
f"INSTRUCTION: {instruction}"
|
||||||
|
)
|
||||||
|
response = self._document_model.generate_content(prompt)
|
||||||
return response.text.strip()
|
return response.text.strip()
|
||||||
|
|||||||
72
processor.py
72
processor.py
@@ -5,8 +5,12 @@ from ai import GeminiProvider
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TRIGGER_PATTERN = re.compile(r"^BLIGHT:\s+(.+)$", re.MULTILINE)
|
# Inline trigger: BLIGHT: <instruction> (single colon, case-insensitive)
|
||||||
FAILED_TEMPLATE = "<!-- BLIGHT_FAILED: {instruction} -->"
|
INLINE_PATTERN = re.compile(r"^BLIGHT:(?!:)\s+(.+)$", re.MULTILINE | re.IGNORECASE)
|
||||||
|
# Document-scope trigger: BLIGHT:: <instruction> (double colon, case-insensitive)
|
||||||
|
DOCUMENT_PATTERN = re.compile(r"^BLIGHT::\s+(.+)$", re.MULTILINE | re.IGNORECASE)
|
||||||
|
|
||||||
|
FAILED_TEMPLATE = "<!-- BLIGHT_FAILED: {instruction} -->\n<!-- BLIGHT_ERROR: {error} -->"
|
||||||
|
|
||||||
_MAX_RETRIES = 3
|
_MAX_RETRIES = 3
|
||||||
_RETRY_DELAYS = [1, 2, 4] # seconds between attempts
|
_RETRY_DELAYS = [1, 2, 4] # seconds between attempts
|
||||||
@@ -17,42 +21,80 @@ _provider = GeminiProvider()
|
|||||||
def process_document(content: str) -> tuple[str, bool]:
|
def process_document(content: str) -> tuple[str, bool]:
|
||||||
"""Scan content for BLIGHT triggers and process each one.
|
"""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:
|
Returns:
|
||||||
(updated_content, changed) where changed is True if any triggers
|
(updated_content, changed) where changed is True if any triggers
|
||||||
were found and the content was modified.
|
were found and the content was modified.
|
||||||
"""
|
"""
|
||||||
triggers = list(TRIGGER_PATTERN.finditer(content))
|
has_inline = bool(INLINE_PATTERN.search(content))
|
||||||
if not triggers:
|
has_document = bool(DOCUMENT_PATTERN.search(content))
|
||||||
|
if not has_inline and not has_document:
|
||||||
return content, False
|
return content, False
|
||||||
|
|
||||||
# Process triggers one by one. After each replacement the string length
|
|
||||||
# may change, so we re-search on the updated content each iteration.
|
|
||||||
changed = False
|
changed = False
|
||||||
for _ in range(len(triggers)):
|
|
||||||
match = TRIGGER_PATTERN.search(content)
|
# --- 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:
|
if not match:
|
||||||
break
|
break
|
||||||
|
|
||||||
instruction = match.group(1).strip()
|
instruction = match.group(1).strip()
|
||||||
trigger_line = match.group(0)
|
logger.info("Processing inline trigger: %s", instruction)
|
||||||
logger.info("Processing trigger: %s", instruction)
|
|
||||||
|
|
||||||
replacement = _call_with_retry(content, instruction)
|
replacement = _call_with_retry(content, instruction, document_scope=False)
|
||||||
content = content[:match.start()] + replacement + content[match.end():]
|
content = content[:match.start()] + replacement + content[match.end():]
|
||||||
changed = True
|
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("<!-- BLIGHT_FAILED:"):
|
||||||
|
# On failure, restore the trigger line and insert the failure comment.
|
||||||
|
content = content[:trigger_start] + result + content[trigger_end:]
|
||||||
|
else:
|
||||||
|
content = result
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
|
||||||
return content, changed
|
return content, changed
|
||||||
|
|
||||||
|
|
||||||
def _call_with_retry(document: str, instruction: str) -> str:
|
def _call_with_retry(document: str, instruction: str, *, document_scope: bool) -> str:
|
||||||
"""Call the AI provider with up to _MAX_RETRIES attempts.
|
"""Call the AI provider with up to _MAX_RETRIES attempts.
|
||||||
|
|
||||||
Returns the AI response on success, or a BLIGHT_FAILED comment on
|
Returns the AI response on success, or BLIGHT_FAILED/BLIGHT_ERROR comments
|
||||||
exhausted retries.
|
on exhausted retries.
|
||||||
"""
|
"""
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(_MAX_RETRIES):
|
for attempt in range(_MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
|
if document_scope:
|
||||||
|
return _provider.complete_document(document, instruction)
|
||||||
return _provider.complete(document, instruction)
|
return _provider.complete(document, instruction)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
@@ -74,4 +116,4 @@ def _call_with_retry(document: str, instruction: str) -> str:
|
|||||||
instruction,
|
instruction,
|
||||||
last_error,
|
last_error,
|
||||||
)
|
)
|
||||||
return FAILED_TEMPLATE.format(instruction=instruction)
|
return FAILED_TEMPLATE.format(instruction=instruction, error=last_error)
|
||||||
|
|||||||
Reference in New Issue
Block a user