fred-capability-documents 4.4.3__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.
- fred_capability_documents/__init__.py +21 -0
- fred_capability_documents/document_extract/__init__.py +28 -0
- fred_capability_documents/document_extract/capability.py +208 -0
- fred_capability_documents/document_label_search/__init__.py +30 -0
- fred_capability_documents/document_label_search/capability.py +249 -0
- fred_capability_documents/document_read_common.py +263 -0
- fred_capability_documents/document_similarity/__init__.py +27 -0
- fred_capability_documents/document_similarity/capability.py +280 -0
- fred_capability_documents/document_summarize/__init__.py +38 -0
- fred_capability_documents/document_summarize/capability.py +382 -0
- fred_capability_documents/document_verbatim/__init__.py +27 -0
- fred_capability_documents/document_verbatim/capability.py +128 -0
- fred_capability_documents/py.typed +0 -0
- fred_capability_documents-4.4.3.dist-info/METADATA +58 -0
- fred_capability_documents-4.4.3.dist-info/RECORD +18 -0
- fred_capability_documents-4.4.3.dist-info/WHEEL +5 -0
- fred_capability_documents-4.4.3.dist-info/entry_points.txt +6 -0
- fred_capability_documents-4.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Shared plumbing for the document-reading capability pair (DOCREAD-01):
|
|
17
|
+
`document_verbatim` (verbatim positional read) and `document_extract`
|
|
18
|
+
(exhaustive extraction). Both sit on the SAME `RuntimeServices.document_markdown`
|
|
19
|
+
port and differ only in tool intent and how the pagination footer is worded, so
|
|
20
|
+
the config model, length resolution, error shaping, and page-to-tool-result
|
|
21
|
+
formatting live here once.
|
|
22
|
+
|
|
23
|
+
`document_tool_failure` has outgrown that pair - `document_similarity` uses it
|
|
24
|
+
too, since the error shaping is about the document ports in general, not about
|
|
25
|
+
paginated reading. It stays here rather than moving to a new module: two other
|
|
26
|
+
capabilities (`document_access`, `document_summarize`) still carry their own
|
|
27
|
+
private copies, and folding all four into one home is a cleanup of its own, not
|
|
28
|
+
something to smuggle into a feature change.
|
|
29
|
+
|
|
30
|
+
Doctrine (RFC §3.5, §3.8, §10), identical to `document_summarize`:
|
|
31
|
+
- the capability reaches the platform ONLY through the typed
|
|
32
|
+
`RuntimeServices.document_markdown` port; the per-turn binding and the raw
|
|
33
|
+
access token NEVER enter `CapabilityContext`;
|
|
34
|
+
- the tool signature exposes ONLY LLM arguments (uid + page window); identity
|
|
35
|
+
and config reach the tool through the middleware closure, never the schema;
|
|
36
|
+
- `document_uid` is an internal working identifier — tools instruct the model to
|
|
37
|
+
NEVER repeat it to the end user (answers refer to documents by display name).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
from fred_sdk.contracts.context import (
|
|
45
|
+
ToolContentBlock,
|
|
46
|
+
ToolContentKind,
|
|
47
|
+
ToolInvocationResult,
|
|
48
|
+
)
|
|
49
|
+
from fred_sdk.contracts.runtime import (
|
|
50
|
+
DocumentMarkdownResult,
|
|
51
|
+
DocumentScopeRefusedError,
|
|
52
|
+
unwrap_run_stop_error,
|
|
53
|
+
)
|
|
54
|
+
from pydantic import BaseModel, Field
|
|
55
|
+
|
|
56
|
+
_KF_SERVICE = "Knowledge Flow"
|
|
57
|
+
|
|
58
|
+
# Built-in default page length when neither the caller (the LLM) nor the
|
|
59
|
+
# capability config specifies one. Matches the adapter's default page size.
|
|
60
|
+
DEFAULT_PAGE_MAX_CHARS = 8000
|
|
61
|
+
# Wire bounds clamped client-side so an out-of-range LLM value degrades
|
|
62
|
+
# gracefully instead of a surprising page.
|
|
63
|
+
_PAGE_MAX_CHARS_BOUNDS = (200, 50_000)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clamp(value: int, bounds: tuple[int, int]) -> int:
|
|
67
|
+
low, high = bounds
|
|
68
|
+
return max(low, min(value, high))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_page_max_chars(cap: int | None, requested: int | None) -> int:
|
|
72
|
+
"""
|
|
73
|
+
Resolve the effective page length from the configured cap and the caller's
|
|
74
|
+
request.
|
|
75
|
+
|
|
76
|
+
The per-agent cap (`page_max_chars` config) is both the default (when the
|
|
77
|
+
caller asks for nothing) and a hard upper bound on whatever the caller
|
|
78
|
+
requests; without one, the built-in default applies and the caller's request
|
|
79
|
+
is honored verbatim (within wire bounds).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
default = cap if cap is not None else DEFAULT_PAGE_MAX_CHARS
|
|
83
|
+
effective = requested if requested is not None else default
|
|
84
|
+
if cap is not None:
|
|
85
|
+
effective = min(effective, cap)
|
|
86
|
+
return _clamp(effective, _PAGE_MAX_CHARS_BOUNDS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DocumentReadConfig(BaseModel):
|
|
90
|
+
"""Shared agent-creation / stored config of the document-reading pair.
|
|
91
|
+
|
|
92
|
+
A single knob today: the default AND hard cap for the per-call page length
|
|
93
|
+
(chars). None = built-in default, caller's request honored verbatim.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
page_max_chars: int | None = Field(default=None, ge=200, le=50_000)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def document_tool_failure(
|
|
100
|
+
*,
|
|
101
|
+
tool_ref: str,
|
|
102
|
+
action: str,
|
|
103
|
+
exc: Exception,
|
|
104
|
+
elapsed_s: float,
|
|
105
|
+
document_uid: str,
|
|
106
|
+
) -> tuple[str, ToolInvocationResult]:
|
|
107
|
+
"""Turn a document-read tool-call failure into a non-empty, actionable error
|
|
108
|
+
message plus an ``is_error=True`` artifact.
|
|
109
|
+
|
|
110
|
+
The v2 ReAct runtime surfaces ``ToolInvocationResult.is_error`` directly to
|
|
111
|
+
the user (and suppresses LLM hallucination), so a failing tool MUST return
|
|
112
|
+
such a result instead of raising. Transport detail (timeout, HTTP status)
|
|
113
|
+
arrives via the SDK-typed ``DocumentPortCallError`` attributes the adapters
|
|
114
|
+
stamp — this module never imports the adapter's HTTP stack.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
err_type = type(exc).__name__
|
|
118
|
+
raw = str(exc).strip()
|
|
119
|
+
timed_out = bool(getattr(exc, "timed_out", False))
|
|
120
|
+
status_code = getattr(exc, "status_code", None)
|
|
121
|
+
|
|
122
|
+
if timed_out:
|
|
123
|
+
cause = f"the {_KF_SERVICE} service timed out after {elapsed_s:.0f}s"
|
|
124
|
+
elif status_code is not None:
|
|
125
|
+
cause = f"the {_KF_SERVICE} service returned HTTP {status_code}"
|
|
126
|
+
else:
|
|
127
|
+
cause = f"the {_KF_SERVICE} service call failed after {elapsed_s:.0f}s"
|
|
128
|
+
|
|
129
|
+
detail = f": {raw}" if raw else ""
|
|
130
|
+
message = (
|
|
131
|
+
f"Could not {action} (document_uid={document_uid}): {cause} "
|
|
132
|
+
f"[{err_type}{detail}]."
|
|
133
|
+
)
|
|
134
|
+
if status_code in (403, 404):
|
|
135
|
+
message += (
|
|
136
|
+
" Likely cause: document_uid was not a document's opaque uid. If you "
|
|
137
|
+
"passed a file NAME, resolve the uid first — it is in the "
|
|
138
|
+
"conversation's attached-files list (the bracketed value after the "
|
|
139
|
+
"file name), in a search hit's 'uid' field, or on a DOCUMENT line of "
|
|
140
|
+
"the document tree. Then retry with a real document uid. Do not "
|
|
141
|
+
"repeat the uid to the user."
|
|
142
|
+
)
|
|
143
|
+
return message, ToolInvocationResult(
|
|
144
|
+
tool_ref=tool_ref,
|
|
145
|
+
is_error=True,
|
|
146
|
+
blocks=(ToolContentBlock(kind=ToolContentKind.TEXT, text=message),),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def document_scope_refusal(
|
|
151
|
+
*,
|
|
152
|
+
tool_ref: str,
|
|
153
|
+
action: str,
|
|
154
|
+
exc: DocumentScopeRefusedError,
|
|
155
|
+
) -> tuple[str, ToolInvocationResult]:
|
|
156
|
+
"""Shape a turn-scope refusal for the model — never as a service failure.
|
|
157
|
+
|
|
158
|
+
Nothing failed downstream: the user narrowed this turn to a selection the
|
|
159
|
+
named document is not in. Told as a transport error the model retries;
|
|
160
|
+
told as an empty result it reports the document as empty.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
uids = ", ".join(exc.requested_uids) or "that document"
|
|
164
|
+
message = (
|
|
165
|
+
f"Cannot {action} ({uids}): the user has restricted this conversation to "
|
|
166
|
+
"a specific set of documents, and this one is not in it. Nothing was "
|
|
167
|
+
"read - this is NOT an empty or unreadable document. Work from a "
|
|
168
|
+
"document that is in scope (search and the document tree return exactly "
|
|
169
|
+
"those), or tell the user the document they mean is outside the current "
|
|
170
|
+
"selection. Never repeat the identifier to the user."
|
|
171
|
+
)
|
|
172
|
+
return message, ToolInvocationResult(
|
|
173
|
+
tool_ref=tool_ref,
|
|
174
|
+
is_error=True,
|
|
175
|
+
blocks=(ToolContentBlock(kind=ToolContentKind.TEXT, text=message),),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pagination_footer(result: DocumentMarkdownResult, *, exhaustive: bool) -> str:
|
|
180
|
+
"""Machine-readable page/continuation banner appended to the tool content.
|
|
181
|
+
|
|
182
|
+
This is the signal that structurally prevents `document_summarize`'s "half
|
|
183
|
+
answer" failure mode: the model is told, in-band, exactly how much of the
|
|
184
|
+
document it has seen and whether it MUST keep paging. `exhaustive` (the
|
|
185
|
+
`extract` tool) makes the "keep going" directive imperative; the verbatim
|
|
186
|
+
tool states it as a plain option.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
end = result.offset + len(result.text)
|
|
190
|
+
seen = f"chars {result.offset}–{end} of {result.total_chars}"
|
|
191
|
+
if result.next_offset is not None:
|
|
192
|
+
if exhaustive:
|
|
193
|
+
return (
|
|
194
|
+
f"\n\n[MORE TEXT REMAINS — {seen} read. You have NOT seen the "
|
|
195
|
+
f"whole document yet; do not conclude. Call this tool again with "
|
|
196
|
+
f"offset={result.next_offset} and keep accumulating matches until "
|
|
197
|
+
f"the end is reached.]"
|
|
198
|
+
)
|
|
199
|
+
return (
|
|
200
|
+
f"\n\n[More text remains — {seen} read. You have NOT seen the "
|
|
201
|
+
f"whole document yet; do not present this page as the complete "
|
|
202
|
+
f"document. You may call this tool again with "
|
|
203
|
+
f"offset={result.next_offset} to continue reading.]"
|
|
204
|
+
)
|
|
205
|
+
if result.offset == 0 and result.total_chars == 0:
|
|
206
|
+
return "\n\n[The document has no readable text content.]"
|
|
207
|
+
if exhaustive:
|
|
208
|
+
return (
|
|
209
|
+
f"\n\n[END OF DOCUMENT reached ({result.total_chars} chars total). "
|
|
210
|
+
f"You now have the complete text — produce your exhaustive answer.]"
|
|
211
|
+
)
|
|
212
|
+
return f"\n\n[End of document ({result.total_chars} chars total).]"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def read_document_page(
|
|
216
|
+
*,
|
|
217
|
+
port,
|
|
218
|
+
tool_ref: str,
|
|
219
|
+
document_uid: str,
|
|
220
|
+
offset: int,
|
|
221
|
+
max_chars: int,
|
|
222
|
+
exhaustive: bool,
|
|
223
|
+
) -> tuple[str, ToolInvocationResult]:
|
|
224
|
+
"""Fetch one page through the `document_markdown` port and format it as a
|
|
225
|
+
`(content, ToolInvocationResult)` return (shared by both tools).
|
|
226
|
+
|
|
227
|
+
`content_and_artifact` convention (see `document_summarize`): the artifact's
|
|
228
|
+
`blocks` carries the same text as `content` so a Graph agent — which keeps
|
|
229
|
+
only the artifact half — still sees the page and its continuation footer.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
if port is None:
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
f"{tool_ref}: RuntimeServices.document_markdown is not available on "
|
|
235
|
+
"this execution path."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
started = time.monotonic()
|
|
239
|
+
action = "extract from the document" if exhaustive else "read the document"
|
|
240
|
+
try:
|
|
241
|
+
result = await port.fetch_markdown(
|
|
242
|
+
document_uid, offset=offset, max_chars=max_chars
|
|
243
|
+
)
|
|
244
|
+
except DocumentScopeRefusedError as exc:
|
|
245
|
+
return document_scope_refusal(tool_ref=tool_ref, action=action, exc=exc)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
run_stop = unwrap_run_stop_error(exc)
|
|
248
|
+
if run_stop is not None:
|
|
249
|
+
raise run_stop from None
|
|
250
|
+
return document_tool_failure(
|
|
251
|
+
tool_ref=tool_ref,
|
|
252
|
+
action=action,
|
|
253
|
+
exc=exc,
|
|
254
|
+
elapsed_s=time.monotonic() - started,
|
|
255
|
+
document_uid=document_uid,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
content = result.text + _pagination_footer(result, exhaustive=exhaustive)
|
|
259
|
+
artifact = ToolInvocationResult(
|
|
260
|
+
tool_ref=tool_ref,
|
|
261
|
+
blocks=(ToolContentBlock(kind=ToolContentKind.TEXT, text=content),),
|
|
262
|
+
)
|
|
263
|
+
return content, artifact
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
`DocumentSimilarityCapability` — targeted document-to-document comparison.
|
|
17
|
+
|
|
18
|
+
Installing fred-capability-documents registers it via the `fred.capabilities` entry point
|
|
19
|
+
(`document_similarity`). Sits on `RuntimeServices.document_similarity`, the
|
|
20
|
+
comparison counterpart of the `document_search` port `document_access` uses.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .capability import DocumentSimilarityCapability
|
|
26
|
+
|
|
27
|
+
__all__ = ["DocumentSimilarityCapability"]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
`DocumentSimilarityCapability` — find the passages most similar to an anchor
|
|
17
|
+
passage, inside documents named on the call.
|
|
18
|
+
|
|
19
|
+
Separate from `document_access` because it COMPARES rather than answers, and
|
|
20
|
+
sits on its own port: its targeting comes from the model per call, not from
|
|
21
|
+
the conversation. Read-only, so no HITL gate; `ADMIN_GATED` by class default.
|
|
22
|
+
Full rationale: RUNTIME-EXECUTION-CONTRACT.md §8.60.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
|
|
32
|
+
from fred_core.store.vector_search import select_citable_sources
|
|
33
|
+
from fred_sdk.contracts.capability import (
|
|
34
|
+
AgentCapability,
|
|
35
|
+
CapabilityContext,
|
|
36
|
+
CapabilityManifest,
|
|
37
|
+
EmptyModel,
|
|
38
|
+
)
|
|
39
|
+
from fred_sdk.contracts.context import (
|
|
40
|
+
ToolContentBlock,
|
|
41
|
+
ToolContentKind,
|
|
42
|
+
ToolInvocationResult,
|
|
43
|
+
)
|
|
44
|
+
from fred_sdk.contracts.models import FieldSpec, UIHints
|
|
45
|
+
from fred_sdk.contracts.runtime import (
|
|
46
|
+
DocumentScopeRefusedError,
|
|
47
|
+
DocumentSearchResult,
|
|
48
|
+
unwrap_run_stop_error,
|
|
49
|
+
)
|
|
50
|
+
from langchain_core.tools import BaseTool, tool
|
|
51
|
+
from pydantic import BaseModel, Field
|
|
52
|
+
|
|
53
|
+
from fred_capability_documents.document_read_common import document_tool_failure
|
|
54
|
+
|
|
55
|
+
DOCUMENT_SIMILARITY_TOOL_REF = "document_similarity"
|
|
56
|
+
|
|
57
|
+
# Same LLM-visible slice the sibling document tools use: citation and reasoning
|
|
58
|
+
# fields only, never URLs or operational paths the model could echo back. `uid`
|
|
59
|
+
# stays - it is the working identifier for chaining into the reading tools.
|
|
60
|
+
_LLM_FIELDS = frozenset(
|
|
61
|
+
{"uid", "title", "content", "file_name", "page", "section", "score"}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Wire bounds, clamped capability-side so an out-of-range model value degrades
|
|
65
|
+
# instead of failing the call downstream.
|
|
66
|
+
_TOP_K_BOUNDS = (1, 50)
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DocumentSimilarityConfig(BaseModel):
|
|
72
|
+
"""Per-agent-instance tuning. Targeting is deliberately absent: the whole
|
|
73
|
+
point of this mode is that the model names its targets per call, and the
|
|
74
|
+
adapter bounds them by the session binding."""
|
|
75
|
+
|
|
76
|
+
default_top_k: int = Field(default=10, ge=1, le=50)
|
|
77
|
+
rerank: bool = Field(default=True)
|
|
78
|
+
min_score: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DocumentSimilarityCapability(
|
|
82
|
+
AgentCapability[DocumentSimilarityConfig, DocumentSimilarityConfig, EmptyModel]
|
|
83
|
+
):
|
|
84
|
+
"""Targeted comparison search. Single tool, no chat controls, no turn
|
|
85
|
+
options, no HITL (read-only)."""
|
|
86
|
+
|
|
87
|
+
manifest = CapabilityManifest(
|
|
88
|
+
id="document_similarity",
|
|
89
|
+
# Pre-GA: version stays 0.1.0 while the platform has not shipped.
|
|
90
|
+
version="0.1.0",
|
|
91
|
+
name="capability.document_similarity.name",
|
|
92
|
+
description="capability.document_similarity.description",
|
|
93
|
+
icon="sync_alt",
|
|
94
|
+
config_fields=[
|
|
95
|
+
FieldSpec(
|
|
96
|
+
key="default_top_k",
|
|
97
|
+
type="integer",
|
|
98
|
+
title="capability.document_similarity.fields.default_top_k.title",
|
|
99
|
+
description="capability.document_similarity.fields.default_top_k.description",
|
|
100
|
+
min=1,
|
|
101
|
+
max=50,
|
|
102
|
+
ui=UIHints(group="retrieval"),
|
|
103
|
+
),
|
|
104
|
+
FieldSpec(
|
|
105
|
+
key="rerank",
|
|
106
|
+
type="boolean",
|
|
107
|
+
title="capability.document_similarity.fields.rerank.title",
|
|
108
|
+
description="capability.document_similarity.fields.rerank.description",
|
|
109
|
+
ui=UIHints(group="retrieval", advanced=True),
|
|
110
|
+
),
|
|
111
|
+
FieldSpec(
|
|
112
|
+
key="min_score",
|
|
113
|
+
type="number",
|
|
114
|
+
title="capability.document_similarity.fields.min_score.title",
|
|
115
|
+
description="capability.document_similarity.fields.min_score.description",
|
|
116
|
+
min=0.0,
|
|
117
|
+
max=1.0,
|
|
118
|
+
ui=UIHints(group="retrieval", advanced=True),
|
|
119
|
+
),
|
|
120
|
+
],
|
|
121
|
+
# team_scope left at the class default (ADMIN_GATED).
|
|
122
|
+
)
|
|
123
|
+
ConfigModel = DocumentSimilarityConfig
|
|
124
|
+
|
|
125
|
+
def tools(
|
|
126
|
+
self,
|
|
127
|
+
ctx: CapabilityContext[DocumentSimilarityConfig, EmptyModel],
|
|
128
|
+
) -> Sequence[BaseTool]:
|
|
129
|
+
config = ctx.config
|
|
130
|
+
services = ctx.services
|
|
131
|
+
default_top_k = config.default_top_k
|
|
132
|
+
rerank = config.rerank
|
|
133
|
+
min_score = config.min_score
|
|
134
|
+
|
|
135
|
+
@tool("find_similar_passages", response_format="content_and_artifact")
|
|
136
|
+
async def find_similar_passages(
|
|
137
|
+
anchor: str,
|
|
138
|
+
document_uids: list[str],
|
|
139
|
+
top_k: int | None = None,
|
|
140
|
+
) -> tuple[str, ToolInvocationResult]:
|
|
141
|
+
"""Find the passages most similar to an anchor passage, inside specific documents.
|
|
142
|
+
|
|
143
|
+
This is a COMPARISON tool, not a question-answering one. `anchor` is
|
|
144
|
+
a passage of text to match against - a sentence, a paragraph, a
|
|
145
|
+
requirement - NOT a question. Use it to compare one document against
|
|
146
|
+
another: "does the operations manual cover what the architecture
|
|
147
|
+
document requires here?", "what in document B corresponds to this
|
|
148
|
+
section of document A?", "is this requirement contradicted anywhere
|
|
149
|
+
in that spec?".
|
|
150
|
+
|
|
151
|
+
To compare two documents end to end, call this once per passage of
|
|
152
|
+
the first document with `document_uids` set to the second - each call
|
|
153
|
+
is aimed independently, which is what this tool is for.
|
|
154
|
+
|
|
155
|
+
When to use a different tool instead:
|
|
156
|
+
- to answer a question from the corpus at large -> use the vector
|
|
157
|
+
search tool, which is not restricted to named documents;
|
|
158
|
+
- to read a document's own text in order -> use the verbatim read
|
|
159
|
+
tool.
|
|
160
|
+
|
|
161
|
+
`document_uids` is REQUIRED and must name at least one document: the
|
|
162
|
+
search runs ONLY inside those documents. They must be opaque
|
|
163
|
+
document uids, not file names - take them from a search hit's 'uid'
|
|
164
|
+
or from the document tree. This tool searches the document corpus
|
|
165
|
+
only: a file attached to this conversation is NOT searchable here,
|
|
166
|
+
so its uid would return nothing. Those uids are internal working
|
|
167
|
+
identifiers for YOUR tool calls: NEVER repeat one in your answer,
|
|
168
|
+
always refer to a document by its display name.
|
|
169
|
+
|
|
170
|
+
`top_k` bounds how many matches come back, best-first (leave unset
|
|
171
|
+
for the agent's default). An empty result means nothing in those
|
|
172
|
+
documents resembles the anchor - report that, do not invent a match.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
port = services.document_similarity
|
|
176
|
+
if port is None:
|
|
177
|
+
# No platform port injected (e.g. a bare test harness). Fail
|
|
178
|
+
# LOUD rather than silently returning nothing.
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
"document_similarity: RuntimeServices.document_similarity is "
|
|
181
|
+
"not available on this execution path."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not isinstance(anchor, str) or not anchor.strip():
|
|
185
|
+
return _bad_call(
|
|
186
|
+
"`anchor` must be a non-empty passage of text to match "
|
|
187
|
+
"against (not a question, and not empty)."
|
|
188
|
+
)
|
|
189
|
+
if not isinstance(document_uids, list) or not document_uids:
|
|
190
|
+
return _bad_call(
|
|
191
|
+
"`document_uids` must name at least one document to search "
|
|
192
|
+
"inside - this tool never searches the whole corpus. Resolve "
|
|
193
|
+
"a document's uid from a search hit, the document tree, or "
|
|
194
|
+
"the conversation's attached files, then retry."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
uids = [str(uid) for uid in document_uids]
|
|
198
|
+
effective_top_k = (
|
|
199
|
+
_clamp(top_k, _TOP_K_BOUNDS)
|
|
200
|
+
if isinstance(top_k, int) and not isinstance(top_k, bool) and top_k > 0
|
|
201
|
+
else default_top_k
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
started = time.monotonic()
|
|
205
|
+
try:
|
|
206
|
+
result: DocumentSearchResult = await port.find_similar(
|
|
207
|
+
anchor,
|
|
208
|
+
document_uids=uids,
|
|
209
|
+
top_k=effective_top_k,
|
|
210
|
+
rerank=rerank,
|
|
211
|
+
min_score=min_score,
|
|
212
|
+
)
|
|
213
|
+
except DocumentScopeRefusedError as exc:
|
|
214
|
+
# Nothing was searched, so this must never reach the model as an
|
|
215
|
+
# empty result - it would report "nothing matches" about a
|
|
216
|
+
# document it never looked at.
|
|
217
|
+
return _bad_call(
|
|
218
|
+
"Cannot search "
|
|
219
|
+
f"{', '.join(exc.requested_uids) or 'those documents'}: they "
|
|
220
|
+
"are not part of this conversation's document scope, so "
|
|
221
|
+
"nothing was searched. This is NOT a 'no matches' answer. "
|
|
222
|
+
"Use a document that is in scope - resolve one from a search "
|
|
223
|
+
"hit or the document tree - or tell the user the document is "
|
|
224
|
+
"out of scope."
|
|
225
|
+
)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
run_stop = unwrap_run_stop_error(exc)
|
|
228
|
+
if run_stop is not None:
|
|
229
|
+
raise run_stop from None
|
|
230
|
+
# Degrade rather than raise: the default ToolNode handler
|
|
231
|
+
# re-raises, killing the turn with an empty error detail.
|
|
232
|
+
return document_tool_failure(
|
|
233
|
+
tool_ref=DOCUMENT_SIMILARITY_TOOL_REF,
|
|
234
|
+
action="find similar passages",
|
|
235
|
+
exc=exc,
|
|
236
|
+
elapsed_s=time.monotonic() - started,
|
|
237
|
+
document_uid=", ".join(uids),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
hits = result.hits
|
|
241
|
+
content = {
|
|
242
|
+
"anchor": anchor,
|
|
243
|
+
"document_uids": uids,
|
|
244
|
+
"hits": [
|
|
245
|
+
{
|
|
246
|
+
k: v
|
|
247
|
+
for k, v in hit.model_dump(mode="json").items()
|
|
248
|
+
if k in _LLM_FIELDS
|
|
249
|
+
}
|
|
250
|
+
for hit in hits
|
|
251
|
+
],
|
|
252
|
+
}
|
|
253
|
+
# Keeps the dataset-pointer exclusion, drops the score-ratio one:
|
|
254
|
+
# the caller named these documents, so a weak match is a real
|
|
255
|
+
# finding, not corpus noise (RUNTIME-EXECUTION-CONTRACT.md §8.60).
|
|
256
|
+
artifact = ToolInvocationResult(
|
|
257
|
+
tool_ref=DOCUMENT_SIMILARITY_TOOL_REF,
|
|
258
|
+
blocks=(ToolContentBlock(kind=ToolContentKind.JSON, data=content),),
|
|
259
|
+
sources=select_citable_sources(hits, min_score_ratio=0.0),
|
|
260
|
+
)
|
|
261
|
+
return json.dumps(content), artifact
|
|
262
|
+
|
|
263
|
+
return [find_similar_passages]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _clamp(value: int, bounds: tuple[int, int]) -> int:
|
|
267
|
+
low, high = bounds
|
|
268
|
+
return max(low, min(value, high))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _bad_call(message: str) -> tuple[str, ToolInvocationResult]:
|
|
272
|
+
"""A malformed tool call, answered as an `is_error` artifact the model can
|
|
273
|
+
act on. Not `document_tool_failure`: nothing failed downstream, so its
|
|
274
|
+
transport wording ("the Knowledge Flow service ...") would be a lie."""
|
|
275
|
+
|
|
276
|
+
return message, ToolInvocationResult(
|
|
277
|
+
tool_ref=DOCUMENT_SIMILARITY_TOOL_REF,
|
|
278
|
+
is_error=True,
|
|
279
|
+
blocks=(ToolContentBlock(kind=ToolContentKind.TEXT, text=message),),
|
|
280
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
`DocumentSummarizeCapability` (RFC §10) — on-demand document summarization,
|
|
17
|
+
split out of the `document_access` pilot.
|
|
18
|
+
|
|
19
|
+
Installing fred-capability-documents registers it via the `fred.capabilities` entry point
|
|
20
|
+
(`document_summarize`), separate from `document_access` so a team admin opts
|
|
21
|
+
into it explicitly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .capability import (
|
|
27
|
+
DEFAULT_SUMMARIZE_MAX_CHARS,
|
|
28
|
+
DocumentSummarizeCapability,
|
|
29
|
+
DocumentSummarizeConfig,
|
|
30
|
+
resolve_summarize_max_chars,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_SUMMARIZE_MAX_CHARS",
|
|
35
|
+
"DocumentSummarizeCapability",
|
|
36
|
+
"DocumentSummarizeConfig",
|
|
37
|
+
"resolve_summarize_max_chars",
|
|
38
|
+
]
|