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.
- biblicus/__init__.py +5 -5
- biblicus/analysis/__init__.py +1 -1
- biblicus/analysis/base.py +10 -10
- biblicus/analysis/markov.py +78 -68
- biblicus/analysis/models.py +47 -47
- biblicus/analysis/profiling.py +58 -48
- biblicus/analysis/topic_modeling.py +56 -51
- biblicus/cli.py +224 -177
- biblicus/{recipes.py → configuration.py} +14 -14
- biblicus/constants.py +2 -2
- biblicus/context_engine/assembler.py +49 -19
- biblicus/context_engine/retrieval.py +46 -42
- biblicus/corpus.py +116 -108
- biblicus/errors.py +3 -3
- biblicus/evaluation.py +27 -25
- biblicus/extraction.py +103 -98
- biblicus/extraction_evaluation.py +26 -26
- biblicus/extractors/deepgram_stt.py +7 -7
- biblicus/extractors/docling_granite_text.py +11 -11
- biblicus/extractors/docling_smol_text.py +11 -11
- biblicus/extractors/markitdown_text.py +4 -4
- biblicus/extractors/openai_stt.py +7 -7
- biblicus/extractors/paddleocr_vl_text.py +20 -18
- biblicus/extractors/pipeline.py +8 -8
- biblicus/extractors/rapidocr_text.py +3 -3
- biblicus/extractors/unstructured_text.py +3 -3
- biblicus/hooks.py +4 -4
- biblicus/knowledge_base.py +33 -31
- biblicus/models.py +78 -78
- biblicus/retrieval.py +47 -40
- biblicus/retrievers/__init__.py +50 -0
- biblicus/retrievers/base.py +65 -0
- biblicus/{backends → retrievers}/embedding_index_common.py +44 -41
- biblicus/{backends → retrievers}/embedding_index_file.py +87 -58
- biblicus/{backends → retrievers}/embedding_index_inmemory.py +88 -59
- biblicus/retrievers/hybrid.py +301 -0
- biblicus/{backends → retrievers}/scan.py +83 -73
- biblicus/{backends → retrievers}/sqlite_full_text_search.py +115 -101
- biblicus/{backends → retrievers}/tf_vector.py +87 -77
- biblicus/text/prompts.py +16 -8
- biblicus/text/tool_loop.py +63 -5
- {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/METADATA +30 -21
- biblicus-1.1.0.dist-info/RECORD +91 -0
- biblicus/backends/__init__.py +0 -50
- biblicus/backends/base.py +0 -65
- biblicus/backends/hybrid.py +0 -292
- biblicus-1.0.0.dist-info/RECORD +0 -91
- {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/WHEEL +0 -0
- {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/entry_points.txt +0 -0
- {biblicus-1.0.0.dist-info → biblicus-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
17
|
+
ExtractionSnapshotReference,
|
|
18
18
|
QueryBudget,
|
|
19
19
|
RetrievalResult,
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
32
|
+
class TfVectorConfiguration(BaseModel):
|
|
28
33
|
"""
|
|
29
|
-
Configuration for the term-frequency vector
|
|
34
|
+
Configuration for the term-frequency vector retriever.
|
|
30
35
|
|
|
31
|
-
:ivar
|
|
32
|
-
:vartype
|
|
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
|
-
|
|
44
|
+
extraction_snapshot: Optional[str] = None
|
|
40
45
|
snippet_characters: Optional[int] = None
|
|
41
46
|
|
|
42
47
|
|
|
43
|
-
class
|
|
48
|
+
class TfVectorRetriever:
|
|
44
49
|
"""
|
|
45
|
-
Deterministic vector
|
|
50
|
+
Deterministic vector retriever using term-frequency cosine similarity.
|
|
46
51
|
|
|
47
|
-
:ivar
|
|
48
|
-
:vartype
|
|
52
|
+
:ivar retriever_id: Retriever identifier.
|
|
53
|
+
:vartype retriever_id: str
|
|
49
54
|
"""
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
retriever_id = "tf-vector"
|
|
52
57
|
|
|
53
|
-
def
|
|
54
|
-
self, corpus: Corpus, *,
|
|
55
|
-
) ->
|
|
58
|
+
def build_snapshot(
|
|
59
|
+
self, corpus: Corpus, *, configuration_name: str, configuration: Dict[str, object]
|
|
60
|
+
) -> RetrievalSnapshot:
|
|
56
61
|
"""
|
|
57
|
-
Register a vector
|
|
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
|
|
62
|
-
:type
|
|
63
|
-
:param
|
|
64
|
-
:type
|
|
65
|
-
:return:
|
|
66
|
-
:rtype:
|
|
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
|
-
|
|
73
|
+
parsed_config = TfVectorConfiguration.model_validate(configuration)
|
|
69
74
|
catalog = corpus.load_catalog()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
name=
|
|
73
|
-
|
|
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(),
|
|
82
|
+
"text_items": _count_text_items(corpus, catalog.items.values(), parsed_config),
|
|
78
83
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
104
|
+
:param corpus: Corpus associated with the snapshot.
|
|
95
105
|
:type corpus: Corpus
|
|
96
|
-
:param
|
|
97
|
-
:type
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
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=
|
|
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
|
-
"
|
|
140
|
-
"
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
161
|
-
) -> Optional[
|
|
170
|
+
corpus: Corpus, configuration: TfVectorConfiguration
|
|
171
|
+
) -> Optional[ExtractionSnapshotReference]:
|
|
162
172
|
"""
|
|
163
|
-
Resolve an extraction
|
|
173
|
+
Resolve an extraction snapshot reference from a configuration.
|
|
164
174
|
|
|
165
|
-
:param corpus: Corpus associated with the
|
|
175
|
+
:param corpus: Corpus associated with the configuration.
|
|
166
176
|
:type corpus: Corpus
|
|
167
|
-
:param
|
|
168
|
-
:type
|
|
177
|
+
:param configuration: Parsed vector configuration.
|
|
178
|
+
:type configuration: TfVectorConfiguration
|
|
169
179
|
:return: Parsed extraction reference or None.
|
|
170
|
-
:rtype:
|
|
171
|
-
:raises FileNotFoundError: If an extraction
|
|
180
|
+
:rtype: ExtractionSnapshotReference or None
|
|
181
|
+
:raises FileNotFoundError: If an extraction snapshot is referenced but not present.
|
|
172
182
|
"""
|
|
173
|
-
if not
|
|
183
|
+
if not configuration.extraction_snapshot:
|
|
174
184
|
return None
|
|
175
|
-
extraction_reference =
|
|
176
|
-
|
|
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
|
-
|
|
188
|
+
snapshot_id=extraction_reference.snapshot_id,
|
|
179
189
|
)
|
|
180
|
-
if not
|
|
181
|
-
raise FileNotFoundError(f"Missing extraction
|
|
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],
|
|
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
|
|
196
|
-
:type
|
|
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,
|
|
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
|
-
|
|
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[
|
|
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
|
|
309
|
-
:type extraction_reference:
|
|
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
|
-
|
|
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[
|
|
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
|
|
402
|
-
:type extraction_reference:
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
biblicus/text/tool_loop.py
CHANGED
|
@@ -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
|
|
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
|
|
288
|
+
elif "not unique" in error_message:
|
|
270
289
|
guidance = (
|
|
271
|
-
"
|
|
272
|
-
"
|
|
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. "
|