openplot 1.0.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.
openplot/feedback.py ADDED
@@ -0,0 +1,141 @@
1
+ """Compile annotations into a structured feedback prompt for LLM agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .domain.regions import region_bounds_from_points, region_zone_hint_from_points
6
+ from .models import Annotation, AnnotationStatus, PlotSession, RegionInfo
7
+
8
+
9
+ def _region_bounds(region: RegionInfo) -> tuple[float, float, float, float] | None:
10
+ return region_bounds_from_points(region.points)
11
+
12
+
13
+ def _region_zone_hint(region: RegionInfo) -> str:
14
+ return region_zone_hint_from_points(region.points)
15
+
16
+
17
+ def _describe_element(ann: Annotation) -> str:
18
+ """Human-readable description of the annotated element or region."""
19
+ if ann.element_info is not None:
20
+ ei = ann.element_info
21
+ attrs_str = ", ".join(f"{k}: {v}" for k, v in ei.attributes.items())
22
+ text_part = f' "{ei.text_content}"' if ei.text_content else ""
23
+ attrs_part = f" ({attrs_str})" if attrs_str else ""
24
+ return f"<{ei.tag}>{text_part}{attrs_part}"
25
+
26
+ if ann.region is not None:
27
+ ri = ann.region
28
+ bounds = _region_bounds(ri)
29
+ if bounds is not None:
30
+ x0, y0, x1, y1 = bounds
31
+ coords = f"({x0:.2f}, {y0:.2f}) -> ({x1:.2f}, {y1:.2f})"
32
+ else:
33
+ coords = "unknown region"
34
+ has_crop = " [cropped image attached]" if ri.crop_base64 else ""
35
+ return f"{ri.type.value.capitalize()} region at {coords}{has_crop}"
36
+
37
+ return "Unknown element"
38
+
39
+
40
+ def compile_feedback(session: PlotSession, *, include_addressed: bool = False) -> str:
41
+ """Build a Markdown prompt containing all pending annotations.
42
+
43
+ Parameters
44
+ ----------
45
+ session:
46
+ The current plot session with annotations.
47
+ include_addressed:
48
+ If True, also include annotations already marked as addressed.
49
+
50
+ Returns
51
+ -------
52
+ str
53
+ A Markdown-formatted feedback prompt ready to send to an LLM.
54
+ """
55
+ annotations = [
56
+ a
57
+ for a in session.annotations
58
+ if include_addressed or a.status == AnnotationStatus.pending
59
+ ]
60
+
61
+ if not annotations:
62
+ return "No pending feedback annotations."
63
+
64
+ lines: list[str] = []
65
+ lines.append(
66
+ f"## Plot Feedback ({len(annotations)} annotation{'s' if len(annotations) != 1 else ''})\n"
67
+ )
68
+
69
+ has_region_annotations = any(a.region is not None for a in annotations)
70
+ has_element_annotations = any(a.element_info is not None for a in annotations)
71
+
72
+ lines.append("### Scope Rules (must follow)\n")
73
+
74
+ if has_region_annotations:
75
+ lines.append(
76
+ "- For **raster-region** annotations, scope is **LOCAL_REGION**. "
77
+ "Treat the attached crop image as authoritative grounding."
78
+ )
79
+ lines.append(
80
+ '- Ambiguous references ("this", "these", "each line", '
81
+ '"the lines") apply only to elements visible inside the selected '
82
+ "region/crop."
83
+ )
84
+
85
+ if has_element_annotations:
86
+ lines.append(
87
+ "- For **svg-element** annotations, scope is **LOCAL_ELEMENT** by default."
88
+ )
89
+
90
+ lines.append(
91
+ "- Do not modify outside local scope unless feedback explicitly asks for "
92
+ 'global edits (for example: "all charts", "entire figure", '
93
+ '"across all subplots").'
94
+ )
95
+ lines.append(
96
+ "- Prefer the minimal set of localized changes that satisfies each annotation.\n"
97
+ )
98
+
99
+ # Include the script source if available.
100
+ if session.source_script:
101
+ lines.append("### Current Script\n")
102
+ lines.append("```python")
103
+ lines.append(session.source_script.rstrip())
104
+ lines.append("```\n")
105
+
106
+ lines.append("### Annotations\n")
107
+ for i, ann in enumerate(annotations, 1):
108
+ desc = _describe_element(ann)
109
+ lines.append(f"{i}. **Element**: {desc}")
110
+ if ann.region is not None:
111
+ lines.append(" **Mode**: raster-region")
112
+ lines.append(" **Scope**: LOCAL_REGION")
113
+ bounds = _region_bounds(ann.region)
114
+ if bounds is not None:
115
+ x0, y0, x1, y1 = bounds
116
+ lines.append(
117
+ " **Region (normalized)**: "
118
+ f"({x0:.3f}, {y0:.3f}) -> ({x1:.3f}, {y1:.3f})"
119
+ )
120
+ lines.append(f" **Zone hint**: {_region_zone_hint(ann.region)}")
121
+ lines.append(
122
+ " **Disambiguation**: Ambiguous references resolve only to "
123
+ "elements visible in this region crop."
124
+ )
125
+ elif ann.element_info is not None:
126
+ lines.append(" **Mode**: svg-element")
127
+ lines.append(" **Scope**: LOCAL_ELEMENT")
128
+ lines.append(
129
+ " **Disambiguation**: Ambiguous references resolve to the "
130
+ "selected SVG element."
131
+ )
132
+ lines.append(f' **Feedback**: "{ann.feedback}"\n')
133
+
134
+ lines.append("---")
135
+ lines.append(
136
+ "Please update the script to address all the feedback above. "
137
+ "Return the complete updated script. Apply scope rules first when "
138
+ "feedback wording is ambiguous."
139
+ )
140
+
141
+ return "\n".join(lines)
openplot/mcp_server.py ADDED
@@ -0,0 +1,353 @@
1
+ """MCP stdio server for OpenPlot agent integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import quote
14
+ from urllib.request import Request, urlopen
15
+
16
+ from mcp.server.fastmcp import FastMCP
17
+ from mcp.server.fastmcp import Image
18
+
19
+ from .domain.annotations import pending_annotation_dicts_for_context
20
+ from .domain.regions import region_bounds_from_points, region_zone_hint_from_bounds
21
+
22
+ PORT_FILE = Path.home() / ".openplot" / "port"
23
+
24
+
25
+ class BackendError(RuntimeError):
26
+ """Raised when the OpenPlot backend request fails."""
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class BackendClient:
31
+ """Small HTTP client for talking to the OpenPlot FastAPI backend."""
32
+
33
+ base_url: str
34
+ timeout_s: float = 20.0
35
+ session_id: str | None = None
36
+
37
+ def _with_session(self, path: str) -> str:
38
+ normalized = path.strip()
39
+ if not self.session_id:
40
+ return normalized
41
+
42
+ separator = "&" if "?" in normalized else "?"
43
+ encoded_session_id = quote(self.session_id, safe="")
44
+ return f"{normalized}{separator}session_id={encoded_session_id}"
45
+
46
+ def get(self, path: str) -> dict:
47
+ req = Request(f"{self.base_url}{self._with_session(path)}", method="GET")
48
+ return _request_json(req, timeout_s=self.timeout_s)
49
+
50
+ def post(self, path: str, payload: dict) -> dict:
51
+ data = json.dumps(payload).encode("utf-8")
52
+ req = Request(
53
+ f"{self.base_url}{self._with_session(path)}",
54
+ method="POST",
55
+ data=data,
56
+ headers={"Content-Type": "application/json"},
57
+ )
58
+ return _request_json(req, timeout_s=self.timeout_s)
59
+
60
+
61
+ def _decode_data_url(data_url_or_base64: str) -> tuple[str, bytes]:
62
+ """Decode a data URL (or raw base64 string) into MIME type + bytes."""
63
+ payload = data_url_or_base64.strip()
64
+ mime_type = "image/png"
65
+
66
+ if payload.startswith("data:"):
67
+ try:
68
+ header, payload = payload.split(",", 1)
69
+ except ValueError as exc:
70
+ raise ValueError("Invalid data URL payload") from exc
71
+
72
+ # Example: data:image/png;base64
73
+ meta = header[5:]
74
+ first = meta.split(";", 1)[0].strip().lower()
75
+ if first:
76
+ mime_type = first
77
+
78
+ payload = "".join(payload.split())
79
+ if not payload:
80
+ raise ValueError("Empty base64 payload")
81
+
82
+ try:
83
+ image_bytes = base64.b64decode(payload, validate=True)
84
+ except binascii.Error:
85
+ # Some encoders omit padding; fallback to permissive decode.
86
+ try:
87
+ image_bytes = base64.b64decode(payload, validate=False)
88
+ except binascii.Error as exc:
89
+ raise ValueError("Invalid base64 image payload") from exc
90
+
91
+ if not image_bytes:
92
+ raise ValueError("Decoded image payload is empty")
93
+
94
+ return mime_type, image_bytes
95
+
96
+
97
+ def _image_format_from_mime(mime_type: str) -> str:
98
+ """Map MIME type to FastMCP Image format token."""
99
+ normalized = mime_type.strip().lower()
100
+ if "/" in normalized:
101
+ normalized = normalized.split("/", 1)[1]
102
+ normalized = normalized.split(";", 1)[0]
103
+
104
+ if normalized in {"jpg", "jpeg"}:
105
+ return "jpeg"
106
+ if normalized in {"png", "gif", "webp"}:
107
+ return normalized
108
+ return "png"
109
+
110
+
111
+ def _request_json(req: Request, *, timeout_s: float) -> dict:
112
+ try:
113
+ with urlopen(req, timeout=timeout_s) as resp:
114
+ body = resp.read().decode("utf-8")
115
+ return json.loads(body) if body else {}
116
+ except HTTPError as exc:
117
+ body = exc.read().decode("utf-8", errors="replace")
118
+ raise BackendError(f"HTTP {exc.code} for {req.full_url}: {body}") from exc
119
+ except URLError as exc:
120
+ raise BackendError(
121
+ f"Could not connect to backend at {req.full_url}: {exc}"
122
+ ) from exc
123
+
124
+
125
+ def _normalize_optional_env(key: str) -> str | None:
126
+ raw_value = os.getenv(key)
127
+ if raw_value is None:
128
+ return None
129
+ normalized = raw_value.strip()
130
+ return normalized or None
131
+
132
+
133
+ def discover_server_url(explicit_url: str | None = None) -> str:
134
+ """Resolve the OpenPlot backend URL from explicit arg, env, or port file."""
135
+ if explicit_url:
136
+ return explicit_url.rstrip("/")
137
+
138
+ env_url = os.getenv("OPENPLOT_SERVER_URL")
139
+ if env_url:
140
+ return env_url.rstrip("/")
141
+
142
+ if PORT_FILE.exists():
143
+ raw = PORT_FILE.read_text().strip()
144
+ try:
145
+ port = int(raw)
146
+ except ValueError as exc:
147
+ raise BackendError(f"Invalid port in {PORT_FILE}: {raw!r}") from exc
148
+ return f"http://127.0.0.1:{port}"
149
+
150
+ raise BackendError(
151
+ "Could not find a running OpenPlot server. Start one with "
152
+ "`openplot serve` or set OPENPLOT_SERVER_URL."
153
+ )
154
+
155
+
156
+ def create_mcp_server(server_url: str) -> FastMCP:
157
+ """Create an MCP server bound to a specific OpenPlot backend URL."""
158
+ client = BackendClient(
159
+ base_url=server_url,
160
+ session_id=_normalize_optional_env("OPENPLOT_SESSION_ID"),
161
+ )
162
+
163
+ mcp = FastMCP(
164
+ name="openplot",
165
+ instructions=(
166
+ "Use OpenPlot tools to read pending visual feedback, inspect plot context, "
167
+ "and submit updated plotting scripts. Treat "
168
+ "python_interpreter.available_packages from plot context as a strict "
169
+ "allowlist for third-party imports. Never import a third-party package "
170
+ "that is not listed there. "
171
+ "For raster-region annotations, treat "
172
+ "attached crop images as authoritative local scope and resolve ambiguous "
173
+ "phrases to crop-visible content unless the user explicitly asks for global edits."
174
+ ),
175
+ )
176
+
177
+ @mcp.tool(
178
+ name="get_pending_feedback",
179
+ description=(
180
+ "Return the compiled visual feedback prompt for all pending annotations "
181
+ "from the current OpenPlot session."
182
+ ),
183
+ )
184
+ def get_pending_feedback() -> dict:
185
+ return client.get("/api/feedback")
186
+
187
+ @mcp.tool(
188
+ name="get_pending_feedback_with_images",
189
+ description=(
190
+ "Return pending visual feedback with image crops (when available) for "
191
+ "raster region annotations."
192
+ ),
193
+ structured_output=False,
194
+ )
195
+ def get_pending_feedback_with_images(max_images: int = 8) -> Any:
196
+ session = client.get("/api/session")
197
+ feedback = client.get("/api/feedback")
198
+
199
+ pending = pending_annotation_dicts_for_context(session)
200
+ script_path = session.get("source_script_path") or "<unknown>"
201
+ plot_type = session.get("plot_type") or "unknown"
202
+ active_branch_id = session.get("active_branch_id") or "<none>"
203
+ checked_out_version_id = session.get("checked_out_version_id") or "<none>"
204
+ target_annotation_id = feedback.get("target_annotation_id") or "<none>"
205
+
206
+ content: list[Any] = [
207
+ "\n".join(
208
+ [
209
+ "## Pending Plot Feedback (Multimodal)",
210
+ f"- Pending annotations: {len(pending)}",
211
+ f"- Plot type: {plot_type}",
212
+ f"- Script path: {script_path}",
213
+ f"- Active branch: {active_branch_id}",
214
+ f"- Checked out version: {checked_out_version_id}",
215
+ f"- FIFO target annotation: {target_annotation_id}",
216
+ "",
217
+ "### Scope Rules (must follow)",
218
+ "- For raster-region annotations, treat attached crop images as authoritative grounding.",
219
+ "- Scope is LOCAL_REGION: ambiguous references apply only to content visible in the selected region/crop.",
220
+ '- Do not modify outside-region content unless feedback explicitly requests global scope ("all charts", "entire figure", "across all subplots").',
221
+ "- Prefer minimal localized edits.",
222
+ "",
223
+ "### Compiled Feedback",
224
+ feedback.get("prompt", "No compiled feedback available."),
225
+ ]
226
+ )
227
+ ]
228
+
229
+ images_added = 0
230
+ for index, ann in enumerate(pending, start=1):
231
+ ann_id = ann.get("id", "unknown")
232
+ ann_feedback = ann.get("feedback", "")
233
+ region = ann.get("region")
234
+ element = ann.get("element_info")
235
+
236
+ lines = [
237
+ f"### Annotation {index}",
238
+ f"- id: {ann_id}",
239
+ f"- feedback: {ann_feedback}",
240
+ ]
241
+
242
+ if element:
243
+ lines.append(f"- mode: svg-element ({element.get('tag', 'unknown')})")
244
+ lines.append("- scope: LOCAL_ELEMENT")
245
+ lines.append(
246
+ "- disambiguation: ambiguous references resolve to the selected element"
247
+ )
248
+ text = element.get("text_content")
249
+ if text:
250
+ lines.append(f"- text: {text}")
251
+ xpath = element.get("xpath")
252
+ if xpath:
253
+ lines.append(f"- xpath: {xpath}")
254
+
255
+ if region:
256
+ lines.append(f"- mode: raster-region ({region.get('type', 'unknown')})")
257
+ lines.append("- scope: LOCAL_REGION")
258
+ lines.append("- grounding: use the attached crop image")
259
+ lines.append(
260
+ "- disambiguation: ambiguous references resolve to crop-visible elements only"
261
+ )
262
+ points = region.get("points")
263
+ bounds = region_bounds_from_points(points)
264
+ if bounds is not None:
265
+ x0, y0, x1, y1 = bounds
266
+ lines.append(
267
+ f"- region(norm): ({x0:.3f}, {y0:.3f}) -> ({x1:.3f}, {y1:.3f})"
268
+ )
269
+ lines.append(f"- zone-hint: {region_zone_hint_from_bounds(bounds)}")
270
+
271
+ content.append("\n".join(lines))
272
+
273
+ crop_data = region.get("crop_base64") if isinstance(region, dict) else None
274
+ if crop_data and images_added < max_images:
275
+ try:
276
+ mime_type, image_bytes = _decode_data_url(crop_data)
277
+ image_format = _image_format_from_mime(mime_type)
278
+ content.append(
279
+ f"Annotation {index} crop image (mime={mime_type}, id={ann_id})"
280
+ )
281
+ content.append(Image(data=image_bytes, format=image_format))
282
+ images_added += 1
283
+ except ValueError as exc:
284
+ content.append(
285
+ f"Annotation {index} crop decode failed: {exc} (id={ann_id})"
286
+ )
287
+
288
+ if len(pending) > 0 and images_added == 0:
289
+ content.append(
290
+ "No decodable raster crop images were attached to pending annotations."
291
+ )
292
+ if len(pending) > max_images:
293
+ content.append(
294
+ f"Only the first {max_images} crop images were attached (limit reached)."
295
+ )
296
+
297
+ return content
298
+
299
+ @mcp.tool(
300
+ name="get_plot_context",
301
+ description=(
302
+ "Return current session context: script source, plot path/type, annotations, "
303
+ "and revision history."
304
+ ),
305
+ )
306
+ def get_plot_context() -> dict:
307
+ session = client.get("/api/session")
308
+ python_interpreter: dict[str, Any]
309
+ try:
310
+ python_interpreter = client.get("/api/python/interpreter")
311
+ except BackendError as exc:
312
+ python_interpreter = {"error": str(exc)}
313
+
314
+ return {
315
+ "session_id": session.get("id"),
316
+ "source_script": session.get("source_script"),
317
+ "source_script_path": session.get("source_script_path"),
318
+ "current_plot": session.get("current_plot"),
319
+ "plot_type": session.get("plot_type"),
320
+ "annotations": session.get("annotations", []),
321
+ "versions": session.get("versions", []),
322
+ "branches": session.get("branches", []),
323
+ "root_version_id": session.get("root_version_id"),
324
+ "active_branch_id": session.get("active_branch_id"),
325
+ "checked_out_version_id": session.get("checked_out_version_id"),
326
+ "artifacts_root": session.get("artifacts_root"),
327
+ "revision_history": session.get("revision_history", []),
328
+ "python_interpreter": python_interpreter,
329
+ }
330
+
331
+ @mcp.tool(
332
+ name="submit_updated_script",
333
+ description=(
334
+ "Submit an updated Python plotting script to OpenPlot. "
335
+ "OpenPlot executes it, updates the rendered plot, and marks one feedback annotation addressed. "
336
+ "Use annotation_id to target a specific pending annotation when needed."
337
+ ),
338
+ )
339
+ def submit_updated_script(code: str, annotation_id: str | None = None) -> dict:
340
+ if not code.strip():
341
+ raise ValueError("`code` must not be empty.")
342
+ payload: dict[str, Any] = {"code": code}
343
+ if annotation_id:
344
+ payload["annotation_id"] = annotation_id
345
+ return client.post("/api/script", payload)
346
+
347
+ return mcp
348
+
349
+
350
+ def run_mcp_stdio(server_url: str) -> None:
351
+ """Run the MCP server over stdio transport."""
352
+ mcp = create_mcp_server(server_url)
353
+ mcp.run(transport="stdio")