Coverage for vibefpmgui/ai_edits.py: 93%

153 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Edits proposed by the AI (the Review panel's *Edit* mode), as 

2search/replace blocks:: 

3 

4 <<<<<<< SEARCH 

5 do i = 1, n 

6 ======= 

7 do j = 1, n 

8 >>>>>>> REPLACE 

9 

10A block is found in the file by its text, not by line numbers (which small 

11models get wrong, and which every earlier edit would shift). When the exact 

12text isn't there, it's looked for again ignoring trailing spaces, then 

13indentation (the replacement is then re-indented to match), then with line 

14numbers the model copied from the prompt taken out. An edit that matches 

15nothing, or more than one place, isn't applied: it never lands anywhere but 

16where it says. 

17 

18Pure text functions (no Qt), used by `vibefpmgui.window.ai_chat` and shown by 

19`vibefpmgui.edit_cards`. 

20""" 

21 

22from __future__ import annotations 

23 

24import re 

25from dataclasses import dataclass 

26 

27START_RE = re.compile(r"^\s*<{5,9}\s*SEARCH\s*$") 

28DIVIDER_RE = re.compile(r"^\s*={5,9}\s*$") 

29END_RE = re.compile(r"^\s*>{5,9}\s*REPLACE\s*$") 

30FENCE_RE = re.compile(r"^\s*```") 

31NUMBER_RE = re.compile(r"^\s*\d+\s?\| ?") # " 12| " (how the chat numbers a file's lines) 

32 

33PENDING, APPLIED, REJECTED, STALE, FAILED = "pending", "applied", "rejected", "stale", "failed" 

34 

35 

36@dataclass 

37class Edit: 

38 search: str # as the model wrote it 

39 replace: str 

40 start: int = -1 # where it applies in the text it was located in (offsets), -1: nowhere 

41 end: int = -1 

42 new_text: str = "" # what goes there (the replacement, re-indented if needed) 

43 line: int = 0 # the first and last lines it replaces (1-based) 

44 end_line: int = 0 

45 how: str = "" # "", "spacing", "indentation", "line numbers": what had to be ignored 

46 status: str = PENDING 

47 problem: str = "" # why it can't be applied 

48 diff: list[tuple[str, str]] | None = None # the change as shown when it was made or turned down 

49 

50 @property 

51 def located(self) -> bool: 

52 return self.start >= 0 

53 

54 def block(self) -> str: 

55 """The block as the model wrote it.""" 

56 return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE" 

57 

58 

59def parse_edits(answer: str) -> list[Edit]: 

60 """The complete search/replace blocks in an answer (also one still being 

61 written: a block counts once its REPLACE line has come).""" 

62 edits: list[Edit] = [] 

63 state, search, replace = "", [], [] 

64 for line in answer.replace("\r\n", "\n").split("\n"): 

65 if state == "": 

66 if START_RE.match(line): 

67 state, search, replace = "search", [], [] 

68 elif state == "search": 

69 if DIVIDER_RE.match(line): 

70 state = "replace" 

71 elif START_RE.match(line): # (a block left unfinished: start again) 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 search = [] 

73 else: 

74 search.append(line) 

75 elif END_RE.match(line): 

76 edits.append(Edit(_unfence(search), _unfence(replace))) 

77 state = "" 

78 else: 

79 replace.append(line) 

80 return edits 

81 

82 

83def _unfence(lines: list[str]) -> str: 

84 """A side of a block, without a ``` fence the model put around it.""" 

85 if lines and FENCE_RE.match(lines[0]): 

86 lines = lines[1:] 

87 if lines and FENCE_RE.match(lines[-1]): 

88 lines = lines[:-1] 

89 return "\n".join(lines) 

90 

91 

92def locate(text: str, edit: Edit, tab_width: int = 4) -> Edit: 

93 """Find where ``edit`` applies in ``text``: sets start/end, new_text, 

94 line/end_line and how; or problem (and status FAILED) when it can't.""" 

95 edit.start = edit.end = -1 

96 edit.problem, edit.how = "", "" 

97 if edit.status == FAILED: 

98 edit.status = PENDING 

99 if not edit.search.strip(): # (nothing to find: add at the end) 

100 joiner = "" if not text or text.endswith("\n") else "\n" 

101 _found(text, edit, len(text), len(text), joiner + edit.replace.strip("\n") + "\n", "") 

102 return edit 

103 count = text.count(edit.search) 

104 if count == 1: 

105 start = text.index(edit.search) 

106 _found(text, edit, start, start + len(edit.search), edit.replace, "") 

107 return edit 

108 if count > 1: 

109 return _fail(edit, f"matches {count} places: ask for a more specific change") 

110 search, replace = _trim_blank_ends(edit.search.split("\n"), edit.replace.split("\n")) 

111 lines = text.split("\n") 

112 for numbers in (False, True): 112 ↛ 132line 112 didn't jump to line 132 because the loop on line 112 didn't complete

113 if numbers: # (the model copied the prompt's line numbers) 

114 if not all(NUMBER_RE.match(s) for s in search if s.strip()): 

115 break 

116 search = [NUMBER_RE.sub("", s, count=1) for s in search] 

117 replace = [NUMBER_RE.sub("", r, count=1) for r in replace] 

118 for how, same in (("spacing", lambda a, b: a.rstrip() == b.rstrip()), 

119 ("indentation", lambda a, b: a.strip() == b.strip())): 

120 places = _places(lines, search, same, tab_width) 

121 if len(places) > 1: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 return _fail(edit, f"matches {len(places)} places: ask for a more specific change") 

123 if places: 

124 first = places[0] 

125 new = replace 

126 if how == "indentation": 

127 new = _reindent(replace, search, lines[first:first + len(search)], tab_width) 

128 start = sum(len(line) + 1 for line in lines[:first]) 

129 end = start + len("\n".join(lines[first:first + len(search)])) 

130 _found(text, edit, start, end, "\n".join(new), "line numbers" if numbers else how) 

131 return edit 

132 return _fail(edit, "not found: the model's text doesn't match the file") 

133 

134 

135def _places(lines: list[str], search: list[str], same, tab_width: int) -> list[int]: 

136 n = len(search) 

137 if n == 0: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 return [] 

139 wanted = [s.expandtabs(tab_width) for s in search] 

140 return [i for i in range(len(lines) - n + 1) 

141 if all(same(lines[i + k].expandtabs(tab_width), wanted[k]) for k in range(n))] 

142 

143 

144def _trim_blank_ends(search: list[str], replace: list[str]) -> tuple[list[str], list[str]]: 

145 """Blank lines around SEARCH (models add them) don't have to match; the 

146 same number goes from around REPLACE, when it has them too.""" 

147 while search and not search[0].strip(): 

148 search = search[1:] 

149 if replace and not replace[0].strip(): 149 ↛ 147line 149 didn't jump to line 147 because the condition on line 149 was always true

150 replace = replace[1:] 

151 while search and not search[-1].strip(): 

152 search = search[:-1] 

153 if replace and not replace[-1].strip(): 153 ↛ 151line 153 didn't jump to line 151 because the condition on line 153 was always true

154 replace = replace[:-1] 

155 return search, replace 

156 

157 

158def _indent(line: str, tab_width: int) -> int: 

159 expanded = line.expandtabs(tab_width) 

160 return len(expanded) - len(expanded.lstrip(" ")) 

161 

162 

163def _reindent(replace: list[str], search: list[str], found: list[str], tab_width: int) -> list[str]: 

164 """Shift the replacement by what the model's indentation was off by.""" 

165 pair = next(((s, f) for s, f in zip(search, found, strict=False) if s.strip()), None) 

166 if pair is None: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 return replace 

168 shift = _indent(pair[1], tab_width) - _indent(pair[0], tab_width) 

169 if shift == 0: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 return replace 

171 shifted = [] 

172 for line in replace: 

173 if not line.strip(): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 shifted.append(line) 

175 elif shift > 0: 

176 shifted.append(" " * shift + line) 

177 else: 

178 line = line.expandtabs(tab_width) 

179 shifted.append(line[min(-shift, _indent(line, tab_width)):]) 

180 return shifted 

181 

182 

183def _found(text: str, edit: Edit, start: int, end: int, new_text: str, how: str): 

184 # Removing whole lines: take the line break too, not to leave a blank line. 

185 if not new_text and (start == 0 or text[start - 1] == "\n"): 

186 if end < len(text) and text[end] == "\n": 

187 end += 1 

188 elif start > 0 and end == len(text): 

189 start -= 1 

190 edit.start, edit.end, edit.new_text, edit.how = start, end, new_text, how 

191 edit.line = text.count("\n", 0, start) + 1 

192 edit.end_line = edit.line + text.count("\n", start, max(start, end - 1)) 

193 

194 

195def _fail(edit: Edit, problem: str) -> Edit: 

196 edit.problem = problem 

197 edit.status = FAILED 

198 return edit 

199 

200 

201def apply(text: str, edit: Edit) -> str: 

202 """``text`` with a located edit made.""" 

203 if not edit.located: 

204 raise ValueError("the edit wasn't located") 

205 return text[:edit.start] + edit.new_text + text[edit.end:] 

206 

207 

208def apply_all(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]: 

209 """``text`` with every located edit made (from the end of the text, so 

210 the offsets stay right); edits overlapping one already made are left 

211 out. Returns the new text and the edits made.""" 

212 made: list[Edit] = [] 

213 for edit in sorted((e for e in edits if e.located), key=lambda e: e.start, reverse=True): 

214 if made and edit.end > made[-1].start: 

215 continue # (overlaps the next one down) 

216 text = apply(text, edit) 

217 made.append(edit) 

218 return text, made[::-1] 

219 

220 

221def report(edits: list[Edit]) -> str: 

222 """What became of an answer's edits, said in the next question ("edit 1, 

223 at line 30, was applied; edit 2 was rejected"), so the model knows. (In 

224 the user's words, not in the model's answer: small models copied notes 

225 put there, or a placeholder for the edit, instead of writing an edit.)""" 

226 words = {APPLIED: "was applied", PENDING: "is not applied yet", REJECTED: "was rejected by the user", 

227 FAILED: "wasn't found in the file", STALE: "no longer matches the file"} 

228 parts = [f"edit {i}{f', at line {e.line},' if e.line else ''} {words.get(e.status, 'was not used')}" 

229 for i, e in enumerate(edits, 1)] 

230 return f"(About your previous answer's edits: {'; '.join(parts)}.)" if parts else ""