biblicus 0.13.0__py3-none-any.whl → 0.15.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 +1 -1
- biblicus/_vendor/dotyaml/__init__.py +2 -2
- biblicus/_vendor/dotyaml/loader.py +40 -1
- biblicus/ai/__init__.py +39 -0
- biblicus/ai/embeddings.py +114 -0
- biblicus/ai/llm.py +138 -0
- biblicus/ai/models.py +226 -0
- biblicus/analysis/__init__.py +5 -2
- biblicus/analysis/markov.py +1624 -0
- biblicus/analysis/models.py +754 -1
- biblicus/analysis/topic_modeling.py +98 -19
- biblicus/backends/hybrid.py +6 -1
- biblicus/backends/sqlite_full_text_search.py +4 -2
- biblicus/cli.py +118 -23
- biblicus/context.py +2 -2
- biblicus/recipes.py +136 -0
- biblicus/text/__init__.py +43 -0
- biblicus/text/annotate.py +222 -0
- biblicus/text/extract.py +210 -0
- biblicus/text/link.py +519 -0
- biblicus/text/markup.py +200 -0
- biblicus/text/models.py +319 -0
- biblicus/text/prompts.py +113 -0
- biblicus/text/redact.py +229 -0
- biblicus/text/slice.py +155 -0
- biblicus/text/tool_loop.py +334 -0
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/METADATA +90 -26
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/RECORD +32 -17
- biblicus/analysis/llm.py +0 -106
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/WHEEL +0 -0
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/entry_points.txt +0 -0
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/licenses/LICENSE +0 -0
- {biblicus-0.13.0.dist-info → biblicus-0.15.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agentic text annotation using virtual file edits.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Iterable, List, Sequence
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, StrictUndefined
|
|
10
|
+
|
|
11
|
+
from .markup import (
|
|
12
|
+
TextAnnotatedSpan,
|
|
13
|
+
build_span_context_section,
|
|
14
|
+
parse_span_markup,
|
|
15
|
+
strip_span_tags,
|
|
16
|
+
)
|
|
17
|
+
from .models import TextAnnotateRequest, TextAnnotateResult
|
|
18
|
+
from .tool_loop import request_confirmation, run_tool_loop
|
|
19
|
+
|
|
20
|
+
DEFAULT_ANNOTATION_ATTRIBUTES = ["label", "phase", "role", "evidence", "entity"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def apply_text_annotate(request: TextAnnotateRequest) -> TextAnnotateResult:
|
|
24
|
+
"""
|
|
25
|
+
Apply text annotation using a language model.
|
|
26
|
+
|
|
27
|
+
:param request: Text annotate request.
|
|
28
|
+
:type request: TextAnnotateRequest
|
|
29
|
+
:return: Text annotate result.
|
|
30
|
+
:rtype: TextAnnotateResult
|
|
31
|
+
:raises ValueError: If model output is invalid or text is modified. Empty outputs trigger
|
|
32
|
+
a confirmation round and return a warning when confirmed.
|
|
33
|
+
"""
|
|
34
|
+
warnings: List[str] = []
|
|
35
|
+
allowed_attributes = _resolve_allowed_attributes(request.allowed_attributes)
|
|
36
|
+
system_prompt = _render_system_prompt(
|
|
37
|
+
request.system_prompt,
|
|
38
|
+
allowed_attributes=allowed_attributes,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if request.mock_marked_up_text is not None:
|
|
42
|
+
return _build_mock_result(
|
|
43
|
+
request,
|
|
44
|
+
request.mock_marked_up_text,
|
|
45
|
+
allowed_attributes=allowed_attributes,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
result = run_tool_loop(
|
|
49
|
+
text=request.text,
|
|
50
|
+
client=request.client,
|
|
51
|
+
system_prompt=system_prompt,
|
|
52
|
+
prompt_template=request.prompt_template,
|
|
53
|
+
max_rounds=request.max_rounds,
|
|
54
|
+
max_edits_per_round=request.max_edits_per_round,
|
|
55
|
+
apply_str_replace=_apply_annotate_replace,
|
|
56
|
+
validate_text=lambda current_text: _validate_annotation_markup(
|
|
57
|
+
current_text, allowed_attributes
|
|
58
|
+
),
|
|
59
|
+
build_retry_message=lambda errors, current_text: _build_retry_message(
|
|
60
|
+
errors, current_text, allowed_attributes
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not result.done:
|
|
65
|
+
if result.last_error:
|
|
66
|
+
raise ValueError(f"Text annotate failed: {result.last_error}")
|
|
67
|
+
warnings.append("Text annotate reached max rounds without done=true")
|
|
68
|
+
|
|
69
|
+
if result.text == request.text:
|
|
70
|
+
if result.last_error:
|
|
71
|
+
raise ValueError(result.last_error)
|
|
72
|
+
confirmation = request_confirmation(
|
|
73
|
+
result=result,
|
|
74
|
+
text=result.text,
|
|
75
|
+
client=request.client,
|
|
76
|
+
system_prompt=system_prompt,
|
|
77
|
+
prompt_template=request.prompt_template,
|
|
78
|
+
max_rounds=2,
|
|
79
|
+
max_edits_per_round=request.max_edits_per_round,
|
|
80
|
+
apply_str_replace=_apply_annotate_replace,
|
|
81
|
+
confirmation_message=_build_empty_confirmation_message(result.text),
|
|
82
|
+
validate_text=lambda current_text: _validate_annotation_markup(
|
|
83
|
+
current_text, allowed_attributes
|
|
84
|
+
),
|
|
85
|
+
build_retry_message=lambda errors, current_text: _build_retry_message(
|
|
86
|
+
errors, current_text, allowed_attributes
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
if not confirmation.done:
|
|
90
|
+
if confirmation.last_error:
|
|
91
|
+
raise ValueError(f"Text annotate failed: {confirmation.last_error}")
|
|
92
|
+
warnings.append("Text annotate confirmation reached max rounds without done=true")
|
|
93
|
+
_validate_preserved_text(original=request.text, marked_up=confirmation.text)
|
|
94
|
+
spans = parse_span_markup(confirmation.text)
|
|
95
|
+
validation_errors = _validate_annotation_spans(spans, allowed_attributes)
|
|
96
|
+
if validation_errors:
|
|
97
|
+
raise ValueError("; ".join(validation_errors))
|
|
98
|
+
if not spans:
|
|
99
|
+
warnings.append("Text annotate returned no spans; model confirmed empty result")
|
|
100
|
+
return TextAnnotateResult(
|
|
101
|
+
marked_up_text=confirmation.text,
|
|
102
|
+
spans=spans,
|
|
103
|
+
warnings=warnings,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_validate_preserved_text(original=request.text, marked_up=result.text)
|
|
107
|
+
spans = parse_span_markup(result.text)
|
|
108
|
+
validation_errors = _validate_annotation_spans(spans, allowed_attributes)
|
|
109
|
+
if validation_errors:
|
|
110
|
+
raise ValueError("; ".join(validation_errors))
|
|
111
|
+
return TextAnnotateResult(marked_up_text=result.text, spans=spans, warnings=warnings)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_mock_result(
|
|
115
|
+
request: TextAnnotateRequest,
|
|
116
|
+
marked_up_text: str,
|
|
117
|
+
*,
|
|
118
|
+
allowed_attributes: Sequence[str],
|
|
119
|
+
) -> TextAnnotateResult:
|
|
120
|
+
if marked_up_text == request.text:
|
|
121
|
+
raise ValueError("Text annotate produced no spans")
|
|
122
|
+
_validate_preserved_text(original=request.text, marked_up=marked_up_text)
|
|
123
|
+
spans = parse_span_markup(marked_up_text)
|
|
124
|
+
errors = _validate_annotation_spans(spans, allowed_attributes)
|
|
125
|
+
if errors:
|
|
126
|
+
raise ValueError("; ".join(errors))
|
|
127
|
+
return TextAnnotateResult(marked_up_text=marked_up_text, spans=spans, warnings=[])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_allowed_attributes(allowed: Sequence[str] | None) -> List[str]:
|
|
131
|
+
if allowed is None:
|
|
132
|
+
return list(DEFAULT_ANNOTATION_ATTRIBUTES)
|
|
133
|
+
return [value for value in allowed]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render_system_prompt(template: str, *, allowed_attributes: Sequence[str]) -> str:
|
|
137
|
+
env = Environment(undefined=StrictUndefined)
|
|
138
|
+
rendered = env.from_string(template).render(
|
|
139
|
+
allowed_attributes=list(allowed_attributes),
|
|
140
|
+
)
|
|
141
|
+
return rendered
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _apply_annotate_replace(text: str, old_str: str, new_str: str) -> str:
|
|
145
|
+
occurrences = text.count(old_str)
|
|
146
|
+
if occurrences == 0:
|
|
147
|
+
raise ValueError("Text annotate replacement old_str not found")
|
|
148
|
+
if occurrences > 1:
|
|
149
|
+
raise ValueError("Text annotate replacement old_str is not unique")
|
|
150
|
+
_validate_replace_text(old_str, new_str)
|
|
151
|
+
return text.replace(old_str, new_str, 1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_replace_text(old_str: str, new_str: str) -> None:
|
|
155
|
+
if strip_span_tags(old_str) != strip_span_tags(new_str):
|
|
156
|
+
raise ValueError("Text annotate replacements may only insert span tags")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate_preserved_text(*, original: str, marked_up: str) -> None:
|
|
160
|
+
if strip_span_tags(marked_up) != original:
|
|
161
|
+
raise ValueError("Text annotate edits modified the source text")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_annotation_markup(
|
|
165
|
+
marked_up_text: str, allowed_attributes: Sequence[str]
|
|
166
|
+
) -> List[str]:
|
|
167
|
+
try:
|
|
168
|
+
spans = parse_span_markup(marked_up_text)
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
return [str(exc)]
|
|
171
|
+
return _validate_annotation_spans(spans, allowed_attributes)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _validate_annotation_spans(
|
|
175
|
+
spans: Iterable[TextAnnotatedSpan], allowed_attributes: Sequence[str]
|
|
176
|
+
) -> List[str]:
|
|
177
|
+
errors: List[str] = []
|
|
178
|
+
allowed_set = set(allowed_attributes)
|
|
179
|
+
for span in spans:
|
|
180
|
+
if not span.attributes:
|
|
181
|
+
errors.append(
|
|
182
|
+
f"Span {span.index} is missing an attribute. Allowed attributes: {', '.join(allowed_attributes)}"
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
if len(span.attributes) > 1:
|
|
186
|
+
errors.append(f"Span {span.index} has multiple attributes; only one is allowed")
|
|
187
|
+
continue
|
|
188
|
+
name, value = next(iter(span.attributes.items()))
|
|
189
|
+
if name not in allowed_set:
|
|
190
|
+
errors.append(
|
|
191
|
+
f"Span {span.index} uses attribute '{name}'. Allowed attributes: {', '.join(allowed_attributes)}"
|
|
192
|
+
)
|
|
193
|
+
if value.strip() == "":
|
|
194
|
+
errors.append(f"Span {span.index} has an empty value for attribute '{name}'")
|
|
195
|
+
return errors
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_retry_message(
|
|
199
|
+
errors: Sequence[str], current_text: str, allowed_attributes: Sequence[str]
|
|
200
|
+
) -> str:
|
|
201
|
+
error_lines = "\n".join(f"- {error}" for error in errors)
|
|
202
|
+
context_section = build_span_context_section(current_text, errors)
|
|
203
|
+
return (
|
|
204
|
+
"Your last edit did not validate.\n"
|
|
205
|
+
"Issues:\n"
|
|
206
|
+
f"{error_lines}\n\n"
|
|
207
|
+
f"{context_section}"
|
|
208
|
+
"Please fix the markup using str_replace. Each span must include exactly one attribute. "
|
|
209
|
+
"Allowed attributes are: "
|
|
210
|
+
f"{', '.join(allowed_attributes)}. Try again.\n"
|
|
211
|
+
"Current text:\n"
|
|
212
|
+
f"---\n{current_text}\n---"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _build_empty_confirmation_message(text: str) -> str:
|
|
217
|
+
return (
|
|
218
|
+
"No annotated spans were inserted. If there are truly no spans to return, "
|
|
219
|
+
"call done again without changes. Otherwise insert span tags with the correct attributes.\n"
|
|
220
|
+
"Current text:\n"
|
|
221
|
+
f"---\n{text}\n---"
|
|
222
|
+
)
|
biblicus/text/extract.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agentic text extraction using virtual file edits.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, List, Optional
|
|
9
|
+
|
|
10
|
+
from .models import TextExtractRequest, TextExtractResult, TextExtractSpan
|
|
11
|
+
from .tool_loop import request_confirmation, run_tool_loop
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_text_extract(request: TextExtractRequest) -> TextExtractResult:
|
|
15
|
+
"""
|
|
16
|
+
Apply text extraction using a language model.
|
|
17
|
+
|
|
18
|
+
:param request: Text extract request.
|
|
19
|
+
:type request: TextExtractRequest
|
|
20
|
+
:return: Text extract result.
|
|
21
|
+
:rtype: TextExtractResult
|
|
22
|
+
:raises ValueError: If model output is invalid or text is modified. Empty outputs trigger
|
|
23
|
+
a confirmation round and return a warning when confirmed.
|
|
24
|
+
"""
|
|
25
|
+
if request.mock_marked_up_text is not None:
|
|
26
|
+
return _build_mock_result(request, request.mock_marked_up_text)
|
|
27
|
+
|
|
28
|
+
warnings: List[str] = []
|
|
29
|
+
result = run_tool_loop(
|
|
30
|
+
text=request.text,
|
|
31
|
+
client=request.client,
|
|
32
|
+
system_prompt=request.system_prompt,
|
|
33
|
+
prompt_template=request.prompt_template,
|
|
34
|
+
max_rounds=request.max_rounds,
|
|
35
|
+
max_edits_per_round=request.max_edits_per_round,
|
|
36
|
+
apply_str_replace=_apply_extract_replace,
|
|
37
|
+
validate_text=_validate_extract_markup,
|
|
38
|
+
build_retry_message=_build_retry_message,
|
|
39
|
+
)
|
|
40
|
+
if not result.done:
|
|
41
|
+
if result.last_error:
|
|
42
|
+
message_error = _extract_validation_error_from_messages(result.messages)
|
|
43
|
+
if message_error:
|
|
44
|
+
raise ValueError(f"Text extract failed: {message_error}")
|
|
45
|
+
raise ValueError(f"Text extract failed: {result.last_error}")
|
|
46
|
+
warnings.append("Text extract reached max rounds without done=true")
|
|
47
|
+
if result.text == request.text:
|
|
48
|
+
if result.last_error:
|
|
49
|
+
raise ValueError(result.last_error)
|
|
50
|
+
confirmation = request_confirmation(
|
|
51
|
+
result=result,
|
|
52
|
+
text=result.text,
|
|
53
|
+
client=request.client,
|
|
54
|
+
system_prompt=request.system_prompt,
|
|
55
|
+
prompt_template=request.prompt_template,
|
|
56
|
+
max_rounds=2,
|
|
57
|
+
max_edits_per_round=request.max_edits_per_round,
|
|
58
|
+
apply_str_replace=_apply_extract_replace,
|
|
59
|
+
confirmation_message=_build_empty_confirmation_message(result.text),
|
|
60
|
+
)
|
|
61
|
+
if not confirmation.done:
|
|
62
|
+
if confirmation.last_error:
|
|
63
|
+
raise ValueError(f"Text extract failed: {confirmation.last_error}")
|
|
64
|
+
warnings.append("Text extract confirmation reached max rounds without done=true")
|
|
65
|
+
_validate_preserved_text(original=request.text, marked_up=confirmation.text)
|
|
66
|
+
spans = _extract_spans(marked_up_text=confirmation.text)
|
|
67
|
+
if not spans:
|
|
68
|
+
warnings.append("Text extract returned no spans; model confirmed empty result")
|
|
69
|
+
return TextExtractResult(
|
|
70
|
+
marked_up_text=confirmation.text,
|
|
71
|
+
spans=spans,
|
|
72
|
+
warnings=warnings,
|
|
73
|
+
)
|
|
74
|
+
_validate_preserved_text(original=request.text, marked_up=result.text)
|
|
75
|
+
spans = _extract_spans(marked_up_text=result.text)
|
|
76
|
+
return TextExtractResult(marked_up_text=result.text, spans=spans, warnings=warnings)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_mock_result(request: TextExtractRequest, marked_up_text: str) -> TextExtractResult:
|
|
80
|
+
if marked_up_text == request.text:
|
|
81
|
+
raise ValueError("Text extract produced no spans")
|
|
82
|
+
_validate_preserved_text(original=request.text, marked_up=marked_up_text)
|
|
83
|
+
spans = _extract_spans(marked_up_text=marked_up_text)
|
|
84
|
+
return TextExtractResult(marked_up_text=marked_up_text, spans=spans, warnings=[])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _apply_extract_replace(text: str, old_str: str, new_str: str) -> str:
|
|
88
|
+
occurrences = text.count(old_str)
|
|
89
|
+
if occurrences == 0:
|
|
90
|
+
raise ValueError("Text extract replacement old_str not found")
|
|
91
|
+
if occurrences > 1:
|
|
92
|
+
raise ValueError("Text extract replacement old_str is not unique")
|
|
93
|
+
_validate_replace_text(old_str, new_str)
|
|
94
|
+
return text.replace(old_str, new_str, 1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_replace_text(old_str: str, new_str: str) -> None:
|
|
98
|
+
if _strip_span_tags(old_str) != _strip_span_tags(new_str):
|
|
99
|
+
raise ValueError("Text extract replacements may only insert span tags")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_preserved_text(*, original: str, marked_up: str) -> None:
|
|
103
|
+
if _strip_span_tags(marked_up) != original:
|
|
104
|
+
raise ValueError("Text extract edits modified the source text")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _strip_span_tags(text: str) -> str:
|
|
108
|
+
return text.replace("<span>", "").replace("</span>", "")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _extract_spans(*, marked_up_text: str) -> List[TextExtractSpan]:
|
|
112
|
+
open_tag = "<span>"
|
|
113
|
+
close_tag = "</span>"
|
|
114
|
+
tag_pattern = re.compile(re.escape(open_tag) + "|" + re.escape(close_tag))
|
|
115
|
+
spans: List[TextExtractSpan] = []
|
|
116
|
+
cursor = 0
|
|
117
|
+
original_index = 0
|
|
118
|
+
span_start = None
|
|
119
|
+
span_text = ""
|
|
120
|
+
|
|
121
|
+
for match in tag_pattern.finditer(marked_up_text):
|
|
122
|
+
chunk = marked_up_text[cursor : match.start()]
|
|
123
|
+
if chunk:
|
|
124
|
+
if span_start is not None:
|
|
125
|
+
span_text += chunk
|
|
126
|
+
original_index += len(chunk)
|
|
127
|
+
tag = match.group(0)
|
|
128
|
+
if tag == open_tag:
|
|
129
|
+
if span_start is not None:
|
|
130
|
+
raise ValueError("Text extract contains nested spans")
|
|
131
|
+
span_start = original_index
|
|
132
|
+
span_text = ""
|
|
133
|
+
else:
|
|
134
|
+
if span_start is None:
|
|
135
|
+
raise ValueError("Text extract contains an unmatched closing tag")
|
|
136
|
+
span_end = original_index
|
|
137
|
+
spans.append(
|
|
138
|
+
TextExtractSpan(
|
|
139
|
+
index=len(spans) + 1,
|
|
140
|
+
start_char=span_start,
|
|
141
|
+
end_char=span_end,
|
|
142
|
+
text=span_text,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
span_start = None
|
|
146
|
+
span_text = ""
|
|
147
|
+
cursor = match.end()
|
|
148
|
+
|
|
149
|
+
tail = marked_up_text[cursor:]
|
|
150
|
+
if tail:
|
|
151
|
+
if span_start is not None:
|
|
152
|
+
span_text += tail
|
|
153
|
+
original_index += len(tail)
|
|
154
|
+
|
|
155
|
+
if span_start is not None:
|
|
156
|
+
raise ValueError("Text extract contains an unclosed span")
|
|
157
|
+
|
|
158
|
+
return spans
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_extract_markup(marked_up_text: str) -> List[str]:
|
|
162
|
+
try:
|
|
163
|
+
_extract_spans(marked_up_text=marked_up_text)
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
return [str(exc)]
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_retry_message(errors: List[str], current_text: str) -> str:
|
|
170
|
+
error_lines = "\n".join(f"- {error}" for error in errors)
|
|
171
|
+
return (
|
|
172
|
+
"Your last edit did not validate.\n"
|
|
173
|
+
"Issues:\n"
|
|
174
|
+
f"{error_lines}\n\n"
|
|
175
|
+
"Please fix the markup using str_replace. "
|
|
176
|
+
"Do not nest <span> tags and do not create unmatched tags.\n"
|
|
177
|
+
"Current text:\n"
|
|
178
|
+
f"---\n{current_text}\n---"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_validation_error_from_messages(
|
|
183
|
+
messages: List[dict[str, Any]],
|
|
184
|
+
) -> Optional[str]:
|
|
185
|
+
for message in messages:
|
|
186
|
+
if message.get("role") != "user":
|
|
187
|
+
continue
|
|
188
|
+
content = str(message.get("content") or "")
|
|
189
|
+
if "Your last edit did not validate." not in content:
|
|
190
|
+
continue
|
|
191
|
+
if "Issues:" not in content:
|
|
192
|
+
continue
|
|
193
|
+
lines = content.splitlines()
|
|
194
|
+
try:
|
|
195
|
+
issues_index = lines.index("Issues:")
|
|
196
|
+
except ValueError:
|
|
197
|
+
continue
|
|
198
|
+
for line in lines[issues_index + 1 :]:
|
|
199
|
+
if line.startswith("- "):
|
|
200
|
+
return line[2:].strip()
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_empty_confirmation_message(text: str) -> str:
|
|
205
|
+
return (
|
|
206
|
+
"No spans were inserted. If there are truly no spans to return, call done again without changes. "
|
|
207
|
+
"Otherwise insert <span> tags for the requested text.\n"
|
|
208
|
+
"Current text:\n"
|
|
209
|
+
f"---\n{text}\n---"
|
|
210
|
+
)
|