@@ -125,69 +125,133 @@ def _edits(base_tokens: list, side_tokens: list) -> list:
125125 ]
126126
127127
128- def apply_fix (base : str , current : str , incoming : str ) -> str :
129- """Apply the ``base → incoming`` correction on top of ``current``.
130-
131- Three-way word merge, so accepting one finding after another keeps the
132- earlier accept instead of resurrecting the run-time caption: regions the
133- fix rewrites take the fix's words, regions only ``current`` changed keep
134- the current words, and when both touched the same region the fix — the
135- one the user just accepted — wins.
128+ # A base segment ends on a word carrying sentence/clause punctuation, so a
129+ # conflict resolves over a whole sentence (prose) or a whole tag (booru).
130+ _SEGMENT_END = re .compile (r"[.!?;,]$" )
131+
132+
133+ def _segment_bounds (tokens : list ) -> list :
134+ """Return the base's segments as token ranges (sentences / clauses)."""
135+ bounds , start = [], 0
136+ for index , token in enumerate (tokens ):
137+ if not token .isspace () and _SEGMENT_END .search (token ):
138+ end = index + 1
139+ if end < len (tokens ) and tokens [end ].isspace ():
140+ end += 1
141+ bounds .append ((start , end ))
142+ start = end
143+ if start < len (tokens ) or not bounds :
144+ bounds .append ((start , len (tokens )))
145+ return bounds
146+
147+
148+ def _overlaps (first : tuple , second : tuple ) -> bool :
149+ """Whether two base-coordinate edits collide (insertions zero-width)."""
150+ a1 , a2 , _ = first
151+ b1 , b2 , _ = second
152+ if a1 == a2 and b1 == b2 : # two insertions at the same point collide
153+ return a1 == b1
154+ return a1 < b2 and b1 < a2
155+
156+
157+ def _replay (tokens : list , start : int , end : int , edits : list ) -> list :
158+ """Return ``tokens[start:end]`` with the (contained) edits applied."""
159+ output = []
160+ position = start
161+ for span_start , span_end , replacement in sorted (
162+ edits , key = lambda edit : (edit [0 ], edit [1 ])
163+ ):
164+ output .extend (tokens [position :span_start ])
165+ output .extend (replacement )
166+ position = span_end
167+ output .extend (tokens [position :end ])
168+ return output
169+
170+
171+ def resolve_fix (base : str , current : str , incoming : str ) -> dict :
172+ """Resolve the ``base → incoming`` fix against the live ``current``.
173+
174+ Git-style three-way merge that never re-locates a diff: both sides'
175+ edits stay in the coordinates of the ``base`` they were computed
176+ against, so collision detection is exact interval arithmetic — no fuzzy
177+ matching, hence no false anchor when a sentence changed.
178+
179+ Edits that do not collide merge automatically, wherever they sit. When
180+ the two sides collide, the *whole segment* (sentence / clause) around
181+ the collision takes the incoming fix's rendering verbatim — one
182+ coherent, judge-authored sentence, never an interleaving of two
183+ rewrites — and the result is flagged so the UI shows a ⚠ conflict the
184+ user settles with accept / reject / inline edit.
185+
186+ Returns ``{"text": str, "conflict": bool}``.
136187 """
137188 if base == incoming : # no-op fix — never clobber the live caption
138- return current
189+ return { "text" : current , "conflict" : False }
139190 if current in (base , incoming ):
140- return incoming
191+ return { "text" : incoming , "conflict" : False }
141192 tokens = _tokens (base )
142193 ours = _edits (tokens , _tokens (current ))
143194 theirs = _edits (tokens , _tokens (incoming ))
144195
145- merged = []
146- cursor = 0 # position in base tokens
147- oi = ti = 0
148- while oi < len (ours ) or ti < len (theirs ):
149- # Open the next cluster at the earliest remaining edit, then absorb
150- # every span (either side) that overlaps or touches it.
151- start = min (
152- edits [index ][0 ]
153- for edits , index in ((ours , oi ), (theirs , ti ))
154- if index < len (edits )
196+ # Group the base's segments so every edit falls entirely inside one
197+ # group (an edit spanning a boundary fuses the neighbours).
198+ groups = _segment_bounds (tokens )
199+ for start , end , _ in ours + theirs :
200+ end = max (end , min (start + 1 , len (tokens )))
201+ touched = [g for g in groups if g [0 ] < end and start < g [1 ]] or [
202+ groups [- 1 ]
203+ ]
204+ fused = (
205+ min (g [0 ] for g in touched ),
206+ max (g [1 ] for g in touched ),
155207 )
156- end = start
157- cluster_ours , cluster_theirs = [], []
208+ groups = [g for g in groups if g not in touched ]
209+ groups .append (fused )
210+ groups .sort ()
158211
159- def touches (span_start , cluster_end ):
160- """Overlap, or separated from the cluster by whitespace only."""
161- return span_start <= cluster_end or all (
162- token .isspace () for token in tokens [cluster_end :span_start ]
212+ merged = []
213+ conflict = False
214+ for index , (group_start , group_end ) in enumerate (groups ):
215+ last = index == len (groups ) - 1
216+ inside_ours = [
217+ e for e in ours if _inside (e , group_start , group_end , last )
218+ ]
219+ inside_theirs = [
220+ e for e in theirs if _inside (e , group_start , group_end , last )
221+ ]
222+ collides = any (
223+ _overlaps (first , second )
224+ for first in inside_ours
225+ for second in inside_theirs
226+ )
227+ if collides :
228+ # The judge's whole sentence, verbatim; our edits there drop.
229+ conflict = True
230+ merged .extend (
231+ _replay (tokens , group_start , group_end , inside_theirs )
232+ )
233+ else :
234+ merged .extend (
235+ _replay (
236+ tokens ,
237+ group_start ,
238+ group_end ,
239+ inside_ours + inside_theirs ,
240+ )
163241 )
242+ return {"text" : "" .join (merged ), "conflict" : conflict }
164243
165- grew = True
166- while grew :
167- grew = False
168- while oi < len (ours ) and touches (ours [oi ][0 ], end ):
169- cluster_ours .append (ours [oi ])
170- end = max (end , ours [oi ][1 ])
171- oi += 1
172- grew = True
173- while ti < len (theirs ) and touches (theirs [ti ][0 ], end ):
174- cluster_theirs .append (theirs [ti ])
175- end = max (end , theirs [ti ][1 ])
176- ti += 1
177- grew = True
178- merged .extend (tokens [cursor :start ])
179- # Conflicting cluster → the incoming fix wins; otherwise replay the
180- # only side that touched it.
181- winner = cluster_theirs if cluster_theirs else cluster_ours
182- position = start
183- for span_start , span_end , replacement in winner :
184- merged .extend (tokens [position :span_start ])
185- merged .extend (replacement )
186- position = span_end
187- merged .extend (tokens [position :end ])
188- cursor = end
189- merged .extend (tokens [cursor :])
190- return "" .join (merged )
244+
245+ def _inside (edit : tuple , start : int , end : int , last : bool ) -> bool :
246+ """Whether an edit falls inside ``[start, end)`` (inserts included).
247+
248+ An insertion belongs to the group containing its point; one sitting at
249+ the very end of the text belongs to the last group.
250+ """
251+ e1 , e2 , _ = edit
252+ if e1 == e2 :
253+ return start <= e1 < end or (last and e1 == end )
254+ return start <= e1 and e2 <= end
191255
192256
193257def judged_finding (before : str , verdict : dict | None ) -> dict | None :
0 commit comments