mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
259 lines
9.2 KiB
Python
259 lines
9.2 KiB
Python
"""Forgiving edit helper for AI-friendly text matching."""
|
|
|
|
import difflib
|
|
import re
|
|
from typing import List, Optional, Tuple
|
|
|
|
|
|
class ForgivingEditHelper:
|
|
"""Helper class to make text editing more forgiving for AI usage.
|
|
|
|
This helper normalizes whitespace, handles partial matches, and provides
|
|
suggestions when exact matches fail.
|
|
"""
|
|
|
|
@staticmethod
|
|
def normalize_whitespace(text: str) -> str:
|
|
"""Normalize whitespace while preserving structure.
|
|
|
|
Args:
|
|
text: Text to normalize
|
|
|
|
Returns:
|
|
Text with normalized whitespace
|
|
"""
|
|
# Handle the input line by line
|
|
lines = []
|
|
for line in text.split("\n"):
|
|
# Replace tabs with 4 spaces everywhere in the line
|
|
line = line.replace("\t", " ")
|
|
|
|
# Split into indentation and content
|
|
stripped = line.lstrip()
|
|
indent = line[: len(line) - len(stripped)]
|
|
|
|
if stripped:
|
|
# For content, normalize multiple spaces to single space
|
|
content = re.sub(r" {2,}", " ", stripped)
|
|
lines.append(indent + content)
|
|
else:
|
|
lines.append(indent)
|
|
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def find_fuzzy_match(
|
|
haystack: str, needle: str, threshold: float = 0.85
|
|
) -> Optional[Tuple[int, int, str]]:
|
|
"""Find a fuzzy match for the needle in the haystack.
|
|
|
|
Args:
|
|
haystack: Text to search in
|
|
needle: Text to search for
|
|
threshold: Similarity threshold (0-1)
|
|
|
|
Returns:
|
|
Tuple of (start_pos, end_pos, matched_text) or None
|
|
"""
|
|
# First try exact match
|
|
if needle in haystack:
|
|
start = haystack.index(needle)
|
|
return (start, start + len(needle), needle)
|
|
|
|
# Normalize for comparison
|
|
norm_haystack = ForgivingEditHelper.normalize_whitespace(haystack)
|
|
norm_needle = ForgivingEditHelper.normalize_whitespace(needle)
|
|
|
|
# Try normalized exact match
|
|
if norm_needle in norm_haystack:
|
|
# Find the match in normalized text
|
|
norm_start = norm_haystack.index(norm_needle)
|
|
|
|
# Map back to original text
|
|
# This is approximate but usually good enough
|
|
lines_before = norm_haystack[:norm_start].count("\n")
|
|
|
|
# Find corresponding position in original
|
|
original_lines = haystack.split("\n")
|
|
norm_haystack.split("\n")
|
|
|
|
start_pos = sum(len(line) + 1 for line in original_lines[:lines_before])
|
|
|
|
# Find end position by counting lines in needle
|
|
needle_line_count = norm_needle.count("\n") + 1
|
|
end_pos = sum(
|
|
len(line) + 1
|
|
for line in original_lines[: lines_before + needle_line_count]
|
|
)
|
|
|
|
matched = "\n".join(
|
|
original_lines[lines_before : lines_before + needle_line_count]
|
|
)
|
|
return (start_pos, end_pos - 1, matched)
|
|
|
|
# Try fuzzy matching on lines
|
|
haystack_lines = haystack.split("\n")
|
|
needle_lines = needle.split("\n")
|
|
|
|
if len(needle_lines) == 1:
|
|
# Single line - find best match
|
|
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
best_ratio: float = 0.0
|
|
best_match = None
|
|
|
|
for i, line in enumerate(haystack_lines):
|
|
line_norm = ForgivingEditHelper.normalize_whitespace(line)
|
|
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
|
|
|
|
if ratio > best_ratio and ratio >= threshold:
|
|
best_ratio = ratio
|
|
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
|
|
best_match = (start_pos, start_pos + len(line), line)
|
|
|
|
return best_match
|
|
|
|
else:
|
|
# Multi-line - find sequence match
|
|
for i in range(len(haystack_lines) - len(needle_lines) + 1):
|
|
candidate_lines = haystack_lines[i : i + len(needle_lines)]
|
|
candidate = "\n".join(candidate_lines)
|
|
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
|
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
|
|
ratio = difflib.SequenceMatcher(
|
|
None, candidate_norm, needle_norm
|
|
).ratio()
|
|
|
|
if ratio >= threshold:
|
|
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
|
|
return (start_pos, start_pos + len(candidate), candidate)
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def suggest_matches(
|
|
haystack: str, needle: str, max_suggestions: int = 3
|
|
) -> List[Tuple[float, str]]:
|
|
"""Suggest possible matches when exact match fails.
|
|
|
|
Args:
|
|
haystack: Text to search in
|
|
needle: Text to search for
|
|
max_suggestions: Maximum number of suggestions
|
|
|
|
Returns:
|
|
List of (similarity_score, text) tuples
|
|
"""
|
|
suggestions = []
|
|
|
|
# Normalize needle
|
|
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
needle_lines = needle.split("\n")
|
|
|
|
if len(needle_lines) == 1:
|
|
# Single line - compare with all lines
|
|
for line in haystack.split("\n"):
|
|
if line.strip(): # Skip empty lines
|
|
line_norm = ForgivingEditHelper.normalize_whitespace(line)
|
|
ratio = difflib.SequenceMatcher(
|
|
None, line_norm, needle_norm
|
|
).ratio()
|
|
if ratio > 0.5: # Only reasonably similar lines
|
|
suggestions.append((ratio, line))
|
|
|
|
else:
|
|
# Multi-line - use sliding window
|
|
haystack_lines = haystack.split("\n")
|
|
window_size = len(needle_lines)
|
|
|
|
for i in range(len(haystack_lines) - window_size + 1):
|
|
candidate_lines = haystack_lines[i : i + window_size]
|
|
candidate = "\n".join(candidate_lines)
|
|
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
|
|
|
ratio = difflib.SequenceMatcher(
|
|
None, candidate_norm, needle_norm
|
|
).ratio()
|
|
if ratio > 0.5:
|
|
suggestions.append((ratio, candidate))
|
|
|
|
# Sort by similarity and return top matches
|
|
suggestions.sort(reverse=True, key=lambda x: x[0])
|
|
return suggestions[:max_suggestions]
|
|
|
|
@staticmethod
|
|
def create_edit_suggestion(
|
|
file_content: str, old_string: str, new_string: str
|
|
) -> dict:
|
|
"""Create a helpful edit suggestion when match fails.
|
|
|
|
Args:
|
|
file_content: Current file content
|
|
old_string: String that couldn't be found
|
|
new_string: Replacement string
|
|
|
|
Returns:
|
|
Dict with error message and suggestions
|
|
"""
|
|
# Try fuzzy match
|
|
fuzzy_match = ForgivingEditHelper.find_fuzzy_match(file_content, old_string)
|
|
|
|
if fuzzy_match:
|
|
_, _, matched_text = fuzzy_match
|
|
return {
|
|
"error": "Exact match not found, but found similar text",
|
|
"found": matched_text,
|
|
"suggestion": "Use this as old_string instead",
|
|
"confidence": "high",
|
|
}
|
|
|
|
# Get suggestions
|
|
suggestions = ForgivingEditHelper.suggest_matches(file_content, old_string)
|
|
|
|
if suggestions:
|
|
return {
|
|
"error": "Could not find exact or fuzzy match",
|
|
"suggestions": [
|
|
{"similarity": f"{score:.0%}", "text": text}
|
|
for score, text in suggestions
|
|
],
|
|
"hint": "Try using one of these suggestions as old_string",
|
|
}
|
|
|
|
# No good matches - provide general help
|
|
return {
|
|
"error": "Could not find any matches",
|
|
"hints": [
|
|
"Check for whitespace differences (tabs vs spaces)",
|
|
"Ensure you're including complete lines",
|
|
"Try a smaller, more unique portion of text",
|
|
"Use the streaming_command tool to view the file with visible whitespace",
|
|
],
|
|
}
|
|
|
|
@staticmethod
|
|
def prepare_edit_string(text: str) -> str:
|
|
"""Prepare a string for editing by handling common issues.
|
|
|
|
Args:
|
|
text: Text to prepare
|
|
|
|
Returns:
|
|
Cleaned text ready for editing
|
|
"""
|
|
# Remove any line number prefixes (common in AI copy-paste)
|
|
lines = []
|
|
for line in text.split("\n"):
|
|
# Remove common line number patterns while preserving indentation
|
|
# Match patterns like "1: ", "123: ", "1| ", "1- ", etc.
|
|
# But preserve the original indentation after the line number
|
|
match = re.match(r"^(\d+[:\|\-])\s(.*)", line)
|
|
if match:
|
|
# Keep only the content part (group 2) which includes any indentation
|
|
lines.append(match.group(2))
|
|
else:
|
|
# No line number pattern found, keep the line as-is
|
|
lines.append(line)
|
|
|
|
return "\n".join(lines)
|