3tears-interfaces 0.93.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.
- 3tears_interfaces-0.93.0.dist-info/METADATA +85 -0
- 3tears_interfaces-0.93.0.dist-info/RECORD +11 -0
- 3tears_interfaces-0.93.0.dist-info/WHEEL +4 -0
- 3tears_interfaces-0.93.0.dist-info/licenses/LICENSE +21 -0
- threetears/interfaces/__init__.py +26 -0
- threetears/interfaces/access.py +37 -0
- threetears/interfaces/media/__init__.py +69 -0
- threetears/interfaces/media/facets.py +131 -0
- threetears/interfaces/media/keys.py +146 -0
- threetears/interfaces/media/protocols.py +618 -0
- threetears/interfaces/py.typed +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: 3tears-interfaces
|
|
3
|
+
Version: 0.93.0
|
|
4
|
+
Summary: Dependency-free Protocols and shared vocabulary for the 3tears family
|
|
5
|
+
Project-URL: Repository, https://github.com/pacepace/3tears
|
|
6
|
+
Author: pace
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# 3tears-interfaces
|
|
21
|
+
|
|
22
|
+
The 3tears family's shared Protocols and vocabulary, in one package that
|
|
23
|
+
depends on nothing.
|
|
24
|
+
|
|
25
|
+
**The rule.** This package holds Protocols, the value types and enums that
|
|
26
|
+
cross them, and pure helpers over those types. No implementations, no
|
|
27
|
+
third-party imports, no dependencies. A provider can implement a Protocol, and
|
|
28
|
+
a consumer can accept one, without inheriting any feature package's dependency
|
|
29
|
+
closure. The workspace's `tests/enforcement/` fails any module-top import here
|
|
30
|
+
that is not the stdlib or `threetears.interfaces` itself.
|
|
31
|
+
|
|
32
|
+
One module per domain:
|
|
33
|
+
|
|
34
|
+
| Module | What it holds |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `threetears.interfaces.access` | The identity and authorization vocabulary `3tears-iam` and `3tears-agent-acl` both name |
|
|
37
|
+
| `threetears.interfaces.media` | Media capability Protocols, the value types that cross them, and the object-key builder |
|
|
38
|
+
|
|
39
|
+
## access
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from threetears.interfaces.access import ImpersonationCategory
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`ImpersonationCategory` is the fixed set of six sensitive actions: credential,
|
|
46
|
+
passkey and MFA management; account deletion; API-key mint or rotate for the
|
|
47
|
+
target; email change; RBAC grant changes; audit editing. agent-acl deny-lists
|
|
48
|
+
all six under impersonation, and iam's `stepup` requires a recent sign-in for
|
|
49
|
+
each. iam proves who a caller is and agent-acl decides what they may do;
|
|
50
|
+
neither depends on the other, so the values both must agree on live here.
|
|
51
|
+
|
|
52
|
+
## media
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from threetears.interfaces.media import GeneratedImage, ImageGenerationBackend
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The Protocols media providers (`3tears-models`, `3tears-object-store`)
|
|
59
|
+
implement and media consumers (`3tears-agent-tools`) accept: `VisionProvider`,
|
|
60
|
+
`ReferenceVisionProvider`, `TextProvider`, `TranscriptionProvider`,
|
|
61
|
+
`ImageGenerationBackend`, `MediaStorage` and `ObjectStore`. The value types
|
|
62
|
+
that cross them: `MediaInfo`, `GeneratedImage`, `ObjectHandle`,
|
|
63
|
+
`ObjectListing` and `MediaSizeLimitExceeded`. Plus the extraction-status and
|
|
64
|
+
generation-metadata constants, and the object-key builder `build_object_key`.
|
|
65
|
+
|
|
66
|
+
`MediaFacets` carries the additive carrier facets: rights status, pixel
|
|
67
|
+
dimensions, and whether a locator points at the file itself or at the page
|
|
68
|
+
containing it. The vocabulary grows by addition only, and a consumer that
|
|
69
|
+
does not recognise a facet ignores it rather than failing, so a new carrier
|
|
70
|
+
ships without a coordinated release.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from threetears.interfaces.media import LOCATOR_KIND_DIRECT_FILE, MediaFacets
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install 3tears-interfaces
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Versioning policy
|
|
83
|
+
|
|
84
|
+
`3tears-interfaces` versions in lockstep with the rest of the 3tears
|
|
85
|
+
monorepo: every package shares one version, tracking the framework git tag.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
threetears/interfaces/__init__.py,sha256=am8iZx27vAMKWaVyXNSLkBAAVfN5usoKZphwKQXD8q8,1421
|
|
2
|
+
threetears/interfaces/access.py,sha256=Zr1Dk8SWoiHLMVX6KOR44K3G2-umQqFkyFow7pNZ2FM,1714
|
|
3
|
+
threetears/interfaces/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
threetears/interfaces/media/__init__.py,sha256=_QXnyVA-jIJUUsr7LnzA3lJMlauQ9-f-77hxubxC978,1956
|
|
5
|
+
threetears/interfaces/media/facets.py,sha256=Kw6I-U-IK6dWzHk5iUFU7AiAM1v-os2uGdc-QCUhoYY,5918
|
|
6
|
+
threetears/interfaces/media/keys.py,sha256=VcAW89tvkTW381LpFeCIdqYvThYpqzAeY2x9BMWxSr4,6336
|
|
7
|
+
threetears/interfaces/media/protocols.py,sha256=8yYDSRdHqO7TC5AxfkOAqFT6boHUN6InO5YiRvUKjKQ,21994
|
|
8
|
+
3tears_interfaces-0.93.0.dist-info/METADATA,sha256=w0MT4ijoQNqz5NSmHznEPoD7AaZrl46cGgx48Lnpkas,3342
|
|
9
|
+
3tears_interfaces-0.93.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
3tears_interfaces-0.93.0.dist-info/licenses/LICENSE,sha256=7GWEoEOcFJenZLt4LDzqH2K7QLxo_2m8rzG7Vv8VGXo,1066
|
|
11
|
+
3tears_interfaces-0.93.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Pace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The 3tears family's shared Protocols and vocabulary, in one package that depends on nothing.
|
|
2
|
+
|
|
3
|
+
One module per domain:
|
|
4
|
+
|
|
5
|
+
- :mod:`threetears.interfaces.access` -- the identity and authorization vocabulary
|
|
6
|
+
``3tears-iam`` (who a caller is) and ``3tears-agent-acl`` (what they may do) both name.
|
|
7
|
+
- :mod:`threetears.interfaces.media` -- the media capability Protocols shared by media
|
|
8
|
+
providers (``3tears-models``, ``3tears-object-store``) and consumers (``3tears-agent-tools``).
|
|
9
|
+
|
|
10
|
+
The rule: Protocols, the value types and enums that cross them, and pure helpers over those
|
|
11
|
+
types. No implementations, no third-party imports, no dependencies. A package that speaks
|
|
12
|
+
the vocabulary or implements a Protocol never inherits another feature package's closure by
|
|
13
|
+
doing so. The workspace's ``tests/enforcement/`` enforces it: every module-top import here
|
|
14
|
+
must be the stdlib or ``threetears.interfaces`` itself.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Version derived from package metadata so the metadata is the single source of truth: a
|
|
18
|
+
# release that bumps pyproject cannot drift the runtime ``__version__``. ``importlib.metadata``
|
|
19
|
+
# is stdlib, so the dependency-free floor is untouched.
|
|
20
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
21
|
+
from importlib.metadata import version as _version
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = _version("3tears-interfaces")
|
|
25
|
+
except _PackageNotFoundError: # pragma: no cover - dev fallback
|
|
26
|
+
__version__ = "unknown"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The sensitive-action categories authentication and authorization both name.
|
|
2
|
+
|
|
3
|
+
Two checks guard the same six actions. Authorization (``3tears-agent-acl``'s
|
|
4
|
+
``authorize_from_claims``) refuses them outright under impersonation. Authentication
|
|
5
|
+
(``3tears-iam``'s ``stepup``) lets them through only on a recent sign-in. Each check is
|
|
6
|
+
only as good as its agreement with the other about what the six are, so the set is
|
|
7
|
+
declared once, here, where neither side pays for the other's dependencies to import it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
__all__ = ["ImpersonationCategory"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImpersonationCategory(StrEnum):
|
|
18
|
+
"""The fixed, non-tenant-configurable sensitive-action category set.
|
|
19
|
+
|
|
20
|
+
security-model.md's Impersonation paragraph names these six categories verbatim:
|
|
21
|
+
"credential, passkey, and MFA management; account deletion; API-key mint/rotate for the
|
|
22
|
+
target; email change; RBAC grant changes; audit editing."
|
|
23
|
+
|
|
24
|
+
Two enforcement points read it. Under impersonation every member is deny-listed: there is
|
|
25
|
+
no partial or tenant-configurable subset. Outside impersonation every member requires
|
|
26
|
+
step-up re-authentication: the same taxonomy, enforced by freshness instead of subtraction.
|
|
27
|
+
|
|
28
|
+
The string values are a wire vocabulary as well as a type: a token, an audit record or a
|
|
29
|
+
peer repository may carry them, so a member is added, never renamed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
CREDENTIAL_PASSKEY_MFA_MANAGEMENT = "credential_passkey_mfa_management"
|
|
33
|
+
ACCOUNT_DELETION = "account_deletion"
|
|
34
|
+
APIKEY_MINT_ROTATE_FOR_TARGET = "apikey_mint_rotate_for_target"
|
|
35
|
+
EMAIL_CHANGE = "email_change"
|
|
36
|
+
RBAC_GRANT_CHANGE = "rbac_grant_change"
|
|
37
|
+
AUDIT_EDITING = "audit_editing"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Media capability interfaces.
|
|
2
|
+
|
|
3
|
+
Pure interface types -- ``typing.Protocol`` classes and stdlib dataclasses -- shared between
|
|
4
|
+
media providers (``3tears-models``, ``3tears-object-store``) and media consumers
|
|
5
|
+
(``3tears-agent-tools``). Implementing or accepting one never inherits a feature package's
|
|
6
|
+
dependency closure: ``3tears-interfaces`` depends on nothing (see :mod:`threetears.interfaces`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threetears.interfaces.media.facets import (
|
|
10
|
+
LOCATOR_KIND_CONTAINING_PAGE,
|
|
11
|
+
LOCATOR_KIND_DIRECT_FILE,
|
|
12
|
+
MediaFacets,
|
|
13
|
+
)
|
|
14
|
+
from threetears.interfaces.media.keys import build_object_key
|
|
15
|
+
from threetears.interfaces.media.protocols import (
|
|
16
|
+
EXTRACTION_STATUS_COMPLETE,
|
|
17
|
+
EXTRACTION_STATUS_FAILED,
|
|
18
|
+
EXTRACTION_STATUS_NONE,
|
|
19
|
+
EXTRACTION_STATUS_PENDING,
|
|
20
|
+
EXTRACTION_STATUS_REFUSED,
|
|
21
|
+
EXTRACTION_STATUS_UNCHANGED,
|
|
22
|
+
GENERATION_IGNORED,
|
|
23
|
+
GENERATION_SEED,
|
|
24
|
+
GENERATION_SIZE,
|
|
25
|
+
GENERATION_SOURCE_USED,
|
|
26
|
+
OBJECT_HANDLE_METADATA_KEY,
|
|
27
|
+
GeneratedImage,
|
|
28
|
+
ImageGenerationBackend,
|
|
29
|
+
MediaInfo,
|
|
30
|
+
MediaSizeLimitExceeded,
|
|
31
|
+
MediaStorage,
|
|
32
|
+
ObjectHandle,
|
|
33
|
+
ObjectListing,
|
|
34
|
+
ObjectStore,
|
|
35
|
+
TextProvider,
|
|
36
|
+
TranscriptionProvider,
|
|
37
|
+
ReferenceVisionProvider,
|
|
38
|
+
VisionProvider,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"EXTRACTION_STATUS_COMPLETE",
|
|
43
|
+
"EXTRACTION_STATUS_FAILED",
|
|
44
|
+
"EXTRACTION_STATUS_NONE",
|
|
45
|
+
"EXTRACTION_STATUS_PENDING",
|
|
46
|
+
"EXTRACTION_STATUS_REFUSED",
|
|
47
|
+
"EXTRACTION_STATUS_UNCHANGED",
|
|
48
|
+
"GENERATION_IGNORED",
|
|
49
|
+
"GENERATION_SEED",
|
|
50
|
+
"GENERATION_SIZE",
|
|
51
|
+
"GENERATION_SOURCE_USED",
|
|
52
|
+
"LOCATOR_KIND_CONTAINING_PAGE",
|
|
53
|
+
"LOCATOR_KIND_DIRECT_FILE",
|
|
54
|
+
"OBJECT_HANDLE_METADATA_KEY",
|
|
55
|
+
"GeneratedImage",
|
|
56
|
+
"ImageGenerationBackend",
|
|
57
|
+
"MediaFacets",
|
|
58
|
+
"MediaInfo",
|
|
59
|
+
"MediaSizeLimitExceeded",
|
|
60
|
+
"MediaStorage",
|
|
61
|
+
"ObjectHandle",
|
|
62
|
+
"ObjectListing",
|
|
63
|
+
"ObjectStore",
|
|
64
|
+
"TextProvider",
|
|
65
|
+
"TranscriptionProvider",
|
|
66
|
+
"ReferenceVisionProvider",
|
|
67
|
+
"VisionProvider",
|
|
68
|
+
"build_object_key",
|
|
69
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Carrier facets -- the additive vocabulary a media result may carry.
|
|
2
|
+
|
|
3
|
+
A facet is the per-carrier detail a *carrier-neutral* result core cannot
|
|
4
|
+
hold: an image's pixel dimensions, the rights it is offered under, whether
|
|
5
|
+
its locator addresses the file itself or the page containing it. The core
|
|
6
|
+
stays carrier-agnostic; facets ride alongside it, keyed by name.
|
|
7
|
+
|
|
8
|
+
Two rules make the vocabulary safe to grow:
|
|
9
|
+
|
|
10
|
+
- **Additive only.** A facet is added, never repurposed or removed within a
|
|
11
|
+
family major. Everything here is optional, and absence means "not known"
|
|
12
|
+
rather than "not so".
|
|
13
|
+
- **Unrecognised facets are ignored, not rejected.** A producer running a
|
|
14
|
+
later family version may carry a facet this reader has never heard of, and
|
|
15
|
+
the reader keeps working -- so :meth:`MediaFacets.from_metadata` drops keys
|
|
16
|
+
it does not know instead of raising. That is what lets a new carrier ship
|
|
17
|
+
without a coordinated release across every consumer.
|
|
18
|
+
|
|
19
|
+
The vocabulary lives here, beside the carrier taxonomy it belongs to
|
|
20
|
+
(:class:`~threetears.interfaces.media.protocols.MediaInfo`'s ``media_category``
|
|
21
|
+
and ``extraction_status``, and the
|
|
22
|
+
:class:`~threetears.interfaces.media.protocols.ObjectHandle` that answers "how
|
|
23
|
+
are the bytes fetchable"), rather than in each retrieval package that produces
|
|
24
|
+
one. A second media vocabulary in a search or discovery leaf is this
|
|
25
|
+
vocabulary, drifting.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"LOCATOR_KIND_CONTAINING_PAGE",
|
|
35
|
+
"LOCATOR_KIND_DIRECT_FILE",
|
|
36
|
+
"MediaFacets",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
#: :attr:`MediaFacets.locator_kind`: the locator addresses the media bytes
|
|
40
|
+
#: themselves -- fetching it yields the file.
|
|
41
|
+
LOCATOR_KIND_DIRECT_FILE = "direct-file"
|
|
42
|
+
|
|
43
|
+
#: :attr:`MediaFacets.locator_kind`: the locator addresses a page the media
|
|
44
|
+
#: appears *on* -- fetching it yields markup, and the bytes need a second step.
|
|
45
|
+
LOCATOR_KIND_CONTAINING_PAGE = "containing-page"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class MediaFacets:
|
|
50
|
+
"""The facets known to this vocabulary, for one media result.
|
|
51
|
+
|
|
52
|
+
Every facet is optional and defaults to ``None`` meaning "not reported":
|
|
53
|
+
a producer fills in what its upstream told it and leaves the rest alone,
|
|
54
|
+
and a consumer reads only the facets it understands.
|
|
55
|
+
|
|
56
|
+
``rights_status`` is an **open** vocabulary, deliberately: providers
|
|
57
|
+
report usage rights in labels nobody here controls (a licence identifier,
|
|
58
|
+
a Creative Commons label, a provider's own bucket name), and coercing
|
|
59
|
+
those into a closed set would lose the distinction that made a consumer
|
|
60
|
+
ask. Carry the upstream label verbatim; ``None`` means the provider said
|
|
61
|
+
nothing, which is not the same as "unrestricted".
|
|
62
|
+
|
|
63
|
+
``locator_kind`` is a *closed* distinction by contrast -- it is ours, not
|
|
64
|
+
a provider's -- so its two values ship as constants
|
|
65
|
+
(:data:`LOCATOR_KIND_DIRECT_FILE`, :data:`LOCATOR_KIND_CONTAINING_PAGE`)
|
|
66
|
+
for callers to compare against rather than as loose strings.
|
|
67
|
+
|
|
68
|
+
``width``/``height`` are pixel dimensions, spelled the way
|
|
69
|
+
:class:`~threetears.interfaces.media.protocols.GeneratedImage` already
|
|
70
|
+
spells them so the two can be read across without a translation step.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
rights_status: str | None = None
|
|
74
|
+
width: int | None = None
|
|
75
|
+
height: int | None = None
|
|
76
|
+
locator_kind: str | None = None # LOCATOR_KIND_DIRECT_FILE | LOCATOR_KIND_CONTAINING_PAGE
|
|
77
|
+
|
|
78
|
+
def to_metadata(self) -> dict[str, Any]:
|
|
79
|
+
"""Project the facets that say something to a JSON-safe dict.
|
|
80
|
+
|
|
81
|
+
Unset facets are omitted rather than carried as explicit nulls, so the
|
|
82
|
+
mapping is exactly the set of facets this result asserts -- a reader
|
|
83
|
+
iterating its keys never has to tell a reported ``None`` apart from an
|
|
84
|
+
absent one.
|
|
85
|
+
|
|
86
|
+
:return: a JSON-safe mapping of facet name to value, holding only the
|
|
87
|
+
facets that are set
|
|
88
|
+
:rtype: dict[str, Any]
|
|
89
|
+
"""
|
|
90
|
+
facets: dict[str, Any] = {}
|
|
91
|
+
if self.rights_status is not None:
|
|
92
|
+
facets["rights_status"] = self.rights_status
|
|
93
|
+
if self.width is not None:
|
|
94
|
+
facets["width"] = self.width
|
|
95
|
+
if self.height is not None:
|
|
96
|
+
facets["height"] = self.height
|
|
97
|
+
if self.locator_kind is not None:
|
|
98
|
+
facets["locator_kind"] = self.locator_kind
|
|
99
|
+
return facets
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_metadata(cls, data: dict[str, Any]) -> MediaFacets:
|
|
103
|
+
"""Read the facets this vocabulary knows, ignoring every other key.
|
|
104
|
+
|
|
105
|
+
Deliberately lenient where the rest of this package is strict: an
|
|
106
|
+
unknown key is a facet from a later version of the vocabulary, and
|
|
107
|
+
dropping it is the contract (a consumer that does not recognise a
|
|
108
|
+
facet ignores it rather than failing). The leniency stops there --
|
|
109
|
+
a *known* facet carrying an unusable value still fails, because that
|
|
110
|
+
is a producer bug rather than a version gap.
|
|
111
|
+
|
|
112
|
+
:param data: a facet mapping (as produced by :meth:`to_metadata`),
|
|
113
|
+
possibly carrying facets this reader does not know
|
|
114
|
+
:ptype data: dict[str, Any]
|
|
115
|
+
:return: the facets this vocabulary recognises; absent ones are None
|
|
116
|
+
:rtype: MediaFacets
|
|
117
|
+
:raises ValueError: when ``width`` or ``height`` is present but does
|
|
118
|
+
not read as an integer
|
|
119
|
+
:raises TypeError: when ``width`` or ``height`` is present but is of
|
|
120
|
+
a type no integer can be read from
|
|
121
|
+
"""
|
|
122
|
+
width = data.get("width")
|
|
123
|
+
height = data.get("height")
|
|
124
|
+
rights_status = data.get("rights_status")
|
|
125
|
+
locator_kind = data.get("locator_kind")
|
|
126
|
+
return cls(
|
|
127
|
+
rights_status=str(rights_status) if rights_status is not None else None,
|
|
128
|
+
width=int(width) if width is not None else None,
|
|
129
|
+
height=int(height) if height is not None else None,
|
|
130
|
+
locator_kind=str(locator_kind) if locator_kind is not None else None,
|
|
131
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tenant-scoped object-key builder (the platform's scope-first scheme).
|
|
2
|
+
|
|
3
|
+
The locked layout (scope-and-objects-design.md section 8) is, under one
|
|
4
|
+
bucket per environment::
|
|
5
|
+
|
|
6
|
+
<customer_id>/<scope>/<category>/<YYYY>/<MM>/<DD>/<object_id>/<filename>
|
|
7
|
+
|
|
8
|
+
``customer_id`` is the tenant-isolation prefix; ``scope`` is a
|
|
9
|
+
framework-general owning-context label the producer supplies
|
|
10
|
+
(``engagement-<id>`` / ``conversation-<id>`` / ``agent-<slug>``);
|
|
11
|
+
``object_id`` is a UUIDv7 (unique + time-ordered) folder so derivatives
|
|
12
|
+
co-locate; ``filename`` keeps the original name + extension for human
|
|
13
|
+
readability and correct download naming.
|
|
14
|
+
|
|
15
|
+
This builder lives in the dependency-free contract (beside
|
|
16
|
+
:class:`~threetears.interfaces.media.protocols.ObjectHandle`) rather than the
|
|
17
|
+
S3 impl package: the layout is a *contract* every producer encodes, and a
|
|
18
|
+
producing tool must be able to build a key without inheriting the aioboto3
|
|
19
|
+
client tree. The impl package re-exports it for back-compat.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from uuid import UUID
|
|
27
|
+
|
|
28
|
+
__all__ = ["build_object_key"]
|
|
29
|
+
|
|
30
|
+
#: anything outside the safe key-segment alphabet collapses to a hyphen.
|
|
31
|
+
_UNSAFE = re.compile(r"[^a-z0-9-]+")
|
|
32
|
+
|
|
33
|
+
#: fallback leaf when no usable filename is supplied.
|
|
34
|
+
_DEFAULT_FILENAME = "object"
|
|
35
|
+
|
|
36
|
+
#: isolation prefix for objects owned by the platform rather than by one
|
|
37
|
+
#: tenant. the scheme leads with ``customer_id`` because that segment IS the
|
|
38
|
+
#: isolation boundary bucket policy grants on, so a platform-shared object
|
|
39
|
+
#: still needs *a* leading segment to grant against -- it just cannot be a
|
|
40
|
+
#: customer. see :func:`build_object_key`.
|
|
41
|
+
_SHARED_PREFIX = "shared"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sanitize_segment(value: str) -> str:
|
|
45
|
+
"""Lower-case and collapse ``value`` to the ``[a-z0-9-]`` key alphabet.
|
|
46
|
+
|
|
47
|
+
:param value: raw segment (scope label, category, filename stem)
|
|
48
|
+
:ptype value: str
|
|
49
|
+
:return: sanitized segment safe as one S3 key path component; falls
|
|
50
|
+
back to ``object`` when nothing usable remains
|
|
51
|
+
:rtype: str
|
|
52
|
+
"""
|
|
53
|
+
cleaned = _UNSAFE.sub("-", value.strip().lower()).strip("-")
|
|
54
|
+
return cleaned or _DEFAULT_FILENAME
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sanitize_filename(filename: str | None) -> str:
|
|
58
|
+
"""Sanitize a filename's stem while preserving its extension.
|
|
59
|
+
|
|
60
|
+
:param filename: original filename (may carry an extension); ``None``
|
|
61
|
+
or empty yields the ``object`` fallback
|
|
62
|
+
:ptype filename: str | None
|
|
63
|
+
:return: readable, key-safe ``<stem>.<ext>`` (or ``<stem>``)
|
|
64
|
+
:rtype: str
|
|
65
|
+
"""
|
|
66
|
+
stem, dot, ext = (filename or "").rpartition(".")
|
|
67
|
+
if not dot:
|
|
68
|
+
result = _sanitize_segment(filename or "")
|
|
69
|
+
else:
|
|
70
|
+
safe_stem = _sanitize_segment(stem)
|
|
71
|
+
safe_ext = _UNSAFE.sub("", ext.lower())
|
|
72
|
+
result = f"{safe_stem}.{safe_ext}" if safe_ext else safe_stem
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_object_key(
|
|
77
|
+
*,
|
|
78
|
+
customer_id: UUID | None,
|
|
79
|
+
scope: str,
|
|
80
|
+
category: str,
|
|
81
|
+
object_id: UUID | None = None,
|
|
82
|
+
created: datetime | None = None,
|
|
83
|
+
filename: str | None = None,
|
|
84
|
+
path: str | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Build the scope-first object key (locked design section 8).
|
|
87
|
+
|
|
88
|
+
Two shapes, distinguished by whether the object is tenant-owned and
|
|
89
|
+
whether its address is opaque or meaningful:
|
|
90
|
+
|
|
91
|
+
- **tenant-owned, opaque address** (the original and still the common
|
|
92
|
+
case): ``customer_id`` plus ``object_id`` and ``created`` produce
|
|
93
|
+
``<customer_id>/<scope>/<category>/<YYYY>/<MM>/<DD>/<object_id>/<filename>``.
|
|
94
|
+
- **deterministic address** (``path`` supplied): the caller's own path
|
|
95
|
+
replaces the date partition, object-id folder and filename. Required
|
|
96
|
+
when a reader must derive the key from a request without a lookup --
|
|
97
|
+
a CDN fetching a map tile cannot consult a database to translate
|
|
98
|
+
``z/x/y`` into a UUID.
|
|
99
|
+
|
|
100
|
+
``customer_id=None`` addresses a platform-shared object under
|
|
101
|
+
the ``shared`` prefix instead of a tenant. That is not a loophole in the
|
|
102
|
+
tenant isolation: ``datasources.customer_id`` is itself nullable
|
|
103
|
+
with NULL meaning platform-shared, so objects derived from such a row
|
|
104
|
+
have no tenant to be scoped to, and forcing one would fork a single
|
|
105
|
+
shared artifact into one copy per customer.
|
|
106
|
+
|
|
107
|
+
:param customer_id: verified tenant UUID, or ``None`` for a
|
|
108
|
+
platform-shared object
|
|
109
|
+
:ptype customer_id: UUID | None
|
|
110
|
+
:param scope: owning-context label, e.g. ``engagement-<id>`` (sanitized)
|
|
111
|
+
:ptype scope: str
|
|
112
|
+
:param category: object kind, e.g. ``reports`` / ``evidence`` (sanitized)
|
|
113
|
+
:ptype category: str
|
|
114
|
+
:param object_id: unique object UUID (UUIDv7, time-ordered); required
|
|
115
|
+
unless ``path`` is supplied
|
|
116
|
+
:ptype object_id: UUID | None
|
|
117
|
+
:param created: UTC creation timestamp; drives the ``YYYY/MM/DD``
|
|
118
|
+
partition; required unless ``path`` is supplied
|
|
119
|
+
:ptype created: datetime | None
|
|
120
|
+
:param filename: original filename + extension; ``None`` -> ``object``
|
|
121
|
+
:ptype filename: str | None
|
|
122
|
+
:param path: caller-supplied deterministic tail, replacing the date /
|
|
123
|
+
object-id / filename segments. Each component is sanitized
|
|
124
|
+
individually so separators survive, and the final component keeps
|
|
125
|
+
its extension
|
|
126
|
+
:ptype path: str | None
|
|
127
|
+
:return: the full object key
|
|
128
|
+
:rtype: str
|
|
129
|
+
:raises ValueError: when neither ``path`` nor both of
|
|
130
|
+
``object_id``/``created`` are supplied
|
|
131
|
+
"""
|
|
132
|
+
owner = str(customer_id) if customer_id is not None else _SHARED_PREFIX
|
|
133
|
+
head = f"{owner}/{_sanitize_segment(scope)}/{_sanitize_segment(category)}"
|
|
134
|
+
if path is not None:
|
|
135
|
+
parts = [part for part in path.split("/") if part]
|
|
136
|
+
if not parts:
|
|
137
|
+
raise ValueError("build_object_key: path= must contain at least one component")
|
|
138
|
+
# the last component is a filename: sanitized as a stem plus extension
|
|
139
|
+
# so ``98.mvt`` does not collapse to ``98-mvt`` and lose the type a
|
|
140
|
+
# reader (and a CDN's content negotiation) depends on.
|
|
141
|
+
tail = "/".join([*(_sanitize_segment(part) for part in parts[:-1]), _sanitize_filename(parts[-1])])
|
|
142
|
+
return f"{head}/{tail}"
|
|
143
|
+
if object_id is None or created is None:
|
|
144
|
+
raise ValueError("build_object_key requires either path=, or both object_id= and created=")
|
|
145
|
+
leaf = _sanitize_filename(filename)
|
|
146
|
+
return f"{head}/{created:%Y/%m/%d}/{object_id}/{leaf}"
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""Protocols for media-related tool capabilities.
|
|
2
|
+
|
|
3
|
+
These define the contracts that host applications implement. Tool
|
|
4
|
+
implementations can depend on these protocols without coupling to
|
|
5
|
+
any specific infrastructure (S3, specific vision APIs, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"EXTRACTION_STATUS_COMPLETE",
|
|
18
|
+
"EXTRACTION_STATUS_FAILED",
|
|
19
|
+
"EXTRACTION_STATUS_NONE",
|
|
20
|
+
"EXTRACTION_STATUS_PENDING",
|
|
21
|
+
"EXTRACTION_STATUS_REFUSED",
|
|
22
|
+
"EXTRACTION_STATUS_UNCHANGED",
|
|
23
|
+
"GENERATION_IGNORED",
|
|
24
|
+
"GENERATION_SEED",
|
|
25
|
+
"GENERATION_SIZE",
|
|
26
|
+
"GENERATION_SOURCE_USED",
|
|
27
|
+
"OBJECT_HANDLE_METADATA_KEY",
|
|
28
|
+
"GeneratedImage",
|
|
29
|
+
"ImageGenerationBackend",
|
|
30
|
+
"MediaInfo",
|
|
31
|
+
"MediaSizeLimitExceeded",
|
|
32
|
+
"MediaStorage",
|
|
33
|
+
"ReferenceVisionProvider",
|
|
34
|
+
"ObjectHandle",
|
|
35
|
+
"ObjectListing",
|
|
36
|
+
"ObjectStore",
|
|
37
|
+
"TextProvider",
|
|
38
|
+
"TranscriptionProvider",
|
|
39
|
+
"VisionProvider",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# ``MediaInfo.extraction_status``: the spellings below are the ones already
|
|
43
|
+
# in the database, and that is deliberately where this vocabulary is
|
|
44
|
+
# defined from. ``agent-memory``'s v021 migration declares the column
|
|
45
|
+
# ``TEXT NOT NULL DEFAULT 'none'`` and v022 builds a partial index on
|
|
46
|
+
# ``WHERE extraction_status = 'pending'`` -- a spelling sitting in a column
|
|
47
|
+
# default and an index predicate is changed by a migration and an index
|
|
48
|
+
# rebuild, not by editing a constant. So these constants name what is
|
|
49
|
+
# stored; they do not propose it (docs/search-spec.md §3.5).
|
|
50
|
+
#
|
|
51
|
+
# The field stays ``str | None``. It is not narrowed to a ``Literal`` or a
|
|
52
|
+
# ``StrEnum``: consumers assign and compare bare ``str`` today, and the
|
|
53
|
+
# column carries no CHECK constraint, so a producer may legitimately store
|
|
54
|
+
# a value this list has not caught up with. Read an unrecognised status the
|
|
55
|
+
# way :mod:`threetears.interfaces.media.facets` reads an unrecognised facet
|
|
56
|
+
# -- ignore it, do not reject it.
|
|
57
|
+
|
|
58
|
+
#: No extraction has been attempted. The column's server default, and the
|
|
59
|
+
#: value a row carries until something asks for extraction.
|
|
60
|
+
EXTRACTION_STATUS_NONE = "none"
|
|
61
|
+
|
|
62
|
+
#: Extraction has been requested and has not finished. The value v022's
|
|
63
|
+
#: partial index is built on, which is what makes it a work queue.
|
|
64
|
+
EXTRACTION_STATUS_PENDING = "pending"
|
|
65
|
+
|
|
66
|
+
#: Extraction finished and the content is available.
|
|
67
|
+
EXTRACTION_STATUS_COMPLETE = "complete"
|
|
68
|
+
|
|
69
|
+
#: Extraction was attempted and did not produce content -- the fetch died,
|
|
70
|
+
#: the carrier was unreadable, the extractor gave up. Distinct from
|
|
71
|
+
#: :data:`EXTRACTION_STATUS_REFUSED`: something tried and failed.
|
|
72
|
+
EXTRACTION_STATUS_FAILED = "failed"
|
|
73
|
+
|
|
74
|
+
#: Extraction was declined before it was attempted -- ``robots.txt``
|
|
75
|
+
#: disallowed the fetch, a required extra was absent, a cap refused the
|
|
76
|
+
#: read. Distinct from :data:`EXTRACTION_STATUS_FAILED`: nothing tried, and
|
|
77
|
+
#: retrying under the same rules will decline again.
|
|
78
|
+
EXTRACTION_STATUS_REFUSED = "refused"
|
|
79
|
+
|
|
80
|
+
#: Upstream confirmed the caller's existing copy is still current, so no
|
|
81
|
+
#: content was produced and none was needed -- an HTTP ``304`` answering a
|
|
82
|
+
#: conditional request (D30 / SR-M4). Distinct from every status above it:
|
|
83
|
+
#: it is a **success**, unlike :data:`EXTRACTION_STATUS_FAILED`; something
|
|
84
|
+
#: did try, unlike :data:`EXTRACTION_STATUS_REFUSED`; and it produced no new
|
|
85
|
+
#: content, unlike :data:`EXTRACTION_STATUS_COMPLETE`.
|
|
86
|
+
#:
|
|
87
|
+
#: A reader that treats "no content" as failure will misread this. The
|
|
88
|
+
#: correct reading is that the caller's own copy is the content, and it has
|
|
89
|
+
#: just been re-confirmed.
|
|
90
|
+
EXTRACTION_STATUS_UNCHANGED = "unchanged"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class GeneratedImage:
|
|
95
|
+
"""Result from an image generation backend."""
|
|
96
|
+
|
|
97
|
+
data: bytes
|
|
98
|
+
mime_type: str
|
|
99
|
+
width: int | None = None
|
|
100
|
+
height: int | None = None
|
|
101
|
+
metadata: dict[str, Any] | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class MediaInfo:
|
|
106
|
+
"""Metadata about a media item, returned by :meth:`MediaStorage.get_media`.
|
|
107
|
+
|
|
108
|
+
``extraction_status`` carries one of the ``EXTRACTION_STATUS_*``
|
|
109
|
+
constants in this module, or ``None``. ``None`` and
|
|
110
|
+
:data:`EXTRACTION_STATUS_NONE` both mean *no extraction attempted*:
|
|
111
|
+
the dataclass defaults to ``None`` while the database column is
|
|
112
|
+
``NOT NULL DEFAULT 'none'``, so which one a reader sees depends on
|
|
113
|
+
whether the value came from a constructor or a row. That split is
|
|
114
|
+
recorded rather than reconciled -- collapsing it is a migration, not a
|
|
115
|
+
contract edit -- so a consumer testing for "nothing has happened yet"
|
|
116
|
+
must accept both.
|
|
117
|
+
|
|
118
|
+
``size_bytes`` is the stored object's size as the storage's catalog
|
|
119
|
+
records it, or ``None`` when the storage does not know it. It lets a
|
|
120
|
+
consumer refuse an object that is too large BEFORE downloading it. It is
|
|
121
|
+
what was recorded, not a measurement, so a consumer that must bound its
|
|
122
|
+
memory still bounds the read itself (``download_media(max_bytes=...)``).
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
media_id: UUID
|
|
126
|
+
media_category: str # "image", "audio", "video", "document"
|
|
127
|
+
mime_type: str
|
|
128
|
+
extraction_status: str | None = None
|
|
129
|
+
has_downloadable_data: bool = True
|
|
130
|
+
size_bytes: int | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class MediaSizeLimitExceeded(Exception):
|
|
134
|
+
"""A bounded download stopped: the media is larger than the limit its caller set.
|
|
135
|
+
|
|
136
|
+
Raised by :meth:`MediaStorage.download_media` when called with
|
|
137
|
+
``max_bytes``, either before anything is read (the recorded size is over
|
|
138
|
+
the limit) or as soon as the bytes read pass it. Whatever was read is
|
|
139
|
+
discarded; the caller never holds more than the limit plus one chunk.
|
|
140
|
+
|
|
141
|
+
:param media_id: the media item that was too large
|
|
142
|
+
:ptype media_id: UUID
|
|
143
|
+
:param limit_bytes: the limit the caller set
|
|
144
|
+
:ptype limit_bytes: int
|
|
145
|
+
:param size_bytes: the recorded size when that is what refused it, or
|
|
146
|
+
``None`` when the read passed the limit and the full size is unknown
|
|
147
|
+
:ptype size_bytes: int | None
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, media_id: UUID, *, limit_bytes: int, size_bytes: int | None) -> None:
|
|
151
|
+
"""Record which media item passed which limit.
|
|
152
|
+
|
|
153
|
+
:param media_id: the media item that was too large
|
|
154
|
+
:ptype media_id: UUID
|
|
155
|
+
:param limit_bytes: the limit the caller set
|
|
156
|
+
:ptype limit_bytes: int
|
|
157
|
+
:param size_bytes: the recorded size, or ``None`` when unknown
|
|
158
|
+
:ptype size_bytes: int | None
|
|
159
|
+
:return: nothing
|
|
160
|
+
:rtype: None
|
|
161
|
+
"""
|
|
162
|
+
self.media_id = media_id
|
|
163
|
+
self.limit_bytes = limit_bytes
|
|
164
|
+
self.size_bytes = size_bytes
|
|
165
|
+
detail = f"it is {size_bytes:,} bytes" if size_bytes is not None else "it passed the limit while being read"
|
|
166
|
+
super().__init__(f"media {media_id} is over the {limit_bytes:,}-byte limit: {detail}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@runtime_checkable
|
|
170
|
+
class ImageGenerationBackend(Protocol):
|
|
171
|
+
"""Protocol for image generation backends.
|
|
172
|
+
|
|
173
|
+
What a backend did with what it was given is said in the result's
|
|
174
|
+
``metadata``, under the keys :data:`GENERATION_SOURCE_USED`,
|
|
175
|
+
:data:`GENERATION_IGNORED`, :data:`GENERATION_SIZE` and
|
|
176
|
+
:data:`GENERATION_SEED`, so the caller can tell the model "the source was not
|
|
177
|
+
used" or "this model takes no seed" instead of reporting what was asked as
|
|
178
|
+
what was done. A backend may also carry a ``takes_source_image`` attribute
|
|
179
|
+
(bool): ``False`` lets a caller refuse an edit before paying for an image
|
|
180
|
+
made from the words alone. It is not part of the protocol's structure, so a
|
|
181
|
+
backend without it still satisfies ``isinstance``.
|
|
182
|
+
|
|
183
|
+
``size``, ``aspect`` and ``seed`` are passed only when the caller set them,
|
|
184
|
+
so a backend written before they existed still takes every call that does
|
|
185
|
+
not use them.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
async def generate(
|
|
189
|
+
self,
|
|
190
|
+
prompt: str,
|
|
191
|
+
*,
|
|
192
|
+
style: str | None = None,
|
|
193
|
+
source_image: bytes | None = None,
|
|
194
|
+
source_mime_type: str | None = None,
|
|
195
|
+
size: str | None = None,
|
|
196
|
+
aspect: str | None = None,
|
|
197
|
+
seed: int | None = None,
|
|
198
|
+
) -> GeneratedImage:
|
|
199
|
+
"""Generate an image from a text prompt.
|
|
200
|
+
|
|
201
|
+
:param prompt: text description of the image to generate
|
|
202
|
+
:ptype prompt: str
|
|
203
|
+
:param style: optional style modifier
|
|
204
|
+
:ptype style: str | None
|
|
205
|
+
:param source_image: optional source image for img2img
|
|
206
|
+
:ptype source_image: bytes | None
|
|
207
|
+
:param source_mime_type: MIME type of source image
|
|
208
|
+
:ptype source_mime_type: str | None
|
|
209
|
+
:param size: pixel size as ``"WIDTHxHEIGHT"``, e.g. ``"1024x1536"``
|
|
210
|
+
:ptype size: str | None
|
|
211
|
+
:param aspect: shape when no size is given: ``"square"``, ``"landscape"``,
|
|
212
|
+
``"portrait"`` or a ratio such as ``"16:9"``
|
|
213
|
+
:ptype aspect: str | None
|
|
214
|
+
:param seed: a number that makes the same prompt give the same image again
|
|
215
|
+
:ptype seed: int | None
|
|
216
|
+
:return: generated image result
|
|
217
|
+
:rtype: GeneratedImage
|
|
218
|
+
"""
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
#: ``GeneratedImage.metadata`` key: ``True`` when a given source image shaped the
|
|
223
|
+
#: result, ``False`` when the backend made the image from the words alone.
|
|
224
|
+
GENERATION_SOURCE_USED = "source_used"
|
|
225
|
+
|
|
226
|
+
#: ``GeneratedImage.metadata`` key: the names of the options the backend could not
|
|
227
|
+
#: apply (``"size"``, ``"aspect"``, ``"seed"``, ``"style"``), each with why, as
|
|
228
|
+
#: ``{name: reason}``.
|
|
229
|
+
GENERATION_IGNORED = "ignored"
|
|
230
|
+
|
|
231
|
+
#: ``GeneratedImage.metadata`` key: the size the image was made at, ``"WIDTHxHEIGHT"``.
|
|
232
|
+
GENERATION_SIZE = "size"
|
|
233
|
+
|
|
234
|
+
#: ``GeneratedImage.metadata`` key: the seed the image was made with, when the backend has one.
|
|
235
|
+
GENERATION_SEED = "seed"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@runtime_checkable
|
|
239
|
+
class MediaStorage(Protocol):
|
|
240
|
+
"""Protocol for media item access and storage.
|
|
241
|
+
|
|
242
|
+
Host applications implement this to bridge their specific storage
|
|
243
|
+
infrastructure (S3, database, etc.) to the generic tool interface.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
async def get_media(
|
|
247
|
+
self,
|
|
248
|
+
media_id: UUID,
|
|
249
|
+
) -> MediaInfo | None:
|
|
250
|
+
"""Look up media metadata by ID.
|
|
251
|
+
|
|
252
|
+
:param media_id: media item UUID
|
|
253
|
+
:ptype media_id: UUID
|
|
254
|
+
:return: media info or None
|
|
255
|
+
:rtype: MediaInfo | None
|
|
256
|
+
"""
|
|
257
|
+
...
|
|
258
|
+
|
|
259
|
+
async def download_media(
|
|
260
|
+
self,
|
|
261
|
+
media_id: UUID,
|
|
262
|
+
*,
|
|
263
|
+
max_bytes: int | None = None,
|
|
264
|
+
) -> tuple[bytes, str] | None:
|
|
265
|
+
"""Download raw media bytes, bounded when the caller sets a limit.
|
|
266
|
+
|
|
267
|
+
With ``max_bytes`` the implementation refuses an item whose recorded
|
|
268
|
+
size is over it without reading any of it, and stops reading as soon
|
|
269
|
+
as the bytes read pass it -- counting as they arrive, because a
|
|
270
|
+
recorded size can be absent or wrong -- releasing its stream and
|
|
271
|
+
raising :class:`MediaSizeLimitExceeded`. ``None`` reads the whole item.
|
|
272
|
+
|
|
273
|
+
:param media_id: media item UUID
|
|
274
|
+
:ptype media_id: UUID
|
|
275
|
+
:param max_bytes: the most bytes the caller will hold, or ``None`` for no limit
|
|
276
|
+
:ptype max_bytes: int | None
|
|
277
|
+
:return: (data, mime_type) or None if unavailable
|
|
278
|
+
:rtype: tuple[bytes, str] | None
|
|
279
|
+
:raises MediaSizeLimitExceeded: when ``max_bytes`` is set and the item is larger
|
|
280
|
+
"""
|
|
281
|
+
...
|
|
282
|
+
|
|
283
|
+
async def get_content(
|
|
284
|
+
self,
|
|
285
|
+
media_id: UUID,
|
|
286
|
+
content_type: str,
|
|
287
|
+
*,
|
|
288
|
+
model_name: str | None = None,
|
|
289
|
+
) -> str | None:
|
|
290
|
+
"""Retrieve extracted content for a media item.
|
|
291
|
+
|
|
292
|
+
:param media_id: media item UUID
|
|
293
|
+
:ptype media_id: UUID
|
|
294
|
+
:param content_type: type of content to retrieve
|
|
295
|
+
:ptype content_type: str
|
|
296
|
+
:param model_name: optional model name filter
|
|
297
|
+
:ptype model_name: str | None
|
|
298
|
+
:return: extracted content or None
|
|
299
|
+
:rtype: str | None
|
|
300
|
+
"""
|
|
301
|
+
...
|
|
302
|
+
|
|
303
|
+
async def store_content(
|
|
304
|
+
self,
|
|
305
|
+
media_id: UUID,
|
|
306
|
+
user_id: UUID,
|
|
307
|
+
content_type: str,
|
|
308
|
+
content: str,
|
|
309
|
+
metadata: dict[str, Any] | None = None,
|
|
310
|
+
) -> str:
|
|
311
|
+
"""Store extracted content for a media item.
|
|
312
|
+
|
|
313
|
+
:param media_id: media item UUID
|
|
314
|
+
:ptype media_id: UUID
|
|
315
|
+
:param user_id: user who triggered extraction
|
|
316
|
+
:ptype user_id: UUID
|
|
317
|
+
:param content_type: type of content being stored
|
|
318
|
+
:ptype content_type: str
|
|
319
|
+
:param content: extracted content text
|
|
320
|
+
:ptype content: str
|
|
321
|
+
:param metadata: optional metadata
|
|
322
|
+
:ptype metadata: dict[str, Any] | None
|
|
323
|
+
:return: content ID
|
|
324
|
+
:rtype: str
|
|
325
|
+
"""
|
|
326
|
+
...
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@runtime_checkable
|
|
330
|
+
class VisionProvider(Protocol):
|
|
331
|
+
"""Protocol for image analysis via a vision-capable model."""
|
|
332
|
+
|
|
333
|
+
async def analyze(
|
|
334
|
+
self,
|
|
335
|
+
image_data: bytes,
|
|
336
|
+
mime_type: str,
|
|
337
|
+
prompt: str,
|
|
338
|
+
) -> str:
|
|
339
|
+
"""Analyze an image and return a text description.
|
|
340
|
+
|
|
341
|
+
:param image_data: raw image bytes
|
|
342
|
+
:ptype image_data: bytes
|
|
343
|
+
:param mime_type: image MIME type
|
|
344
|
+
:ptype mime_type: str
|
|
345
|
+
:param prompt: analysis prompt
|
|
346
|
+
:ptype prompt: str
|
|
347
|
+
:return: analysis result text
|
|
348
|
+
:rtype: str
|
|
349
|
+
"""
|
|
350
|
+
...
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@runtime_checkable
|
|
354
|
+
class ReferenceVisionProvider(Protocol):
|
|
355
|
+
"""Protocol for image analysis by OBJECT REFERENCE, not raw bytes.
|
|
356
|
+
|
|
357
|
+
A vision backend that resolves the image itself -- a gateway-backed
|
|
358
|
+
provider that names the object on the model wire and lets the platform
|
|
359
|
+
stream the bytes at the model boundary -- implements this instead of
|
|
360
|
+
:class:`VisionProvider`. The bytes never reach the calling pod, so a
|
|
361
|
+
provider on this protocol needs no object-store credentials and puts no
|
|
362
|
+
image on the message bus.
|
|
363
|
+
|
|
364
|
+
Additive and separate from :class:`VisionProvider` on purpose: a
|
|
365
|
+
bytes-taking backend and a reference-taking backend are different
|
|
366
|
+
capabilities, and a consumer (:mod:`threetears.agent.tools.builtin.analyze_media`)
|
|
367
|
+
detects which it holds with :func:`isinstance` and routes accordingly. A
|
|
368
|
+
provider may implement either; a reference-only backend does not have to
|
|
369
|
+
pretend it can accept bytes it cannot honour.
|
|
370
|
+
|
|
371
|
+
Several object ids analyse together in ONE turn, so a multi-image question
|
|
372
|
+
reaches a vision model as a single interleaved message rather than N
|
|
373
|
+
separate calls.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
async def analyze_ref(
|
|
377
|
+
self,
|
|
378
|
+
object_ids: list[UUID],
|
|
379
|
+
prompt: str,
|
|
380
|
+
) -> str:
|
|
381
|
+
"""Analyze one or more referenced images and return a text answer.
|
|
382
|
+
|
|
383
|
+
:param object_ids: catalogued object ids to analyse together, in order
|
|
384
|
+
:ptype object_ids: list[UUID]
|
|
385
|
+
:param prompt: analysis prompt
|
|
386
|
+
:ptype prompt: str
|
|
387
|
+
:return: analysis result text
|
|
388
|
+
:rtype: str
|
|
389
|
+
"""
|
|
390
|
+
...
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@runtime_checkable
|
|
394
|
+
class TextProvider(Protocol):
|
|
395
|
+
"""Protocol for text-only queries (document QA, summarization).
|
|
396
|
+
|
|
397
|
+
Separate from :class:`VisionProvider` so that apps can use different
|
|
398
|
+
models for image analysis vs. text reasoning. Apps that don't
|
|
399
|
+
handle documents can ignore this entirely.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
async def answer(
|
|
403
|
+
self,
|
|
404
|
+
prompt: str,
|
|
405
|
+
) -> str:
|
|
406
|
+
"""Answer a text prompt.
|
|
407
|
+
|
|
408
|
+
:param prompt: text prompt
|
|
409
|
+
:ptype prompt: str
|
|
410
|
+
:return: answer text
|
|
411
|
+
:rtype: str
|
|
412
|
+
"""
|
|
413
|
+
...
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@runtime_checkable
|
|
417
|
+
class TranscriptionProvider(Protocol):
|
|
418
|
+
"""Protocol for audio/video transcription."""
|
|
419
|
+
|
|
420
|
+
async def transcribe(
|
|
421
|
+
self,
|
|
422
|
+
audio_data: bytes,
|
|
423
|
+
mime_type: str,
|
|
424
|
+
) -> str:
|
|
425
|
+
"""Transcribe audio data to text.
|
|
426
|
+
|
|
427
|
+
:param audio_data: raw audio bytes
|
|
428
|
+
:ptype audio_data: bytes
|
|
429
|
+
:param mime_type: audio MIME type
|
|
430
|
+
:ptype mime_type: str
|
|
431
|
+
:return: transcription text
|
|
432
|
+
:rtype: str
|
|
433
|
+
"""
|
|
434
|
+
...
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
#: the key under which a producing tool places its :class:`ObjectHandle`
|
|
438
|
+
#: (as :meth:`ObjectHandle.to_metadata`) in ``ToolResult.metadata`` so the
|
|
439
|
+
#: agent's catalog seam can recognise + persist it in the object catalog.
|
|
440
|
+
OBJECT_HANDLE_METADATA_KEY = "object_handle"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@dataclass
|
|
444
|
+
class ObjectHandle:
|
|
445
|
+
"""Handle to a stored object -- the small descriptor that crosses NATS
|
|
446
|
+
in place of the bytes.
|
|
447
|
+
|
|
448
|
+
The producing tool returns this (in ``ToolResult.metadata`` under
|
|
449
|
+
:data:`OBJECT_HANDLE_METADATA_KEY`); the agent catalogs it into the
|
|
450
|
+
hub-owned ``objects`` catalog; consumers resolve ``object_id`` back to
|
|
451
|
+
``s3_key`` (customer-safe, pod-side) and stream the bytes down. The bytes
|
|
452
|
+
themselves never travel with the handle.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
object_id: UUID
|
|
456
|
+
s3_key: str
|
|
457
|
+
mime_type: str
|
|
458
|
+
size_bytes: int
|
|
459
|
+
summary: str | None = None
|
|
460
|
+
category: str | None = None
|
|
461
|
+
|
|
462
|
+
def to_metadata(self) -> dict[str, Any]:
|
|
463
|
+
"""Project the handle to a JSON-safe dict for ``ToolResult.metadata``.
|
|
464
|
+
|
|
465
|
+
UUIDs are stringified at this border so the descriptor survives the
|
|
466
|
+
NATS/JSON round-trip to the agent intact.
|
|
467
|
+
|
|
468
|
+
:return: a JSON-safe representation of this handle
|
|
469
|
+
:rtype: dict[str, Any]
|
|
470
|
+
"""
|
|
471
|
+
return {
|
|
472
|
+
"object_id": str(self.object_id),
|
|
473
|
+
"s3_key": self.s3_key,
|
|
474
|
+
"mime_type": self.mime_type,
|
|
475
|
+
"size_bytes": self.size_bytes,
|
|
476
|
+
"summary": self.summary,
|
|
477
|
+
"category": self.category,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@classmethod
|
|
481
|
+
def from_metadata(cls, data: dict[str, Any]) -> ObjectHandle:
|
|
482
|
+
"""Reconstruct a handle from its :meth:`to_metadata` dict.
|
|
483
|
+
|
|
484
|
+
:param data: the JSON-safe handle dict (as produced by
|
|
485
|
+
:meth:`to_metadata`)
|
|
486
|
+
:ptype data: dict[str, Any]
|
|
487
|
+
:return: the reconstructed handle
|
|
488
|
+
:rtype: ObjectHandle
|
|
489
|
+
:raises KeyError: when a required field is absent
|
|
490
|
+
:raises ValueError: when ``object_id`` is not a valid UUID
|
|
491
|
+
"""
|
|
492
|
+
return cls(
|
|
493
|
+
object_id=UUID(str(data["object_id"])),
|
|
494
|
+
s3_key=str(data["s3_key"]),
|
|
495
|
+
mime_type=str(data["mime_type"]),
|
|
496
|
+
size_bytes=int(data["size_bytes"]),
|
|
497
|
+
summary=data.get("summary"),
|
|
498
|
+
category=data.get("category"),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@dataclass
|
|
503
|
+
class ObjectListing:
|
|
504
|
+
"""One entry from an object-store listing: key plus server metadata.
|
|
505
|
+
|
|
506
|
+
Yielded by :meth:`ObjectStore.list_entries` so a reconciler can decide
|
|
507
|
+
orphan-eligibility by age (``last_modified``) without a per-key HEAD
|
|
508
|
+
round-trip. ``last_modified`` is the store's own last-modified timestamp
|
|
509
|
+
(timezone-aware); ``size_bytes`` is the stored object size.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
key: str
|
|
513
|
+
last_modified: datetime
|
|
514
|
+
size_bytes: int
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@runtime_checkable
|
|
518
|
+
class ObjectStore(Protocol):
|
|
519
|
+
"""Streaming S3-compatible store for large binary artifacts.
|
|
520
|
+
|
|
521
|
+
Host apps / the SDK implement this over their object-store
|
|
522
|
+
infrastructure. STREAMING by contract: writes consume an async byte
|
|
523
|
+
stream and reads yield one, so a multi-GB artifact (pcap, db dump,
|
|
524
|
+
rendered report) never has to sit whole in a pod's memory. The key is
|
|
525
|
+
opaque here; the platform's tenant-scoped key scheme is built above
|
|
526
|
+
this contract.
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
async def put(
|
|
530
|
+
self,
|
|
531
|
+
key: str,
|
|
532
|
+
body: AsyncIterator[bytes],
|
|
533
|
+
*,
|
|
534
|
+
content_type: str,
|
|
535
|
+
size: int | None = None,
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Stream ``body`` to ``key`` (multipart for large objects).
|
|
538
|
+
|
|
539
|
+
:param key: tenant-scoped object key (opaque to this contract)
|
|
540
|
+
:ptype key: str
|
|
541
|
+
:param body: async iterator yielding the object's bytes in chunks
|
|
542
|
+
:ptype body: AsyncIterator[bytes]
|
|
543
|
+
:param content_type: MIME type stored on the object
|
|
544
|
+
:ptype content_type: str
|
|
545
|
+
:param size: total byte length when known (lets the impl pick a
|
|
546
|
+
single PUT below the multipart threshold); None streams multipart
|
|
547
|
+
:ptype size: int | None
|
|
548
|
+
:return: nothing
|
|
549
|
+
:rtype: None
|
|
550
|
+
"""
|
|
551
|
+
...
|
|
552
|
+
|
|
553
|
+
def open_read(self, key: str) -> AsyncIterator[bytes]:
|
|
554
|
+
"""Open ``key`` for streaming read, yielding bytes in chunks.
|
|
555
|
+
|
|
556
|
+
:param key: object key
|
|
557
|
+
:ptype key: str
|
|
558
|
+
:return: async iterator over the object's bytes
|
|
559
|
+
:rtype: AsyncIterator[bytes]
|
|
560
|
+
"""
|
|
561
|
+
...
|
|
562
|
+
|
|
563
|
+
async def delete(self, key: str) -> None:
|
|
564
|
+
"""Delete a single object.
|
|
565
|
+
|
|
566
|
+
:param key: object key
|
|
567
|
+
:ptype key: str
|
|
568
|
+
:return: nothing
|
|
569
|
+
:rtype: None
|
|
570
|
+
"""
|
|
571
|
+
...
|
|
572
|
+
|
|
573
|
+
async def delete_many(self, keys: list[str]) -> None:
|
|
574
|
+
"""Delete many objects in one batched request (reconciler sweep).
|
|
575
|
+
|
|
576
|
+
:param keys: object keys to delete
|
|
577
|
+
:ptype keys: list[str]
|
|
578
|
+
:return: nothing
|
|
579
|
+
:rtype: None
|
|
580
|
+
"""
|
|
581
|
+
...
|
|
582
|
+
|
|
583
|
+
def list_keys(self, prefix: str | None = None) -> AsyncIterator[str]:
|
|
584
|
+
"""Yield object keys, optionally restricted to ``prefix``.
|
|
585
|
+
|
|
586
|
+
:param prefix: key-prefix filter (e.g. a tenant's ``<customer_id>/``);
|
|
587
|
+
None lists the whole bucket
|
|
588
|
+
:ptype prefix: str | None
|
|
589
|
+
:return: async iterator over object keys
|
|
590
|
+
:rtype: AsyncIterator[str]
|
|
591
|
+
"""
|
|
592
|
+
...
|
|
593
|
+
|
|
594
|
+
def list_entries(self, prefix: str | None = None) -> AsyncIterator[ObjectListing]:
|
|
595
|
+
"""Yield object listings (key + last-modified + size), optionally by ``prefix``.
|
|
596
|
+
|
|
597
|
+
Like :meth:`list_keys` but carries each object's server metadata so a
|
|
598
|
+
reconciler can judge orphan age without a per-key HEAD request.
|
|
599
|
+
|
|
600
|
+
:param prefix: key-prefix filter (e.g. a tenant's ``<customer_id>/``);
|
|
601
|
+
None lists the whole bucket
|
|
602
|
+
:ptype prefix: str | None
|
|
603
|
+
:return: async iterator over object listings
|
|
604
|
+
:rtype: AsyncIterator[ObjectListing]
|
|
605
|
+
"""
|
|
606
|
+
...
|
|
607
|
+
|
|
608
|
+
async def presigned_get_url(self, key: str, *, expires_in: int = 300) -> str:
|
|
609
|
+
"""Presigned GET URL for delivery -- bytes never cross the agent.
|
|
610
|
+
|
|
611
|
+
:param key: object key
|
|
612
|
+
:ptype key: str
|
|
613
|
+
:param expires_in: URL validity in seconds
|
|
614
|
+
:ptype expires_in: int
|
|
615
|
+
:return: presigned URL
|
|
616
|
+
:rtype: str
|
|
617
|
+
"""
|
|
618
|
+
...
|
|
File without changes
|