annotide 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annotide/__init__.py +22 -0
- annotide/cli.py +214 -0
- annotide/client.py +660 -0
- annotide/errors.py +58 -0
- annotide/mcp_server.py +213 -0
- annotide/models.py +2683 -0
- annotide/py.typed +0 -0
- annotide-0.1.0.dist-info/METADATA +18 -0
- annotide-0.1.0.dist-info/RECORD +14 -0
- annotide-0.1.0.dist-info/WHEEL +5 -0
- annotide-0.1.0.dist-info/entry_points.txt +2 -0
- annotide-0.1.0.dist-info/licenses/LICENSE +201 -0
- annotide-0.1.0.dist-info/licenses/NOTICE +7 -0
- annotide-0.1.0.dist-info/top_level.txt +1 -0
annotide/errors.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exceptions raised by the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from annotide.models import JobRead
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnnotationError(Exception):
|
|
12
|
+
"""Base class of every error the SDK raises."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiError(AnnotationError):
|
|
16
|
+
"""The API answered with an error: an RFC 9457 problem (CONTRACTS.md, API-2)."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
status: int,
|
|
21
|
+
title: str,
|
|
22
|
+
detail: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
type: str = "about:blank", # noqa: A002 - the problem-details field name
|
|
25
|
+
method: str = "",
|
|
26
|
+
path: str = "",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.status = status
|
|
29
|
+
self.title = title
|
|
30
|
+
self.detail = detail
|
|
31
|
+
self.type = type
|
|
32
|
+
self.method = method
|
|
33
|
+
self.path = path
|
|
34
|
+
where = f"{method} {path}: " if method else ""
|
|
35
|
+
super().__init__(f"{where}{status} {detail or title}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class JobFailedError(AnnotationError):
|
|
39
|
+
"""A job ended `failed` or `cancelled`. `job` is its final state."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, job: JobRead) -> None:
|
|
42
|
+
self.job = job
|
|
43
|
+
reason = f": {job['error']}" if job.get("error") else ""
|
|
44
|
+
super().__init__(f"job {_describe(job)} ended {job.get('status')}{reason}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JobTimeoutError(AnnotationError):
|
|
48
|
+
"""A job did not finish in time. `job` is the last state seen."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, job: JobRead, timeout: float) -> None:
|
|
51
|
+
self.job = job
|
|
52
|
+
super().__init__(f"job {_describe(job)} still {job.get('status')} after {timeout:.0f}s")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _describe(job: JobRead) -> str:
|
|
56
|
+
# Tolerant of a partial body: an error message must never raise itself.
|
|
57
|
+
kind = job.get("type")
|
|
58
|
+
return f"{job.get('id')} ({kind})" if kind else str(job.get("id"))
|
annotide/mcp_server.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""MCP server for AI agents (API-8): `annotide mcp`.
|
|
2
|
+
|
|
3
|
+
Serves the Model Context Protocol over stdio and calls the platform's REST
|
|
4
|
+
API through `Client`, with an API key. Use a service account's key: the
|
|
5
|
+
agent can then do exactly what that account's project memberships allow,
|
|
6
|
+
and nothing in this module widens it.
|
|
7
|
+
|
|
8
|
+
The tools cover what an agent pre-labelling a project needs: find projects
|
|
9
|
+
and their label schema, look at items (an image comes back as an image),
|
|
10
|
+
read annotations, claim and release tasks, and post pre-labels as an
|
|
11
|
+
external producer (`POST /items/{id}/prelabels`). An agent cannot submit,
|
|
12
|
+
review, delete or export.
|
|
13
|
+
|
|
14
|
+
Needs the `mcp` extra: `pip install "annotide[mcp]"`.
|
|
15
|
+
|
|
16
|
+
Configuration (environment):
|
|
17
|
+
|
|
18
|
+
- `ANNOTIDE_URL`, `ANNOTIDE_API_KEY` — as for every SDK client.
|
|
19
|
+
- `ANNOTIDE_MODEL_VERSION_ID` — the model version `create_prelabel` writes
|
|
20
|
+
under unless the call names one. Register an endpoint-less model for the
|
|
21
|
+
agent (Models page, empty endpoint) and add a version to it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from pathlib import PurePosixPath
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import anyio
|
|
32
|
+
from mcp.server.mcpserver import Image, MCPServer
|
|
33
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
34
|
+
from mcp.types import ToolAnnotations
|
|
35
|
+
|
|
36
|
+
from annotide.client import Client
|
|
37
|
+
from annotide.errors import AnnotationError
|
|
38
|
+
|
|
39
|
+
__all__ = ["MAX_MEDIA_BYTES", "MODEL_VERSION_ENV", "build_server"]
|
|
40
|
+
|
|
41
|
+
MODEL_VERSION_ENV = "ANNOTIDE_MODEL_VERSION_ID"
|
|
42
|
+
#: `view_item` refuses larger media; a model's context is not a file share.
|
|
43
|
+
MAX_MEDIA_BYTES = 5 * 1024 * 1024
|
|
44
|
+
#: The fields an agent needs from an item; signed URLs and paging noise stay out.
|
|
45
|
+
_ITEM_FIELDS = ("id", "path", "media_type", "status", "width", "height", "meta")
|
|
46
|
+
_IMAGE_FORMATS = {".jpg": "jpeg", ".jpeg": "jpeg", ".png": "png", ".webp": "webp", ".gif": "gif"}
|
|
47
|
+
_TEXT_TYPES = {"text"}
|
|
48
|
+
|
|
49
|
+
INSTRUCTIONS = """\
|
|
50
|
+
Tools for the Annotide. Typical loop for pre-labelling:
|
|
51
|
+
1. list_projects, then get_label_schema for the project: shapes must use its
|
|
52
|
+
class names and the tools each class allows.
|
|
53
|
+
2. claim_task (or list_items with status "new") to pick an item.
|
|
54
|
+
3. view_item to see it, get_annotations to see what is already there.
|
|
55
|
+
4. create_prelabel with an Annotation result JSON
|
|
56
|
+
({"schema_version": 1, "media_type": ..., "classification": {...},
|
|
57
|
+
"shapes": [...]}); a bbox is {"id", "type": "bbox", "class", "bbox":
|
|
58
|
+
[x_min, y_min, x_max, y_max]} in image pixels; every shape `id` is a
|
|
59
|
+
UUID you generate (uuid4).
|
|
60
|
+
5. release_task when done; a person reviews and submits the pre-label.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
64
|
+
_WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _item_summary(item: Any) -> dict[str, Any]:
|
|
68
|
+
return {key: item.get(key) for key in _ITEM_FIELDS if key in item}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_server(client_factory: Callable[[], Client] | None = None) -> MCPServer:
|
|
72
|
+
"""The MCP server; `client_factory` lets tests hand in a client."""
|
|
73
|
+
factory = client_factory or Client
|
|
74
|
+
holder: dict[str, Client] = {}
|
|
75
|
+
|
|
76
|
+
def client() -> Client:
|
|
77
|
+
# Created on first use, so `annotide mcp` starts (and lists its tools)
|
|
78
|
+
# even before the environment is complete; the first call says why not.
|
|
79
|
+
if "client" not in holder:
|
|
80
|
+
holder["client"] = factory()
|
|
81
|
+
return holder["client"]
|
|
82
|
+
|
|
83
|
+
async def call(fn: Callable[[], Any]) -> Any:
|
|
84
|
+
# The SDK is synchronous; keep the stdio loop responsive. Its errors
|
|
85
|
+
# (a 409, a missing key) are answers the agent should read, not crashes.
|
|
86
|
+
try:
|
|
87
|
+
return await anyio.to_thread.run_sync(fn)
|
|
88
|
+
except AnnotationError as exc:
|
|
89
|
+
raise ToolError(str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
server = MCPServer(name="annotide", instructions=INSTRUCTIONS)
|
|
92
|
+
|
|
93
|
+
@server.tool(annotations=_READ)
|
|
94
|
+
async def list_projects() -> list[dict[str, Any]]:
|
|
95
|
+
"""Projects the API key's account is a member of."""
|
|
96
|
+
projects = await call(lambda: list(client().list_projects()))
|
|
97
|
+
return [
|
|
98
|
+
{"id": p["id"], "name": p["name"], "description": p.get("description")}
|
|
99
|
+
for p in projects
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
@server.tool(annotations=_READ)
|
|
103
|
+
async def get_label_schema(project_id: str) -> dict[str, Any]:
|
|
104
|
+
"""The project's current label schema: classes, their tools and attributes."""
|
|
105
|
+
versions = await call(lambda: client().list_schema_versions(project_id))
|
|
106
|
+
if not versions:
|
|
107
|
+
raise AnnotationError(f"project {project_id} has no label schema")
|
|
108
|
+
latest = versions[0]
|
|
109
|
+
return {
|
|
110
|
+
"label_schema_version_id": latest["id"],
|
|
111
|
+
"version": latest["version"],
|
|
112
|
+
"definition": latest["definition"],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@server.tool(annotations=_READ)
|
|
116
|
+
async def list_items(
|
|
117
|
+
project_id: str, status: str | None = None, limit: int = 20
|
|
118
|
+
) -> list[dict[str, Any]]:
|
|
119
|
+
"""A project's items, oldest first; `status` filters (new, prelabeled, annotating, …)."""
|
|
120
|
+
limit = max(1, min(limit, 200))
|
|
121
|
+
|
|
122
|
+
def fetch() -> list[dict[str, Any]]:
|
|
123
|
+
items = client().list_items(project_id, status=status)
|
|
124
|
+
out: list[dict[str, Any]] = []
|
|
125
|
+
for item in items:
|
|
126
|
+
out.append(_item_summary(item))
|
|
127
|
+
if len(out) >= limit:
|
|
128
|
+
break
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
result: list[dict[str, Any]] = await call(fetch)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
@server.tool(annotations=_READ)
|
|
135
|
+
async def get_item(item_id: str) -> dict[str, Any]:
|
|
136
|
+
"""One item's metadata and a short-lived signed `media_url`."""
|
|
137
|
+
item = await call(lambda: client().get_item(item_id))
|
|
138
|
+
return {**_item_summary(item), "media_url": item.get("media_url")}
|
|
139
|
+
|
|
140
|
+
@server.tool(annotations=_READ)
|
|
141
|
+
async def view_item(item_id: str) -> Image | str:
|
|
142
|
+
"""The item's media itself: an image as an image, a text item as its text."""
|
|
143
|
+
|
|
144
|
+
def fetch() -> Image | str:
|
|
145
|
+
item = client().get_item(item_id)
|
|
146
|
+
media_type = str(item.get("media_type"))
|
|
147
|
+
if media_type == "image":
|
|
148
|
+
data = client().download_media(item, max_bytes=MAX_MEDIA_BYTES)
|
|
149
|
+
suffix = PurePosixPath(str(item.get("path", ""))).suffix.lower()
|
|
150
|
+
return Image(data=data, format=_IMAGE_FORMATS.get(suffix, "png"))
|
|
151
|
+
if media_type in _TEXT_TYPES:
|
|
152
|
+
return (
|
|
153
|
+
client()
|
|
154
|
+
.download_media(item, max_bytes=MAX_MEDIA_BYTES)
|
|
155
|
+
.decode("utf-8", errors="replace")
|
|
156
|
+
)
|
|
157
|
+
raise AnnotationError(
|
|
158
|
+
f"view_item shows images and text; this item is {media_type}. "
|
|
159
|
+
"Use get_item for its signed URL."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result: Image | str = await call(fetch)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
@server.tool(annotations=_READ)
|
|
166
|
+
async def get_annotations(item_id: str, all_versions: bool = False) -> dict[str, Any]:
|
|
167
|
+
"""The item's latest primary annotation version, or its whole history."""
|
|
168
|
+
versions = await call(lambda: client().list_annotations(item_id))
|
|
169
|
+
primary = [v for v in versions if v.get("kind", "primary") == "primary"]
|
|
170
|
+
if all_versions:
|
|
171
|
+
return {"versions": versions}
|
|
172
|
+
return {"latest": primary[0] if primary else None, "version_count": len(versions)}
|
|
173
|
+
|
|
174
|
+
@server.tool(annotations=_WRITE)
|
|
175
|
+
async def claim_task(project_id: str, task_type: str = "annotate") -> dict[str, Any]:
|
|
176
|
+
"""Claim the next open task in the project (it is locked to you until released)."""
|
|
177
|
+
task = await call(lambda: client().claim_task(project_id, task_type=task_type))
|
|
178
|
+
if task is None:
|
|
179
|
+
return {"task": None, "message": "No open task right now."}
|
|
180
|
+
return {"task": task}
|
|
181
|
+
|
|
182
|
+
@server.tool(annotations=_WRITE)
|
|
183
|
+
async def release_task(task_id: str) -> dict[str, Any]:
|
|
184
|
+
"""Give a claimed task back to the queue."""
|
|
185
|
+
task = await call(lambda: client().release_task(task_id))
|
|
186
|
+
return {"task": task}
|
|
187
|
+
|
|
188
|
+
@server.tool(annotations=_WRITE)
|
|
189
|
+
async def create_prelabel(
|
|
190
|
+
item_id: str,
|
|
191
|
+
result: dict[str, Any],
|
|
192
|
+
model_version_id: str | None = None,
|
|
193
|
+
label_schema_version_id: str | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Post a pre-label: a draft a person will review. Never replaces human work."""
|
|
196
|
+
version = model_version_id or os.environ.get(MODEL_VERSION_ENV)
|
|
197
|
+
if not version:
|
|
198
|
+
raise ToolError(f"no model version: pass model_version_id or set {MODEL_VERSION_ENV}")
|
|
199
|
+
annotation = await call(
|
|
200
|
+
lambda: client().create_prelabel(
|
|
201
|
+
item_id,
|
|
202
|
+
model_version_id=version,
|
|
203
|
+
result=result,
|
|
204
|
+
label_schema_version_id=label_schema_version_id,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return {
|
|
208
|
+
"annotation_id": annotation["id"],
|
|
209
|
+
"version": annotation["version"],
|
|
210
|
+
"status": annotation["status"],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return server
|