From 67977c0ff2c3767cfb43f5131afbcf8fb8ef24b7 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 19 Aug 2026 10:39:09 +0300 Subject: [PATCH 01/19] fix: resolve pre-existing TS errors and install RAG dependencies Fix 3 pre-existing TypeScript errors in challenge-context-workflow.ts:313 and skill-extraction-workflow.ts:102,146 by adding the required 'observe' property (noopObserve from @mastra/core/tools) to tool.execute?.() calls. ToolExecutionContext requires observe as a non-optional field. Install new dependencies for RAG pipeline: - Production: @mastra/rag@^2.5.0 (2.6.0), turndown, js-tiktoken, csv-parse - Dev: @types/turndown, tsx Verified @mastra/rag 2.6.0: MDocument.chunk() returns chunks with 'text' field (not 'content'), confirming architecture assumptions. Green baseline confirmed: tsc --noEmit, pnpm lint, pnpm test, pnpm run build all pass with exit code 0. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- package.json | 6 + pnpm-lock.yaml | 484 +++++++++++++++++- .../challenge/challenge-context-workflow.ts | 3 +- .../skills/skill-extraction-workflow.ts | 5 +- 4 files changed, 488 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 21b672d..f5cf459 100644 --- a/package.json +++ b/package.json @@ -36,20 +36,26 @@ "@mastra/memory": "^1.26.0", "@mastra/observability": "^1.16.5", "@mastra/pg": "^1.19.0", + "@mastra/rag": "^2.6.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.221.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.221.0", "@topcoder/wipro-ai-sdk-provider": "git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git", "ai": "^6.0.209", "ai-sdk-ollama": "^3.8.8", + "csv-parse": "^7.0.2", + "js-tiktoken": "^1.0.21", "tc-core-library-js": "^2.4.1", + "turndown": "^7.2.4", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.0.1", + "@types/turndown": "^5.0.6", "eslint": "^10.5.0", "mastra": "^1.23.0", "prettier": "^3.8.4", + "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.62.0", "vitest": "^4.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42e7f4e..da6857b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@mastra/pg': specifier: ^1.19.0 version: 1.19.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + '@mastra/rag': + specifier: ^2.6.0 + version: 2.6.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1) @@ -56,9 +59,18 @@ importers: ai-sdk-ollama: specifier: ^3.8.8 version: 3.8.8(ai@6.0.209(zod@4.4.3))(zod@4.4.3) + csv-parse: + specifier: ^7.0.2 + version: 7.0.2 + js-tiktoken: + specifier: ^1.0.21 + version: 1.0.21 tc-core-library-js: specifier: ^2.4.1 version: 2.4.1 + turndown: + specifier: ^7.2.4 + version: 7.2.4 zod: specifier: ^4.4.3 version: 4.4.3 @@ -69,6 +81,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 eslint: specifier: ^10.5.0 version: 10.5.0 @@ -78,6 +93,9 @@ importers: prettier: specifier: ^3.8.4 version: 3.8.4 + tsx: + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -86,7 +104,7 @@ importers: version: 8.62.0(eslint@10.5.0)(typescript@6.0.3) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -192,6 +210,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-bedrock-agent-runtime@3.1113.0': + resolution: {integrity: sha512-YsbasH6Aa1KrAxdENZy6+oePgKIBqhhpQd8c9XsXpjvfMi7ueTdgmfUYsavtyAnk70aBcVZ2nVfUKlNxK2uWpg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-cognito-identity@3.1075.0': resolution: {integrity: sha512-typFcdyFwIPt86QPKsO7xwcsYoEmNcDpoNqn0tqa4+Lss7niNKQzPe/765XNgAHO3I1ixiT1OKv8KD6M+CKw2w==} engines: {node: '>=20.0.0'} @@ -200,6 +222,10 @@ packages: resolution: {integrity: sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-cognito-identity@3.972.48': resolution: {integrity: sha512-dTSY4wCPx87Gd1peDcTop1li61f6KLAs3LSlq/omnCxpIOvMDpAQjylJ7ZDaZk6KA88+oZ0k/XuVSou1YuWI/w==} engines: {node: '>=20.0.0'} @@ -208,34 +234,66 @@ packages: resolution: {integrity: sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.51': resolution: {integrity: sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.56': resolution: {integrity: sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.55': resolution: {integrity: sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.58': resolution: {integrity: sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.49': resolution: {integrity: sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.55': resolution: {integrity: sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.55': resolution: {integrity: sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-providers@3.1075.0': resolution: {integrity: sha512-2HoJ1IxwdzEryyUmZvl6dVKfgWBnS6NzSDl5hOqmbHns5Cy+wCQLDFU+HGVi+kTGZgSyMZIa0rH7fQvNf7v9jQ==} engines: {node: '>=20.0.0'} @@ -244,18 +302,34 @@ packages: resolution: {integrity: sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.35': resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1074.0': resolution: {integrity: sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.13': resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.8': resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} engines: {node: '>=20.0.0'} @@ -264,10 +338,18 @@ packages: resolution: {integrity: sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@8.0.0': resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -791,6 +873,13 @@ packages: peerDependencies: '@mastra/core': '>=1.53.0-0 <2.0.0-0' + '@mastra/rag@2.6.0': + resolution: {integrity: sha512-kOnXQNOHrCXOqJMAlXylcL146seoOtCFDfdd3l/InvStw5+oIjs7KI9z+tXVPZ/uYrMkrrH8xwUm985Q1nyckg==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@mastra/core': '>=1.0.0-0 <2.0.0-0' + zod: ^3.25.0 || ^4.0.0 + '@mastra/schema-compat@1.3.5': resolution: {integrity: sha512-9CdBZZ2Fb8q6Ms4r6hs0msH8MTmVn99Zrc+i8BnNNuqKlIrywIoH3BghmPr5VsvkBUx7u/GjdYAsJ6i9nPcZZA==} engines: {node: '>=22.13.0'} @@ -804,6 +893,9 @@ packages: '@mastra/core': '>=1.50.0-0 <2.0.0-0' zod: ^3.25.0 || ^4.0.0 + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1249,10 +1341,18 @@ packages: resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} engines: {node: '>=18.0.0'} + '@smithy/core@3.33.2': + resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.2': resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.4.2': resolution: {integrity: sha512-iv6jeGoL5dIGXglIe0aJb8vvTuJkGB4z0LeB2mKV0PH9iAlr4jNhRhEWU7ZGmIeNC+8Zj6jhmSumDez6DidTOA==} engines: {node: '>=18.0.0'} @@ -1261,10 +1361,18 @@ packages: resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} + '@smithy/node-http-handler@4.11.2': + resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.8.2': resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==} engines: {node: '>=18.0.0'} @@ -1273,10 +1381,18 @@ packages: resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.2': + resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -1357,6 +1473,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} @@ -1375,6 +1497,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1504,6 +1629,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ai-sdk-ollama@3.8.8: resolution: {integrity: sha512-peWelPf6sVsRULQyYhfyu1dMZhwewRszsbQVfbuhNLflh+ncRXn6pe1BRE3NAcSsWd7JMRZAV5RzcuR3R9ZfaQ==} engines: {node: '>=22'} @@ -1682,6 +1811,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + big.js@7.0.1: + resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1906,6 +2038,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csv-parse@7.0.2: + resolution: {integrity: sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==} + dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} @@ -2284,6 +2419,9 @@ packages: forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@2.3.3: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} @@ -2292,6 +2430,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2405,6 +2547,9 @@ packages: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2429,6 +2574,9 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2569,6 +2717,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3038,6 +3189,23 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-html-better-parser@1.5.9: + resolution: {integrity: sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==} + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -3696,6 +3864,9 @@ packages: resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} engines: {node: '>=0.8'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -3708,9 +3879,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -3746,6 +3926,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -3902,9 +4085,19 @@ packages: jsdom: optional: true + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3985,6 +4178,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zeroentropy@0.1.0-alpha.7: + resolution: {integrity: sha512-T17yfhO0pJZ0yj7P9STEn1/b6e+yiPdxRgHuFOjU/vXdivpOv96vmadbfYbKJoBMgoDIo/WmPXxUIcS29/Ui0w==} + zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} @@ -4131,6 +4327,17 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-bedrock-agent-runtime@3.1113.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/client-cognito-identity@3.1075.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4155,6 +4362,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.977.8': + dependencies: + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-cognito-identity@3.972.48': dependencies: '@aws-sdk/nested-clients': 3.997.23 @@ -4171,6 +4389,14 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.51': dependencies: '@aws-sdk/core': 3.974.23 @@ -4181,6 +4407,16 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.56': dependencies: '@aws-sdk/core': 3.974.23 @@ -4197,6 +4433,22 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.55': dependencies: '@aws-sdk/core': 3.974.23 @@ -4206,6 +4458,15 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.58': dependencies: '@aws-sdk/credential-provider-env': 3.972.49 @@ -4220,6 +4481,20 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.80': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.49': dependencies: '@aws-sdk/core': 3.974.23 @@ -4228,6 +4503,14 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.55': dependencies: '@aws-sdk/core': 3.974.23 @@ -4238,6 +4521,16 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.13': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.55': dependencies: '@aws-sdk/core': 3.974.23 @@ -4247,6 +4540,15 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-providers@3.1075.0': dependencies: '@aws-sdk/client-cognito-identity': 3.1075.0 @@ -4280,6 +4582,17 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.43': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.35': dependencies: '@aws-sdk/types': 3.973.13 @@ -4287,6 +4600,13 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.45': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1074.0': dependencies: '@aws-sdk/core': 3.974.23 @@ -4296,11 +4616,25 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/types@3.973.13': dependencies: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/types@3.974.4': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.8': dependencies: tslib: 2.8.1 @@ -4310,8 +4644,15 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.39': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@8.0.0': dependencies: '@babel/helper-validator-identifier': 8.0.4 @@ -4889,6 +5230,18 @@ snapshots: transitivePeerDependencies: - pg-native + '@mastra/rag@2.6.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@aws-sdk/client-bedrock-agent-runtime': 3.1113.0 + '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + big.js: 7.0.1 + js-tiktoken: 1.0.21 + node-html-better-parser: 1.5.9 + zeroentropy: 0.1.0-alpha.7 + zod: 4.4.3 + transitivePeerDependencies: + - encoding + '@mastra/schema-compat@1.3.5(zod@4.4.3)': dependencies: json-schema-to-zod: 2.8.1 @@ -4903,6 +5256,8 @@ snapshots: hono: 4.12.27 zod: 4.4.3 + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) @@ -5263,12 +5618,23 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@smithy/core@3.33.2': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.2': dependencies: '@smithy/core': 3.26.0 '@smithy/types': 4.15.0 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -5280,10 +5646,22 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 + '@smithy/node-http-handler@4.11.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/node-http-handler@4.8.2': dependencies: '@smithy/core': 3.26.0 @@ -5296,10 +5674,20 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@smithy/signature-v4@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/types@4.15.0': dependencies: tslib: 2.8.1 + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -5391,6 +5779,15 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 26.0.1 + form-data: 4.0.6 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 @@ -5410,6 +5807,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 26.0.1 + '@types/turndown@5.0.6': {} + '@types/unist@3.0.3': {} '@types/ws@8.18.1': @@ -5518,13 +5917,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -5577,6 +5976,10 @@ snapshots: transitivePeerDependencies: - supports-color + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ai-sdk-ollama@3.8.8(ai@6.0.209(zod@4.4.3))(zod@4.4.3): dependencies: '@ai-sdk/provider': 3.0.10 @@ -5753,6 +6156,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + big.js@7.0.1: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -5980,6 +6385,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csv-parse@7.0.2: {} + dashdash@1.14.1: dependencies: assert-plus: 1.0.0 @@ -6388,6 +6795,8 @@ snapshots: forever-agent@0.6.1: {} + form-data-encoder@1.7.2: {} + form-data@2.3.3: dependencies: asynckit: 0.4.0 @@ -6402,6 +6811,11 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6510,6 +6924,8 @@ snapshots: hono@4.12.27: {} + html-entities@2.6.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6543,6 +6959,10 @@ snapshots: human-signals@8.0.1: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -6637,6 +7057,10 @@ snapshots: js-base64@3.7.8: {} + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + js-tokens@10.0.0: {} js-yaml@3.14.2: @@ -7286,6 +7710,16 @@ snapshots: negotiator@1.0.0: {} + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-html-better-parser@1.5.9: + dependencies: + html-entities: 2.6.0 + node-releases@2.0.50: {} normalize-path@3.0.0: {} @@ -8071,6 +8505,8 @@ snapshots: psl: 1.15.0 punycode: 2.3.1 + tr46@0.0.3: {} + trough@2.2.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -8079,10 +8515,20 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -8116,6 +8562,8 @@ snapshots: ufo@1.6.4: {} + undici-types@5.26.5: {} + undici-types@8.3.0: {} unicorn-magic@0.3.0: {} @@ -8197,7 +8645,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0): + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8208,15 +8656,16 @@ snapshots: '@types/node': 26.0.1 esbuild: 0.28.1 fsevents: 2.3.3 + tsx: 4.23.12 yaml: 2.9.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -8233,7 +8682,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(yaml@2.9.0) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -8241,8 +8690,17 @@ snapshots: transitivePeerDependencies: - msw + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + whatwg-fetch@3.6.20: {} + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -8308,6 +8766,18 @@ snapshots: yoctocolors@2.1.2: {} + zeroentropy@0.1.0-alpha.7: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 diff --git a/src/mastra/workflows/challenge/challenge-context-workflow.ts b/src/mastra/workflows/challenge/challenge-context-workflow.ts index 2688bda..d88734f 100644 --- a/src/mastra/workflows/challenge/challenge-context-workflow.ts +++ b/src/mastra/workflows/challenge/challenge-context-workflow.ts @@ -1,4 +1,5 @@ import { createWorkflow, createStep } from '@mastra/core/workflows'; +import { noopObserve } from '@mastra/core/tools'; import { z } from 'zod'; import { tcAILogger } from '../../../utils/logger'; import { generateWithStructuredOutputFallback } from '../../../utils/structured-output-wrapper'; @@ -310,7 +311,7 @@ const fetchChallengeDetails = createStep({ const toolResult = await fetchChallengeTool.execute?.( { challengeId: inputData.challengeId }, - { requestContext }, + { requestContext, observe: noopObserve }, ); if (!toolResult || 'error' in toolResult || !toolResult.challenge) { diff --git a/src/mastra/workflows/skills/skill-extraction-workflow.ts b/src/mastra/workflows/skills/skill-extraction-workflow.ts index 0dd048a..cd212bf 100644 --- a/src/mastra/workflows/skills/skill-extraction-workflow.ts +++ b/src/mastra/workflows/skills/skill-extraction-workflow.ts @@ -1,4 +1,5 @@ import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { noopObserve } from '@mastra/core/tools'; import { z } from 'zod'; import { standardizedSkillsFuzzyTool } from '../../tools/skills/standardized-skills-fuzzy-tool'; import { standardizedSkillsSemanticTool } from '../../tools/skills/standardized-skills-semantic-tool'; @@ -99,7 +100,7 @@ const fuzzyMatchTermSkills = createStep({ try { const toolResult = await standardizedSkillsFuzzyTool.execute?.( { term: searchTerm, size: matchSize }, - { requestContext }, + { requestContext, observe: noopObserve }, ); if (!toolResult || 'error' in toolResult || !toolResult.matches) { @@ -143,7 +144,7 @@ const semanticMatchTermSkills = createStep({ try { const toolResult = await standardizedSkillsSemanticTool.execute?.( { text: searchTerm }, - { requestContext }, + { requestContext, observe: noopObserve }, ); if (!toolResult || 'error' in toolResult || !toolResult.matches) { From f376c775c0627852dec32c510639272ddabe3d82 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Wed, 19 Aug 2026 10:45:50 +0300 Subject: [PATCH 02/19] feat: add lazy RAG config and embedding factory (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create src/config/rag.config.ts with lazy getRagConfig() that never throws at module load (D5). Provider/model dimension map: TC-Ollama/nomic-embed-text → 768/2048, AWSBedrock/amazon.titan-embed-text-v2:0 → 1024/8192. All RAG env vars overridable with defaults. VECTOR_INDEX_NAME validated as SQL identifier. type/track as free-form strings (D12). Reuses MASTRA_DB_CONNECTION and MASTRA_DB_SCHEMA. Create src/utils/providers/embedding-factory.ts with createEmbeddingModel() switch mirroring createModel: TC-Ollama → ollama.embedding(), AWSBedrock → createBedrockProvider().embedding(). Unknown provider throws actionable error. Logs via tcAILogger. Re-exported from src/utils/index.ts. 33 unit tests covering all config and factory behaviors (VAL-FOUND-021 through VAL-FOUND-033). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/config/rag.config.test.ts | 247 ++++++++++++++++++ src/config/rag.config.ts | 176 +++++++++++++ src/utils/index.ts | 3 +- src/utils/providers/embedding-factory.test.ts | 170 ++++++++++++ src/utils/providers/embedding-factory.ts | 38 +++ 5 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/config/rag.config.test.ts create mode 100644 src/config/rag.config.ts create mode 100644 src/utils/providers/embedding-factory.test.ts create mode 100644 src/utils/providers/embedding-factory.ts diff --git a/src/config/rag.config.test.ts b/src/config/rag.config.test.ts new file mode 100644 index 0000000..3a7acbc --- /dev/null +++ b/src/config/rag.config.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getRagConfig } from './rag.config'; + +// --------------------------------------------------------------------------- +// Env var keys managed by these tests +// --------------------------------------------------------------------------- +const RAG_ENV_KEYS = [ + 'RAG_EMBEDDING_PROVIDER', + 'RAG_EMBEDDING_MODEL_ID', + 'VECTOR_INDEX_NAME', + 'VECTOR_SEARCH_THRESHOLD', + 'RAG_CHUNK_MAX_SIZE', + 'RAG_CHUNK_OVERLAP', + 'RAG_TOP_K', + 'CHALLENGE_SEARCH_AI_PROVIDER', + 'CHALLENGE_SEARCH_AI_MODEL_ID', + 'MASTRA_DB_CONNECTION', + 'MASTRA_DB_SCHEMA', +]; + +describe('rag.config — getRagConfig', () => { + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of RAG_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of RAG_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + // VAL-FOUND-021: getRagConfig resolves lazily and does not throw at module load + describe('lazy resolution (D5)', () => { + it('does not throw at module load even when all RAG env vars are unset', async () => { + // Re-import the module fresh with no RAG env vars set. + // vi.resetModules is not needed here because the module never throws + // at import time by design — but we verify the import succeeds. + const mod = await import('./rag.config'); + expect(mod.getRagConfig).toBeDefined(); + expect(typeof mod.getRagConfig).toBe('function'); + }); + + it('returns defaults when called with no RAG env vars set', () => { + // With no env vars, defaults should be used (TC-Ollama/nomic-embed-text) + const config = getRagConfig(); + expect(config.embedding.provider).toBe('TC-Ollama'); + expect(config.embedding.modelId).toBe('nomic-embed-text'); + expect(config.embedding.dimension).toBe(768); + expect(config.embedding.maxContextWindow).toBe(2048); + }); + }); + + // VAL-FOUND-022: TC-Ollama/nomic-embed-text → dimension 768, contextWindow 2048 + describe('dimension mapping', () => { + it('maps TC-Ollama/nomic-embed-text to dimension 768 and maxContextWindow 2048', () => { + process.env.RAG_EMBEDDING_PROVIDER = 'TC-Ollama'; + process.env.RAG_EMBEDDING_MODEL_ID = 'nomic-embed-text'; + const config = getRagConfig(); + expect(config.embedding.dimension).toBe(768); + expect(config.embedding.maxContextWindow).toBe(2048); + }); + + // VAL-FOUND-023: AWSBedrock/amazon.titan-embed-text-v2:0 → dimension 1024, contextWindow 8192 + it('maps AWSBedrock/amazon.titan-embed-text-v2:0 to dimension 1024 and maxContextWindow 8192', () => { + process.env.RAG_EMBEDDING_PROVIDER = 'AWSBedrock'; + process.env.RAG_EMBEDDING_MODEL_ID = 'amazon.titan-embed-text-v2:0'; + const config = getRagConfig(); + expect(config.embedding.dimension).toBe(1024); + expect(config.embedding.maxContextWindow).toBe(8192); + }); + + // VAL-FOUND-024: unknown provider/model throws actionable error + it('throws actionable error for unknown provider/model combination', () => { + process.env.RAG_EMBEDDING_PROVIDER = 'TC-Ollama'; + process.env.RAG_EMBEDDING_MODEL_ID = 'unknown-model'; + expect(() => getRagConfig()).toThrow( + /(provider|model|supported|RAG_EMBEDDING)/i, + ); + }); + + it('throws actionable error for unknown provider', () => { + process.env.RAG_EMBEDDING_PROVIDER = 'UnknownProvider'; + process.env.RAG_EMBEDDING_MODEL_ID = 'nomic-embed-text'; + expect(() => getRagConfig()).toThrow( + /(provider|model|supported|RAG_EMBEDDING)/i, + ); + }); + + it('error message names the offending provider and model', () => { + process.env.RAG_EMBEDDING_PROVIDER = 'TC-Ollama'; + process.env.RAG_EMBEDDING_MODEL_ID = 'bad-model'; + expect(() => getRagConfig()).toThrow(/TC-Ollama/); + expect(() => getRagConfig()).toThrow(/bad-model/); + }); + }); + + // VAL-FOUND-025: VECTOR_INDEX_NAME validated as SQL identifier + describe('VECTOR_INDEX_NAME SQL identifier validation', () => { + it('accepts a valid SQL identifier', () => { + process.env.VECTOR_INDEX_NAME = 'challenge_embeddings'; + expect(() => getRagConfig()).not.toThrow(); + const config = getRagConfig(); + expect(config.vectorIndexName).toBe('challenge_embeddings'); + }); + + it('rejects a SQL injection-style string', () => { + process.env.VECTOR_INDEX_NAME = 'challenge; DROP TABLE;'; + expect(() => getRagConfig()).toThrow(); + }); + + it('rejects a digit-prefixed identifier', () => { + process.env.VECTOR_INDEX_NAME = '1bad'; + expect(() => getRagConfig()).toThrow(); + }); + + it('rejects an identifier with spaces', () => { + process.env.VECTOR_INDEX_NAME = 'bad name'; + expect(() => getRagConfig()).toThrow(); + }); + + it('defaults to challenge_embeddings when unset', () => { + const config = getRagConfig(); + expect(config.vectorIndexName).toBe('challenge_embeddings'); + }); + }); + + // VAL-FOUND-026: env-overridable numeric config + describe('env-overridable numeric config', () => { + it('overrides RAG_CHUNK_MAX_SIZE, RAG_CHUNK_OVERLAP, RAG_TOP_K, VECTOR_SEARCH_THRESHOLD', () => { + process.env.RAG_CHUNK_MAX_SIZE = '256'; + process.env.RAG_CHUNK_OVERLAP = '25'; + process.env.RAG_TOP_K = '5'; + process.env.VECTOR_SEARCH_THRESHOLD = '0.75'; + const config = getRagConfig(); + expect(config.chunkMaxSize).toBe(256); + expect(config.chunkOverlap).toBe(25); + expect(config.topK).toBe(5); + expect(config.vectorSearchThreshold).toBe(0.75); + }); + + it('restores defaults when env vars are unset', () => { + const config = getRagConfig(); + expect(config.chunkMaxSize).toBe(512); + expect(config.chunkOverlap).toBe(50); + expect(config.topK).toBe(10); + expect(config.vectorSearchThreshold).toBe(0.5); + }); + + it('throws actionable error for non-numeric RAG_CHUNK_MAX_SIZE', () => { + process.env.RAG_CHUNK_MAX_SIZE = 'not-a-number'; + expect(() => getRagConfig()).toThrow(); + }); + + it('throws actionable error for non-numeric VECTOR_SEARCH_THRESHOLD', () => { + process.env.VECTOR_SEARCH_THRESHOLD = 'not-a-number'; + expect(() => getRagConfig()).toThrow(); + }); + }); + + // VAL-FOUND-027: type and track are free-form strings, not enums + describe('type and track as free-form strings (D12)', () => { + it('documents known types and tracks as informational arrays', () => { + const config = getRagConfig(); + expect(Array.isArray(config.knownTypes)).toBe(true); + expect(Array.isArray(config.knownTracks)).toBe(true); + // Known types include the current reference-table names + expect(config.knownTypes).toContain('Challenge'); + expect(config.knownTypes).toContain('First2Finish'); + // Known tracks include all four ChallengeTrackEnum values + expect(config.knownTracks).toContain('Design'); + expect(config.knownTracks).toContain('Data Science'); + expect(config.knownTracks).toContain('Development'); + expect(config.knownTracks).toContain('Quality Assurance'); + }); + + it('known types and tracks are plain strings, not enum-validated', () => { + const config = getRagConfig(); + for (const t of config.knownTypes) { + expect(typeof t).toBe('string'); + } + for (const t of config.knownTracks) { + expect(typeof t).toBe('string'); + } + }); + }); + + // VAL-FOUND-028: reuses MASTRA_DB_CONNECTION and MASTRA_DB_SCHEMA + describe('database config reuses MASTRA_DB_CONNECTION and MASTRA_DB_SCHEMA', () => { + it('sources connectionString from MASTRA_DB_CONNECTION', () => { + process.env.MASTRA_DB_CONNECTION = 'postgresql://user:pass@localhost/ai-api?schema=agents'; + const config = getRagConfig(); + expect(config.database.connectionString).toBe( + 'postgresql://user:pass@localhost/ai-api?schema=agents', + ); + }); + + it('sources schemaName from MASTRA_DB_SCHEMA', () => { + process.env.MASTRA_DB_SCHEMA = 'agents'; + const config = getRagConfig(); + expect(config.database.schemaName).toBe('agents'); + }); + + it('defaults schemaName to "ai" when MASTRA_DB_SCHEMA is unset', () => { + delete process.env.MASTRA_DB_SCHEMA; + const config = getRagConfig(); + expect(config.database.schemaName).toBe('ai'); + }); + + it('does not introduce separate POSTGRES_* variables', () => { + process.env.MASTRA_DB_CONNECTION = 'postgresql://user:pass@localhost/ai-api?schema=agents'; + process.env.MASTRA_DB_SCHEMA = 'agents'; + const config = getRagConfig(); + // The database config only has connectionString and schemaName + expect(Object.keys(config.database).sort()).toEqual( + ['connectionString', 'schemaName'], + ); + }); + }); + + describe('challenge search AI config', () => { + it('defaults to AWSBedrock/us.anthropic.claude-haiku-4-5', () => { + const config = getRagConfig(); + expect(config.challengeSearchAI.provider).toBe('AWSBedrock'); + expect(config.challengeSearchAI.modelId).toBe( + 'us.anthropic.claude-haiku-4-5', + ); + }); + + it('is env-overridable via CHALLENGE_SEARCH_AI_PROVIDER and CHALLENGE_SEARCH_AI_MODEL_ID', () => { + process.env.CHALLENGE_SEARCH_AI_PROVIDER = 'TC-Ollama'; + process.env.CHALLENGE_SEARCH_AI_MODEL_ID = 'qwen2.5:latest'; + const config = getRagConfig(); + expect(config.challengeSearchAI.provider).toBe('TC-Ollama'); + expect(config.challengeSearchAI.modelId).toBe('qwen2.5:latest'); + }); + }); +}); diff --git a/src/config/rag.config.ts b/src/config/rag.config.ts new file mode 100644 index 0000000..f41a338 --- /dev/null +++ b/src/config/rag.config.ts @@ -0,0 +1,176 @@ +/** + * RAG configuration — resolved lazily (D5). + * + * This module NEVER throws at import time. All validation happens inside + * getRagConfig() on first call. This is critical for server boot and Docker + * build (pnpm test runs in an image with no network/DB/Ollama). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RagConfig { + embedding: { + provider: string; + modelId: string; + dimension: number; + maxContextWindow: number; + }; + vectorIndexName: string; + vectorSearchThreshold: number; + chunkMaxSize: number; + chunkOverlap: number; + topK: number; + challengeSearchAI: { + provider: string; + modelId: string; + }; + database: { + connectionString: string | undefined; + schemaName: string; + }; + /** Informational only — not enforced as enums (D12) */ + knownTypes: string[]; + /** Informational only — not enforced as enums (D12) */ + knownTracks: string[]; +} + +// --------------------------------------------------------------------------- +// Provider/model → { dimension, maxContextWindow } map (D2) +// --------------------------------------------------------------------------- + +const EMBEDDING_MODEL_MAP: Record = { + 'TC-Ollama/nomic-embed-text': { dimension: 768, maxContextWindow: 2048 }, + 'AWSBedrock/amazon.titan-embed-text-v2:0': { dimension: 1024, maxContextWindow: 8192 }, +}; + +// --------------------------------------------------------------------------- +// Known values — documented for readability, NOT enforced as enums (D12) +// --------------------------------------------------------------------------- + +const KNOWN_TYPES = ['Challenge', 'First2Finish', 'Marathon Match', 'Task']; + +const KNOWN_TRACKS = [ + 'Design', + 'Data Science', + 'Development', + 'Quality Assurance', +]; + +// --------------------------------------------------------------------------- +// SQL identifier validation for VECTOR_INDEX_NAME +// --------------------------------------------------------------------------- + +const SQL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function validateSqlIdentifier(name: string, envVar: string): string { + if (!SQL_IDENTIFIER_RE.test(name)) { + throw new Error( + `Invalid ${envVar}="${name}": must be a valid SQL identifier ` + + '(matching ^[A-Za-z_][A-Za-z0-9_]*$). ' + + 'Set VECTOR_INDEX_NAME to a valid SQL identifier.', + ); + } + return name; +} + +// --------------------------------------------------------------------------- +// Numeric env var parsing +// --------------------------------------------------------------------------- + +function parseNumber(value: string | undefined, envVar: string, defaultValue: number): number { + if (value === undefined || value === '') { + return defaultValue; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new Error( + `Invalid ${envVar}="${value}": must be a number. ` + + `Set ${envVar} to a valid numeric value.`, + ); + } + return parsed; +} + +// --------------------------------------------------------------------------- +// getRagConfig — lazy, no module-load throw (D5) +// --------------------------------------------------------------------------- + +export function getRagConfig(): RagConfig { + const embeddingProvider = process.env.RAG_EMBEDDING_PROVIDER || 'TC-Ollama'; + const embeddingModelId = process.env.RAG_EMBEDDING_MODEL_ID || 'nomic-embed-text'; + + const mapKey = `${embeddingProvider}/${embeddingModelId}`; + const modelInfo = EMBEDDING_MODEL_MAP[mapKey]; + if (!modelInfo) { + throw new Error( + `Unsupported embedding provider/model combination: ` + + `RAG_EMBEDDING_PROVIDER="${embeddingProvider}", ` + + `RAG_EMBEDDING_MODEL_ID="${embeddingModelId}". ` + + `Supported combinations: ${Object.keys(EMBEDDING_MODEL_MAP).join(', ')}. ` + + `Set RAG_EMBEDDING_PROVIDER and RAG_EMBEDDING_MODEL_ID to a supported combination.`, + ); + } + + const vectorIndexName = validateSqlIdentifier( + process.env.VECTOR_INDEX_NAME || 'challenge_embeddings', + 'VECTOR_INDEX_NAME', + ); + + const vectorSearchThreshold = parseNumber( + process.env.VECTOR_SEARCH_THRESHOLD, + 'VECTOR_SEARCH_THRESHOLD', + 0.5, + ); + + const chunkMaxSize = parseNumber( + process.env.RAG_CHUNK_MAX_SIZE, + 'RAG_CHUNK_MAX_SIZE', + 512, + ); + + const chunkOverlap = parseNumber( + process.env.RAG_CHUNK_OVERLAP, + 'RAG_CHUNK_OVERLAP', + 50, + ); + + const topK = parseNumber( + process.env.RAG_TOP_K, + 'RAG_TOP_K', + 10, + ); + + const challengeSearchProvider = + process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; + const challengeSearchModelId = + process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5'; + + const connectionString = process.env.MASTRA_DB_CONNECTION; + const schemaName = process.env.MASTRA_DB_SCHEMA || 'ai'; + + return { + embedding: { + provider: embeddingProvider, + modelId: embeddingModelId, + dimension: modelInfo.dimension, + maxContextWindow: modelInfo.maxContextWindow, + }, + vectorIndexName, + vectorSearchThreshold, + chunkMaxSize, + chunkOverlap, + topK, + challengeSearchAI: { + provider: challengeSearchProvider, + modelId: challengeSearchModelId, + }, + database: { + connectionString, + schemaName, + }, + knownTypes: KNOWN_TYPES, + knownTracks: KNOWN_TRACKS, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index c288817..1ef5fce 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,4 +5,5 @@ export * from './logger'; export * from './auth/m2m.service' export * from './providers/wipro'; export * from './providers/bedrock'; -export * from './providers/model-factory'; \ No newline at end of file +export * from './providers/model-factory'; +export * from './providers/embedding-factory'; \ No newline at end of file diff --git a/src/utils/providers/embedding-factory.test.ts b/src/utils/providers/embedding-factory.test.ts new file mode 100644 index 0000000..b703b2d --- /dev/null +++ b/src/utils/providers/embedding-factory.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before importing the module under test +// --------------------------------------------------------------------------- + +// Mock the ollama provider so we can assert ollama.embedding was called +vi.mock('./ollama', () => ({ + ollama: { + embedding: vi.fn(), + }, +})); + +// Mock the bedrock provider factory +vi.mock('./bedrock', () => ({ + createBedrockProvider: vi.fn(), +})); + +// Mock tcAILogger so we can assert logging behavior +vi.mock('../logger', () => ({ + tcAILogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock auth modules so importing src/utils/index.ts does not throw +// (MastraAuthAuth0 requires AUTH0_DOMAIN/AUTH0_AUDIENCE env vars at import) +vi.mock('../auth', () => ({ + apiAuthLayer: undefined, +})); +vi.mock('../auth/m2m.service', () => ({ + M2MService: vi.fn(), +})); +vi.mock('../middleware', () => ({})); +vi.mock('../structured-output-wrapper', () => ({})); +// Mock other providers re-exported from index.ts that may throw without env vars +vi.mock('./wipro', () => ({ + wipro: { chatModel: vi.fn() }, +})); +vi.mock('./openai', () => ({ + openai: vi.fn(), +})); +vi.mock('./model-factory', () => ({ + createModel: vi.fn(), +})); + +// Import after mocks are set up +import { createEmbeddingModel } from './embedding-factory'; +import { ollama } from './ollama'; +import { createBedrockProvider } from './bedrock'; +import { tcAILogger } from '../logger'; + +describe('embedding-factory — createEmbeddingModel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // VAL-FOUND-029: routes TC-Ollama to ollama.embedding + describe('TC-Ollama routing', () => { + it('calls ollama.embedding with the model id', () => { + const mockModel = { modelId: 'nomic-embed-text', doEmbed: vi.fn() }; + vi.mocked(ollama.embedding).mockReturnValue(mockModel as any); + + const result = createEmbeddingModel('TC-Ollama', 'nomic-embed-text'); + + expect(ollama.embedding).toHaveBeenCalledWith('nomic-embed-text'); + expect(result).toBe(mockModel); + }); + + it('returns an object usable with embed/embedMany (has doEmbed)', () => { + const mockModel = { modelId: 'nomic-embed-text', doEmbed: vi.fn() }; + vi.mocked(ollama.embedding).mockReturnValue(mockModel as any); + + const result = createEmbeddingModel('TC-Ollama', 'nomic-embed-text'); + + expect(result).toBeDefined(); + expect(typeof (result as any).doEmbed).toBe('function'); + }); + }); + + // VAL-FOUND-030: routes AWSBedrock to createBedrockProvider().embedding() + describe('AWSBedrock routing', () => { + it('calls createBedrockProvider().embedding with the model id', () => { + const mockEmbedding = vi.fn().mockReturnValue({ + modelId: 'amazon.titan-embed-text-v2:0', + doEmbed: vi.fn(), + }); + const mockProvider = { embedding: mockEmbedding }; + vi.mocked(createBedrockProvider).mockReturnValue(mockProvider as any); + + const result = createEmbeddingModel( + 'AWSBedrock', + 'amazon.titan-embed-text-v2:0', + ); + + expect(createBedrockProvider).toHaveBeenCalled(); + expect(mockEmbedding).toHaveBeenCalledWith( + 'amazon.titan-embed-text-v2:0', + ); + expect(result).toBeDefined(); + expect(typeof (result as any).doEmbed).toBe('function'); + }); + }); + + // VAL-FOUND-031: throws actionable error for unknown provider + describe('unknown provider', () => { + it('throws an error naming the provider and pointing at remediation', () => { + expect(() => createEmbeddingModel('UnknownProvider', 'any-model')).toThrow( + /(provider|supported|RAG_EMBEDDING)/i, + ); + }); + + it('error message contains the offending provider name', () => { + expect(() => + createEmbeddingModel('UnknownProvider', 'any-model'), + ).toThrow(/UnknownProvider/); + }); + }); + + // VAL-FOUND-032: logs via tcAILogger, not console + describe('logging', () => { + it('logs via tcAILogger.info with provider and model id', () => { + vi.mocked(ollama.embedding).mockReturnValue({ + modelId: 'nomic-embed-text', + doEmbed: vi.fn(), + } as any); + + createEmbeddingModel('TC-Ollama', 'nomic-embed-text'); + + expect(tcAILogger.info).toHaveBeenCalled(); + const logCall = vi.mocked(tcAILogger.info).mock.calls[0][0]; + expect(logCall).toMatch(/TC-Ollama/); + expect(logCall).toMatch(/nomic-embed-text/); + }); + + it('does not call console.log', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + vi.mocked(ollama.embedding).mockReturnValue({ + modelId: 'nomic-embed-text', + doEmbed: vi.fn(), + } as any); + + createEmbeddingModel('TC-Ollama', 'nomic-embed-text'); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('logs error via tcAILogger.error for unknown provider', () => { + expect(() => + createEmbeddingModel('BadProvider', 'any-model'), + ).toThrow(); + + expect(tcAILogger.error).toHaveBeenCalled(); + }); + }); +}); + +// VAL-FOUND-033: re-exported from src/utils/index.ts +describe('embedding-factory re-export', () => { + it('is importable from src/utils/index.ts', async () => { + const mod = await import('../index'); + expect(mod.createEmbeddingModel).toBeDefined(); + expect(typeof mod.createEmbeddingModel).toBe('function'); + }); +}); diff --git a/src/utils/providers/embedding-factory.ts b/src/utils/providers/embedding-factory.ts new file mode 100644 index 0000000..3421a27 --- /dev/null +++ b/src/utils/providers/embedding-factory.ts @@ -0,0 +1,38 @@ +import { ollama } from './ollama'; +import { createBedrockProvider } from './bedrock'; +import { tcAILogger } from '../logger'; + +/** + * Creates an embedding model instance for the given provider, mirroring the + * createModel pattern. Uses ollama.embedding() for TC-Ollama and + * createBedrockProvider().embedding() for AWSBedrock. + * + * @param provider - Provider name (e.g. 'TC-Ollama', 'AWSBedrock') + * @param modelId - Embedding model ID (e.g. 'nomic-embed-text') + * @returns An AI SDK v6 embedding model usable with embed/embedMany + */ +export function createEmbeddingModel(provider: string, modelId: string) { + tcAILogger.info( + `[Embedding Factory] PROVIDER: ${provider}, MODEL: ${modelId}`, + ); + + switch (provider) { + case 'TC-Ollama': + return ollama.embedding(modelId); + + case 'AWSBedrock': + return createBedrockProvider().embedding(modelId); + + default: + tcAILogger.error( + `[Embedding Factory] Unsupported embedding provider: ${provider}. ` + + `Supported providers: TC-Ollama, AWSBedrock. ` + + `Set RAG_EMBEDDING_PROVIDER to a supported value.`, + ); + throw new Error( + `Unsupported embedding provider: ${provider}. ` + + `Supported providers: TC-Ollama, AWSBedrock. ` + + `Set RAG_EMBEDDING_PROVIDER to a supported value.`, + ); + } +} From 759f5b3f20480ad40d81f167080b2a0f7ebe1167 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 20 Aug 2026 08:15:43 +0300 Subject: [PATCH 03/19] feat: add challenge vector store with lazy singleton and dimension guard Create src/mastra/vector/challenge-vector-store.ts with: - getChallengeVectorStore(): lazy PgVector singleton (created on first call, not at module load) constructed with id, connectionString, and schemaName from MASTRA_DB_CONNECTION/MASTRA_DB_SCHEMA - ensureChallengeIndex(): idempotently creates HNSW index with cosine metric and metadataIndexes for challengeId, projectId, track - D7 dimension guard: compares describeIndex().dimension against configured model dimension, throws actionable error on mismatch - No disconnect() on request paths (singleton persists for process lifetime) - 29 unit tests with mocked PgVector covering all 6 validation assertions Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../vector/challenge-vector-store.test.ts | 435 ++++++++++++++++++ src/mastra/vector/challenge-vector-store.ts | 115 +++++ 2 files changed, 550 insertions(+) create mode 100644 src/mastra/vector/challenge-vector-store.test.ts create mode 100644 src/mastra/vector/challenge-vector-store.ts diff --git a/src/mastra/vector/challenge-vector-store.test.ts b/src/mastra/vector/challenge-vector-store.test.ts new file mode 100644 index 0000000..2b57466 --- /dev/null +++ b/src/mastra/vector/challenge-vector-store.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before importing the module under test +// --------------------------------------------------------------------------- + +// Use vi.hoisted so mock function references survive vi.mock hoisting +const mocks = vi.hoisted(() => { + return { + createIndex: vi.fn(), + describeIndex: vi.fn(), + disconnect: vi.fn(), + }; +}); + +// Mock PgVector from @mastra/pg — must use a regular function (not arrow) +// so `new PgVector(...)` works as a constructor +vi.mock('@mastra/pg', () => { + return { + PgVector: vi.fn().mockImplementation(function () { + return { + createIndex: mocks.createIndex, + describeIndex: mocks.describeIndex, + disconnect: mocks.disconnect, + }; + }), + }; +}); + +// Mock rag.config so we can control dimension/vectorIndexName +vi.mock('../../config/rag.config', () => ({ + getRagConfig: vi.fn(), +})); + +// Mock logger +vi.mock('../../utils/logger', () => ({ + tcAILogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +// Import after mocks are set up +import { PgVector } from '@mastra/pg'; +import { getRagConfig } from '../../config/rag.config'; +import { + getChallengeVectorStore, + ensureChallengeIndex, + _testing, +} from './challenge-vector-store'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockConfig(overrides: Partial<{ + dimension: number; + vectorIndexName: string; + provider: string; + modelId: string; +}> = {}) { + vi.mocked(getRagConfig).mockReturnValue({ + embedding: { + provider: overrides.provider || 'TC-Ollama', + modelId: overrides.modelId || 'nomic-embed-text', + dimension: overrides.dimension ?? 768, + maxContextWindow: 2048, + }, + vectorIndexName: overrides.vectorIndexName || 'challenge_embeddings', + vectorSearchThreshold: 0.5, + chunkMaxSize: 512, + chunkOverlap: 50, + topK: 10, + challengeSearchAI: { provider: 'AWSBedrock', modelId: 'haiku' }, + database: { connectionString: 'test-conn', schemaName: 'ai' }, + knownTypes: [], + knownTracks: [], + } as any); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('challenge-vector-store', () => { + let savedDbConnection: string | undefined; + let savedDbSchema: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + _testing.resetSingleton(); + mockConfig(); + + savedDbConnection = process.env.MASTRA_DB_CONNECTION; + savedDbSchema = process.env.MASTRA_DB_SCHEMA; + process.env.MASTRA_DB_CONNECTION = 'postgres://test:5432/db'; + process.env.MASTRA_DB_SCHEMA = 'agents'; + }); + + afterEach(() => { + _testing.resetSingleton(); + if (savedDbConnection === undefined) { + delete process.env.MASTRA_DB_CONNECTION; + } else { + process.env.MASTRA_DB_CONNECTION = savedDbConnection; + } + if (savedDbSchema === undefined) { + delete process.env.MASTRA_DB_SCHEMA; + } else { + process.env.MASTRA_DB_SCHEMA = savedDbSchema; + } + }); + + // VAL-FOUND-034: getChallengeVectorStore is a lazy singleton + describe('getChallengeVectorStore — lazy singleton', () => { + it('returns the same PgVector instance on repeated calls', () => { + const store1 = getChallengeVectorStore(); + const store2 = getChallengeVectorStore(); + expect(store1).toBe(store2); + }); + + it('constructs PgVector at most once across multiple calls', () => { + getChallengeVectorStore(); + getChallengeVectorStore(); + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledTimes(1); + }); + + it('does not construct PgVector at module import time', () => { + // After resetSingleton, no construction should have occurred yet. + // The module was already imported at the top of this file. + // If construction happened at import, PgVector would have been + // called before any test ran. + _testing.resetSingleton(); + vi.mocked(PgVector).mockClear(); + // Verify zero calls immediately after reset (no construction) + expect(PgVector).not.toHaveBeenCalled(); + // Construction happens only when the function is called + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledTimes(1); + }); + }); + + // VAL-FOUND-035: PgVector constructed with id, connectionString, schemaName + describe('PgVector constructor arguments', () => { + it('constructs with id "tc-ai-api-rag-vector"', () => { + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'tc-ai-api-rag-vector', + }), + ); + }); + + it('constructs with connectionString from MASTRA_DB_CONNECTION', () => { + process.env.MASTRA_DB_CONNECTION = 'postgres://user:pass@host:5432/mydb'; + _testing.resetSingleton(); + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: 'postgres://user:pass@host:5432/mydb', + }), + ); + }); + + it('constructs with schemaName from MASTRA_DB_SCHEMA', () => { + process.env.MASTRA_DB_SCHEMA = 'agents'; + _testing.resetSingleton(); + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'agents', + }), + ); + }); + + it('defaults schemaName to "ai" when MASTRA_DB_SCHEMA is unset', () => { + delete process.env.MASTRA_DB_SCHEMA; + _testing.resetSingleton(); + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'ai', + }), + ); + }); + + it('passes all three fields in a single constructor call', () => { + process.env.MASTRA_DB_CONNECTION = 'postgres://localhost/db'; + process.env.MASTRA_DB_SCHEMA = 'myschema'; + _testing.resetSingleton(); + getChallengeVectorStore(); + expect(PgVector).toHaveBeenCalledWith({ + id: 'tc-ai-api-rag-vector', + connectionString: 'postgres://localhost/db', + schemaName: 'myschema', + }); + }); + }); + + // VAL-FOUND-036: ensureChallengeIndex creates HNSW index with cosine metric + describe('ensureChallengeIndex — HNSW + cosine', () => { + it('calls createIndex with metric: "cosine"', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledWith( + expect.objectContaining({ + metric: 'cosine', + }), + ); + }); + + it('calls createIndex with indexConfig: { type: "hnsw" }', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledWith( + expect.objectContaining({ + indexConfig: { type: 'hnsw' }, + }), + ); + }); + + it('calls createIndex with the configured indexName and dimension', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + mockConfig({ dimension: 768, vectorIndexName: 'challenge_embeddings' }); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledWith( + expect.objectContaining({ + indexName: 'challenge_embeddings', + dimension: 768, + }), + ); + }); + + it('is idempotent — second call does not call createIndex', async () => { + // First call: index exists with correct dimension → skip create + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + expect(mocks.createIndex).toHaveBeenCalledTimes(0); + + // Second call: still exists with correct dimension → still skip + await ensureChallengeIndex(); + expect(mocks.createIndex).toHaveBeenCalledTimes(0); + }); + + it('does not throw on second call', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await expect(ensureChallengeIndex()).resolves.not.toThrow(); + await expect(ensureChallengeIndex()).resolves.not.toThrow(); + }); + + it('creates index when it does not exist (dimension 0)', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledTimes(1); + }); + + it('creates index when describeIndex throws (table missing)', async () => { + mocks.describeIndex.mockRejectedValue(new Error('relation does not exist')); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledTimes(1); + }); + + it('returns the PgVector instance', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + const store = getChallengeVectorStore(); + const result = await ensureChallengeIndex(); + expect(result).toBe(store); + }); + }); + + // VAL-FOUND-037: metadataIndexes for challengeId, projectId, track + describe('ensureChallengeIndex — metadataIndexes', () => { + it('passes metadataIndexes with exactly challengeId, projectId, track', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + expect(mocks.createIndex).toHaveBeenCalledWith( + expect.objectContaining({ + metadataIndexes: ['challengeId', 'projectId', 'track'], + }), + ); + }); + + it('does not include other fields in metadataIndexes', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + + const callArgs = mocks.createIndex.mock.calls[0][0] as any; + expect(callArgs.metadataIndexes).toEqual( + ['challengeId', 'projectId', 'track'], + ); + expect(callArgs.metadataIndexes).toHaveLength(3); + }); + }); + + // VAL-FOUND-038: dimension guard + describe('ensureChallengeIndex — dimension guard (D7)', () => { + it('throws actionable error on dimension mismatch', async () => { + // Index exists with 768, config says 1024 + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024 }); + + await expect(ensureChallengeIndex()).rejects.toThrow( + /(VECTOR_INDEX_NAME|reindex|dimension)/i, + ); + }); + + it('error message names both dimensions', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024 }); + + try { + await ensureChallengeIndex(); + expect.fail('Should have thrown'); + } catch (e: any) { + expect(e.message).toContain('768'); + expect(e.message).toContain('1024'); + } + }); + + it('error message points at VECTOR_INDEX_NAME or reindex as remediation', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024 }); + + try { + await ensureChallengeIndex(); + expect.fail('Should have thrown'); + } catch (e: any) { + expect(e.message).toMatch(/VECTOR_INDEX_NAME|reindex/i); + } + }); + + it('does not call createIndex on dimension mismatch', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024 }); + + try { + await ensureChallengeIndex(); + } catch { + // expected + } + expect(mocks.createIndex).not.toHaveBeenCalled(); + }); + + it('does not throw when dimensions match', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 768 }); + + await expect(ensureChallengeIndex()).resolves.not.toThrow(); + }); + + it('does not call createIndex when dimensions match', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 768 }); + + await ensureChallengeIndex(); + expect(mocks.createIndex).not.toHaveBeenCalled(); + }); + + it('names the index in the error message', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024, vectorIndexName: 'challenge_embeddings' }); + + try { + await ensureChallengeIndex(); + expect.fail('Should have thrown'); + } catch (e: any) { + expect(e.message).toContain('challenge_embeddings'); + } + }); + }); + + // VAL-FOUND-039: no disconnect on request paths + describe('no disconnect on request paths', () => { + it('does not call disconnect during getChallengeVectorStore', () => { + getChallengeVectorStore(); + expect(mocks.disconnect).not.toHaveBeenCalled(); + }); + + it('does not call disconnect during ensureChallengeIndex (create path)', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 0, count: 0 }); + mocks.createIndex.mockResolvedValue(undefined); + + await ensureChallengeIndex(); + expect(mocks.disconnect).not.toHaveBeenCalled(); + }); + + it('does not call disconnect during ensureChallengeIndex (idempotent path)', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 0 }); + + await ensureChallengeIndex(); + expect(mocks.disconnect).not.toHaveBeenCalled(); + }); + + it('does not call disconnect during dimension guard failure', async () => { + mocks.describeIndex.mockResolvedValue({ dimension: 768, count: 100 }); + mockConfig({ dimension: 1024 }); + + try { + await ensureChallengeIndex(); + } catch { + // expected + } + expect(mocks.disconnect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/mastra/vector/challenge-vector-store.ts b/src/mastra/vector/challenge-vector-store.ts new file mode 100644 index 0000000..843aff8 --- /dev/null +++ b/src/mastra/vector/challenge-vector-store.ts @@ -0,0 +1,115 @@ +/** + * Challenge vector store — lazy PgVector singleton with dimension guard (D7). + * + * This module NEVER constructs PgVector at import time. The singleton is created + * on first call to getChallengeVectorStore(), so that missing + * MASTRA_DB_CONNECTION does not prevent server boot or Docker build (D5). + * + * No disconnect() is ever called on request paths — the singleton persists + * for the lifetime of the process. + */ + +import { PgVector } from '@mastra/pg'; +import { getRagConfig } from '../../config/rag.config'; +import { tcAILogger } from '../../utils/logger'; + +// --------------------------------------------------------------------------- +// Lazy singleton (created on first call, not at module load) +// --------------------------------------------------------------------------- + +let vectorStore: PgVector | null = null; + +/** + * Returns the shared PgVector instance (lazy singleton). + * + * The instance is created on first call, not at module load, so that + * missing MASTRA_DB_CONNECTION does not prevent server boot or Docker build. + * + * No disconnect() is ever called on request paths — the singleton persists + * for the lifetime of the process. + */ +export function getChallengeVectorStore(): PgVector { + if (vectorStore === null) { + vectorStore = new PgVector({ + id: 'tc-ai-api-rag-vector', + connectionString: process.env.MASTRA_DB_CONNECTION!, + schemaName: process.env.MASTRA_DB_SCHEMA || 'ai', + }); + tcAILogger.info('[challenge-vector-store] Created PgVector singleton'); + } + return vectorStore; +} + +/** + * Idempotently ensures the challenge vector index exists with the correct + * dimension. Enforces the D7 dimension guard: if the index already exists + * with a different dimension than the configured embedding model, throws + * an actionable error naming both dimensions and pointing at + * VECTOR_INDEX_NAME/reindex as remediation. + * + * @returns The shared PgVector instance + */ +export async function ensureChallengeIndex(): Promise { + const config = getRagConfig(); + const store = getChallengeVectorStore(); + const indexName = config.vectorIndexName; + const configuredDimension = config.embedding.dimension; + + // Check if the index already exists + let existingDimension: number | null = null; + try { + const indexInfo = await store.describeIndex({ indexName }); + if (indexInfo.dimension > 0) { + existingDimension = indexInfo.dimension; + } + } catch { + // Index/table doesn't exist — describeIndex threw + } + + if (existingDimension !== null) { + // Index exists — D7 dimension guard + if (existingDimension !== configuredDimension) { + throw new Error( + `Dimension mismatch: vector index "${indexName}" has ` + + `dimension ${existingDimension}, but the configured embedding ` + + `model (${config.embedding.provider}/${config.embedding.modelId}) ` + + `requires dimension ${configuredDimension}. ` + + `Set VECTOR_INDEX_NAME to use a new index name, or reindex ` + + `the existing index to match the configured model dimension.`, + ); + } + // Index exists with correct dimension — idempotent, nothing to do + tcAILogger.info( + `[challenge-vector-store] Index "${indexName}" already exists ` + + `with correct dimension ${configuredDimension}`, + ); + return store; + } + + // Index doesn't exist — create it with HNSW, cosine, and metadata indexes + tcAILogger.info( + `[challenge-vector-store] Creating index "${indexName}" ` + + `with dimension ${configuredDimension}`, + ); + + await store.createIndex({ + indexName, + dimension: configuredDimension, + metric: 'cosine', + indexConfig: { type: 'hnsw' }, + metadataIndexes: ['challengeId', 'projectId', 'track'], + }); + + return store; +} + +// --------------------------------------------------------------------------- +// Testing exports — NOT for production use +// --------------------------------------------------------------------------- + +export const _testing = { + /** Resets the singleton — for unit tests only */ + resetSingleton(): void { + vectorStore = null; + }, +}; From 5cc0d40b0a7038e53d973a3df956c4701b826e1b Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 20 Aug 2026 10:00:56 +0300 Subject: [PATCH 04/19] feat: add pure RAG library (content, chunking, ingestion utils) with unit tests Ports the prototype's pure library code into src/mastra/rag/ (ADR 0001 Phase 3): - content.ts: normalizeLineEndings, BOM-aware trim, stripFrontmatter, htmlToMarkdown (Turndown, atx headings + fenced code), parseSkills, generic enrichChunksWithChallengeName, processDescription (normalize -> trim -> htmlToMarkdown -> stripFrontmatter, in that order) - chunking.ts: chunkChallengeDescription as a pure two-pass function (markdown header pass with stripHeaders: false, then size-based pass). Code blocks and tables stay atomic while under floor(contextWindow * 0.97) tokens; oversized ones are force-split at safeCharLimit = contextWindow * 3 (6144), NOT maxSize (512). Returns { chunks, forceSplits }. - ingestion-utils.ts: withRetry with LINEAR backoff (delay * attempt), sleep, REQUIRED_COLUMNS, validateColumns, validateRecord (id/name/description only), generateDeterministicId (SHA-256 rendered UUID-shaped) - types.ts: ChallengeRecord, ChunkingOptions derived from RagConfig, ChallengeChunk, ForceSplitRecord, ChunkMetadata (text field, not content; projectId as string; new groups/ingestedAt fields), IngestionReport, IngestOptions 89 new unit tests colocated as src/mastra/rag/*.test.ts. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/mastra/rag/chunking.test.ts | 340 +++++++++++++++++++++++++ src/mastra/rag/chunking.ts | 142 +++++++++++ src/mastra/rag/content.test.ts | 257 +++++++++++++++++++ src/mastra/rag/content.ts | 130 ++++++++++ src/mastra/rag/ingestion-utils.test.ts | 237 +++++++++++++++++ src/mastra/rag/ingestion-utils.ts | 103 ++++++++ src/mastra/rag/types.ts | 147 +++++++++++ 7 files changed, 1356 insertions(+) create mode 100644 src/mastra/rag/chunking.test.ts create mode 100644 src/mastra/rag/chunking.ts create mode 100644 src/mastra/rag/content.test.ts create mode 100644 src/mastra/rag/content.ts create mode 100644 src/mastra/rag/ingestion-utils.test.ts create mode 100644 src/mastra/rag/ingestion-utils.ts create mode 100644 src/mastra/rag/types.ts diff --git a/src/mastra/rag/chunking.test.ts b/src/mastra/rag/chunking.test.ts new file mode 100644 index 0000000..6158b98 --- /dev/null +++ b/src/mastra/rag/chunking.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getEncoding } from 'js-tiktoken'; +import { chunkChallengeDescription } from './chunking'; +import type { ChunkingOptions } from './types'; + +const enc = getEncoding('cl100k_base'); + +const OPTIONS: ChunkingOptions = { maxSize: 512, overlap: 50, contextWindow: 2048 }; +const SAFE_CHAR_LIMIT = OPTIONS.contextWindow * 3; // 6144 +const MAX_SAFE_TOKENS = Math.floor(OPTIONS.contextWindow * 0.97); // 1986 + +function buildProse(repetitions: number): string { + return 'The quick brown fox jumps over the lazy dog near the riverbank. '.repeat(repetitions); +} + +function buildCodeBlock(lines: number): string { + const line = "const longVariableName = 'payload value here';\n"; + return `# Setup\n\n\`\`\`js\n${line.repeat(lines)}\`\`\`\n`; +} + +function buildTable(rows: number): string { + let table = '# Metrics\n\n| col a | col b | col c |\n| --- | --- | --- |\n'; + for (let i = 0; i < rows; i++) { + table += `| value ${i} aaaaaaaaaaaa | value ${i} bbbbbbbbbbbb | value ${i} cccccccccccc |\n`; + } + return table; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +describe('chunking — chunkChallengeDescription', () => { + // VAL-FOUND-020: returns { chunks, forceSplits } + describe('return shape', () => { + it('returns exactly the chunks and forceSplits keys', async () => { + const result = await chunkChallengeDescription('a short description', OPTIONS); + expect(Object.keys(result).sort()).toEqual(['chunks', 'forceSplits']); + expect(Array.isArray(result.chunks)).toBe(true); + expect(Array.isArray(result.forceSplits)).toBe(true); + }); + + it('gives every chunk a string-valued text property', async () => { + const { chunks } = await chunkChallengeDescription(buildProse(32), OPTIONS); + expect(chunks.length).toBeGreaterThan(0); + for (const chunk of chunks) { + expect(typeof chunk.text).toBe('string'); + } + }); + + it('returns no chunks for empty content', async () => { + const result = await chunkChallengeDescription('', OPTIONS); + expect(result.chunks).toEqual([]); + expect(result.forceSplits).toEqual([]); + }); + }); + + // VAL-FOUND-011: small chunks pass through unchanged + describe('small chunk passthrough', () => { + it('returns a single unchanged chunk for content at or below maxSize', async () => { + const content = buildProse(4).slice(0, 200); + expect(content.length).toBe(200); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks).toHaveLength(1); + expect(chunks[0].text).toContain(content); + expect(chunks[0].text).toBe(content); + expect(forceSplits).toHaveLength(0); + }); + + it('does not split a header-bearing chunk that is under maxSize', async () => { + const content = '# Overview\n\nA compact challenge description that fits in one chunk.'; + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks).toHaveLength(1); + expect(chunks[0].text).toContain('# Overview'); + expect(chunks[0].text).toContain('A compact challenge description that fits in one chunk.'); + expect(forceSplits).toHaveLength(0); + }); + }); + + // VAL-FOUND-012: oversized pure text is recursively split with maxSize and overlap + describe('oversized pure text', () => { + it('splits prose longer than maxSize into chunks bounded by maxSize', async () => { + const content = buildProse(32); + expect(content.length).toBeGreaterThan(2000); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(OPTIONS.maxSize); + } + expect(forceSplits).toHaveLength(0); + }); + + it('carries overlap between consecutive chunks', async () => { + const content = buildProse(32); + const { chunks } = await chunkChallengeDescription(content, OPTIONS); + + const totalChunkChars = chunks.reduce((sum, c) => sum + c.text.length, 0); + // Overlapping windows repeat context, so the chunks together exceed the source length. + expect(totalChunkChars).toBeGreaterThan(content.length); + }); + + it('honours a smaller maxSize', async () => { + const { chunks } = await chunkChallengeDescription(buildProse(32), { + ...OPTIONS, + maxSize: 200, + }); + for (const chunk of chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(200); + } + }); + + it('propagates header metadata onto recursive sub-chunks', async () => { + const content = `# Requirements\n\n${buildProse(32)}`; + const { chunks } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.metadata?.title).toBe('Requirements'); + } + }); + }); + + // VAL-FOUND-013 / VAL-FOUND-014: atomicity within the token safety limit + describe('atomic code blocks and tables', () => { + it('keeps an oversized-but-token-safe code block in a single chunk', async () => { + const content = buildCodeBlock(13); + expect(content.length).toBeGreaterThan(OPTIONS.maxSize); + expect(enc.encode(content).length).toBeLessThan(MAX_SAFE_TOKENS); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks).toHaveLength(1); + expect(chunks[0].text).toContain('```js'); + expect(countOccurrences(chunks[0].text, '```')).toBe(2); + expect(countOccurrences(chunks[0].text, 'const longVariableName')).toBe(13); + expect(forceSplits).toHaveLength(0); + }); + + it('keeps an oversized-but-token-safe table in a single chunk', async () => { + const content = buildTable(10); + expect(content.length).toBeGreaterThan(OPTIONS.maxSize); + expect(enc.encode(content).length).toBeLessThan(MAX_SAFE_TOKENS); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(chunks).toHaveLength(1); + // One "cccccccccccc |" terminator per table row — all 10 rows intact. + expect(countOccurrences(chunks[0].text, 'cccccccccccc |')).toBe(10); + expect(chunks[0].text).toContain('| value 0 aaaaaaaaaaaa'); + expect(chunks[0].text).toContain('| value 9 aaaaaaaaaaaa'); + expect(forceSplits).toHaveLength(0); + }); + }); + + // VAL-FOUND-015 / VAL-FOUND-016: force-split at safeCharLimit, not maxSize + describe('force-splitting oversized atomic blocks', () => { + it('force-splits an over-token code block at safeCharLimit, not maxSize', async () => { + const content = buildCodeBlock(210); + expect(enc.encode(content).length).toBeGreaterThan(MAX_SAFE_TOKENS); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(forceSplits.length).toBeGreaterThanOrEqual(1); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(SAFE_CHAR_LIMIT); + } + // A chunk far larger than maxSize proves safeCharLimit (6144) was used, not 512. + expect(Math.max(...chunks.map((c) => c.text.length))).toBeGreaterThan(OPTIONS.maxSize); + expect(Math.max(...chunks.map((c) => c.text.length))).toBeGreaterThan(SAFE_CHAR_LIMIT / 2); + }); + + it('force-splits an over-token table at safeCharLimit, not maxSize', async () => { + const content = buildTable(200); + expect(enc.encode(content).length).toBeGreaterThan(MAX_SAFE_TOKENS); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(forceSplits.length).toBeGreaterThanOrEqual(1); + for (const chunk of chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(SAFE_CHAR_LIMIT); + } + expect(Math.max(...chunks.map((c) => c.text.length))).toBeGreaterThan(SAFE_CHAR_LIMIT / 2); + }); + + // VAL-FOUND-015: force-splits are tracked + it('records each force-split with identifying information', async () => { + const content = buildCodeBlock(210); + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(forceSplits).toHaveLength(1); + const record = forceSplits[0]; + expect(record.chunkIndex).toBe(0); + expect(record.originalTokens).toBeGreaterThan(MAX_SAFE_TOKENS); + expect(record.resultingChunks).toBe(chunks.length); + expect(record.reason).toBe('code-block'); + }); + + it('labels a force-split table with the table reason', async () => { + const { forceSplits } = await chunkChallengeDescription(buildTable(200), OPTIONS); + expect(forceSplits[0].reason).toBe('table'); + }); + + it('propagates header metadata onto force-split sub-chunks', async () => { + const { chunks } = await chunkChallengeDescription(buildCodeBlock(210), OPTIONS); + for (const chunk of chunks) { + expect(chunk.metadata?.title).toBe('Setup'); + } + }); + }); + + // VAL-FOUND-017: markdown header pass splits on # and ## with stripHeaders: false + describe('markdown header pass', () => { + it('splits on # and ## boundaries and retains the header text', async () => { + const { chunks } = await chunkChallengeDescription( + '# A\npara1\n## B\npara2\n## C\npara3', + OPTIONS, + ); + + expect(chunks).toHaveLength(3); + const texts = chunks.map((c) => c.text); + expect(texts.some((t) => t.includes('# A'))).toBe(true); + expect(texts.some((t) => t.includes('## B'))).toBe(true); + expect(texts.some((t) => t.includes('## C'))).toBe(true); + + // No chunk spans two distinct header sections. + for (const text of texts) { + expect(countOccurrences(text, '## ')).toBeLessThanOrEqual(1); + } + expect(texts.find((t) => t.includes('## B'))).not.toContain('para3'); + expect(texts.find((t) => t.includes('## C'))).not.toContain('para2'); + }); + + it('exposes the header hierarchy as chunk metadata', async () => { + const { chunks } = await chunkChallengeDescription( + '# A\npara1\n## B\npara2\n## C\npara3', + OPTIONS, + ); + expect(chunks.map((c) => c.metadata)).toEqual([ + { title: 'A' }, + { title: 'A', section: 'B' }, + { title: 'A', section: 'C' }, + ]); + }); + }); + + // VAL-FOUND-018: cl100k_base with floor(contextWindow * 0.97) threshold + describe('token safety threshold', () => { + it('does not force-split content just below floor(contextWindow * 0.97)', async () => { + const content = buildCodeBlock(185); + const tokens = enc.encode(content).length; + expect(tokens).toBeLessThan(MAX_SAFE_TOKENS); + + const { chunks, forceSplits } = await chunkChallengeDescription(content, OPTIONS); + + expect(forceSplits).toHaveLength(0); + expect(chunks).toHaveLength(1); + }); + + it('force-splits content just above floor(contextWindow * 0.97)', async () => { + const content = buildCodeBlock(210); + expect(enc.encode(content).length).toBeGreaterThan(MAX_SAFE_TOKENS); + + const { forceSplits } = await chunkChallengeDescription(content, OPTIONS); + expect(forceSplits).toHaveLength(1); + }); + + it('scales the threshold with contextWindow (8192 → 7946 tokens)', async () => { + const content = buildCodeBlock(210); + const tokens = enc.encode(content).length; + expect(tokens).toBeGreaterThan(Math.floor(2048 * 0.97)); + expect(tokens).toBeLessThan(Math.floor(8192 * 0.97)); + + // Same content, larger context window: no force-split, block stays atomic. + const wide = await chunkChallengeDescription(content, { + ...OPTIONS, + contextWindow: 8192, + }); + expect(wide.forceSplits).toHaveLength(0); + expect(wide.chunks).toHaveLength(1); + + // Narrow context window: force-split triggers. + const narrow = await chunkChallengeDescription(content, OPTIONS); + expect(narrow.forceSplits).toHaveLength(1); + }); + + it('uses the contextWindow to derive safeCharLimit for force-splits', async () => { + const content = buildTable(200); + const { chunks } = await chunkChallengeDescription(content, { + ...OPTIONS, + contextWindow: 1024, + }); + // safeCharLimit = 1024 * 3 = 3072 + for (const chunk of chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(3072); + } + expect(Math.max(...chunks.map((c) => c.text.length))).toBeGreaterThan(3072 / 2); + }); + }); + + // VAL-FOUND-019: pure — no I/O, no console, deterministic + describe('purity', () => { + it('writes nothing to the console', async () => { + const swallow = () => undefined; + const log = vi.spyOn(console, 'log').mockImplementation(swallow); + const error = vi.spyOn(console, 'error').mockImplementation(swallow); + const warn = vi.spyOn(console, 'warn').mockImplementation(swallow); + const info = vi.spyOn(console, 'info').mockImplementation(swallow); + const debug = vi.spyOn(console, 'debug').mockImplementation(swallow); + + await chunkChallengeDescription(buildCodeBlock(210), OPTIONS); + await chunkChallengeDescription(buildProse(32), OPTIONS); + + expect(log).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(debug).not.toHaveBeenCalled(); + }); + + it('is deterministic for identical inputs', async () => { + const content = `# Intro\n\n${buildProse(32)}\n\n${buildCodeBlock(210)}`; + const first = await chunkChallengeDescription(content, OPTIONS); + const second = await chunkChallengeDescription(content, OPTIONS); + expect(first).toEqual(second); + }); + + it('does not mutate the input string', async () => { + const content = buildProse(32); + const copy = `${content}`; + await chunkChallengeDescription(content, OPTIONS); + expect(content).toBe(copy); + }); + }); +}); diff --git a/src/mastra/rag/chunking.ts b/src/mastra/rag/chunking.ts new file mode 100644 index 0000000..22a18b5 --- /dev/null +++ b/src/mastra/rag/chunking.ts @@ -0,0 +1,142 @@ +/** + * Two-pass chunking for challenge descriptions. + * + * Pass 1 splits on Markdown headers (semantic boundaries). Pass 2 sizes each + * resulting chunk, keeping code blocks and tables atomic unless they exceed the + * embedding model's token budget. + * + * chunkChallengeDescription is pure: no network, filesystem or console access, + * and deterministic for a given input. + */ + +import { MDocument } from '@mastra/rag'; +import { getEncoding } from 'js-tiktoken'; +import type { + ChallengeChunk, + ChunkingOptions, + ChunkingResult, + ForceSplitReason, +} from './types'; + +/** Fenced code block delimited by ``` or ~~~ */ +const CODE_BLOCK_RE = /```[^\n]*\n[\s\S]*?\n```|~~~[^\n]*\n[\s\S]*?\n~~~/m; + +/** A line that starts (with optional indent) with | and contains a further | */ +const TABLE_RE = /^\s*\|.*\|/m; + +type Encoding = ReturnType; + +let encoding: Encoding | null = null; + +/** Lazily loads the cl100k_base encoding — the standard for modern embeddings. */ +function getTokenEncoding(): Encoding { + if (encoding === null) { + encoding = getEncoding('cl100k_base'); + } + return encoding; +} + +function toChallengeChunk( + chunk: { text: string; metadata?: Record }, + parentMetadata?: Record, +): ChallengeChunk { + return { + text: chunk.text, + metadata: { ...chunk.metadata, ...parentMetadata }, + }; +} + +/** + * Chunks a processed challenge description. + * + * @param content - Markdown content, already run through processDescription + * @param options - maxSize / overlap / contextWindow, normally from getRagConfig() + * @returns The final chunks plus a record of any atomic block that had to be force-split + */ +export async function chunkChallengeDescription( + content: string, + options: ChunkingOptions, +): Promise { + const { maxSize, overlap, contextWindow } = options; + + const chunks: ChallengeChunk[] = []; + const forceSplits: ChunkingResult['forceSplits'] = []; + + if (content.length === 0) { + return { chunks, forceSplits }; + } + + // 3% margin absorbs tokenizer differences and characters the embedding API adds. + const maxSafeTokens = Math.floor(contextWindow * 0.97); + // ~4 chars per token, so 3x the context window stays well under the token + // ceiling while keeping force-split fragments large enough to stay useful. + // This is deliberately NOT maxSize. + const safeCharLimit = contextWindow * 3; + + // Pass 1 — split on header boundaries, keeping the headers for semantic context. + const initialChunks = await MDocument.fromMarkdown(content).chunk({ + strategy: 'markdown', + headers: [ + ['#', 'title'], + ['##', 'section'], + ], + stripHeaders: false, + }); + + // Pass 2 — size each header chunk. + for (let chunkIndex = 0; chunkIndex < initialChunks.length; chunkIndex++) { + const chunk = initialChunks[chunkIndex]; + const parentMetadata = chunk.metadata as Record | undefined; + + if (chunk.text.length <= maxSize) { + chunks.push(toChallengeChunk(chunk)); + continue; + } + + const hasCodeBlock = CODE_BLOCK_RE.test(chunk.text); + const hasTable = TABLE_RE.test(chunk.text); + + if (hasCodeBlock || hasTable) { + const originalTokens = getTokenEncoding().encode(chunk.text).length; + + if (originalTokens <= maxSafeTokens) { + // Atomic content that still fits the model — keep it intact even + // though it is longer than maxSize. + chunks.push(toChallengeChunk(chunk)); + continue; + } + + const forced = await MDocument.fromText(chunk.text).chunk({ + strategy: 'recursive', + maxSize: safeCharLimit, + overlap: 0, + }); + + const reason: ForceSplitReason = hasCodeBlock ? 'code-block' : 'table'; + forceSplits.push({ + chunkIndex, + originalTokens, + resultingChunks: forced.length, + reason, + }); + + for (const miniChunk of forced) { + chunks.push(toChallengeChunk(miniChunk, parentMetadata)); + } + continue; + } + + // Pure text above maxSize — split recursively for retrieval precision. + const textChunks = await MDocument.fromText(chunk.text).chunk({ + strategy: 'recursive', + maxSize, + overlap, + }); + + for (const miniChunk of textChunks) { + chunks.push(toChallengeChunk(miniChunk, parentMetadata)); + } + } + + return { chunks, forceSplits }; +} diff --git a/src/mastra/rag/content.test.ts b/src/mastra/rag/content.test.ts new file mode 100644 index 0000000..d779551 --- /dev/null +++ b/src/mastra/rag/content.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from 'vitest'; +import { + enrichChunksWithChallengeName, + htmlToMarkdown, + normalizeLineEndings, + parseSkills, + processDescription, + stripFrontmatter, + trim, +} from './content'; + +describe('content — normalizeLineEndings', () => { + // VAL-FOUND-001: converts CRLF to LF + it('converts every CRLF sequence to a single LF', () => { + const result = normalizeLineEndings('line1\r\nline2\r\n'); + expect(result).toBe('line1\nline2\n'); + expect(result).not.toContain('\r\n'); + }); + + // VAL-FOUND-002: converts lone CR to LF + it('converts lone CR (old Mac endings) to LF', () => { + const result = normalizeLineEndings('line1\rline2\r'); + expect(result).toBe('line1\nline2\n'); + expect(result).not.toContain('\r'); + }); + + it('handles mixed CRLF, lone CR, and LF without doubling newlines', () => { + const result = normalizeLineEndings('a\r\nb\rc\nd'); + expect(result).toBe('a\nb\nc\nd'); + expect(result).not.toContain('\r'); + }); + + it('leaves LF-only content unchanged', () => { + expect(normalizeLineEndings('a\nb\n')).toBe('a\nb\n'); + }); +}); + +describe('content — trim', () => { + // VAL-FOUND-003: BOM-aware trim + it('strips a leading BOM together with surrounding whitespace', () => { + expect(trim('\uFEFF hello world \n')).toBe('hello world'); + }); + + it('returns an empty string for BOM-only input', () => { + expect(trim('\uFEFF')).toBe(''); + }); + + it('strips trailing BOM characters', () => { + expect(trim('hello\uFEFF')).toBe('hello'); + }); + + it('strips newlines, tabs and carriage returns from both ends', () => { + expect(trim('\n\t\r hello \r\t\n')).toBe('hello'); + }); + + it('preserves interior BOM and whitespace', () => { + expect(trim(' a\uFEFFb ')).toBe('a\uFEFFb'); + }); + + it('returns an empty string for whitespace-only input', () => { + expect(trim(' \n\t ')).toBe(''); + }); + + it('leaves already-trimmed content unchanged', () => { + expect(trim('hello')).toBe('hello'); + }); +}); + +describe('content — stripFrontmatter', () => { + // VAL-FOUND-004: removes a leading YAML frontmatter block + it('removes the frontmatter block including both delimiters', () => { + const result = stripFrontmatter('---\ntitle: x\ntags: [a]\n---\n# Body'); + expect(result).toBe('# Body'); + expect(result).not.toContain('---'); + expect(result).not.toContain('title:'); + }); + + // VAL-FOUND-005: returns original when no frontmatter present + it('returns the input verbatim when there is no frontmatter', () => { + const input = '# Heading\nbody'; + expect(stripFrontmatter(input)).toBe(input); + }); + + it('returns the input verbatim when the frontmatter is not at position 0', () => { + const input = 'preamble\n---\ntitle: x\n---\nbody'; + expect(stripFrontmatter(input)).toBe(input); + }); + + it('returns the input verbatim when the closing delimiter is missing', () => { + const input = '---\ntitle: x\nbody without close'; + expect(stripFrontmatter(input)).toBe(input); + }); +}); + +describe('content — htmlToMarkdown', () => { + // VAL-FOUND-006: ATX headings and fenced code blocks + it('produces ATX headings and fenced code blocks', () => { + const result = htmlToMarkdown('

Title

code
'); + expect(result).toContain('# Title'); + expect(result).toContain('```'); + // Not Setext headings + expect(result).not.toContain('Title\n='); + // Not indented code blocks + expect(result).not.toMatch(/^ {4}code/m); + }); + + it('converts h2 headings to ## and drops HTML tags', () => { + const result = htmlToMarkdown('

A

B

text

'); + expect(result).toContain('# A'); + expect(result).toContain('## B'); + expect(result).not.toContain('

'); + expect(result).not.toContain('

'); + }); + + it('preserves the body of a fenced code block verbatim', () => { + const result = htmlToMarkdown('

const a = 1;\nconst b = 2;
'); + expect(result).toContain('const a = 1;\nconst b = 2;'); + }); +}); + +describe('content — parseSkills', () => { + // VAL-FOUND-007: splits, trims, deduplicates, filters empty + it('splits, trims, deduplicates and filters empty segments', () => { + expect(parseSkills('React, React , , TypeScript,')).toEqual(['React', 'TypeScript']); + }); + + it('returns an empty array for an empty string', () => { + expect(parseSkills('')).toEqual([]); + }); + + it('returns an empty array for whitespace-only input', () => { + expect(parseSkills(' ')).toEqual([]); + }); + + it('returns an empty array for a comma-only string', () => { + expect(parseSkills(',,,')).toEqual([]); + }); + + it('preserves first-seen order of distinct skills', () => { + expect(parseSkills('Node.js, React, Node.js, Go')).toEqual(['Node.js', 'React', 'Go']); + }); +}); + +describe('content — enrichChunksWithChallengeName', () => { + // VAL-FOUND-008: prepends "# Challenge: \n\n" to each chunk + it('prepends the challenge header and preserves other fields', () => { + const result = enrichChunksWithChallengeName([{ text: 'body', chunkIndex: 1 }], 'Demo'); + expect(result[0].text).toBe('# Challenge: Demo\n\nbody'); + expect(result[0].chunkIndex).toBe(1); + }); + + it('prefixes every chunk, not just the first', () => { + const result = enrichChunksWithChallengeName( + [{ text: 'one' }, { text: 'two' }, { text: 'three' }], + 'Demo', + ); + expect(result).toHaveLength(3); + for (const chunk of result) { + expect(chunk.text.startsWith('# Challenge: Demo\n\n')).toBe(true); + } + }); + + it('preserves chunk metadata unchanged', () => { + const metadata = { title: 'A', section: 'B' }; + const result = enrichChunksWithChallengeName([{ text: 'body', metadata }], 'Demo'); + expect(result[0].metadata).toEqual({ title: 'A', section: 'B' }); + }); + + it('does not mutate the input chunks', () => { + const input = [{ text: 'body' }]; + enrichChunksWithChallengeName(input, 'Demo'); + expect(input[0].text).toBe('body'); + }); + + it('returns an empty array for no chunks', () => { + expect(enrichChunksWithChallengeName([], 'Demo')).toEqual([]); + }); +}); + +describe('content — processDescription', () => { + // VAL-FOUND-009: normalize → trim → htmlToMarkdown → stripFrontmatter, in order + it('normalizes, trims, converts HTML and strips frontmatter for the html path', () => { + const description = + '\uFEFF

Title

\r\n

Section

\r\n

Body text

\r\n' + + '
const a = 1;
\r\n'; + + const result = processDescription(description, 'html'); + + // (a) LF-only line endings + expect(result).not.toContain('\r'); + // (b) no BOM + expect(result).not.toContain('\uFEFF'); + // (c) HTML converted to Markdown + expect(result).toContain('# Title'); + expect(result).toContain('## Section'); + expect(result).toContain('```'); + expect(result).not.toContain('

'); + expect(result).not.toContain('

'); + // (d) no leading YAML frontmatter block survives + expect(result).not.toMatch(/^---\n[\s\S]*?\n---\n/); + expect(result.startsWith('---')).toBe(false); + // trimmed + expect(result).toBe(result.trim()); + }); + + it('strips frontmatter produced after conversion, not before it', () => { + // Turndown escapes a bare "---" line, so a frontmatter block present in the + // RAW HTML is no longer a leading "---\n" delimiter once converted. Its YAML + // body therefore survives on the html path — direct evidence that + // stripFrontmatter runs on the converted Markdown, not on the raw input. + const html = '---\ntitle: raw\n---\n

Body

'; + const htmlResult = processDescription(html, 'html'); + expect(htmlResult).toContain('title: raw'); + expect(htmlResult).toContain('# Body'); + + // The same input on the plain path IS a leading frontmatter block and is stripped. + const plainResult = processDescription(html, 'markdown'); + expect(plainResult).toBe('

Body

'); + }); + + it('strips a frontmatter block that leads the converted markdown', () => { + const markdown = '---\ntitle: x\ntags: [a]\n---\n# Body'; + expect(processDescription(markdown, 'markdown')).toBe('# Body'); + }); + + // VAL-FOUND-010: plain-text path skips htmlToMarkdown + it('does not convert HTML when the format is not html', () => { + const description = '\uFEFF---\ntitle: x\n---\nHello bold\r\nsecond line '; + const result = processDescription(description, 'markdown'); + + // htmlToMarkdown NOT applied — the tag passes through verbatim + expect(result).toContain('bold'); + // normalize / trim / stripFrontmatter still applied + expect(result).not.toContain('\r'); + expect(result).not.toContain('\uFEFF'); + expect(result).not.toContain('title: x'); + expect(result).toBe('Hello bold\nsecond line'); + }); + + it('skips HTML conversion for the text format', () => { + expect(processDescription('plain text', 'text')).toBe('plain text'); + }); + + it('skips HTML conversion when the format is omitted', () => { + expect(processDescription('plain text')).toBe('plain text'); + }); + + it('treats the format as case-insensitive for the html path', () => { + const result = processDescription('

Title

', 'HTML'); + expect(result).toContain('# Title'); + expect(result).not.toContain('

'); + }); + + it('returns an empty string for whitespace-only input', () => { + expect(processDescription(' \r\n\t ', 'html')).toBe(''); + }); +}); diff --git a/src/mastra/rag/content.ts b/src/mastra/rag/content.ts new file mode 100644 index 0000000..344087d --- /dev/null +++ b/src/mastra/rag/content.ts @@ -0,0 +1,130 @@ +/** + * Content processing for the challenge RAG pipeline. + * + * Pure functions only — no I/O, no logging. Handles line-ending normalization, + * BOM-aware trimming, YAML frontmatter stripping, HTML→Markdown conversion, + * skills parsing and chunk enrichment. + */ + +import TurndownService from 'turndown'; + +const turndown = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', +}); + +const BOM = '\uFEFF'; + +/** + * Normalizes all line endings to Unix-style \n, handling Windows (\r\n) and + * old Mac (lone \r). + */ +export function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/g, '\n'); +} + +/** + * BOM-aware trim. Removes leading and trailing spaces, newlines, tabs, + * carriage returns and BOM characters. + * + * String.prototype.trim does not strip \uFEFF in all runtimes, so the scan is + * done by hand. + */ +export function trim(content: string): string { + const isTrimChar = (ch: string) => + ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r' || ch === BOM; + + let start = 0; + let end = content.length - 1; + + while (start <= end && isTrimChar(content[start])) { + start++; + } + + while (end >= start && isTrimChar(content[end])) { + end--; + } + + return start > end ? '' : content.substring(start, end + 1); +} + +/** + * Strips a leading YAML frontmatter block. The block must start at position 0. + * Returns the input verbatim when no well-formed frontmatter is present. + */ +export function stripFrontmatter(content: string): string { + if (!content.startsWith('---\n')) { + return content; + } + + const endIndex = content.indexOf('\n---\n', 4); + if (endIndex === -1) { + return content; + } + + return content.slice(endIndex + 5); +} + +/** + * Converts HTML to Markdown, preserving structure (ATX headings, fenced code + * blocks, lists) while dropping layout noise. + */ +export function htmlToMarkdown(html: string): string { + return turndown.turndown(html); +} + +/** + * Parses a comma-separated skills string into a trimmed, deduplicated list + * with empty entries removed. First-seen order is preserved. + */ +export function parseSkills(skillsString: string): string[] { + if (!skillsString || !skillsString.trim()) { + return []; + } + + return Array.from( + new Set( + skillsString + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ), + ); +} + +/** + * Prepends `# Challenge: ` as a header to every chunk's text. + * + * Called AFTER chunking so the header becomes part of the embedded text and of + * the deterministic vector id hash input. + */ +export function enrichChunksWithChallengeName( + chunks: T[], + challengeName: string, +): T[] { + const header = `# Challenge: ${challengeName}\n\n`; + return chunks.map((chunk) => ({ ...chunk, text: header + chunk.text })); +} + +/** + * Runs a raw challenge description through the full content pipeline: + * normalize line endings → BOM-aware trim → HTML→Markdown (html format only) + * → strip frontmatter. + * + * The order is significant: frontmatter is stripped from the CONVERTED + * Markdown, not from the raw HTML. + * + * @param description - Raw description + * @param format - Description format; only 'html' (case-insensitive) triggers conversion + */ +export function processDescription(description: string, format?: string): string { + let content = normalizeLineEndings(description); + + content = trim(content); + + if (format?.toLowerCase() === 'html') { + content = htmlToMarkdown(content); + } + + return stripFrontmatter(content); +} diff --git a/src/mastra/rag/ingestion-utils.test.ts b/src/mastra/rag/ingestion-utils.test.ts new file mode 100644 index 0000000..1b97d1d --- /dev/null +++ b/src/mastra/rag/ingestion-utils.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + generateDeterministicId, + REQUIRED_COLUMNS, + sleep, + validateColumns, + validateRecord, + withRetry, +} from './ingestion-utils'; +import type { ChallengeRecord } from './types'; + +/** + * Replaces setTimeout with a synchronous stub that records the requested delay, + * so backoff timings can be asserted without real waiting. + */ +function captureSleepDelays(): number[] { + const delays: number[] = []; + vi.spyOn(globalThis, 'setTimeout').mockImplementation((( + callback: () => void, + ms?: number, + ) => { + delays.push(ms ?? 0); + callback(); + return 0 as unknown as ReturnType; + }) as unknown as typeof setTimeout); + return delays; +} + +describe('ingestion-utils — sleep', () => { + it('resolves after the requested delay', async () => { + const delays = captureSleepDelays(); + await sleep(1234); + expect(delays).toEqual([1234]); + }); + + it('actually waits when timers are real', async () => { + const start = Date.now(); + await sleep(10); + expect(Date.now() - start).toBeGreaterThanOrEqual(5); + }); +}); + +describe('ingestion-utils — withRetry', () => { + it('returns the result without sleeping when the first attempt succeeds', async () => { + const delays = captureSleepDelays(); + const fn = vi.fn().mockResolvedValue('ok'); + + await expect(withRetry(fn)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + expect(delays).toEqual([]); + }); + + // VAL-INGEST-067 / VAL-FOUND: linear backoff (delay * attempt), not exponential + it('uses linear backoff delays of delay * attempt', async () => { + const delays = captureSleepDelays(); + const fn = vi.fn().mockRejectedValue(new Error('boom')); + + await expect(withRetry(fn, 4, 1000)).rejects.toThrow('boom'); + + // Linear: 1s, 2s, 3s. Exponential would have produced 1s, 2s, 4s. + expect(delays).toEqual([1000, 2000, 3000]); + expect(delays).not.toEqual([1000, 2000, 4000]); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('sleeps delay * attempt for the default maxRetries of 3', async () => { + const delays = captureSleepDelays(); + const fn = vi.fn().mockRejectedValue(new Error('boom')); + + await expect(withRetry(fn)).rejects.toThrow('boom'); + expect(delays).toEqual([1000, 2000]); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('scales the linear backoff with a custom base delay', async () => { + const delays = captureSleepDelays(); + const fn = vi.fn().mockRejectedValue(new Error('boom')); + + await expect(withRetry(fn, 4, 250)).rejects.toThrow('boom'); + expect(delays).toEqual([250, 500, 750]); + }); + + it('succeeds on a later attempt after a transient failure', async () => { + const delays = captureSleepDelays(); + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValue('recovered'); + + await expect(withRetry(fn)).resolves.toBe('recovered'); + expect(fn).toHaveBeenCalledTimes(2); + expect(delays).toEqual([1000]); + }); + + it('rethrows the error from the final attempt', async () => { + captureSleepDelays(); + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('first')) + .mockRejectedValueOnce(new Error('second')) + .mockRejectedValueOnce(new Error('third')); + + await expect(withRetry(fn)).rejects.toThrow('third'); + }); + + it('does not retry when maxRetries is 1', async () => { + const delays = captureSleepDelays(); + const fn = vi.fn().mockRejectedValue(new Error('boom')); + + await expect(withRetry(fn, 1)).rejects.toThrow('boom'); + expect(fn).toHaveBeenCalledTimes(1); + expect(delays).toEqual([]); + }); +}); + +describe('ingestion-utils — REQUIRED_COLUMNS / validateColumns', () => { + it('declares the seven required CSV columns', () => { + expect(REQUIRED_COLUMNS).toEqual([ + 'id', + 'name', + 'description', + 'descriptionFormat', + 'typeName', + 'trackName', + 'skills', + ]); + }); + + it('returns no missing columns when all required headers are present', () => { + expect(validateColumns([...REQUIRED_COLUMNS])).toEqual([]); + }); + + it('ignores extra headers', () => { + expect(validateColumns([...REQUIRED_COLUMNS, 'projectId', 'groups'])).toEqual([]); + }); + + it('reports every missing column', () => { + expect(validateColumns(['id', 'name', 'description'])).toEqual([ + 'descriptionFormat', + 'typeName', + 'trackName', + 'skills', + ]); + }); + + it('reports all required columns for an empty header list', () => { + expect(validateColumns([])).toEqual([...REQUIRED_COLUMNS]); + }); +}); + +describe('ingestion-utils — validateRecord', () => { + const base: ChallengeRecord = { + id: 'c-1', + name: 'Demo challenge', + description: 'Some description', + }; + + // VAL-INGEST-068: checks only id, name, description + it('accepts a record carrying only id, name and description', () => { + expect(validateRecord(base)).toBeNull(); + }); + + it('does not reject a record missing descriptionFormat, typeName, trackName or skills', () => { + const record: ChallengeRecord = { ...base }; + delete record.descriptionFormat; + delete record.typeName; + delete record.trackName; + delete record.skills; + expect(validateRecord(record)).toBeNull(); + }); + + it('rejects a missing id', () => { + expect(validateRecord({ ...base, id: '' })).toBe('Missing id'); + }); + + it('rejects a whitespace-only id', () => { + expect(validateRecord({ ...base, id: ' ' })).toBe('Missing id'); + }); + + it('rejects a missing name', () => { + expect(validateRecord({ ...base, name: '' })).toBe('Missing name'); + }); + + it('rejects an empty description', () => { + expect(validateRecord({ ...base, description: '' })).toBe('Empty description'); + }); + + it('rejects a whitespace-only description', () => { + expect(validateRecord({ ...base, description: ' \n ' })).toBe('Empty description'); + }); + + it('accepts records with unknown type and track values (D12)', () => { + expect( + validateRecord({ + ...base, + typeName: 'Brand New Type', + trackName: 'Emerging Track', + }), + ).toBeNull(); + }); +}); + +describe('ingestion-utils — generateDeterministicId', () => { + const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + + // VAL-INGEST-069: UUID-shaped SHA-256 hash, deterministic + it('returns a UUID-shaped string', () => { + expect(generateDeterministicId('challenge-1-chunk text')).toMatch(UUID_SHAPE); + }); + + it('produces identical output for identical input', () => { + const a = generateDeterministicId('challenge-1-chunk text'); + const b = generateDeterministicId('challenge-1-chunk text'); + expect(a).toBe(b); + }); + + it('produces different output for different input', () => { + expect(generateDeterministicId('a')).not.toBe(generateDeterministicId('b')); + }); + + it('is a stable SHA-256 digest rendered as 8-4-4-4-12', () => { + // sha256('abc') = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + expect(generateDeterministicId('abc')).toBe('ba7816bf-8f01-cfea-4141-40de5dae2223'); + }); + + it('handles empty input deterministically', () => { + expect(generateDeterministicId('')).toMatch(UUID_SHAPE); + expect(generateDeterministicId('')).toBe(generateDeterministicId('')); + }); + + it('distinguishes the same chunk text across different challenge ids', () => { + const chunk = '# Challenge: Demo\n\nbody'; + expect(generateDeterministicId(`c-1-${chunk}`)).not.toBe( + generateDeterministicId(`c-2-${chunk}`), + ); + }); +}); diff --git a/src/mastra/rag/ingestion-utils.ts b/src/mastra/rag/ingestion-utils.ts new file mode 100644 index 0000000..162ef00 --- /dev/null +++ b/src/mastra/rag/ingestion-utils.ts @@ -0,0 +1,103 @@ +/** + * Shared ingestion utilities: retry, CSV/record validation and deterministic + * vector id generation. + * + * Pure functions plus timer-based sleep — no network, filesystem or console use. + */ + +import { createHash } from 'node:crypto'; +import type { ChallengeRecord } from './types'; + +// --------------------------------------------------------------------------- +// Retry +// --------------------------------------------------------------------------- + +/** Resolves after the given number of milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retries an async operation with LINEAR backoff (`delay * attempt`: + * 1s, 2s, 3s), not exponential. + * + * @param fn - Operation to retry + * @param maxRetries - Total number of attempts (default 3) + * @param delay - Base delay in ms (default 1000) + * @throws The error thrown by the final attempt + */ +export async function withRetry( + fn: () => Promise, + maxRetries = 3, + delay = 1000, +): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries) { + throw error; + } + await sleep(delay * attempt); + } + } + // Unreachable: the loop either returns or throws. + throw new Error('withRetry: exhausted attempts without a result'); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** Required CSV columns for ingestion. */ +export const REQUIRED_COLUMNS = [ + 'id', + 'name', + 'description', + 'descriptionFormat', + 'typeName', + 'trackName', + 'skills', +]; + +/** + * Returns the required columns missing from the supplied CSV headers. + */ +export function validateColumns(headers: string[]): string[] { + return REQUIRED_COLUMNS.filter((col) => !headers.includes(col)); +} + +/** + * Validates a challenge record. Only `id`, `name` and `description` are + * required — `type`, `track`, `descriptionFormat` and `skills` are optional + * and never rejected (D12). + * + * @returns An error message, or null when the record is valid + */ +export function validateRecord(record: ChallengeRecord): string | null { + if (!record.id?.trim()) return 'Missing id'; + if (!record.name?.trim()) return 'Missing name'; + if (!record.description?.trim()) return 'Empty description'; + return null; +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/** + * Generates a deterministic, UUID-shaped (8-4-4-4-12) id from a SHA-256 digest + * of the content, so identical content always maps to the same vector id. + * + * @param content - String to hash (e.g. `${challengeId}-${chunkText}`) + */ +export function generateDeterministicId(content: string): string { + const hash = createHash('sha256').update(content).digest('hex'); + return [ + hash.substring(0, 8), + hash.substring(8, 12), + hash.substring(12, 16), + hash.substring(16, 20), + hash.substring(20, 32), + ].join('-'); +} diff --git a/src/mastra/rag/types.ts b/src/mastra/rag/types.ts new file mode 100644 index 0000000..4b1fe99 --- /dev/null +++ b/src/mastra/rag/types.ts @@ -0,0 +1,147 @@ +/** + * Shared types for the challenge RAG pipeline. + * + * Numeric/dimensional types are derived from the lazily-resolved RagConfig + * (see getRagConfig in src/config/rag.config.ts) so that the two never drift. + */ + +import type { RagConfig } from '../../config/rag.config'; + +// --------------------------------------------------------------------------- +// Challenge classification (D12) +// --------------------------------------------------------------------------- + +/** + * Free-form challenge type. Known values are documented in + * `getRagConfig().knownTypes` for readability but are never enforced (D12). + */ +export type ChallengeType = string; + +/** + * Free-form challenge track. Known values are documented in + * `getRagConfig().knownTracks` for readability but are never enforced (D12). + */ +export type ChallengeTrack = string; + +// --------------------------------------------------------------------------- +// Source records +// --------------------------------------------------------------------------- + +/** + * A challenge as supplied to the ingestion pipeline — either a CSV row or an + * inline record. Only `id`, `name` and `description` are required + * (see validateRecord). + */ +export interface ChallengeRecord { + id: string; + name: string; + description: string; + descriptionFormat?: string; + typeName?: ChallengeType; + trackName?: ChallengeTrack; + /** Comma-separated in CSV, array when supplied inline */ + skills?: string | string[]; + projectId?: string | number | null; + groups?: string[]; +} + +// --------------------------------------------------------------------------- +// Chunking +// --------------------------------------------------------------------------- + +/** Chunking parameters, derived from the resolved RagConfig. */ +export interface ChunkingOptions { + maxSize: RagConfig['chunkMaxSize']; + overlap: RagConfig['chunkOverlap']; + contextWindow: RagConfig['embedding']['maxContextWindow']; +} + +/** A chunk of challenge content. The text field is named `text`, not `content`. */ +export interface ChallengeChunk { + text: string; + metadata?: Record; +} + +/** Reason an atomic block had to be force-split. */ +export type ForceSplitReason = 'code-block' | 'table'; + +/** Record of an atomic block that exceeded the token limit and was force-split. */ +export interface ForceSplitRecord { + /** Zero-based index of the header-pass chunk that was force-split */ + chunkIndex: number; + originalTokens: number; + resultingChunks: number; + reason: ForceSplitReason; +} + +/** Result of chunkChallengeDescription. */ +export interface ChunkingResult { + chunks: ChallengeChunk[]; + forceSplits: ForceSplitRecord[]; +} + +// --------------------------------------------------------------------------- +// Vector metadata +// --------------------------------------------------------------------------- + +/** + * Metadata stored alongside each vector embedding. + * + * `projectId` is a string (or null) because @mastra/pg compares metadata + * scalars as text — a numeric value would silently fail to match a filter. + */ +export interface ChunkMetadata { + challengeId: string; + name: string; + type: ChallengeType; + track: ChallengeTrack; + skills: string[]; + groups: string[]; + projectId: string | null; + /** 1-based */ + chunkIndex: number; + totalChunks: number; + /** Chunk text including the `# Challenge: ` header */ + text: string; + /** ISO-8601 */ + ingestedAt: string; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +/** A force-split attributed to the challenge record it came from. */ +export interface ReportedForceSplit extends ForceSplitRecord { + recordId: string; +} + +/** Structure of the CLI ingestion report (report.json). */ +export interface IngestionReport { + startTime: string; + endTime: string; + totals: { + files: number; + records: number; + chunks: number; + errors: number; + forceSplits: number; + }; + files: Record< + string, + { + records: number; + chunks: number; + errors: { recordId: string; message: string; stack?: string }[]; + forceSplits: ReportedForceSplit[]; + } + >; +} + +/** CLI options for the ingestion script. */ +export interface IngestOptions { + folder?: string; + file?: string; + dryRun: boolean; + clearAll: boolean; +} From 64aacd4c7a335627b05c117126c3c4a817a1ca69 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 20 Aug 2026 10:34:26 +0300 Subject: [PATCH 05/19] feat: add projectId and groups to fetchChallengeTool output schema Additively adds projectId: z.number().optional() and groups: z.array(z.string()).optional() to the fetchChallengeTool output schema and mapping function. projectId from the Challenge API is Int? (nullable); null/absent values become undefined via ?? undefined. The challenge-context-workflow uses z.any() for the challenge object so it is unaffected. The app-version: 2.0.0 header is preserved unchanged. 15 unit tests verify: - projectId present when API returns a number - projectId omitted when API returns null or is absent - groups present when API returns an array - groups omitted when API does not include them - both fields passed through in mapping - output schema includes and preserves both fields - existing fields, headers, error handling unchanged Fulfills VAL-INGEST-053 through VAL-INGEST-058. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../challenge/fetch-challenge-tool.test.ts | 335 ++++++++++++++++++ .../tools/challenge/fetch-challenge-tool.ts | 4 + 2 files changed, 339 insertions(+) create mode 100644 src/mastra/tools/challenge/fetch-challenge-tool.test.ts diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.test.ts b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts new file mode 100644 index 0000000..b65b0dd --- /dev/null +++ b/src/mastra/tools/challenge/fetch-challenge-tool.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { m2mTokenMock } = vi.hoisted(() => ({ + m2mTokenMock: vi.fn(), +})); + +vi.mock('../../../utils/auth/m2m.service', () => ({ + M2MService: class MockM2MService { + getM2MToken = m2mTokenMock; + }, +})); + +import { fetchChallengeTool } from './fetch-challenge-tool'; + +// Minimal context for execute — the tool only uses context.mastra?.getLogger?.() +// which is optional, so undefined mastra is safe. +const minimalContext = { mastra: undefined } as any; + +// Valid UUID v4 (zod 4's .uuid() rejects version-0 UUIDs) +const CHALLENGE_UUID = '550e8400-e29b-41d4-a716-446655440000'; + +/** + * Installs a global fetch spy that resolves with the given JSON body. + * Returns the spy so tests can assert call arguments (URL, headers). + */ +function mockFetchResponse(data: Record) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => data, + } as Response); +} + +/** + * Installs a global fetch spy that resolves with a non-2xx status. + */ +function mockFetchError(status: number) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status, + json: async () => ({}), + } as Response); +} + +/** + * Base API response with all existing fields populated. + * Override individual fields via `overrides`. + */ +function baseApiResponse(overrides: Record = {}) { + return { + id: 'challenge-1', + name: 'Test Challenge', + description: 'A test description', + privateDescription: 'secret', + descriptionFormat: 'markdown', + status: 'ACTIVE', + track: { name: 'Development' }, + type: { name: 'Challenge' }, + tags: ['tag1', 'tag2'], + skills: [{ id: 's1', name: 'React' }], + numOfRegistrants: 5, + numOfSubmissions: 2, + ...overrides, + }; +} + +/** + * Calls the tool's execute and returns the result cast to any, since + * Mastra's execute return type is a union with ValidationError that + * TypeScript cannot narrow without runtime checks. + */ +async function executeTool(challengeId: string): Promise { + return fetchChallengeTool.execute?.( + { challengeId }, + minimalContext, + ) as Promise; +} + +// --------------------------------------------------------------------------- +// VAL-INGEST-053: output schema includes projectId +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — additive projectId field', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('returns projectId when the API response includes a numeric projectId', async () => { + mockFetchResponse(baseApiResponse({ projectId: 12345 })); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.projectId).toBe(12345); + }); + + // VAL-INGEST-058: projectId nullable from API — null becomes undefined + it('omits projectId when the API response has projectId: null', async () => { + mockFetchResponse(baseApiResponse({ projectId: null })); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.projectId).toBeUndefined(); + }); + + it('omits projectId when the API response does not include projectId', async () => { + mockFetchResponse(baseApiResponse()); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.projectId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-054: output schema includes groups +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — additive groups field', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('returns groups when the API response includes a groups array', async () => { + mockFetchResponse(baseApiResponse({ groups: ['acme', 'beta'] })); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.groups).toEqual(['acme', 'beta']); + }); + + it('omits groups when the API response does not include groups', async () => { + mockFetchResponse(baseApiResponse()); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.groups).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-055: mapping function passes projectId and groups through +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — mapping passes both additive fields through', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('passes both projectId and groups from the API response into the output', async () => { + mockFetchResponse(baseApiResponse({ projectId: 999, groups: ['x', 'y'] })); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.projectId).toBe(999); + expect(result?.challenge.groups).toEqual(['x', 'y']); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-057: app-version: 2.0.0 header preserved +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — app-version header', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('includes app-version: 2.0.0 header in the request', async () => { + const fetchMock = mockFetchResponse(baseApiResponse()); + + await executeTool(CHALLENGE_UUID); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const callArgs = fetchMock.mock.calls[0] as [string, any]; + expect(callArgs[1].headers['app-version']).toBe('2.0.0'); + }); + + it('includes Authorization bearer token from M2MService', async () => { + const fetchMock = mockFetchResponse(baseApiResponse()); + + await executeTool(CHALLENGE_UUID); + + const callArgs = fetchMock.mock.calls[0] as [string, any]; + expect(callArgs[1].headers.Authorization).toBe('Bearer fake-m2m-token'); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-056: existing behavior unchanged +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — existing behavior unchanged', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('returns all existing fields with correct values', async () => { + mockFetchResponse(baseApiResponse()); + + const result = await executeTool(CHALLENGE_UUID); + + expect(result?.challenge.id).toBe('challenge-1'); + expect(result?.challenge.name).toBe('Test Challenge'); + expect(result?.challenge.description).toBe('A test description'); + expect(result?.challenge.privateDescription).toBe('secret'); + expect(result?.challenge.descriptionFormat).toBe('markdown'); + expect(result?.challenge.status).toBe('ACTIVE'); + expect(result?.challenge.track).toBe('Development'); + expect(result?.challenge.type).toBe('Challenge'); + expect(result?.challenge.tags).toEqual(['tag1', 'tag2']); + expect(result?.challenge.skills).toEqual([{ id: 's1', name: 'React' }]); + expect(result?.challenge.numOfRegistrants).toBe(5); + expect(result?.challenge.numOfSubmissions).toBe(2); + }); + + it('throws on non-2xx response', async () => { + mockFetchError(404); + + await expect( + executeTool(CHALLENGE_UUID), + ).rejects.toThrow(/HTTP 404/); + }); + + it('constructs the URL with the challenge ID', async () => { + const fetchMock = mockFetchResponse(baseApiResponse()); + + await executeTool(CHALLENGE_UUID); + + const callArgs = fetchMock.mock.calls[0] as [string, any]; + expect(callArgs[0]).toContain(encodeURIComponent(CHALLENGE_UUID)); + }); + + it('uses GET method', async () => { + const fetchMock = mockFetchResponse(baseApiResponse()); + + await executeTool(CHALLENGE_UUID); + + const callArgs = fetchMock.mock.calls[0] as [string, any]; + expect(callArgs[1].method).toBe('GET'); + }); +}); + +// --------------------------------------------------------------------------- +// Output schema validation — projectId and groups are defined in the schema +// --------------------------------------------------------------------------- + +describe('fetchChallengeTool — output schema defines additive fields', () => { + /** + * Validates against the tool's outputSchema using whichever interface the + * schema exposes (zod safeParse or StandardSchema ~standard.validate). + * Returns the parsed/validated value on success, or throws on failure. + */ + async function validateOutput(value: unknown): Promise { + const schema = fetchChallengeTool.outputSchema as any; + expect(schema).toBeDefined(); + + if (typeof schema.safeParse === 'function') { + const result = schema.safeParse(value); + if (!result.success) { + throw new Error(JSON.stringify(result.error)); + } + return result.data; + } + + if (schema['~standard']?.validate) { + const result = await schema['~standard'].validate(value); + if (result.issues) { + throw new Error(JSON.stringify(result.issues)); + } + return result.value; + } + + throw new Error('Schema has neither safeParse nor ~standard.validate'); + } + + // VAL-INGEST-053: output schema includes projectId + it('outputSchema includes projectId in the parsed output', async () => { + const parsed = await validateOutput({ + challenge: { + id: 'c1', + name: 'Test', + status: 'ACTIVE', + tags: [], + skills: [], + numOfRegistrants: 0, + numOfSubmissions: 0, + projectId: 12345, + groups: ['acme'], + }, + }); + + expect(parsed.challenge.projectId).toBe(12345); + }); + + // VAL-INGEST-054: output schema includes groups + it('outputSchema includes groups in the parsed output', async () => { + const parsed = await validateOutput({ + challenge: { + id: 'c1', + name: 'Test', + status: 'ACTIVE', + tags: [], + skills: [], + numOfRegistrants: 0, + numOfSubmissions: 0, + projectId: 12345, + groups: ['acme'], + }, + }); + + expect(parsed.challenge.groups).toEqual(['acme']); + }); + + it('outputSchema accepts absence of projectId and groups (optional)', async () => { + const parsed = await validateOutput({ + challenge: { + id: 'c1', + name: 'Test', + status: 'ACTIVE', + tags: [], + skills: [], + numOfRegistrants: 0, + numOfSubmissions: 0, + }, + }); + + expect(parsed.challenge.projectId).toBeUndefined(); + expect(parsed.challenge.groups).toBeUndefined(); + }); +}); diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.ts b/src/mastra/tools/challenge/fetch-challenge-tool.ts index 08aacb9..043f95e 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.ts @@ -34,6 +34,8 @@ export const fetchChallengeTool = createTool({ ), numOfRegistrants: z.number(), numOfSubmissions: z.number(), + projectId: z.number().optional(), + groups: z.array(z.string()).optional(), registrationStartDate: z.string().optional(), registrationEndDate: z.string().optional(), startDate: z.string().optional(), @@ -134,6 +136,8 @@ const fetchChallenge = async (challengeId: string) => { })), numOfRegistrants: data.numOfRegistrants ?? 0, numOfSubmissions: data.numOfSubmissions ?? 0, + projectId: data.projectId ?? undefined, + groups: data.groups ?? undefined, registrationStartDate: data.registrationStartDate ?? undefined, registrationEndDate: data.registrationEndDate ?? undefined, startDate: data.startDate ?? undefined, From 877473f8e6369d08ac063c404677c1000e6df60b Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 20 Aug 2026 13:37:58 +0300 Subject: [PATCH 06/19] feat: add searchChallengesTool wrapping M2M GET /v6/challenges with filters Wraps the Topcoder v6 Challenges API search endpoint with M2M auth. Supports filters: projectId/projectIds, status, approvalStatus, types/tracks, tags, groups, updatedDateStart/End, ids, page, perPage, sortBy, sortOrder. Always sets isLightweight=false (lightweight omits description), includes app-version: 2.0.0 header, 15s timeout via AbortSignal.timeout(15_000). Handles bare-array API response, wraps into { challenges, total, page, perPage }. Discards privateDescription from return value. Throws on upstream non-2xx. 26 unit tests with mocked HTTP covering all 8 validation assertions. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../challenge/search-challenges-tool.test.ts | 468 ++++++++++++++++++ .../tools/challenge/search-challenges-tool.ts | 167 +++++++ 2 files changed, 635 insertions(+) create mode 100644 src/mastra/tools/challenge/search-challenges-tool.test.ts create mode 100644 src/mastra/tools/challenge/search-challenges-tool.ts diff --git a/src/mastra/tools/challenge/search-challenges-tool.test.ts b/src/mastra/tools/challenge/search-challenges-tool.test.ts new file mode 100644 index 0000000..47a52fc --- /dev/null +++ b/src/mastra/tools/challenge/search-challenges-tool.test.ts @@ -0,0 +1,468 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { m2mTokenMock } = vi.hoisted(() => ({ + m2mTokenMock: vi.fn(), +})); + +vi.mock('../../../utils/auth/m2m.service', () => ({ + M2MService: class MockM2MService { + getM2MToken = m2mTokenMock; + }, +})); + +import { searchChallengesTool } from './search-challenges-tool'; + +// Minimal context — the tool only uses context.mastra?.getLogger?.() +const minimalContext = { mastra: undefined } as any; + +/** + * Installs a global fetch spy that resolves with the given JSON body. + * Returns the spy so tests can assert call arguments (URL, headers). + */ +function mockFetchResponse(data: unknown) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => data, + } as Response); +} + +/** + * Installs a global fetch spy that resolves with a non-2xx status. + */ +function mockFetchError(status: number) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status, + json: async () => ({}), + } as Response); +} + +/** + * Base challenge object returned by the v6 challenges API. + * Override individual fields via `overrides`. + */ +function baseChallenge(overrides: Record = {}) { + return { + id: 'challenge-1', + name: 'Test Challenge', + description: 'A test description', + privateDescription: 'secret private info', + descriptionFormat: 'markdown', + status: 'ACTIVE', + track: 'Development', + type: 'Challenge', + tags: ['tag1', 'tag2'], + skills: [{ id: 's1', name: 'React' }], + projectId: 12345, + groups: ['acme'], + ...overrides, + }; +} + +/** + * Calls the tool's execute and returns the result cast to any. + */ +async function executeTool(input: Record): Promise { + return searchChallengesTool.execute?.( + input, + minimalContext, + ) as Promise; +} + +// --------------------------------------------------------------------------- +// VAL-INGEST-045: issues M2M GET /v6/challenges with filters +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — request construction', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('issues GET /v6/challenges with supplied filter query parameters', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ + projectId: '123', + status: ['ACTIVE'], + types: ['Challenge'], + tracks: ['Development'], + tags: ['react'], + groups: ['acme'], + page: 1, + perPage: 10, + sortBy: 'updated', + sortOrder: 'asc', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('/v6/challenges'); + expect(url).toContain('projectId=123'); + expect(url).toContain('status=ACTIVE'); + expect(url).toContain('types=Challenge'); + expect(url).toContain('tracks=Development'); + expect(url).toContain('tags=react'); + expect(url).toContain('groups=acme'); + expect(url).toContain('page=1'); + expect(url).toContain('perPage=10'); + expect(url).toContain('sortBy=updated'); + expect(url).toContain('sortOrder=asc'); + expect(init.method).toBe('GET'); + }); + + it('includes Authorization bearer token from M2MService', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({}); + + const [, init] = fetchMock.mock.calls[0] as [string, any]; + expect(init.headers.Authorization).toBe('Bearer fake-m2m-token'); + }); + + it('forwards projectIds as comma-separated query parameter', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ projectIds: ['111', '222', '333'] }); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('projectIds=111%2C222%2C333'); + }); + + it('forwards approvalStatus filter', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ approvalStatus: ['APPROVED', 'PENDING'] }); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('approvalStatus=APPROVED%2CPENDING'); + }); + + it('forwards updatedDateStart and updatedDateEnd filters', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ + updatedDateStart: '2024-01-01T00:00:00Z', + updatedDateEnd: '2024-12-31T23:59:59Z', + }); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('updatedDateStart=2024-01-01T00%3A00%3A00Z'); + expect(url).toContain('updatedDateEnd=2024-12-31T23%3A59%3A59Z'); + }); + + it('forwards ids filter as comma-separated', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ ids: ['abc', 'def'] }); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('ids=abc%2Cdef'); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-046: always sets isLightweight: false +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — isLightweight', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('always sends isLightweight=false in the query parameters', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({}); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('isLightweight=false'); + }); + + it('sends isLightweight=false even when no other filters are provided', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ page: 1, perPage: 5 }); + + const [url] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toContain('isLightweight=false'); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-047: includes app-version: 2.0.0 header +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — app-version header', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('includes app-version: 2.0.0 header in the request', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({}); + + const [, init] = fetchMock.mock.calls[0] as [string, any]; + expect(init.headers['app-version']).toBe('2.0.0'); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-048: handles bare-array API response +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — bare-array response handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('wraps a bare JSON array into { challenges, total, page, perPage }', async () => { + mockFetchResponse([ + baseChallenge({ id: 'c1' }), + baseChallenge({ id: 'c2' }), + baseChallenge({ id: 'c3' }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result).toBeDefined(); + expect(result.challenges).toHaveLength(3); + expect(result.total).toBe(3); + expect(result.page).toBe(1); + expect(result.perPage).toBe(10); + }); + + it('handles an empty bare array response', async () => { + mockFetchResponse([]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges).toHaveLength(0); + expect(result.total).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-049: returns pagination envelope +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — pagination envelope', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('returns { challenges, total, page, perPage } with correct values', async () => { + mockFetchResponse([ + baseChallenge({ id: 'c1' }), + baseChallenge({ id: 'c2' }), + ]); + + const result = await executeTool({ page: 2, perPage: 20 }); + + expect(result).toHaveProperty('challenges'); + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('page'); + expect(result).toHaveProperty('perPage'); + expect(Array.isArray(result.challenges)).toBe(true); + expect(result.total).toBe(2); + expect(result.page).toBe(2); + expect(result.perPage).toBe(20); + }); + + it('defaults page to 1 and perPage to 20 when not provided', async () => { + mockFetchResponse([baseChallenge()]); + + const result = await executeTool({}); + + expect(result.page).toBe(1); + expect(result.perPage).toBe(20); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-050: discards privateDescription +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — discards privateDescription', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('strips privateDescription from each challenge in the return value', async () => { + mockFetchResponse([ + baseChallenge({ id: 'c1', privateDescription: 'secret1' }), + baseChallenge({ id: 'c2', privateDescription: 'secret2' }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + for (const challenge of result.challenges) { + expect(challenge).not.toHaveProperty('privateDescription'); + } + }); + + it('preserves public description while discarding privateDescription', async () => { + mockFetchResponse([ + baseChallenge({ + description: 'public description', + privateDescription: 'private info', + }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].description).toBe('public description'); + expect(result.challenges[0]).not.toHaveProperty('privateDescription'); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-051: uses 15s timeout +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — 15s timeout', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('creates an AbortSignal with a 15-second timeout', async () => { + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(new AbortController().signal); + + mockFetchResponse([]); + + await executeTool({}); + + expect(timeoutSpy).toHaveBeenCalledWith(15_000); + }); + + it('passes the AbortSignal to fetch', async () => { + vi.spyOn(AbortSignal, 'timeout').mockReturnValue( + new AbortController().signal, + ); + + const fetchMock = mockFetchResponse([]); + + await executeTool({}); + + const [, init] = fetchMock.mock.calls[0] as [string, any]; + expect(init.signal).toBeDefined(); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); +}); + +// --------------------------------------------------------------------------- +// VAL-INGEST-052: propagates upstream non-2xx as errors +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — error handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('throws on 401 response with status code in error message', async () => { + mockFetchError(401); + + await expect(executeTool({})).rejects.toThrow(/401/); + }); + + it('throws on 403 response with status code in error message', async () => { + mockFetchError(403); + + await expect(executeTool({})).rejects.toThrow(/403/); + }); + + it('throws on 500 response with status code in error message', async () => { + mockFetchError(500); + + await expect(executeTool({})).rejects.toThrow(/500/); + }); + + it('error message identifies the upstream failure', async () => { + mockFetchError(502); + + await expect(executeTool({})).rejects.toThrow( + /search challenges/i, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Challenge field mapping +// --------------------------------------------------------------------------- + +describe('searchChallengesTool — challenge field mapping', () => { + beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); + }); + + it('maps track and type from string values', async () => { + mockFetchResponse([ + baseChallenge({ track: 'Data Science', type: 'First2Finish' }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].track).toBe('Data Science'); + expect(result.challenges[0].type).toBe('First2Finish'); + }); + + it('maps track and type from object with name property', async () => { + mockFetchResponse([ + baseChallenge({ + track: { name: 'Design' }, + type: { name: 'Task' }, + }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].track).toBe('Design'); + expect(result.challenges[0].type).toBe('Task'); + }); + + it('maps skills array with id and name', async () => { + mockFetchResponse([ + baseChallenge({ + skills: [ + { id: 's1', name: 'React' }, + { id: 's2', name: 'TypeScript' }, + ], + }), + ]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].skills).toEqual([ + { id: 's1', name: 'React' }, + { id: 's2', name: 'TypeScript' }, + ]); + }); + + it('preserves projectId as a number', async () => { + mockFetchResponse([baseChallenge({ projectId: 99999 })]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].projectId).toBe(99999); + }); + + it('preserves groups array', async () => { + mockFetchResponse([baseChallenge({ groups: ['team-a', 'team-b'] })]); + + const result = await executeTool({ page: 1, perPage: 10 }); + + expect(result.challenges[0].groups).toEqual(['team-a', 'team-b']); + }); +}); diff --git a/src/mastra/tools/challenge/search-challenges-tool.ts b/src/mastra/tools/challenge/search-challenges-tool.ts new file mode 100644 index 0000000..a7881d8 --- /dev/null +++ b/src/mastra/tools/challenge/search-challenges-tool.ts @@ -0,0 +1,167 @@ +// Challenge API: GET /v6/challenges (M2M token required) +// Searches Topcoder challenges with filters via the v6 Challenges API. +// The v6 endpoint returns a bare JSON array (not a paginated envelope); +// this tool wraps it into { challenges, total, page, perPage } for callers. +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { M2MService } from '../../../utils/auth/m2m.service'; + +const BASE_URL = `${process.env.TC_API_BASE}/v6/challenges`; + +const m2mService = new M2MService(); + +const challengeSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + descriptionFormat: z.string().optional(), + status: z.string(), + track: z.string().optional(), + type: z.string().optional(), + tags: z.array(z.string()), + skills: z.array(z.object({ id: z.string(), name: z.string() })), + projectId: z.number().optional(), + groups: z.array(z.string()).optional(), +}); + +export const searchChallengesTool = createTool({ + id: 'search-challenges', + description: + 'Searches Topcoder challenges via the v6 Challenges API using M2M authentication with filter support (projectId, status, types, tracks, tags, groups, dates, pagination)', + inputSchema: z.object({ + projectId: z.string().optional(), + projectIds: z.array(z.string()).optional(), + status: z.array(z.string()).optional(), + approvalStatus: z.array(z.string()).optional(), + types: z.array(z.string()).optional(), + tracks: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + groups: z.array(z.string()).optional(), + updatedDateStart: z.string().optional(), + updatedDateEnd: z.string().optional(), + ids: z.array(z.string()).optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sortBy: z.string().optional(), + sortOrder: z.string().optional(), + }), + outputSchema: z.object({ + challenges: z.array(challengeSummarySchema), + total: z.number(), + page: z.number(), + perPage: z.number(), + }), + execute: async (inputData, context) => { + const logger = context.mastra?.getLogger?.(); + logger?.info('Searching challenges with filters'); + return await searchChallenges(inputData); + }, +}); + +interface SearchChallengesInput { + projectId?: string; + projectIds?: string[]; + status?: string[]; + approvalStatus?: string[]; + types?: string[]; + tracks?: string[]; + tags?: string[]; + groups?: string[]; + updatedDateStart?: string; + updatedDateEnd?: string; + ids?: string[]; + page?: number; + perPage?: number; + sortBy?: string; + sortOrder?: string; +} + +/** + * Builds URLSearchParams from the tool input filters. + * Always sets isLightweight=false — the lightweight response omits `description`, + * which is the field being indexed. + */ +function buildQueryParams(input: SearchChallengesInput): URLSearchParams { + const params = new URLSearchParams(); + + // Always set isLightweight: false + params.set('isLightweight', 'false'); + + if (input.projectId) params.set('projectId', input.projectId); + if (input.projectIds?.length) params.set('projectIds', input.projectIds.join(',')); + if (input.status?.length) params.set('status', input.status.join(',')); + if (input.approvalStatus?.length) params.set('approvalStatus', input.approvalStatus.join(',')); + if (input.types?.length) params.set('types', input.types.join(',')); + if (input.tracks?.length) params.set('tracks', input.tracks.join(',')); + if (input.tags?.length) params.set('tags', input.tags.join(',')); + if (input.groups?.length) params.set('groups', input.groups.join(',')); + if (input.updatedDateStart) params.set('updatedDateStart', input.updatedDateStart); + if (input.updatedDateEnd) params.set('updatedDateEnd', input.updatedDateEnd); + if (input.ids?.length) params.set('ids', input.ids.join(',')); + if (input.page !== undefined) params.set('page', String(input.page)); + if (input.perPage !== undefined) params.set('perPage', String(input.perPage)); + if (input.sortBy) params.set('sortBy', input.sortBy); + if (input.sortOrder) params.set('sortOrder', input.sortOrder); + + return params; +} + +/** + * Maps a raw challenge object from the v6 API response into the output schema. + * Handles track/type as both string and { name } object shapes. + * privateDescription is intentionally NOT included — only the public description is returned. + */ +function mapChallenge(raw: any) { + return { + id: raw.id, + name: raw.name, + description: raw.description ?? undefined, + descriptionFormat: raw.descriptionFormat ?? undefined, + status: raw.status ?? '', + track: typeof raw.track === 'string' ? raw.track : raw.track?.name ?? undefined, + type: typeof raw.type === 'string' ? raw.type : raw.type?.name ?? undefined, + tags: raw.tags ?? [], + skills: (raw.skills ?? []).map((s: any) => ({ id: s.id, name: s.name })), + projectId: raw.projectId ?? undefined, + groups: raw.groups ?? undefined, + // privateDescription is intentionally NOT included + }; +} + +const searchChallenges = async (input: SearchChallengesInput) => { + const token = await m2mService.getM2MToken(); + + const params = buildQueryParams(input); + const url = `${BASE_URL}?${params.toString()}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'app-version': '2.0.0', + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error( + `Failed to search challenges (HTTP ${response.status})`, + ); + } + + const data = await response.json(); + + // v6 challenges API returns a bare JSON array, not a paginated envelope + const challengeArray: any[] = Array.isArray(data) ? data : []; + + const page = input.page ?? 1; + const perPage = input.perPage ?? 20; + + return { + challenges: challengeArray.map(mapChallenge), + total: challengeArray.length, + page, + perPage, + }; +}; From 33f2c9bed84589f846294358e7075e942862e57f Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 20 Aug 2026 16:40:18 +0300 Subject: [PATCH 07/19] feat: add challenge-ingestion-workflow with 3-step pipeline and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements src/mastra/workflows/challenge/challenge-ingestion-workflow.ts (id: challenge-ingestion) registered in src/mastra/index.ts: - Step resolve-challenge: fetch by challengeId via fetchChallengeTool or validate inline record with validateRecord; exactly-one-source enforced - Step chunk-and-embed: processDescription → chunkChallengeDescription → enrichChunksWithChallengeName → embedMany with withRetry (linear backoff); only public description embedded, never privateDescription - Step upsert-vectors: ensureChallengeIndex() → upsert with deleteFilter { challengeId } for atomic per-challenge replacement; skipped on dryRun - Metadata: all 11 fields (challengeId, name, type, track, skills[], groups[], projectId string|null, chunkIndex 1-based, totalChunks, text with # Challenge: header, ingestedAt ISO-8601) - Logging via tcAILogger with [challenge-ingestion:] prefixes - Config lazy via getRagConfig() inside step execute (D5) - No console interception (D6), no projects-api call (D10) - 41 unit tests covering happy paths, dryRun, error handling, metadata, idempotent re-ingestion, embedding vs database failure, dimension guard Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/mastra/index.ts | 8 +- .../challenge-ingestion-workflow.test.ts | 768 ++++++++++++++++++ .../challenge/challenge-ingestion-workflow.ts | 532 ++++++++++++ 3 files changed, 1307 insertions(+), 1 deletion(-) create mode 100644 src/mastra/workflows/challenge/challenge-ingestion-workflow.test.ts create mode 100644 src/mastra/workflows/challenge/challenge-ingestion-workflow.ts diff --git a/src/mastra/index.ts b/src/mastra/index.ts index bbca88a..3b58163 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -1,6 +1,7 @@ import { Mastra } from '@mastra/core'; import { skillExtractionWorkflow } from './workflows/skills/skill-extraction-workflow'; import { challengeContextWorkflow } from './workflows/challenge/challenge-context-workflow'; +import { challengeIngestionWorkflow } from './workflows/challenge/challenge-ingestion-workflow'; import { jdAutowriteWorkflow } from './workflows/jd/jd-autowrite-workflow'; import { skillsMatchingAgent } from './agents/skills/skills-matching-agent'; import { challengeParserAgent } from './agents/challenge/challenge-parser-agent'; @@ -14,7 +15,12 @@ import { apiAuthLayer, middlewareConfig, tcAILogger } from '../utils'; import { aiWorkspace } from './workspaces'; export const mastra = new Mastra({ - workflows: { skillExtractionWorkflow, challengeContextWorkflow, jdAutowriteWorkflow }, + workflows: { + skillExtractionWorkflow, + challengeContextWorkflow, + challengeIngestionWorkflow, + jdAutowriteWorkflow, + }, agents: { skillsMatchingAgent, challengeParserAgent, jdRewriterAgent }, scorers: { instanceAnswerRelevancyScorer, diff --git a/src/mastra/workflows/challenge/challenge-ingestion-workflow.test.ts b/src/mastra/workflows/challenge/challenge-ingestion-workflow.test.ts new file mode 100644 index 0000000..86c5907 --- /dev/null +++ b/src/mastra/workflows/challenge/challenge-ingestion-workflow.test.ts @@ -0,0 +1,768 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + fetchExecute: vi.fn(), + embedMany: vi.fn(), + createEmbeddingModel: vi.fn(() => ({ modelId: 'mock-embedding-model' })), + ensureChallengeIndex: vi.fn(), + upsert: vi.fn(), + getRagConfig: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../tools/challenge/fetch-challenge-tool', () => ({ + fetchChallengeTool: { execute: mocks.fetchExecute }, +})); + +vi.mock('ai', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, embedMany: mocks.embedMany }; +}); + +vi.mock('../../../utils/providers/embedding-factory', () => ({ + createEmbeddingModel: mocks.createEmbeddingModel, +})); + +vi.mock('../../vector/challenge-vector-store', () => ({ + ensureChallengeIndex: mocks.ensureChallengeIndex, +})); + +vi.mock('../../../config/rag.config', () => ({ + getRagConfig: mocks.getRagConfig, +})); + +vi.mock('../../../utils/logger', () => ({ + tcAILogger: mocks.logger, +})); + +// Import after mocks are set up +import { generateDeterministicId } from '../../rag/ingestion-utils'; +import type { ChunkMetadata } from '../../rag/types'; +import { challengeIngestionWorkflow, _testing } from './challenge-ingestion-workflow'; + +// Captured immediately after module evaluation: getRagConfig must not be +// invoked while the module graph is being loaded (D5 — lazy config). +const getRagConfigCallsAtImport = mocks.getRagConfig.mock.calls.length; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface StepExecutor { + execute: (params: Record) => Promise>; +} + +async function runStep>( + step: unknown, + inputData: unknown, +): Promise { + const executable = step as unknown as StepExecutor; + return (await executable.execute({ inputData, requestContext: {} })) as T; +} + +function baseConfig(overrides: Record = {}) { + return { + embedding: { + provider: 'TC-Ollama', + modelId: 'nomic-embed-text', + dimension: 768, + maxContextWindow: 2048, + }, + vectorIndexName: 'challenge_embeddings', + vectorSearchThreshold: 0.5, + chunkMaxSize: 512, + chunkOverlap: 50, + topK: 10, + challengeSearchAI: { provider: 'AWSBedrock', modelId: 'us.anthropic.claude-haiku-4-5' }, + database: { connectionString: undefined, schemaName: 'ai' }, + knownTypes: [], + knownTracks: [], + ...overrides, + }; +} + +const CHALLENGE_ID = '11111111-2222-4333-8444-555555555555'; + +function apiChallenge(overrides: Record = {}) { + return { + id: CHALLENGE_ID, + name: 'Build a Widget', + description: 'PUBLIC BODY about widgets and gizmos.', + privateDescription: 'SECRET reviewer-only notes.', + descriptionFormat: 'markdown', + status: 'Active', + track: 'Development', + type: 'Challenge', + tags: [], + skills: [{ id: 's1', name: 'React' }, { id: 's2', name: 'React' }, { id: 's3', name: 'Node.js' }], + numOfRegistrants: 0, + numOfSubmissions: 0, + projectId: 4321, + groups: ['group-a'], + ...overrides, + }; +} + +/** Normalized record as produced by the resolve-challenge step. */ +function normalizedRecord(overrides: Record = {}) { + return { + challengeId: CHALLENGE_ID, + name: 'Build a Widget', + description: 'PUBLIC BODY about widgets and gizmos.', + descriptionFormat: 'markdown', + type: 'Challenge', + track: 'Development', + skills: ['React', 'Node.js'], + groups: ['group-a'], + projectId: '4321', + ...overrides, + }; +} + +interface ResolveOutput { + record: ReturnType; + dryRun: boolean; +} + +interface EmbedOutput { + challengeId: string; + projectId: string | null; + dryRun: boolean; + skipped: boolean; + chunks: number; + forceSplits: unknown[]; + vectorIds: string[]; + embeddings: number[][]; + metadata: ChunkMetadata[]; +} + +interface Report { + chunks: number; + forceSplits: unknown[]; + dryRun: boolean; + skipped: boolean; + projectId: string | null; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRagConfig.mockReturnValue(baseConfig()); + mocks.createEmbeddingModel.mockReturnValue({ modelId: 'mock-embedding-model' }); + mocks.fetchExecute.mockResolvedValue({ challenge: apiChallenge() }); + mocks.embedMany.mockImplementation(async ({ values }: { values: string[] }) => ({ + embeddings: values.map((_, i) => [i, 0.1, 0.2]), + })); + mocks.upsert.mockResolvedValue([]); + mocks.ensureChallengeIndex.mockResolvedValue({ upsert: mocks.upsert }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Workflow shape & registration +// --------------------------------------------------------------------------- + +describe('challenge-ingestion workflow definition', () => { + it('exposes the workflow id challenge-ingestion', () => { + expect(challengeIngestionWorkflow.id).toBe('challenge-ingestion'); + }); + + it('is registered in src/mastra/index.ts', () => { + const indexSource = readFileSync( + resolvePath(__dirname, '../../index.ts'), + 'utf8', + ); + expect(indexSource).toContain('challengeIngestionWorkflow'); + expect(indexSource).toContain( + "from './workflows/challenge/challenge-ingestion-workflow'", + ); + }); + + it('does not resolve the RAG config at module load (D5)', () => { + expect(getRagConfigCallsAtImport).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Input schema +// --------------------------------------------------------------------------- + +describe('challenge-ingestion input schema', () => { + it('accepts a UUID challengeId and defaults dryRun to false', () => { + const parsed = _testing.ingestionInputSchema.parse({ challengeId: CHALLENGE_ID }); + expect(parsed.challengeId).toBe(CHALLENGE_ID); + expect(parsed.dryRun).toBe(false); + }); + + it('rejects a challengeId that is not a UUID', () => { + const result = _testing.ingestionInputSchema.safeParse({ challengeId: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('accepts an inline challenge with free-form type and track (D12)', () => { + const result = _testing.ingestionInputSchema.safeParse({ + challenge: { + id: 'abc', + name: 'Inline', + description: 'Body', + typeName: 'Totally New Type', + trackName: 'Some Future Track', + }, + }); + expect(result.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// resolve-challenge +// --------------------------------------------------------------------------- + +describe('resolve-challenge step', () => { + it('fetches by challengeId via fetchChallengeTool and normalizes the record', async () => { + const out = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + dryRun: false, + }); + + expect(mocks.fetchExecute).toHaveBeenCalledTimes(1); + expect(mocks.fetchExecute.mock.calls[0][0]).toEqual({ challengeId: CHALLENGE_ID }); + expect(out.record.challengeId).toBe(CHALLENGE_ID); + expect(out.record.name).toBe('Build a Widget'); + expect(out.record.type).toBe('Challenge'); + expect(out.record.track).toBe('Development'); + expect(out.record.groups).toEqual(['group-a']); + expect(out.dryRun).toBe(false); + }); + + it('parses, deduplicates and filters skills', async () => { + mocks.fetchExecute.mockResolvedValue({ + challenge: apiChallenge({ + skills: [{ id: 'a', name: 'React' }, { id: 'b', name: ' React ' }, { id: 'c', name: '' }], + }), + }); + + const out = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + }); + + expect(out.record.skills).toEqual(['React']); + }); + + it('normalizes a numeric projectId to a string (never a number)', async () => { + const out = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + }); + + expect(out.record.projectId).toBe('4321'); + expect(typeof out.record.projectId).toBe('string'); + }); + + it('normalizes a missing projectId to null', async () => { + mocks.fetchExecute.mockResolvedValue({ + challenge: apiChallenge({ projectId: undefined }), + }); + + const out = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + }); + + expect(out.record.projectId).toBeNull(); + }); + + it('validates an inline record and never calls fetchChallengeTool', async () => { + const out = await runStep(_testing.resolveChallengeStep, { + challenge: { + id: 'inline-1', + name: 'Inline Challenge', + description: 'Inline body text.', + skills: 'React, , React, Node.js', + projectId: 99, + groups: ['g1'], + typeName: 'First2Finish', + trackName: 'Development', + }, + dryRun: true, + }); + + expect(mocks.fetchExecute).not.toHaveBeenCalled(); + expect(out.record.challengeId).toBe('inline-1'); + expect(out.record.skills).toEqual(['React', 'Node.js']); + expect(out.record.projectId).toBe('99'); + expect(out.dryRun).toBe(true); + }); + + it('rejects an inline record that fails validateRecord', async () => { + await expect( + runStep(_testing.resolveChallengeStep, { + challenge: { id: 'inline-2', name: 'No body', description: ' ' }, + }), + ).rejects.toThrow(/Empty description/); + }); + + it('fails with an exactly-one-source error when both sources are supplied', async () => { + await expect( + runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + challenge: { id: 'x', name: 'y', description: 'z' }, + }), + ).rejects.toThrow(/exactly one source/i); + expect(mocks.fetchExecute).not.toHaveBeenCalled(); + }); + + it('fails with an exactly-one-source error when neither source is supplied', async () => { + await expect(runStep(_testing.resolveChallengeStep, { dryRun: false })).rejects.toThrow( + /exactly one source/i, + ); + }); + + it('fails gracefully with challenge-not-found for an unknown challengeId', async () => { + mocks.fetchExecute.mockResolvedValue({ challenge: null }); + + await expect( + runStep(_testing.resolveChallengeStep, { challengeId: CHALLENGE_ID }), + ).rejects.toThrow(/challenge-not-found/); + }); + + it('makes no projects-api call during resolution (D10)', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + await runStep(_testing.resolveChallengeStep, { challengeId: CHALLENGE_ID }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('logs with the [challenge-ingestion:resolve-challenge] prefix', async () => { + await runStep(_testing.resolveChallengeStep, { challengeId: CHALLENGE_ID }); + + const messages = mocks.logger.info.mock.calls.map((c) => String(c[0])); + expect(messages.some((m) => m.startsWith('[challenge-ingestion:resolve-challenge]'))).toBe( + true, + ); + }); +}); + +// --------------------------------------------------------------------------- +// chunk-and-embed +// --------------------------------------------------------------------------- + +describe('chunk-and-embed step', () => { + it('embeds every chunk and produces 1-based metadata with all 11 fields', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + expect(out.skipped).toBe(false); + expect(out.chunks).toBeGreaterThan(0); + expect(out.embeddings).toHaveLength(out.chunks); + expect(out.metadata).toHaveLength(out.chunks); + expect(mocks.embedMany).toHaveBeenCalledTimes(1); + + const meta = out.metadata[0]; + expect(Object.keys(meta).sort()).toEqual( + [ + 'challengeId', + 'chunkIndex', + 'groups', + 'ingestedAt', + 'name', + 'projectId', + 'skills', + 'text', + 'totalChunks', + 'track', + 'type', + ].sort(), + ); + expect(meta.challengeId).toBe(CHALLENGE_ID); + expect(meta.name).toBe('Build a Widget'); + expect(meta.type).toBe('Challenge'); + expect(meta.track).toBe('Development'); + expect(Array.isArray(meta.skills)).toBe(true); + expect(Array.isArray(meta.groups)).toBe(true); + expect(typeof meta.projectId).toBe('string'); + expect(meta.chunkIndex).toBe(1); + expect(out.metadata.map((m) => m.chunkIndex)).toEqual( + out.metadata.map((_, i) => i + 1), + ); + expect(meta.totalChunks).toBe(out.chunks); + expect(typeof meta.text).toBe('string'); + expect(new Date(meta.ingestedAt).toISOString()).toBe(meta.ingestedAt); + }); + + it('prefixes every chunk with the # Challenge: header', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + for (const meta of out.metadata) { + expect(meta.text.startsWith('# Challenge: Build a Widget')).toBe(true); + } + const embedded: string[] = mocks.embedMany.mock.calls[0][0].values; + for (const value of embedded) { + expect(value.startsWith('# Challenge: Build a Widget')).toBe(true); + } + }); + + it('converts HTML descriptions to Markdown before chunking', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord({ + description: '

Overview

Build a widget.

', + descriptionFormat: 'HTML', + }), + dryRun: false, + }); + + const joined = out.metadata.map((m) => m.text).join('\n'); + expect(joined).toContain('## Overview'); + expect(joined).toContain('**widget**'); + expect(joined).not.toContain('

'); + }); + + it('strips YAML frontmatter before chunking', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord({ + description: '---\ntitle: hidden meta\n---\nVisible body content.', + }), + dryRun: false, + }); + + const joined = out.metadata.map((m) => m.text).join('\n'); + expect(joined).not.toContain('hidden meta'); + expect(joined).toContain('Visible body content.'); + }); + + it('normalizes BOM and CRLF before chunking', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord({ + description: '\uFEFFLine one\r\nLine two\r\n', + }), + dryRun: false, + }); + + const joined = out.metadata.map((m) => m.text).join('\n'); + expect(joined).not.toContain('\uFEFF'); + expect(joined).not.toContain('\r'); + expect(joined).toContain('Line one\nLine two'); + }); + + it('embeds only the public description, never privateDescription', async () => { + const resolved = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + }); + const out = await runStep(_testing.chunkAndEmbedStep, { + record: resolved.record, + dryRun: false, + }); + + const embedded: string[] = mocks.embedMany.mock.calls[0][0].values; + expect(embedded.join('\n')).toContain('PUBLIC BODY'); + expect(embedded.join('\n')).not.toContain('SECRET'); + expect(out.metadata.map((m) => m.text).join('\n')).not.toContain('SECRET'); + }); + + it('skips an empty/whitespace description with skipped: true', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord({ description: ' \r\n \uFEFF ' }), + dryRun: false, + }); + + expect(out.skipped).toBe(true); + expect(out.chunks).toBe(0); + expect(out.metadata).toEqual([]); + expect(mocks.embedMany).not.toHaveBeenCalled(); + }); + + it('produces deterministic vector ids across runs', async () => { + const first = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + const second = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + expect(second.vectorIds).toEqual(first.vectorIds); + expect(first.vectorIds).toEqual( + first.metadata.map((m) => generateDeterministicId(`${CHALLENGE_ID}-${m.text}`)), + ); + }); + + it('reports a chunk count and force-split records', async () => { + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + expect(Array.isArray(out.forceSplits)).toBe(true); + expect(out.chunks).toBe(out.metadata.length); + }); + + it('surfaces an embedding failure with chunk count, total chars and longest chunk', async () => { + mocks.embedMany.mockRejectedValue(new Error('ollama connection refused')); + + await expect( + runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }), + ).rejects.toThrow( + /embedding failure.*chunks: \d+.*totalChars: \d+.*longestChunk: \d+.*ollama connection refused/s, + ); + }, 15_000); + + it('retries embedding with linear backoff via withRetry', async () => { + mocks.embedMany + .mockRejectedValueOnce(new Error('transient')) + .mockImplementation(async ({ values }: { values: string[] }) => ({ + embeddings: values.map((_, i) => [i]), + })); + + const out = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + expect(mocks.embedMany).toHaveBeenCalledTimes(2); + expect(out.chunks).toBeGreaterThan(0); + }, 15_000); + + it('logs with the [challenge-ingestion:chunk-and-embed] prefix', async () => { + await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + + const messages = mocks.logger.info.mock.calls.map((c) => String(c[0])); + expect(messages.some((m) => m.startsWith('[challenge-ingestion:chunk-and-embed]'))).toBe( + true, + ); + }); +}); + +// --------------------------------------------------------------------------- +// upsert-vectors +// --------------------------------------------------------------------------- + +describe('upsert-vectors step', () => { + function embedded(overrides: Partial = {}): EmbedOutput { + return { + challengeId: CHALLENGE_ID, + projectId: '4321', + dryRun: false, + skipped: false, + chunks: 2, + forceSplits: [], + vectorIds: ['id-1', 'id-2'], + embeddings: [[0.1], [0.2]], + metadata: [ + { challengeId: CHALLENGE_ID, chunkIndex: 1 } as unknown as ChunkMetadata, + { challengeId: CHALLENGE_ID, chunkIndex: 2 } as unknown as ChunkMetadata, + ], + ...overrides, + }; + } + + it('ensures the index and upserts with deleteFilter for atomic per-challenge replacement', async () => { + const report = await runStep(_testing.upsertVectorsStep, embedded()); + + expect(mocks.ensureChallengeIndex).toHaveBeenCalledTimes(1); + expect(mocks.upsert).toHaveBeenCalledTimes(1); + expect(mocks.upsert.mock.calls[0][0]).toEqual({ + indexName: 'challenge_embeddings', + vectors: [[0.1], [0.2]], + metadata: embedded().metadata, + ids: ['id-1', 'id-2'], + deleteFilter: { challengeId: CHALLENGE_ID }, + }); + expect(report).toEqual({ + chunks: 2, + forceSplits: [], + dryRun: false, + skipped: false, + projectId: '4321', + }); + }); + + it('indexes a challenge with a null projectId', async () => { + const report = await runStep( + _testing.upsertVectorsStep, + embedded({ projectId: null }), + ); + + expect(mocks.upsert).toHaveBeenCalledTimes(1); + expect(report.projectId).toBeNull(); + }); + + it('does not upsert on dryRun', async () => { + const report = await runStep(_testing.upsertVectorsStep, embedded({ dryRun: true })); + + expect(mocks.upsert).not.toHaveBeenCalled(); + expect(mocks.ensureChallengeIndex).not.toHaveBeenCalled(); + expect(report.dryRun).toBe(true); + expect(report.chunks).toBe(2); + }); + + it('does not upsert when the record was skipped', async () => { + const report = await runStep( + _testing.upsertVectorsStep, + embedded({ skipped: true, chunks: 0, embeddings: [], vectorIds: [], metadata: [] }), + ); + + expect(mocks.upsert).not.toHaveBeenCalled(); + expect(report.skipped).toBe(true); + expect(report.chunks).toBe(0); + }); + + it('distinguishes a database failure from an embedding failure and leaves no partial state', async () => { + mocks.upsert.mockRejectedValue(new Error('duplicate key value')); + + await expect(runStep(_testing.upsertVectorsStep, embedded())).rejects.toThrow( + /database failure.*duplicate key value/s, + ); + await expect(runStep(_testing.upsertVectorsStep, embedded())).rejects.not.toThrow( + /embedding failure/, + ); + // The single upsert call (delete + insert in one transaction) is the only + // write attempted, so a failure cannot leave partially indexed chunks. + expect(mocks.upsert).toHaveBeenCalledTimes(2); + }); + + it('surfaces an actionable dimension-mismatch error from ensureChallengeIndex', async () => { + mocks.ensureChallengeIndex.mockRejectedValue( + new Error( + 'Dimension mismatch: vector index "challenge_embeddings" has dimension 1024, ' + + 'but the configured embedding model requires dimension 768.', + ), + ); + + await expect(runStep(_testing.upsertVectorsStep, embedded())).rejects.toThrow( + /Dimension mismatch.*dimension 1024.*dimension 768/s, + ); + expect(mocks.upsert).not.toHaveBeenCalled(); + }); + + it('logs with the [challenge-ingestion:upsert-vectors] prefix', async () => { + await runStep(_testing.upsertVectorsStep, embedded()); + + const messages = mocks.logger.info.mock.calls.map((c) => String(c[0])); + expect(messages.some((m) => m.startsWith('[challenge-ingestion:upsert-vectors]'))).toBe( + true, + ); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end (steps chained manually) +// --------------------------------------------------------------------------- + +describe('challenge-ingestion end to end', () => { + it('ingests by challengeId and upserts, writing nothing to console (D6)', async () => { + const consoleSpies = [ + vi.spyOn(console, 'log').mockImplementation(() => undefined), + vi.spyOn(console, 'info').mockImplementation(() => undefined), + vi.spyOn(console, 'warn').mockImplementation(() => undefined), + vi.spyOn(console, 'error').mockImplementation(() => undefined), + ]; + + const resolved = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + dryRun: false, + }); + const embeddedOut = await runStep(_testing.chunkAndEmbedStep, resolved); + const report = await runStep(_testing.upsertVectorsStep, embeddedOut); + + expect(Object.keys(report).sort()).toEqual( + ['chunks', 'dryRun', 'forceSplits', 'projectId', 'skipped'].sort(), + ); + expect(report.chunks).toBeGreaterThan(0); + expect(report.dryRun).toBe(false); + expect(report.skipped).toBe(false); + expect(report.projectId).toBe('4321'); + expect(mocks.upsert).toHaveBeenCalledTimes(1); + for (const spy of consoleSpies) { + expect(spy).not.toHaveBeenCalled(); + } + }); + + it('re-ingestion preserves vector ids and chunk count', async () => { + const first = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + await runStep(_testing.upsertVectorsStep, first); + const firstIds: string[] = mocks.upsert.mock.calls[0][0].ids; + + const second = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord(), + dryRun: false, + }); + await runStep(_testing.upsertVectorsStep, second); + const secondIds: string[] = mocks.upsert.mock.calls[1][0].ids; + + expect(secondIds).toEqual(firstIds); + expect(second.chunks).toBe(first.chunks); + }); + + it('modified re-ingestion replaces chunks atomically via deleteFilter', async () => { + const modified = await runStep(_testing.chunkAndEmbedStep, { + record: normalizedRecord({ description: 'Completely different body text now.' }), + dryRun: false, + }); + await runStep(_testing.upsertVectorsStep, modified); + + const call = mocks.upsert.mock.calls[0][0]; + expect(call.deleteFilter).toEqual({ challengeId: CHALLENGE_ID }); + expect(call.ids).toHaveLength(modified.chunks); + }); + + it('dryRun chunks and embeds but does not upsert', async () => { + const resolved = await runStep(_testing.resolveChallengeStep, { + challengeId: CHALLENGE_ID, + dryRun: true, + }); + const embeddedOut = await runStep(_testing.chunkAndEmbedStep, resolved); + const report = await runStep(_testing.upsertVectorsStep, embeddedOut); + + expect(mocks.embedMany).toHaveBeenCalledTimes(1); + expect(mocks.upsert).not.toHaveBeenCalled(); + expect(report.dryRun).toBe(true); + expect(report.chunks).toBeGreaterThan(0); + }); + + it('ingests an inline record without calling fetchChallengeTool', async () => { + const resolved = await runStep(_testing.resolveChallengeStep, { + challenge: { + id: 'inline-e2e', + name: 'Inline E2E', + description: 'Inline description body for the end to end test.', + skills: ['TypeScript'], + projectId: null, + groups: [], + }, + dryRun: false, + }); + const embeddedOut = await runStep(_testing.chunkAndEmbedStep, resolved); + const report = await runStep(_testing.upsertVectorsStep, embeddedOut); + + expect(mocks.fetchExecute).not.toHaveBeenCalled(); + expect(report.chunks).toBeGreaterThan(0); + expect(report.projectId).toBeNull(); + expect(mocks.upsert.mock.calls[0][0].deleteFilter).toEqual({ challengeId: 'inline-e2e' }); + }); +}); diff --git a/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts b/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts new file mode 100644 index 0000000..2729d6b --- /dev/null +++ b/src/mastra/workflows/challenge/challenge-ingestion-workflow.ts @@ -0,0 +1,532 @@ +/** + * Challenge ingestion workflow (id: challenge-ingestion). + * + * Ingests a single challenge into the vector index, either by challengeId + * (fetched through fetchChallengeTool) or from an inline record. Only the + * PUBLIC description is ever embedded — privateDescription is reviewer-only + * content and is deliberately never read here. + * + * projectId is carried as a string reference only; no projects-api call is + * made during ingestion (D10). + */ + +import { createWorkflow, createStep } from '@mastra/core/workflows'; +import { noopObserve } from '@mastra/core/tools'; +import { embedMany } from 'ai'; +import { z } from 'zod'; +import { getRagConfig } from '../../../config/rag.config'; +import { tcAILogger } from '../../../utils/logger'; +import { createEmbeddingModel } from '../../../utils/providers/embedding-factory'; +import { chunkChallengeDescription } from '../../rag/chunking'; +import { enrichChunksWithChallengeName, parseSkills, processDescription } from '../../rag/content'; +import { + generateDeterministicId, + validateRecord, + withRetry, +} from '../../rag/ingestion-utils'; +import type { ChallengeRecord, ChunkMetadata, ReportedForceSplit } from '../../rag/types'; +import { ensureChallengeIndex } from '../../vector/challenge-vector-store'; +import { fetchChallengeTool } from '../../tools/challenge/fetch-challenge-tool'; + +// --------------------------------------------------------------------------- +// Zod Schemas +// --------------------------------------------------------------------------- + +/** + * Inline challenge record. `typeName` / `trackName` are free-form strings and + * are never enum-validated (D12). + */ +const inlineChallengeSchema = z.object({ + id: z.string().describe('Challenge id (any non-empty string for inline records)'), + name: z.string().describe('Challenge name'), + description: z.string().describe('Public challenge description (markdown or html)'), + descriptionFormat: z.string().optional().describe('"html" triggers HTML→Markdown conversion'), + typeName: z.string().optional().describe('Free-form challenge type (not enum-validated)'), + trackName: z.string().optional().describe('Free-form challenge track (not enum-validated)'), + skills: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('Comma-separated string or array of skill names'), + projectId: z + .union([z.string(), z.number()]) + .nullable() + .optional() + .describe('Project reference; stored as a string (D10 — never dereferenced)'), + groups: z.array(z.string()).optional().describe('Challenge group ids'), +}); + +const ingestionInputSchema = z.object({ + challengeId: z + .string() + .uuid() + .optional() + .describe('UUID of the challenge to fetch and ingest'), + challenge: inlineChallengeSchema + .optional() + .describe('Inline challenge record to ingest instead of fetching one'), + dryRun: z + .boolean() + .optional() + .default(false) + .describe('Chunk and embed but skip the vector upsert'), +}); + +/** A challenge normalized into the shape the ingestion pipeline consumes. */ +const normalizedRecordSchema = z.object({ + challengeId: z.string(), + name: z.string(), + description: z.string(), + descriptionFormat: z.string().optional(), + type: z.string(), + track: z.string(), + skills: z.array(z.string()), + groups: z.array(z.string()), + projectId: z.string().nullable(), +}); + +const resolvedChallengeSchema = z.object({ + record: normalizedRecordSchema, + dryRun: z.boolean(), +}); + +const reportedForceSplitSchema = z.object({ + recordId: z.string(), + chunkIndex: z.number(), + originalTokens: z.number(), + resultingChunks: z.number(), + reason: z.string(), +}); + +const chunkMetadataSchema = z.object({ + challengeId: z.string(), + name: z.string(), + type: z.string(), + track: z.string(), + skills: z.array(z.string()), + groups: z.array(z.string()), + projectId: z.string().nullable(), + chunkIndex: z.number(), + totalChunks: z.number(), + text: z.string(), + ingestedAt: z.string(), +}); + +const embeddedChunksSchema = z.object({ + challengeId: z.string(), + projectId: z.string().nullable(), + dryRun: z.boolean(), + skipped: z.boolean(), + chunks: z.number(), + forceSplits: z.array(reportedForceSplitSchema), + vectorIds: z.array(z.string()), + embeddings: z.array(z.array(z.number())), + metadata: z.array(chunkMetadataSchema), +}); + +const ingestionReportSchema = z.object({ + chunks: z.number().describe('Number of chunks produced (and upserted unless dryRun/skipped)'), + forceSplits: z.array(reportedForceSplitSchema).describe('Atomic blocks that had to be split'), + dryRun: z.boolean(), + skipped: z.boolean().describe('True when the description was empty after processing'), + projectId: z.string().nullable(), +}); + +export type ChallengeIngestionReport = z.infer; + +// --------------------------------------------------------------------------- +// Normalization helpers +// --------------------------------------------------------------------------- + +/** + * @mastra/pg compares metadata scalars as text, so a numeric projectId would + * silently fail to match a filter — always store it as a string. + */ +function normalizeProjectId(value: string | number | null | undefined): string | null { + if (value === null || value === undefined || value === '') { + return null; + } + return String(value); +} + +/** Free-form type/track value, accepting either a plain string or `{ name }`. */ +function toFreeFormName(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (value && typeof value === 'object' && 'name' in value) { + const name = (value as { name?: unknown }).name; + return typeof name === 'string' ? name : ''; + } + return ''; +} + +/** Flattens the API's `{ id, name }[]` skills shape, or an inline array/CSV string. */ +function normalizeSkills(value: unknown): string[] { + if (value === null || value === undefined) { + return []; + } + if (typeof value === 'string') { + return parseSkills(value); + } + if (Array.isArray(value)) { + const names = value.map((entry) => + typeof entry === 'string' ? entry : toFreeFormName(entry), + ); + return parseSkills(names.join(',')); + } + return []; +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +type NormalizedRecord = z.infer; + +function normalizeInlineRecord(record: ChallengeRecord): NormalizedRecord { + return { + challengeId: record.id, + name: record.name, + description: record.description, + descriptionFormat: record.descriptionFormat, + type: record.typeName ?? '', + track: record.trackName ?? '', + skills: normalizeSkills(record.skills), + groups: toStringArray(record.groups), + projectId: normalizeProjectId(record.projectId), + }; +} + +function normalizeFetchedChallenge( + challenge: Record, + fallbackId: string, +): NormalizedRecord { + return { + challengeId: typeof challenge.id === 'string' ? challenge.id : fallbackId, + name: typeof challenge.name === 'string' ? challenge.name : '', + // privateDescription is intentionally not read — it must never be embedded. + description: typeof challenge.description === 'string' ? challenge.description : '', + descriptionFormat: + typeof challenge.descriptionFormat === 'string' ? challenge.descriptionFormat : undefined, + type: toFreeFormName(challenge.type), + track: toFreeFormName(challenge.track), + skills: normalizeSkills(challenge.skills), + groups: toStringArray(challenge.groups), + projectId: normalizeProjectId(challenge.projectId as string | number | null | undefined), + }; +} + +// --------------------------------------------------------------------------- +// Step 1 – Resolve the challenge (fetch by id or accept inline) +// --------------------------------------------------------------------------- + +const resolveChallengeStep = createStep({ + id: 'resolve-challenge', + description: + 'Resolves the challenge to ingest from exactly one source: a challengeId ' + + '(fetched via fetchChallengeTool) or an inline challenge record.', + inputSchema: ingestionInputSchema, + outputSchema: resolvedChallengeSchema, + execute: async ({ inputData, requestContext }) => { + const { challengeId, challenge, dryRun = false } = inputData; + const hasChallengeId = typeof challengeId === 'string' && challengeId.trim().length > 0; + const hasInlineChallenge = challenge !== undefined && challenge !== null; + + if (hasChallengeId === hasInlineChallenge) { + throw new Error( + '[challenge-ingestion:resolve-challenge] exactly one source required: ' + + 'supply either challengeId or challenge (not both, not neither)', + ); + } + + if (hasInlineChallenge) { + const record = challenge as ChallengeRecord; + const validationError = validateRecord(record); + if (validationError) { + throw new Error( + `[challenge-ingestion:resolve-challenge] invalid inline challenge record: ${validationError}`, + ); + } + tcAILogger.info( + `[challenge-ingestion:resolve-challenge] Using inline challenge record ${record.id} ("${record.name}")`, + ); + return { record: normalizeInlineRecord(record), dryRun }; + } + + tcAILogger.info( + `[challenge-ingestion:resolve-challenge] Fetching challenge ${challengeId} via fetchChallengeTool`, + ); + + const toolResult = await fetchChallengeTool.execute?.( + { challengeId: challengeId! }, + { requestContext, observe: noopObserve }, + ); + + if (!toolResult || 'error' in toolResult || !toolResult.challenge) { + throw new Error( + `[challenge-ingestion:resolve-challenge] challenge-not-found: no challenge returned for id ${challengeId}`, + ); + } + + const record = normalizeFetchedChallenge( + toolResult.challenge as unknown as Record, + challengeId!, + ); + + tcAILogger.info( + `[challenge-ingestion:resolve-challenge] Resolved challenge ${record.challengeId} ` + + `("${record.name}", type: ${record.type || 'n/a'}, track: ${record.track || 'n/a'}, ` + + `projectId: ${record.projectId ?? 'null'}, skills: ${record.skills.length})`, + ); + + return { record, dryRun }; + }, +}); + +// --------------------------------------------------------------------------- +// Step 2 – Process, chunk and embed the public description +// --------------------------------------------------------------------------- + +const chunkAndEmbedStep = createStep({ + id: 'chunk-and-embed', + description: + 'Processes the public description (line endings, BOM, HTML→Markdown, frontmatter), ' + + 'chunks it, enriches each chunk with the challenge name header and embeds the chunks.', + inputSchema: resolvedChallengeSchema, + outputSchema: embeddedChunksSchema, + execute: async ({ inputData }) => { + const config = getRagConfig(); + const { record, dryRun } = inputData; + + const emptyResult = { + challengeId: record.challengeId, + projectId: record.projectId, + dryRun, + skipped: true, + chunks: 0, + forceSplits: [] as ReportedForceSplit[], + vectorIds: [] as string[], + embeddings: [] as number[][], + metadata: [] as ChunkMetadata[], + }; + + const content = processDescription(record.description, record.descriptionFormat); + if (content.length === 0) { + tcAILogger.info( + `[challenge-ingestion:chunk-and-embed] Skipping challenge ${record.challengeId} — ` + + 'description is empty after processing', + ); + return emptyResult; + } + + const { chunks, forceSplits } = await chunkChallengeDescription(content, { + maxSize: config.chunkMaxSize, + overlap: config.chunkOverlap, + contextWindow: config.embedding.maxContextWindow, + }); + + if (chunks.length === 0) { + tcAILogger.info( + `[challenge-ingestion:chunk-and-embed] Skipping challenge ${record.challengeId} — ` + + 'chunking produced no chunks', + ); + return emptyResult; + } + + // Enrichment happens AFTER chunking so the header is part of both the + // embedded text and the deterministic vector id hash input. + const enriched = enrichChunksWithChallengeName(chunks, record.name); + const chunkTexts = enriched.map((chunk) => chunk.text); + const totalChars = chunkTexts.reduce((sum, text) => sum + text.length, 0); + const longestChunk = chunkTexts.reduce((max, text) => Math.max(max, text.length), 0); + + tcAILogger.info( + `[challenge-ingestion:chunk-and-embed] Challenge ${record.challengeId} — ` + + `${chunkTexts.length} chunks, ${totalChars} chars, longest ${longestChunk} chars, ` + + `${forceSplits.length} force-splits`, + ); + + let embeddings: number[][]; + try { + const result = await withRetry(() => + embedMany({ + model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId), + values: chunkTexts, + }), + ); + embeddings = result.embeddings; + } catch (error) { + throw new Error( + `[challenge-ingestion:chunk-and-embed] embedding failure for challenge ` + + `${record.challengeId} (chunks: ${chunkTexts.length}, totalChars: ${totalChars}, ` + + `longestChunk: ${longestChunk}): ${errorMessage(error)}`, + { cause: error }, + ); + } + + if (embeddings.length !== chunkTexts.length) { + throw new Error( + `[challenge-ingestion:chunk-and-embed] embedding failure for challenge ` + + `${record.challengeId} (chunks: ${chunkTexts.length}, totalChars: ${totalChars}, ` + + `longestChunk: ${longestChunk}): embedding provider returned ` + + `${embeddings.length} vectors`, + ); + } + + const ingestedAt = new Date().toISOString(); + const metadata: ChunkMetadata[] = chunkTexts.map((text, index) => ({ + challengeId: record.challengeId, + name: record.name, + type: record.type, + track: record.track, + skills: record.skills, + groups: record.groups, + projectId: record.projectId, + chunkIndex: index + 1, + totalChunks: chunkTexts.length, + text, + ingestedAt, + })); + + const vectorIds = chunkTexts.map((text) => + generateDeterministicId(`${record.challengeId}-${text}`), + ); + + tcAILogger.info( + `[challenge-ingestion:chunk-and-embed] Embedded ${embeddings.length} chunks for ` + + `challenge ${record.challengeId}`, + ); + + return { + challengeId: record.challengeId, + projectId: record.projectId, + dryRun, + skipped: false, + chunks: chunkTexts.length, + forceSplits: forceSplits.map((split) => ({ + ...split, + recordId: record.challengeId, + })), + vectorIds, + embeddings, + metadata, + }; + }, +}); + +// --------------------------------------------------------------------------- +// Step 3 – Upsert vectors (atomic per-challenge replacement) +// --------------------------------------------------------------------------- + +const upsertVectorsStep = createStep({ + id: 'upsert-vectors', + description: + 'Ensures the challenge vector index exists, then replaces the challenge\'s vectors in a ' + + 'single upsert using deleteFilter { challengeId }. Skipped on dryRun.', + inputSchema: embeddedChunksSchema, + outputSchema: ingestionReportSchema, + execute: async ({ inputData }) => { + const { + challengeId, + projectId, + dryRun, + skipped, + chunks, + forceSplits, + vectorIds, + embeddings, + metadata, + } = inputData; + + const report = { chunks, forceSplits, dryRun, skipped, projectId }; + + if (skipped) { + tcAILogger.info( + `[challenge-ingestion:upsert-vectors] Nothing to upsert for challenge ${challengeId} — record skipped`, + ); + return report; + } + + if (dryRun) { + tcAILogger.info( + `[challenge-ingestion:upsert-vectors] Dry run — skipping upsert of ${chunks} ` + + `chunks for challenge ${challengeId}`, + ); + return report; + } + + const config = getRagConfig(); + const indexName = config.vectorIndexName; + + let store; + try { + store = await ensureChallengeIndex(); + } catch (error) { + throw new Error( + `[challenge-ingestion:upsert-vectors] database failure while ensuring index ` + + `"${indexName}" for challenge ${challengeId}: ${errorMessage(error)}`, + { cause: error }, + ); + } + + try { + // deleteFilter makes the delete + insert a single atomic per-challenge + // replacement, so a failure cannot leave partially indexed chunks. + await store.upsert({ + indexName, + vectors: embeddings, + metadata, + ids: vectorIds, + deleteFilter: { challengeId }, + }); + } catch (error) { + throw new Error( + `[challenge-ingestion:upsert-vectors] database failure during upsert for challenge ` + + `${challengeId} (chunks: ${chunks}, vectors: ${embeddings.length}): ${errorMessage(error)}`, + { cause: error }, + ); + } + + tcAILogger.info( + `[challenge-ingestion:upsert-vectors] Upserted ${embeddings.length} vectors into ` + + `"${indexName}" for challenge ${challengeId} (replaced any previous chunks)`, + ); + + return report; + }, +}); + +// --------------------------------------------------------------------------- +// Testing Exports +// --------------------------------------------------------------------------- + +export const _testing = { + ingestionInputSchema, + resolveChallengeStep, + chunkAndEmbedStep, + upsertVectorsStep, + normalizeProjectId, + normalizeSkills, + toFreeFormName, +}; + +// --------------------------------------------------------------------------- +// Workflow Definition +// --------------------------------------------------------------------------- + +export const challengeIngestionWorkflow = createWorkflow({ + id: 'challenge-ingestion', + description: + 'Ingests one challenge into the vector index: resolve (by id or inline) → ' + + 'chunk and embed the public description → upsert with per-challenge replacement.', + inputSchema: ingestionInputSchema, + outputSchema: ingestionReportSchema, +}) + .then(resolveChallengeStep) + .then(chunkAndEmbedStep) + .then(upsertVectorsStep) + .commit(); From 038473f2de843ebc939bd8d0a501830c404d4645 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Fri, 21 Aug 2026 11:00:31 +0300 Subject: [PATCH 08/19] feat: add challenge-bulk-ingestion workflow with paginated search fan-out Adds src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts (id: challenge-bulk-ingestion), registered in src/mastra/index.ts: - collect-challenges paginates searchChallengesTool (1-based page/perPage, stops on an empty or short page, bounded by a maxPages guard) and emits one task per matched challenge; the v6 API validates status as a scalar enum, so the default ACTIVE + COMPLETED filter runs one paginated pass per status with results de-duplicated by challenge id. - ingest-one-challenge runs challenge-ingestion imperatively (getWorkflowById -> createRun -> run.start) and converts every failure path into a failed item result, so one bad challenge cannot kill the fan-out queue or abort the run. - .foreach fan-out with a concurrency resolver reading the workflow input (default 3, clamped to 1..10). - aggregate-reports reduces the per-challenge results into processed / succeeded / failed / skipped / totalChunks / forceSplits while retaining every per-challenge entry; zero matches yields zero-valued totals. Also fixes searchChallengesTool array-filter serialization: the v6 endpoint rejects both comma-joined and bare single values for types/tracks/tags/groups with `"criteria." must be an array` (HTTP 400), so those filters are now sent as bracketed array params (key[]=value). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/mastra/index.ts | 2 + .../challenge/search-challenges-tool.test.ts | 29 +- .../tools/challenge/search-challenges-tool.ts | 23 +- .../challenge-bulk-ingestion-workflow.test.ts | 587 ++++++++++++++++++ .../challenge-bulk-ingestion-workflow.ts | 447 +++++++++++++ 5 files changed, 1080 insertions(+), 8 deletions(-) create mode 100644 src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.test.ts create mode 100644 src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 3b58163..9326086 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -2,6 +2,7 @@ import { Mastra } from '@mastra/core'; import { skillExtractionWorkflow } from './workflows/skills/skill-extraction-workflow'; import { challengeContextWorkflow } from './workflows/challenge/challenge-context-workflow'; import { challengeIngestionWorkflow } from './workflows/challenge/challenge-ingestion-workflow'; +import { challengeBulkIngestionWorkflow } from './workflows/challenge/challenge-bulk-ingestion-workflow'; import { jdAutowriteWorkflow } from './workflows/jd/jd-autowrite-workflow'; import { skillsMatchingAgent } from './agents/skills/skills-matching-agent'; import { challengeParserAgent } from './agents/challenge/challenge-parser-agent'; @@ -19,6 +20,7 @@ export const mastra = new Mastra({ skillExtractionWorkflow, challengeContextWorkflow, challengeIngestionWorkflow, + challengeBulkIngestionWorkflow, jdAutowriteWorkflow, }, agents: { skillsMatchingAgent, challengeParserAgent, jdRewriterAgent }, diff --git a/src/mastra/tools/challenge/search-challenges-tool.test.ts b/src/mastra/tools/challenge/search-challenges-tool.test.ts index 47a52fc..d0f9338 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.test.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.test.ts @@ -101,10 +101,10 @@ describe('searchChallengesTool — request construction', () => { expect(url).toContain('/v6/challenges'); expect(url).toContain('projectId=123'); expect(url).toContain('status=ACTIVE'); - expect(url).toContain('types=Challenge'); - expect(url).toContain('tracks=Development'); - expect(url).toContain('tags=react'); - expect(url).toContain('groups=acme'); + expect(url).toContain('types%5B%5D=Challenge'); + expect(url).toContain('tracks%5B%5D=Development'); + expect(url).toContain('tags%5B%5D=react'); + expect(url).toContain('groups%5B%5D=acme'); expect(url).toContain('page=1'); expect(url).toContain('perPage=10'); expect(url).toContain('sortBy=updated'); @@ -112,6 +112,27 @@ describe('searchChallengesTool — request construction', () => { expect(init.method).toBe('GET'); }); + it('sends types, tracks, tags and groups as bracketed array params', async () => { + const fetchMock = mockFetchResponse([]); + + await executeTool({ + types: ['Challenge', 'Task'], + tracks: ['Development', 'Design'], + tags: ['react', 'node'], + groups: ['acme', 'globex'], + }); + + // The v6 API rejects comma-joined and bare single array criteria with + // HTTP 400 ("must be an array"); only key[]=... is accepted. + const [url] = fetchMock.mock.calls[0] as [string, any]; + const query = decodeURIComponent(url.split('?')[1]); + expect(query).toContain('types[]=Challenge&types[]=Task'); + expect(query).toContain('tracks[]=Development&tracks[]=Design'); + expect(query).toContain('tags[]=react&tags[]=node'); + expect(query).toContain('groups[]=acme&groups[]=globex'); + expect(query).not.toContain('types=Challenge,Task'); + }); + it('includes Authorization bearer token from M2MService', async () => { const fetchMock = mockFetchResponse([]); diff --git a/src/mastra/tools/challenge/search-challenges-tool.ts b/src/mastra/tools/challenge/search-challenges-tool.ts index a7881d8..e1fd765 100644 --- a/src/mastra/tools/challenge/search-challenges-tool.ts +++ b/src/mastra/tools/challenge/search-challenges-tool.ts @@ -76,6 +76,19 @@ interface SearchChallengesInput { sortOrder?: string; } +/** + * The v6 endpoint validates `types`/`tracks`/`tags`/`groups` as arrays: both a + * comma-joined value and a single bare `key=value` are rejected with + * `"criteria." must be an array` (HTTP 400). Bracket notation + * (`key[]=a&key[]=b`) is the form the query parser accepts for one or more + * values. + */ +function appendArrayParam(params: URLSearchParams, key: string, values: string[]): void { + for (const value of values) { + params.append(`${key}[]`, value); + } +} + /** * Builds URLSearchParams from the tool input filters. * Always sets isLightweight=false — the lightweight response omits `description`, @@ -89,12 +102,14 @@ function buildQueryParams(input: SearchChallengesInput): URLSearchParams { if (input.projectId) params.set('projectId', input.projectId); if (input.projectIds?.length) params.set('projectIds', input.projectIds.join(',')); + // `status` is a scalar enum in the v6 API — repeated or comma-joined values + // are both rejected. Callers wanting several statuses must search per status. if (input.status?.length) params.set('status', input.status.join(',')); if (input.approvalStatus?.length) params.set('approvalStatus', input.approvalStatus.join(',')); - if (input.types?.length) params.set('types', input.types.join(',')); - if (input.tracks?.length) params.set('tracks', input.tracks.join(',')); - if (input.tags?.length) params.set('tags', input.tags.join(',')); - if (input.groups?.length) params.set('groups', input.groups.join(',')); + if (input.types?.length) appendArrayParam(params, 'types', input.types); + if (input.tracks?.length) appendArrayParam(params, 'tracks', input.tracks); + if (input.tags?.length) appendArrayParam(params, 'tags', input.tags); + if (input.groups?.length) appendArrayParam(params, 'groups', input.groups); if (input.updatedDateStart) params.set('updatedDateStart', input.updatedDateStart); if (input.updatedDateEnd) params.set('updatedDateEnd', input.updatedDateEnd); if (input.ids?.length) params.set('ids', input.ids.join(',')); diff --git a/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.test.ts b/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.test.ts new file mode 100644 index 0000000..d2361a0 --- /dev/null +++ b/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.test.ts @@ -0,0 +1,587 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + searchExecute: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../tools/challenge/search-challenges-tool', () => ({ + searchChallengesTool: { execute: mocks.searchExecute }, +})); + +vi.mock('../../../utils/logger', () => ({ + tcAILogger: mocks.logger, +})); + +// Import after mocks are set up +import { challengeBulkIngestionWorkflow, _testing } from './challenge-bulk-ingestion-workflow'; + +const { + collectChallengesStep, + ingestOneChallengeStep, + aggregateReportsStep, + resolveConcurrency, + foreachConcurrency, +} = _testing; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface StepExecutor { + execute: (params: Record) => Promise; +} + +async function runStep(step: unknown, params: Record): Promise { + const executable = step as unknown as StepExecutor; + return (await executable.execute({ + requestContext: {}, + getInitData: () => ({}), + ...params, + })) as T; +} + +interface ChallengeTask { + challengeId: string; + name: string; + dryRun: boolean; +} + +interface ChallengeResult { + challengeId: string; + name: string; + status: 'success' | 'failed'; + error?: string; + chunks: number; + skipped: boolean; + dryRun: boolean; + forceSplits: unknown[]; + projectId: string | null; +} + +interface BulkReport { + processed: number; + succeeded: number; + failed: number; + skipped: number; + totalChunks: number; + forceSplits: unknown[]; + dryRun: boolean; + results: ChallengeResult[]; +} + +function summary(id: string, overrides: Record = {}) { + return { + id, + name: `Challenge ${id}`, + status: 'Active', + tags: [], + skills: [], + ...overrides, + }; +} + +/** A search page result as returned by searchChallengesTool. */ +function page(challenges: Record[], pageNumber: number, perPage: number) { + return { + challenges, + total: challenges.length, + page: pageNumber, + perPage, + }; +} + +function ingestionReport(overrides: Record = {}) { + return { + chunks: 3, + forceSplits: [], + dryRun: false, + skipped: false, + projectId: '4321', + ...overrides, + }; +} + +/** Builds a mastra stub whose challenge-ingestion run resolves to `runResult`. */ +function mastraStub(runResult: unknown) { + const start = vi.fn().mockResolvedValue(runResult); + const createRun = vi.fn().mockResolvedValue({ start }); + const getWorkflowById = vi.fn().mockReturnValue({ createRun }); + return { mastra: { getWorkflowById }, getWorkflowById, createRun, start }; +} + +function challengeResult(overrides: Partial = {}): ChallengeResult { + return { + challengeId: 'c1', + name: 'Challenge c1', + status: 'success', + chunks: 2, + skipped: false, + dryRun: false, + forceSplits: [], + projectId: '4321', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Workflow shape / registration contract +// --------------------------------------------------------------------------- + +describe('challengeBulkIngestionWorkflow', () => { + it('is registered with id challenge-bulk-ingestion', () => { + expect(challengeBulkIngestionWorkflow.id).toBe('challenge-bulk-ingestion'); + }); + + it('exposes the three pipeline steps in order', () => { + const ids = Object.keys(challengeBulkIngestionWorkflow.steps); + expect(ids).toEqual([ + 'collect-challenges', + 'ingest-one-challenge', + 'aggregate-reports', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Step 1 – collect-challenges (pagination + filters) +// --------------------------------------------------------------------------- + +describe('collect-challenges step', () => { + it('defaults the status filter to ACTIVE + COMPLETED, one scalar pass each', async () => { + mocks.searchExecute.mockResolvedValue(page([], 1, 20)); + + await runStep(collectChallengesStep, { inputData: {} }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(2); + expect(mocks.searchExecute.mock.calls.map((call) => call[0].status)).toEqual([ + ['ACTIVE'], + ['COMPLETED'], + ]); + expect(mocks.searchExecute.mock.calls[0][0]).toMatchObject({ page: 1, perPage: 20 }); + }); + + it('forwards an explicit status filter instead of the default', async () => { + mocks.searchExecute.mockResolvedValueOnce(page([], 1, 20)); + + await runStep(collectChallengesStep, { + inputData: { status: ['DRAFT'] }, + }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(1); + expect(mocks.searchExecute.mock.calls[0][0].status).toEqual(['DRAFT']); + }); + + it('de-duplicates a challenge returned by more than one status pass', async () => { + mocks.searchExecute + .mockResolvedValueOnce(page([summary('a'), summary('b')], 1, 20)) + .mockResolvedValueOnce(page([summary('b'), summary('c')], 1, 20)); + + const tasks = await runStep(collectChallengesStep, { inputData: {} }); + + expect(tasks.map((task) => task.challengeId)).toEqual(['a', 'b', 'c']); + }); + + it('forwards projectId, types, tracks, tags, groups and updatedDateStart filters', async () => { + mocks.searchExecute.mockResolvedValueOnce(page([], 1, 20)); + + await runStep(collectChallengesStep, { + inputData: { + status: ['ACTIVE'], + projectId: '4321', + types: ['Challenge'], + tracks: ['Development'], + tags: ['React'], + groups: ['group-a'], + updatedDateStart: '2026-01-01T00:00:00.000Z', + }, + }); + + expect(mocks.searchExecute.mock.calls[0][0]).toMatchObject({ + projectId: '4321', + types: ['Challenge'], + tracks: ['Development'], + tags: ['React'], + groups: ['group-a'], + updatedDateStart: '2026-01-01T00:00:00.000Z', + }); + }); + + it('paginates until a short page is returned', async () => { + mocks.searchExecute + .mockResolvedValueOnce(page([summary('a'), summary('b')], 1, 2)) + .mockResolvedValueOnce(page([summary('c'), summary('d')], 2, 2)) + .mockResolvedValueOnce(page([summary('e')], 3, 2)); + + const tasks = await runStep(collectChallengesStep, { + inputData: { status: ['ACTIVE'], perPage: 2 }, + }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(3); + expect(mocks.searchExecute.mock.calls.map((call) => call[0].page)).toEqual([1, 2, 3]); + expect(tasks.map((task) => task.challengeId)).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('stops when a full page is followed by an empty page', async () => { + mocks.searchExecute + .mockResolvedValueOnce(page([summary('a'), summary('b')], 1, 2)) + .mockResolvedValueOnce(page([], 2, 2)); + + const tasks = await runStep(collectChallengesStep, { + inputData: { status: ['ACTIVE'], perPage: 2 }, + }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(2); + expect(tasks).toHaveLength(2); + }); + + it('never derives a page count from total (total is the current page length)', async () => { + // total === 1 on a full page of 1 would yield 1 page if treated as a + // grand total; the short-page rule must keep paginating instead. + mocks.searchExecute + .mockResolvedValueOnce(page([summary('a')], 1, 1)) + .mockResolvedValueOnce(page([summary('b')], 2, 1)) + .mockResolvedValueOnce(page([], 3, 1)); + + const tasks = await runStep(collectChallengesStep, { + inputData: { status: ['ACTIVE'], perPage: 1 }, + }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(3); + expect(tasks.map((task) => task.challengeId)).toEqual(['a', 'b']); + }); + + it('stops at the maxPages guard and warns', async () => { + mocks.searchExecute.mockResolvedValue(page([summary('a'), summary('b')], 1, 2)); + + const tasks = await runStep(collectChallengesStep, { + inputData: { status: ['ACTIVE'], perPage: 2, maxPages: 2 }, + }); + + expect(mocks.searchExecute).toHaveBeenCalledTimes(2); + expect(tasks).toHaveLength(2); // duplicate ids across pages are de-duplicated + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('maxPages guard')); + }); + + it('returns an empty task list when zero challenges match', async () => { + mocks.searchExecute.mockResolvedValue(page([], 1, 20)); + + const tasks = await runStep(collectChallengesStep, { inputData: {} }); + + expect(tasks).toEqual([]); + }); + + it('propagates dryRun onto every task', async () => { + mocks.searchExecute.mockResolvedValue(page([summary('a'), summary('b')], 1, 20)); + + const tasks = await runStep(collectChallengesStep, { + inputData: { dryRun: true }, + }); + + expect(tasks).toHaveLength(2); + expect(tasks.every((task) => task.dryRun === true)).toBe(true); + }); + + it('skips challenges without a usable id', async () => { + mocks.searchExecute.mockResolvedValue( + page([summary('a'), { ...summary('x'), id: '' }, { ...summary('y'), id: undefined }], 1, 20), + ); + + const tasks = await runStep(collectChallengesStep, { inputData: {} }); + + expect(tasks.map((task) => task.challengeId)).toEqual(['a']); + }); + + it('throws when the search tool returns a validation error instead of results', async () => { + mocks.searchExecute.mockResolvedValueOnce({ error: { message: 'bad input' } }); + + await expect( + runStep(collectChallengesStep, { inputData: {} }), + ).rejects.toThrow('did not return results for page 1'); + }); + + it('logs and rethrows a search failure (a broken search aborts the run)', async () => { + mocks.searchExecute.mockRejectedValueOnce( + new Error('Failed to search challenges (HTTP 500)'), + ); + + await expect( + runStep(collectChallengesStep, { inputData: {} }), + ).rejects.toThrow('Failed to search challenges (HTTP 500)'); + + expect(mocks.logger.error).toHaveBeenCalledWith( + expect.stringContaining('Search failed on page 1'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Step 2 – ingest-one-challenge (nested workflow invocation + isolation) +// --------------------------------------------------------------------------- + +describe('ingest-one-challenge step', () => { + const task: ChallengeTask = { challengeId: 'c1', name: 'Challenge c1', dryRun: false }; + + it('runs challenge-ingestion via getWorkflowById + createRun + run.start', async () => { + const stub = mastraStub({ status: 'success', result: ingestionReport() }); + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra: stub.mastra, + }); + + expect(stub.getWorkflowById).toHaveBeenCalledWith('challenge-ingestion'); + expect(stub.createRun).toHaveBeenCalledTimes(1); + expect(stub.start).toHaveBeenCalledWith( + expect.objectContaining({ inputData: { challengeId: 'c1', dryRun: false } }), + ); + expect(result).toMatchObject({ + challengeId: 'c1', + status: 'success', + chunks: 3, + skipped: false, + projectId: '4321', + }); + }); + + it('propagates dryRun into the nested run input', async () => { + const stub = mastraStub({ + status: 'success', + result: ingestionReport({ dryRun: true }), + }); + + const result = await runStep(ingestOneChallengeStep, { + inputData: { ...task, dryRun: true }, + mastra: stub.mastra, + }); + + expect(stub.start).toHaveBeenCalledWith( + expect.objectContaining({ inputData: { challengeId: 'c1', dryRun: true } }), + ); + expect(result.dryRun).toBe(true); + }); + + it('carries through a skipped ingestion report', async () => { + const stub = mastraStub({ + status: 'success', + result: ingestionReport({ chunks: 0, skipped: true, projectId: null }), + }); + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra: stub.mastra, + }); + + expect(result).toMatchObject({ status: 'success', skipped: true, chunks: 0, projectId: null }); + }); + + it('returns a failed result (without throwing) when the nested run fails', async () => { + const stub = mastraStub({ status: 'failed', error: new Error('embedding failure') }); + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra: stub.mastra, + }); + + expect(result).toMatchObject({ + challengeId: 'c1', + status: 'failed', + error: 'embedding failure', + chunks: 0, + }); + }); + + it('returns a failed result for a non-success, non-failed run status', async () => { + const stub = mastraStub({ status: 'suspended' }); + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra: stub.mastra, + }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('suspended'); + }); + + it('returns a failed result when getWorkflowById throws', async () => { + const mastra = { + getWorkflowById: vi.fn(() => { + throw new Error('workflow lookup exploded'); + }), + }; + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra, + }); + + expect(result).toMatchObject({ status: 'failed', error: 'workflow lookup exploded' }); + }); + + it('returns a failed result when the ingestion workflow is not registered', async () => { + const mastra = { getWorkflowById: vi.fn().mockReturnValue(undefined) }; + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra, + }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('challenge-ingestion'); + }); + + it('returns a failed result when createRun throws', async () => { + const mastra = { + getWorkflowById: vi.fn().mockReturnValue({ + createRun: vi.fn().mockRejectedValue(new Error('createRun exploded')), + }), + }; + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra, + }); + + expect(result).toMatchObject({ status: 'failed', error: 'createRun exploded' }); + }); + + it('returns a failed result when run.start throws', async () => { + const mastra = { + getWorkflowById: vi.fn().mockReturnValue({ + createRun: vi.fn().mockResolvedValue({ + start: vi.fn().mockRejectedValue(new Error('run.start exploded')), + }), + }), + }; + + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra, + }); + + expect(result).toMatchObject({ status: 'failed', error: 'run.start exploded' }); + }); + + it('returns a failed result when mastra is unavailable', async () => { + const result = await runStep(ingestOneChallengeStep, { + inputData: task, + mastra: undefined, + }); + + expect(result.status).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// Concurrency resolution +// --------------------------------------------------------------------------- + +describe('concurrency resolution', () => { + it('defaults to 3 when concurrency is absent', () => { + expect(foreachConcurrency({ getInitData: () => ({}) })).toBe(3); + expect(foreachConcurrency({ getInitData: () => undefined })).toBe(3); + }); + + it('honours an explicit concurrency from the workflow input', () => { + expect(foreachConcurrency({ getInitData: () => ({ concurrency: 7 }) })).toBe(7); + }); + + it('clamps concurrency to the bounded range', () => { + expect(resolveConcurrency(0)).toBe(1); + expect(resolveConcurrency(-5)).toBe(1); + expect(resolveConcurrency(100)).toBe(10); + expect(resolveConcurrency(2.7)).toBe(2); + expect(resolveConcurrency(Number.NaN)).toBe(3); + expect(resolveConcurrency('many')).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Step 3 – aggregate-reports +// --------------------------------------------------------------------------- + +describe('aggregate-reports step', () => { + it('aggregates totals while retaining per-challenge entries', async () => { + const results: ChallengeResult[] = [ + challengeResult({ challengeId: 'a', chunks: 2 }), + challengeResult({ + challengeId: 'b', + chunks: 4, + forceSplits: [ + { + recordId: 'b', + chunkIndex: 1, + originalTokens: 3000, + resultingChunks: 2, + reason: 'atomic block too large', + }, + ], + }), + challengeResult({ challengeId: 'c', chunks: 0, skipped: true }), + challengeResult({ challengeId: 'd', status: 'failed', error: 'boom', chunks: 0 }), + ]; + + const report = await runStep(aggregateReportsStep, { inputData: results }); + + expect(report).toMatchObject({ + processed: 4, + succeeded: 3, + failed: 1, + skipped: 1, + totalChunks: 6, + dryRun: false, + }); + expect(report.forceSplits).toHaveLength(1); + expect(report.results.map((entry) => entry.challengeId)).toEqual(['a', 'b', 'c', 'd']); + }); + + it('produces zero-valued totals for zero matching challenges', async () => { + const report = await runStep(aggregateReportsStep, { inputData: [] }); + + expect(report).toEqual({ + processed: 0, + succeeded: 0, + failed: 0, + skipped: 0, + totalChunks: 0, + forceSplits: [], + dryRun: false, + results: [], + }); + }); + + it('reports dryRun: true from the workflow input even with zero matches', async () => { + const report = await runStep(aggregateReportsStep, { + inputData: [], + getInitData: () => ({ dryRun: true }), + }); + + expect(report.dryRun).toBe(true); + expect(report.processed).toBe(0); + }); + + it('does not abort when every challenge failed', async () => { + const results: ChallengeResult[] = [ + challengeResult({ challengeId: 'a', status: 'failed', error: 'boom', chunks: 0 }), + challengeResult({ challengeId: 'b', status: 'failed', error: 'bang', chunks: 0 }), + ]; + + const report = await runStep(aggregateReportsStep, { inputData: results }); + + expect(report).toMatchObject({ processed: 2, succeeded: 0, failed: 2, totalChunks: 0 }); + expect(report.results.map((entry) => entry.error)).toEqual(['boom', 'bang']); + }); +}); diff --git a/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts b/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts new file mode 100644 index 0000000..b547728 --- /dev/null +++ b/src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts @@ -0,0 +1,447 @@ +/** + * Challenge bulk ingestion workflow (id: challenge-bulk-ingestion). + * + * Paginates through searchChallengesTool with the supplied filters and runs the + * single-challenge `challenge-ingestion` workflow once per matched challenge, + * fanned out with bounded concurrency, then aggregates the per-challenge + * reports. + * + * The nested workflow is invoked imperatively (getWorkflowById → createRun → + * run.start) rather than composed as a foreach step: a composed workflow step + * rethrows the nested failure, which would kill the fan-out queue and abort the + * whole run on the first bad challenge. + */ + +import { createWorkflow, createStep } from '@mastra/core/workflows'; +import { noopObserve } from '@mastra/core/tools'; +import { z } from 'zod'; +import { tcAILogger } from '../../../utils/logger'; +import { searchChallengesTool } from '../../tools/challenge/search-challenges-tool'; + +const DEFAULT_STATUS = ['ACTIVE', 'COMPLETED']; +const DEFAULT_CONCURRENCY = 3; +const MIN_CONCURRENCY = 1; +const MAX_CONCURRENCY = 10; +const DEFAULT_PER_PAGE = 20; +const DEFAULT_MAX_PAGES = 50; +const INGESTION_WORKFLOW_ID = 'challenge-ingestion'; + +// --------------------------------------------------------------------------- +// Zod Schemas +// --------------------------------------------------------------------------- + +const bulkInputSchema = z.object({ + status: z + .array(z.string()) + .optional() + .describe('Challenge statuses to ingest (defaults to ACTIVE + COMPLETED)'), + projectId: z + .string() + .optional() + .describe('Restrict the search to a single project (string — D10)'), + types: z.array(z.string()).optional().describe('Challenge type filter'), + tracks: z.array(z.string()).optional().describe('Challenge track filter'), + tags: z.array(z.string()).optional().describe('Challenge tag filter'), + groups: z.array(z.string()).optional().describe('Challenge group filter'), + updatedDateStart: z + .string() + .optional() + .describe('Only ingest challenges updated on or after this date (incremental sync)'), + dryRun: z + .boolean() + .optional() + .default(false) + .describe('Chunk and embed every matched challenge but skip all vector upserts'), + concurrency: z + .number() + .int() + .optional() + .default(DEFAULT_CONCURRENCY) + .describe(`Fan-out width for per-challenge ingestion (clamped to ${MIN_CONCURRENCY}-${MAX_CONCURRENCY})`), + perPage: z + .number() + .int() + .optional() + .default(DEFAULT_PER_PAGE) + .describe('Page size used when paginating searchChallengesTool'), + maxPages: z + .number() + .int() + .optional() + .default(DEFAULT_MAX_PAGES) + .describe('Safety guard on the number of search pages fetched'), +}); + +/** One unit of per-challenge work handed to the fan-out step. */ +const challengeTaskSchema = z.object({ + challengeId: z.string(), + name: z.string(), + dryRun: z.boolean(), +}); + +const reportedForceSplitSchema = z.object({ + recordId: z.string(), + chunkIndex: z.number(), + originalTokens: z.number(), + resultingChunks: z.number(), + reason: z.string(), +}); + +const challengeResultSchema = z.object({ + challengeId: z.string(), + name: z.string(), + status: z.enum(['success', 'failed']), + error: z.string().optional(), + chunks: z.number(), + skipped: z.boolean(), + dryRun: z.boolean(), + forceSplits: z.array(reportedForceSplitSchema), + projectId: z.string().nullable(), +}); + +const bulkReportSchema = z.object({ + processed: z.number().describe('Number of matched challenges that were run'), + succeeded: z.number(), + failed: z.number(), + skipped: z.number().describe('Successful runs whose description was empty after processing'), + totalChunks: z.number(), + forceSplits: z.array(reportedForceSplitSchema), + dryRun: z.boolean(), + results: z.array(challengeResultSchema).describe('Per-challenge reports, in search order'), +}); + +export type ChallengeBulkIngestionReport = z.infer; + +type ChallengeResult = z.infer; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Bounded fan-out width: never 0 (would stall the queue), never unbounded. */ +function resolveConcurrency(value: unknown): number { + const requested = + typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_CONCURRENCY; + return Math.min(MAX_CONCURRENCY, Math.max(MIN_CONCURRENCY, requested)); +} + +function foreachConcurrency({ getInitData }: { getInitData: () => unknown }): number { + const init = getInitData() as { concurrency?: number } | undefined; + return resolveConcurrency(init?.concurrency); +} + +/** + * Minimal shape of the nested run, kept structural so the deeply generic + * Workflow/Run types don't leak into this workflow's own inference. + */ +interface NestedRunResult { + status: string; + result?: unknown; + error?: unknown; +} + +interface NestedWorkflow { + createRun: () => Promise<{ + start: (args: { inputData: unknown; requestContext?: unknown }) => Promise; + }>; +} + +interface WorkflowRegistry { + getWorkflowById?: (id: string) => unknown; +} + +// --------------------------------------------------------------------------- +// Step 1 – Collect matching challenges (paginated search) +// --------------------------------------------------------------------------- + +const collectChallengesStep = createStep({ + id: 'collect-challenges', + description: + 'Paginates searchChallengesTool with the supplied filters and returns one ingestion ' + + 'task per matched challenge.', + inputSchema: bulkInputSchema, + outputSchema: z.array(challengeTaskSchema), + execute: async ({ inputData, requestContext }) => { + const { + status, + projectId, + types, + tracks, + tags, + groups, + updatedDateStart, + dryRun = false, + perPage = DEFAULT_PER_PAGE, + maxPages = DEFAULT_MAX_PAGES, + } = inputData; + + const effectiveStatus = status?.length ? status : DEFAULT_STATUS; + const effectivePerPage = perPage > 0 ? perPage : DEFAULT_PER_PAGE; + const effectiveMaxPages = maxPages > 0 ? maxPages : DEFAULT_MAX_PAGES; + + tcAILogger.info( + `[challenge-bulk-ingestion:collect-challenges] Searching challenges — ` + + `status: ${effectiveStatus.join(',')}, projectId: ${projectId ?? 'any'}, ` + + `types: ${types?.join(',') || 'any'}, tracks: ${tracks?.join(',') || 'any'}, ` + + `tags: ${tags?.join(',') || 'any'}, groups: ${groups?.join(',') || 'any'}, ` + + `updatedDateStart: ${updatedDateStart ?? 'none'}, perPage: ${effectivePerPage}, ` + + `maxPages: ${effectiveMaxPages}, dryRun: ${dryRun}`, + ); + + const tasks: z.infer[] = []; + const seen = new Set(); + let pagesFetched = 0; + + // `status` is a scalar enum in the v6 API (a list is rejected with HTTP + // 400), so each status gets its own paginated pass; `seen` de-duplicates + // any challenge that shows up under more than one pass. + for (const singleStatus of effectiveStatus) { + for (let page = 1; page <= effectiveMaxPages; page++) { + pagesFetched++; + let result; + try { + result = await searchChallengesTool.execute?.( + { + status: [singleStatus], + projectId, + types, + tracks, + tags, + groups, + updatedDateStart, + page, + perPage: effectivePerPage, + }, + { requestContext, observe: noopObserve }, + ); + } catch (error) { + tcAILogger.error( + `[challenge-bulk-ingestion:collect-challenges] Search failed on page ${page} ` + + `for status ${singleStatus} (perPage: ${effectivePerPage}, collected so far: ` + + `${tasks.length}): ${errorMessage(error)}`, + ); + throw error; + } + + if (!result || 'error' in result) { + const detail = result ? JSON.stringify(result.error) : 'no result returned'; + tcAILogger.error( + `[challenge-bulk-ingestion:collect-challenges] Search rejected on page ${page} ` + + `for status ${singleStatus}: ${detail}`, + ); + throw new Error( + `[challenge-bulk-ingestion:collect-challenges] searchChallengesTool did not return ` + + `results for page ${page} (status ${singleStatus}): ${detail}`, + ); + } + + const challenges = result.challenges ?? []; + + for (const challenge of challenges) { + const challengeId = challenge?.id; + if (typeof challengeId !== 'string' || challengeId.length === 0) { + continue; + } + if (seen.has(challengeId)) { + continue; + } + seen.add(challengeId); + tasks.push({ challengeId, name: challenge.name ?? '', dryRun }); + } + + // `total` from the tool is the CURRENT page length (the v6 + // endpoint returns a bare array), so a short or empty page is + // the only reliable end-of-results signal. + if (challenges.length === 0 || challenges.length < effectivePerPage) { + break; + } + + if (page === effectiveMaxPages) { + tcAILogger.warn( + `[challenge-bulk-ingestion:collect-challenges] Reached maxPages guard ` + + `(${effectiveMaxPages}) for status ${singleStatus}; additional matching ` + + 'challenges may not be ingested', + ); + } + } + } + + tcAILogger.info( + `[challenge-bulk-ingestion:collect-challenges] Collected ${tasks.length} challenges ` + + `across ${pagesFetched} page(s) / ${effectiveStatus.length} status pass(es)`, + ); + + return tasks; + }, +}); + +// --------------------------------------------------------------------------- +// Step 2 – Ingest one challenge (fanned out, never throws) +// --------------------------------------------------------------------------- + +const ingestOneChallengeStep = createStep({ + id: 'ingest-one-challenge', + description: + 'Runs the challenge-ingestion workflow for a single challenge and converts any failure ' + + 'into a failed result so one bad challenge cannot abort the bulk run.', + inputSchema: challengeTaskSchema, + outputSchema: challengeResultSchema, + execute: async ({ inputData, mastra, requestContext }): Promise => { + const { challengeId, name, dryRun } = inputData; + + const failure = (error: string): ChallengeResult => { + tcAILogger.error( + `[challenge-bulk-ingestion:ingest-one-challenge] Challenge ${challengeId} failed: ${error}`, + ); + return { + challengeId, + name, + status: 'failed', + error, + chunks: 0, + skipped: false, + dryRun, + forceSplits: [], + projectId: null, + }; + }; + + // Resolution, run creation and run start are all inside one try/catch: + // every one of them can throw, and a throw here would kill the fan-out + // queue for the remaining challenges. + try { + const registry = mastra as unknown as WorkflowRegistry | undefined; + const workflow = registry?.getWorkflowById?.(INGESTION_WORKFLOW_ID) as + | NestedWorkflow + | undefined; + + if (!workflow) { + return failure(`workflow "${INGESTION_WORKFLOW_ID}" is not registered`); + } + + const run = await workflow.createRun(); + const runResult = await run.start({ + inputData: { challengeId, dryRun }, + requestContext, + }); + + if (!runResult || runResult.status !== 'success') { + const status = runResult?.status ?? 'unknown'; + const detail = runResult?.error + ? errorMessage(runResult.error) + : `challenge-ingestion run ended with status "${status}"`; + return failure(detail); + } + + const report = (runResult.result ?? {}) as { + chunks?: number; + skipped?: boolean; + dryRun?: boolean; + forceSplits?: z.infer[]; + projectId?: string | null; + }; + + tcAILogger.info( + `[challenge-bulk-ingestion:ingest-one-challenge] Challenge ${challengeId} ingested — ` + + `chunks: ${report.chunks ?? 0}, skipped: ${report.skipped ?? false}, dryRun: ${dryRun}`, + ); + + return { + challengeId, + name, + status: 'success', + chunks: report.chunks ?? 0, + skipped: report.skipped ?? false, + dryRun: report.dryRun ?? dryRun, + forceSplits: report.forceSplits ?? [], + projectId: report.projectId ?? null, + }; + } catch (error) { + return failure(errorMessage(error)); + } + }, +}); + +// --------------------------------------------------------------------------- +// Step 3 – Aggregate the per-challenge reports +// --------------------------------------------------------------------------- + +const aggregateReportsStep = createStep({ + id: 'aggregate-reports', + description: + 'Reduces the per-challenge results into run totals while retaining every ' + + 'per-challenge entry.', + inputSchema: z.array(challengeResultSchema), + outputSchema: bulkReportSchema, + execute: async ({ inputData, getInitData }) => { + const results = inputData ?? []; + const init = getInitData() as { dryRun?: boolean } | undefined; + + const succeeded = results.filter((result) => result.status === 'success').length; + const failed = results.filter((result) => result.status === 'failed').length; + const skipped = results.filter((result) => result.status === 'success' && result.skipped).length; + const totalChunks = results.reduce((sum, result) => sum + result.chunks, 0); + const forceSplits = results.flatMap((result) => result.forceSplits); + const dryRun = + typeof init?.dryRun === 'boolean' + ? init.dryRun + : results.length > 0 && results.every((result) => result.dryRun); + + tcAILogger.info( + `[challenge-bulk-ingestion:aggregate-reports] Processed ${results.length} challenges — ` + + `succeeded: ${succeeded}, failed: ${failed}, skipped: ${skipped}, ` + + `chunks: ${totalChunks}, forceSplits: ${forceSplits.length}, dryRun: ${dryRun}`, + ); + + return { + processed: results.length, + succeeded, + failed, + skipped, + totalChunks, + forceSplits, + dryRun, + results, + }; + }, +}); + +// --------------------------------------------------------------------------- +// Testing Exports +// --------------------------------------------------------------------------- + +export const _testing = { + bulkInputSchema, + collectChallengesStep, + ingestOneChallengeStep, + aggregateReportsStep, + resolveConcurrency, + foreachConcurrency, + DEFAULT_STATUS, + DEFAULT_CONCURRENCY, + MIN_CONCURRENCY, + MAX_CONCURRENCY, + DEFAULT_PER_PAGE, + DEFAULT_MAX_PAGES, +}; + +// --------------------------------------------------------------------------- +// Workflow Definition +// --------------------------------------------------------------------------- + +export const challengeBulkIngestionWorkflow = createWorkflow({ + id: 'challenge-bulk-ingestion', + description: + 'Bulk-ingests challenges into the vector index: paginated search → bounded per-challenge ' + + 'challenge-ingestion fan-out → aggregated report.', + inputSchema: bulkInputSchema, + outputSchema: bulkReportSchema, +}) + .then(collectChallengesStep) + .foreach(ingestOneChallengeStep, { concurrency: foreachConcurrency }) + .then(aggregateReportsStep) + .commit(); From 75d5a3741fa5e744f78cfc7d91dbaed71022c2ec Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 09:56:00 +0300 Subject: [PATCH 09/19] docs: add ADR 0001 for challenges vector RAG integration Records the decision, hazards, phased plan, data model, and security notes for porting tc-challenges-vector-rag's ingestion and retrieval pipeline into tc-ai-api. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MkcggGkcnz73GXUDR7mgrE --- .../0001-integrate-challenges-vector-rag.md | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 docs/adr/0001-integrate-challenges-vector-rag.md diff --git a/docs/adr/0001-integrate-challenges-vector-rag.md b/docs/adr/0001-integrate-challenges-vector-rag.md new file mode 100644 index 0000000..1c123de --- /dev/null +++ b/docs/adr/0001-integrate-challenges-vector-rag.md @@ -0,0 +1,793 @@ +# ADR 0001 — Integrate the TC Challenges Vector RAG ingestion pipeline and retrieval into tc-ai-api + +- **Status:** **Accepted** +- **Date:** 2026-08-18 (accepted 2026-08-19) +- **Target branch:** `challenges-rag` +- **Source repository:** `TC-challenges-vector-rag` (`https://git.topcoder.com/Topcoder-Platform/TC-challenges-vector-rag.git`, branch `ch1`) + +## Context + +`tc-challenges-vector-rag` is a standalone prototype that ingests Topcoder challenge +descriptions from CSV files into PostgreSQL/pgvector and exposes a Mastra agent +("Topcoder Challenge Assistant") that answers challenge questions through a +`vectorQuery` tool. It contains three separable pieces of value: + +1. A content-processing and chunking pipeline (HTML→Markdown, BOM/frontmatter + handling, two-pass markdown-header + size-based chunking with code-block and + table atomicity, token-safety force-splitting). +2. An idempotent embedding/upsert pipeline (deterministic vector IDs, per-challenge + delete-then-insert as one transaction, retry with backoff, structured run reports). +3. A retrieval tool with metadata filtering (`skills`, `type`, `track`) plus the + agent instructions that drive it. + +The goal is to move all three into `tc-ai-api` without regressing the existing +`skill-extraction`, `challenge-context`, and `jd-autowrite` workflows. + +### Constraints discovered during analysis + +| Concern | `tc-challenges-vector-rag` | `tc-ai-api` | +| --- | --- | --- | +| `@mastra/core` | 0.24.3 | 1.57.0 | +| `@mastra/pg` | 0.17.8 | 1.19.0 | +| `@mastra/rag` | 1.3.4 | not installed | +| `zod` | 3.x | 4.x | +| Embeddings | `ollama` client, `nomic-embed-text` (768d) | `ai-sdk-ollama`, `@ai-sdk/amazon-bedrock`, `ai` v6 | +| Config loading | `dotenv` in every module | env injected by `mastra dev` / ECS task definition | +| TypeScript execution | `ts-node` | `mastra` CLI (no `ts-node`/`tsx`) | +| Logging | custom hierarchical logger + `console` interception | `PinoLogger` (`tcAILogger`) | + +API verification against the versions actually installed in `tc-ai-api`: + +- `upsert({ ..., deleteFilter })` still exists in core 1.57 + (`@mastra/core/dist/vector/types.d.ts`), so the atomic per-challenge replace + semantics survive the upgrade. +- `MDocument.chunk({ strategy: 'markdown', headers, stripHeaders, maxSize, overlap })` + matches the current documented signature. +- `PgVector` in pg 1.19 now **requires** an `id`, and additionally supports + `schemaName`, `disableInit`, `metadataIndexes`, and a native `minScore` on + `query()`. +- `@mastra/rag@2.5.0` is the release compatible with core 1.57 + (peers: `@mastra/core >=1.0.0 <2.0.0`, `zod ^3.25 || ^4`). +- `@ai-sdk/amazon-bedrock` exposes `.embedding('amazon.titan-embed-text-v2:0')` + and `ai-sdk-ollama` exposes `.embedding('nomic-embed-text')`, so + provider-switched embeddings need no new packages; `ai` v6 exports + `embed`/`embedMany`. + +### Two hazards in a verbatim port + +1. `src/lib/config.ts` runs `validateConfig()` at module load and **throws** when + `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB` are absent. Imported into the + `tc-ai-api` server graph, a missing RAG variable would prevent the whole service + from booting. +2. `Logger.interceptConsole()` globally replaces `console`, which would corrupt + Pino's structured server logs for every request, not just ingestion. + +Neither can be copied as-is. + +## Decisions + +| # | Decision | Rationale | +| --- | --- | --- | +| D1 | Ingestion is primarily a **Mastra workflow** (`challenge-ingestion`) triggered by challenge ID, with a thin CSV CLI wrapping the same workflow for bulk backfill. | `tc-ai-api`'s API surface is auto-generated from registered workflows; a CSV-only CLI is unreachable on ECS Fargate. `fetchChallengeTool` (M2M `GET /v6/challenges/:id`) already returns exactly the fields the CSV supplied, so the Challenge API replaces CSV as the production source while CSV remains available for backfill. One code path serves both. | +| D2 | **Provider-switched embeddings** behind a `createEmbeddingModel` factory mirroring the existing `createModel`: production uses **`amazon.titan-embed-text-v2:0` at 1024 dimensions** (Bedrock), local development uses Ollama `nomic-embed-text` at 768. | Matches how every existing agent selects its provider, and avoids adding the `ollama` package. Titan v2's default 1024 dimensions are kept rather than the reduced 512/256 variants, trading storage for retrieval quality. | +| D3 | Vectors live in the **existing `MASTRA_DB_CONNECTION` database, `ai` schema** (`PgVector` `schemaName`). The `vector` extension is assumed enabled and the runtime role is assumed to hold DDL rights, so `PgVector.createIndex()` performs all schema/table/index creation. | One database, one credential, one backup story; no new connection string to provision per environment, and no hand-maintained DDL script to keep in sync with Mastra's expected table shape. | +| D4 | **Plain port** of the source files, refactored into `tc-ai-api` conventions, as new commits on `challenges-rag`. No `git subtree` merge. | A subtree merge introduces an unrelated root plus a nested layout that would be refactored away immediately; the original history remains available in the source repository. | +| D5 | RAG configuration is resolved and validated **lazily**, never at module load. | Directly mitigates hazard 1: absent RAG variables must not be able to break service startup. | +| D6 | `console` interception is confined to the CLI script and dropped from the server path; the workflow logs through `tcAILogger` and returns a structured report. | Directly mitigates hazard 2. | +| D7 | Index dimension is guarded at runtime via `describeIndex()`, and the index name is environment-overridable (`VECTOR_INDEX_NAME`). | D2 + D3 together mean local (768d Ollama) and production (1024d Titan) write into the same schema; a mismatch must fail loudly rather than corrupt the index. | +| D8 | Retrieval is exposed **twice**: the `challenge-search-agent` for synthesised natural-language answers, and a deterministic `challenge-search` workflow returning **raw ranked results with no LLM synthesis**. | Callers that only need ranked challenges (list UIs, other services, evaluation harnesses) should not pay LLM latency, cost, or non-determinism. Both paths share one tool, so filters and thresholds cannot drift. | +| D9 | Event-driven ingestion (triggering on challenge activation/update) is **deferred**; the first release is on-demand invocation only. | Keeps the initial surface small; the workflow is already idempotent per challenge, so an event trigger can be layered on later without reworking it. | +| D10 | Metadata carries **only `projectId`** as an opaque project reference (nullable, stored as a string). No project attributes are denormalized into metadata, no project text is indexed, and ingestion makes no call to projects-api. Consumers that need project detail resolve it in a **subsequent** call to `GET /v6/projects/:projectId`. | Keeps ingestion dependent on a single upstream API, and eliminates the denormalization staleness problem outright: there is no copied project field that can drift when a project is renamed, re-typed, or reassigned to a different billing account, so no refresh mechanism, no re-ingestion trigger, and no staleness signal are needed. It also keeps customer-identifying commercial data out of a store searched by similarity, leaving authorization where it belongs — on the projects-api call, which is already scope-guarded. `projectId` alone still supports project-scoped filtering (`{ projectId: { $in: [...] } }`) and roll-up of challenge hits by project. | +| D11 | The **Challenge Search API** (`GET /v6/challenges`) is the primary bulk ingestion source, not CSV. It supports `projectId`/`projectIds`, `status`, `approvalStatus`, `types`/`tracks`, `tags`/`groups`, `updatedDateStart`/`updatedDateEnd`, and `page`/`perPage` pagination, enabling project-scoped fan-out, status-filtered corpus building, and incremental sync by `updatedDateStart`. CSV backfill remains as a secondary path for offline/air-gapped environments. | The search endpoint is already M2M-authenticated (`scopes: [READ]`) and returns the full challenge payload (including `description`) when `isLightweight` is false (the default). It eliminates the need to export and ship CSV files, and its `updatedDateStart` filter makes incremental sync a single paginated call rather than a full re-export. | +| D12 | `type` and `track` are stored and filtered as **free-form strings**, not Zod enums. `ChallengeType` is a reference table (`model ChallengeType` with `name`, `isActive`, `isTask`, `isLegacy`), not an enum — new types can be added at runtime. `ChallengeTrackEnum` has four values (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, `QUALITY_ASSURANCE`) but the API returns `track.name` (human-readable, e.g. "Quality Assurance"), not the enum value, and tracks can be deactivated via `isActive`. | The prototype hardcoded `['Challenge', 'First2Finish', 'Marathon Match']` as a Zod enum for type and `['Data Science', 'Design', 'Development']` for track, missing `Quality Assurance` and rejecting any future type. Treating both as strings (with the known values documented for reference but not enforced) is forward-compatible with the reference-table model and avoids ingestion failures when a new type or track is added. | + +## Implementation plan + +### Phase 0 — Baseline and dependencies + +1. Record a green baseline on `challenges-rag`: `pnpm lint`, `pnpm test`, `pnpm run build`. +2. `pnpm add @mastra/rag@^2.5.0 turndown js-tiktoken csv-parse` + and `pnpm add -D @types/turndown tsx`. + Deliberately **not** added: `ollama` (use `ai-sdk-ollama`'s `.embedding()` with + `embedMany` from `ai`), `dotenv` (Node `--env-file`), `commander` + (use `node:util` `parseArgs`). +3. Confirm against the freshly installed `@mastra/rag` types and embedded docs + (`node_modules/@mastra/rag/dist/*.d.ts`, `dist/docs`) that `MDocument.chunk()` + returns an **array of chunks, each exposing `.text`** — the documented pipeline is + `chunks.map(chunk => chunk.text)`, which is also why the stored metadata field is + named `text` (see Data model) — and that the package is zod-4 compatible. Adjust + the ported chunking code to whatever the installed version actually returns. + +### Phase 1 — Config and embedding provider (additive) + +- **`src/config/rag.config.ts`** — replaces `src/lib/config.ts`. Exports + `getRagConfig()`, resolved and validated on first RAG use (per D5); **no + module-load throw**. Holds a provider/model → `{ dimension, maxContextWindow }` + map keyed `provider/modelId` — `TC-Ollama/nomic-embed-text` → 768/2048 (local + default) and `AWSBedrock/amazon.titan-embed-text-v2:0` → 1024/8192 (production + default, per D2) — chunk sizes, + `VECTOR_SEARCH_THRESHOLD`, `VECTOR_INDEX_NAME` (SQL-identifier validated, as in + the original), `RAG_TOP_K`. Per D12, `type` and `track` are **not** hardcoded + enums — the config documents the known `ChallengeTrackEnum` values + (`DESIGN`, `DATA_SCIENCE`, `DEVELOPMENT`, `QUALITY_ASSURANCE`) and the current + `ChallengeType` reference-table names for readability, but the query tool + accepts any string. Database settings reuse `MASTRA_DB_CONNECTION` and + `MASTRA_DB_SCHEMA` (default `ai`). +- **`src/utils/providers/embedding-factory.ts`** — `createEmbeddingModel(provider, modelId)` + switch mirroring `createModel`, using `ollama.embedding(modelId)` and + `createBedrockProvider().embedding(modelId)`, logging via `tcAILogger`. Re-exported + from `src/utils/index.ts`. + +New environment keys, all optional with defaults: `RAG_EMBEDDING_PROVIDER`, +`RAG_EMBEDDING_MODEL_ID`, `VECTOR_INDEX_NAME`, `VECTOR_SEARCH_THRESHOLD`, +`RAG_CHUNK_MAX_SIZE`, `RAG_CHUNK_OVERLAP`, `RAG_TOP_K`, +`CHALLENGE_SEARCH_AI_PROVIDER`, `CHALLENGE_SEARCH_AI_MODEL_ID`. + +### Phase 2 — Vector store + +- **`src/mastra/vector/challenge-vector-store.ts`** — lazy singleton + `getChallengeVectorStore()` returning + `new PgVector({ id: 'tc-ai-api-rag-vector', connectionString: process.env.MASTRA_DB_CONNECTION!, schemaName: process.env.MASTRA_DB_SCHEMA || 'ai' })` + (pg 1.19 requires `id`), plus `ensureChallengeIndex()` which idempotently calls + `createIndex({ indexName, dimension, metric: 'cosine', indexConfig: { type: 'hnsw' }, metadataIndexes: ['challengeId', 'projectId', 'track'] })` + and enforces the D7 dimension guard: compare `describeIndex().dimension` against + the configured model's dimension and throw an actionable error + ("index X is 768-dim, configured model is 1024-dim — set `VECTOR_INDEX_NAME` or + reindex"). No `disconnect()` on request paths (singleton, as in the original). +- Per D3, all DDL (schema, `vector` extension, table, indexes) is delegated to + `PgVector.createIndex()`; the source repository's `docker/init-db.sh` and + `init-db.sql` are **not ported**. HNSW replaces the original IVFFlat (better recall + at this data size, and `createIndex` manages its lifecycle). The library still + honours `MASTRA_DISABLE_STORAGE_INIT` should an environment ever need DDL + suppressed, but no code path depends on it. +- `docker/docker-compose.yml` is carried over unchanged for local development + (`pgvector/pgvector:pg16`), minus the `init-db` volume mounts. + +### Phase 3 — Port the pure library code, with unit tests + +Straight ports (pure, no I/O, no `console`): + +- **`src/mastra/rag/content.ts`** ← `src/lib/content.ts` — `normalizeLineEndings`, + BOM-aware `trim`, `stripFrontmatter`, `htmlToMarkdown` (Turndown), `parseSkills`, + `enrichChunksWithChallengeName`. +- **`src/mastra/rag/chunking.ts`** ← the two-pass chunking currently inlined in + `ingest.ts`, extracted as + `chunkChallengeDescription(content, { maxSize, overlap, contextWindow })` + returning `{ chunks, forceSplits }` instead of mutating a report and writing to + `console`. Preserves the markdown-header pass, the code-block/table atomicity + check, the `js-tiktoken` `cl100k_base` safety check, and the force-split fallback. +- **`src/mastra/rag/ingestion-utils.ts`** ← `src/lib/utils.ts` minus `confirmAction` + (readline belongs to the CLI): `withRetry`, `sleep`, `REQUIRED_COLUMNS`, + `validateColumns`, `validateRecord`, `generateDeterministicId`. +- **`src/mastra/rag/types.ts`** ← `src/lib/types.ts`, with types derived from + `getRagConfig()`. + +**Rename to apply while porting:** the metadata field holding the chunk text is +`text`, not the prototype's `content`. It appears in the chunk-metadata builder, the +query tool, and every test fixture, so it is a mechanical rename applied once here and +carried through Phases 4 and 5 — see "Why the chunk text field is `text`, not +`content`" under Data model for the reasoning. There is no populated table to migrate, +so this must be settled before the first ingestion run rather than after. + +Unit tests colocated as `src/**/*.test.ts` (matching `vitest.config.ts`): content +pipeline edge cases (CRLF, BOM, frontmatter, HTML→Markdown, skills dedup); chunking +(small-chunk passthrough, oversized text split, code block kept atomic, oversized +code block force-split); `generateDeterministicId` stability; validators; +`withRetry` backoff; config dimension mapping including the unknown-model error. + +### Phase 4 — Ingestion workflow (production API surface) + +**`src/mastra/workflows/challenge/challenge-ingestion-workflow.ts`**, id +`challenge-ingestion`, registered in `src/mastra/index.ts` under `workflows`: + +- Input `{ challengeId?: uuid, challenge?: , dryRun?: boolean }`; + exactly one source required. +- Step `resolve-challenge` — for `challengeId`, reuse `fetchChallengeTool` and + normalise its output (`name`, `description`, `descriptionFormat`, `track`, `type`, + `skills[]`, `projectId`, `groups[]`) into the `ChallengeRecord` shape the + CSV produced; for an inline `challenge`, validate with `validateRecord`. + Per D10, `projectId` is carried through as a **string reference only** — the step + makes no call to projects-api and denormalizes no project attributes. + `Challenge.projectId` is `Int?` in challenge-api-v6, so it is nullable: a challenge + with no project is still indexed and searchable, just not project-filterable. +- Step `chunk-and-embed` — `processDescription` → `chunkChallengeDescription` → + `enrichChunksWithChallengeName` → + `embedMany({ model: createEmbeddingModel(...), values })` wrapped in `withRetry`, + retaining the original's error-context enrichment (embedding vs database failure, + chunk count, total characters, longest chunk). The embedded text is the challenge's + public `description` only — `privateDescription` is not indexed, and per D10 no + project text is concatenated in. +- Step `upsert-vectors` — `ensureChallengeIndex()`, then + `upsert({ indexName, vectors, metadata, ids, deleteFilter: { challengeId } })`, + preserving atomic per-challenge replacement. Skipped when `dryRun`. +- Output — a per-challenge report (`chunks`, `forceSplits`, `dryRun`, `skipped`, + `projectId`) the CLI aggregates into the same `report.json` structure. +- Logging — `tcAILogger` with `[challenge-ingestion:]` prefixes, matching + `challenge-context-workflow`. No `console` interception (D6). + +Supporting changes: + +- **`src/mastra/tools/challenge/fetch-challenge-tool.ts`** (modified) — its output + schema currently drops `projectId`. Add `projectId: z.number().optional()` and + `groups: z.array(z.string()).optional()` and pass them through. Additive and + optional, so `challenge-context-workflow`, its only other consumer, is unaffected. +- **`src/mastra/tools/challenge/search-challenges-tool.ts`** (new, per D11) — wraps + `GET /v6/challenges` with M2M auth from the existing `M2MService`. Accepts the + search filters the ingestion pipeline needs: `projectId` / `projectIds`, + `status`, `approvalStatus`, `types` / `tracks`, `tags`, `groups`, + `updatedDateStart` / `updatedDateEnd`, `ids`, `page`, `perPage`, `sortBy`, + `sortOrder`. Always sets `isLightweight: false`, because the lightweight response + omits `description`, which is the field being indexed. Note that this also returns + `privateDescription`; the ingestion workflow **discards it** and embeds only the + public `description`. Returns `{ challenges: [...], total, page, perPage }` so + callers can paginate. This tool is the backbone of bulk ingestion and incremental + sync, replacing the CSV file as the primary challenge source. +- **`src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts`** (new), + id `challenge-bulk-ingestion`, per D11 — paginates through `searchChallengesTool` + results and invokes `challenge-ingestion` for each challenge, optionally filtering + by `status` (default: only `ACTIVE` and `COMPLETED`), `projectId`, `types`, + `tracks`, `tags`, `groups`, or `updatedDateStart` (for incremental sync). Supports + `dryRun` and `concurrency` (default 3, bounded to avoid overwhelming the embedding + provider). Aggregates per-challenge reports into the same `report.json` structure + the CLI produces. This is the production bulk-ingestion surface; the CSV CLI + becomes secondary. Passing `projectId` makes this the natural bulk unit for a + project-wide backfill, replacing the need for a separate project-ingestion + workflow. + +### Phase 5 — Retrieval: shared tool, deterministic workflow, and agent + +- **`src/mastra/tools/challenge/challenge-vector-query-tool.ts`** ← `src/lib/tools.ts`. + The original `query` / `skills` / `type` / `track` inputs plus `projectId` (the + only project dimension, per D10) and the challenge dimension `groups` — + all composed into the same `$and` filter array, embedding through the factory + and store through the shared singleton. + Per D12, `type` and `track` are `z.string().optional()` (not enums), since the + set of valid values is dynamic (reference tables, not fixed enums). + The relevance threshold stays **post-filtered in the tool** rather than passed as + `query({ minScore })` — see "Query planning consequences" below, where passing + `minScore` is shown to forfeit the HNSW fast path. The original's warning when + every result falls below threshold is kept. The + `z.preprocess` workaround for the Ollama `optional`→`nullable` schema-compat issue + is re-tested under zod 4 + core 1.57 and kept only if the failure still + reproduces, with a comment explaining the constraint. +- **`src/mastra/agents/challenge/challenge-search-agent.ts`** ← the "Topcoder + Challenge Assistant" instructions verbatim, constructed the `tc-ai-api` way: + `createModel(process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock', process.env.CHALLENGE_SEARCH_AI_MODEL_ID || , 'challenge-search-agent')`, + `tools: { challengeVectorQueryTool }`. The agent instructions are updated from the + prototype to mention the added `groups` filter dimension in the + tool-usage strategy, so the LLM can infer it from natural language. `projectId` + is deliberately *not* something the LLM is asked to guess — it is an opaque + identifier, so it is expected to arrive from the caller's context rather than from + the query text. Registered under `agents` (adds routes only; no effect on + existing agents). +- **`src/mastra/workflows/challenge/challenge-search-workflow.ts`**, id + `challenge-search`, registered under `workflows` — the deterministic path from D8, + with no agent and no LLM call: + - Input `{ query?: string, skills?: string[], type?: string, track?: string, groups?: string[], projectId?: string | string[], groupBy?: 'chunk' | 'challenge' | 'project', topK?: number, minScore?: number }`. + Filters are supplied explicitly by the caller; unlike the agent path, nothing is + inferred from natural language. + - Single step `search-challenges` executing `challengeVectorQueryTool` with the + same store, embedding factory, and `$and` filter composition, so agent and + workflow retrieval cannot diverge. + - Output: ranked `results[]` (`text`, `score`, `metadata`) plus `count`, + ordered by descending score and passed through verbatim — no summarisation, + reranking, or rewriting. `topK` / `minScore` default from `getRagConfig()`. + - `groupBy` controls aggregation, defaulting to `challenge`: chunk-level hits are + grouped by `challengeId` (best chunk score becomes the challenge score, + contributing chunks listed underneath) so list consumers get one entry per + challenge; `project` rolls the same hits up by `projectId`, which is the + project-based results path from D10 — the grouping key is the bare reference, so + a consumer that needs project names resolves them once per distinct `projectId` + in a follow-up projects-api call rather than reading them from the vector + metadata; `chunk` returns raw hits ungrouped. + - Because a `projectId` filter alone answers "everything indexed for this + project", the workflow permits `query` to be omitted when at least one filter + is present, falling back to `query({ filter })` without a `queryVector` — + `@mastra/pg` supports metadata-only retrieval, ordered by `vector_id` with + `score: 0`. +- **`src/mastra/tools/project/fetch-project-tool.ts`** (new, optional) — the + "subsequent call" side of D10: `GET ${TC_API_BASE}/v6/projects/:projectId` with an + M2M token from the existing `M2MService`, 15 s timeout, and a zod output schema. + It is a **retrieval-time enrichment** tool, not part of ingestion: a caller (or the + search agent) that has a `projectId` from a hit and needs the project's name, + status, or tech stack fetches it here, on demand and under the caller's own + authorization. Verified against projects-api-v6: the endpoint's Prisma query + already includes `details.projectData` and `techstack` in the default response, and + the optional `fields` query parameter can narrow it. Note that `Project.id`, + `billingAccountId`, and `directProjectId` are Prisma `BigInt`, which **throws on + `JSON.stringify`** ("Do not know how to serialize a BigInt"), so the tool must + convert them to strings explicitly. Ship this only if a consumer actually needs it; + nothing in the ingestion or retrieval path depends on it. + +### Phase 6 — CSV backfill CLI (secondary path, per D11) + +The CSV CLI is now the **secondary** ingestion path. The primary bulk path is the +`challenge-bulk-ingestion` workflow (Phase 4), which paginates the Challenge Search +API. The CLI remains for offline/air-gapped environments and for importing +historical CSV exports that predate the search API. + +- **`src/scripts/ingestion-logger.ts`** ← `src/lib/logger.ts`. Hierarchical child + loggers and per-run `logs/ingestion-/{output.log,error.log,report.json}` + preserved; `interceptConsole` dropped. +- **`src/scripts/ingest-challenges.ts`** — `node:util` `parseArgs` for `--folder`, + `--file`, `--dry-run`, `--clear-all` (readline confirmation, then + `deleteVectors({ filter })` instead of the original's raw `pool.query` behind a + `@ts-ignore`), CSV streaming via `csv-parse`, column validation on the first + record, and per-record invocation of `challenge-ingestion` through + `createRunAsync()` so CLI and API share one code path. Aggregates per-file stats + into the same `report.json` shape. +- **`src/scripts/sync-challenges.ts`** (new, per D11) — a thin CLI wrapper around + `challenge-bulk-ingestion` that accepts `--project-id`, `--status`, `--types`, + `--tracks`, `--updated-since`, `--dry-run`, `--concurrency`. This is the + incremental-sync and project-scoped backfill script for operators who prefer CLI + over API calls. +- `package.json`: `"ingest": "tsx --env-file=.env src/scripts/ingest-challenges.ts"` + and `"sync": "tsx --env-file=.env src/scripts/sync-challenges.ts"`. + Add `logs/` to `.gitignore`. Trim one small CSV into `tests/fixtures/` for tests; + bulk CSVs stay out of git. + +### Phase 7 — Configuration surface, documentation, validation + +- `.env.sample` and `.env.sh` — add the new keys with safe local defaults. +- `README.md` — new sections in the existing style: RAG overview, ingestion + (workflow and CLI), retrieval tool and agent, metadata schema, chunking strategy, + embedding-model/dimension table, database bootstrap, plus added rows under + **Environment Variables** and **API Surface**. Adapted from the source README + rather than rewritten. +- `Dockerfile` / `.circleci` — expected to need no change (the image build already + runs lint, test, and build). Verify `turndown` and `@mastra/rag` bundle cleanly + into `.mastra/output`; only if the build breaks, add them to `bundler.externals` / + `transpilePackages` in `src/mastra/index.ts`. +- Validation gate — `pnpm lint`, `pnpm test`, `pnpm run build`; then `pnpm dev` and + in Studio: (a) run `challenge-ingestion` for one real challenge ID, (b) run + `challenge-bulk-ingestion` with `status: 'ACTIVE'` and a small `perPage` to confirm + search-API pagination and per-challenge ingestion, (c) run `challenge-search` with + explicit filters (including `projectId` and `skills`) and confirm identical + ranked results across repeated runs, (d) query via `challenge-search-agent` with a + natural-language query that implies a track and skill filter (e.g. "QA challenges + about React"), (e) re-run `skill-extraction`, `challenge-context`, and `jd-autowrite` to + confirm no regression; then `pnpm run ingest -- --file --dry-run` followed by + a live run against a local pgvector container, and `pnpm run sync -- --project-id + --dry-run` to exercise the search-API path. + +## File-level mapping + +| Source (`tc-challenges-vector-rag`) | Destination (`tc-ai-api`) | Change | +| --- | --- | --- | +| `src/lib/config.ts` | `src/config/rag.config.ts` | Lazy `getRagConfig()`, no module-load throw; reuses `MASTRA_DB_*`; provider-keyed model map | +| `src/lib/llm.ts` | `src/utils/providers/embedding-factory.ts` | Replaced by a `createModel`-style factory; `ollama` package dropped | +| `src/lib/db.ts` | `src/mastra/vector/challenge-vector-store.ts` | Adds `id`, `schemaName`, `ensureChallengeIndex()`, dimension guard | +| `src/lib/content.ts` | `src/mastra/rag/content.ts` | Verbatim (pure) | +| `src/lib/utils.ts` | `src/mastra/rag/ingestion-utils.ts` | `confirmAction` moved to the CLI | +| `src/lib/types.ts` | `src/mastra/rag/types.ts` | zod 4 / config-derived types | +| `src/lib/tools.ts` | `src/mastra/tools/challenge/challenge-vector-query-tool.ts` | Embedding factory, shared store, post-filtered `minScore`; chunk text field renamed `content` → `text`; shared by the agent and the deterministic workflow | +| — (new, D8) | `src/mastra/workflows/challenge/challenge-search-workflow.ts` | Deterministic raw ranked retrieval, no LLM | +| — (new, D10, optional) | `src/mastra/tools/project/fetch-project-tool.ts` | Retrieval-time enrichment only: resolves a `projectId` from a hit to project detail on demand; **not** used during ingestion | +| existing | `src/mastra/tools/challenge/fetch-challenge-tool.ts` | **Modified**: pass through `projectId`, `groups` (additive, optional) | +| — (new, D11) | `src/mastra/tools/challenge/search-challenges-tool.ts` | M2M `GET /v6/challenges` with filters and pagination | +| — (new, D11) | `src/mastra/workflows/challenge/challenge-bulk-ingestion-workflow.ts` | Paginated bulk ingestion via search API; incremental sync via `updatedDateStart`; `projectId` gives project-wide backfill | +| `src/lib/logger.ts` | `src/scripts/ingestion-logger.ts` | `interceptConsole` removed; CLI-only | +| `src/scripts/ingest.ts` (chunking) | `src/mastra/rag/chunking.ts` | Extracted as a pure, testable function | +| `src/scripts/ingest.ts` (pipeline) | `src/mastra/workflows/challenge/challenge-ingestion-workflow.ts` | Becomes a Mastra workflow; CSV source replaced by `fetchChallengeTool`; chunk metadata written under `text` | +| `src/scripts/ingest.ts` (CLI) | `src/scripts/ingest-challenges.ts` | `parseArgs`, invokes the workflow | +| `src/mastra/index.ts` (agent) | `src/mastra/agents/challenge/challenge-search-agent.ts` | Instructions kept; `createModel` provider pattern | +| `src/scripts/ask.ts` | — | Dropped; the `challenge-search` workflow, Studio, and the generated API replace it | +| `docker/init-db.{sh,sql}` | — | Dropped; `PgVector.createIndex()` performs all DDL (D3) | +| `docker/docker-compose.yml` | `docker/docker-compose.yml` | Local pgvector for development only, without the `init-db` mounts | +| `.env`, `data/*.csv` | — | Not copied (credentials / bulk data) | + +## Data model — the table that stores the embeddings + +Because D3 delegates DDL to `PgVector.createIndex()`, the physical table is defined +by `@mastra/pg` 1.19, **not** by the prototype's `docker/init-db.sql`. The statement +issued (verified in `node_modules/@mastra/pg/dist/index.js`) is: + +```sql +CREATE TABLE IF NOT EXISTS "ai"."challenge_embeddings" ( + id SERIAL PRIMARY KEY, + vector_id TEXT UNIQUE NOT NULL, + embedding vector(1024), -- 768 locally with nomic-embed-text + metadata JSONB DEFAULT '{}'::jsonb +); +``` + +One table holds one index. The table name **is** the `indexName` passed to +`createIndex()` / `upsert()` / `query()`, qualified by `MASTRA_DB_SCHEMA` (`ai`), so +`VECTOR_INDEX_NAME` names a table rather than an index. Both identifiers pass through +`parseSqlIdentifier`, which is why the config keeps the original's SQL-identifier +validation. + +### Columns + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | `SERIAL PRIMARY KEY` | Surrogate key, unused by application code. Added by Mastra; the prototype had no such column. | +| `vector_id` | `TEXT UNIQUE NOT NULL` | The application key and the `ON CONFLICT` target. Populated exactly as the prototype did, by `generateDeterministicId()` over `-` — a SHA-256 hash rendered UUID-shaped — so identical content re-ingests to the same row. Demoted from primary key to unique constraint relative to the prototype; semantics are unchanged. | +| `embedding` | `vector(N)` | N is fixed at table creation from the configured model: 1024 for `amazon.titan-embed-text-v2:0`, 768 for `nomic-embed-text`. This is the column the D7 guard protects. | +| `metadata` | `JSONB DEFAULT '{}'` | Everything else, including the chunk text. | + +**There is no `created_at` column.** The prototype's +`created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` does not exist in Mastra's schema +and there is no hook to add one without taking over the DDL. To preserve the ability +to audit and re-ingest by age, the ingestion workflow therefore adds an +`ingestedAt` (ISO-8601 string) field to `metadata`. + +### Metadata document + +There is no separate text column, so the chunk text lives in `metadata.text`, renamed +from the prototype's `metadata.content` for the reasons given after the example. Every +row is a challenge chunk, and the document is a single flat set of fields — the chunk, +the challenge, and (per D10) a bare `projectId` reference: + +| Field | Type | Purpose | +| --- | --- | --- | +| `challengeId` | `string` | Challenge UUID. The `deleteFilter` key for per-challenge replacement. | +| `name` | `string` | Challenge title. | +| `type` | `string` | Free-form from the `ChallengeType` reference table (per D12). Known: "Challenge", "First2Finish", "Marathon Match", "Task", etc. Filterable. | +| `track` | `string` | `ChallengeTrack.name` (human-readable, per D12). Known: "Design", "Data Science", "Development", "Quality Assurance". Filterable. | +| `skills` | `string[]` | Filterable via `$in`. | +| `groups` | `string[]` | Challenge groups. Filterable via `$in`. | +| `projectId` | `string \| null` | The **only** project field (D10), from `Challenge.projectId`. `null` when the challenge has no project. Filterable, including `{ projectId: { $in: [...] } }` for a set of projects. | +| `chunkIndex` | `number` | 1-based position within the challenge description. | +| `totalChunks` | `number` | Chunk count for the challenge, for reassembly. | +| `text` | `string` | The chunk text, prefixed with `# Challenge: `. Named `text`, **not** `content`, to match the Mastra convention. | +| `ingestedAt` | `string` | ISO-8601. **New**, compensating for the missing `created_at`. | + +One type-mapping rule matters: `projectId` is stored as a **string**, not a number, +because `@mastra/pg` compares scalars as text (`metadata#>>'{key}' = $n`), so a +numeric literal in a filter would silently fail to match a JSON number. Callers +therefore always filter with a string value. + +Example row: + +```json +{ + "vector_id": "a3f1c8d2-9b4e-7c15-2f8a-6d0e4b1c9a72", + "embedding": "[0.0231, -0.0114, ...]", + "metadata": { + "challengeId": "30401234-0000-4000-8000-000000000001", + "name": "Build a Realtime Energy Dashboard", + "type": "Challenge", + "track": "Development", + "skills": ["React", "TypeScript", "Node.js"], + "groups": ["acme-internal"], + "chunkIndex": 3, + "totalChunks": 11, + "text": "# Challenge: Build a Realtime Energy Dashboard\n\n## Requirements\nThe dashboard must ...", + "ingestedAt": "2026-08-18T09:14:02.511Z", + "projectId": "17423" + } +} +``` + +Everything a consumer might want about project `17423` — its name, status, type, +billing account, customer, and tech stack — is deliberately absent. It is one +`GET /v6/projects/17423` away, resolved once per distinct `projectId` in a result +set rather than stored redundantly on every chunk of every challenge in the project. + +#### Why the chunk text field is `text`, not `content` + +The prototype stores the chunk in `metadata.content`. This plan renames it to +`metadata.text` when the code is ported, for three reasons: + +- **`rerank()` requires it.** The `@mastra/rag` reference states that for semantic + scoring during reranking, "each result must include the text content in its + `metadata.text` field". Reranking is out of scope for this release, but a field + name is the cheapest possible thing to get right up front — under `content` the + feature would silently score against nothing rather than fail loudly. +- **It matches the rest of the ecosystem.** `MDocument.chunk()` returns chunks whose + text is on `chunk.text`, and every documented pipeline reads + `chunks.map(chunk => chunk.text)`. Storing that under a different key means every + ingestion and retrieval site has to cross the two names over. +- **It is free right now.** `tc-ai-api` creates its own index under + `VECTOR_INDEX_NAME`, so there is no populated table to migrate and no consumer + outside this plan reading the field. The same rename after go-live would mean + either rewriting every row's metadata or carrying a mapping shim permanently. + +The rename is applied consistently: `challengeVectorQueryTool` and the +`challenge-search` workflow also return `text` in their result objects, so there is +no point in the pipeline where the two names have to be mapped onto each other. + +### Indexes actually created + +| Index | Definition | Created by | +| --- | --- | --- | +| `challenge_embeddings_pkey` | btree on `id` | `SERIAL PRIMARY KEY` | +| `challenge_embeddings_vector_id_key` | unique btree on `vector_id` | `UNIQUE` constraint | +| `"challenge_embeddings_vector_idx"` | `USING hnsw (embedding vector_cosine_ops) WITH (m = 8, ef_construction = 32)` | `createIndex({ indexConfig: { type: 'hnsw' } })`; the name is always `_vector_idx` | +| `"challenge_embeddi..._md__idx"` | one btree per field on `((metadata->>''))`, built `CONCURRENTLY` | `metadataIndexes: ['challengeId', 'projectId', 'track']`; each name is truncated to 55 chars plus an h32 hash of the field | + +`challengeId` is indexed because it is the `deleteFilter` key on every upsert, so it +is touched by every write. `projectId` is indexed because it drives the maintenance +and reporting paths — per-project delete, per-project re-ingest, per-project counting +— where a btree genuinely helps. `track` is indexed as the most common low-cardinality +retrieval filter. None of them speed up filtered vector *search* (the filter still +runs inside the CTE before the vector index), for the reason given next. + +Two deviations from the prototype's index set are deliberate and worth noting: + +- The prototype's **GIN index on `metadata`** is not created. Mastra's + `metadataIndexes` option only emits btree indexes on `(metadata->>'field')`. +- The prototype's **GIN index on `metadata->'skills'` would be dead weight** under + Mastra's filter builder. A `{ skills: { $in: [...] } }` filter compiles to a + correlated subquery — `EXISTS (SELECT 1 FROM jsonb_array_elements_text(metadata->'skills') elem WHERE elem = ANY($n::text[]))` + — rather than an indexable containment operator such as `@>` or `?|`, so no GIN + index can serve it. Porting that index would add write cost for zero read benefit, + so it is dropped. + +### Query planning consequences + +Reading the SQL that `@mastra/pg` generates shows `query()` has two shapes, and which +one you get is not obvious from the API: + +1. **ANN fast path**, used only when the index is HNSW **and** no `filter` is given + **and** `minScore <= 0`: `ORDER BY LIMIT $2` inside the CTE, so + the HNSW index drives the scan. +2. **Exact scan**, used in every other case: the CTE computes the score for *all* + rows matching the metadata filter with no inner `LIMIT`, and ordering happens on + the computed `score` alias in the outer query, so the vector index cannot be used. + +Two implications: + +- Passing `minScore` — even a small positive threshold — pushes an otherwise + unfiltered query off the fast path onto a full scan. Hence the tool applies the + threshold in application code after retrieval (Phase 5) instead of delegating it + to `query()`. +- Any metadata-filtered search is an exact scan by construction. That is acceptable + at the expected corpus size (a few thousand challenges × ~10 chunks, so tens of + thousands of rows), and the `challengeId` btree index only helps the + `deleteFilter` path, not filtered search. If the corpus grows by an order of + magnitude, filtered search latency should be re-measured before adding + partitioning or a pre-filter strategy. + +### Write path + +`upsert()` wraps everything in a single transaction: + +``` +BEGIN + DELETE FROM "ai"."challenge_embeddings" WHERE -- challengeId = $1 + INSERT ... ON CONFLICT (vector_id) DO UPDATE SET embedding = ..., metadata = ... + -- one INSERT statement per vector +COMMIT +``` + +This confirms the prototype's atomicity claim survives the upgrade: a failed insert +rolls the delete back, so a challenge is never left partially indexed. Note that +vectors are inserted one statement per row rather than in a single batched +statement, which is the dominant cost in bulk backfill; the CLI should therefore +size its per-challenge batches with that round-trip cost in mind. + +### No metadata refresh path is needed + +Because `projectId` is an immutable reference rather than a copy of project state +(D10), the update problem the denormalized design would have created does not exist: +renaming a project, changing its status, or reassigning it to a different billing +account leaves nothing in the vector store to fix. Metadata is only ever rewritten by +re-ingesting the challenge, which the `deleteFilter` upsert already handles atomically. + +Worth recording for anyone who later reconsiders this: `updateVector({ filter, update: { metadata } })` +would **not** have been a usable refresh mechanism. It compiles to +`UPDATE ... SET metadata = $1::jsonb WHERE `, replacing the entire document on +every matching row — destroying `text`, `chunkIndex`, and `challengeId` — so a +partial metadata update would have required dropping to raw SQL +(`SET metadata = metadata || $1::jsonb`) through the `pgVector.pool` escape hatch. +Avoiding that is part of the value of D10. + +### Sizing + +At 1024 dimensions a vector occupies ~4 KB (`4 bytes × 1024`), and a chunk's +metadata is dominated by `text` — roughly 0.6–0.8 KB given the 512-character chunk +target, with the scalar fields (including `projectId`) adding well under 0.1 KB. +Budget ~5 KB per chunk, so ~50 KB per challenge at ~10 chunks, or ~500 MB per 10,000 +challenges before HNSW index overhead (which roughly adds another 20–40% for +`m = 8`). + +## Consequences + +**Positive** + +- Ingestion becomes reachable in production over the authenticated API and reuses + the existing M2M Challenge API integration instead of manually exported CSVs. +- One vector store, one database credential, one deployment; no new + infrastructure component. +- The chunking and content pipeline become pure, unit-tested functions rather than + logic embedded in a script. +- CLI and API share a single ingestion implementation, so backfill and incremental + ingestion cannot drift. +- Retrieval serves both synthesised answers and raw ranked results from one tool, + so consumers that cannot tolerate LLM latency or non-determinism are covered + without a second retrieval implementation. +- Project-scoped retrieval needs no join and no denormalization: a `projectId` filter + (or `{ projectId: { $in: [...] } }` for a set of projects) answers "everything + indexed for this project", and challenge hits roll up by `projectId`. Anything + richer resolves through projects-api on demand, so the vector store never holds a + stale copy of project state (D10). +- The vector store holds only challenge content plus identifiers, so no + customer-identifying or commercial data (billing account, SOW number, cost centre) + enters a store that is queried by similarity. Authorization for project data stays + on the projects-api call that already enforces it. +- No hand-maintained DDL script: the vector table and index shape stay whatever the + installed `@mastra/pg` expects, removing a class of upgrade breakage. +- Bulk ingestion and incremental sync work through the existing Challenge Search + API (D11), so no CSV export/import pipeline is needed in production. The + `updatedDateStart` filter makes incremental sync a single paginated call. +- `type` and `track` are forward-compatible with the reference-table model (D12): + adding a new `ChallengeType` or reactivating a retired `ChallengeTrack` does not + require a code change or redeployment. + +**Negative / accepted costs** + +- Ollama (768d) and Bedrock Titan (1024d) produce incompatible indexes in the same + schema. Mitigated by an environment-specific `VECTOR_INDEX_NAME` and the runtime + dimension guard, but switching a given environment's embedding provider requires + a full reindex. +- Delegating DDL to `createIndex()` means the runtime database role must keep its + DDL privileges; if they are ever revoked, first startup against a fresh index + fails until the table is created out of band. +- Titan v2 at 1024 dimensions costs ~33% more index storage than the local 768d + model and ~2x the 512d reduced variant; accepted for retrieval quality. +- Mastra's table has no `created_at`; ingestion recency is tracked in + `metadata.ingestedAt` instead, which is not indexed and so is unsuitable for + range queries without an added expression index. +- Metadata-filtered search is an exact scan (no ANN index usage) by construction in + `@mastra/pg`. Acceptable at the expected corpus size, but a scaling ceiling to + revisit rather than ignore. +- Project attributes are not filterable in the vector query (D10). Answering + "everything Topcoder built for this customer" becomes a two-step operation: resolve + the customer's projects from projects-api, then filter the vector query with + `{ projectId: { $in: [...] } }`. That is more work for the caller than a single + `billingAccountId` filter would have been, and the `$in` list grows with the + customer's project count. +- Project text (`Project.description`, `projectFullText`) is not semantically + searchable at all. If project-level semantic search turns out to be a requirement, + it needs its own decision — indexing projects as a second document kind was + considered and rejected here as unnecessary scope. +- Consumers that render project names alongside results must make N extra API calls + (one per distinct `projectId` in a page of results). Cheap and cacheable, but it is + latency the denormalized design would not have had. +- Original commit history from the prototype is not carried over (D4). +- Four new runtime dependencies (`@mastra/rag`, `turndown`, `js-tiktoken`, + `csv-parse`) enter the production bundle. + +**Out of scope** + +Everything below is deliberately deferred rather than rejected. `@mastra/rag` is +already entering the dependency set for `MDocument.chunk()`, so adopting the first two +later is a retrieval-logic change — no new dependency, no schema change. + +**Reranking.** `rerank(results, query, model, { weights, topK })` from `@mastra/rag` +takes the top-N results of a vector search and re-scores them by blending LLM-judged +semantic relevance with the original vector similarity and rank position (weights must +sum to 1; Cohere's `rerank-v3.5` uses that model's native reranking instead). It +reliably corrects the case where a chunk carries the right vocabulary but is not the +right challenge. It is excluded because it needs an LLM call per query, which +contradicts D8: the deterministic `challenge-search` workflow exists precisely to +return ranked results with no LLM latency, cost, or non-determinism. Enabling +reranking on only the agent path would make the two paths rank differently, which D8 +avoids by having them share one tool; enabling it on both turns the deterministic path +into an LLM path. Two supporting reasons: reranker model access is not in the +prerequisites (only Titan embed is), and tuning those weights is meaningless without a +retrieval quality baseline, which this ADR does not build — see below. The one +structural prerequisite has been handled up front: the chunk text is stored under +`metadata.text`, which is where `rerank()` reads it from. + +**Graph RAG.** `new GraphRAG(dimension, threshold)` builds an in-memory knowledge +graph in which nodes are chunks and edges join chunks whose embeddings exceed a +similarity threshold, then answers a query by combining direct similarity with a +random walk with restart (`randomWalkSteps`, `restartProb`) so it surfaces content +*connected* to the match rather than only the match. It is excluded on three concrete +grounds, not just scope. First, it is incompatible with the store D3 chooses: the graph +lives in process memory rather than in pgvector, and construction is **O(n²) in +chunks** — at the corpus this ADR sizes for (tens of thousands of chunks) that is on +the order of 10⁹ pair comparisons. Second, persisting it is a second unsolved problem: +snapshots are plain JSON carrying every node's full embedding, documented at ~20 MB per +1,000 nodes at 1536 dimensions, so hundreds of megabytes here, with nowhere in the +current design to put them. Third, snapshots are not incremental — a changed document +means rebuilding the graph — which is the direct opposite of the per-challenge +idempotent upsert (`deleteFilter: { challengeId }`) this pipeline is built around. +Beyond cost, the demand is absent: the access patterns in scope are single-hop lookups, +whereas graph traversal earns its keep on multi-hop synthesis across a linked corpus. + +**Retrieval quality baseline.** A fixed set of queries, each paired with the challenge +IDs that should come back, plus a script that scores retrieval against them — recall@k +as the primary metric (a miss is invisible to the user), MRR/nDCG@k for whether the +right hit lands near the top, precision@k as a secondary signal. Deferred because there +is nothing to measure until Phase 4 has populated an index, and because realistic +queries are best curated from real usage rather than invented up front. Two things make +it cheap when it is picked up: `@mastra/evals` (already a dependency, 1.7.0) ships +`createContextPrecisionScorer` and `createContextRecallScorer` for the LLM-judged tier, +and D8's deterministic `challenge-search` workflow — which already names "evaluation +harnesses" as a consumer — is the right surface to measure, because it is reproducible +and LLM-free, so a score change reflects a retrieval change rather than model sampling. + +Its value is that it converts guesses in this ADR into decisions. The following are +currently chosen by judgement and unmeasured: chunk `maxSize` 512 and `overlap` 50 and +the force-split fallback (chunk size is usually the largest single lever on quality); +Titan v2 at 1024 dimensions, where the Consequences above accept ~33% more storage than +768 and ~2x the 512 variant explicitly "for retrieval quality"; the parity between the +local 768-dimension Ollama model and production Titan at 1024 (D2), which nothing +currently establishes; `RAG_TOP_K` and the post-filtered `minScore` threshold, which +silently drops results when set too high; HNSW `m = 8` / `ef_construction = 32`, +including the counterintuitive asymmetry that filtered searches are exact scans at full +recall while unfiltered searches go through the ANN index; and whether +`enrichChunksWithChallengeName` actually helps, since it exists only to improve +retrieval. It would also make the deferred reranking decision adjudicable, and would +de-risk any future embedding-provider switch, which D7's dimension guard already forces +into a full reindex. + +A sensible progression for the next iteration, cheapest first: (1) **known-item +retrieval** — query with a challenge's title plus skills and assert its own chunks rank +in the top k; zero labelling cost, deterministic, cheap enough for CI, and it catches +every gross regression (wrong dimensions, broken chunking, a filter that excludes +everything); (2) **paraphrased queries** generated once by an LLM and committed as +fixtures, so the eval stays deterministic while testing semantic rather than keyword +matching; (3) **~50 human-curated realistic queries** with labelled relevant +challenges, which is the tier that can actually settle reranking and chunk-size +questions. Worth noting alongside this: the Phase 7 validation gate checks that +`challenge-search` returns *identical* results across runs, which is a determinism +check, not a relevance one — a system that consistently returns the wrong challenges +passes it today. + +**Also out of scope:** wiring `PgVector` into `Memory` `semanticRecall` for existing +agents; event-driven ingestion on challenge lifecycle events (deferred per D9); and +any change to existing agents, workflows, authentication, or middleware. + +## Questions settled during review + +Four questions were raised and answered while this ADR was under review. They are +recorded here because the answers are assumptions the implementation depends on: + +1. **Production embedding model** — `amazon.titan-embed-text-v2:0` at 1024 + dimensions (D2). +2. **Database prerequisites** — the `vector` extension is enabled and the runtime + role will be granted the required privileges, so `PgVector.createIndex()` owns all + DDL and the prototype's `init-db` scripts are dropped (D3). +3. **Event-driven ingestion** — not needed for this release; deferred until a + concrete trigger is required (D9). +4. **Deterministic retrieval** — in scope; a `challenge-search` workflow returns raw + ranked results with no LLM synthesis, alongside the agent (D8). + +## Security note — tenant scoping of challenge content + +D10 keeps commercial and customer-identifying project data out of the vector store +entirely, so the disclosure surface is limited to challenge content. That still leaves +one rule: + +**Scope filters must be enforced server-side, not by the model.** In the agent path +the LLM decides which filters to pass, so it cannot be relied on to restrict results. +Any endpoint serving a scoped audience must inject the `projectId` (or `groups`) +filter from the authenticated request context — the existing `resourceIdMiddleware` +already extracts the caller identity — rather than accepting it as model-chosen +input. `resourceIdMiddleware` does not solve this on its own: it scopes Mastra +resources such as memory threads by user, whereas the vector table has no per-user +partition, so an unrestricted `challenge-search` call can retrieve any indexed +challenge. + +Two content-level notes bound the exposure: only the challenge's public +`description` is embedded (`privateDescription` is never indexed), and `groups` is +carried in metadata so group-restricted challenges *can* be filtered out — but +nothing enforces that automatically today. Until a tenant-scoping decision exists, +the search surface should stay restricted to internal M2M callers. A follow-up ADR +should decide whether isolation is achieved by injected filters, by per-tenant +`VECTOR_INDEX_NAME` tables, or by `PgVector` `schemaName` partitioning (the +multi-tenant resolver pattern that `@mastra/rag` documents). + +## Prerequisites to confirm before implementation starts + +- The runtime database role for each environment has been granted DDL privileges on + the `ai` schema (assumed by D3). +- Bedrock access to `amazon.titan-embed-text-v2:0` is enabled for the deployment's + AWS region and task role. +- The M2M client used by `M2MService` holds the **challenge `READ`** scope required by + `GET /v6/challenges` (challenge-api-v6 guards it with `scopes: [READ, ALL]`). The + existing `fetchChallengeTool` already uses it for single-challenge fetches; the new + `searchChallengesTool` (D11) uses the same scope for bulk search. No projects-api + scope is required by the ingestion or retrieval path — **`projects:read`** is needed + only if the optional `fetchProjectTool` enrichment tool is shipped (projects-api-v6 + guards `GET /v6/projects/:projectId` with `@Scopes(M2M_SCOPES.PROJECTS.READ)`). +Tracked as a follow-up rather than a gate: which of the tenant-scoping options in the +security note applies to the challenge search surface. Implementation can proceed +because the interim mitigation — restricting the search surface to internal M2M +callers — is part of this decision, but the question must be closed before the surface +is exposed to any customer-scoped audience. From e9b6fac39663a43aa388ae5d86edbba15b2399e1 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 09:56:08 +0300 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20add=20challenge-search=20retrieva?= =?UTF-8?q?l=20=E2=80=94=20shared=20tool,=20agent,=20deterministic=20workf?= =?UTF-8?q?low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of ADR 0001. challengeVectorQueryTool composes the $and metadata filter (skills/type/track/groups/projectId) and is shared by two retrieval paths so they cannot diverge (D8): challenge-search-agent for synthesised NL answers, and the challenge-search workflow for raw ranked results with no LLM call, grouped by chunk/challenge/project. The relevance threshold is post-filtered in app code to preserve the HNSW ANN fast path, and query is optional so a filter-only call becomes a metadata-only lookup. Also adds fetchProjectTool (D10) — optional retrieval-time enrichment that resolves a projectId from a hit to project detail, never used during ingestion. Registers challengeSearchAgent and challengeSearchWorkflow in the Mastra instance. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MkcggGkcnz73GXUDR7mgrE --- .../challenge/challenge-search-agent.ts | 53 +++ src/mastra/index.ts | 5 +- .../challenge-vector-query-tool.test.ts | 301 ++++++++++++++++++ .../challenge/challenge-vector-query-tool.ts | 235 ++++++++++++++ .../tools/project/fetch-project-tool.test.ts | 130 ++++++++ .../tools/project/fetch-project-tool.ts | 97 ++++++ .../challenge-search-workflow.test.ts | 253 +++++++++++++++ .../challenge/challenge-search-workflow.ts | Bin 0 -> 12149 bytes 8 files changed, 1073 insertions(+), 1 deletion(-) create mode 100644 src/mastra/agents/challenge/challenge-search-agent.ts create mode 100644 src/mastra/tools/challenge/challenge-vector-query-tool.test.ts create mode 100644 src/mastra/tools/challenge/challenge-vector-query-tool.ts create mode 100644 src/mastra/tools/project/fetch-project-tool.test.ts create mode 100644 src/mastra/tools/project/fetch-project-tool.ts create mode 100644 src/mastra/workflows/challenge/challenge-search-workflow.test.ts create mode 100644 src/mastra/workflows/challenge/challenge-search-workflow.ts diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts new file mode 100644 index 0000000..497232d --- /dev/null +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -0,0 +1,53 @@ +import { Agent } from '@mastra/core/agent'; +import { createModel } from '../../../utils'; +import { challengeVectorQueryTool } from '../../tools/challenge/challenge-vector-query-tool'; + +const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; +const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5'; +const AGENT_ID = 'challenge-search-agent'; + +/** + * "Topcoder Challenge Assistant" — synthesises natural-language answers over + * indexed challenge descriptions via challengeVectorQueryTool. + * + * Ported from tc-challenges-vector-rag with the groups filter dimension added + * to the tool-usage strategy (source repo predates it). projectId is + * deliberately NOT something this agent is asked to infer from the query + * text — it is an opaque reference (D10) expected to arrive from the + * caller's context, and any scope restriction MUST be enforced server-side, + * never left to the model (see ADR 0001, "Security note"). + * + * For callers that need raw ranked results with no LLM latency, cost, or + * non-determinism, use the `challenge-search` workflow instead (D8) — it + * shares this same tool, so filters and thresholds cannot drift between the + * two paths. + */ +export const challengeSearchAgent = new Agent({ + id: AGENT_ID, + name: 'Topcoder Challenge Assistant', + model: createModel(PROVIDER_NAME, MODEL_ID, AGENT_ID), + instructions: { + role: 'system', + content: `You are a helpful Topcoder Challenge Assistant. Your goal is to assist members in finding relevant information about Topcoder challenges regarding their query. + +Use the "challenge-vector-query" tool to retrieve information about challenges. Never answer from your own knowledge base. + +Tool Usage Strategy: +1. Analyze the user's request to extract the following filters if the request contains any of them: + - "type": Free-form challenge type (e.g. "Challenge", "First2Finish", "Marathon Match", "Task"). Map "F2F" to "First2Finish". + - "track": Free-form challenge track (e.g. "Development", "Design", "Data Science", "Quality Assurance"). + - "skills": An array of technologies (e.g., ["React", "TypeScript", "Python", "Node.js"]). + - "groups": An array of challenge group ids, when the user names a specific group or cohort explicitly. +2. Always use the original naming for technologies. Example — user writings of "react", "typescript", "nodejs" must be mapped to "React", "TypeScript", "Node.js" in the tool input. +3. Use the "query" parameter ONLY for generic context that doesn't fit the above filters (e.g., "healthcare", "dashboard", "fintech"). +4. If no "query" value can be derived from the request but at least one filter is present, you may omit "query" entirely — the tool supports filter-only lookups. +5. If a term maps to a filter (e.g., "design"), prefer the filter over the query string. + +**Critical:** type, track, skills, and groups are optional. If no value can be derived for one of them, do not include it in the tool input. Do not pass null or empty string — omit the parameter completely. + +**Never infer "projectId" from the query text.** It is an opaque reference supplied by the caller's context, not something you should guess from natural language — omit it unless it has been explicitly provided to you as part of the conversation context. + +Ground your response SOLELY on the context returned by the tool. If no results are found, say "I couldn't find any challenges matching your criteria."`, + }, + tools: { challengeVectorQueryTool }, +}); diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 9326086..67844d3 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -3,9 +3,11 @@ import { skillExtractionWorkflow } from './workflows/skills/skill-extraction-wor import { challengeContextWorkflow } from './workflows/challenge/challenge-context-workflow'; import { challengeIngestionWorkflow } from './workflows/challenge/challenge-ingestion-workflow'; import { challengeBulkIngestionWorkflow } from './workflows/challenge/challenge-bulk-ingestion-workflow'; +import { challengeSearchWorkflow } from './workflows/challenge/challenge-search-workflow'; import { jdAutowriteWorkflow } from './workflows/jd/jd-autowrite-workflow'; import { skillsMatchingAgent } from './agents/skills/skills-matching-agent'; import { challengeParserAgent } from './agents/challenge/challenge-parser-agent'; +import { challengeSearchAgent } from './agents/challenge/challenge-search-agent'; import { jdRewriterAgent } from './agents/jd/jd-rewriter-agent'; import { PostgresStore } from '@mastra/pg'; import { @@ -21,9 +23,10 @@ export const mastra = new Mastra({ challengeContextWorkflow, challengeIngestionWorkflow, challengeBulkIngestionWorkflow, + challengeSearchWorkflow, jdAutowriteWorkflow, }, - agents: { skillsMatchingAgent, challengeParserAgent, jdRewriterAgent }, + agents: { skillsMatchingAgent, challengeParserAgent, challengeSearchAgent, jdRewriterAgent }, scorers: { instanceAnswerRelevancyScorer, instancePromptAlignmentScorer, diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts new file mode 100644 index 0000000..b95480a --- /dev/null +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + embed: vi.fn(), + createEmbeddingModel: vi.fn(() => ({ modelId: 'mock-embedding-model' })), + storeQuery: vi.fn(), + getRagConfig: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('ai', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, embed: mocks.embed }; +}); + +vi.mock('../../../utils/providers/embedding-factory', () => ({ + createEmbeddingModel: mocks.createEmbeddingModel, +})); + +vi.mock('../../vector/challenge-vector-store', () => ({ + getChallengeVectorStore: () => ({ query: mocks.storeQuery }), +})); + +vi.mock('../../../config/rag.config', () => ({ + getRagConfig: mocks.getRagConfig, +})); + +vi.mock('../../../utils/logger', () => ({ + tcAILogger: mocks.logger, +})); + +import { challengeVectorQueryTool, _testing } from './challenge-vector-query-tool'; + +const { buildMetadataFilter } = _testing; + +const minimalContext = { mastra: undefined } as any; + +async function executeTool(input: Record): Promise { + return challengeVectorQueryTool.execute?.(input, minimalContext) as Promise; +} + +function defaultConfig() { + return { + embedding: { provider: 'AWSBedrock', modelId: 'amazon.titan-embed-text-v2:0', dimension: 1024, maxContextWindow: 8192 }, + vectorIndexName: 'challenge_embeddings', + vectorSearchThreshold: 0.5, + chunkMaxSize: 512, + chunkOverlap: 50, + topK: 10, + challengeSearchAI: { provider: 'AWSBedrock', modelId: 'model' }, + database: { connectionString: 'postgres://x', schemaName: 'ai' }, + knownTypes: [], + knownTracks: [], + }; +} + +function hit(overrides: Record = {}) { + return { + id: 'vec-1', + score: 0.9, + metadata: { + challengeId: 'challenge-1', + name: 'Test Challenge', + type: 'Challenge', + track: 'Development', + skills: ['React'], + groups: ['acme'], + projectId: '17423', + chunkIndex: 1, + totalChunks: 3, + text: 'chunk text', + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRagConfig.mockReturnValue(defaultConfig()); + mocks.embed.mockResolvedValue({ embedding: [0.1, 0.2, 0.3] }); + mocks.storeQuery.mockResolvedValue([]); +}); + +// --------------------------------------------------------------------------- +// buildMetadataFilter +// --------------------------------------------------------------------------- + +describe('buildMetadataFilter', () => { + it('returns undefined when no filter dimension is present', () => { + expect(buildMetadataFilter({})).toBeUndefined(); + }); + + it('composes $eq for type and track', () => { + expect(buildMetadataFilter({ type: 'Challenge', track: 'Development' })).toEqual({ + $and: [{ type: { $eq: 'Challenge' } }, { track: { $eq: 'Development' } }], + }); + }); + + it('composes $in for skills and groups', () => { + expect(buildMetadataFilter({ skills: ['React', 'TypeScript'], groups: ['g1'] })).toEqual({ + $and: [{ skills: { $in: ['React', 'TypeScript'] } }, { groups: { $in: ['g1'] } }], + }); + }); + + it('normalizes a single projectId string into $in', () => { + expect(buildMetadataFilter({ projectId: '17423' })).toEqual({ + $and: [{ projectId: { $in: ['17423'] } }], + }); + }); + + it('passes an array projectId through to $in', () => { + expect(buildMetadataFilter({ projectId: ['1', '2'] })).toEqual({ + $and: [{ projectId: { $in: ['1', '2'] } }], + }); + }); + + it('ignores an empty skills/groups array', () => { + expect(buildMetadataFilter({ skills: [], groups: [] })).toBeUndefined(); + }); + + it('combines every dimension into one $and array', () => { + const filter = buildMetadataFilter({ + type: 'Challenge', + track: 'Development', + skills: ['React'], + groups: ['acme'], + projectId: '17423', + }); + expect(filter.$and).toHaveLength(5); + }); +}); + +// --------------------------------------------------------------------------- +// execute — input validation +// --------------------------------------------------------------------------- + +describe('challengeVectorQueryTool — input validation', () => { + it('fails when neither query nor a filter is supplied', async () => { + const result = await executeTool({}); + expect(result.success).toBe(false); + expect(result.error).toMatch(/at least one of query or a filter/i); + expect(mocks.storeQuery).not.toHaveBeenCalled(); + }); + + it('fails when query is only whitespace and no filter is supplied', async () => { + const result = await executeTool({ query: ' ' }); + expect(result.success).toBe(false); + expect(mocks.storeQuery).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// execute — semantic query path +// --------------------------------------------------------------------------- + +describe('challengeVectorQueryTool — semantic query path', () => { + it('embeds the query and queries the store with the resulting vector', async () => { + mocks.storeQuery.mockResolvedValue([hit()]); + + const result = await executeTool({ query: 'realtime dashboard' }); + + expect(mocks.createEmbeddingModel).toHaveBeenCalledWith('AWSBedrock', 'amazon.titan-embed-text-v2:0'); + expect(mocks.embed).toHaveBeenCalledWith( + expect.objectContaining({ value: 'realtime dashboard' }), + ); + expect(mocks.storeQuery).toHaveBeenCalledWith({ + indexName: 'challenge_embeddings', + queryVector: [0.1, 0.2, 0.3], + topK: 10, + filter: undefined, + }); + expect(result.success).toBe(true); + expect(result.count).toBe(1); + expect(result.results[0]).toEqual({ + text: 'chunk text', + score: 0.9, + metadata: { + challengeId: 'challenge-1', + name: 'Test Challenge', + type: 'Challenge', + track: 'Development', + skills: ['React'], + groups: ['acme'], + projectId: '17423', + chunkIndex: 1, + totalChunks: 3, + }, + }); + }); + + it('never passes minScore to store.query (post-filters in app code instead)', async () => { + mocks.storeQuery.mockResolvedValue([hit({ score: 0.9 }), hit({ score: 0.1 })]); + + const result = await executeTool({ query: 'test', minScore: 0.5 }); + + const callArgs = mocks.storeQuery.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('minScore'); + expect(result.count).toBe(1); + expect(result.results[0].score).toBe(0.9); + }); + + it('applies the configured minScore when none is supplied', async () => { + mocks.storeQuery.mockResolvedValue([hit({ score: 0.6 }), hit({ score: 0.4 })]); + + const result = await executeTool({ query: 'test' }); + + expect(result.count).toBe(1); + expect(result.results[0].score).toBe(0.6); + }); + + it('warns but still succeeds when every result falls below threshold', async () => { + mocks.storeQuery.mockResolvedValue([hit({ score: 0.1 })]); + + const result = await executeTool({ query: 'test', minScore: 0.5 }); + + expect(result.success).toBe(true); + expect(result.count).toBe(0); + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('below threshold')); + }); + + it('respects an explicit topK override', async () => { + mocks.storeQuery.mockResolvedValue([]); + await executeTool({ query: 'test', topK: 3 }); + expect(mocks.storeQuery).toHaveBeenCalledWith(expect.objectContaining({ topK: 3 })); + }); + + it('composes filters alongside the query', async () => { + mocks.storeQuery.mockResolvedValue([]); + await executeTool({ query: 'test', type: 'Challenge', skills: ['React'] }); + expect(mocks.storeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { $and: [{ type: { $eq: 'Challenge' } }, { skills: { $in: ['React'] } }] }, + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// execute — metadata-only path (D8/D10, no query) +// --------------------------------------------------------------------------- + +describe('challengeVectorQueryTool — metadata-only path', () => { + it('skips embedding and queries with no queryVector when only a filter is supplied', async () => { + mocks.storeQuery.mockResolvedValue([hit({ score: 0 })]); + + const result = await executeTool({ projectId: '17423' }); + + expect(mocks.embed).not.toHaveBeenCalled(); + expect(mocks.storeQuery).toHaveBeenCalledWith({ + indexName: 'challenge_embeddings', + queryVector: undefined, + topK: 10, + filter: { $and: [{ projectId: { $in: ['17423'] } }] }, + }); + expect(result.success).toBe(true); + expect(result.count).toBe(1); + }); + + it('does not threshold score:0 metadata-only rows even with a high minScore', async () => { + mocks.storeQuery.mockResolvedValue([hit({ score: 0 }), hit({ score: 0 })]); + + const result = await executeTool({ projectId: '17423', minScore: 0.9 }); + + expect(result.count).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// execute — error path +// --------------------------------------------------------------------------- + +describe('challengeVectorQueryTool — error path', () => { + it('returns success:false with the error message when the store throws', async () => { + mocks.storeQuery.mockRejectedValue(new Error('connection refused')); + + const result = await executeTool({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('connection refused'); + expect(mocks.logger.error).toHaveBeenCalledWith(expect.stringContaining('connection refused')); + }); + + it('returns success:false when the embedding call fails', async () => { + mocks.embed.mockRejectedValue(new Error('embedding provider unavailable')); + + const result = await executeTool({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('embedding provider unavailable'); + expect(mocks.storeQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/src/mastra/tools/challenge/challenge-vector-query-tool.ts b/src/mastra/tools/challenge/challenge-vector-query-tool.ts new file mode 100644 index 0000000..62e78d7 --- /dev/null +++ b/src/mastra/tools/challenge/challenge-vector-query-tool.ts @@ -0,0 +1,235 @@ +/** + * Challenge vector query tool — shared retrieval logic for the + * challenge-search-agent and the challenge-search workflow (D8), so the two + * paths cannot rank differently. + * + * Composes an $and metadata filter from skills/type/track/groups/projectId, + * embeds the query text through the provider factory, and queries the shared + * PgVector store. The relevance threshold is applied AFTER retrieval + * (post-filtered in this tool) rather than passed to query({ minScore }), + * because passing minScore forces @mastra/pg off the HNSW ANN fast path onto + * a full exact scan (see ADR 0001, "Query planning consequences"). + * + * `query` is optional here (unlike the source prototype's required `query`): + * a caller supplying only filters (e.g. projectId) gets a metadata-only + * lookup — @mastra/pg's query() accepts a filter with no queryVector — which + * is how the challenge-search workflow answers "everything indexed for this + * project" without an LLM call (D8/D10). + */ + +import { createTool } from '@mastra/core/tools'; +import { embed } from 'ai'; +import { z } from 'zod'; +import { getRagConfig } from '../../../config/rag.config'; +import { tcAILogger } from '../../../utils/logger'; +import { createEmbeddingModel } from '../../../utils/providers/embedding-factory'; +import { getChallengeVectorStore } from '../../vector/challenge-vector-store'; + +// --------------------------------------------------------------------------- +// Zod Schemas +// --------------------------------------------------------------------------- + +const resultMetadataSchema = z.object({ + challengeId: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), + track: z.string().optional(), + skills: z.array(z.string()).optional(), + groups: z.array(z.string()).optional(), + projectId: z.string().nullable().optional(), + chunkIndex: z.number().optional(), + totalChunks: z.number().optional(), +}); + +const resultSchema = z.object({ + text: z.string().optional(), + score: z.number().optional(), + metadata: resultMetadataSchema.optional(), +}); + +export type ChallengeVectorQueryResult = z.infer; + +const outputSchema = z.object({ + success: z.boolean().describe('Indicates if the query was successful.'), + count: z.number().optional().describe('Number of relevant results found.'), + results: z.array(resultSchema).optional().describe('Ranked chunks with their score and metadata.'), + error: z.string().optional().describe('Error message if the query failed.'), +}); + +/** + * WORKAROUND (carried over from the source prototype, re-verified against + * zod 4 + @mastra/core 1.57): Mastra's OpenAISchemaCompatLayer rewrites + * `.optional()` fields to `.nullable()` for some providers, and Ollama then + * sends `""` instead of `null` for those now-nullable fields — which fails + * Zod validation on plain string fields expecting omission rather than an + * empty string. Wrapping the whole input in z.preprocess() stops Mastra from + * introspecting the inner object shape, bypassing that rewrite. + * See @mastra/schema-compat/src/provider-compats/openai.ts (processZodType). + */ +const inputSchema = z.preprocess( + (input) => input, + z.object({ + query: z + .string() + .optional() + .describe( + 'Natural-language search text. May be omitted when at least one filter ' + + '(skills, type, track, groups, projectId) is supplied instead — the search ' + + 'then becomes a metadata-only lookup with no embedding call.', + ), + skills: z.array(z.string()).optional().describe('Filter by challenge skills (e.g. ["TypeScript", "React"])'), + type: z.string().optional().describe('Filter by challenge type. Free-form (D12) — not an enum.'), + track: z.string().optional().describe('Filter by challenge track. Free-form (D12) — not an enum.'), + groups: z.array(z.string()).optional().describe('Filter by challenge group ids'), + projectId: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('Opaque project reference (D10) — a single id or a set of ids'), + topK: z.number().optional().describe('Max results to return (defaults from RAG_TOP_K)'), + minScore: z + .number() + .optional() + .describe('Minimum similarity score, applied after retrieval (defaults from VECTOR_SEARCH_THRESHOLD)'), + }), +); + +export type ChallengeVectorQueryInput = z.infer; + +// --------------------------------------------------------------------------- +// Filter composition +// --------------------------------------------------------------------------- + +/** + * Builds the $and metadata filter from the supplied dimensions. + * Returns undefined when no filter dimension is present. + * + * Typed `any`: @mastra/pg's PGVectorFilter type is a closed structural union + * not exported from the package's public entry point (deep imports are + * blocked by its package.json "exports" map), so there is no way to name it + * from here. The shape below ($and of $eq/$in leaves) is exactly what + * PgVector's filter builder documents and what the source prototype used. + */ +export function buildMetadataFilter( + input: Pick, +): any { + const conditions: Record[] = []; + + if (input.type) { + conditions.push({ type: { $eq: input.type } }); + } + if (input.track) { + conditions.push({ track: { $eq: input.track } }); + } + if (input.skills?.length) { + conditions.push({ skills: { $in: input.skills } }); + } + if (input.groups?.length) { + conditions.push({ groups: { $in: input.groups } }); + } + if (input.projectId !== undefined) { + const ids = Array.isArray(input.projectId) ? input.projectId : [input.projectId]; + if (ids.length > 0) { + conditions.push({ projectId: { $in: ids } }); + } + } + + return conditions.length > 0 ? { $and: conditions } : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------- +// Tool Definition +// --------------------------------------------------------------------------- + +export const challengeVectorQueryTool = createTool({ + id: 'challenge-vector-query', + description: + 'Searches indexed Topcoder challenge descriptions by semantic similarity, with optional ' + + 'metadata filters for skills, type, track, groups, and projectId. Returns raw ranked chunks ' + + '(no summarisation). query may be omitted when at least one filter is supplied.', + inputSchema, + outputSchema, + execute: async (inputData) => { + const config = getRagConfig(); + const filter = buildMetadataFilter(inputData); + const query = inputData.query?.trim(); + + if (!query && !filter) { + return { + success: false, + error: + 'At least one of query or a filter (skills, type, track, groups, projectId) is required', + }; + } + + const topK = inputData.topK ?? config.topK; + const minScore = inputData.minScore ?? config.vectorSearchThreshold; + + try { + const store = getChallengeVectorStore(); + + let queryVector: number[] | undefined; + if (query) { + const { embedding } = await embed({ + model: createEmbeddingModel(config.embedding.provider, config.embedding.modelId), + value: query, + }); + queryVector = embedding; + } + + // minScore is deliberately NOT passed to query() — see module docblock. + const results = await store.query({ + indexName: config.vectorIndexName, + queryVector, + topK, + filter, + }); + + // The metadata-only path (no queryVector) returns score: 0 for every + // row by construction (ordered by vector_id instead) — thresholding + // it would silently discard every result. + const relevant = queryVector ? results.filter((r) => (r.score ?? 0) >= minScore) : results; + + if (results.length > 0 && relevant.length === 0) { + tcAILogger.warn( + `[challenge-vector-query] ${results.length} results found, all below threshold ` + + `${minScore}. Top scores: ${results.slice(0, 3).map((r) => r.score?.toFixed(3)).join(', ')}. ` + + 'Consider lowering VECTOR_SEARCH_THRESHOLD.', + ); + } + + return { + success: true, + count: relevant.length, + results: relevant.map((r) => ({ + text: r.metadata?.text, + score: r.score, + metadata: { + challengeId: r.metadata?.challengeId, + name: r.metadata?.name, + type: r.metadata?.type, + track: r.metadata?.track, + skills: r.metadata?.skills, + groups: r.metadata?.groups, + projectId: r.metadata?.projectId ?? null, + chunkIndex: r.metadata?.chunkIndex, + totalChunks: r.metadata?.totalChunks, + }, + })), + }; + } catch (error) { + const message = errorMessage(error); + tcAILogger.error(`[challenge-vector-query] query failed: ${message}`); + return { success: false, error: message }; + } + }, +}); + +// --------------------------------------------------------------------------- +// Testing Exports +// --------------------------------------------------------------------------- + +export const _testing = { buildMetadataFilter }; diff --git a/src/mastra/tools/project/fetch-project-tool.test.ts b/src/mastra/tools/project/fetch-project-tool.test.ts new file mode 100644 index 0000000..acef7ea --- /dev/null +++ b/src/mastra/tools/project/fetch-project-tool.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { m2mTokenMock } = vi.hoisted(() => ({ + m2mTokenMock: vi.fn(), +})); + +vi.mock('../../../utils/auth/m2m.service', () => ({ + M2MService: class MockM2MService { + getM2MToken = m2mTokenMock; + }, +})); + +import { fetchProjectTool } from './fetch-project-tool'; + +// Minimal context for execute — the tool only uses context.mastra?.getLogger?.() +// which is optional, so undefined mastra is safe. +const minimalContext = { mastra: undefined } as any; + +/** + * Installs a global fetch spy that resolves with the given JSON body. + * Returns the spy so tests can assert call arguments (URL, headers). + */ +function mockFetchResponse(data: Record) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => data, + } as Response); +} + +/** + * Installs a global fetch spy that resolves with a non-2xx status. + */ +function mockFetchError(status: number) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status, + json: async () => ({}), + } as Response); +} + +function baseApiResponse(overrides: Record = {}) { + return { + id: 17423, + name: 'Acme Redesign', + status: 'active', + type: 'app_dev', + billingAccountId: 98765, + directProjectId: 54321, + techStack: ['React', 'Node.js'], + ...overrides, + }; +} + +async function executeTool(input: Record): Promise { + return fetchProjectTool.execute?.(input as any, minimalContext) as Promise; +} + +beforeEach(() => { + vi.clearAllMocks(); + m2mTokenMock.mockResolvedValue('fake-m2m-token'); +}); + +describe('fetchProjectTool — request construction', () => { + it('sends an M2M-authenticated GET request to the project endpoint', async () => { + const fetchSpy = mockFetchResponse(baseApiResponse()); + + await executeTool({ projectId: '17423' }); + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/v6\/projects\/17423$/); + expect(init.method).toBe('GET'); + expect((init.headers as Record).Authorization).toBe('Bearer fake-m2m-token'); + }); + + it('appends a fields query param when supplied', async () => { + const fetchSpy = mockFetchResponse(baseApiResponse()); + + await executeTool({ projectId: '17423', fields: 'id,name' }); + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toMatch(/\/v6\/projects\/17423\?fields=id%2Cname$/); + }); +}); + +describe('fetchProjectTool — response mapping', () => { + it('coerces id/billingAccountId/directProjectId to strings', async () => { + mockFetchResponse(baseApiResponse()); + + const result = await executeTool({ projectId: '17423' }); + + expect(result.project).toEqual({ + id: '17423', + name: 'Acme Redesign', + status: 'active', + type: 'app_dev', + billingAccountId: '98765', + directProjectId: '54321', + techStack: ['React', 'Node.js'], + }); + }); + + it('falls back to the requested projectId when the response omits id', async () => { + mockFetchResponse(baseApiResponse({ id: undefined })); + const result = await executeTool({ projectId: '17423' }); + expect(result.project.id).toBe('17423'); + }); + + it('leaves billingAccountId/directProjectId undefined when absent', async () => { + mockFetchResponse(baseApiResponse({ billingAccountId: null, directProjectId: undefined })); + const result = await executeTool({ projectId: '17423' }); + expect(result.project.billingAccountId).toBeUndefined(); + expect(result.project.directProjectId).toBeUndefined(); + }); + + it('reads techStack from details.techStack when the top-level field is absent', async () => { + mockFetchResponse( + baseApiResponse({ techStack: undefined, details: { techStack: ['Python'] } }), + ); + const result = await executeTool({ projectId: '17423' }); + expect(result.project.techStack).toEqual(['Python']); + }); +}); + +describe('fetchProjectTool — error handling', () => { + it('throws with the HTTP status when the response is not ok', async () => { + mockFetchError(404); + await expect(executeTool({ projectId: '17423' })).rejects.toThrow(/404/); + }); +}); diff --git a/src/mastra/tools/project/fetch-project-tool.ts b/src/mastra/tools/project/fetch-project-tool.ts new file mode 100644 index 0000000..dc4b2e5 --- /dev/null +++ b/src/mastra/tools/project/fetch-project-tool.ts @@ -0,0 +1,97 @@ +// Projects API: GET /v6/projects/:projectId (M2M token required) +// +// Retrieval-time enrichment only (D10): resolves the opaque `projectId` +// reference stored in challenge vector metadata to project detail (name, +// status, tech stack) on demand, under the CALLER's own authorization. +// Not used by, and nothing in, the ingestion or retrieval path depends on +// this tool — it exists so a consumer that already has a projectId from a +// challenge-search hit can make the "subsequent call" D10 describes instead +// of that data being denormalized into the vector store. +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { M2MService } from '../../../utils/auth/m2m.service'; + +const BASE_URL = `${process.env.TC_API_BASE}/v6/projects`; + +const m2mService = new M2MService(); + +export const fetchProjectTool = createTool({ + id: 'fetch-project-by-id', + description: + 'Fetches a Topcoder project by id from the v6 Projects API using M2M authentication. ' + + 'Retrieval-time enrichment only — resolves a projectId reference from a challenge-search hit ' + + 'to the project\'s name, status, type, and tech stack.', + inputSchema: z.object({ + projectId: z.string().describe('Project id to fetch (D10 — carried as a string reference)'), + fields: z.string().optional().describe('Optional comma-separated field list to narrow the response'), + }), + outputSchema: z.object({ + project: z.object({ + id: z.string(), + name: z.string().optional(), + status: z.string().optional(), + type: z.string().optional(), + billingAccountId: z.string().optional(), + directProjectId: z.string().optional(), + techStack: z.array(z.string()).optional(), + }), + }), + execute: async (inputData, context) => { + const logger = context.mastra?.getLogger?.(); + logger?.info('Fetching project by ID: {projectId}', { projectId: inputData.projectId }); + return await fetchProject(inputData.projectId, inputData.fields); + }, +}); + +/** + * Project.id / billingAccountId / directProjectId are Prisma BigInt on the + * server (projects-api-v6), which throws on JSON.stringify ("Do not know how + * to serialize a BigInt") if ever returned without an explicit conversion. + * By the time we read a value here it has already crossed fetch().json() + * (which never produces a BigInt), but every one of those fields is still + * coerced to string explicitly so this tool's own output never depends on + * how the upstream API happened to serialize them. + */ +function toStringOrUndefined(value: unknown): string | undefined { + return value === null || value === undefined ? undefined : String(value); +} + +const fetchProject = async (projectId: string, fields?: string) => { + const token = await m2mService.getM2MToken(); + + const params = fields ? `?fields=${encodeURIComponent(fields)}` : ''; + const url = `${BASE_URL}/${encodeURIComponent(projectId)}${params}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'app-version': '2.0.0', + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch project ${projectId} (HTTP ${response.status})`); + } + + const data = await response.json(); + const techStack = Array.isArray(data.techStack) + ? data.techStack + : Array.isArray(data.details?.techStack) + ? data.details.techStack + : undefined; + + return { + project: { + id: toStringOrUndefined(data.id) ?? projectId, + name: data.name ?? undefined, + status: data.status ?? undefined, + type: data.type ?? undefined, + billingAccountId: toStringOrUndefined(data.billingAccountId), + directProjectId: toStringOrUndefined(data.directProjectId), + techStack, + }, + }; +}; diff --git a/src/mastra/workflows/challenge/challenge-search-workflow.test.ts b/src/mastra/workflows/challenge/challenge-search-workflow.test.ts new file mode 100644 index 0000000..7090917 --- /dev/null +++ b/src/mastra/workflows/challenge/challenge-search-workflow.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + queryExecute: vi.fn(), + getRagConfig: vi.fn(() => ({ topK: 10, vectorSearchThreshold: 0.5 })), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../tools/challenge/challenge-vector-query-tool', () => ({ + challengeVectorQueryTool: { execute: mocks.queryExecute }, +})); + +vi.mock('../../../config/rag.config', () => ({ + getRagConfig: mocks.getRagConfig, +})); + +vi.mock('../../../utils/logger', () => ({ + tcAILogger: mocks.logger, +})); + +import { challengeSearchWorkflow, _testing } from './challenge-search-workflow'; + +const { searchChallengesStep } = _testing; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function hit(overrides: Record = {}) { + return { + text: 'chunk text', + score: 0.9, + metadata: { + challengeId: 'challenge-1', + name: 'Test Challenge', + type: 'Challenge', + track: 'Development', + skills: ['React'], + groups: ['acme'], + projectId: '17423', + chunkIndex: 1, + totalChunks: 3, + }, + ...overrides, + }; +} + +interface StepExecutor { + execute: (params: Record) => Promise>; +} + +async function runStep(inputData: Record): Promise { + const executable = searchChallengesStep as unknown as StepExecutor; + return executable.execute({ inputData, requestContext: {} }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRagConfig.mockReturnValue({ topK: 10, vectorSearchThreshold: 0.5 }); +}); + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +describe('challenge-search:search-challenges — input validation', () => { + it('throws when neither query nor a filter is supplied', async () => { + await expect(runStep({})).rejects.toThrow(/at least one of query or a filter/i); + expect(mocks.queryExecute).not.toHaveBeenCalled(); + }); + + it('proceeds with a filter-only input (no query)', async () => { + mocks.queryExecute.mockResolvedValue({ success: true, results: [] }); + await expect(runStep({ projectId: '17423' })).resolves.toBeTruthy(); + expect(mocks.queryExecute).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Tool delegation +// --------------------------------------------------------------------------- + +describe('challenge-search:search-challenges — tool delegation', () => { + it('passes query, filters, topK and minScore through to the shared tool', async () => { + mocks.queryExecute.mockResolvedValue({ success: true, results: [] }); + + await runStep({ + query: 'realtime dashboard', + skills: ['React'], + type: 'Challenge', + track: 'Development', + groups: ['acme'], + projectId: '17423', + topK: 5, + minScore: 0.4, + }); + + expect(mocks.queryExecute).toHaveBeenCalledWith( + { + query: 'realtime dashboard', + skills: ['React'], + type: 'Challenge', + track: 'Development', + groups: ['acme'], + projectId: '17423', + topK: 5, + minScore: 0.4, + }, + expect.objectContaining({ requestContext: {} }), + ); + }); + + it('throws when the tool reports failure', async () => { + mocks.queryExecute.mockResolvedValue({ success: false, error: 'connection refused' }); + await expect(runStep({ query: 'test' })).rejects.toThrow(/connection refused/); + }); + + it('throws when the tool call itself rejects', async () => { + mocks.queryExecute.mockRejectedValue(new Error('embedding provider unavailable')); + await expect(runStep({ query: 'test' })).rejects.toThrow(/embedding provider unavailable/); + }); +}); + +// --------------------------------------------------------------------------- +// groupBy: chunk +// --------------------------------------------------------------------------- + +describe('challenge-search:search-challenges — groupBy chunk', () => { + it('returns raw hits ordered by descending score, no grouping', async () => { + mocks.queryExecute.mockResolvedValue({ + success: true, + results: [hit({ score: 0.3 }), hit({ score: 0.9 })], + }); + + const result = await runStep({ query: 'test', groupBy: 'chunk' }); + + expect(result.groupBy).toBe('chunk'); + expect(result.count).toBe(2); + expect(result.results.map((r: any) => r.score)).toEqual([0.9, 0.3]); + expect(result.results[0]).toMatchObject({ text: 'chunk text', challengeId: 'challenge-1' }); + }); +}); + +// --------------------------------------------------------------------------- +// groupBy: challenge (default) +// --------------------------------------------------------------------------- + +describe('challenge-search:search-challenges — groupBy challenge (default)', () => { + it('groups chunks by challengeId, using the best chunk score as the challenge score', async () => { + mocks.queryExecute.mockResolvedValue({ + success: true, + results: [ + hit({ score: 0.5, metadata: { ...hit().metadata, chunkIndex: 2 } }), + hit({ score: 0.9, metadata: { ...hit().metadata, chunkIndex: 1 } }), + ], + }); + + const result = await runStep({ query: 'test' }); + + expect(result.groupBy).toBe('challenge'); + expect(result.count).toBe(1); + const [entry] = result.results; + expect(entry.challengeId).toBe('challenge-1'); + expect(entry.score).toBe(0.9); + expect(entry.name).toBe('Test Challenge'); + expect(entry.chunks).toHaveLength(2); + expect(entry.chunks[0].score).toBe(0.9); + expect(entry.chunks[1].score).toBe(0.5); + }); + + it('produces one entry per distinct challenge, ranked by best score', async () => { + mocks.queryExecute.mockResolvedValue({ + success: true, + results: [ + hit({ score: 0.4, metadata: { ...hit().metadata, challengeId: 'challenge-2' } }), + hit({ score: 0.95, metadata: { ...hit().metadata, challengeId: 'challenge-1' } }), + ], + }); + + const result = await runStep({ query: 'test' }); + + expect(result.count).toBe(2); + expect(result.results.map((r: any) => r.challengeId)).toEqual(['challenge-1', 'challenge-2']); + }); + + it('defaults to challenge grouping when groupBy is omitted', async () => { + mocks.queryExecute.mockResolvedValue({ success: true, results: [hit()] }); + const result = await runStep({ query: 'test' }); + expect(result.groupBy).toBe('challenge'); + }); +}); + +// --------------------------------------------------------------------------- +// groupBy: project +// --------------------------------------------------------------------------- + +describe('challenge-search:search-challenges — groupBy project', () => { + it('rolls chunks up by projectId and lists distinct contributing challengeIds', async () => { + mocks.queryExecute.mockResolvedValue({ + success: true, + results: [ + hit({ score: 0.6, metadata: { ...hit().metadata, challengeId: 'challenge-1' } }), + hit({ score: 0.8, metadata: { ...hit().metadata, challengeId: 'challenge-2' } }), + ], + }); + + const result = await runStep({ projectId: '17423', groupBy: 'project' }); + + expect(result.groupBy).toBe('project'); + expect(result.count).toBe(1); + const [entry] = result.results; + expect(entry.projectId).toBe('17423'); + expect(entry.score).toBe(0.8); + expect(entry.challengeIds.sort()).toEqual(['challenge-1', 'challenge-2']); + expect(entry.chunks).toHaveLength(2); + }); + + it('groups a null projectId separately from string projectIds', async () => { + mocks.queryExecute.mockResolvedValue({ + success: true, + results: [ + hit({ score: 0.5, metadata: { ...hit().metadata, projectId: null } }), + hit({ score: 0.7, metadata: { ...hit().metadata, projectId: '17423' } }), + ], + }); + + const result = await runStep({ type: 'Challenge', groupBy: 'project' }); + + expect(result.count).toBe(2); + const projectIds = result.results.map((r: any) => r.projectId); + expect(projectIds).toContain(null); + expect(projectIds).toContain('17423'); + }); +}); + +// --------------------------------------------------------------------------- +// Workflow definition +// --------------------------------------------------------------------------- + +describe('challengeSearchWorkflow', () => { + it('is registered with id challenge-search', () => { + expect(challengeSearchWorkflow.id).toBe('challenge-search'); + }); +}); diff --git a/src/mastra/workflows/challenge/challenge-search-workflow.ts b/src/mastra/workflows/challenge/challenge-search-workflow.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b5a8b6fad7ca444f547b726d53dacdb15d2aac0 GIT binary patch literal 12149 zcmd^FZFAek5$WO-xOI=KpEHPy=RKrP}raHUO z%ILTlPSjOiT#nQHN(ITN-%R&px~pFNDU5)IFda|gLYoHWJ3TD(;@e6W^OHPJ)m2hX znpumX7hmYCRDp@7pb2>0C0u$s(4$e3U8r$PSms?7XCw8PevFe8TUNt-I?GK`CV3Wi zmC2Q0B8Ym^gsISFk?0R`s)liv<)s=WA9Qh{BlRvZc$7tMaOx zRV59T6RnE)3jec9JrZoDN=wsK`HTo->0DhDc{S6cuA0Th=n+<7-~z0p^Jcj+)pQyc ziHT_ipRUk^5vK#50z14aaE$DtX5{%liD&27(OeC*%BM+L0`JvCXDTjLs$)~CJkvEN zu+teh4?gx)j6d`5!TAH;ujN&o5_p`MD_t11slkDHIU)8Y*+_o`@-a4o5yHgFH`RwE zRxzD^s3mvJx8aN0D_FRPgfa4_Dc+;YgAGg^BW{DJSk$56cz5YFeF)t@ z@{u|oPBbp zKQ_{4SR?}-bl$~fRm5p8jkAjiR;9LurT$n(>g~_g%BT~{eVWT8O%3)I#YGX%gHLX2 zVbp9XYIgFqn7YBd5fmjzYLp}*gs?v2{qfMsNPS&s-GlN^Rq)~ue-t$)D;Z}>XVuhg z9ZER7{Dp*Q>HR6OIsLUvVk9aVnVL_|Dz+q}%3zX%(}sQBb$4@iZ^=2F#Sp*RhQhLs zb)mDNCgwgB=EllE7*mbSk@3e@4P!p`_sW}pK5y-mE+aVY3@24~+370$VI4Jnv4`v2 zz0q+DyAgP+y`wp?b9zGB)7fO2o8(BVA~Wm|CrN2km08ahDZtq6Y^q?SnMx>*(I^J? zMfhOE-lC&^-=;Ls&~T=v3>kblKR9`W6y%2;1(}1l$TR5m0EQWEj8_?zB8q`CtKig% z49;Z|sTC@l&chWsw2_f>^dX0uDQwkAOVkm~B~Dl+F7w%IQUqMcKo`Lcn)^2X2##^E z;5cH$sU1Cg`TpeaoAyIVRywAifi ztWpgJiU$;X+;Cb_^p5Ypbtgj;O4 zJ^n35S0L`^Q3RGR`%c3uuucJ52gs^4jR&c23B4>})_;ZzwPxk^OTFPnEQ{7l>i^&E zcvruw_b_grBH8P|o$GhW$gS-6uFTt4V==6;WwG)A?%KG-hliZO-DCn72fNqko?g|8 zcwSM4)mVNRwpN}d>Zs0!6)WZlPOJ8lK5LOVO48*Io`v<5=#QK)wUdEkPS53_90MmA zVvVl|2*JcE8+JZR!2gm`U-G{boz9S2K6uo>^#pYVB(_PWQ_k@f*&vB5i0-AHb1Ld& zF;t^mo2-LC0^U7AO3!IHMG)e^)Fxrrj3^ZqC;0BKZ{ECrz5jQxvGY5cq~vH^Wkbr> zh1B!;S0_6`QtByW@#OTZON0-{eN;s-hM`bx?^pt1A1T7@E1}jU=p7}QiRW_-h-La} z1u}p$qgaE|@j&2-0tbjhJ+!C@My81a+p-%4rw;h6K)iM!6S|ZU^r?mMqkJN%sCqE^iv}?ZHSa?hy%wHfI z(-~EOI5G<2J_-ixn|J$=0Sdj;qN@h$hH(QHLilb81%rqYsNG$KV7#Y-*dJ)`pv?Cu zC=!hfCJ7XOuh!j_L>vS^MJ)wfQT|SJJ#4-u5Kp>*O#H7%;jYDXH|7>QQ4WSr| z=Ia8@Zsbih@Z#y(fHc-XNFdUNpfL|ZB9SE!8f;+dSfmXkqYadZ$nwTzw1IN^ybS0j z=oU#?n>EaH8-ld=(9AT@tX*>&pQ=hY9+g{;tXH2cp zdckN=2SvJSO*6qoCQXJEU(-~uVg@}+Dj1NAV!7ZseR?L&gUrW@ouNU0Z>`kZ3KFpG zdM2@v3|cH^gh3jE)3zf7Cbnb=tZhsfObp60T-%}$U~N^FAeYSwfy$Ni)e@)-w-4jY z$O^QZSv4?3gEaxNDlF$aFY}ku0bp}r16P8D+2CLH*6fv7F}nGNeMiW9e?I?4;=^C0 zC-y*oZ29Z7q{X2+c=`pJ!Vv-=@N;U2C-W3^J)^>FM9qbur2!O-9V2Y&V!CKnxtT!WPv`O60g-#$KO7Ox7*v>s^+p1pQm`H!AMxG z3OiG9vm*N(Y2)~fgje_vpWi$|`OW-YY(1D=U?Jy7n$Almko6*E1bX?TLRzV|1 zy*ZXr=Q|Wx_2{U|#2}#kiK1(gHGJp|s`Rpl(*TgRSCf(M9mwkfJz1^3tsA=*kzACi zhC|#!Ukya~#RLi`4MO+5+UI-h76hl(doeozDfcgT_u!o>ZZiJ#mXEN^ahUss69Z%c5q9%JsviF{oOk+bnDbRD{x$mq=XJ16+uEslOz}0M)Ce-ca zzLMddx~P`a^`+5W7p~D+@JjR@<_{KgDIa?3RWrDcZS9sTcYDtT!n%&0G@U!#Ff+w# z=7}|u{<{RmHIQG?5h$x6=y;oXHdMhigcx1H7id&>aR9VoOu2^^0g@`USQ~v^O9LBk z2e>WQWi7=|jzMfN;Yu?H=WXu^!#t?S`hvIT>QPaO=3==BPk;U=lVypL2NW zE*J#|T1g)p))mQgsz);!39p)$Lo9{)*zAwclI7UGP;%<4%;Q zr6YuwKAqrxG8Iz5K*E~9Q+SCT;qbx)xGx%^kH`mp(t86W4&L*Fx^_fh%~J-dOXTg- zpWo}`eA2#peBI*tt~%dr4=py&ahDxD2*DE*dOst4;nyNz2Q`{=&xp|8&V@IGx|YoE z^CSy8-A)LZQ=K@Q(>lo{K#T;G(y@A6QU}@vNvSupLPkn4OPTz4d^?vB#)IcYu}g1d#-D}ECOxw(kGF3y|i%h&1@)jA3y#4Yw$9PJN{ zsx)?^EapqKxCRB}B#5u#gqo{1!S%c^1v_56;UtZ&iaysXBQ$EIqa_=-JEz3W(FnT! zSglx#aWzM*SBXVlyGX8eJu!Ey>!;b8uSHs0_|G8 zHnJfw*#L@k9g|4g?c7QgS^SY7`|O-{EO5sw_7FR*1(JXR?$kaNS>|EAjctCGaaXy? z);GAYam3k>e`x6dtR-%B4+gz$J{t0Nzu$P3jaA%tEOzlU7e@T$4SyfN6F$DC>$I`U zqIm%G)4%==HL*u(rkPIxS~V-~sJ^6SHxb_83F`8I1f$fl^lq76z;Hvdy#We-<=IJH zezKk2hkx#OLKz~m-hFORK78}fHa3dJ9os9A;Uq@#tDp|@u>lc#Elk2-u(e@kffZnRxGdxTlx{%j%5pwG+SSOEm9 Date: Mon, 24 Aug 2026 09:56:17 +0300 Subject: [PATCH 11/19] feat: add CSV backfill and sync CLIs sharing the ingestion workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of ADR 0001 — secondary bulk-ingestion path (D11), for offline/air-gapped environments and CSV exports that predate the Challenge Search API. - ingestion-logger.ts: hierarchical file logger ported from the source prototype, minus interceptConsole (D6) — per-run logs/ingestion-/ {output.log,error.log,report.json}. - ingest-challenges.ts: streams CSV rows and invokes the challenge-ingestion workflow per record via mastra.getWorkflowById().createRun().start(), so CLI and API share one implementation. --clear-all drops the vector index via deleteIndex() (the actual @mastra/pg 1.19 API for "delete everything" — deleteVectors() explicitly rejects an empty filter and points here). - sync-challenges.ts: thin wrapper around challenge-bulk-ingestion for incremental sync / project-scoped backfill from the CLI. - package.json ingest/sync scripts, logs/ in .gitignore, a small CSV fixture for tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MkcggGkcnz73GXUDR7mgrE --- .gitignore | 3 +- package.json | 4 +- src/scripts/ingest-challenges.test.ts | 344 ++++++++++++++++++++++++++ src/scripts/ingest-challenges.ts | 316 +++++++++++++++++++++++ src/scripts/ingestion-logger.test.ts | 136 ++++++++++ src/scripts/ingestion-logger.ts | 102 ++++++++ src/scripts/sync-challenges.test.ts | 130 ++++++++++ src/scripts/sync-challenges.ts | 144 +++++++++++ tests/fixtures/challenges-sample.csv | 9 + 9 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 src/scripts/ingest-challenges.test.ts create mode 100644 src/scripts/ingest-challenges.ts create mode 100644 src/scripts/ingestion-logger.test.ts create mode 100644 src/scripts/ingestion-logger.ts create mode 100644 src/scripts/sync-challenges.test.ts create mode 100644 src/scripts/sync-challenges.ts create mode 100644 tests/fixtures/challenges-sample.csv diff --git a/.gitignore b/.gitignore index 71949b0..2900238 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ dist *.db-* .vscode test-data -.env.sh \ No newline at end of file +.env.sh +logs/ \ No newline at end of file diff --git a/package.json b/package.json index f5cf459..1b1ba0d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "format": "prettier . --write", "format:check": "prettier . --check", "studio": "mastra studio", - "test:watch": "vitest" + "test:watch": "vitest", + "ingest": "tsx --env-file=.env src/scripts/ingest-challenges.ts", + "sync": "tsx --env-file=.env src/scripts/sync-challenges.ts" }, "keywords": [], "author": "Kiril Kartunov", diff --git a/src/scripts/ingest-challenges.test.ts b/src/scripts/ingest-challenges.test.ts new file mode 100644 index 0000000..129f578 --- /dev/null +++ b/src/scripts/ingest-challenges.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + getWorkflowById: vi.fn(), + createRun: vi.fn(), + runStart: vi.fn(), + getRagConfig: vi.fn(), + deleteIndex: vi.fn(), + readlineAnswer: 'y', +})); + +vi.mock('../mastra', () => ({ + mastra: { getWorkflowById: mocks.getWorkflowById }, +})); + +vi.mock('../config/rag.config', () => ({ + getRagConfig: mocks.getRagConfig, +})); + +vi.mock('../mastra/vector/challenge-vector-store', () => ({ + getChallengeVectorStore: () => ({ deleteIndex: mocks.deleteIndex }), +})); + +vi.mock('node:readline', () => ({ + createInterface: () => ({ + question: (_prompt: string, cb: (answer: string) => void) => cb(mocks.readlineAnswer), + close: () => undefined, + }), +})); + +import { main, _testing } from './ingest-challenges'; +import { IngestionLogger } from './ingestion-logger'; +import type { IngestionReport } from '../mastra/rag/types'; + +const { parseOptions, getCsvFiles, processFile, runIngestionWorkflow } = _testing; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const tmpDirs: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ingest-challenges-test-')); + tmpDirs.push(dir); + return dir; +} + +function writeCsv(dir: string, filename: string, content: string): string { + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, content); + return filePath; +} + +const VALID_CSV = [ + '"id","name","description","descriptionFormat","typeName","trackName","skills"', + '"c1","Challenge One","Body one","markdown","Challenge","Development","React"', + '"c2","Challenge Two","Body two","markdown","Task","Quality Assurance","Selenium"', +].join('\n'); + +function defaultRagConfig() { + return { + embedding: { provider: 'TC-Ollama', modelId: 'nomic-embed-text', dimension: 768, maxContextWindow: 2048 }, + vectorIndexName: 'challenge_embeddings', + vectorSearchThreshold: 0.5, + chunkMaxSize: 512, + chunkOverlap: 50, + topK: 10, + challengeSearchAI: { provider: 'AWSBedrock', modelId: 'model' }, + database: { connectionString: 'postgres://x', schemaName: 'ai' }, + knownTypes: [], + knownTracks: [], + }; +} + +function successfulWorkflow(report: { chunks: number; forceSplits: unknown[] } = { chunks: 3, forceSplits: [] }) { + mocks.getWorkflowById.mockReturnValue({ createRun: mocks.createRun }); + mocks.createRun.mockResolvedValue({ start: mocks.runStart }); + mocks.runStart.mockResolvedValue({ status: 'success', result: report }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.readlineAnswer = 'y'; + mocks.getRagConfig.mockReturnValue(defaultRagConfig()); +}); + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// parseOptions +// --------------------------------------------------------------------------- + +describe('parseOptions', () => { + it('defaults folder to "data" when neither --file nor --folder is given', () => { + expect(parseOptions([])).toEqual({ folder: 'data', file: undefined, dryRun: false, clearAll: false }); + }); + + it('parses --file and leaves folder undefined', () => { + expect(parseOptions(['--file', 'x.csv'])).toMatchObject({ file: 'x.csv', folder: undefined }); + }); + + it('parses --folder explicitly', () => { + expect(parseOptions(['--folder', 'custom'])).toMatchObject({ folder: 'custom' }); + }); + + it('rejects both --file and --folder together', () => { + expect(() => parseOptions(['--file', 'x.csv', '--folder', 'y'])).toThrow(/cannot use both/i); + }); + + it('parses --dry-run and --clear-all flags', () => { + expect(parseOptions(['--dry-run', '--clear-all'])).toMatchObject({ dryRun: true, clearAll: true }); + }); +}); + +// --------------------------------------------------------------------------- +// getCsvFiles +// --------------------------------------------------------------------------- + +describe('getCsvFiles', () => { + it('returns the single resolved file for --file', () => { + const dir = tmpDir(); + const filePath = writeCsv(dir, 'a.csv', VALID_CSV); + expect(getCsvFiles({ file: filePath, dryRun: false, clearAll: false })).toEqual([filePath]); + }); + + it('throws when --file does not exist', () => { + expect(() => getCsvFiles({ file: '/nonexistent/x.csv', dryRun: false, clearAll: false })).toThrow( + /file not found/i, + ); + }); + + it('throws when --folder does not exist', () => { + expect(() => getCsvFiles({ folder: '/nonexistent/dir', dryRun: false, clearAll: false })).toThrow( + /folder not found/i, + ); + }); + + it('throws when the folder has no .csv files', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'hello'); + expect(() => getCsvFiles({ folder: dir, dryRun: false, clearAll: false })).toThrow(/no csv files found/i); + }); + + it('returns every .csv file in the folder, ignoring non-csv files', () => { + const dir = tmpDir(); + writeCsv(dir, 'a.csv', VALID_CSV); + writeCsv(dir, 'b.csv', VALID_CSV); + fs.writeFileSync(path.join(dir, 'readme.md'), 'ignore me'); + const files = getCsvFiles({ folder: dir, dryRun: false, clearAll: false }); + expect(files.map((f) => path.basename(f)).sort()).toEqual(['a.csv', 'b.csv']); + }); +}); + +// --------------------------------------------------------------------------- +// runIngestionWorkflow +// --------------------------------------------------------------------------- + +describe('runIngestionWorkflow', () => { + it('invokes challenge-ingestion via createRun().start() and returns chunks/forceSplits', async () => { + successfulWorkflow({ chunks: 4, forceSplits: [{ chunkIndex: 0, originalTokens: 10, resultingChunks: 2, reason: 'code-block' }] }); + + const result = await runIngestionWorkflow( + { id: 'c1', name: 'Test', description: 'body' }, + false, + ); + + expect(mocks.getWorkflowById).toHaveBeenCalledWith('challenge-ingestion'); + expect(mocks.runStart).toHaveBeenCalledWith({ + inputData: { challenge: { id: 'c1', name: 'Test', description: 'body' }, dryRun: false }, + }); + expect(result.chunks).toBe(4); + expect(result.forceSplits).toHaveLength(1); + }); + + it('throws when the workflow is not registered', async () => { + mocks.getWorkflowById.mockReturnValue(undefined); + await expect(runIngestionWorkflow({ id: 'c1', name: 'Test', description: 'body' }, false)).rejects.toThrow( + /not registered/i, + ); + }); + + it('throws with the run error detail when the run does not succeed', async () => { + mocks.getWorkflowById.mockReturnValue({ createRun: mocks.createRun }); + mocks.createRun.mockResolvedValue({ start: mocks.runStart }); + mocks.runStart.mockResolvedValue({ status: 'failed', error: new Error('embedding provider unavailable') }); + + await expect(runIngestionWorkflow({ id: 'c1', name: 'Test', description: 'body' }, false)).rejects.toThrow( + /embedding provider unavailable/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// processFile +// --------------------------------------------------------------------------- + +describe('processFile', () => { + async function runProcessFile(csvContent: string, dryRun = false) { + const dir = tmpDir(); + const filePath = writeCsv(dir, 'challenges.csv', csvContent); + const logger = IngestionLogger.create(dir); + const report: IngestionReport = { + startTime: new Date().toISOString(), + endTime: '', + totals: { files: 1, records: 0, chunks: 0, errors: 0, forceSplits: 0 }, + files: {}, + }; + await processFile(filePath, logger, report, dryRun); + await logger.close(); + return report; + } + + it('ingests every valid row through runIngestionWorkflow and aggregates chunks', async () => { + successfulWorkflow({ chunks: 3, forceSplits: [] }); + + const report = await runProcessFile(VALID_CSV); + + expect(mocks.runStart).toHaveBeenCalledTimes(2); + expect(report.totals.records).toBe(2); + expect(report.totals.chunks).toBe(6); + expect(report.totals.errors).toBe(0); + expect(report.files['challenges.csv'].records).toBe(2); + }); + + it('passes dryRun through to the workflow input', async () => { + successfulWorkflow({ chunks: 1, forceSplits: [] }); + await runProcessFile(VALID_CSV, true); + expect(mocks.runStart).toHaveBeenCalledWith(expect.objectContaining({ + inputData: expect.objectContaining({ dryRun: true }), + })); + }); + + it('records a per-row error and continues when a row fails validation', async () => { + successfulWorkflow({ chunks: 2, forceSplits: [] }); + const csv = [ + '"id","name","description","descriptionFormat","typeName","trackName","skills"', + '"","Missing Id","Body","markdown","Challenge","Development","React"', + '"c2","Challenge Two","Body two","markdown","Task","Quality Assurance","Selenium"', + ].join('\n'); + + const report = await runProcessFile(csv); + + expect(report.totals.errors).toBe(1); + expect(report.totals.records).toBe(1); + expect(report.files['challenges.csv'].errors[0].message).toMatch(/missing id/i); + }); + + it('records a per-row error and continues when the workflow rejects for one row', async () => { + mocks.getWorkflowById.mockReturnValue({ createRun: mocks.createRun }); + mocks.createRun.mockResolvedValue({ start: mocks.runStart }); + mocks.runStart + .mockResolvedValueOnce({ status: 'success', result: { chunks: 2, forceSplits: [] } }) + .mockResolvedValueOnce({ status: 'failed', error: new Error('database unreachable') }); + + const report = await runProcessFile(VALID_CSV); + + expect(report.totals.records).toBe(1); + expect(report.totals.errors).toBe(1); + expect(report.files['challenges.csv'].errors[0].message).toMatch(/database unreachable/); + }); + + it('aborts the whole file with a single error when required columns are missing', async () => { + successfulWorkflow(); + const csv = '"id","name"\n"c1","Missing Columns"'; + + const report = await runProcessFile(csv); + + expect(mocks.runStart).not.toHaveBeenCalled(); + expect(report.totals.errors).toBe(1); + expect(report.files['challenges.csv'].errors[0].message).toMatch(/missing required columns/i); + }); +}); + +// --------------------------------------------------------------------------- +// main — end-to-end +// --------------------------------------------------------------------------- + +describe('main', () => { + it('ingests a CSV file end-to-end and writes report.json', async () => { + successfulWorkflow({ chunks: 5, forceSplits: [] }); + const csvDir = tmpDir(); + const logDir = tmpDir(); + const filePath = writeCsv(csvDir, 'challenges.csv', VALID_CSV); + + await main(['--file', filePath], logDir); + + const runDirs = fs.readdirSync(logDir); + expect(runDirs).toHaveLength(1); + const reportPath = path.join(logDir, runDirs[0], 'report.json'); + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + expect(report.totals.records).toBe(2); + expect(report.totals.chunks).toBe(10); + expect(report.totals.errors).toBe(0); + }); + + it('drops the vector index on --clear-all after confirmation, before ingesting', async () => { + successfulWorkflow({ chunks: 1, forceSplits: [] }); + mocks.deleteIndex.mockResolvedValue(undefined); + const csvDir = tmpDir(); + const logDir = tmpDir(); + const filePath = writeCsv(csvDir, 'challenges.csv', VALID_CSV); + + await main(['--file', filePath, '--clear-all'], logDir); + + expect(mocks.deleteIndex).toHaveBeenCalledWith({ indexName: 'challenge_embeddings' }); + }); + + it('skips both deleteIndex and ingestion when the user declines the --clear-all confirmation', async () => { + mocks.readlineAnswer = 'n'; + const csvDir = tmpDir(); + const logDir = tmpDir(); + const filePath = writeCsv(csvDir, 'challenges.csv', VALID_CSV); + + await main(['--file', filePath, '--clear-all'], logDir); + + expect(mocks.deleteIndex).not.toHaveBeenCalled(); + expect(mocks.runStart).not.toHaveBeenCalled(); + // No log directory was ever created since main() returns before IngestionLogger.create() + expect(fs.existsSync(logDir) && fs.readdirSync(logDir).length).toBeFalsy(); + }); + + it('does not call deleteIndex on --clear-all when --dry-run is also set', async () => { + successfulWorkflow({ chunks: 1, forceSplits: [] }); + const csvDir = tmpDir(); + const logDir = tmpDir(); + const filePath = writeCsv(csvDir, 'challenges.csv', VALID_CSV); + + await main(['--file', filePath, '--clear-all', '--dry-run'], logDir); + + expect(mocks.deleteIndex).not.toHaveBeenCalled(); + }); +}); diff --git a/src/scripts/ingest-challenges.ts b/src/scripts/ingest-challenges.ts new file mode 100644 index 0000000..86bc2d2 --- /dev/null +++ b/src/scripts/ingest-challenges.ts @@ -0,0 +1,316 @@ +/** + * CSV backfill CLI (secondary path, per D11) — ingest-challenges. + * + * Streams challenge rows from CSV file(s) through the same `challenge-ingestion` + * workflow the API path uses (via mastra.getWorkflowById → createRun → run.start), + * so CLI and API cannot drift onto separate implementations. The PRIMARY bulk + * path is the `challenge-bulk-ingestion` workflow (paginated Challenge Search + * API, see sync-challenges.ts); this CLI remains for offline/air-gapped + * environments and for importing historical CSV exports that predate the + * search API. + * + * Usage: + * pnpm run ingest -- --file path/to/challenges.csv [--dry-run] + * pnpm run ingest -- --folder path/to/csvs [--dry-run] + * pnpm run ingest -- --clear-all --folder path/to/csvs + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as readline from 'node:readline'; +import { parseArgs } from 'node:util'; +import { parse } from 'csv-parse'; +import { mastra } from '../mastra'; +import { getChallengeVectorStore } from '../mastra/vector/challenge-vector-store'; +import { getRagConfig } from '../config/rag.config'; +import { validateColumns, validateRecord } from '../mastra/rag/ingestion-utils'; +import type { ChallengeRecord, IngestionReport, ReportedForceSplit } from '../mastra/rag/types'; +import { IngestionLogger } from './ingestion-logger'; + +const INGESTION_WORKFLOW_ID = 'challenge-ingestion'; + +// --------------------------------------------------------------------------- +// CLI options +// --------------------------------------------------------------------------- + +interface CliOptions { + folder?: string; + file?: string; + dryRun: boolean; + clearAll: boolean; +} + +function parseOptions(argv: string[]): CliOptions { + const { values } = parseArgs({ + args: argv, + options: { + folder: { type: 'string' }, + file: { type: 'string' }, + 'dry-run': { type: 'boolean', default: false }, + 'clear-all': { type: 'boolean', default: false }, + }, + }); + + if (values.file && values.folder) { + throw new Error('Cannot use both --file and --folder. Use one or the other.'); + } + + return { + folder: values.file ? undefined : (values.folder ?? 'data'), + file: values.file, + dryRun: Boolean(values['dry-run']), + clearAll: Boolean(values['clear-all']), + }; +} + +function getCsvFiles(options: CliOptions): string[] { + if (options.file) { + const filePath = path.resolve(options.file); + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + return [filePath]; + } + + const folderPath = path.resolve(options.folder!); + if (!fs.existsSync(folderPath)) { + throw new Error(`Folder not found: ${folderPath}`); + } + + const files = fs + .readdirSync(folderPath) + .filter((f) => f.endsWith('.csv')) + .map((f) => path.join(folderPath, f)); + + if (files.length === 0) { + throw new Error(`No CSV files found in: ${folderPath}`); + } + + return files; +} + +function confirmAction(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${question} (y/N) `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------- +// Per-file / per-record processing — shares the challenge-ingestion workflow +// with the API path via mastra.getWorkflowById().createRun().start() +// --------------------------------------------------------------------------- + +interface IngestionRunReport { + chunks: number; + forceSplits: ReportedForceSplit[]; +} + +/** + * Minimal structural shape of a Workflow/Run, kept loose (rather than + * importing the deeply generic Workflow/Run types) so this script's own + * inference isn't dragged through every OTHER registered workflow's input + * schema — same rationale as the NestedRunResult cast in + * challenge-bulk-ingestion-workflow.ts. + */ +interface WorkflowRun { + start: (args: { inputData: unknown }) => Promise<{ status: string; result?: unknown; error?: unknown }>; +} +interface WorkflowHandle { + createRun: () => Promise; +} + +async function runIngestionWorkflow( + record: ChallengeRecord, + dryRun: boolean, +): Promise { + const workflow = mastra.getWorkflowById(INGESTION_WORKFLOW_ID) as unknown as WorkflowHandle | undefined; + if (!workflow) { + throw new Error(`Workflow "${INGESTION_WORKFLOW_ID}" is not registered`); + } + + const run = await workflow.createRun(); + const runResult = await run.start({ inputData: { challenge: record, dryRun } }); + + if (!runResult || runResult.status !== 'success') { + const detail = + runResult && 'error' in runResult && runResult.error + ? errorMessage(runResult.error) + : `run ended with status "${runResult?.status ?? 'unknown'}"`; + throw new Error(detail); + } + + const report = runResult.result as unknown as IngestionRunReport; + return { + chunks: report?.chunks ?? 0, + forceSplits: report?.forceSplits ?? [], + }; +} + +async function processFile( + filePath: string, + logger: IngestionLogger, + report: IngestionReport, + dryRun: boolean, +): Promise { + const filename = path.basename(filePath); + const fileLogger = logger.child(filename); + fileLogger.log('Processing file...'); + + report.files[filename] = { records: 0, chunks: 0, errors: [], forceSplits: [] }; + const fileStats = report.files[filename]; + + const parser = fs.createReadStream(filePath).pipe(parse({ columns: true, skip_empty_lines: true })); + + let isFirstRecord = true; + let recordCount = 0; + + for await (const rawRecord of parser) { + if (isFirstRecord) { + isFirstRecord = false; + const missingColumns = validateColumns(Object.keys(rawRecord as Record)); + if (missingColumns.length > 0) { + const message = `Missing required columns: ${missingColumns.join(', ')}`; + fileLogger.error(message); + fileStats.errors.push({ recordId: filename, message }); + report.totals.errors++; + return; + } + } + + recordCount++; + const record = rawRecord as ChallengeRecord; + + const validationError = validateRecord(record); + if (validationError) { + const idRef = record.id || `#${recordCount}`; + fileLogger.child(idRef).error(validationError); + fileStats.errors.push({ recordId: idRef, message: validationError }); + report.totals.errors++; + continue; + } + + const recordLogger = fileLogger.child(`${record.id} (${record.name})`); + try { + const runReport = await runIngestionWorkflow(record, dryRun); + + fileStats.records++; + fileStats.chunks += runReport.chunks; + report.totals.records++; + report.totals.chunks += runReport.chunks; + for (const split of runReport.forceSplits) { + fileStats.forceSplits.push({ ...split, recordId: record.id }); + report.totals.forceSplits++; + } + + if (recordCount % 10 === 0) { + fileLogger.log(` Processed ${recordCount} records...`); + } + } catch (error) { + const message = errorMessage(error); + recordLogger.error(message, error instanceof Error ? error : undefined); + fileStats.errors.push({ + recordId: record.id, + message, + stack: error instanceof Error ? error.stack : undefined, + }); + report.totals.errors++; + } + } + + fileLogger.log(`Complete: ${fileStats.records} records, ${fileStats.chunks} chunks`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export async function main( + argv: string[] = process.argv.slice(2), + logBaseDir = 'logs', +): Promise { + const startDate = new Date(); + const report: IngestionReport = { + startTime: startDate.toISOString(), + endTime: '', + totals: { files: 0, records: 0, chunks: 0, errors: 0, forceSplits: 0 }, + files: {}, + }; + + const options = parseOptions(argv); + const csvFiles = getCsvFiles(options); + + if (options.clearAll) { + const confirmed = await confirmAction('This will delete ALL embeddings. Are you sure?'); + if (!confirmed) { + console.log('Aborted.'); + return; + } + } + + const logger = IngestionLogger.create(logBaseDir); + logger.log('Starting CSV ingestion...'); + logger.log(` Command: ingest ${argv.join(' ')}`); + logger.log(` Mode: ${options.dryRun ? 'DRY RUN' : 'LIVE'}`); + + const config = getRagConfig(); + logger.log( + ` Embedding: ${config.embedding.provider}/${config.embedding.modelId} (${config.embedding.dimension}D)`, + ); + logger.log(`Found ${csvFiles.length} CSV file(s)`); + report.totals.files = csvFiles.length; + + try { + if (options.clearAll && !options.dryRun) { + logger.log('Clearing ALL embeddings — dropping the vector index...'); + const store = getChallengeVectorStore(); + await store.deleteIndex({ indexName: config.vectorIndexName }); + logger.log('Index dropped. It will be recreated on the next ingestion run.'); + } + + for (const filePath of csvFiles) { + await processFile(filePath, logger, report, options.dryRun); + } + + const endDate = new Date(); + report.endTime = endDate.toISOString(); + const duration = ((endDate.getTime() - startDate.getTime()) / 1000).toFixed(2); + + logger.log(`Ingestion complete in ${duration}s!`); + logger.log(` Files: ${report.totals.files}`); + logger.log(` Records: ${report.totals.records}`); + logger.log(` Chunks: ${report.totals.chunks}`); + logger.log(` Errors: ${report.totals.errors}`); + if (report.totals.forceSplits > 0) { + logger.warn(`Force-splits: ${report.totals.forceSplits} (atomic chunks exceeded token limit)`); + } + logger.log(`Logs saved to: ${logger.getLogDir()}`); + logger.writeReport(report); + + if (report.totals.errors > 0) { + process.exitCode = 1; + } + } catch (error) { + logger.error('Fatal error during ingestion', error instanceof Error ? error : undefined); + process.exitCode = 1; + } finally { + await logger.close(); + } +} + +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +// --------------------------------------------------------------------------- +// Testing Exports +// --------------------------------------------------------------------------- + +export const _testing = { parseOptions, getCsvFiles, processFile, runIngestionWorkflow }; diff --git a/src/scripts/ingestion-logger.test.ts b/src/scripts/ingestion-logger.test.ts new file mode 100644 index 0000000..7cbc8ba --- /dev/null +++ b/src/scripts/ingestion-logger.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { IngestionLogger } from './ingestion-logger'; +import type { IngestionReport } from '../mastra/rag/types'; + +const tmpDirs: string[] = []; + +function tmpBaseDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ingestion-logger-test-')); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('IngestionLogger.create', () => { + it('creates a timestamped ingestion- directory under baseDir', async () => { + const base = tmpBaseDir(); + const logger = IngestionLogger.create(base); + + const logDir = logger.getLogDir(); + expect(logDir.startsWith(base)).toBe(true); + expect(path.basename(logDir)).toMatch(/^ingestion-/); + expect(fs.existsSync(logDir)).toBe(true); + + await logger.close(); + }); + + it('has created output.log but not error.log once the stream has flushed', async () => { + const base = tmpBaseDir(); + const logger = IngestionLogger.create(base); + const logDir = logger.getLogDir(); + + await logger.close(); + + expect(fs.existsSync(path.join(logDir, 'output.log'))).toBe(true); + expect(fs.existsSync(path.join(logDir, 'error.log'))).toBe(false); + }); +}); + +describe('IngestionLogger — child context prefixing', () => { + it('nests child contexts as [parent] [child]', async () => { + const base = tmpBaseDir(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = IngestionLogger.create(base); + const fileLogger = logger.child('challenges.csv'); + const recordLogger = fileLogger.child('challenge-1 (Test)'); + + recordLogger.log('processed'); + + const printedLine = logSpy.mock.calls.at(-1)?.[0] as string; + expect(printedLine).toContain('[challenges.csv] [challenge-1 (Test)] processed'); + + await logger.close(); + }); + + it('writes every log() call to output.log', async () => { + const base = tmpBaseDir(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = IngestionLogger.create(base); + + logger.log('starting'); + logger.child('file.csv').log('processing'); + await logger.close(); + + const contents = fs.readFileSync(path.join(logger.getLogDir(), 'output.log'), 'utf8'); + expect(contents).toContain('starting'); + expect(contents).toContain('[file.csv] processing'); + }); +}); + +describe('IngestionLogger — error handling', () => { + it('never creates error.log when error() is never called', async () => { + const base = tmpBaseDir(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = IngestionLogger.create(base); + + logger.log('no error yet'); + await logger.close(); + + expect(fs.existsSync(path.join(logger.getLogDir(), 'error.log'))).toBe(false); + }); + + it('lazily creates error.log once error() has been called', async () => { + const base = tmpBaseDir(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const logger = IngestionLogger.create(base); + + logger.error('something failed'); + await logger.close(); + + expect(fs.existsSync(path.join(logger.getLogDir(), 'error.log'))).toBe(true); + }); + + it('includes both message and error.message, plus the stack, in error.log', async () => { + const base = tmpBaseDir(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const logger = IngestionLogger.create(base); + + const err = new Error('boom'); + logger.error('operation failed', err); + await logger.close(); + + const errorContents = fs.readFileSync(path.join(logger.getLogDir(), 'error.log'), 'utf8'); + expect(errorContents).toContain('operation failed: boom'); + expect(errorContents).toContain(err.stack!.split('\n')[0]); + }); +}); + +describe('IngestionLogger.writeReport', () => { + it('writes report.json with the exact report contents', async () => { + const base = tmpBaseDir(); + const logger = IngestionLogger.create(base); + + const report: IngestionReport = { + startTime: '2026-08-24T00:00:00.000Z', + endTime: '2026-08-24T00:01:00.000Z', + totals: { files: 1, records: 2, chunks: 5, errors: 0, forceSplits: 0 }, + files: { + 'challenges.csv': { records: 2, chunks: 5, errors: [], forceSplits: [] }, + }, + }; + logger.writeReport(report); + await logger.close(); + + const written = JSON.parse(fs.readFileSync(path.join(logger.getLogDir(), 'report.json'), 'utf8')); + expect(written).toEqual(report); + }); +}); diff --git a/src/scripts/ingestion-logger.ts b/src/scripts/ingestion-logger.ts new file mode 100644 index 0000000..e8eda77 --- /dev/null +++ b/src/scripts/ingestion-logger.ts @@ -0,0 +1,102 @@ +/** + * Hierarchical file logger for the CSV backfill CLI. + * + * Ported from tc-challenges-vector-rag's src/lib/logger.ts. Preserves the + * dual output (console + file) and per-run log directory + * (logs/ingestion-/{output.log,error.log,report.json}), but drops + * `interceptConsole()` entirely (D6) — the server path never touches this + * module, and this CLI-only logger has no reason to rewrite `console` + * globally to get contextual prefixes; every call site logs through an + * explicit IngestionLogger instance instead. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { IngestionReport } from '../mastra/rag/types'; + +export class IngestionLogger { + // Shared streams across all logger instances created from the same root + private static logDir: string; + private static outputStream: fs.WriteStream; + private static errorStream: fs.WriteStream | null = null; + + // Context for log prefixing (e.g., "file.csv] [challengeId") + private readonly context: string; + + private constructor(context = '') { + this.context = context; + } + + /** Creates the root logger and initializes the shared per-run streams. */ + static create(baseDir: string): IngestionLogger { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + IngestionLogger.logDir = path.join(baseDir, `ingestion-${timestamp}`); + fs.mkdirSync(IngestionLogger.logDir, { recursive: true }); + IngestionLogger.outputStream = fs.createWriteStream(path.join(IngestionLogger.logDir, 'output.log')); + // Reset so the lazy errorStream getter creates a fresh stream under + // the new logDir, rather than reusing a prior run's (possibly closed) one. + IngestionLogger.errorStream = null; + return new IngestionLogger(); + } + + /** Creates a child logger with appended context (e.g. filename, then challenge id). */ + child(context: string): IngestionLogger { + const newContext = this.context ? `${this.context}] [${context}` : context; + return new IngestionLogger(newContext); + } + + /** Lazy-initialized error stream — created only when the first error is logged. */ + private get errorStream(): fs.WriteStream { + if (!IngestionLogger.errorStream) { + IngestionLogger.errorStream = fs.createWriteStream(path.join(IngestionLogger.logDir, 'error.log')); + } + return IngestionLogger.errorStream; + } + + private formatLine(message: string, level: 'log' | 'error' | 'warn' = 'log'): string { + const prefix = this.context ? `[${this.context}] ` : ''; + const marker = level === 'error' ? '[ERROR] ' : level === 'warn' ? '[WARN] ' : ''; + return `[${new Date().toISOString()}] ${marker}${prefix}${message}`; + } + + log(message: string): void { + const line = this.formatLine(message); + console.log(line); + IngestionLogger.outputStream.write(line + '\n'); + } + + warn(message: string): void { + const line = this.formatLine(message, 'warn'); + console.warn(line); + IngestionLogger.outputStream.write(line + '\n'); + } + + error(message: string, error?: Error): void { + const fullMessage = message ? (error ? `${message}: ${error.message}` : message) : error?.message || 'Unknown error'; + const line = this.formatLine(fullMessage, 'error'); + console.error(line); + IngestionLogger.outputStream.write(line + '\n'); + this.errorStream.write(line + '\n'); + if (error?.stack) { + this.errorStream.write(error.stack + '\n'); + } + } + + writeReport(report: IngestionReport): void { + fs.writeFileSync(path.join(IngestionLogger.logDir, 'report.json'), JSON.stringify(report, null, 2)); + } + + /** Ends the underlying streams and resolves once they have fully flushed to disk. */ + close(): Promise { + const streams = [IngestionLogger.outputStream, IngestionLogger.errorStream].filter( + (s): s is fs.WriteStream => Boolean(s), + ); + return Promise.all(streams.map((stream) => new Promise((resolve) => stream.end(resolve)))).then( + () => undefined, + ); + } + + getLogDir(): string { + return IngestionLogger.logDir; + } +} diff --git a/src/scripts/sync-challenges.test.ts b/src/scripts/sync-challenges.test.ts new file mode 100644 index 0000000..f6d79b7 --- /dev/null +++ b/src/scripts/sync-challenges.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + getWorkflowById: vi.fn(), + createRun: vi.fn(), + runStart: vi.fn(), +})); + +vi.mock('../mastra', () => ({ + mastra: { getWorkflowById: mocks.getWorkflowById }, +})); + +import { main, _testing } from './sync-challenges'; + +const { parseOptions } = _testing; + +function successfulWorkflow( + report: { processed: number; succeeded: number; failed: number; skipped: number; totalChunks: number } = { + processed: 3, + succeeded: 3, + failed: 0, + skipped: 0, + totalChunks: 12, + }, +) { + mocks.getWorkflowById.mockReturnValue({ createRun: mocks.createRun }); + mocks.createRun.mockResolvedValue({ start: mocks.runStart }); + mocks.runStart.mockResolvedValue({ status: 'success', result: report }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + process.exitCode = undefined; +}); + +// --------------------------------------------------------------------------- +// parseOptions +// --------------------------------------------------------------------------- + +describe('parseOptions', () => { + it('defaults to no filters and LIVE mode', () => { + expect(parseOptions([])).toEqual({ + projectId: undefined, + status: undefined, + types: undefined, + tracks: undefined, + updatedSince: undefined, + dryRun: false, + concurrency: undefined, + }); + }); + + it('parses --project-id, --updated-since and --dry-run', () => { + expect(parseOptions(['--project-id', '17423', '--updated-since', '2026-08-01', '--dry-run'])).toMatchObject({ + projectId: '17423', + updatedSince: '2026-08-01', + dryRun: true, + }); + }); + + it('parses repeated --status/--types/--tracks into arrays', () => { + const options = parseOptions(['--status', 'ACTIVE', '--status', 'COMPLETED', '--types', 'Challenge']); + expect(options.status).toEqual(['ACTIVE', 'COMPLETED']); + expect(options.types).toEqual(['Challenge']); + }); + + it('parses --concurrency as a number', () => { + expect(parseOptions(['--concurrency', '5'])).toMatchObject({ concurrency: 5 }); + }); +}); + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +describe('main', () => { + it('invokes challenge-bulk-ingestion with the parsed filters', async () => { + successfulWorkflow(); + + await main(['--project-id', '17423', '--status', 'ACTIVE', '--updated-since', '2026-08-01']); + + expect(mocks.getWorkflowById).toHaveBeenCalledWith('challenge-bulk-ingestion'); + expect(mocks.runStart).toHaveBeenCalledWith({ + inputData: { + projectId: '17423', + status: ['ACTIVE'], + types: undefined, + tracks: undefined, + updatedDateStart: '2026-08-01', + dryRun: false, + concurrency: undefined, + }, + }); + }); + + it('reports success and leaves exitCode unset when nothing failed', async () => { + successfulWorkflow({ processed: 5, succeeded: 5, failed: 0, skipped: 1, totalChunks: 20 }); + await main([]); + expect(process.exitCode).toBeUndefined(); + }); + + it('sets exitCode 1 when the bulk report has failures, without throwing', async () => { + successfulWorkflow({ processed: 5, succeeded: 3, failed: 2, skipped: 0, totalChunks: 10 }); + await expect(main([])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + }); + + it('rejects when the workflow is not registered', async () => { + mocks.getWorkflowById.mockReturnValue(undefined); + await expect(main([])).rejects.toThrow(/not registered/i); + }); + + it('sets exitCode 1 without throwing when the run does not succeed', async () => { + mocks.getWorkflowById.mockReturnValue({ createRun: mocks.createRun }); + mocks.createRun.mockResolvedValue({ start: mocks.runStart }); + mocks.runStart.mockResolvedValue({ status: 'failed', error: new Error('search API unreachable') }); + + await expect(main([])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/src/scripts/sync-challenges.ts b/src/scripts/sync-challenges.ts new file mode 100644 index 0000000..f97624f --- /dev/null +++ b/src/scripts/sync-challenges.ts @@ -0,0 +1,144 @@ +/** + * Incremental-sync / project-scoped backfill CLI (per D11) — sync-challenges. + * + * Thin CLI wrapper around the `challenge-bulk-ingestion` workflow's paginated + * Challenge Search API fan-out (via mastra.getWorkflowById → createRun → + * run.start), for operators who prefer a command line over calling the + * workflow's generated API route. This is the PRIMARY bulk-ingestion and + * incremental-sync surface — the CSV CLI (ingest-challenges.ts) is secondary. + * + * Usage: + * pnpm run sync -- --project-id 17423 [--dry-run] + * pnpm run sync -- --status ACTIVE --updated-since 2026-08-01 --concurrency 5 + */ + +import { parseArgs } from 'node:util'; +import { mastra } from '../mastra'; + +const BULK_WORKFLOW_ID = 'challenge-bulk-ingestion'; + +interface CliOptions { + projectId?: string; + status?: string[]; + types?: string[]; + tracks?: string[]; + updatedSince?: string; + dryRun: boolean; + concurrency?: number; +} + +function parseOptions(argv: string[]): CliOptions { + const { values } = parseArgs({ + args: argv, + options: { + 'project-id': { type: 'string' }, + status: { type: 'string', multiple: true }, + types: { type: 'string', multiple: true }, + tracks: { type: 'string', multiple: true }, + 'updated-since': { type: 'string' }, + 'dry-run': { type: 'boolean', default: false }, + concurrency: { type: 'string' }, + }, + }); + + return { + projectId: values['project-id'], + status: values.status, + types: values.types, + tracks: values.tracks, + updatedSince: values['updated-since'], + dryRun: Boolean(values['dry-run']), + concurrency: values.concurrency ? Number(values.concurrency) : undefined, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Minimal structural shape of a Workflow/Run, kept loose (rather than + * importing the deeply generic Workflow/Run types) so this script's own + * inference isn't dragged through every OTHER registered workflow's input + * schema — same rationale as the NestedRunResult cast in + * challenge-bulk-ingestion-workflow.ts. + */ +interface WorkflowRun { + start: (args: { inputData: unknown }) => Promise<{ status: string; result?: unknown; error?: unknown }>; +} +interface WorkflowHandle { + createRun: () => Promise; +} + +interface BulkIngestionReport { + processed: number; + succeeded: number; + failed: number; + skipped: number; + totalChunks: number; +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const options = parseOptions(argv); + + console.log('Starting challenge sync...'); + console.log(` projectId: ${options.projectId ?? 'any'}`); + console.log(` status: ${options.status?.join(',') || 'default (ACTIVE, COMPLETED)'}`); + console.log(` types: ${options.types?.join(',') || 'any'}`); + console.log(` tracks: ${options.tracks?.join(',') || 'any'}`); + console.log(` updatedDateStart: ${options.updatedSince ?? 'none (full sync)'}`); + console.log(` mode: ${options.dryRun ? 'DRY RUN' : 'LIVE'}`); + + const workflow = mastra.getWorkflowById(BULK_WORKFLOW_ID) as unknown as WorkflowHandle | undefined; + if (!workflow) { + throw new Error(`Workflow "${BULK_WORKFLOW_ID}" is not registered`); + } + + const run = await workflow.createRun(); + const runResult = await run.start({ + inputData: { + projectId: options.projectId, + status: options.status, + types: options.types, + tracks: options.tracks, + updatedDateStart: options.updatedSince, + dryRun: options.dryRun, + concurrency: options.concurrency, + }, + }); + + if (!runResult || runResult.status !== 'success') { + const detail = + runResult && 'error' in runResult && runResult.error + ? errorMessage(runResult.error) + : `run ended with status "${runResult?.status ?? 'unknown'}"`; + console.error(`Sync failed: ${detail}`); + process.exitCode = 1; + return; + } + + const report = runResult.result as unknown as BulkIngestionReport; + console.log('Sync complete!'); + console.log(` Processed: ${report.processed}`); + console.log(` Succeeded: ${report.succeeded}`); + console.log(` Failed: ${report.failed}`); + console.log(` Skipped (empty description): ${report.skipped}`); + console.log(` Chunks: ${report.totalChunks}`); + + if (report.failed > 0) { + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error('Fatal error during sync:', errorMessage(error)); + process.exitCode = 1; + }); +} + +// --------------------------------------------------------------------------- +// Testing Exports +// --------------------------------------------------------------------------- + +export const _testing = { parseOptions }; diff --git a/tests/fixtures/challenges-sample.csv b/tests/fixtures/challenges-sample.csv new file mode 100644 index 0000000..eda9ba1 --- /dev/null +++ b/tests/fixtures/challenges-sample.csv @@ -0,0 +1,9 @@ +"id","name","description","descriptionFormat","typeName","trackName","skills" +"9781b09b-3853-4ee0-a28f-7e56c3f7937d","Build a Realtime Energy Dashboard","## Overview +Build a dashboard that visualizes realtime energy usage data. + +### Requirements +- Ingest a streaming data source +- Render a live-updating chart +- Persist historical readings for trend analysis","markdown","Challenge","Development","React,TypeScript,Node.js" +"1941cf87-36a2-4a90-9c23-eeeb8de3fd5c","QA Automation Suite for Checkout Flow","

Build an automated test suite covering the checkout flow.

  • Cart validation
  • Payment gateway mocking
  • Order confirmation emails
","html","Task","Quality Assurance","Selenium,Cypress" From b6dfdd7fa69edc9eeac4e4dc1d3bbd79eea4b5ff Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 09:56:24 +0300 Subject: [PATCH 12/19] docs: document challenges vector RAG in README and .env.sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 of ADR 0001 — configuration surface and documentation, adapted from the source prototype's README. Adds a Challenges Vector RAG section (overview, ingestion, retrieval, chunking strategy, metadata schema, embedding-model/dimension table, database bootstrap) plus new rows under Environment Variables and API Surface. .env.sh and .env (both gitignored, untracked) were also given the same commented reference block locally. Dockerfile/.circleci need no change — the image build already runs lint, test, and build against this branch, and `pnpm run build` bundles turndown and @mastra/rag into .mastra/output cleanly with no bundler config change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MkcggGkcnz73GXUDR7mgrE --- .env.sample | 13 ++++++- README.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/.env.sample b/.env.sample index 0af9525..26de05d 100644 --- a/.env.sample +++ b/.env.sample @@ -18,4 +18,15 @@ M2M_AUTH_AUDIENCE="[M2M_AUTH_AUDIENCE]" WORKSPACE_PATH="[ABS PATH_TO_WORKSPACE_FOLDER]" WIPRO_API_KEY="[WIPRO_API_KEY]" LOG_LEVEL="[LOG_LEVEL]" -MASTRA_STUDIO_PATH="[ABS PATH_TO_MASTRA_STUDIO_OUTPUT_FOLDER]" \ No newline at end of file +MASTRA_STUDIO_PATH="[ABS PATH_TO_MASTRA_STUDIO_OUTPUT_FOLDER]" + +# ============== Challenges Vector RAG (all optional, defaults shown) ============== +RAG_EMBEDDING_PROVIDER="[TC-Ollama|AWSBedrock — default TC-Ollama]" +RAG_EMBEDDING_MODEL_ID="[nomic-embed-text|amazon.titan-embed-text-v2:0 — default nomic-embed-text]" +VECTOR_INDEX_NAME="[SQL identifier — default challenge_embeddings]" +VECTOR_SEARCH_THRESHOLD="[0-1 — default 0.5]" +RAG_CHUNK_MAX_SIZE="[characters — default 512]" +RAG_CHUNK_OVERLAP="[characters — default 50]" +RAG_TOP_K="[default 10]" +CHALLENGE_SEARCH_AI_PROVIDER="[TC-Ollama|WiproAI|AWSBedrock|OpenAI — default AWSBedrock]" +CHALLENGE_SEARCH_AI_MODEL_ID="[default us.anthropic.claude-haiku-4-5]" \ No newline at end of file diff --git a/README.md b/README.md index 3dfe5c2..8bda445 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ 11. [Tools](#tools) 12. [Scorers (Evaluation)](#scorers-evaluation) 13. [Workflows — Skill Extraction](#workflows--skill-extraction) -14. [Sequence Diagrams](#sequence-diagrams) -15. [External API Interactions](#external-api-interactions) -16. [CI/CD Pipeline](#cicd-pipeline) -17. [Deployment](#deployment) +14. [Challenges Vector RAG](#challenges-vector-rag) +15. [Sequence Diagrams](#sequence-diagrams) +16. [External API Interactions](#external-api-interactions) +17. [CI/CD Pipeline](#cicd-pipeline) +18. [Deployment](#deployment) --- @@ -118,6 +119,15 @@ tc-ai-api/ | `SKILL_MATCHING_CONCURRENCY` | No | `5` | Concurrency limit for parallel skill-matching requests | | `SKILL_MATCHING_SEMANTIC_THRESHOLD` | No | `0.45` | Max cosine distance for semantic matches (lower = stricter) | | `SKILL_DISCOVERY_EVAL_SAMPLE_RATE` | No | - | Fraction of agent interactions sampled for evaluation scoring | +| `RAG_EMBEDDING_PROVIDER` | No | `TC-Ollama` | Embedding provider for challenge RAG (`TC-Ollama` \| `AWSBedrock`) | +| `RAG_EMBEDDING_MODEL_ID` | No | `nomic-embed-text` | Embedding model id (768d locally; `amazon.titan-embed-text-v2:0`, 1024d, in prod) | +| `VECTOR_INDEX_NAME` | No | `challenge_embeddings` | Vector table name (SQL-identifier validated) — override per environment when reindexing | +| `VECTOR_SEARCH_THRESHOLD` | No | `0.5` | Minimum similarity score, applied after retrieval | +| `RAG_CHUNK_MAX_SIZE` | No | `512` | Max characters per chunk before recursive splitting | +| `RAG_CHUNK_OVERLAP` | No | `50` | Character overlap between recursively-split chunks | +| `RAG_TOP_K` | No | `10` | Default result count for challenge vector search | +| `CHALLENGE_SEARCH_AI_PROVIDER` | No | `AWSBedrock` | Model provider for `challenge-search-agent` | +| `CHALLENGE_SEARCH_AI_MODEL_ID` | No | `us.anthropic.claude-haiku-4-5` | Model id for `challenge-search-agent` | > \* Auth0 variables are required unless `DISABLE_AUTH=true`. @@ -352,6 +362,93 @@ Runs two branches **in parallel**: --- +## Challenges Vector RAG + +Retrieval-Augmented Generation over Topcoder challenge descriptions: an ingestion pipeline that chunks and embeds challenge text into `pgvector`, and a retrieval surface exposed both as an LLM-backed agent and as a deterministic, LLM-free workflow. Ported from the `tc-challenges-vector-rag` prototype (see [ADR 0001](docs/adr/0001-integrate-challenges-vector-rag.md) for the full design record and rationale — decisions below are cited as `D#`). + +### Overview + +| Concern | Summary | +| --- | --- | +| **Vector store** | `PgVector` (`@mastra/pg`), same `MASTRA_DB_CONNECTION` database and `MASTRA_DB_SCHEMA` as the rest of Mastra (D3) — no new infrastructure. | +| **Embeddings** | Provider-switched: Ollama `nomic-embed-text` (768d) locally, AWS Bedrock `amazon.titan-embed-text-v2:0` (1024d) in production (D2). | +| **Ingestion source** | Primary: the Challenge Search API (`GET /v6/challenges`), via `searchChallengesTool` and the `challenge-bulk-ingestion` workflow (D11). Secondary: CSV backfill CLI, for offline/air-gapped environments. | +| **Retrieval** | Two paths sharing one tool (`challengeVectorQueryTool`) so they cannot diverge: `challenge-search-agent` (synthesised NL answers) and `challenge-search` workflow (raw ranked results, no LLM) (D8). | +| **Project scoping** | Vector metadata carries only an opaque `projectId` reference — no project attributes are denormalized in. Callers resolve project detail via a separate `GET /v6/projects/:projectId` call (D10). | + +### Ingestion + +**`challenge-ingestion`** (workflow) — ingests one challenge, by `challengeId` (fetched via `fetchChallengeTool`) or an inline record: + +1. `resolve-challenge` — fetch or validate the inline record; `projectId` is carried through as a string reference only, never dereferenced (D10). +2. `chunk-and-embed` — `processDescription` (line-ending normalization, BOM-aware trim, HTML→Markdown, frontmatter strip) → `chunkChallengeDescription` (two-pass chunking, see below) → `enrichChunksWithChallengeName` → `embedMany` via the embedding-provider factory, wrapped in retry-with-backoff. +3. `upsert-vectors` — `ensureChallengeIndex()` (idempotent create/dimension-guard, D7), then `upsert({ deleteFilter: { challengeId } })` — delete-then-insert as one transaction, so a challenge is never left partially indexed. + +Only the challenge's **public** `description` is ever embedded — `privateDescription` is never read by the ingestion path. + +**`challenge-bulk-ingestion`** (workflow, D11) — paginates `searchChallengesTool` (filterable by `projectId`/`projectIds`, `status`, `types`, `tracks`, `tags`, `groups`, `updatedDateStart` for incremental sync) and fans out to `challenge-ingestion` per challenge with bounded concurrency (default 3). One bad challenge cannot abort the run — failures are captured per-challenge in the aggregated report. + +**CLI (secondary path, D11):** + +```bash +# CSV backfill — offline/air-gapped environments, or CSV exports that predate the search API +pnpm run ingest -- --file path/to/challenges.csv [--dry-run] +pnpm run ingest -- --folder path/to/csvs +pnpm run ingest -- --clear-all --folder path/to/csvs # confirms, then drops the vector index + +# Incremental sync / project-scoped backfill — thin wrapper around challenge-bulk-ingestion +pnpm run sync -- --project-id 17423 [--dry-run] +pnpm run sync -- --status ACTIVE --updated-since 2026-08-01 --concurrency 5 +``` + +Both CLIs invoke the same workflows the API exposes (via `mastra.getWorkflowById(...).createRun().start(...)`), so the CLI and API paths cannot drift onto separate implementations. `ingest-challenges.ts` writes per-run logs to `logs/ingestion-/{output.log,error.log,report.json}` (git-ignored). + +### Retrieval + +- **`challengeVectorQueryTool`** — the shared retrieval primitive. Composes an `$and` metadata filter from `skills` (`$in`), `type`/`track` (`$eq`, free-form strings per D12 — not enums), `groups` (`$in`), and `projectId` (`$in`, D10). `query` is optional: with at least one filter and no query text, it performs a metadata-only lookup (`query({ filter })`, no `queryVector`) — e.g. "everything indexed for project 17423". The relevance threshold (`VECTOR_SEARCH_THRESHOLD`) is applied **after** retrieval in application code rather than passed to `query({ minScore })`, because passing `minScore` forces `@mastra/pg` off the HNSW ANN fast path onto a full exact scan. +- **`challenge-search-agent`** ("Topcoder Challenge Assistant") — infers `skills`/`type`/`track`/`groups` filters from natural language and calls the tool. Never infers `projectId` from the query text — that must come from the caller's context, and **scope filters must be enforced server-side**, not left to the model (see the ADR's security note). +- **`challenge-search`** (workflow, D8) — the deterministic, LLM-free path: same tool, same filter composition, so results cannot diverge from the agent path. Input adds `groupBy` (`chunk` | `challenge` | `project`, default `challenge`): `chunk` returns raw hits ungrouped; `challenge` groups hits by `challengeId` (best chunk score becomes the challenge score, contributing chunks listed underneath); `project` rolls the same hits up by `projectId`. +- **`fetchProjectTool`** (optional, D10) — retrieval-time enrichment: resolves a `projectId` from a hit to project name/status/tech stack via `GET /v6/projects/:projectId`, under the caller's own authorization. Not used by, and nothing in, the ingestion or retrieval path depends on it. + +### Chunking strategy + +Two-pass, mirroring the source prototype: + +1. **Markdown-header pass** — splits on `#`/`##` headers, keeping code blocks and tables as atomic units. +2. **Size-based pass** — chunks over `RAG_CHUNK_MAX_SIZE` (default 512 chars) are recursively split (`RAG_CHUNK_OVERLAP`, default 50). Atomic blocks (code/tables) are kept intact if they fit the embedding model's context window; if an atomic block still exceeds it, it is force-split and reported (`forceSplits` in every ingestion report) — a rare safety fallback, not expected in real Topcoder data. + +### Metadata schema + +Every row is one challenge chunk. There is no separate text column — the chunk text lives in `metadata.text` (named `text`, not the prototype's `content`, to match `@mastra/rag`'s convention — see ADR 0001 for the full rationale): + +| Field | Type | Notes | +| --- | --- | --- | +| `challengeId` | `string` | The `deleteFilter` key for per-challenge replacement. | +| `name` | `string` | Challenge title. | +| `type` | `string` | Free-form (`ChallengeType` reference table, D12) — not an enum. | +| `track` | `string` | Free-form (`ChallengeTrack.name`, D12) — not an enum. | +| `skills` | `string[]` | Filterable via `$in`. | +| `groups` | `string[]` | Filterable via `$in`. | +| `projectId` | `string \| null` | The **only** project field (D10). Stored as a string — `@mastra/pg` compares metadata scalars as text. | +| `chunkIndex` / `totalChunks` | `number` | 1-based position / total chunk count, for reassembly. | +| `text` | `string` | The chunk text, prefixed with `# Challenge: `. | +| `ingestedAt` | `string` | ISO-8601. Compensates for Mastra's table having no `created_at` column. | + +### Embedding models + +| Provider | Model | Dimension | Context window | Use | +| --- | --- | --- | --- | --- | +| `TC-Ollama` | `nomic-embed-text` | 768 | 2048 | Local development default | +| `AWSBedrock` | `amazon.titan-embed-text-v2:0` | 1024 | 8192 | Production default | + +Switching a given environment's embedding provider requires a full reindex — `VECTOR_INDEX_NAME` is environment-overridable, and `ensureChallengeIndex()` throws an actionable error if the configured model's dimension doesn't match an existing index (D7). + +### Database bootstrap + +Per D3, there is **no hand-maintained DDL script** — `PgVector.createIndex()` performs all schema/table/index creation on first use (schema, table, HNSW vector index, and btree `metadataIndexes` on `challengeId`/`projectId`/`track`). This assumes the `vector` extension is enabled and the runtime database role holds DDL privileges. Local development uses the same `docker/docker-compose.yml` (`pgvector/pgvector:pg16`) as the rest of the project — no separate `init-db` step. + +--- + ## Sequence Diagrams ### Skill Extraction Workflow — End-to-End @@ -597,6 +694,11 @@ Mastra automatically exposes the following REST endpoints: | `/api/workflows/skill-extraction-workflow/:runId` | `GET` | Get workflow run status/result | | `/api/agents/skillsMatchingAgent/generate` | `POST` | Direct agent text generation | | `/api/agents/skillsMatchingAgent/stream` | `POST` | Direct agent streaming generation | +| `/api/workflows/challenge-ingestion/start` | `POST` | Ingest one challenge by id or inline record | +| `/api/workflows/challenge-bulk-ingestion/start` | `POST` | Paginated bulk / incremental-sync ingestion | +| `/api/workflows/challenge-search/start` | `POST` | Deterministic ranked challenge search (no LLM) | +| `/api/agents/challengeSearchAgent/generate` | `POST` | Synthesised NL challenge search | +| `/api/agents/challengeSearchAgent/stream` | `POST` | Synthesised NL challenge search (streaming) | | `/studio/*` | `GET` | Mastra Studio UI (development/debugging) | All `/api/*` endpoints are protected by Auth0 authentication (unless `DISABLE_AUTH=true`) and scoped by the resource ID middleware. From 7fb91b2da411556260f9f4a138208102c49d3a83 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 10:43:45 +0300 Subject: [PATCH 13/19] clean up wipro provider --- package.json | 17 +- pnpm-lock.yaml | 878 +++++++-------------------- src/mastra/index.ts | 2 +- src/utils/index.ts | 1 - src/utils/providers/model-factory.ts | 4 - src/utils/providers/wipro.ts | 22 - 6 files changed, 218 insertions(+), 706 deletions(-) delete mode 100644 src/utils/providers/wipro.ts diff --git a/package.json b/package.json index 1b1ba0d..1c6cc3c 100644 --- a/package.json +++ b/package.json @@ -30,18 +30,17 @@ "@ai-sdk/amazon-bedrock": "^4.0.121", "@ai-sdk/openai": "^3.0.74", "@aws-sdk/credential-providers": "^3.1075.0", - "@mastra/auth-auth0": "^1.2.1", - "@mastra/core": "^1.57.0", - "@mastra/evals": "^1.7.0", - "@mastra/libsql": "^1.19.0", + "@mastra/auth-auth0": "^1.2.2", + "@mastra/core": "^1.61.0", + "@mastra/evals": "^1.9.0", + "@mastra/libsql": "^1.21.1", "@mastra/loggers": "^1.2.0", - "@mastra/memory": "^1.26.0", - "@mastra/observability": "^1.16.5", - "@mastra/pg": "^1.19.0", + "@mastra/memory": "^1.27.0", + "@mastra/observability": "^1.17.1", + "@mastra/pg": "^1.21.1", "@mastra/rag": "^2.6.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.221.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.221.0", - "@topcoder/wipro-ai-sdk-provider": "git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git", "ai": "^6.0.209", "ai-sdk-ollama": "^3.8.8", "csv-parse": "^7.0.2", @@ -55,7 +54,7 @@ "@types/node": "^26.0.1", "@types/turndown": "^5.0.6", "eslint": "^10.5.0", - "mastra": "^1.23.0", + "mastra": "^1.26.0", "prettier": "^3.8.4", "tsx": "^4.23.12", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6857b..3f6de3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,41 +18,38 @@ importers: specifier: ^3.1075.0 version: 3.1075.0 '@mastra/auth-auth0': - specifier: ^1.2.1 - version: 1.2.1 + specifier: ^1.2.2 + version: 1.2.2 '@mastra/core': - specifier: ^1.57.0 - version: 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + specifier: ^1.61.0 + version: 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) '@mastra/evals': - specifier: ^1.7.0 - version: 1.7.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.9.0 + version: 1.9.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/libsql': - specifier: ^1.19.0 - version: 1.19.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.21.1 + version: 1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/loggers': specifier: ^1.2.0 - version: 1.2.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + version: 1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/memory': - specifier: ^1.26.0 - version: 1.26.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.27.0 + version: 1.27.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/observability': - specifier: ^1.16.5 - version: 1.16.5(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + specifier: ^1.17.1 + version: 1.17.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@mastra/pg': - specifier: ^1.19.0 - version: 1.19.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + specifier: ^1.21.1 + version: 1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) '@mastra/rag': specifier: ^2.6.0 - version: 2.6.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + version: 2.6.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1) - '@topcoder/wipro-ai-sdk-provider': - specifier: git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git - version: git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git#92233a2b00d9dd46900437b54c406231ef24d7b2(zod@4.4.3) ai: specifier: ^6.0.209 version: 6.0.209(zod@4.4.3) @@ -88,8 +85,8 @@ importers: specifier: ^10.5.0 version: 10.5.0 mastra: - specifier: ^1.23.0 - version: 1.23.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) + specifier: ^1.26.0 + version: 1.26.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) prettier: specifier: ^3.8.4 version: 3.8.4 @@ -123,6 +120,21 @@ packages: express: optional: true + '@a2a-js/sdk@1.0.1': + resolution: {integrity: sha512-CJQdh3Wzwo8qIx5UUkSJ7+7BEI16PB+MXMHHNSmx8JQsQed2HlQgvx1ENOiKUfYA3PlcEvxIwv14dBblhDuPmw==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.10.2 + '@grpc/grpc-js': ^1.11.0 + express: ^4.21.2 || ^5.1.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + '@grpc/grpc-js': + optional: true + express: + optional: true + '@ai-sdk/amazon-bedrock@4.0.121': resolution: {integrity: sha512-osyum0QGdIHvd/Z9dQHhouXQnd9NX0cUSX5De5MgHpn5oER61/qkJ8MUD5I+PaWeTYUYOAPrY1QHkbwqSNWa9Q==} engines: {node: '>=18'} @@ -141,12 +153,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai-compatible@2.0.51': - resolution: {integrity: sha512-A6qfyaVs4lxmRxRux6U3ViOa8mMbsSd0OaHghpei2MpiBT6791J4zFH5MN7kaW1tLfQ246rSC2DVTMOavsycjQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.74': resolution: {integrity: sha512-LPDBWd2WCv0GQs29K2pHcNrGx24hm4D8QEP386HwUAUPr1URho6bNVXHNmIv0FxaW+xDkLpNMTen+mFCUBp2LA==} engines: {node: '>=18'} @@ -820,30 +826,30 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} - '@mastra/auth-auth0@1.2.1': - resolution: {integrity: sha512-2FvKRyMbzY9mOw8vkF4iRMS60lcsycGsMo4ZRzfaAjFxax/kaZJBJLZmo7DedKaW7dr/2FpxpW+KAk2qzu9w7Q==} + '@mastra/auth-auth0@1.2.2': + resolution: {integrity: sha512-Oggr7zNOZ64F1empufZLy7nzDCLo53XT/41ZatEmwPJpMs3Lv9wdq/IScMharTMnLKFPCpPVAlKOc6y1I3MXxg==} engines: {node: '>=22.13.0'} - '@mastra/core@1.57.0': - resolution: {integrity: sha512-2ud56Ow5wwyAFegxXkkOHcQfCG0W9Sz1ex2qVf3y/704zwwYZl/RBZXZV/7277RIxWFk8Bnw8pmGtGQ4tZVWEg==} + '@mastra/core@1.61.0': + resolution: {integrity: sha512-HaarHi6tn8mgm9/wtD/H51rJ8YbiQFTZMbFP3zUfcpL6/v5uZcIa9KzNH6pyKVjMB3cWZU+2Np3yCei1LKf+aQ==} engines: {node: '>=22.13.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@mastra/deployer@1.57.0': - resolution: {integrity: sha512-slFPv72TQhDH5eJgh+BslSOs4ViHC+31leMtfxpFPnRV9/ZTWL3gs7XWJfI5DQ0LqIGRNiICA42fyq34eHlX2A==} + '@mastra/deployer@1.61.0': + resolution: {integrity: sha512-xUTPHvb48jwCKLGucGWNUJp2EU1k1UhvqIFb8DBh2irKukqpPickIytc/1VHvV4PzIgJ9L7YoL+g/7OZN5xZWg==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.50.0-0 <2.0.0-0' - '@mastra/evals@1.7.0': - resolution: {integrity: sha512-K+UXN0h+Z9RfTyD1rCZJP2WbLjfti/3S2DT32BLUwsNaBZmEODEeNByyFA5bfW3XrsoMcKMEl1TOIKfVt6jFbg==} + '@mastra/evals@1.9.0': + resolution: {integrity: sha512-brabLN4sEwFzyH6MvqPHrhPQ6Pn7ITBMVlF4oMn/loJxr0BZ8W+8ea0WwtWvYQ6BM1awYuPS1iTsIe/slifWcg==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.0.0-0 <2.0.0-0' - '@mastra/libsql@1.19.0': - resolution: {integrity: sha512-BbxEMhHHhXJSL5Argwre3ybJRgwxGQu6N2fj4nTFEmDQ4wj+CjaeEAuen2wD6wLA58PB4BtMMCcHDm1GoeME7Q==} + '@mastra/libsql@1.21.1': + resolution: {integrity: sha512-QgS9c5XIFr/xoH1LsnH5WsgjZBt+jBDzE01tx6a1r2nNaj5Cb46aNbc+zOWx2NNASmQ7OhaGdWZVIJHHQXfauw==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.51.0-0 <2.0.0-0' @@ -854,21 +860,21 @@ packages: peerDependencies: '@mastra/core': '>=1.0.0-0 <2.0.0-0' - '@mastra/memory@1.26.0': - resolution: {integrity: sha512-6Hwbpzh5RYcG+vk8WYxEJzChQADpDT4k/esTD56i+azIN59mWCCnVpEEc/dg5iK0Oxd8lgmB4WSApoadDfgSFw==} + '@mastra/memory@1.27.0': + resolution: {integrity: sha512-/So8OB4gh5DIdRZ/0EZz7M6lHB3TVlbPIbO8c3PrVygg2IFiHbfckUmA/im6qq2pSaB0ScfrouEHOkRsfxdwyQ==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.4.1-0 <2.0.0-0' - '@mastra/observability@1.16.5': - resolution: {integrity: sha512-aADhcVMrvqebmGpVy4CCeIxz18BE3TBYDpDj11ymiy+PuC7RR2fN87NyuNA7AeYN1P3JV1KuDhnLpLSypjeINw==} + '@mastra/observability@1.17.1': + resolution: {integrity: sha512-Fk6tBZZMN56HFgudz+tCSH7XZkLXaHRENOybqsc5YwH3bTbmZpqoICumpoHGM3MI/0S8YYaEXdWiYKQ9MH+4kw==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.16.0-0 <2.0.0-0' zod: ^3.25.0 || ^4.0.0 - '@mastra/pg@1.19.0': - resolution: {integrity: sha512-tQyHBNQAAHw6I9gX2vkGaj5w+VhnPwpFvWm0x4EwaBPRLdx8/fuRJ3busRN+VyKAQ626fZxRj7mnC9AWIyJFiQ==} + '@mastra/pg@1.21.1': + resolution: {integrity: sha512-HpMojerOULXj7aDCK1Xa/QUDgcNiq+XqYFlsjsWy3xun2vi9gcOYGMrgKaHBlXD4G86g6vO+w5aMgdJ6rNOChw==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.53.0-0 <2.0.0-0' @@ -880,14 +886,14 @@ packages: '@mastra/core': '>=1.0.0-0 <2.0.0-0' zod: ^3.25.0 || ^4.0.0 - '@mastra/schema-compat@1.3.5': - resolution: {integrity: sha512-9CdBZZ2Fb8q6Ms4r6hs0msH8MTmVn99Zrc+i8BnNNuqKlIrywIoH3BghmPr5VsvkBUx7u/GjdYAsJ6i9nPcZZA==} + '@mastra/schema-compat@1.3.7': + resolution: {integrity: sha512-06WfY+j9rulYnDp8CM7pL7JIyrntkMolqmyyb1VJNEzlYTQCIcJcY0SwQiubZv2tztmoYGikszDiRVyXp0E9gA==} engines: {node: '>=22.13.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@mastra/server@1.57.0': - resolution: {integrity: sha512-+n25jqZClDrX5QXSZ8dWrCV0KzRDJhY0Rd6500N9O+XhxJ7aTZzoqyzegukW3NaNZfhsB6RuPmMYN7rbzXZQFg==} + '@mastra/server@1.61.0': + resolution: {integrity: sha512-0/St5fZ82Z/OZqFu3bdynfn6uNOUEQ2/TKGmtksMmILFTDZPpobpsI9m63qbMUwHAvBQm0omwWsysgymbC1kgQ==} engines: {node: '>=22.13.0'} peerDependencies: '@mastra/core': '>=1.50.0-0 <2.0.0-0' @@ -896,15 +902,13 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} @@ -1009,11 +1013,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@posthog/core@1.37.3': - resolution: {integrity: sha512-Dvw4CTlRVH4K5ag/B8YIHgG4E27w43MCUsXpn1iKQHIggIMN3Shq9whG4Omm3OvXPF+VikBTmpyMKUjckPxw+g==} + '@posthog/core@1.48.8': + resolution: {integrity: sha512-LAOBOjMQrQmgcbZxnubl74l2GQd5OWqxCJJ8mzcWdZRWbfO6Y/A6E3fqWjDtm68hY4LxC/bbopG60cxZW8cfWw==} - '@posthog/types@1.391.1': - resolution: {integrity: sha512-ASwd7Nf4pViqdYRYaNRyPYRVKWa1CcHUAUWR0XeQJLGdNnsWACBwe0sSieb/cHnKsRXjRwO/23KIY83lm/Ccpw==} + '@posthog/types@1.405.1': + resolution: {integrity: sha512-JvaR4ChUKUk7qSTG58vKN2Br6es9riFF5mvlu7YGcwbB476vfyTO0o/TBh+zE88QhsfxnE5Tr/jKxI+e1v3Q5A==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1134,15 +1138,6 @@ packages: '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - '@rollup/plugin-alias@6.0.0': - resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} - engines: {node: '>=20.19.0'} - peerDependencies: - rollup: '>=4.0.0' - peerDependenciesMeta: - rollup: - optional: true - '@rollup/plugin-commonjs@29.0.2': resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -1412,12 +1407,6 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} - '@topcoder/wipro-ai-sdk-provider@git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git#92233a2b00d9dd46900437b54c406231ef24d7b2': - resolution: {commit: 92233a2b00d9dd46900437b54c406231ef24d7b2, repo: https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git, type: git} - version: 0.0.1 - peerDependencies: - zod: ^3.22.4 - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1604,9 +1593,6 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} - '@zeit/schemas@2.36.0': - resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1645,52 +1631,24 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - arch@2.2.0: - resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} - archiver@8.0.0: resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} engines: {node: '>=18'} - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1821,10 +1779,6 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - boxen@7.0.0: - resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} - engines: {node: '>=14.16'} - brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -1872,10 +1826,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - camelcase@7.0.1: - resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} - engines: {node: '>=14.16'} - caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -1889,18 +1839,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk-template@0.4.0: - resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} - engines: {node: '>=12'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.0.1: - resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -1919,14 +1857,6 @@ packages: zod: optional: true - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - - clipboardy@3.0.0: - resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1959,14 +1889,6 @@ packages: resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} engines: {node: '>=18'} - compressible@2.0.18: - resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} - engines: {node: '>= 0.6'} - - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} - engines: {node: '>= 0.8.0'} - compromise@14.15.1: resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} engines: {node: '>=12.0.0'} @@ -2017,10 +1939,6 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2084,10 +2002,6 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2134,9 +2048,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} @@ -2156,9 +2067,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -2284,14 +2192,6 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -2300,12 +2200,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - express-unless@2.1.3: resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} @@ -2474,10 +2368,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -2524,10 +2414,6 @@ packages: engines: {node: '>=6'} deprecated: this library is no longer supported - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2566,10 +2452,6 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -2596,11 +2478,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - image-size@1.2.1: - resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} - engines: {node: '>=16.x'} - hasBin: true - import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -2615,13 +2492,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2630,11 +2500,6 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -2666,20 +2531,12 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-port-reachable@4.0.0: - resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -2691,10 +2548,6 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -2748,9 +2601,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -2953,8 +2803,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - mastra@1.23.0: - resolution: {integrity: sha512-GBR76V3DIEWfZ5YcqWwfdrCsGrhABxUk8LHA2EZmgBLU3rBo8G5/d/gSLKcXkqGnoWI3eY7WVh7079PO32J/Ug==} + mastra@1.26.0: + resolution: {integrity: sha512-0HEzz0v5akGHAXqJinj86PZDU3ixLIg7ex5ta3DAAo2PkpHFcAgqxluz6vKpy4nOQPPR+u2DNQOAXl0LD5zsIA==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: @@ -3005,9 +2855,6 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3127,10 +2974,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3181,10 +3024,6 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true - negotiator@0.6.4: - resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3214,10 +3053,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -3225,10 +3060,6 @@ packages: oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3248,17 +3079,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - openapi-fetch@0.17.0: resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} @@ -3386,10 +3209,6 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -3416,8 +3235,8 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - posthog-node@5.38.5: - resolution: {integrity: sha512-ZqMK2MFXQWkfdc0PDzPMRBrZXHCZLvl9jbvaaIyXTgbfn802/hMJ8M24BJiChPrMpZejsTzfAq8ZsQYF8zcOoA==} + posthog-node@5.50.0: + resolution: {integrity: sha512-7trcTN4EcPBiYAI1nnD1rk+VB+OcjgN0Mdnp7kOK09PTCSW0LCAUVAYj3FD7u8Ne5zYCnW/ESv6k3VU7sOsL5w==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -3493,9 +3312,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - queue@6.0.2: - resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -3515,10 +3331,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -3540,13 +3352,6 @@ packages: reconnect-core@1.3.0: resolution: {integrity: sha512-+gLKwmyRf2tjl6bLR03DoeWELzyN6LW9Xgr3vh7NXHHwPi0JC0N2TwPyf90oUEBkCRcD+bgQ+s3HORoG3nwHDg==} - registry-auth-token@3.3.2: - resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} - - registry-url@3.1.0: - resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} - engines: {node: '>=0.10.0'} - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3676,11 +3481,6 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - serve@14.2.6: - resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} - engines: {node: '>= 14'} - hasBin: true - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3692,10 +3492,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3715,9 +3511,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3768,10 +3561,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3782,26 +3571,14 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3809,10 +3586,6 @@ packages: suffix-thumb@5.0.2: resolution: {integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -3898,10 +3671,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@2.19.0: - resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} - engines: {node: '>=12.20'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -3969,9 +3738,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-check@1.5.4: - resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4108,10 +3874,6 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@4.0.1: - resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} - engines: {node: '>=12'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4120,10 +3882,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4185,20 +3943,9 @@ packages: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} - zod-from-json-schema@0.0.5: - resolution: {integrity: sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ==} - zod-from-json-schema@0.5.3: resolution: {integrity: sha512-44YFiuq+WHw9YZQAo/Ad0F7o9c/im0Q6cnHI23BsXhEmZtkNn4cD0bljLMMjkfb/EidopPWdsmKI8EvLHX5ZyA==} - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4214,6 +3961,14 @@ snapshots: '@grpc/grpc-js': 1.14.4 express: 5.2.1 + '@a2a-js/sdk@1.0.1(@grpc/grpc-js@1.14.4)(express@5.2.1)': + dependencies: + jose: 6.2.3 + uuid: 11.1.1 + optionalDependencies: + '@grpc/grpc-js': 1.14.4 + express: 5.2.1 + '@ai-sdk/amazon-bedrock@4.0.121(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 3.0.86(zod@4.4.3) @@ -4238,12 +3993,6 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/openai-compatible@2.0.51(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) - zod: 4.4.3 - '@ai-sdk/openai@3.0.74(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -5089,13 +4838,14 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@mastra/auth-auth0@1.2.1': + '@mastra/auth-auth0@1.2.2': dependencies: jose: 6.2.3 - '@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)': + '@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)': dependencies: - '@a2a-js/sdk': 0.3.14(@grpc/grpc-js@1.14.4)(express@5.2.1) + '@a2a-js/sdk-v0_3': '@a2a-js/sdk@0.3.14(@grpc/grpc-js@1.14.4)(express@5.2.1)' + '@a2a-js/sdk-v1': '@a2a-js/sdk@1.0.1(@grpc/grpc-js@1.14.4)(express@5.2.1)' '@ai-sdk/provider-utils-v5': '@ai-sdk/provider-utils@3.0.30(zod@4.4.3)' '@ai-sdk/provider-utils-v6': '@ai-sdk/provider-utils@4.0.40(zod@4.4.3)' '@ai-sdk/provider-utils-v7': '@ai-sdk/provider-utils@5.0.13(zod@4.4.3)' @@ -5104,8 +4854,8 @@ snapshots: '@ai-sdk/provider-v7': '@ai-sdk/provider@4.0.4' '@isaacs/ttlcache': 2.1.5 '@lukeed/uuid': 2.0.1 - '@mastra/schema-compat': 1.3.5(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@mastra/schema-compat': 1.3.7(zod@4.4.3) + '@modelcontextprotocol/server': 2.0.0 '@sindresorhus/slugify': 2.2.1 '@standard-schema/spec': 1.1.0 ajv: 8.20.0 @@ -5122,14 +4872,13 @@ snapshots: p-map: 7.0.4 p-retry: 7.1.1 picomatch: 4.0.4 - posthog-node: 5.38.5 + posthog-node: 5.50.0 tokenx: 1.3.0 ws: 8.21.0 xxhash-wasm: 1.1.0 zod: 4.4.3 transitivePeerDependencies: - '@bufbuild/protobuf' - - '@cfworker/json-schema' - '@grpc/grpc-js' - ai - bufferutil @@ -5139,16 +4888,15 @@ snapshots: - utf-8-validate - workflow - '@mastra/deployer@1.57.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3)': + '@mastra/deployer@1.61.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3)': dependencies: '@babel/core': 8.0.1 '@babel/preset-typescript': 8.0.1(@babel/core@8.0.1) '@babel/traverse': 8.0.4 '@hono/node-ws': 1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27) - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) - '@mastra/server': 1.57.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/server': 1.61.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@optimize-lodash/rollup-plugin': 5.1.0(rollup@4.62.2) - '@rollup/plugin-alias': 6.0.0(rollup@4.62.2) '@rollup/plugin-commonjs': 29.0.2(rollup@4.62.2) '@rollup/plugin-esm-shim': 0.1.8(rollup@4.62.2) '@rollup/plugin-json': 6.1.0(rollup@4.62.2) @@ -5169,6 +4917,7 @@ snapshots: tinyglobby: 0.2.17 typescript-paths: 1.5.2(typescript@6.0.3) ws: 8.21.0 + yaml: 2.9.0 transitivePeerDependencies: - '@hono/node-server' - bufferutil @@ -5177,35 +4926,34 @@ snapshots: - utf-8-validate - zod - '@mastra/evals@1.7.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/evals@1.9.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) compromise: 14.15.1 keyword-extractor: 0.0.28 sentiment: 5.0.2 string-similarity: 4.0.4 - '@mastra/libsql@1.19.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/libsql@1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: '@libsql/client': 0.17.4 - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate - '@mastra/loggers@1.2.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/loggers@1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) pino: 10.3.1 pino-pretty: 13.1.3 - '@mastra/memory@1.26.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/memory@1.27.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) - '@mastra/schema-compat': 1.3.5(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/schema-compat': 1.3.7(zod@4.4.3) async-mutex: 0.5.0 diff: 8.0.4 - image-size: 1.2.1 json-schema: 0.4.0 lru-cache: 11.5.1 probe-image-size: 7.3.0 @@ -5215,14 +4963,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@mastra/observability@1.16.5(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/observability@1.17.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) zod: 4.4.3 - '@mastra/pg@1.19.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': + '@mastra/pg@1.21.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) async-mutex: 0.5.0 pg: 8.22.0 pg-connection-string: 2.14.0 @@ -5230,10 +4978,10 @@ snapshots: transitivePeerDependencies: - pg-native - '@mastra/rag@2.6.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/rag@2.6.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: '@aws-sdk/client-bedrock-agent-runtime': 3.1113.0 - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) big.js: 7.0.1 js-tiktoken: 1.0.21 node-html-better-parser: 1.5.9 @@ -5242,43 +4990,28 @@ snapshots: transitivePeerDependencies: - encoding - '@mastra/schema-compat@1.3.5(zod@4.4.3)': + '@mastra/schema-compat@1.3.7(zod@4.4.3)': dependencies: json-schema-to-zod: 2.8.1 zod: 4.4.3 zod-from-json-schema: 0.5.3 - zod-from-json-schema-v3: zod-from-json-schema@0.0.5 - zod-to-json-schema: 3.25.2(zod@4.4.3) - '@mastra/server@1.57.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + '@mastra/server@1.61.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) hono: 4.12.27 zod: 4.4.3 '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/core@2.0.0': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.27) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.27 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - supports-color + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: @@ -5386,11 +5119,11 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@posthog/core@1.37.3': + '@posthog/core@1.48.8': dependencies: - '@posthog/types': 1.391.1 + '@posthog/types': 1.405.1 - '@posthog/types@1.391.1': {} + '@posthog/types@1.405.1': {} '@protobufjs/aspromise@1.1.2': optional: true @@ -5473,10 +5206,6 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.12': {} - '@rollup/plugin-alias@6.0.0(rollup@4.62.2)': - optionalDependencies: - rollup: 4.62.2 - '@rollup/plugin-commonjs@29.0.2(rollup@4.62.2)': dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.2) @@ -5707,13 +5436,6 @@ snapshots: '@tootallnate/once@1.1.2': {} - '@topcoder/wipro-ai-sdk-provider@git+https://git.topcoder.com/Topcoder-Platform/Wipro-Provider-AI-SDK.git#92233a2b00d9dd46900437b54c406231ef24d7b2(zod@4.4.3)': - dependencies: - '@ai-sdk/openai-compatible': 2.0.51(zod@4.4.3) - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) - zod: 4.4.3 - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -5953,8 +5675,6 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} - '@zeit/schemas@2.36.0': {} - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5963,6 +5683,7 @@ snapshots: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + optional: true acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -5998,10 +5719,6 @@ snapshots: '@opentelemetry/api': 1.9.1 zod: 4.4.3 - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6009,13 +5726,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -6023,21 +5733,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} + ansi-regex@5.0.1: + optional: true ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - arch@2.2.0: {} + optional: true archiver@8.0.0: dependencies: @@ -6055,8 +5757,6 @@ snapshots: - bare-buffer - react-native-b4a - arg@5.0.2: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -6171,20 +5871,10 @@ snapshots: type-is: 2.1.0 transitivePeerDependencies: - supports-color + optional: true bowser@2.14.1: {} - boxen@7.0.0: - dependencies: - ansi-align: 3.0.1 - camelcase: 7.0.1 - chalk: 5.0.1 - cli-boxes: 3.0.0 - string-width: 5.1.2 - type-fest: 2.19.0 - widest-line: 4.0.1 - wrap-ansi: 8.1.0 - brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -6224,7 +5914,8 @@ snapshots: bytes@3.0.0: {} - bytes@3.1.2: {} + bytes@3.1.2: + optional: true call-bind-apply-helpers@1.0.2: dependencies: @@ -6235,8 +5926,7 @@ snapshots: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - - camelcase@7.0.1: {} + optional: true caniuse-lite@1.0.30001799: {} @@ -6246,17 +5936,6 @@ snapshots: chai@6.2.2: {} - chalk-template@0.4.0: - dependencies: - chalk: 4.1.2 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.0.1: {} - character-entities@2.0.2: {} chat@4.35.0(ai@6.0.209(zod@4.4.3))(zod@4.4.3): @@ -6274,14 +5953,6 @@ snapshots: transitivePeerDependencies: - supports-color - cli-boxes@3.0.0: {} - - clipboardy@3.0.0: - dependencies: - arch: 2.2.0 - execa: 5.1.1 - is-wsl: 2.2.0 - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -6296,8 +5967,10 @@ snapshots: color-convert@2.0.1: dependencies: color-name: 1.1.4 + optional: true - color-name@1.1.4: {} + color-name@1.1.4: + optional: true colorette@2.0.20: {} @@ -6317,22 +5990,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - compressible@2.0.18: - dependencies: - mime-db: 1.54.0 - - compression@1.8.1: - dependencies: - bytes: 3.1.2 - compressible: 2.0.18 - debug: 2.6.9 - negotiator: 0.6.4 - on-headers: 1.1.0 - safe-buffer: 5.2.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - compromise@14.15.1: dependencies: efrt: 2.7.0 @@ -6347,17 +6004,22 @@ snapshots: content-disposition@0.5.2: {} - content-disposition@1.1.0: {} + content-disposition@1.1.0: + optional: true - content-type@1.0.5: {} + content-type@1.0.5: + optional: true - content-type@2.0.0: {} + content-type@2.0.0: + optional: true convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} + cookie-signature@1.2.2: + optional: true - cookie@0.7.2: {} + cookie@0.7.2: + optional: true core-js@2.6.12: {} @@ -6365,11 +6027,6 @@ snapshots: core-util-is@1.0.3: {} - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - crc-32@1.2.2: {} crc32-stream@7.0.1: @@ -6413,15 +6070,14 @@ snapshots: dependencies: character-entities: 2.0.2 - deep-extend@0.6.0: {} - deep-is@0.1.4: {} deepmerge@4.3.1: {} delayed-stream@1.0.0: {} - depd@2.0.0: {} + depd@2.0.0: + optional: true dequal@2.0.3: {} @@ -6448,8 +6104,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 @@ -6459,19 +6113,20 @@ snapshots: dependencies: safe-buffer: 5.2.1 - ee-first@1.1.1: {} + ee-first@1.1.1: + optional: true efrt@2.7.0: {} electron-to-chromium@1.5.378: {} - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} + emoji-regex@8.0.0: + optional: true empathic@2.0.1: {} - encodeurl@2.0.0: {} + encodeurl@2.0.0: + optional: true end-of-stream@1.4.5: dependencies: @@ -6527,7 +6182,8 @@ snapshots: escalade@3.2.0: {} - escape-html@1.0.3: {} + escape-html@1.0.3: + optional: true escape-string-regexp@4.0.0: {} @@ -6605,7 +6261,8 @@ snapshots: esutils@2.0.3: {} - etag@1.8.1: {} + etag@1.8.1: + optional: true event-target-shim@5.0.1: {} @@ -6619,22 +6276,6 @@ snapshots: eventsource-parser@3.1.0: {} - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.1.0 - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -6652,11 +6293,6 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.5.2(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.2.0 - express-unless@2.1.3: {} express@5.2.1: @@ -6691,6 +6327,7 @@ snapshots: vary: 1.1.2 transitivePeerDependencies: - supports-color + optional: true exsolve@1.1.0: {} @@ -6764,6 +6401,7 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true find-up@5.0.0: dependencies: @@ -6816,9 +6454,11 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 - forwarded@0.2.0: {} + forwarded@0.2.0: + optional: true - fresh@2.0.0: {} + fresh@2.0.0: + optional: true fs-extra@11.3.5: dependencies: @@ -6856,8 +6496,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - get-stream@6.0.1: {} - get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -6908,8 +6546,6 @@ snapshots: ajv: 6.15.0 har-schema: 2.0.0 - has-flag@4.0.0: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6933,6 +6569,7 @@ snapshots: setprototypeof: 1.2.0 statuses: 2.0.2 toidentifier: 1.0.1 + optional: true http-proxy-agent@4.0.1: dependencies: @@ -6955,8 +6592,6 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@2.1.0: {} - human-signals@8.0.1: {} humanize-ms@1.2.1: @@ -6970,6 +6605,7 @@ snapshots: iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + optional: true ieee754@1.2.1: {} @@ -6977,10 +6613,6 @@ snapshots: ignore@7.0.5: {} - image-size@1.2.1: - dependencies: - queue: 6.0.2 - import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -6993,23 +6625,19 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - - ip-address@10.2.0: {} - - ipaddr.js@1.9.1: {} + ipaddr.js@1.9.1: + optional: true is-core-module@2.16.2: dependencies: hasown: 2.0.4 - is-docker@2.2.1: {} - is-extendable@0.1.1: {} is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@3.0.0: + optional: true is-glob@4.0.3: dependencies: @@ -7023,26 +6651,19 @@ snapshots: is-plain-obj@4.1.0: {} - is-port-reachable@4.0.0: {} - - is-promise@4.0.0: {} + is-promise@4.0.0: + optional: true is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 - is-stream@2.0.1: {} - is-stream@4.0.1: {} is-typedarray@1.0.0: {} is-unicode-supported@2.1.0: {} - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - isarray@1.0.0: {} isexe@2.0.0: {} @@ -7080,8 +6701,6 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema-typed@8.0.2: {} - json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -7284,15 +6903,15 @@ snapshots: markdown-table@3.0.4: {} - mastra@1.23.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3): + mastra@1.26.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3): dependencies: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 '@clack/prompts': 1.7.0 '@expo/devcert': 1.2.1 - '@mastra/core': 1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) - '@mastra/deployer': 1.57.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) - '@mastra/loggers': 1.2.0(@mastra/core@1.57.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/deployer': 1.61.0(@hono/node-server@1.19.14(hono@4.12.27))(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(typescript@6.0.3)(zod@4.4.3) + '@mastra/loggers': 1.2.0(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3)) archiver: 8.0.0 commander: 14.0.3 dotenv: 17.4.2 @@ -7302,11 +6921,9 @@ snapshots: local-pkg: 1.2.1 openapi-fetch: 0.17.0 picocolors: 1.1.1 - posthog-node: 5.38.5 + posthog-node: 5.50.0 semver: 7.8.5 - serve: 14.2.6 serve-handler: 6.1.7 - shell-quote: 1.9.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 yocto-spinner: 1.2.0 @@ -7426,11 +7043,11 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} + media-typer@1.1.0: + optional: true - merge-stream@2.0.0: {} + merge-descriptors@2.0.0: + optional: true merge2@1.4.1: {} @@ -7636,7 +7253,8 @@ snapshots: mime-db@1.52.0: {} - mime-db@1.54.0: {} + mime-db@1.54.0: + optional: true mime-types@2.1.18: dependencies: @@ -7649,8 +7267,7 @@ snapshots: mime-types@3.0.2: dependencies: mime-db: 1.54.0 - - mimic-fn@2.1.0: {} + optional: true minimatch@10.2.5: dependencies: @@ -7706,9 +7323,8 @@ snapshots: transitivePeerDependencies: - supports-color - negotiator@0.6.4: {} - - negotiator@1.0.0: {} + negotiator@1.0.0: + optional: true node-domexception@1.0.0: {} @@ -7724,10 +7340,6 @@ snapshots: normalize-path@3.0.0: {} - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -7735,9 +7347,8 @@ snapshots: oauth-sign@0.9.0: {} - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} + object-inspect@1.13.4: + optional: true obug@2.1.3: {} @@ -7750,17 +7361,12 @@ snapshots: on-finished@2.4.1: dependencies: ee-first: 1.1.1 - - on-headers@1.1.0: {} + optional: true once@1.4.0: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - openapi-fetch@0.17.0: dependencies: openapi-typescript-helpers: 0.1.0 @@ -7792,7 +7398,8 @@ snapshots: parse-ms@4.0.0: {} - parseurl@1.3.3: {} + parseurl@1.3.3: + optional: true path-exists@4.0.0: {} @@ -7809,7 +7416,8 @@ snapshots: path-to-regexp@3.3.0: {} - path-to-regexp@8.4.2: {} + path-to-regexp@8.4.2: + optional: true pathe@2.0.3: {} @@ -7892,8 +7500,6 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - pkce-challenge@5.0.1: {} - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -7922,9 +7528,9 @@ snapshots: dependencies: xtend: 4.0.2 - posthog-node@5.38.5: + posthog-node@5.50.0: dependencies: - '@posthog/core': 1.37.3 + '@posthog/core': 1.48.8 precond@0.2.3: {} @@ -7971,6 +7577,7 @@ snapshots: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + optional: true proxy-from-env@1.1.0: {} @@ -7989,6 +7596,7 @@ snapshots: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 + optional: true qs@6.5.5: {} @@ -7996,10 +7604,6 @@ snapshots: queue-microtask@1.2.3: {} - queue@6.0.2: - dependencies: - inherits: 2.0.4 - quick-format-unescaped@4.0.4: {} r7insight_node@1.8.4: @@ -8013,7 +7617,8 @@ snapshots: range-parser@1.2.0: {} - range-parser@1.2.1: {} + range-parser@1.2.1: + optional: true raw-body@3.0.2: dependencies: @@ -8021,13 +7626,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 + optional: true readable-stream@2.3.8: dependencies: @@ -8059,15 +7658,6 @@ snapshots: dependencies: backoff: 2.5.0 - registry-auth-token@3.3.2: - dependencies: - rc: 1.2.8 - safe-buffer: 5.2.1 - - registry-url@3.1.0: - dependencies: - rc: 1.2.8 - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -8217,6 +7807,7 @@ snapshots: path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color + optional: true run-parallel@1.2.0: dependencies: @@ -8265,6 +7856,7 @@ snapshots: statuses: 2.0.2 transitivePeerDependencies: - supports-color + optional: true sentiment@5.0.2: {} @@ -8286,24 +7878,10 @@ snapshots: send: 1.2.1 transitivePeerDependencies: - supports-color + optional: true - serve@14.2.6: - dependencies: - '@zeit/schemas': 2.36.0 - ajv: 8.18.0 - arg: 5.0.2 - boxen: 7.0.0 - chalk: 5.0.1 - chalk-template: 0.4.0 - clipboardy: 3.0.0 - compression: 1.8.1 - is-port-reachable: 4.0.0 - serve-handler: 6.1.7 - update-check: 1.5.4 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} + setprototypeof@1.2.0: + optional: true shebang-command@2.0.0: dependencies: @@ -8311,12 +7889,11 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-map@1.0.1: dependencies: @@ -8324,6 +7901,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 + optional: true side-channel-weakmap@1.0.2: dependencies: @@ -8332,6 +7910,7 @@ snapshots: get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 + optional: true side-channel@1.1.1: dependencies: @@ -8340,11 +7919,10 @@ snapshots: side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + optional: true siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sisteransi@1.0.5: {} @@ -8373,7 +7951,8 @@ snapshots: stackback@0.0.2: {} - statuses@2.0.2: {} + statuses@2.0.2: + optional: true std-env@4.1.0: {} @@ -8399,12 +7978,7 @@ snapshots: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 + optional: true string_decoder@1.1.1: dependencies: @@ -8417,27 +7991,16 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 + optional: true strip-bom-string@1.0.0: {} - strip-final-newline@2.0.0: {} - strip-final-newline@4.0.0: {} - strip-json-comments@2.0.1: {} - strip-json-comments@5.0.3: {} suffix-thumb@5.0.2: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} tar-stream@3.2.0: @@ -8496,7 +8059,8 @@ snapshots: dependencies: is-number: 7.0.0 - toidentifier@1.0.1: {} + toidentifier@1.0.1: + optional: true tokenx@1.3.0: {} @@ -8535,13 +8099,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@2.19.0: {} - type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 + optional: true typescript-eslint@8.62.0(eslint@10.5.0)(typescript@6.0.3): dependencies: @@ -8599,7 +8162,8 @@ snapshots: universalify@2.0.1: {} - unpipe@1.0.0: {} + unpipe@1.0.0: + optional: true unplugin-utils@0.2.5: dependencies: @@ -8612,11 +8176,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-check@1.5.4: - dependencies: - registry-auth-token: 3.3.2 - registry-url: 3.1.0 - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -8627,7 +8186,8 @@ snapshots: uuid@3.4.0: {} - vary@1.1.2: {} + vary@1.1.2: + optional: true verror@1.10.0: dependencies: @@ -8710,10 +8270,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@4.0.1: - dependencies: - string-width: 5.1.2 - word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -8723,12 +8279,6 @@ snapshots: strip-ansi: 6.0.1 optional: true - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.21.0: {} @@ -8784,20 +8334,10 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - zod-from-json-schema@0.0.5: - dependencies: - zod: 3.25.76 - zod-from-json-schema@0.5.3: dependencies: zod: 4.4.3 - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@3.25.76: {} - zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 67844d3..39769c8 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -65,6 +65,6 @@ export const mastra = new Mastra({ }, bundler: { externals: ["tc-core-library-js"], - transpilePackages: ['@topcoder/wipro-ai-sdk-provider'], + transpilePackages: [], }, }); diff --git a/src/utils/index.ts b/src/utils/index.ts index 1ef5fce..6edb0e7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,7 +3,6 @@ export * from './auth'; export * from './middleware'; export * from './logger'; export * from './auth/m2m.service' -export * from './providers/wipro'; export * from './providers/bedrock'; export * from './providers/model-factory'; export * from './providers/embedding-factory'; \ No newline at end of file diff --git a/src/utils/providers/model-factory.ts b/src/utils/providers/model-factory.ts index 4947154..9d331a7 100644 --- a/src/utils/providers/model-factory.ts +++ b/src/utils/providers/model-factory.ts @@ -1,5 +1,4 @@ import { ollama } from './ollama'; -import { wipro } from './wipro'; import { createBedrockProvider } from './bedrock'; import { tcAILogger } from '../logger'; import { openai } from './openai'; @@ -19,9 +18,6 @@ export function createModel(providerName: string, modelName: string, agentId?: s // } }); - case 'WiproAI': - return wipro.chatModel(modelName); - case 'AWSBedrock': return createBedrockProvider(agentId)(modelName); diff --git a/src/utils/providers/wipro.ts b/src/utils/providers/wipro.ts deleted file mode 100644 index 638a1a8..0000000 --- a/src/utils/providers/wipro.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createWipro } from "@topcoder/wipro-ai-sdk-provider"; - -// Create provider with custom auth headers -export const wipro = createWipro({ - headers: { "x-api-key": process.env.WIPRO_API_KEY! }, - chatSettings: { - // Keep sampling deterministic to reduce malformed/shape-drifted JSON. - temperature: 0, - topP: 0.1, - topK: 20, - - maxOutputTokens: 8192, - - // Avoid repetition penalties that can destabilize strict JSON output. - frequencyPenalty: 0, - presencePenalty: 0, - - // Default JSON mode for non-structured calls. - // Structured calls can still override this when needed. - responseFormat: 'json_object', - } -}); From 7cc8ec5d044d92d72beca6ab6c9e07657d557c01 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 16:14:40 +0300 Subject: [PATCH 14/19] adds chat history memory to tc challenge assistant --- src/mastra/agents/challenge/challenge-search-agent.ts | 9 ++++++++- src/mastra/tools/challenge/fetch-challenge-tool.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/mastra/agents/challenge/challenge-search-agent.ts b/src/mastra/agents/challenge/challenge-search-agent.ts index 497232d..b6114bc 100644 --- a/src/mastra/agents/challenge/challenge-search-agent.ts +++ b/src/mastra/agents/challenge/challenge-search-agent.ts @@ -1,6 +1,8 @@ import { Agent } from '@mastra/core/agent'; import { createModel } from '../../../utils'; import { challengeVectorQueryTool } from '../../tools/challenge/challenge-vector-query-tool'; +import { Memory } from '@mastra/memory'; +import { fetchProjectTool } from '../../tools/project/fetch-project-tool'; const PROVIDER_NAME = process.env.CHALLENGE_SEARCH_AI_PROVIDER || 'AWSBedrock'; const MODEL_ID = process.env.CHALLENGE_SEARCH_AI_MODEL_ID || 'us.anthropic.claude-haiku-4-5'; @@ -26,6 +28,11 @@ export const challengeSearchAgent = new Agent({ id: AGENT_ID, name: 'Topcoder Challenge Assistant', model: createModel(PROVIDER_NAME, MODEL_ID, AGENT_ID), + memory: new Memory({ + options: { + lastMessages: 10, + }, + }), instructions: { role: 'system', content: `You are a helpful Topcoder Challenge Assistant. Your goal is to assist members in finding relevant information about Topcoder challenges regarding their query. @@ -49,5 +56,5 @@ Tool Usage Strategy: Ground your response SOLELY on the context returned by the tool. If no results are found, say "I couldn't find any challenges matching your criteria."`, }, - tools: { challengeVectorQueryTool }, + tools: { challengeVectorQueryTool, fetchProjectTool }, }); diff --git a/src/mastra/tools/challenge/fetch-challenge-tool.ts b/src/mastra/tools/challenge/fetch-challenge-tool.ts index 043f95e..793c384 100644 --- a/src/mastra/tools/challenge/fetch-challenge-tool.ts +++ b/src/mastra/tools/challenge/fetch-challenge-tool.ts @@ -11,7 +11,7 @@ const m2mService = new M2MService(); export const fetchChallengeTool = createTool({ id: 'fetch-challenge-by-id', description: - 'Fetches a Topcoder challenge by its UUID from the Topcoder v5 Challenges API using M2M authentication', + 'Fetches a Topcoder challenge by its UUID from the Topcoder v6 Challenges API using M2M authentication', inputSchema: z.object({ challengeId: z.string().uuid().describe('UUID of the Topcoder challenge to fetch'), }), From bd06d9adc17cdb1eff0607869f7af6cb6761ae4a Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 24 Aug 2026 17:06:39 +0300 Subject: [PATCH 15/19] docs: sync README Agents/Tools with the current codebase The Agents and Tools sections only covered skillsMatchingAgent and the two skills tools, predating challengeParserAgent, jdRewriterAgent, challengeSearchAgent, and the four Challenge/Project tools. Adds per-agent/tool detail sections plus summary tables, and corrects two stale facts: skillsMatchingAgent now defaults to Bedrock (not Ollama mistral), and its scorers are gated on LOCAL_DEV=true. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MkcggGkcnz73GXUDR7mgrE --- README.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8bda445..ae20fa5 100644 --- a/README.md +++ b/README.md @@ -263,14 +263,25 @@ The default model is `mistral:latest` with conservative generation parameters: ## Agents +Four agents are registered in `src/mastra/index.ts`, all built via the shared `createModel(provider, modelId, agentId)` factory (`src/utils/providers/model-factory.ts`), which switches on `TC-Ollama` / `AWSBedrock` / `OpenAI`. + +| Agent (registry key) | ID | Default model | Memory | Tools | +| --- | --- | --- | --- | --- | +| `skillsMatchingAgent` | `skillsMatchingAgent` | AWSBedrock `us.anthropic.claude-haiku-4-5-20251001-v1:0` | PostgreSQL-backed | — (workflow calls skill tools directly) | +| `challengeParserAgent` | `challenge-parser-agent` | AWSBedrock `us.anthropic.claude-sonnet-5` | — | — (structured-output extractor) | +| `challengeSearchAgent` | `challenge-search-agent` | AWSBedrock `us.anthropic.claude-haiku-4-5` | In-memory, last 10 messages | `challengeVectorQueryTool`, `fetchProjectTool` | +| `jdRewriterAgent` | `jd-rewriter-agent` | AWSBedrock `us.anthropic.claude-haiku-4-5-20251001-v1:0` | — | — (structured-output rewriter) | + +Every default is overridable per-agent via `_AI_PROVIDER` / `_AI_MODEL_ID` env vars (e.g. `SKILLS_EXTRACTOR_AI_PROVIDER`, `CHALLENGE_PARSER_AI_PROVIDER`, `CHALLENGE_SEARCH_AI_PROVIDER`, `JD_REWRITER_AI_PROVIDER`). + ### `skillsMatchingAgent` | Property | Value | | ----------- | -------------------------------------------- | | **ID** | `skillsMatchingAgent` | -| **Model** | `ollama('mistral:latest')` | +| **Model** | `createModel('AWSBedrock', 'us.anthropic.claude-haiku-4-5-20251001-v1:0')` by default | | **Memory** | PostgreSQL-backed conversation memory | -| **Scorers** | Answer Relevancy, Prompt Alignment (sampled) | +| **Scorers** | Answer Relevancy, Prompt Alignment — sampled, only when `LOCAL_DEV=true` | **System Prompt Behavior:** @@ -283,10 +294,54 @@ The agent is instructed to: The agent is used within the workflow's `generateSkillCandidateTerms` step via its `.stream()` method, producing incremental text output that is then parsed into a JSON array. +### `challengeParserAgent` + +| Property | Value | +| ---------- | --------------------------------------------------------------- | +| **ID** | `challenge-parser-agent` | +| **Model** | `createModel('AWSBedrock', 'us.anthropic.claude-sonnet-5')` by default | +| **Memory** | None | +| **Tools** | None — pure structured-output extractor | + +Reads a full challenge specification (public + private description, skills, metadata) and returns structured JSON: requirements (grouped), tech stack, runtime environment, existing-codebase status, and submission guidelines. Used by `challenge-context-workflow`'s `parse-challenge-context` step, invoked as four focused, partly-parallel extraction calls (requirements+grouping, then tech/runtime + codebase + guidelines in parallel) via `generateWithStructuredOutputFallback`, each validated against the source text afterward to prune hallucinated items. + +### `challengeSearchAgent` ("Topcoder Challenge Assistant") + +| Property | Value | +| ---------- | --------------------------------------------------------------- | +| **ID** | `challenge-search-agent` | +| **Model** | `createModel('AWSBedrock', 'us.anthropic.claude-haiku-4-5')` by default | +| **Memory** | In-memory only (`Memory({ options: { lastMessages: 10 } })`) — no persistent storage backend, unlike `skillsMatchingAgent` | +| **Tools** | `challengeVectorQueryTool`, `fetchProjectTool` | + +Answers natural-language questions about indexed Topcoder challenges. Infers `skills`/`type`/`track`/`groups` filters from the query and calls `challenge-vector-query`; never infers `projectId` from query text (it must arrive from the caller's context — see [Challenges Vector RAG](#challenges-vector-rag)). Grounds every answer solely in tool results. For callers needing raw ranked results with no LLM latency/cost/non-determinism, the `challenge-search` workflow shares the same underlying tool. + +### `jdRewriterAgent` + +| Property | Value | +| ---------- | ----------------------------------------------------------------------------- | +| **ID** | `jd-rewriter-agent` | +| **Model** | `createModel('AWSBedrock', 'us.anthropic.claude-haiku-4-5-20251001-v1:0')` by default | +| **Memory** | None | +| **Tools** | None — pure structured-output rewriter | + +Rewrites a raw/rough job description into Topcoder's canonical structured format (formatted description + extracted skill keywords) for `jd-autowrite-workflow`. + --- ## Tools +Six tools are defined under `src/mastra/tools/`, each a `createTool()` with a Zod input/output schema. The four Challenge/Project tools authenticate via `M2MService` (M2M JWT); the two Skills tools call unauthenticated public endpoints. + +| Tool ID | Purpose | Called by | +| --- | --- | --- | +| `standardized-skills-fuzzy-match` | Fuzzy-match skill names | `skill-extraction-workflow` | +| `standardized-skills-semantic-search` | Vector-based skill search | `skill-extraction-workflow` | +| `fetch-challenge-by-id` | Fetch one challenge by UUID | `challenge-context-workflow`, `challenge-ingestion-workflow` | +| `search-challenges` | Paginated/filtered challenge search | `challenge-bulk-ingestion-workflow` | +| `challenge-vector-query` | Semantic + metadata-filtered vector search | `challengeSearchAgent`, `challenge-search` workflow | +| `fetch-project-by-id` | Resolve a `projectId` reference to project detail | `challengeSearchAgent` (on-demand enrichment) | + ### `standardized-skills-fuzzy-match` | Property | Value | @@ -309,6 +364,49 @@ Performs fuzzy string matching against Topcoder's standardized skills taxonomy. Performs vector-based semantic search against the skills taxonomy. Returns matches ranked by cosine distance. +### `fetch-challenge-by-id` + +| Property | Value | +| ---------- | -------------------------------------------------------------------- | +| **ID** | `fetch-challenge-by-id` | +| **API** | `GET {TC_API_BASE}/v6/challenges/:challengeId` (M2M) | +| **Input** | `{ challengeId: uuid }` | +| **Output** | Full challenge object — `name`, `description`, `privateDescription`, `descriptionFormat`, `status`, `track`, `type`, `tags`, `skills`, `projectId`, `groups`, timeline dates, `prizeSets`, `reviewers`, `discussions`, `overview`, `task`, `legacy` | + +Fetches one challenge's full detail, including the reviewer-only `privateDescription` (consumers must be deliberate about never embedding or exposing it — the RAG ingestion path explicitly discards it). + +### `search-challenges` + +| Property | Value | +| ---------- | -------------------------------------------------------------------- | +| **ID** | `search-challenges` | +| **API** | `GET {TC_API_BASE}/v6/challenges` (M2M) | +| **Input** | `{ projectId?, projectIds?, status?, approvalStatus?, types?, tracks?, tags?, groups?, updatedDateStart?, updatedDateEnd?, ids?, page?, perPage?, sortBy?, sortOrder? }` | +| **Output** | `{ challenges: [...], total, page, perPage }` | + +Wraps the v6 endpoint's bare JSON array into a paginated envelope. Always requests `isLightweight: false` (the lightweight form omits `description`). `privateDescription` is intentionally excluded from every mapped result. + +### `challenge-vector-query` + +| Property | Value | +| ---------- | -------------------------------------------------------------------- | +| **ID** | `challenge-vector-query` | +| **Input** | `{ query?: string, skills?: string[], type?: string, track?: string, groups?: string[], projectId?: string \| string[], topK?: number, minScore?: number }` | +| **Output** | `{ success: boolean, count?: number, results?: [{ text, score, metadata }], error?: string }` | + +The shared retrieval primitive behind both the search agent and the deterministic `challenge-search` workflow — see [Challenges Vector RAG → Retrieval](#challenges-vector-rag) for filter composition and the metadata-only lookup path. + +### `fetch-project-by-id` + +| Property | Value | +| ---------- | -------------------------------------------------------------------- | +| **ID** | `fetch-project-by-id` | +| **API** | `GET {TC_API_BASE}/v6/projects/:projectId` (M2M) | +| **Input** | `{ projectId: string, fields?: string }` | +| **Output** | `{ project: { id, name?, status?, type?, billingAccountId?, directProjectId?, techStack? } }` | + +Retrieval-time enrichment only (not used by ingestion): resolves the opaque `projectId` a challenge-search hit carries into project name/status/tech stack, under the caller's own authorization. + --- ## Scorers (Evaluation) From 5ca6e1f9005de31b72de7dad5c25ceff14432685 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 25 Aug 2026 08:38:04 +0300 Subject: [PATCH 16/19] Fixed auth and per-user resource isolation & enable chat routes --- package.json | 1 + pnpm-lock.yaml | 15 +++++++++++ src/mastra/index.ts | 11 ++++++-- src/utils/auth/index.ts | 27 ++++++++++++++++++++ src/utils/middleware/index.ts | 4 +-- src/utils/middleware/resourceIdMiddleware.ts | 19 +++++++++++--- src/utils/server-routes.ts | 5 ++++ 7 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/utils/server-routes.ts diff --git a/package.json b/package.json index 1c6cc3c..2970cd6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@ai-sdk/amazon-bedrock": "^4.0.121", "@ai-sdk/openai": "^3.0.74", "@aws-sdk/credential-providers": "^3.1075.0", + "@mastra/ai-sdk": "^1.9.1", "@mastra/auth-auth0": "^1.2.2", "@mastra/core": "^1.61.0", "@mastra/evals": "^1.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6de3f..29836b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@aws-sdk/credential-providers': specifier: ^3.1075.0 version: 3.1075.0 + '@mastra/ai-sdk': + specifier: ^1.9.1 + version: 1.9.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) '@mastra/auth-auth0': specifier: ^1.2.2 version: 1.2.2 @@ -826,6 +829,13 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} + '@mastra/ai-sdk@1.9.1': + resolution: {integrity: sha512-93ptk2jsUG6HbDAZdcgodnewGp7B19A7/CmDh9Y64dTd25I3DqGmY4Y3KZRhCLJ/bDAIW9jMwo8N2UxHrMh/hg==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@mastra/core': '>=1.5.0-0 <2.0.0-0' + zod: ^3.25.0 || ^4.0.0 + '@mastra/auth-auth0@1.2.2': resolution: {integrity: sha512-Oggr7zNOZ64F1empufZLy7nzDCLo53XT/41ZatEmwPJpMs3Lv9wdq/IScMharTMnLKFPCpPVAlKOc6y1I3MXxg==} engines: {node: '>=22.13.0'} @@ -4838,6 +4848,11 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 + '@mastra/ai-sdk@1.9.1(@mastra/core@1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@mastra/core': 1.61.0(@grpc/grpc-js@1.14.4)(ai@6.0.209(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + zod: 4.4.3 + '@mastra/auth-auth0@1.2.2': dependencies: jose: 6.2.3 diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 39769c8..feb82ff 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -15,7 +15,9 @@ import { instancePromptAlignmentScorer, } from './scorers/instance-scorers'; import { apiAuthLayer, middlewareConfig, tcAILogger } from '../utils'; +import { API_PREFIX, CHAT_ROUTE_PATH } from '../utils/server-routes'; import { aiWorkspace } from './workspaces'; +import { chatRoute } from '@mastra/ai-sdk'; export const mastra = new Mastra({ workflows: { @@ -42,7 +44,7 @@ export const mastra = new Mastra({ host: process.env.MASTRA_HOST || process.env.HOST || '0.0.0.0', port: Number(process.env.PORT || 3000), studioBase: '/studio', - apiPrefix: '/v6/ai', + apiPrefix: API_PREFIX, auth: process.env.DISABLE_AUTH === 'true' ? undefined : apiAuthLayer, build: { apiReqLogs: true, @@ -61,7 +63,12 @@ export const mastra = new Mastra({ "Link", ], maxAge: 3600 - } + }, + apiRoutes: [ + chatRoute({ + path: CHAT_ROUTE_PATH, + }), + ], }, bundler: { externals: ["tc-core-library-js"], diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index 9885713..6ca7c78 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -1,15 +1,42 @@ import { MastraAuthAuth0 } from '@mastra/auth-auth0'; import { CompositeAuth } from '@mastra/core/server'; +import { API_PREFIX } from '../server-routes'; + +// Matches the TC userId claim key used across the platform, e.g. +// https://topcoder.com/userId or https://topcoder-dev.com/userId +const tcUserIdClaimKey = (): string => { + const tcApiBase = process.env.TC_API_BASE || ''; + let domain = 'topcoder.com'; + try { + if (tcApiBase) { + domain = new URL(tcApiBase).hostname.replace('api.', ''); + } + } catch { + // fall back to default domain + } + return `https://${domain}/userId`; +}; + +const mapUserToResourceId = (user: Record): string | undefined => { + const userId = user[tcUserIdClaimKey()]; + if (typeof userId === 'string') return userId; + // M2M tokens don't carry the TC userId claim; fall back to the subject + return typeof user.sub === 'string' ? user.sub : undefined; +}; export const apiAuthLayer = new CompositeAuth([ // TC Member Auth0 JWTs new MastraAuthAuth0({ domain: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_AUDIENCE, + protected: [`${API_PREFIX}/*`], + mapUserToResourceId, }), // TC M2M Auth0 JWTs new MastraAuthAuth0({ domain: process.env.AUTH0_M2M_DOMAIN, audience: process.env.AUTH0_M2M_AUDIENCE, + protected: [`${API_PREFIX}/*`], + mapUserToResourceId, }), ]); diff --git a/src/utils/middleware/index.ts b/src/utils/middleware/index.ts index 46cc5cc..6ff2c09 100644 --- a/src/utils/middleware/index.ts +++ b/src/utils/middleware/index.ts @@ -1,9 +1,9 @@ -import { resourceIdMiddleware } from './resourceIdMiddleware'; +import { resourceIdMiddleware, chatResourceIdMiddleware } from './resourceIdMiddleware'; export * from './resourceIdMiddleware'; export const middlewareConfig: any[] = []; // Only when auth is enabled if (process.env.DISABLE_AUTH !== 'true') { - middlewareConfig.push(resourceIdMiddleware); + middlewareConfig.push(resourceIdMiddleware, chatResourceIdMiddleware); } \ No newline at end of file diff --git a/src/utils/middleware/resourceIdMiddleware.ts b/src/utils/middleware/resourceIdMiddleware.ts index bcf8946..e15190f 100644 --- a/src/utils/middleware/resourceIdMiddleware.ts +++ b/src/utils/middleware/resourceIdMiddleware.ts @@ -1,6 +1,7 @@ import { MASTRA_RESOURCE_ID_KEY } from "@mastra/core/request-context"; import { apiAuthLayer } from '../auth'; import { tcAILogger } from '../logger'; +import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; /** * Resource ID Middleware @@ -17,9 +18,7 @@ import { tcAILogger } from '../logger'; * * @returns 401 Unauthorized if the user is missing or an ID cannot be extracted. */ -export const resourceIdMiddleware = { - path: '/api/*', - handler: async (c: any, next: any) => { +const resourceIdMiddlewareHandler = async (c: any, next: any) => { const requestContext = c.get('requestContext'); let user = requestContext.get('user'); @@ -72,5 +71,17 @@ export const resourceIdMiddleware = { requestContext.set(MASTRA_RESOURCE_ID_KEY, userId || sub); return next(); - }, +}; + +// Built-in Mastra routes (agents, workflows, memory, threads, ...) live under apiPrefix. +export const resourceIdMiddleware = { + path: `${API_PREFIX}/*`, + handler: resourceIdMiddlewareHandler, +}; + +// chatRoute() is a custom API route registered outside apiPrefix (see src/mastra/index.ts), +// so it needs its own entry to be covered. +export const chatResourceIdMiddleware = { + path: `${CHAT_ROUTE_BASE_PATH}/*`, + handler: resourceIdMiddlewareHandler, }; \ No newline at end of file diff --git a/src/utils/server-routes.ts b/src/utils/server-routes.ts new file mode 100644 index 0000000..11fd78b --- /dev/null +++ b/src/utils/server-routes.ts @@ -0,0 +1,5 @@ +// Single source of truth for the server's route surfaces, so auth/middleware +// path patterns can't drift out of sync with how routes are actually mounted. +export const API_PREFIX = '/v6/ai'; +export const CHAT_ROUTE_BASE_PATH = '/chat'; +export const CHAT_ROUTE_PATH = `${CHAT_ROUTE_BASE_PATH}/:agentId`; From a8061739c3f984cb09b4d281930c10c62dd45cc6 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 25 Aug 2026 09:30:40 +0300 Subject: [PATCH 17/19] add auth logs --- src/utils/middleware/resourceIdMiddleware.test.ts | 14 +++++++++++++- src/utils/middleware/resourceIdMiddleware.ts | 9 ++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/utils/middleware/resourceIdMiddleware.test.ts b/src/utils/middleware/resourceIdMiddleware.test.ts index b9e8423..7c0dd8b 100644 --- a/src/utils/middleware/resourceIdMiddleware.test.ts +++ b/src/utils/middleware/resourceIdMiddleware.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { MASTRA_RESOURCE_ID_KEY } from '@mastra/core/request-context'; -const { authenticateTokenMock, loggerErrorMock } = vi.hoisted(() => ({ +const { authenticateTokenMock, loggerErrorMock, loggerInfoMock } = vi.hoisted(() => ({ authenticateTokenMock: vi.fn(), loggerErrorMock: vi.fn(), + loggerInfoMock: vi.fn(), })); vi.mock('../auth', () => ({ @@ -15,6 +16,7 @@ vi.mock('../auth', () => ({ vi.mock('../logger', () => ({ tcAILogger: { error: loggerErrorMock, + info: loggerInfoMock, }, })); @@ -86,6 +88,11 @@ describe('resourceIdMiddleware', () => { expect(next).toHaveBeenCalledOnce(); expect(json).not.toHaveBeenCalled(); expect(authenticateTokenMock).not.toHaveBeenCalled(); + expect(loggerInfoMock).toHaveBeenCalledWith('Auth resolved for request', { + authType: 'member', + resourceId: '12345', + user, + }); }); it('hydrates user from bearer token when request context user is missing', async () => { @@ -124,6 +131,11 @@ describe('resourceIdMiddleware', () => { expect(requestContext.get(MASTRA_RESOURCE_ID_KEY)).toBe('m2m-subject'); expect(next).toHaveBeenCalledOnce(); expect(json).not.toHaveBeenCalled(); + expect(loggerInfoMock).toHaveBeenCalledWith('Auth resolved for request', { + authType: 'm2m', + resourceId: 'm2m-subject', + user, + }); }); it('returns 401 when user cannot be resolved', async () => { diff --git a/src/utils/middleware/resourceIdMiddleware.ts b/src/utils/middleware/resourceIdMiddleware.ts index e15190f..024bf5a 100644 --- a/src/utils/middleware/resourceIdMiddleware.ts +++ b/src/utils/middleware/resourceIdMiddleware.ts @@ -68,7 +68,14 @@ const resourceIdMiddlewareHandler = async (c: any, next: any) => { // Force all API operations to use this user's ID // This takes precedence over any client-provided resourceId - requestContext.set(MASTRA_RESOURCE_ID_KEY, userId || sub); + const resourceId = userId || sub; + requestContext.set(MASTRA_RESOURCE_ID_KEY, resourceId); + + tcAILogger.info('Auth resolved for request', { + authType: userId ? 'member' : 'm2m', + resourceId, + user, + }); return next(); }; From f900d022c62abc6884039bf9a0d44a1711886c4f Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 25 Aug 2026 09:57:57 +0300 Subject: [PATCH 18/19] dump only resourceId on auth resolve --- src/utils/middleware/resourceIdMiddleware.ts | 91 ++++++++++---------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/src/utils/middleware/resourceIdMiddleware.ts b/src/utils/middleware/resourceIdMiddleware.ts index 024bf5a..33b2fdb 100644 --- a/src/utils/middleware/resourceIdMiddleware.ts +++ b/src/utils/middleware/resourceIdMiddleware.ts @@ -19,65 +19,64 @@ import { API_PREFIX, CHAT_ROUTE_BASE_PATH } from '../server-routes'; * @returns 401 Unauthorized if the user is missing or an ID cannot be extracted. */ const resourceIdMiddlewareHandler = async (c: any, next: any) => { - const requestContext = c.get('requestContext'); - let user = requestContext.get('user'); + const requestContext = c.get('requestContext'); + let user = requestContext.get('user'); - if (!user) { - const authHeader = c.req.header('authorization') || ''; - const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; - const apiKeyToken = c.req.query('apiKey') || ''; - const token = bearerToken || apiKeyToken; + if (!user) { + const authHeader = c.req.header('authorization') || ''; + const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; + const apiKeyToken = c.req.query('apiKey') || ''; + const token = bearerToken || apiKeyToken; - if (token) { - try { - user = await apiAuthLayer.authenticateToken(token, c.req.raw); - if (user) { - requestContext.set('user', user); - } - } catch (error) { - tcAILogger.error('Failed to authenticate token in resource middleware', { error }); + if (token) { + try { + user = await apiAuthLayer.authenticateToken(token, c.req.raw); + if (user) { + requestContext.set('user', user); } + } catch (error) { + tcAILogger.error('Failed to authenticate token in resource middleware', { error }); } } + } - if (!user) { - tcAILogger.error('User object missing in context!'); - return c.json({ error: 'Unauthorized' }, 401); - } + if (!user) { + tcAILogger.error('User object missing in context!'); + return c.json({ error: 'Unauthorized' }, 401); + } - // Logic to extract userId - const tcApiBase = process.env.TC_API_BASE || ''; - let domain = 'topcoder.com'; - try { - if (tcApiBase) { - const url = new URL(tcApiBase); - domain = url.hostname.replace('api.', ''); - } - } catch (e) { - console.error('Error parsing TC_API_BASE:', e); + // Logic to extract userId + const tcApiBase = process.env.TC_API_BASE || ''; + let domain = 'topcoder.com'; + try { + if (tcApiBase) { + const url = new URL(tcApiBase); + domain = url.hostname.replace('api.', ''); } + } catch (e) { + console.error('Error parsing TC_API_BASE:', e); + } - const userIdKey = `https://${domain}/userId`; - const userId = user[userIdKey]; - const sub = user['sub']; // M2M user + const userIdKey = `https://${domain}/userId`; + const userId = user[userIdKey]; + const sub = user['sub']; // M2M user - if (!userId && !sub) { - tcAILogger.error('Failed to identify userId/sub', { user }); - return c.json({ error: 'Failed to extract userId/sub from user object' }, 401); - } + if (!userId && !sub) { + tcAILogger.error('Failed to identify userId/sub', { user }); + return c.json({ error: 'Failed to extract userId/sub from user object' }, 401); + } - // Force all API operations to use this user's ID - // This takes precedence over any client-provided resourceId - const resourceId = userId || sub; - requestContext.set(MASTRA_RESOURCE_ID_KEY, resourceId); + // Force all API operations to use this user's ID + // This takes precedence over any client-provided resourceId + const resourceId = userId || sub; + requestContext.set(MASTRA_RESOURCE_ID_KEY, resourceId); - tcAILogger.info('Auth resolved for request', { - authType: userId ? 'member' : 'm2m', - resourceId, - user, - }); + tcAILogger.info('Auth resolved for request', { + authType: userId ? 'member' : 'm2m', + resourceId, + }); - return next(); + return next(); }; // Built-in Mastra routes (agents, workflows, memory, threads, ...) live under apiPrefix. From e0de2816da0e46acdfaaa376690de3a1f8dd1e3a Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 25 Aug 2026 10:08:28 +0300 Subject: [PATCH 19/19] fix tests for auth breaking the build --- src/utils/middleware/resourceIdMiddleware.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/middleware/resourceIdMiddleware.test.ts b/src/utils/middleware/resourceIdMiddleware.test.ts index 7c0dd8b..769a116 100644 --- a/src/utils/middleware/resourceIdMiddleware.test.ts +++ b/src/utils/middleware/resourceIdMiddleware.test.ts @@ -91,7 +91,6 @@ describe('resourceIdMiddleware', () => { expect(loggerInfoMock).toHaveBeenCalledWith('Auth resolved for request', { authType: 'member', resourceId: '12345', - user, }); }); @@ -134,7 +133,6 @@ describe('resourceIdMiddleware', () => { expect(loggerInfoMock).toHaveBeenCalledWith('Auth resolved for request', { authType: 'm2m', resourceId: 'm2m-subject', - user, }); });