From a9f35121dab2ffdd86db9cd6850d68ada88ab153 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Thu, 21 May 2026 21:02:15 +0200 Subject: [PATCH] Generate the frontend API client from qh's OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend/src/api/schema.ts was hand-written and verified against the live backend, because qh's /openapi.json emitted empty request/response schemas. With i2mint/qh#9 qh now derives full JSON Schema from the EfService Python type hints, so the frontend's API types can be generated from the spec. - backend/export_openapi.py — exports the backend's OpenAPI document to frontend/src/api/openapi.json (the committed API-contract snapshot). - frontend: openapi-typescript devDependency + `gen:api` script generating src/api/openapi.d.ts from openapi.json. - src/api/schema.ts — was 113 lines of hand-written interfaces; now thin aliases (CorpusInfo, Segment, SearchHit, ExploreResult, CreateCorpusBody, QueryBody, ExploreBody) over the generated types. All 8 consumers untouched. - src/surfaces/SearchSurface.tsx — guard SearchHit.source_id, which the generated types correctly show as nullable (ef's source_id: str | None); the hand-written schema had wrongly declared it always-present. - .gitignore — track frontend/src/api/openapi.json (the API contract). Verified end-to-end: tsc + vite build pass; backend serves the enriched /openapi.json; create -> search -> explore -> list -> delete all work, and a browser create+search round-trip through the UI renders with zero errors. Closes #7 --- .gitignore | 3 + backend/export_openapi.py | 49 ++ frontend/package.json | 14 +- frontend/pnpm-lock.yaml | 198 ++++++- frontend/src/api/openapi.d.ts | 552 +++++++++++++++++++ frontend/src/api/openapi.json | 687 ++++++++++++++++++++++++ frontend/src/api/schema.ts | 120 +---- frontend/src/surfaces/SearchSurface.tsx | 8 +- 8 files changed, 1528 insertions(+), 103 deletions(-) create mode 100644 backend/export_openapi.py create mode 100644 frontend/src/api/openapi.d.ts create mode 100644 frontend/src/api/openapi.json diff --git a/.gitignore b/.gitignore index 07d705f..1d710d9 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ data/ !package.json !tsconfig.json !.vscode/settings.json +# The backend's OpenAPI document — the API contract the frontend types are +# generated from (see backend/export_openapi.py, frontend gen:api script). +!frontend/src/api/openapi.json # Docker .dockerignore diff --git a/backend/export_openapi.py b/backend/export_openapi.py new file mode 100644 index 0000000..8ba453b --- /dev/null +++ b/backend/export_openapi.py @@ -0,0 +1,49 @@ +"""Export the backend's OpenAPI document to the frontend. + +The frontend's API types are **generated** from this document (via +``openapi-typescript`` — see ``frontend/package.json``'s ``gen:api`` script), +not hand-written. ``qh`` derives a complete OpenAPI schema — request bodies, +responses and ``components.schemas`` — from :class:`ef.service.EfService`'s +Python type hints, so this file is the single source of truth for the +frontend's view of the API. + +Run it whenever the backend API surface changes:: + + cd backend && python export_openapi.py + +It writes ``frontend/src/api/openapi.json``; regenerate the TypeScript types +afterwards with ``cd frontend && pnpm gen:api``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.main import build_app + +#: Where the spec is written — ``frontend/src/api/openapi.json``, resolved +#: relative to this script so it works from any working directory. +SPEC_PATH = ( + Path(__file__).resolve().parent.parent + / "frontend" + / "src" + / "api" + / "openapi.json" +) + + +def export_openapi(spec_path: Path = SPEC_PATH) -> Path: + """Build the app, render its OpenAPI document and write it to ``spec_path``. + + Returns the path written, for logging / scripting. + """ + spec = build_app().openapi() + spec_path.parent.mkdir(parents=True, exist_ok=True) + spec_path.write_text(json.dumps(spec, indent=2) + "\n") + return spec_path + + +if __name__ == "__main__": + written = export_openapi() + print(f"Wrote OpenAPI spec to {written}") diff --git a/frontend/package.json b/frontend/package.json index 4408a07..5dfb313 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,9 +8,14 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "typecheck": "tsc" + "typecheck": "tsc", + "gen:api": "openapi-typescript src/api/openapi.json -o src/api/openapi.d.ts" }, "dependencies": { + "@zodal/core": "link:../../../i/_zodals/zodal/packages/core", + "@zodal/store": "link:../../../i/_zodals/zodal/packages/store", + "@zodal/ui": "link:../../../i/_zodals/zodal/packages/ui", + "@zodal/ui-shadcn": "link:../../../i/_zodals/zodal-ui-shadcn", "acture": "^1.2.1", "acture-hotkeys": "^1.0.0", "acture-palette-react": "^1.0.0", @@ -21,11 +26,7 @@ "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", "zod": "^4.0.0", - "zustand": "^5.0.0", - "@zodal/core": "link:../../../i/_zodals/zodal/packages/core", - "@zodal/ui": "link:../../../i/_zodals/zodal/packages/ui", - "@zodal/store": "link:../../../i/_zodals/zodal/packages/store", - "@zodal/ui-shadcn": "link:../../../i/_zodals/zodal-ui-shadcn" + "zustand": "^5.0.0" }, "devDependencies": { "@types/node": "^22.10.0", @@ -33,6 +34,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "openapi-typescript": "^7.13.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index da4be3c..c7cde0a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.15) + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.15 @@ -548,6 +551,16 @@ packages: '@types/react': optional: true + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.14': + resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -743,6 +756,14 @@ packages: peerDependencies: zod: ^4.0.0 + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -753,6 +774,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -764,6 +788,9 @@ packages: peerDependencies: postcss: ^8.1.0 + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.10.31: resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} @@ -773,6 +800,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -789,6 +819,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -806,6 +839,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -855,6 +891,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -906,6 +945,14 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -930,14 +977,25 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -961,6 +1019,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -987,6 +1049,16 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1009,6 +1081,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -1109,6 +1185,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -1142,6 +1222,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1178,6 +1262,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1192,6 +1280,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -1258,6 +1349,13 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -1304,7 +1402,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1386,7 +1484,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -1663,6 +1761,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.14(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -1804,6 +1925,10 @@ snapshots: dependencies: zod: 4.4.3 + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -1813,6 +1938,8 @@ snapshots: arg@5.0.2: {} + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -1826,10 +1953,16 @@ snapshots: postcss: 8.5.15 postcss-value-parser: 4.2.0 + balanced-match@1.0.2: {} + baseline-browser-mapping@2.10.31: {} binary-extensions@2.3.0: {} + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1846,6 +1979,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + change-case@5.4.4: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1876,6 +2011,8 @@ snapshots: - '@types/react' - '@types/react-dom' + colorette@1.4.0: {} + commander@4.1.1: {} convert-source-map@2.0.0: {} @@ -1884,9 +2021,11 @@ snapshots: csstype@3.2.3: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 detect-node-es@1.1.0: {} @@ -1929,6 +2068,8 @@ snapshots: escalade@3.2.0: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1972,6 +2113,15 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + index-to-position@1.2.0: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -1990,10 +2140,18 @@ snapshots: jiti@1.21.7: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} lilconfig@3.1.3: {} @@ -2011,6 +2169,10 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + ms@2.1.3: {} mz@2.7.0: @@ -2029,6 +2191,22 @@ snapshots: object-hash@3.0.0: {} + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.14(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + path-parse@1.0.7: {} picocolors@1.1.1: {} @@ -2041,6 +2219,8 @@ snapshots: pirates@4.0.7: {} + pluralize@8.0.0: {} + postcss-import@15.1.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -2124,6 +2304,8 @@ snapshots: dependencies: picomatch: 2.3.2 + require-from-string@2.0.2: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -2184,6 +2366,8 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-preserve-symlinks-flag@1.0.0: {} tailwind-merge@2.6.1: {} @@ -2239,6 +2423,8 @@ snapshots: tslib@2.8.1: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} undici-types@6.21.0: {} @@ -2249,6 +2435,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.6): dependencies: react: 19.2.6 @@ -2281,6 +2469,10 @@ snapshots: yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + zod@4.4.3: {} zustand@5.0.13(@types/react@19.2.15)(react@19.2.6): diff --git a/frontend/src/api/openapi.d.ts b/frontend/src/api/openapi.d.ts new file mode 100644 index 0000000..5976871 --- /dev/null +++ b/frontend/src/api/openapi.d.ts @@ -0,0 +1,552 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health + * @description Liveness probe — used by the Docker healthcheck. + */ + get: operations["health_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/create_corpus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Index ``sources`` into a new corpus, register it, return its :class:`CorpusInfo`. + * @description Index ``sources`` into a new corpus, register it, return its :class:`CorpusInfo`. + * + * Args: + * sources: the corpus — a list of text documents. + * embedder: the embedder, as a string the DI seam resolves + * (:func:`~ef.embedder_adapters.as_embedder`) — ``"hashing"``, + * ``"openai:text-embedding-3-small"``, ``"cohere:..."``, an + * ``http(s)://`` URL, …. ``None`` → + * :data:`~ef.source_manager.DEFAULT_EMBEDDER`, the dependency-free + * :class:`~ef.embedders.HashingEmbedder`. + * segmenter: the segmenter, as a string + * (:func:`~ef.segmenter_adapters.as_segmenter`) — ``None`` → the + * recursive-character default. + * corpus_id: the handle to register the corpus under; ``None`` → a + * fresh random id. Reusing a live id is an error. + * + * Returns: + * the :class:`CorpusInfo` of the freshly indexed corpus. + * + * Raises: + * ValueError: if ``corpus_id`` is already registered. + */ + post: operations["create_corpus_create_corpus_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Search a registered corpus — up to ``limit`` ranked :class:`~ef.source_manager.SearchHit`\ s. + * @description Search a registered corpus — up to ``limit`` ranked :class:`~ef.source_manager.SearchHit`\ s. + * + * Each hit carries the matched :class:`~ef.segments.Segment`, its + * similarity ``score`` (higher = closer) and the ``source_id`` it was cut + * from. + * + * Raises: + * KeyError: if ``corpus_id`` is not registered. + */ + post: operations["search_search_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/retrieve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retrieve ranked :class:`~ef.segments.Segment`\ s — the RAG-plug-in shape. + * @description Retrieve ranked :class:`~ef.segments.Segment`\ s — the RAG-plug-in shape. + * + * Like :meth:`search`, but returns plain segments in rank order (the + * ``score`` dropped, the ``source_id`` folded into ``metadata["source"]``) + * — clean context to hand to an external RAG/agent framework. ``ef`` + * returns context; it does not synthesize answers. + * + * Raises: + * KeyError: if ``corpus_id`` is not registered. + */ + post: operations["retrieve_retrieve_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/explore_corpus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Project & cluster a registered corpus — the corpus-map surface. + * @description Project & cluster a registered corpus — the corpus-map surface. + * + * Runs :func:`ef.exploration.explore` over the corpus: every indexed + * segment is projected to ``dims`` coordinates and assigned a cluster, + * returned as a row-aligned :class:`~ef.exploration.ExploreResult` (``ids`` / + * ``coords`` / ``labels`` / ``cluster_titles``) — the JSON-friendly shape + * an ``app_ef`` corpus map consumes. + * + * Args: + * corpus_id: the corpus to explore. + * dims: projection target dimensionality — ``2`` or ``3``. + * projection_method: ``"auto"`` / ``"umap"`` / ``"pca"``. + * cluster_method: ``"kmeans"`` / ``"hdbscan"``. + * n_clusters: number of k-means clusters. + * label: when ``True``, also name each cluster with an LLM (needs the + * ``ef[imbed]`` extra and a key); default ``False``. + * + * Raises: + * KeyError: if ``corpus_id`` is not registered. + * ValueError: if the corpus has fewer than 2 indexed segments. + */ + post: operations["explore_corpus_explore_corpus_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/corpus_info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Return the :class:`CorpusInfo` of a registered corpus. + * @description Return the :class:`CorpusInfo` of a registered corpus. + * + * Raises: + * KeyError: if ``corpus_id`` is not registered. + */ + post: operations["corpus_info_corpus_info_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/list_corpora": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Return the :class:`CorpusInfo` of every registered corpus. + * @description Return the :class:`CorpusInfo` of every registered corpus. + */ + post: operations["list_corpora_list_corpora_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/delete_corpus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Drop a corpus from the registry, releasing its index. + * @description Drop a corpus from the registry, releasing its index. + * + * Raises: + * KeyError: if ``corpus_id`` is not registered. + */ + post: operations["delete_corpus_delete_corpus_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description A JSON-friendly summary of one registered corpus. + * + * The return shape shared by :meth:`EfService.create_corpus`, + * :meth:`EfService.corpus_info` and (as a list) :meth:`EfService.list_corpora` + * — a plain ``dict`` an HTTP client and ``qh``'s schema both understand. + * + * Keys: + * corpus_id: the registry handle — how every other method addresses it. + * n_sources: the number of source documents in the corpus. + * n_segments: the number of indexed segments (sources cut by the segmenter). + * embedder: the embedder's ``model_id`` (e.g. ``"hashing:v1@512"``). + * dim: the embedding dimensionality. + * config_id: the content hash of the segmenter+embedder pipeline. + */ + CorpusInfo: { + corpus_id: string; + n_sources: number; + n_segments: number; + embedder: string; + dim: number; + config_id: string; + }; + /** + * @description A piece of text carved from a source document — the interchange type. + * + * A ``TypedDict``: a :class:`Segment` *is* a plain ``dict``, which keeps it + * cheap to create and stream. ``total=False`` because most keys are optional; + * by convention ``text`` is always present and ``id`` is always set (derived + * from ``text`` if not supplied). + * + * Keys: + * text: The segment's text. **Required.** + * id: Stable identifier — content-derived by default (:func:`segment_id`). + * parent_id: Id of the source document or parent segment this came from. + * start: Character offset of the segment's start in the source text. + * end: Character offset of the segment's end (exclusive) in the source. + * index: Ordinal position of the segment in its segmenter's output. + * tokens: Token count of ``text`` (meaningful only with ``metadata`` key + * ``tokenizer`` recording which tokenizer counted it). + * metadata: Free-form mapping; framework-specific keys live here. See + * :data:`PROMOTED_METADATA_KEYS` for the conventional keys. + */ + Segment: { + text?: string; + id?: string; + parent_id?: string; + start?: number; + end?: number; + index?: number; + tokens?: number; + metadata?: { + [key: string]: unknown; + }; + }; + /** + * @description One ranked search result — a :class:`~ef.segments.Segment` and its score. + * + * :meth:`SearchableCorpus.search` / :meth:`SourceManager.search` return a + * ranked ``list`` of these. Keeping :class:`~ef.segments.Segment` (the + * interchange ``TypedDict``) pure of a result-only ``score`` key, a + * :class:`SearchHit` *wraps* it alongside the similarity ``score`` and the + * ``source_id`` of the document the segment was cut from. + * + * Attributes: + * segment: the matched segment — the canonical :class:`~ef.segments.Segment`. + * score: the similarity score from the vector store (higher = closer). + * source_id: the corpus key of the source document, if recorded. + * + * >>> hit = SearchHit(segment={'text': 'hi', 'id': 'x'}, score=0.9, source_id='doc-1') + * >>> hit.segment['text'], hit.score, hit.source_id + * ('hi', 0.9, 'doc-1') + */ + SearchHit: { + segment: components["schemas"]["Segment"]; + score: number; + source_id?: string | null; + }; + /** + * @description A structured, JSON-friendly corpus-exploration result. + * + * What :func:`explore` returns and :meth:`ef.service.EfService.explore_corpus` + * serves. Every list is **row-aligned**: ``ids[i]``, ``coords[i]`` and + * ``labels[i]`` all describe the same item. + * + * Keys: + * ids: the per-item identifiers — a corpus's keys, or positional ``"0"``, + * ``"1"``, … for an id-less vector matrix. + * coords: the projected coordinates — one ``[x, y]`` (or ``[x, y, z]``) + * row per item. + * labels: the cluster id of each item (HDBSCAN marks noise ``-1``). + * cluster_titles: ``{cluster_id: title}`` — empty unless :func:`explore` + * was called with ``label=True``. + */ + ExploreResult: { + ids: string[]; + coords: number[][]; + labels: number[]; + cluster_titles: { + [key: string]: string; + }; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + create_corpus_create_corpus_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + sources: string[]; + embedder?: string | null; + segmenter?: string | null; + corpus_id?: string | null; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorpusInfo"]; + }; + }; + }; + }; + search_search_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + corpus_id: string; + query: string; + limit?: number; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchHit"][]; + }; + }; + }; + }; + retrieve_retrieve_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + corpus_id: string; + query: string; + limit?: number; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Segment"][]; + }; + }; + }; + }; + explore_corpus_explore_corpus_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + corpus_id: string; + dims?: number; + projection_method?: string; + cluster_method?: string; + n_clusters?: number; + label?: boolean; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExploreResult"]; + }; + }; + }; + }; + corpus_info_corpus_info_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + corpus_id: string; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorpusInfo"]; + }; + }; + }; + }; + list_corpora_list_corpora_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorpusInfo"][]; + }; + }; + }; + }; + delete_corpus_delete_corpus_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + corpus_id: string; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": null; + }; + }; + }; + }; +} diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json new file mode 100644 index 0000000..7e37a80 --- /dev/null +++ b/frontend/src/api/openapi.json @@ -0,0 +1,687 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "app_ef API", + "description": "HTTP transport over ef.EfService \u2014 semantic-search corpora.", + "version": "0.2.0" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "ops" + ], + "summary": "Health", + "description": "Liveness probe \u2014 used by the Docker healthcheck.", + "operationId": "health_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Health Health Get" + } + } + } + } + } + } + }, + "/create_corpus": { + "post": { + "summary": "Index ``sources`` into a new corpus, register it, return its :class:`CorpusInfo`.", + "description": "Index ``sources`` into a new corpus, register it, return its :class:`CorpusInfo`.\n\n Args:\n sources: the corpus \u2014 a list of text documents.\n embedder: the embedder, as a string the DI seam resolves\n (:func:`~ef.embedder_adapters.as_embedder`) \u2014 ``\"hashing\"``,\n ``\"openai:text-embedding-3-small\"``, ``\"cohere:...\"``, an\n ``http(s)://`` URL, \u2026. ``None`` \u2192\n :data:`~ef.source_manager.DEFAULT_EMBEDDER`, the dependency-free\n :class:`~ef.embedders.HashingEmbedder`.\n segmenter: the segmenter, as a string\n (:func:`~ef.segmenter_adapters.as_segmenter`) \u2014 ``None`` \u2192 the\n recursive-character default.\n corpus_id: the handle to register the corpus under; ``None`` \u2192 a\n fresh random id. Reusing a live id is an error.\n\n Returns:\n the :class:`CorpusInfo` of the freshly indexed corpus.\n\n Raises:\n ValueError: if ``corpus_id`` is already registered.", + "operationId": "create_corpus_create_corpus_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorpusInfo" + } + } + } + } + }, + "x-python-signature": { + "name": "create_corpus", + "module": "ef.service", + "parameters": [ + { + "name": "sources", + "type": "list", + "required": true + }, + { + "name": "embedder", + "type": "UnionType[str, NoneType]", + "required": false, + "default": null + }, + { + "name": "segmenter", + "type": "UnionType[str, NoneType]", + "required": false, + "default": null + }, + { + "name": "corpus_id", + "type": "UnionType[str, NoneType]", + "required": false, + "default": null + } + ], + "return_type": "CorpusInfo", + "docstring": "Index ``sources`` into a new corpus, register it, return its :class:`CorpusInfo`.\n\nArgs:\n sources: the corpus \u2014 a list of text documents.\n embedder: the embedder, as a string the DI seam resolves\n (:func:`~ef.embedder_adapters.as_embedder`) \u2014 ``\"hashing\"``,\n ``\"openai:text-embedding-3-small\"``, ``\"cohere:...\"``, an\n ``http(s)://`` URL, \u2026. ``None`` \u2192\n :data:`~ef.source_manager.DEFAULT_EMBEDDER`, the dependency-free\n :class:`~ef.embedders.HashingEmbedder`.\n segmenter: the segmenter, as a string\n (:func:`~ef.segmenter_adapters.as_segmenter`) \u2014 ``None`` \u2192 the\n recursive-character default.\n corpus_id: the handle to register the corpus under; ``None`` \u2192 a\n fresh random id. Reusing a live id is an error.\n\nReturns:\n the :class:`CorpusInfo` of the freshly indexed corpus.\n\nRaises:\n ValueError: if ``corpus_id`` is already registered." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "embedder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "segmenter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "corpus_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sources" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "sources": [ + "example" + ] + } + } + } + } + } + } + } + }, + "/search": { + "post": { + "summary": "Search a registered corpus \u2014 up to ``limit`` ranked :class:`~ef.source_manager.SearchHit`\\ s.", + "description": "Search a registered corpus \u2014 up to ``limit`` ranked :class:`~ef.source_manager.SearchHit`\\ s.\n\n Each hit carries the matched :class:`~ef.segments.Segment`, its\n similarity ``score`` (higher = closer) and the ``source_id`` it was cut\n from.\n\n Raises:\n KeyError: if ``corpus_id`` is not registered.", + "operationId": "search_search_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchHit" + } + } + } + } + } + }, + "x-python-signature": { + "name": "search", + "module": "ef.service", + "parameters": [ + { + "name": "corpus_id", + "type": "str", + "required": true + }, + { + "name": "query", + "type": "str", + "required": true + }, + { + "name": "limit", + "type": "int", + "required": false, + "default": 10 + } + ], + "return_type": "list", + "docstring": "Search a registered corpus \u2014 up to ``limit`` ranked :class:`~ef.source_manager.SearchHit`\\ s.\n\nEach hit carries the matched :class:`~ef.segments.Segment`, its\nsimilarity ``score`` (higher = closer) and the ``source_id`` it was cut\nfrom.\n\nRaises:\n KeyError: if ``corpus_id`` is not registered." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "corpus_id", + "query" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "corpus_id": "abc123", + "query": "example" + } + } + } + } + } + } + } + }, + "/retrieve": { + "post": { + "summary": "Retrieve ranked :class:`~ef.segments.Segment`\\ s \u2014 the RAG-plug-in shape.", + "description": "Retrieve ranked :class:`~ef.segments.Segment`\\ s \u2014 the RAG-plug-in shape.\n\n Like :meth:`search`, but returns plain segments in rank order (the\n ``score`` dropped, the ``source_id`` folded into ``metadata[\"source\"]``)\n \u2014 clean context to hand to an external RAG/agent framework. ``ef``\n returns context; it does not synthesize answers.\n\n Raises:\n KeyError: if ``corpus_id`` is not registered.", + "operationId": "retrieve_retrieve_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Segment" + } + } + } + } + } + }, + "x-python-signature": { + "name": "retrieve", + "module": "ef.service", + "parameters": [ + { + "name": "corpus_id", + "type": "str", + "required": true + }, + { + "name": "query", + "type": "str", + "required": true + }, + { + "name": "limit", + "type": "int", + "required": false, + "default": 10 + } + ], + "return_type": "list", + "docstring": "Retrieve ranked :class:`~ef.segments.Segment`\\ s \u2014 the RAG-plug-in shape.\n\nLike :meth:`search`, but returns plain segments in rank order (the\n``score`` dropped, the ``source_id`` folded into ``metadata[\"source\"]``)\n\u2014 clean context to hand to an external RAG/agent framework. ``ef``\nreturns context; it does not synthesize answers.\n\nRaises:\n KeyError: if ``corpus_id`` is not registered." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "corpus_id", + "query" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "corpus_id": "abc123", + "query": "example" + } + } + } + } + } + } + } + }, + "/explore_corpus": { + "post": { + "summary": "Project & cluster a registered corpus \u2014 the corpus-map surface.", + "description": "Project & cluster a registered corpus \u2014 the corpus-map surface.\n\n Runs :func:`ef.exploration.explore` over the corpus: every indexed\n segment is projected to ``dims`` coordinates and assigned a cluster,\n returned as a row-aligned :class:`~ef.exploration.ExploreResult` (``ids`` /\n ``coords`` / ``labels`` / ``cluster_titles``) \u2014 the JSON-friendly shape\n an ``app_ef`` corpus map consumes.\n\n Args:\n corpus_id: the corpus to explore.\n dims: projection target dimensionality \u2014 ``2`` or ``3``.\n projection_method: ``\"auto\"`` / ``\"umap\"`` / ``\"pca\"``.\n cluster_method: ``\"kmeans\"`` / ``\"hdbscan\"``.\n n_clusters: number of k-means clusters.\n label: when ``True``, also name each cluster with an LLM (needs the\n ``ef[imbed]`` extra and a key); default ``False``.\n\n Raises:\n KeyError: if ``corpus_id`` is not registered.\n ValueError: if the corpus has fewer than 2 indexed segments.", + "operationId": "explore_corpus_explore_corpus_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExploreResult" + } + } + } + } + }, + "x-python-signature": { + "name": "explore_corpus", + "module": "ef.service", + "parameters": [ + { + "name": "corpus_id", + "type": "str", + "required": true + }, + { + "name": "dims", + "type": "int", + "required": false, + "default": 2 + }, + { + "name": "projection_method", + "type": "str", + "required": false, + "default": "auto" + }, + { + "name": "cluster_method", + "type": "str", + "required": false, + "default": "kmeans" + }, + { + "name": "n_clusters", + "type": "int", + "required": false, + "default": 8 + }, + { + "name": "label", + "type": "bool", + "required": false, + "default": false + } + ], + "return_type": "ExploreResult", + "docstring": "Project & cluster a registered corpus \u2014 the corpus-map surface.\n\nRuns :func:`ef.exploration.explore` over the corpus: every indexed\nsegment is projected to ``dims`` coordinates and assigned a cluster,\nreturned as a row-aligned :class:`~ef.exploration.ExploreResult` (``ids`` /\n``coords`` / ``labels`` / ``cluster_titles``) \u2014 the JSON-friendly shape\nan ``app_ef`` corpus map consumes.\n\nArgs:\n corpus_id: the corpus to explore.\n dims: projection target dimensionality \u2014 ``2`` or ``3``.\n projection_method: ``\"auto\"`` / ``\"umap\"`` / ``\"pca\"``.\n cluster_method: ``\"kmeans\"`` / ``\"hdbscan\"``.\n n_clusters: number of k-means clusters.\n label: when ``True``, also name each cluster with an LLM (needs the\n ``ef[imbed]`` extra and a key); default ``False``.\n\nRaises:\n KeyError: if ``corpus_id`` is not registered.\n ValueError: if the corpus has fewer than 2 indexed segments." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + }, + "dims": { + "type": "integer" + }, + "projection_method": { + "type": "string" + }, + "cluster_method": { + "type": "string" + }, + "n_clusters": { + "type": "integer" + }, + "label": { + "type": "boolean" + } + }, + "required": [ + "corpus_id" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "corpus_id": "abc123" + } + } + } + } + } + } + } + }, + "/corpus_info": { + "post": { + "summary": "Return the :class:`CorpusInfo` of a registered corpus.", + "description": "Return the :class:`CorpusInfo` of a registered corpus.\n\n Raises:\n KeyError: if ``corpus_id`` is not registered.", + "operationId": "corpus_info_corpus_info_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CorpusInfo" + } + } + } + } + }, + "x-python-signature": { + "name": "corpus_info", + "module": "ef.service", + "parameters": [ + { + "name": "corpus_id", + "type": "str", + "required": true + } + ], + "return_type": "CorpusInfo", + "docstring": "Return the :class:`CorpusInfo` of a registered corpus.\n\nRaises:\n KeyError: if ``corpus_id`` is not registered." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + } + }, + "required": [ + "corpus_id" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "corpus_id": "abc123" + } + } + } + } + } + } + } + }, + "/list_corpora": { + "post": { + "summary": "Return the :class:`CorpusInfo` of every registered corpus.", + "description": "Return the :class:`CorpusInfo` of every registered corpus.", + "operationId": "list_corpora_list_corpora_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CorpusInfo" + } + } + } + } + } + }, + "x-python-signature": { + "name": "list_corpora", + "module": "ef.service", + "parameters": [], + "return_type": "list", + "docstring": "Return the :class:`CorpusInfo` of every registered corpus." + } + } + }, + "/delete_corpus": { + "post": { + "summary": "Drop a corpus from the registry, releasing its index.", + "description": "Drop a corpus from the registry, releasing its index.\n\n Raises:\n KeyError: if ``corpus_id`` is not registered.", + "operationId": "delete_corpus_delete_corpus_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "null" + } + } + } + } + }, + "x-python-signature": { + "name": "delete_corpus", + "module": "ef.service", + "parameters": [ + { + "name": "corpus_id", + "type": "str", + "required": true + } + ], + "return_type": "NoneType", + "docstring": "Drop a corpus from the registry, releasing its index.\n\nRaises:\n KeyError: if ``corpus_id`` is not registered." + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + } + }, + "required": [ + "corpus_id" + ] + }, + "examples": { + "example_0": { + "summary": "Basic example", + "value": { + "corpus_id": "abc123" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CorpusInfo": { + "type": "object", + "properties": { + "corpus_id": { + "type": "string" + }, + "n_sources": { + "type": "integer" + }, + "n_segments": { + "type": "integer" + }, + "embedder": { + "type": "string" + }, + "dim": { + "type": "integer" + }, + "config_id": { + "type": "string" + } + }, + "required": [ + "corpus_id", + "n_sources", + "n_segments", + "embedder", + "dim", + "config_id" + ], + "description": "A JSON-friendly summary of one registered corpus.\n\nThe return shape shared by :meth:`EfService.create_corpus`,\n:meth:`EfService.corpus_info` and (as a list) :meth:`EfService.list_corpora`\n\u2014 a plain ``dict`` an HTTP client and ``qh``'s schema both understand.\n\nKeys:\n corpus_id: the registry handle \u2014 how every other method addresses it.\n n_sources: the number of source documents in the corpus.\n n_segments: the number of indexed segments (sources cut by the segmenter).\n embedder: the embedder's ``model_id`` (e.g. ``\"hashing:v1@512\"``).\n dim: the embedding dimensionality.\n config_id: the content hash of the segmenter+embedder pipeline." + }, + "Segment": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parent_id": { + "type": "string" + }, + "start": { + "type": "integer" + }, + "end": { + "type": "integer" + }, + "index": { + "type": "integer" + }, + "tokens": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + } + }, + "description": "A piece of text carved from a source document \u2014 the interchange type.\n\nA ``TypedDict``: a :class:`Segment` *is* a plain ``dict``, which keeps it\ncheap to create and stream. ``total=False`` because most keys are optional;\nby convention ``text`` is always present and ``id`` is always set (derived\nfrom ``text`` if not supplied).\n\nKeys:\n text: The segment's text. **Required.**\n id: Stable identifier \u2014 content-derived by default (:func:`segment_id`).\n parent_id: Id of the source document or parent segment this came from.\n start: Character offset of the segment's start in the source text.\n end: Character offset of the segment's end (exclusive) in the source.\n index: Ordinal position of the segment in its segmenter's output.\n tokens: Token count of ``text`` (meaningful only with ``metadata`` key\n ``tokenizer`` recording which tokenizer counted it).\n metadata: Free-form mapping; framework-specific keys live here. See\n :data:`PROMOTED_METADATA_KEYS` for the conventional keys." + }, + "SearchHit": { + "type": "object", + "properties": { + "segment": { + "$ref": "#/components/schemas/Segment" + }, + "score": { + "type": "number" + }, + "source_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "segment", + "score" + ], + "description": "One ranked search result \u2014 a :class:`~ef.segments.Segment` and its score.\n\n:meth:`SearchableCorpus.search` / :meth:`SourceManager.search` return a\nranked ``list`` of these. Keeping :class:`~ef.segments.Segment` (the\ninterchange ``TypedDict``) pure of a result-only ``score`` key, a\n:class:`SearchHit` *wraps* it alongside the similarity ``score`` and the\n``source_id`` of the document the segment was cut from.\n\nAttributes:\n segment: the matched segment \u2014 the canonical :class:`~ef.segments.Segment`.\n score: the similarity score from the vector store (higher = closer).\n source_id: the corpus key of the source document, if recorded.\n\n>>> hit = SearchHit(segment={'text': 'hi', 'id': 'x'}, score=0.9, source_id='doc-1')\n>>> hit.segment['text'], hit.score, hit.source_id\n('hi', 0.9, 'doc-1')" + }, + "ExploreResult": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "coords": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "labels": { + "type": "array", + "items": { + "type": "integer" + } + }, + "cluster_titles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "ids", + "coords", + "labels", + "cluster_titles" + ], + "description": "A structured, JSON-friendly corpus-exploration result.\n\nWhat :func:`explore` returns and :meth:`ef.service.EfService.explore_corpus`\nserves. Every list is **row-aligned**: ``ids[i]``, ``coords[i]`` and\n``labels[i]`` all describe the same item.\n\nKeys:\n ids: the per-item identifiers \u2014 a corpus's keys, or positional ``\"0\"``,\n ``\"1\"``, \u2026 for an id-less vector matrix.\n coords: the projected coordinates \u2014 one ``[x, y]`` (or ``[x, y, z]``)\n row per item.\n labels: the cluster id of each item (HDBSCAN marks noise ``-1``).\n cluster_titles: ``{cluster_id: title}`` \u2014 empty unless :func:`explore`\n was called with ``label=True``." + } + } + } +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b5817cc..1f32985 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -1,113 +1,51 @@ /** * TypeScript contracts for the app_ef backend (qh-over-`ef.EfService`). * - * These are **hand-written and verified against the live API**, not generated. - * `qh`'s OpenAPI document (`/openapi.json`) currently exposes the routes and - * docstrings but emits empty `{}` request/response schemas and no - * `components.schemas` — so `openapi-typescript` would produce no useful - * types. Each interface below was confirmed by probing the running backend - * (see PR notes / issue #5). If `qh` later derives full JSON Schema from the - * Python type hints, this file can be regenerated from the spec. + * **Generated, not hand-written.** The names below are thin aliases over + * `openapi.d.ts`, which `openapi-typescript` generates from `openapi.json` — + * the backend's OpenAPI document. `qh` derives that document's request / + * response JSON Schema from `ef.service.EfService`'s Python type hints, so + * these types track the backend automatically. + * + * To refresh after a backend API change: + * ```sh + * cd backend && python export_openapi.py # refresh src/api/openapi.json + * cd frontend && pnpm gen:api # regenerate src/api/openapi.d.ts + * ``` * * The backend exposes seven `POST /` endpoints whose JSON body is a * flat object of the underlying Python function's parameters. */ +import type { components, paths } from './openapi'; // ── Response shapes ──────────────────────────────────────────────────────── /** A JSON-friendly summary of one registered corpus (`ef.service.CorpusInfo`). */ -export interface CorpusInfo { - /** The registry handle — how every other endpoint addresses the corpus. */ - corpus_id: string; - /** Number of source documents. */ - n_sources: number; - /** Number of indexed segments (sources cut by the segmenter). */ - n_segments: number; - /** The embedder's model id, e.g. `"hashing:v1@512"`. */ - embedder: string; - /** Embedding dimensionality. */ - dim: number; - /** Content hash of the segmenter+embedder pipeline. */ - config_id: string; -} +export type CorpusInfo = components['schemas']['CorpusInfo']; /** One indexed text segment (`ef.segments.Segment`). */ -export interface Segment { - /** The segment text. */ - text: string; - /** Stable content-hash id of the segment. */ - id: string; - /** Character offset of the segment's start within its source. */ - start: number; - /** Character offset of the segment's end within its source. */ - end: number; - /** The segment's position among its source's segments. */ - index: number; - /** Approximate token count. */ - tokens: number; - /** Free-form metadata (tokenizer, source id when retrieved, …). */ - metadata: Record; -} +export type Segment = components['schemas']['Segment']; -/** One ranked search result (`ef.source_manager.SearchHit`). */ -export interface SearchHit { - /** The matched segment. */ - segment: Segment; - /** Similarity score — higher is closer. */ - score: number; - /** Id of the source document the segment was cut from. */ - source_id: string; -} +/** One ranked search result — segment + score + source (`ef.source_manager.SearchHit`). */ +export type SearchHit = components['schemas']['SearchHit']; -/** A projected & clustered corpus map (`ef.exploration.ExploreResult`). - * All four arrays/maps are row-aligned by segment. */ -export interface ExploreResult { - /** Segment ids, one per point. */ - ids: string[]; - /** 2-D (or 3-D) coordinates, one `[x, y]` per point. */ - coords: number[][]; - /** Cluster index per point. */ - labels: number[]; - /** Optional `{clusterIndex: title}` — populated only when `label` is set. */ - cluster_titles: Record; -} +/** A projected & clustered corpus map (`ef.exploration.ExploreResult`). */ +export type ExploreResult = components['schemas']['ExploreResult']; // ── Request bodies ───────────────────────────────────────────────────────── -/** Body of `POST /create_corpus`. */ -export interface CreateCorpusBody { - /** The corpus — a list of text documents. */ - sources: string[]; - /** Embedder id; omit for the dependency-free hashing embedder. */ - embedder?: string; - /** Segmenter id; omit for the recursive-character default. */ - segmenter?: string; - /** Handle to register the corpus under; omit for a random id. */ - corpus_id?: string; +/** The `application/json` body of an endpoint's `POST` request. */ +type JsonRequestBody

= paths[P]['post'] extends { + requestBody: { content: { 'application/json': infer B } }; } + ? B + : never; -/** Body of `POST /search` and `POST /retrieve`. */ -export interface QueryBody { - /** The corpus to query. */ - corpus_id: string; - /** The natural-language query. */ - query: string; - /** Maximum number of results. */ - limit?: number; -} +/** Body of `POST /create_corpus`. */ +export type CreateCorpusBody = JsonRequestBody<'/create_corpus'>; + +/** Body of `POST /search` and `POST /retrieve` (identical shape). */ +export type QueryBody = JsonRequestBody<'/search'>; /** Body of `POST /explore_corpus`. */ -export interface ExploreBody { - /** The corpus to explore. */ - corpus_id: string; - /** Projection target dimensionality — 2 or 3. */ - dims?: number; - /** `"auto"` | `"umap"` | `"pca"`. */ - projection_method?: string; - /** `"kmeans"` | `"hdbscan"`. */ - cluster_method?: string; - /** Number of k-means clusters. */ - n_clusters?: number; - /** Name each cluster with an LLM (needs the `ef[imbed]` extra + a key). */ - label?: boolean; -} +export type ExploreBody = JsonRequestBody<'/explore_corpus'>; diff --git a/frontend/src/surfaces/SearchSurface.tsx b/frontend/src/surfaces/SearchSurface.tsx index 8c00508..a97a0b7 100644 --- a/frontend/src/surfaces/SearchSurface.tsx +++ b/frontend/src/surfaces/SearchSurface.tsx @@ -65,9 +65,11 @@ export function SearchSurface() {

{hit.segment.text}

-

- source {hit.source_id.slice(0, 12)} -

+ {hit.source_id != null && ( +

+ source {hit.source_id.slice(0, 12)} +

+ )}