changex-api 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.
@@ -0,0 +1,29 @@
1
+ """ChangeX HTTP/REST API: the changex-core spine, re-exposed over plain HTTP.
2
+
3
+ This package wraps :mod:`changex_core` (reusing the MCP tool semantics from
4
+ :mod:`changex_mcp.tools`) in a FastAPI app so ANY caller can drive tracked
5
+ editing over HTTP — a local/offline model with no function-calling, a ``curl``
6
+ script, or a ChatGPT custom GPT whose Action imports the auto-generated
7
+ ``/openapi.json``.
8
+
9
+ Public surface (used by the entry point and tests):
10
+
11
+ App
12
+ ``app.app`` — the configured :class:`fastapi.FastAPI` instance;
13
+ ``app.create_app()`` — a factory that builds a fresh app with its own
14
+ in-process session store.
15
+
16
+ Runner
17
+ ``__main__.main`` — the ``changex-api`` console script / ``python -m
18
+ changex_api`` entry point (launches uvicorn, 127.0.0.1 by default).
19
+
20
+ Models
21
+ ``models`` — the Pydantic request/response models that shape the OpenAPI
22
+ schema consumed by ChatGPT Actions and other clients.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = ["__version__"]
@@ -0,0 +1,77 @@
1
+ """Run the ChangeX API with uvicorn: ``changex-api`` / ``python -m changex_api``.
2
+
3
+ Binds ``127.0.0.1`` by default (the local, trustworthy path that needs no token).
4
+ A non-local bind (any host other than ``127.0.0.1`` / ``localhost`` / ``::1``) is
5
+ *refused* unless ``CHANGEX_API_TOKEN`` is set, so the surface is never exposed to
6
+ a network without bearer-token auth — a fail-closed default rather than a footgun.
7
+
8
+ Flags / env:
9
+
10
+ * ``--host`` / ``CHANGEX_API_HOST`` (default ``127.0.0.1``)
11
+ * ``--port`` / ``CHANGEX_API_PORT`` (default ``8000``)
12
+ * ``--reload`` for development auto-reload
13
+ * ``CHANGEX_API_TOKEN`` — when set, every non-health route requires
14
+ ``Authorization: Bearer <token>``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+
23
+ import uvicorn
24
+
25
+ DEFAULT_HOST = "127.0.0.1"
26
+ DEFAULT_PORT = 8000
27
+ _LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
28
+ _TOKEN_ENV = "CHANGEX_API_TOKEN"
29
+
30
+
31
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
32
+ parser = argparse.ArgumentParser(
33
+ prog="changex-api",
34
+ description="Serve the ChangeX HTTP/REST API (FastAPI + uvicorn).",
35
+ )
36
+ parser.add_argument(
37
+ "--host",
38
+ default=os.environ.get("CHANGEX_API_HOST", DEFAULT_HOST),
39
+ help="Bind host (default 127.0.0.1). Non-local hosts require CHANGEX_API_TOKEN.",
40
+ )
41
+ parser.add_argument(
42
+ "--port",
43
+ type=int,
44
+ default=int(os.environ.get("CHANGEX_API_PORT", DEFAULT_PORT)),
45
+ help="Bind port (default 8000).",
46
+ )
47
+ parser.add_argument(
48
+ "--reload", action="store_true", help="Enable uvicorn auto-reload (development)."
49
+ )
50
+ return parser.parse_args(argv)
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ """Console-script / ``python -m changex_api`` entry point.
55
+
56
+ Returns a process exit code (``0`` on a clean shutdown, ``2`` if a non-local
57
+ bind was requested without a token).
58
+ """
59
+ args = _parse_args(argv)
60
+ host = str(args.host)
61
+ if host not in _LOCAL_HOSTS and not os.environ.get(_TOKEN_ENV):
62
+ sys.stderr.write(
63
+ f"refusing to bind non-local host {host!r} without {_TOKEN_ENV}; "
64
+ f"set {_TOKEN_ENV}=<secret> to enable bearer-token auth, or bind 127.0.0.1.\n"
65
+ )
66
+ return 2
67
+ uvicorn.run(
68
+ "changex_api.app:app",
69
+ host=host,
70
+ port=int(args.port),
71
+ reload=bool(args.reload),
72
+ )
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__": # pragma: no cover
77
+ raise SystemExit(main())
changex_api/app.py ADDED
@@ -0,0 +1,405 @@
1
+ """The ChangeX HTTP/REST API — a thin FastAPI wrapper over ``changex_core``.
2
+
3
+ Why this exists: ChangeX already exposes its tracked-editing spine over MCP
4
+ (:mod:`changex_mcp`). This package re-exposes the *same* semantics over plain
5
+ HTTP so ANY caller can use it — a local/offline model with no function-calling, a
6
+ shell script with ``curl``, or a ChatGPT custom GPT whose Action consumes the
7
+ auto-generated ``/openapi.json``. The endpoint set mirrors the MCP tools 1:1.
8
+
9
+ Design notes:
10
+
11
+ * **Reuse, not reimplementation.** The tracked-editing tools delegate to
12
+ :mod:`changex_mcp.tools` against an in-process :class:`SessionStore`, so the
13
+ boundary enforcement (before-substring validation, oversized-op rejection) and
14
+ the structured error codes are byte-for-byte the same as MCP. The passive
15
+ ``/open`` + ``/seal`` and ``/report`` endpoints call the core directly.
16
+ * **Path-sanitized.** Every caller-supplied path goes through
17
+ :func:`changex_core.paths.safe_path` (the same guard the core uses) before any
18
+ I/O, rejecting directory traversal / NUL bytes / wrong suffixes.
19
+ * **Local by default.** ``create_app`` binds nothing itself; the runner
20
+ (:mod:`changex_api.__main__`) defaults to ``127.0.0.1``. When a token is set via
21
+ ``CHANGEX_API_TOKEN`` (required for any non-local bind), every route except the
22
+ liveness probe demands a matching ``Authorization: Bearer <token>`` header.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from typing import Any
29
+
30
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
31
+ from fastapi.responses import HTMLResponse
32
+
33
+ from changex_core import __version__ as _CORE_VERSION
34
+ from changex_core.journal.journal import Journal
35
+ from changex_core.paths import UnsafePathError, safe_path
36
+ from changex_core.passive import open_passive, seal_passive
37
+ from changex_core.render.html import render_html, render_markdown
38
+
39
+ from changex_mcp import tools
40
+ from changex_mcp.session import SessionError, SessionStore
41
+
42
+ from changex_api import __version__ as _API_VERSION
43
+ from changex_api.models import (
44
+ ChangesResponse,
45
+ EditRequest,
46
+ EditResponse,
47
+ HealthResponse,
48
+ OpenTrackedRequest,
49
+ OpenTrackedResponse,
50
+ OutlineResponse,
51
+ PassiveOpenRequest,
52
+ PassiveOpenResponse,
53
+ PassiveSealRequest,
54
+ PassiveSealResponse,
55
+ ReportResponse,
56
+ SaveRequest,
57
+ SaveResponse,
58
+ )
59
+
60
+ #: Environment variable holding the bearer token. When set, all non-health routes
61
+ #: require ``Authorization: Bearer <CHANGEX_API_TOKEN>``. Leave unset only for a
62
+ #: trusted local (127.0.0.1) bind.
63
+ TOKEN_ENV = "CHANGEX_API_TOKEN"
64
+
65
+ # Starlette renamed 422 to *_CONTENT; prefer the new name, fall back for older
66
+ # pins so the package works across the supported FastAPI/Starlette range. (The
67
+ # deprecated alias is only touched when the new constant is absent, so newer
68
+ # Starlette never triggers its DeprecationWarning.)
69
+ if hasattr(status, "HTTP_422_UNPROCESSABLE_CONTENT"):
70
+ HTTP_422 = status.HTTP_422_UNPROCESSABLE_CONTENT
71
+ else: # pragma: no cover - depends on the installed Starlette version
72
+ HTTP_422 = status.HTTP_422_UNPROCESSABLE_ENTITY
73
+
74
+ API_TITLE = "ChangeX API"
75
+ API_DESCRIPTION = (
76
+ "Provenance-first tracked editing for Word documents, over HTTP. Open a .docx "
77
+ "for tracked editing, discover node ids via the outline, make the SMALLEST "
78
+ "possible intent-named edit per call (always passing the exact existing text in "
79
+ "`before` — blind overwrites are refused), then save a native Word "
80
+ "accept/reject revisions file plus a hash-chained .changex provenance journal. "
81
+ "A passive (no-tool-calling) /open + /seal path serves offline models. This "
82
+ "schema is consumable directly as a ChatGPT custom GPT Action."
83
+ )
84
+
85
+
86
+ def _require_token(authorization: str | None = Header(default=None)) -> None:
87
+ """Enforce bearer-token auth iff ``CHANGEX_API_TOKEN`` is set.
88
+
89
+ When the env var is unset (the local-only default) this is a no-op so the
90
+ 127.0.0.1 developer flow needs no header. When it is set, every guarded route
91
+ requires ``Authorization: Bearer <token>`` and rejects anything else with 401.
92
+ """
93
+ expected = os.environ.get(TOKEN_ENV)
94
+ if not expected:
95
+ return
96
+ if not authorization or not authorization.startswith("Bearer "):
97
+ raise HTTPException(
98
+ status_code=status.HTTP_401_UNAUTHORIZED,
99
+ detail="missing bearer token",
100
+ headers={"WWW-Authenticate": "Bearer"},
101
+ )
102
+ presented = authorization[len("Bearer ") :].strip()
103
+ if presented != expected:
104
+ raise HTTPException(
105
+ status_code=status.HTTP_401_UNAUTHORIZED,
106
+ detail="invalid bearer token",
107
+ headers={"WWW-Authenticate": "Bearer"},
108
+ )
109
+
110
+
111
+ def _tool_error_http(exc: tools.ToolError) -> HTTPException:
112
+ """Map an MCP ToolError to an HTTP 4xx carrying its structured payload.
113
+
114
+ ``already_open`` is a conflict (409); ``open_failed`` is a bad request (400);
115
+ everything else (split_required, before_mismatch, node_not_found, …) is a 422
116
+ unprocessable entity — the model should read the detail and adjust the op.
117
+ """
118
+ if exc.code == "already_open":
119
+ code = status.HTTP_409_CONFLICT
120
+ elif exc.code in ("open_failed", "bad_format"):
121
+ code = status.HTTP_400_BAD_REQUEST
122
+ else:
123
+ code = HTTP_422
124
+ return HTTPException(status_code=code, detail=exc.to_dict())
125
+
126
+
127
+ def _value_error_http(exc: Exception) -> HTTPException:
128
+ """Map a validation / unsafe-path error to a 400 with a structured payload."""
129
+ code = "unsafe_path" if isinstance(exc, UnsafePathError) else "invalid_argument"
130
+ return HTTPException(
131
+ status_code=status.HTTP_400_BAD_REQUEST,
132
+ detail={"error": code, "detail": str(exc)},
133
+ )
134
+
135
+
136
+ def create_app() -> FastAPI:
137
+ """Build and return the configured FastAPI application.
138
+
139
+ A single in-process :class:`SessionStore` backs the tracked-editing routes
140
+ (one process, single-session-per-handle — same model as the MCP server). The
141
+ returned app auto-serves its OpenAPI schema at ``/openapi.json``; that schema
142
+ is exactly what a ChatGPT custom GPT Action imports.
143
+ """
144
+ store = SessionStore()
145
+ app = FastAPI(
146
+ title=API_TITLE,
147
+ description=API_DESCRIPTION,
148
+ version=_API_VERSION,
149
+ )
150
+
151
+ guard = [Depends(_require_token)]
152
+
153
+ # -- liveness (unauthenticated) -------------------------------------------
154
+
155
+ @app.get("/healthz", response_model=HealthResponse, tags=["meta"], operation_id="healthz")
156
+ def healthz() -> HealthResponse:
157
+ """Liveness probe; never requires auth."""
158
+ return HealthResponse(version=_API_VERSION)
159
+
160
+ # -- tracked editing (mirrors the MCP tools) ------------------------------
161
+
162
+ @app.post(
163
+ "/sessions",
164
+ response_model=OpenTrackedResponse,
165
+ tags=["tracked"],
166
+ dependencies=guard,
167
+ operation_id="openTracked",
168
+ )
169
+ def open_session(body: OpenTrackedRequest) -> Any:
170
+ """Open a .docx for tracked editing and return a session handle + summary."""
171
+ agent_ctx = body.agent_context.model_dump() if body.agent_context else None
172
+ try:
173
+ return tools.open_tracked(
174
+ store, path=body.path, agent_context=agent_ctx, author=body.author
175
+ )
176
+ except tools.ToolError as exc:
177
+ raise _tool_error_http(exc) from exc
178
+
179
+ @app.get(
180
+ "/sessions/{handle}/outline",
181
+ response_model=OutlineResponse,
182
+ tags=["tracked"],
183
+ dependencies=guard,
184
+ operation_id="getOutline",
185
+ )
186
+ def get_outline(
187
+ handle: str,
188
+ cursor: str | None = Query(default=None, description="Opaque pagination cursor."),
189
+ limit: int = Query(default=100, ge=1, le=500, description="Max entries per page."),
190
+ ) -> Any:
191
+ """Return a bounded, paginated outline of the document's paragraphs."""
192
+ try:
193
+ return tools.get_outline(store, handle=handle, cursor=cursor, limit=limit)
194
+ except tools.ToolError as exc:
195
+ raise _tool_error_http(exc) from exc
196
+ except SessionError as exc:
197
+ raise _unknown_handle_http(exc) from exc
198
+ except (ValueError, KeyError) as exc:
199
+ raise _value_error_http(exc) from exc
200
+
201
+ @app.post(
202
+ "/sessions/{handle}/edit",
203
+ response_model=EditResponse,
204
+ tags=["tracked"],
205
+ dependencies=guard,
206
+ operation_id="editSession",
207
+ )
208
+ def edit(handle: str, body: EditRequest) -> Any:
209
+ """Apply ONE small, intent-named tracked edit to a node and journal it."""
210
+ try:
211
+ return tools.edit(
212
+ store,
213
+ handle=handle,
214
+ op=body.op,
215
+ node_id=body.node_id,
216
+ before=body.before,
217
+ after=body.after,
218
+ anchor=body.anchor,
219
+ text=body.text,
220
+ style=body.style,
221
+ rationale=body.rationale,
222
+ prompt=body.prompt,
223
+ turn_id=body.turn_id,
224
+ )
225
+ except tools.ToolError as exc:
226
+ raise _tool_error_http(exc) from exc
227
+ except SessionError as exc:
228
+ raise _unknown_handle_http(exc) from exc
229
+ except (ValueError, KeyError) as exc:
230
+ raise _value_error_http(exc) from exc
231
+
232
+ @app.post(
233
+ "/sessions/{handle}/save",
234
+ response_model=SaveResponse,
235
+ tags=["tracked"],
236
+ dependencies=guard,
237
+ operation_id="saveSession",
238
+ )
239
+ def save(handle: str, body: SaveRequest) -> Any:
240
+ """Save the native-revisions .docx and report the tracked + .changex paths."""
241
+ try:
242
+ return tools.save_tracked(store, handle=handle, out=body.out)
243
+ except tools.ToolError as exc:
244
+ raise _tool_error_http(exc) from exc
245
+ except SessionError as exc:
246
+ raise _unknown_handle_http(exc) from exc
247
+ except (ValueError, KeyError) as exc:
248
+ raise _value_error_http(exc) from exc
249
+
250
+ @app.get(
251
+ "/sessions/{handle}/changes",
252
+ response_model=ChangesResponse,
253
+ tags=["tracked"],
254
+ dependencies=guard,
255
+ operation_id="getChanges",
256
+ )
257
+ def get_changes(handle: str) -> Any:
258
+ """Return the structured provenance journal (active, non-reverted events)."""
259
+ try:
260
+ return tools.get_changes(store, handle=handle)
261
+ except tools.ToolError as exc:
262
+ raise _tool_error_http(exc) from exc
263
+ except SessionError as exc:
264
+ raise _unknown_handle_http(exc) from exc
265
+ except (ValueError, KeyError) as exc:
266
+ raise _value_error_http(exc) from exc
267
+
268
+ # -- passive ("native to any model") open / seal --------------------------
269
+
270
+ @app.post(
271
+ "/open",
272
+ response_model=PassiveOpenResponse,
273
+ tags=["passive"],
274
+ dependencies=guard,
275
+ operation_id="passiveOpen",
276
+ )
277
+ def passive_open(body: PassiveOpenRequest) -> PassiveOpenResponse:
278
+ """Snapshot a docx and write a pending passive journal (no tool calls).
279
+
280
+ Any tool may then edit the docx freely; call /seal to capture the delta.
281
+ """
282
+ try:
283
+ result = open_passive(body.docx, body.changex)
284
+ except (UnsafePathError, ValueError, OSError) as exc:
285
+ raise _value_error_http(exc) from exc
286
+ return PassiveOpenResponse(
287
+ changex_path=str(result.changex_path),
288
+ session_id=result.session_id,
289
+ baseline_sha256=result.baseline.sha256,
290
+ paragraphs=result.paragraphs,
291
+ )
292
+
293
+ @app.post(
294
+ "/seal",
295
+ response_model=PassiveSealResponse,
296
+ tags=["passive"],
297
+ dependencies=guard,
298
+ operation_id="passiveSeal",
299
+ )
300
+ def passive_seal(body: PassiveSealRequest) -> PassiveSealResponse:
301
+ """Diff the edited docx vs the stored baseline and append passive ops.
302
+
303
+ Returns honest, *degraded* counts: passive ops are observed net textual
304
+ deltas, not true provenance (agent/vendor/prompt are null).
305
+ """
306
+ try:
307
+ result = seal_passive(body.docx, body.changex)
308
+ except (UnsafePathError, ValueError, OSError) as exc:
309
+ raise _value_error_http(exc) from exc
310
+ return PassiveSealResponse(
311
+ changex_path=str(result.changex_path),
312
+ appended=result.appended,
313
+ replaced=result.replaced,
314
+ inserted=result.inserted,
315
+ deleted=result.deleted,
316
+ style_changed=result.style_changed,
317
+ baseline_unchanged=result.baseline_unchanged,
318
+ degraded=result.degraded,
319
+ )
320
+
321
+ # -- report (HTML/markdown redline) ---------------------------------------
322
+
323
+ @app.post(
324
+ "/report",
325
+ tags=["review"],
326
+ dependencies=guard,
327
+ operation_id="renderReport",
328
+ responses={200: {"content": {"text/html": {}, "application/json": {}}}},
329
+ )
330
+ def report(
331
+ handle: str | None = Query(
332
+ default=None,
333
+ description="An open tracked-session handle to render from.",
334
+ ),
335
+ changex: str | None = Query(
336
+ default=None,
337
+ description="OR a path to a .changex journal to render from (passive flow).",
338
+ ),
339
+ fmt: str = Query(default="html", description="'html' (default) or 'markdown'."),
340
+ raw: bool = Query(
341
+ default=False,
342
+ description="If true, return the report as text/html instead of JSON.",
343
+ ),
344
+ ) -> Any:
345
+ """Render an HTML/markdown redline of a session's or journal's changes.
346
+
347
+ Supply either an open ``handle`` (tracked flow) or a ``changex`` journal
348
+ path (passive flow). With ``raw=true`` and ``fmt=html`` the response is a
349
+ ready-to-display ``text/html`` page; otherwise a JSON ``{format, report}``.
350
+ """
351
+ fmt_norm = (fmt or "html").lower()
352
+ if fmt_norm not in ("html", "markdown"):
353
+ raise HTTPException(
354
+ status_code=status.HTTP_400_BAD_REQUEST,
355
+ detail={"error": "bad_format", "detail": "fmt must be 'html' or 'markdown'."},
356
+ )
357
+ if handle:
358
+ try:
359
+ payload = tools.render_review(store, handle=handle, fmt=fmt_norm)
360
+ except tools.ToolError as exc:
361
+ raise _tool_error_http(exc) from exc
362
+ except SessionError as exc:
363
+ raise _unknown_handle_http(exc) from exc
364
+ except (ValueError, KeyError) as exc:
365
+ raise _value_error_http(exc) from exc
366
+ rendered = str(payload["report"])
367
+ elif changex:
368
+ rendered = _render_from_journal(changex, fmt_norm)
369
+ else:
370
+ raise HTTPException(
371
+ status_code=status.HTTP_400_BAD_REQUEST,
372
+ detail={"error": "missing_argument", "detail": "pass either handle or changex."},
373
+ )
374
+ if raw and fmt_norm == "html":
375
+ return HTMLResponse(content=rendered)
376
+ return ReportResponse(format=fmt_norm, report=rendered)
377
+
378
+ return app
379
+
380
+
381
+ def _render_from_journal(changex: str, fmt: str) -> str:
382
+ """Render a redline directly from a ``.changex`` journal path (passive flow)."""
383
+ try:
384
+ path = safe_path(changex, must_exist=True, allow_suffixes=(".changex", ".jsonl"))
385
+ journal = Journal.open(str(path))
386
+ except (UnsafePathError, ValueError, OSError) as exc:
387
+ raise _value_error_http(exc) from exc
388
+ events = journal.active_events()
389
+ return render_markdown(events) if fmt == "markdown" else render_html(events)
390
+
391
+
392
+ def _unknown_handle_http(exc: SessionError) -> HTTPException:
393
+ """Map an unknown-handle error to a 404 so 'bad handle' != 'bad input'.
394
+
395
+ ``SessionStore.get`` raises :class:`SessionError` for an unknown handle; we
396
+ surface that as 404 with a structured payload distinct from a 400 input error.
397
+ """
398
+ return HTTPException(
399
+ status_code=status.HTTP_404_NOT_FOUND,
400
+ detail={"error": "unknown_handle", "detail": str(exc)},
401
+ )
402
+
403
+
404
+ #: Module-level app for ``uvicorn changex_api.app:app`` and the TestClient.
405
+ app = create_app()
changex_api/models.py ADDED
@@ -0,0 +1,235 @@
1
+ """Pydantic request/response models for the ChangeX REST API.
2
+
3
+ These are deliberately explicit (typed fields + descriptions + examples) because
4
+ they ARE the OpenAPI schema that a ChatGPT custom GPT Action consumes at
5
+ ``/openapi.json``. Good field descriptions here become good tool affordances for
6
+ any model calling the API. The semantics mirror the MCP tools 1:1
7
+ (:mod:`changex_mcp.tools`): an ``op`` discriminator selects one small intent and
8
+ its required payload, the exact ``before`` substring is always carried so the
9
+ adapter can refuse blind overwrites, and an oversized op is rejected so the model
10
+ splits the change.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Literal, Optional
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ # The intent discriminators accepted by POST /sessions/{id}/edit, mirroring
20
+ # ``changex_mcp.tools.EDIT_OPS``.
21
+ EditOp = Literal["replace_text", "insert_text_after", "delete_text", "set_paragraph_style"]
22
+
23
+
24
+ class AgentContext(BaseModel):
25
+ """Once-per-session declared identity (captured at open, never per-edit)."""
26
+
27
+ model: Optional[str] = Field(
28
+ default=None,
29
+ description="Your model id, e.g. 'claude-opus-4-8' or 'gpt-4o'. Recorded "
30
+ "once for the whole session and used as the Word revision author.",
31
+ )
32
+ vendor: Optional[str] = Field(
33
+ default=None, description="Your vendor, e.g. 'anthropic', 'openai', 'google'."
34
+ )
35
+
36
+
37
+ # -- open / sessions ----------------------------------------------------------
38
+
39
+
40
+ class OpenTrackedRequest(BaseModel):
41
+ """Body for POST /sessions — open a .docx for tracked editing."""
42
+
43
+ path: str = Field(
44
+ ...,
45
+ description="Absolute server-side path to the .docx to open for tracked editing.",
46
+ examples=["/data/contract.docx"],
47
+ )
48
+ agent_context: Optional[AgentContext] = Field(
49
+ default=None,
50
+ description="Optional once-per-session declared identity {model, vendor}.",
51
+ )
52
+ author: Optional[str] = Field(
53
+ default=None,
54
+ description="Override the Word revision author (defaults to the model id).",
55
+ )
56
+
57
+
58
+ class OpenTrackedResponse(BaseModel):
59
+ """Session handle + summary returned by POST /sessions."""
60
+
61
+ handle: str = Field(..., description="Opaque session handle; pass it to every other call.")
62
+ session_id: str = Field(..., description="Stable journal session id (provenance identity).")
63
+ baseline_sha256: str = Field(..., description="SHA-256 of the opened baseline bytes.")
64
+ summary: dict[str, Any] = Field(
65
+ ..., description="{filename, paragraphs, agent, vendor, changex_path}."
66
+ )
67
+
68
+
69
+ # -- outline ------------------------------------------------------------------
70
+
71
+
72
+ class OutlineEntry(BaseModel):
73
+ """One paragraph node in a paginated outline page."""
74
+
75
+ node_id: str = Field(..., description="Durable node id; use it as an edit target.")
76
+ kind: str = Field(..., description="Node kind, e.g. 'paragraph'.")
77
+ preview: str = Field(..., description="Truncated text preview for discovery.")
78
+ style: Optional[str] = Field(default=None, description="Paragraph style name, if any.")
79
+
80
+
81
+ class OutlineResponse(BaseModel):
82
+ """A bounded outline page plus the cursor to fetch the next one."""
83
+
84
+ nodes: list[OutlineEntry]
85
+ next_cursor: Optional[str] = Field(
86
+ default=None, description="Pass back as ?cursor= to page forward; null when done."
87
+ )
88
+ total: int = Field(..., description="Total paragraph count in the document.")
89
+
90
+
91
+ # -- edit ---------------------------------------------------------------------
92
+
93
+
94
+ class EditRequest(BaseModel):
95
+ """Body for POST /sessions/{id}/edit — apply ONE small intent-named edit.
96
+
97
+ ``op`` selects the intent and its required payload:
98
+
99
+ * ``replace_text`` — node_id, before (exact current text), after
100
+ * ``insert_text_after`` — node_id, anchor (exact text to insert after), text
101
+ * ``delete_text`` — node_id, before (exact text to delete)
102
+ * ``set_paragraph_style`` — node_id, style (new), before (current style name)
103
+ """
104
+
105
+ op: EditOp = Field(..., description="The edit intent.")
106
+ node_id: str = Field(..., description="Target node id (from the outline).")
107
+ before: Optional[str] = Field(
108
+ default=None,
109
+ description="Exact CURRENT text/style to match; the server refuses blind overwrites.",
110
+ )
111
+ after: Optional[str] = Field(default=None, description="Replacement text for replace_text.")
112
+ anchor: Optional[str] = Field(
113
+ default=None, description="Exact text to insert after (insert_text_after)."
114
+ )
115
+ text: Optional[str] = Field(default=None, description="Text to insert (insert_text_after).")
116
+ style: Optional[str] = Field(
117
+ default=None, description="New paragraph style (set_paragraph_style)."
118
+ )
119
+ rationale: Optional[str] = Field(default=None, description="Optional declared 'why'.")
120
+ prompt: Optional[str] = Field(
121
+ default=None, description="Optional prompt; hashed to prompt_sha256, never stored verbatim."
122
+ )
123
+ turn_id: Optional[str] = Field(default=None, description="Optional declared turn id.")
124
+
125
+
126
+ class EditResponse(BaseModel):
127
+ """Result of one journaled edit."""
128
+
129
+ op_id: str = Field(..., description="Stable id of the journaled op.")
130
+ seq: int = Field(..., description="Server-assigned monotonic sequence number.")
131
+ node_id: str = Field(..., description="Resolved target node id.")
132
+ provenance_source: str = Field(..., description="'declared' or 'observed'.")
133
+
134
+
135
+ # -- save / changes -----------------------------------------------------------
136
+
137
+
138
+ class SaveRequest(BaseModel):
139
+ """Body for POST /sessions/{id}/save — render the tracked .docx."""
140
+
141
+ out: str = Field(
142
+ ...,
143
+ description="Absolute server-side .docx path to write the native-revisions file to.",
144
+ examples=["/data/contract.tracked.docx"],
145
+ )
146
+
147
+
148
+ class SaveResponse(BaseModel):
149
+ """Paths + verification result after a save."""
150
+
151
+ tracked_path: str
152
+ changex_path: str
153
+ ops: int = Field(..., description="Number of active (non-reverted) ops written.")
154
+ verified: bool = Field(..., description="Whether the journal hash-chain verified.")
155
+
156
+
157
+ class ChangesResponse(BaseModel):
158
+ """The structured provenance journal (active, non-reverted events)."""
159
+
160
+ session_id: str
161
+ events: list[dict[str, Any]]
162
+ count: int
163
+ verified: bool
164
+
165
+
166
+ # -- passive open / seal ------------------------------------------------------
167
+
168
+
169
+ class PassiveOpenRequest(BaseModel):
170
+ """Body for POST /open — start a passive (no-tool-calling) capture session."""
171
+
172
+ docx: str = Field(..., description="Absolute path to the .docx to snapshot.")
173
+ changex: Optional[str] = Field(
174
+ default=None, description="Optional sidecar journal path (.changex/.jsonl)."
175
+ )
176
+
177
+
178
+ class PassiveOpenResponse(BaseModel):
179
+ """Outcome of POST /open."""
180
+
181
+ changex_path: str
182
+ session_id: str
183
+ baseline_sha256: str
184
+ paragraphs: int
185
+
186
+
187
+ class PassiveSealRequest(BaseModel):
188
+ """Body for POST /seal — diff the edited docx vs the stored baseline."""
189
+
190
+ docx: str = Field(..., description="Absolute path to the (now edited) .docx.")
191
+ changex: Optional[str] = Field(default=None, description="Optional sidecar journal path.")
192
+
193
+
194
+ class PassiveSealResponse(BaseModel):
195
+ """Honest, degraded capture counts from a passive seal."""
196
+
197
+ changex_path: str
198
+ appended: int
199
+ replaced: int
200
+ inserted: int
201
+ deleted: int
202
+ style_changed: int
203
+ baseline_unchanged: bool
204
+ degraded: bool = Field(
205
+ default=True,
206
+ description="Always true: passive ops are observed net deltas, not true provenance.",
207
+ )
208
+
209
+
210
+ # -- report -------------------------------------------------------------------
211
+
212
+
213
+ class ReportResponse(BaseModel):
214
+ """A rendered HTML/markdown redline of a session's journal."""
215
+
216
+ format: str = Field(..., description="'html' or 'markdown'.")
217
+ report: str = Field(..., description="The rendered redline.")
218
+
219
+
220
+ # -- shared -------------------------------------------------------------------
221
+
222
+
223
+ class HealthResponse(BaseModel):
224
+ """Liveness probe payload."""
225
+
226
+ status: Literal["ok"] = "ok"
227
+ service: str = "changex-api"
228
+ version: str
229
+
230
+
231
+ class ErrorResponse(BaseModel):
232
+ """Structured boundary error mirroring the MCP ToolError shape."""
233
+
234
+ error: str = Field(..., description="Stable machine-readable code, e.g. 'split_required'.")
235
+ detail: str = Field(..., description="Human/agent-facing instruction.")
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: changex-api
3
+ Version: 0.1.0
4
+ Summary: ChangeX HTTP/REST API: a thin FastAPI wrapper over the changex-core spine so any app or model — local/offline LLMs, or a ChatGPT custom GPT Action consuming /openapi.json — can open, edit, save, and review tracked Word documents over HTTP.
5
+ Author: ChangeX
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: actions,ai,docx,fastapi,llm,openapi,provenance,rest,track-changes
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
13
+ Classifier: Topic :: Office/Business :: Office Suites
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: changex-core>=0.1.0
16
+ Requires-Dist: fastapi>=0.110
17
+ Requires-Dist: uvicorn>=0.27
18
+ Provides-Extra: dev
19
+ Requires-Dist: httpx>=0.27; extra == 'dev'
20
+ Requires-Dist: mypy>=1.5; extra == 'dev'
21
+ Requires-Dist: pytest>=7.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.1; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # changex-api
26
+
27
+ A thin HTTP/REST API over the [ChangeX](https://github.com/ArioMoniri/changex)
28
+ core spine, so **any** app or model can drive provenance-first tracked editing of
29
+ Word documents over plain HTTP — a local/offline LLM with no function-calling, a
30
+ `curl` script, or a **ChatGPT custom GPT Action** that imports the
31
+ auto-generated OpenAPI schema.
32
+
33
+ It wraps `changex-core` and reuses the exact MCP tool semantics
34
+ (`changex-mcp`): an `op` discriminator selects one small intent, the exact
35
+ `before` substring is always carried so blind overwrites are refused, and an
36
+ oversized op is rejected so the model splits the change.
37
+
38
+ ## Run
39
+
40
+ ```bash
41
+ # install (workspace) and launch on 127.0.0.1:8000
42
+ uv sync
43
+ changex-api # or: python -m changex_api
44
+ # custom bind / port:
45
+ changex-api --host 0.0.0.0 --port 9000 # non-local host REQUIRES a token:
46
+ CHANGEX_API_TOKEN=secret changex-api --host 0.0.0.0
47
+ ```
48
+
49
+ Bind is `127.0.0.1` by default. A non-local host is **refused** unless
50
+ `CHANGEX_API_TOKEN` is set; when it is, every non-`/healthz` route requires
51
+ `Authorization: Bearer <token>`.
52
+
53
+ ## Endpoints
54
+
55
+ | Method & path | Purpose |
56
+ |---|---|
57
+ | `POST /sessions` | Open a `.docx` for tracked editing (returns a `handle`). |
58
+ | `GET /sessions/{handle}/outline` | Bounded, paginated paragraph outline (discover `node_id`s). |
59
+ | `POST /sessions/{handle}/edit` | One small, intent-named tracked edit. |
60
+ | `POST /sessions/{handle}/save` | Write the native-revisions `.docx` + `.changex` journal. |
61
+ | `GET /sessions/{handle}/changes` | The structured provenance journal. |
62
+ | `POST /open` | Passive (no-tool-calling) capture: snapshot a docx. |
63
+ | `POST /seal` | Diff the edited docx vs the baseline; append passive ops. |
64
+ | `POST /report` | Render an HTML/markdown redline (by `handle` or `.changex` path). |
65
+ | `GET /healthz` | Liveness probe (never requires auth). |
66
+
67
+ ## OpenAPI / function-calling schemas
68
+
69
+ FastAPI serves the schema at **`/openapi.json`** — that file IS the ChatGPT
70
+ custom GPT Action schema; point a GPT's Action import at it. Static copies plus
71
+ OpenAI/Gemini function-calling schemas live in the repo's `integrations/`:
72
+
73
+ - `integrations/openapi.json` — the dumped OpenAPI 3.1 schema (ChatGPT Actions).
74
+ - `integrations/openai-functions.json` — OpenAI `tools` format.
75
+ - `integrations/gemini-functions.json` — Gemini `functionDeclarations` format.
@@ -0,0 +1,9 @@
1
+ changex_api/__init__.py,sha256=2nNxP7Q1mZISpiNnCRvw_Wz9riodAwx9V8xcdfvfLvk,1026
2
+ changex_api/__main__.py,sha256=Vnm08OzIjxoAsQjzGcKJ4K8CurqIFr-2X1ktovNd3A8,2489
3
+ changex_api/app.py,sha256=ho0MUDaZa6p0u99c00Em905r3gefgr1G7ALWvmP9Dzk,16117
4
+ changex_api/models.py,sha256=HwpUEIIiOm-iKx0q7BtNg1B0xTig2sarDnI7-WmxoIU,8474
5
+ changex_api-0.1.0.dist-info/METADATA,sha256=YRS9U7UlqUVtAsNPidSPXRJ0KSPqSioKuOyLR0VW9XM,3425
6
+ changex_api-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ changex_api-0.1.0.dist-info/entry_points.txt,sha256=LdyIM_hFucRbamN3wGvF_U7WBzUD3mMAbfrJm22dCa8,58
8
+ changex_api-0.1.0.dist-info/licenses/LICENSE,sha256=5qBe8VP3umqM2WMYBYJ4B9tznqeKkjmIhXZ62pJ0038,1068
9
+ changex_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ changex-api = changex_api.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ario Moniri
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.