biblicus 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. biblicus/__init__.py +5 -5
  2. biblicus/analysis/__init__.py +1 -1
  3. biblicus/analysis/base.py +10 -10
  4. biblicus/analysis/markov.py +78 -68
  5. biblicus/analysis/models.py +47 -47
  6. biblicus/analysis/profiling.py +58 -48
  7. biblicus/analysis/topic_modeling.py +56 -51
  8. biblicus/cli.py +224 -177
  9. biblicus/{recipes.py → configuration.py} +14 -14
  10. biblicus/constants.py +2 -2
  11. biblicus/context_engine/assembler.py +49 -19
  12. biblicus/context_engine/retrieval.py +46 -42
  13. biblicus/corpus.py +116 -108
  14. biblicus/errors.py +3 -3
  15. biblicus/evaluation.py +27 -25
  16. biblicus/extraction.py +103 -98
  17. biblicus/extraction_evaluation.py +26 -26
  18. biblicus/extractors/deepgram_stt.py +7 -7
  19. biblicus/extractors/docling_granite_text.py +11 -11
  20. biblicus/extractors/docling_smol_text.py +11 -11
  21. biblicus/extractors/markitdown_text.py +4 -4
  22. biblicus/extractors/openai_stt.py +7 -7
  23. biblicus/extractors/paddleocr_vl_text.py +20 -18
  24. biblicus/extractors/pipeline.py +8 -8
  25. biblicus/extractors/rapidocr_text.py +3 -3
  26. biblicus/extractors/unstructured_text.py +3 -3
  27. biblicus/hooks.py +4 -4
  28. biblicus/knowledge_base.py +33 -31
  29. biblicus/models.py +78 -78
  30. biblicus/retrieval.py +47 -40
  31. biblicus/retrievers/__init__.py +50 -0
  32. biblicus/retrievers/base.py +65 -0
  33. biblicus/{backends → retrievers}/embedding_index_common.py +44 -41
  34. biblicus/{backends → retrievers}/embedding_index_file.py +87 -58
  35. biblicus/{backends → retrievers}/embedding_index_inmemory.py +88 -59
  36. biblicus/retrievers/hybrid.py +301 -0
  37. biblicus/{backends → retrievers}/scan.py +83 -73
  38. biblicus/{backends → retrievers}/sqlite_full_text_search.py +115 -101
  39. biblicus/{backends → retrievers}/tf_vector.py +87 -77
  40. biblicus/text/prompts.py +16 -8
  41. biblicus/text/tool_loop.py +63 -5
  42. {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/METADATA +30 -21
  43. biblicus-1.1.0.dist-info/RECORD +91 -0
  44. biblicus/backends/__init__.py +0 -50
  45. biblicus/backends/base.py +0 -65
  46. biblicus/backends/hybrid.py +0 -292
  47. biblicus-1.0.0.dist-info/RECORD +0 -91
  48. {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/WHEEL +0 -0
  49. {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/entry_points.txt +0 -0
  50. {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/licenses/LICENSE +0 -0
  51. {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  """
2
- Deterministic term-frequency vector retrieval backend.
2
+ Deterministic term-frequency vector retriever.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -14,87 +14,97 @@ from ..corpus import Corpus
14
14
  from ..frontmatter import parse_front_matter
15
15
  from ..models import (
16
16
  Evidence,
17
- ExtractionRunReference,
17
+ ExtractionSnapshotReference,
18
18
  QueryBudget,
19
19
  RetrievalResult,
20
- RetrievalRun,
21
- parse_extraction_run_reference,
20
+ RetrievalSnapshot,
21
+ parse_extraction_snapshot_reference,
22
+ )
23
+ from ..retrieval import (
24
+ apply_budget,
25
+ create_configuration_manifest,
26
+ create_snapshot_manifest,
27
+ hash_text,
22
28
  )
23
- from ..retrieval import apply_budget, create_recipe_manifest, create_run_manifest, hash_text
24
29
  from ..time import utc_now_iso
25
30
 
26
31
 
27
- class TfVectorRecipeConfig(BaseModel):
32
+ class TfVectorConfiguration(BaseModel):
28
33
  """
29
- Configuration for the term-frequency vector retrieval backend.
34
+ Configuration for the term-frequency vector retriever.
30
35
 
31
- :ivar extraction_run: Optional extraction run reference in the form extractor_id:run_id.
32
- :vartype extraction_run: str or None
36
+ :ivar extraction_snapshot: Optional extraction snapshot reference in the form extractor_id:snapshot_id.
37
+ :vartype extraction_snapshot: str or None
33
38
  :ivar snippet_characters: Optional maximum character count for returned evidence text.
34
39
  :vartype snippet_characters: int or None
35
40
  """
36
41
 
37
42
  model_config = ConfigDict(extra="forbid")
38
43
 
39
- extraction_run: Optional[str] = None
44
+ extraction_snapshot: Optional[str] = None
40
45
  snippet_characters: Optional[int] = None
41
46
 
42
47
 
43
- class TfVectorBackend:
48
+ class TfVectorRetriever:
44
49
  """
45
- Deterministic vector backend using term-frequency cosine similarity.
50
+ Deterministic vector retriever using term-frequency cosine similarity.
46
51
 
47
- :ivar backend_id: Backend identifier.
48
- :vartype backend_id: str
52
+ :ivar retriever_id: Retriever identifier.
53
+ :vartype retriever_id: str
49
54
  """
50
55
 
51
- backend_id = "tf-vector"
56
+ retriever_id = "tf-vector"
52
57
 
53
- def build_run(
54
- self, corpus: Corpus, *, recipe_name: str, config: Dict[str, object]
55
- ) -> RetrievalRun:
58
+ def build_snapshot(
59
+ self, corpus: Corpus, *, configuration_name: str, configuration: Dict[str, object]
60
+ ) -> RetrievalSnapshot:
56
61
  """
57
- Register a vector backend run (no materialization).
62
+ Register a vector retriever snapshot (no snapshot artifacts).
58
63
 
59
64
  :param corpus: Corpus to build against.
60
65
  :type corpus: Corpus
61
- :param recipe_name: Human-readable recipe name.
62
- :type recipe_name: str
63
- :param config: Backend-specific configuration values.
64
- :type config: dict[str, object]
65
- :return: Run manifest describing the build.
66
- :rtype: RetrievalRun
66
+ :param configuration_name: Human-readable configuration name.
67
+ :type configuration_name: str
68
+ :param configuration: Retriever-specific configuration values.
69
+ :type configuration: dict[str, object]
70
+ :return: Snapshot manifest describing the build.
71
+ :rtype: RetrievalSnapshot
67
72
  """
68
- recipe_config = TfVectorRecipeConfig.model_validate(config)
73
+ parsed_config = TfVectorConfiguration.model_validate(configuration)
69
74
  catalog = corpus.load_catalog()
70
- recipe = create_recipe_manifest(
71
- backend_id=self.backend_id,
72
- name=recipe_name,
73
- config=recipe_config.model_dump(),
75
+ configuration_manifest = create_configuration_manifest(
76
+ retriever_id=self.retriever_id,
77
+ name=configuration_name,
78
+ configuration=parsed_config.model_dump(),
74
79
  )
75
80
  stats = {
76
81
  "items": len(catalog.items),
77
- "text_items": _count_text_items(corpus, catalog.items.values(), recipe_config),
82
+ "text_items": _count_text_items(corpus, catalog.items.values(), parsed_config),
78
83
  }
79
- run = create_run_manifest(corpus, recipe=recipe, stats=stats, artifact_paths=[])
80
- corpus.write_run(run)
81
- return run
84
+ snapshot = create_snapshot_manifest(
85
+ corpus,
86
+ configuration=configuration_manifest,
87
+ stats=stats,
88
+ snapshot_artifacts=[],
89
+ )
90
+ corpus.write_snapshot(snapshot)
91
+ return snapshot
82
92
 
83
93
  def query(
84
94
  self,
85
95
  corpus: Corpus,
86
96
  *,
87
- run: RetrievalRun,
97
+ snapshot: RetrievalSnapshot,
88
98
  query_text: str,
89
99
  budget: QueryBudget,
90
100
  ) -> RetrievalResult:
91
101
  """
92
102
  Query the corpus using term-frequency cosine similarity.
93
103
 
94
- :param corpus: Corpus associated with the run.
104
+ :param corpus: Corpus associated with the snapshot.
95
105
  :type corpus: Corpus
96
- :param run: Run manifest to use for querying.
97
- :type run: RetrievalRun
106
+ :param snapshot: Snapshot manifest to use for querying.
107
+ :type snapshot: RetrievalSnapshot
98
108
  :param query_text: Query text to execute.
99
109
  :type query_text: str
100
110
  :param budget: Evidence selection budget.
@@ -102,15 +112,15 @@ class TfVectorBackend:
102
112
  :return: Retrieval results containing evidence.
103
113
  :rtype: RetrievalResult
104
114
  """
105
- recipe_config = TfVectorRecipeConfig.model_validate(run.recipe.config)
115
+ parsed_config = TfVectorConfiguration.model_validate(snapshot.configuration.configuration)
106
116
  query_tokens = _tokenize_text(query_text)
107
117
  if not query_tokens:
108
118
  return RetrievalResult(
109
119
  query_text=query_text,
110
120
  budget=budget,
111
- run_id=run.run_id,
112
- recipe_id=run.recipe.recipe_id,
113
- backend_id=self.backend_id,
121
+ snapshot_id=snapshot.snapshot_id,
122
+ configuration_id=snapshot.configuration.configuration_id,
123
+ retriever_id=snapshot.configuration.retriever_id,
114
124
  generated_at=utc_now_iso(),
115
125
  evidence=[],
116
126
  stats={"candidates": 0, "returned": 0},
@@ -118,7 +128,7 @@ class TfVectorBackend:
118
128
  query_vector = _term_frequencies(query_tokens)
119
129
  query_norm = _vector_norm(query_vector)
120
130
  catalog = corpus.load_catalog()
121
- extraction_reference = _resolve_extraction_reference(corpus, recipe_config)
131
+ extraction_reference = _resolve_extraction_reference(corpus, parsed_config)
122
132
  scored_candidates = _score_items(
123
133
  corpus,
124
134
  catalog.items.values(),
@@ -126,7 +136,7 @@ class TfVectorBackend:
126
136
  query_vector=query_vector,
127
137
  query_norm=query_norm,
128
138
  extraction_reference=extraction_reference,
129
- snippet_characters=recipe_config.snippet_characters,
139
+ snippet_characters=parsed_config.snippet_characters,
130
140
  )
131
141
  sorted_candidates = sorted(
132
142
  scored_candidates,
@@ -136,8 +146,8 @@ class TfVectorBackend:
136
146
  evidence_item.model_copy(
137
147
  update={
138
148
  "rank": index,
139
- "recipe_id": run.recipe.recipe_id,
140
- "run_id": run.run_id,
149
+ "configuration_id": snapshot.configuration.configuration_id,
150
+ "snapshot_id": snapshot.snapshot_id,
141
151
  }
142
152
  )
143
153
  for index, evidence_item in enumerate(sorted_candidates, start=1)
@@ -147,9 +157,9 @@ class TfVectorBackend:
147
157
  return RetrievalResult(
148
158
  query_text=query_text,
149
159
  budget=budget,
150
- run_id=run.run_id,
151
- recipe_id=run.recipe.recipe_id,
152
- backend_id=self.backend_id,
160
+ snapshot_id=snapshot.snapshot_id,
161
+ configuration_id=snapshot.configuration.configuration_id,
162
+ retriever_id=snapshot.configuration.retriever_id,
153
163
  generated_at=utc_now_iso(),
154
164
  evidence=evidence,
155
165
  stats=stats,
@@ -157,33 +167,33 @@ class TfVectorBackend:
157
167
 
158
168
 
159
169
  def _resolve_extraction_reference(
160
- corpus: Corpus, recipe_config: TfVectorRecipeConfig
161
- ) -> Optional[ExtractionRunReference]:
170
+ corpus: Corpus, configuration: TfVectorConfiguration
171
+ ) -> Optional[ExtractionSnapshotReference]:
162
172
  """
163
- Resolve an extraction run reference from a recipe config.
173
+ Resolve an extraction snapshot reference from a configuration.
164
174
 
165
- :param corpus: Corpus associated with the recipe.
175
+ :param corpus: Corpus associated with the configuration.
166
176
  :type corpus: Corpus
167
- :param recipe_config: Parsed vector recipe configuration.
168
- :type recipe_config: TfVectorRecipeConfig
177
+ :param configuration: Parsed vector configuration.
178
+ :type configuration: TfVectorConfiguration
169
179
  :return: Parsed extraction reference or None.
170
- :rtype: ExtractionRunReference or None
171
- :raises FileNotFoundError: If an extraction run is referenced but not present.
180
+ :rtype: ExtractionSnapshotReference or None
181
+ :raises FileNotFoundError: If an extraction snapshot is referenced but not present.
172
182
  """
173
- if not recipe_config.extraction_run:
183
+ if not configuration.extraction_snapshot:
174
184
  return None
175
- extraction_reference = parse_extraction_run_reference(recipe_config.extraction_run)
176
- run_dir = corpus.extraction_run_dir(
185
+ extraction_reference = parse_extraction_snapshot_reference(configuration.extraction_snapshot)
186
+ snapshot_dir = corpus.extraction_snapshot_dir(
177
187
  extractor_id=extraction_reference.extractor_id,
178
- run_id=extraction_reference.run_id,
188
+ snapshot_id=extraction_reference.snapshot_id,
179
189
  )
180
- if not run_dir.is_dir():
181
- raise FileNotFoundError(f"Missing extraction run: {extraction_reference.as_string()}")
190
+ if not snapshot_dir.is_dir():
191
+ raise FileNotFoundError(f"Missing extraction snapshot: {extraction_reference.as_string()}")
182
192
  return extraction_reference
183
193
 
184
194
 
185
195
  def _count_text_items(
186
- corpus: Corpus, items: Iterable[object], recipe_config: TfVectorRecipeConfig
196
+ corpus: Corpus, items: Iterable[object], configuration: TfVectorConfiguration
187
197
  ) -> int:
188
198
  """
189
199
  Count catalog items that represent text content.
@@ -192,19 +202,19 @@ def _count_text_items(
192
202
  :type corpus: Corpus
193
203
  :param items: Catalog items to inspect.
194
204
  :type items: Iterable[object]
195
- :param recipe_config: Parsed vector recipe configuration.
196
- :type recipe_config: TfVectorRecipeConfig
205
+ :param configuration: Parsed vector configuration.
206
+ :type configuration: TfVectorConfiguration
197
207
  :return: Number of text items.
198
208
  :rtype: int
199
209
  """
200
210
  text_item_count = 0
201
- extraction_reference = _resolve_extraction_reference(corpus, recipe_config)
211
+ extraction_reference = _resolve_extraction_reference(corpus, configuration)
202
212
  for catalog_item in items:
203
213
  item_id = str(getattr(catalog_item, "id", ""))
204
214
  if extraction_reference and item_id:
205
215
  extracted_text = corpus.read_extracted_text(
206
216
  extractor_id=extraction_reference.extractor_id,
207
- run_id=extraction_reference.run_id,
217
+ snapshot_id=extraction_reference.snapshot_id,
208
218
  item_id=item_id,
209
219
  )
210
220
  if isinstance(extracted_text, str) and extracted_text.strip():
@@ -292,7 +302,7 @@ def _load_text_from_item(
292
302
  item_id: str,
293
303
  relpath: str,
294
304
  media_type: str,
295
- extraction_reference: Optional[ExtractionRunReference],
305
+ extraction_reference: Optional[ExtractionSnapshotReference],
296
306
  ) -> Optional[str]:
297
307
  """
298
308
  Load a text payload from a catalog item.
@@ -305,15 +315,15 @@ def _load_text_from_item(
305
315
  :type relpath: str
306
316
  :param media_type: Media type for the stored content.
307
317
  :type media_type: str
308
- :param extraction_reference: Optional extraction run reference.
309
- :type extraction_reference: ExtractionRunReference or None
318
+ :param extraction_reference: Optional extraction snapshot reference.
319
+ :type extraction_reference: ExtractionSnapshotReference or None
310
320
  :return: Text payload or None if not decodable as text.
311
321
  :rtype: str or None
312
322
  """
313
323
  if extraction_reference:
314
324
  extracted_text = corpus.read_extracted_text(
315
325
  extractor_id=extraction_reference.extractor_id,
316
- run_id=extraction_reference.run_id,
326
+ snapshot_id=extraction_reference.snapshot_id,
317
327
  item_id=item_id,
318
328
  )
319
329
  if isinstance(extracted_text, str) and extracted_text.strip():
@@ -382,7 +392,7 @@ def _score_items(
382
392
  query_tokens: List[str],
383
393
  query_vector: Dict[str, float],
384
394
  query_norm: float,
385
- extraction_reference: Optional[ExtractionRunReference],
395
+ extraction_reference: Optional[ExtractionSnapshotReference],
386
396
  snippet_characters: Optional[int],
387
397
  ) -> List[Evidence]:
388
398
  """
@@ -398,8 +408,8 @@ def _score_items(
398
408
  :type query_vector: dict[str, float]
399
409
  :param query_norm: Query vector norm.
400
410
  :type query_norm: float
401
- :param extraction_reference: Optional extraction run reference.
402
- :type extraction_reference: ExtractionRunReference or None
411
+ :param extraction_reference: Optional extraction snapshot reference.
412
+ :type extraction_reference: ExtractionSnapshotReference or None
403
413
  :param snippet_characters: Optional maximum character count for returned evidence text.
404
414
  :type snippet_characters: int or None
405
415
  :return: Evidence candidates with provisional ranks.
@@ -444,8 +454,8 @@ def _score_items(
444
454
  span_start=span_start,
445
455
  span_end=span_end,
446
456
  stage="tf-vector",
447
- recipe_id="",
448
- run_id="",
457
+ configuration_id="",
458
+ snapshot_id="",
449
459
  metadata=getattr(catalog_item, "metadata", {}) or {},
450
460
  hash=hash_text(evidence_text or ""),
451
461
  )
biblicus/text/prompts.py CHANGED
@@ -11,14 +11,16 @@ DEFAULT_EXTRACT_SYSTEM_PROMPT = (
11
11
  "Interpret the word 'return' in the user's request as: wrap the returned text with "
12
12
  "<span>...</span> in-place in the current text.\n\n"
13
13
  "Use the str_replace tool to insert <span>...</span> tags and the done tool when finished.\n"
14
+ "For long spans, insert <span> and </span> using separate str_replace calls. "
15
+ "For short spans (a few words), it is acceptable to insert both tags in one call.\n"
14
16
  "When finished, call done. Do NOT return JSON in the assistant message.\n\n"
15
17
  "Rules:\n"
16
18
  "- Use str_replace only.\n"
17
19
  "- old_str must match exactly once in the current text.\n"
18
20
  "- When choosing old_str, copy the exact substring (including punctuation/case) from the current text.\n"
19
21
  "- old_str and new_str must be non-empty strings.\n"
20
- "- new_str must be identical to old_str with only <span> and </span> inserted.\n"
21
- "- Do not include <span> or </span> inside old_str or new_str.\n"
22
+ "- new_str must be identical to old_str with only <span> and/or </span> inserted.\n"
23
+ "- Do not include <span> or </span> inside old_str.\n"
22
24
  "- Do not insert nested spans.\n"
23
25
  "- If a tool call fails due to non-unique old_str, retry with a longer unique old_str.\n"
24
26
  "- If a tool call fails, read the error and keep editing. Do not call done until spans are inserted.\n"
@@ -49,13 +51,15 @@ DEFAULT_ANNOTATE_SYSTEM_PROMPT = (
49
51
  '<span ATTRIBUTE="VALUE">...</span> in-place in the current text.\n'
50
52
  "Each span must include exactly one attribute from: {{ allowed_attributes }}.\n\n"
51
53
  "Use the str_replace tool to insert span tags and the done tool when finished.\n"
54
+ "For long spans, insert the opening and closing tags using separate str_replace calls. "
55
+ "For short spans (a few words), it is acceptable to insert both tags in one call.\n"
52
56
  "When finished, call done. Do NOT return JSON in the assistant message.\n\n"
53
57
  "Rules:\n"
54
58
  "- Use str_replace only.\n"
55
59
  "- old_str must match exactly once in the current text.\n"
56
60
  "- old_str and new_str must be non-empty strings.\n"
57
- "- new_str must be identical to old_str with only <span ...> and </span> inserted.\n"
58
- "- Do not include <span or </span> inside old_str or new_str.\n"
61
+ "- new_str must be identical to old_str with only <span ...> and/or </span> inserted.\n"
62
+ "- Do not include <span or </span> inside old_str.\n"
59
63
  "- Do not insert nested spans.\n"
60
64
  "- Do not wrap text that is already inside a span; spans must never overlap.\n"
61
65
  "- If a name appears inside an existing span, leave it alone and wrap only bare text.\n"
@@ -80,13 +84,15 @@ DEFAULT_LINK_SYSTEM_PROMPT = (
80
84
  "- Do not call done until every repeated name or entity in the text is wrapped.\n"
81
85
  "- If a name appears multiple times, there must be one id and refs for every later occurrence.\n\n"
82
86
  "Use the str_replace tool to insert span tags and the done tool when finished.\n"
87
+ "For long spans, insert the opening and closing tags using separate str_replace calls. "
88
+ "For short spans (a few words), it is acceptable to insert both tags in one call.\n"
83
89
  "When finished, call done. Do NOT return JSON in the assistant message.\n\n"
84
90
  "Rules:\n"
85
91
  "- Use str_replace only.\n"
86
92
  "- old_str must match exactly once in the current text.\n"
87
93
  "- old_str and new_str must be non-empty strings.\n"
88
- "- new_str must be identical to old_str with only <span ...> and </span> inserted.\n"
89
- "- Do not include <span or </span> inside old_str or new_str.\n"
94
+ "- new_str must be identical to old_str with only <span ...> and/or </span> inserted.\n"
95
+ "- Do not include <span or </span> inside old_str.\n"
90
96
  "- Do not insert nested spans.\n"
91
97
  "- If a tool call fails due to non-unique old_str, retry with a longer unique old_str.\n"
92
98
  "- If a tool call fails, read the error and keep editing. Do not call done until spans are inserted.\n"
@@ -100,13 +106,15 @@ DEFAULT_REDACT_SYSTEM_PROMPT = (
100
106
  "<span>...</span> in-place in the current text.\n"
101
107
  "If redaction types are provided, use a redact attribute with one of: {{ redaction_types }}.\n\n"
102
108
  "Use the str_replace tool to insert span tags and the done tool when finished.\n"
109
+ "For long spans, insert the opening and closing tags using separate str_replace calls. "
110
+ "For short spans (a few words), it is acceptable to insert both tags in one call.\n"
103
111
  "When finished, call done. Do NOT return JSON in the assistant message.\n\n"
104
112
  "Rules:\n"
105
113
  "- Use str_replace only.\n"
106
114
  "- old_str must match exactly once in the current text.\n"
107
115
  "- old_str and new_str must be non-empty strings.\n"
108
- "- new_str must be identical to old_str with only <span ...> and </span> inserted.\n"
109
- "- Do not include <span or </span> inside old_str or new_str.\n"
116
+ "- new_str must be identical to old_str with only <span ...> and/or </span> inserted.\n"
117
+ "- Do not include <span or </span> inside old_str.\n"
110
118
  "- Do not insert nested spans.\n"
111
119
  "- If a tool call fails due to non-unique old_str, retry with a longer unique old_str.\n"
112
120
  "- If a tool call fails, read the error and keep editing. Do not call done until spans are inserted.\n"
@@ -5,6 +5,7 @@ Shared tool loop for virtual file edit workflows.
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
+ import re
8
9
  from dataclasses import dataclass
9
10
  from typing import Any, Callable, Dict, List, Optional, Sequence
10
11
 
@@ -182,6 +183,18 @@ def run_tool_loop(
182
183
  last_error = "Tool loop requires non-empty old_str and new_str"
183
184
  tool_result = f"Error: {last_error}"
184
185
  else:
186
+ if old_str == new_str:
187
+ last_error = "Tool loop requires str_replace to make a change"
188
+ tool_result = f"Error: {last_error}"
189
+ had_tool_error = True
190
+ messages.append(
191
+ {
192
+ "role": "tool",
193
+ "tool_call_id": tool_call.get("id", ""),
194
+ "content": tool_result,
195
+ }
196
+ )
197
+ continue
185
198
  try:
186
199
  current_text = apply_str_replace(current_text, old_str, new_str)
187
200
  tool_result = (
@@ -214,6 +227,7 @@ def run_tool_loop(
214
227
  "content": _build_tool_error_message(
215
228
  error_message=last_error,
216
229
  current_text=current_text,
230
+ old_str=old_str if "old_str" in locals() else "",
217
231
  ),
218
232
  }
219
233
  )
@@ -260,19 +274,26 @@ def _build_retry_message(
260
274
  )
261
275
 
262
276
 
263
- def _build_tool_error_message(*, error_message: str, current_text: str) -> str:
264
- if "not unique" in error_message:
277
+ def _build_tool_error_message(*, error_message: str, current_text: str, old_str: str) -> str:
278
+ if "found 0 matches" in error_message or "not found" in error_message:
279
+ guidance = (
280
+ "Copy the exact old_str from the current text (including punctuation/case) "
281
+ "or call view to inspect the latest text."
282
+ )
283
+ elif "found " in error_message and "matches" in error_message:
265
284
  guidance = (
266
285
  "Use a longer unique old_str by including surrounding words or punctuation "
267
286
  "so it matches exactly once."
268
287
  )
269
- elif "not found" in error_message:
288
+ elif "not unique" in error_message:
270
289
  guidance = (
271
- "Copy the exact old_str from the current text (including punctuation/case) "
272
- "or call view to inspect the latest text."
290
+ "Use a longer unique old_str by including surrounding words or punctuation "
291
+ "so it matches exactly once."
273
292
  )
274
293
  else:
275
294
  guidance = "Fix the tool call and try again."
295
+ if old_str and len(old_str) <= 3:
296
+ guidance = f"{guidance} If unsure, call view to pick a longer unique substring."
276
297
  return (
277
298
  "Your last tool call failed.\n"
278
299
  f"Error: {error_message}\n"
@@ -282,6 +303,43 @@ def _build_tool_error_message(*, error_message: str, current_text: str) -> str:
282
303
  )
283
304
 
284
305
 
306
+ _SPAN_OPEN_PATTERN = re.compile(r"<span\b[^>]*>")
307
+ _SPAN_CLOSE_PATTERN = re.compile(r"</span>")
308
+ _SLICE_PATTERN = re.compile(r"<slice\s*/>")
309
+
310
+
311
+ def _strip_markup(text: str) -> str:
312
+ without_spans = _SPAN_CLOSE_PATTERN.sub("", _SPAN_OPEN_PATTERN.sub("", text))
313
+ return _SLICE_PATTERN.sub("", without_spans)
314
+
315
+
316
+ def apply_unique_str_replace(text: str, old_str: str, new_str: str) -> str:
317
+ """
318
+ Apply a single replacement only when old_str matches exactly once.
319
+
320
+ :param text: Current text content.
321
+ :type text: str
322
+ :param old_str: Substring to replace.
323
+ :type old_str: str
324
+ :param new_str: Replacement string.
325
+ :type new_str: str
326
+ :return: Updated text.
327
+ :rtype: str
328
+ :raises ValueError: If old_str matches zero or multiple times.
329
+ """
330
+ matches = text.count(old_str)
331
+ if matches != 1:
332
+ raise ValueError(
333
+ "Tool loop requires old_str to match exactly once " f"(found {matches} matches)"
334
+ )
335
+ if _strip_markup(old_str) != _strip_markup(new_str):
336
+ raise ValueError(
337
+ "Tool loop replacements may only insert markup tags; "
338
+ "the underlying text must stay the same"
339
+ )
340
+ return text.replace(old_str, new_str, 1)
341
+
342
+
285
343
  def _build_no_tool_calls_message(*, assistant_message: str, current_text: str) -> str:
286
344
  guidance = (
287
345
  "Use the tools to edit the text. "