Coverage for lintro / ai / fix_parsing.py: 100%

62 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Response parsing and diff generation for AI fix suggestions. 

2 

3Parses single and batch AI responses into AIFixSuggestion objects 

4and generates unified diffs between original and suggested code. 

5""" 

6 

7from __future__ import annotations 

8 

9import difflib 

10import json 

11from typing import TYPE_CHECKING 

12 

13from loguru import logger 

14 

15from lintro.ai.paths import relative_path 

16 

17if TYPE_CHECKING: 

18 from lintro.ai.models import AIFixSuggestion 

19 

20 

21def generate_diff( 

22 file_path: str, 

23 original: str, 

24 suggested: str, 

25) -> str: 

26 """Generate a unified diff between original and suggested code. 

27 

28 Args: 

29 file_path: Path for the diff header. 

30 original: Original code snippet. 

31 suggested: Suggested replacement. 

32 

33 Returns: 

34 Unified diff string. 

35 """ 

36 original_lines = original.splitlines() 

37 suggested_lines = suggested.splitlines() 

38 

39 rel = relative_path(file_path) 

40 diff = difflib.unified_diff( 

41 original_lines, 

42 suggested_lines, 

43 fromfile=f"a/{rel}", 

44 tofile=f"b/{rel}", 

45 ) 

46 return "\n".join(diff) 

47 

48 

49def parse_fix_response( 

50 content: str, 

51 file_path: str, 

52 line: int, 

53 code: str, 

54) -> AIFixSuggestion | None: 

55 """Parse an AI response into an AIFixSuggestion. 

56 

57 Args: 

58 content: Raw AI response content. 

59 file_path: Path to the file. 

60 line: Line number of the issue. 

61 code: Error code of the issue. 

62 

63 Returns: 

64 Parsed AIFixSuggestion, or None if parsing fails. 

65 """ 

66 from lintro.ai.models import AIFixSuggestion 

67 

68 try: 

69 data = json.loads(content) 

70 except json.JSONDecodeError: 

71 logger.debug(f"Failed to parse AI fix response for {file_path}:{line}") 

72 return None 

73 

74 if not isinstance(data, dict): 

75 logger.debug(f"AI fix response is not a JSON object for {file_path}:{line}") 

76 return None 

77 

78 original = data.get("original_code", "") 

79 suggested = data.get("suggested_code", "") 

80 

81 if not isinstance(original, str) or not isinstance(suggested, str): 

82 logger.debug(f"AI fix code fields are not strings for {file_path}:{line}") 

83 return None 

84 

85 if not original or not suggested: 

86 return None 

87 if original == suggested: 

88 logger.debug(f"AI suggested no-op fix for {file_path}:{line}") 

89 return None 

90 

91 diff = generate_diff(file_path, original, suggested) 

92 

93 return AIFixSuggestion( 

94 file=file_path, 

95 line=line, 

96 code=code, 

97 original_code=original, 

98 suggested_code=suggested, 

99 diff=diff, 

100 explanation=data.get("explanation", ""), 

101 confidence=data.get("confidence", "medium"), 

102 risk_level=data.get("risk_level", ""), 

103 ) 

104 

105 

106def parse_batch_response( 

107 content: str, 

108 file_path: str, 

109) -> list[AIFixSuggestion]: 

110 """Parse a batch AI response into a list of AIFixSuggestions. 

111 

112 Args: 

113 content: Raw AI response content (expected JSON array). 

114 file_path: Path to the file. 

115 

116 Returns: 

117 List of parsed AIFixSuggestions (may be empty on parse failure). 

118 """ 

119 from lintro.ai.models import AIFixSuggestion 

120 

121 try: 

122 data = json.loads(content) 

123 except json.JSONDecodeError: 

124 logger.debug(f"Failed to parse batch AI response for {file_path}") 

125 return [] 

126 

127 if not isinstance(data, list): 

128 logger.debug(f"Batch response is not an array for {file_path}") 

129 return [] 

130 

131 results: list[AIFixSuggestion] = [] 

132 for item in data: 

133 if not isinstance(item, dict): 

134 continue 

135 original = item.get("original_code", "") 

136 suggested = item.get("suggested_code", "") 

137 if ( 

138 not isinstance(original, str) 

139 or not isinstance(suggested, str) 

140 or not original 

141 or not suggested 

142 or original == suggested 

143 ): 

144 continue 

145 raw_line = item.get("line", 0) 

146 try: 

147 line = int(raw_line) if not isinstance(raw_line, int) else raw_line 

148 except (TypeError, ValueError): 

149 line = 0 

150 raw_code = item.get("code", "") 

151 code = str(raw_code) if not isinstance(raw_code, str) else raw_code 

152 diff = generate_diff(file_path, original, suggested) 

153 results.append( 

154 AIFixSuggestion( 

155 file=file_path, 

156 line=line, 

157 code=code, 

158 original_code=original, 

159 suggested_code=suggested, 

160 diff=diff, 

161 explanation=item.get("explanation", ""), 

162 confidence=item.get("confidence", "medium"), 

163 risk_level=item.get("risk_level", ""), 

164 ), 

165 ) 

166 return results