feat/ai-pipeline-hardening-plan #2
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,3 +31,7 @@ dist-ssr
|
||||
pb_data/
|
||||
pocketbase.exe
|
||||
pocketbase.zip
|
||||
|
||||
# Claude Code local agent state (settings.local.json holds per-user
|
||||
# command allowlists; never share across teammates)
|
||||
.claude/
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'pb_migrations']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
@@ -17,5 +17,25 @@ export default defineConfig([
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
rules: {
|
||||
// Conventional initial-load pattern across the admin/chat surface;
|
||||
// refactoring every effect is out of scope and not currently
|
||||
// observed to cause cascading renders in practice.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/store/AppContext.jsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['vite.config.js'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
{
|
||||
files: ['src/lib/__tests__/**/*.{js,jsx}'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
])
|
||||
|
||||
365
package-lock.json
generated
365
package-lock.json
generated
@@ -14,7 +14,8 @@
|
||||
"pocketbase": "^0.26.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0"
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -29,7 +30,8 @@
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -843,6 +845,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||
@@ -1126,6 +1135,24 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1193,6 +1220,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -1233,6 +1373,16 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
@@ -1361,6 +1511,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
@@ -1878,6 +2038,13 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2073,6 +2240,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2083,6 +2260,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2825,6 +3012,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -2895,6 +3093,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3140,6 +3345,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3150,6 +3362,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
@@ -3171,6 +3397,23 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -3188,6 +3431,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3326,6 +3579,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3342,6 +3685,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -3376,7 +3736,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,7 +17,8 @@
|
||||
"pocketbase": "^0.26.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0"
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -31,6 +33,7 @@
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
|
||||
import { useApp } from './store/AppContext'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
|
||||
AlertCircle, BookOpen, ArrowLeft, Eye
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
|
||||
import LearningContentViewer from '../ui/LearningContentViewer';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, BookOpen, Loader, AlertTriangle } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getQuarterForWeek,
|
||||
getQuarterName,
|
||||
getFullCurriculum,
|
||||
hasCurriculum,
|
||||
} from '../../lib/curriculumService';
|
||||
|
||||
const QUARTER_COLORS = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
||||
import * as db from '../../lib/db';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, X, Clock, Sparkles } from 'lucide-react';
|
||||
import { kbStore } from '../../lib/kbStore';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye } from 'lucide-react';
|
||||
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
|
||||
import * as db from '../../lib/db';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const TestManager = () => {
|
||||
const [topics, setTopics] = useState([]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { UploadCloud, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { processSourceText } from '../../lib/extractionPipeline';
|
||||
import * as db from '../../lib/db';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Mark from '../ui/Mark';
|
||||
import ChatWindow from './ChatWindow';
|
||||
import { useApp } from '../../store/AppContext';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import Mark from '../ui/Mark';
|
||||
import { STRINGS } from './prompts';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Mark from '../ui/Mark';
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { useChat } from './useChat';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
const Card = ({ children, className = '', hoverable = false, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Shared component used by both the student Learn page and the Admin Content Manager.
|
||||
* Renders all four learning modes: Article, Slides, Podcast, Infographic.
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, BookOpen, Presentation,
|
||||
CheckCircle, BarChart2
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Respellion brand mark — typeset directly from BallPill Light.
|
||||
* Three states drive the middle glyph; braces always remain.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
const Tag = ({ children, variant = 'default', className = '', ...props }) => {
|
||||
const variants = {
|
||||
default: "bg-bg-warm text-fg",
|
||||
|
||||
232
src/lib/__tests__/llm.test.js
Normal file
232
src/lib/__tests__/llm.test.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
vi.mock('../storage', () => ({
|
||||
storage: {
|
||||
_data: new Map(),
|
||||
get(key, fallback = null) {
|
||||
return this._data.has(key) ? this._data.get(key) : fallback;
|
||||
},
|
||||
set(key, value) { this._data.set(key, value); },
|
||||
remove(key) { this._data.delete(key); },
|
||||
getKeysByPrefix() { return []; },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../pb', () => ({
|
||||
pb: { collection: () => ({ create: () => ({ catch: () => {} }) }) },
|
||||
}));
|
||||
|
||||
import {
|
||||
callLLM,
|
||||
LLMHttpError,
|
||||
LLMOutputError,
|
||||
LLMTruncatedError,
|
||||
LLMValidationError,
|
||||
parseStructuredText,
|
||||
resolveModel,
|
||||
} from '../llm';
|
||||
import { storage } from '../storage';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
storage._data.clear();
|
||||
});
|
||||
|
||||
describe('parseStructuredText', () => {
|
||||
it('extracts an object from raw JSON', () => {
|
||||
expect(parseStructuredText('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('extracts an object from a json-fenced block', () => {
|
||||
const fenced = '```json\n{"hello":"world"}\n```';
|
||||
expect(parseStructuredText(fenced)).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
it('extracts an object surrounded by prose', () => {
|
||||
const messy = 'Sure! Here you go:\n{"id":"x","label":"X"}\nLet me know if you want changes.';
|
||||
expect(parseStructuredText(messy)).toEqual({ id: 'x', label: 'X' });
|
||||
});
|
||||
|
||||
it('extracts an array when it is the top-level value', () => {
|
||||
expect(parseStructuredText('[1,2,3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('ignores braces inside string literals', () => {
|
||||
const tricky = '{"text":"this { is not } a brace"}';
|
||||
expect(parseStructuredText(tricky)).toEqual({ text: 'this { is not } a brace' });
|
||||
});
|
||||
|
||||
it('throws LLMOutputError when no balanced JSON is present', () => {
|
||||
expect(() => parseStructuredText('no json here, just words')).toThrow(LLMOutputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel', () => {
|
||||
it('falls back to tier defaults when no override is set', () => {
|
||||
expect(resolveModel('fast')).toBe('claude-haiku-4-5-20251001');
|
||||
expect(resolveModel('standard')).toBe('claude-sonnet-4-6');
|
||||
expect(resolveModel('reasoning')).toBe('claude-opus-4-7');
|
||||
});
|
||||
|
||||
it('honours an explicit tier override', () => {
|
||||
storage.set('admin:model:reasoning', 'claude-opus-9-future');
|
||||
expect(resolveModel('reasoning')).toBe('claude-opus-9-future');
|
||||
});
|
||||
|
||||
it('uses the legacy admin:model setting as a standard-tier fallback', () => {
|
||||
storage.set('admin:model', 'claude-some-legacy-id');
|
||||
expect(resolveModel('standard')).toBe('claude-some-legacy-id');
|
||||
});
|
||||
|
||||
it('prefers the tier-specific override over the legacy fallback', () => {
|
||||
storage.set('admin:model', 'claude-legacy');
|
||||
storage.set('admin:model:standard', 'claude-new');
|
||||
expect(resolveModel('standard')).toBe('claude-new');
|
||||
});
|
||||
});
|
||||
|
||||
function mockJsonResponse(body, { status = 200, headers = {} } = {}) {
|
||||
const h = new Headers({ 'content-type': 'application/json', ...headers });
|
||||
return new Response(JSON.stringify(body), { status, headers: h });
|
||||
}
|
||||
|
||||
describe('callLLM happy path', () => {
|
||||
it('returns parsed tool input when toolChoice forces a tool', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
mockJsonResponse({
|
||||
id: 'msg_1',
|
||||
model: 'claude-sonnet-4-6',
|
||||
stop_reason: 'tool_use',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'emit_custom_topic',
|
||||
input: { label: 'Pair Programming', type: 'process', description: 'Two engineers, one keyboard.' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await callLLM({
|
||||
task: 'learning.custom_topic',
|
||||
tier: 'standard',
|
||||
user: 'Pair programming',
|
||||
tools: [{ name: 'emit_custom_topic', description: 'x', input_schema: { type: 'object' } }],
|
||||
toolChoice: { type: 'tool', name: 'emit_custom_topic' },
|
||||
});
|
||||
|
||||
expect(result.toolUses).toHaveLength(1);
|
||||
expect(result.toolUses[0].input.label).toBe('Pair Programming');
|
||||
});
|
||||
|
||||
it('parses and validates plain text against a Zod schema', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
mockJsonResponse({
|
||||
id: 'msg_2',
|
||||
model: 'claude-sonnet-4-6',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 5, output_tokens: 7 },
|
||||
content: [{ type: 'text', text: '```json\n{"value":42}\n```' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const schema = z.object({ value: z.number() });
|
||||
const result = await callLLM({
|
||||
task: 'demo.json',
|
||||
user: 'give me a number',
|
||||
schema,
|
||||
});
|
||||
expect(result.parsed).toEqual({ value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLLM error paths', () => {
|
||||
it('throws LLMTruncatedError when stop_reason is max_tokens and a tool was requested', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
mockJsonResponse({
|
||||
stop_reason: 'max_tokens',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
content: [],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
callLLM({
|
||||
task: 'extract.source',
|
||||
user: 'x',
|
||||
tools: [{ name: 'emit_knowledge_graph', description: 'x', input_schema: { type: 'object' } }],
|
||||
toolChoice: { type: 'tool', name: 'emit_knowledge_graph' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(LLMTruncatedError);
|
||||
});
|
||||
|
||||
it('throws LLMTruncatedError when stop_reason is max_tokens and a schema was requested', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
mockJsonResponse({
|
||||
stop_reason: 'max_tokens',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
content: [{ type: 'text', text: 'partial...' }],
|
||||
}),
|
||||
);
|
||||
const schema = z.object({ value: z.number() });
|
||||
await expect(
|
||||
callLLM({ task: 'demo.json', user: 'x', schema }),
|
||||
).rejects.toBeInstanceOf(LLMTruncatedError);
|
||||
});
|
||||
|
||||
it('throws LLMValidationError when tool input fails schema validation', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
mockJsonResponse({
|
||||
stop_reason: 'tool_use',
|
||||
usage: {},
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'emit_custom_topic', input: { label: 'X', type: 'concept' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
callLLM({
|
||||
task: 'learning.custom_topic',
|
||||
user: 'x',
|
||||
tools: [{ name: 'emit_custom_topic', description: 'x', input_schema: { type: 'object' } }],
|
||||
toolChoice: { type: 'tool', name: 'emit_custom_topic' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(LLMValidationError);
|
||||
});
|
||||
|
||||
it('surfaces a non-retryable HTTP error as LLMHttpError', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ error: 'bad request' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await expect(callLLM({ task: 'demo', user: 'x' })).rejects.toBeInstanceOf(LLMHttpError);
|
||||
});
|
||||
|
||||
it('detects an auth portal HTML response and raises a clear message', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response('<html>login</html>', { status: 200, headers: { 'content-type': 'text/html' } }),
|
||||
);
|
||||
await expect(callLLM({ task: 'demo', user: 'x' })).rejects.toThrow(/session has expired/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLLM simulation mode', () => {
|
||||
it('returns the chat stub when admin:use_simulation is true and task is chat-like', async () => {
|
||||
storage.set('admin:use_simulation', true);
|
||||
const result = await callLLM({ task: 'chat.r42', user: 'hello' });
|
||||
expect(result.stopReason).toBe('end_turn');
|
||||
expect(result.text).toMatch(/Simulatiemodus/);
|
||||
});
|
||||
|
||||
it('returns the extraction stub for other tasks in simulation mode', async () => {
|
||||
storage.set('admin:use_simulation', true);
|
||||
const result = await callLLM({ task: 'extract.source', user: 'doc' });
|
||||
expect(() => JSON.parse(result.text)).not.toThrow();
|
||||
expect(JSON.parse(result.text)).toHaveProperty('topics');
|
||||
});
|
||||
});
|
||||
197
src/lib/__tests__/llmSchemas.test.js
Normal file
197
src/lib/__tests__/llmSchemas.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractionResultSchema,
|
||||
handbookResultSchema,
|
||||
normalizeHandbookResult,
|
||||
learningArticleSchema,
|
||||
learningSlidesSchema,
|
||||
learningInfographicSchema,
|
||||
learningAllSchema,
|
||||
quizQuestionsSchema,
|
||||
customTopicSchema,
|
||||
graphActionsSchema,
|
||||
proposeGraphDeltaSchema,
|
||||
} from '../llmSchemas';
|
||||
|
||||
const sampleTopic = {
|
||||
id: 'software-engineer',
|
||||
label: 'Software Engineer',
|
||||
type: 'role',
|
||||
description: 'Builds and maintains the platform.',
|
||||
learning_relevance: 'core',
|
||||
};
|
||||
|
||||
const sampleRelation = {
|
||||
source: 'software-engineer',
|
||||
target: 'onboarding',
|
||||
type: 'part_of',
|
||||
};
|
||||
|
||||
const sampleArticle = {
|
||||
title: 'Onboarding 101',
|
||||
intro: 'A short intro.',
|
||||
sections: [{ heading: 'Day one', body: 'Welcome to the team.' }],
|
||||
keyTakeaways: ['Show up', 'Ask questions'],
|
||||
};
|
||||
|
||||
const sampleSlide = {
|
||||
title: 'Welcome',
|
||||
bullets: ['Meet your buddy', 'Read the handbook'],
|
||||
speakerNote: 'Greet new joiners warmly.',
|
||||
};
|
||||
|
||||
const sampleInfographic = {
|
||||
headline: 'Onboarding flow',
|
||||
tagline: 'From hire to productive in 30 days',
|
||||
stats: [{ value: '30', label: 'days', icon: '📅' }],
|
||||
steps: [{ number: 1, title: 'Sign in', description: 'Use the welcome email.', icon: '🔑' }],
|
||||
quote: 'A great start beats a great recovery.',
|
||||
colorTheme: 'teal',
|
||||
};
|
||||
|
||||
describe('extractionResultSchema', () => {
|
||||
it('accepts a minimal extraction result', () => {
|
||||
const parsed = extractionResultSchema.parse({
|
||||
topics: [sampleTopic],
|
||||
relations: [sampleRelation],
|
||||
});
|
||||
expect(parsed.topics).toHaveLength(1);
|
||||
expect(parsed.relations[0].type).toBe('part_of');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handbookResultSchema', () => {
|
||||
it('accepts the loose vocabulary including executes', () => {
|
||||
const parsed = handbookResultSchema.parse({
|
||||
topics: [{ ...sampleTopic, metadata: { source: 'github_handbook' } }],
|
||||
relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }],
|
||||
});
|
||||
expect(parsed.relations[0].type).toBe('executes');
|
||||
});
|
||||
|
||||
it('normalises executes into executed_by with swapped source/target', () => {
|
||||
const parsed = handbookResultSchema.parse({
|
||||
topics: [sampleTopic],
|
||||
relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }],
|
||||
});
|
||||
const normalised = normalizeHandbookResult(parsed);
|
||||
expect(normalised.relations[0]).toMatchObject({
|
||||
source: 'code-review',
|
||||
target: 'software-engineer',
|
||||
type: 'executed_by',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('learning schemas', () => {
|
||||
it('accepts an article payload', () => {
|
||||
expect(() => learningArticleSchema.parse({ article: sampleArticle })).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a slides payload', () => {
|
||||
expect(() => learningSlidesSchema.parse({ slides: [sampleSlide] })).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts an infographic payload', () => {
|
||||
expect(() => learningInfographicSchema.parse({ infographic: sampleInfographic })).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a combined "all" payload', () => {
|
||||
expect(() =>
|
||||
learningAllSchema.parse({
|
||||
article: sampleArticle,
|
||||
slides: [sampleSlide],
|
||||
infographic: sampleInfographic,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('quizQuestionsSchema', () => {
|
||||
it('accepts a quiz with four options and a valid correctIndex', () => {
|
||||
const parsed = quizQuestionsSchema.parse({
|
||||
questions: [
|
||||
{
|
||||
id: 'q-1',
|
||||
question: 'What is the buddy system?',
|
||||
topicLabel: 'Onboarding',
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 2,
|
||||
explanation: 'C describes the buddy system best.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.questions[0].options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('rejects three options or an out-of-range correctIndex', () => {
|
||||
expect(() =>
|
||||
quizQuestionsSchema.parse({
|
||||
questions: [
|
||||
{
|
||||
id: 'q',
|
||||
question: 'q',
|
||||
topicLabel: 't',
|
||||
options: ['A', 'B', 'C'],
|
||||
correctIndex: 0,
|
||||
explanation: 'e',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
quizQuestionsSchema.parse({
|
||||
questions: [
|
||||
{
|
||||
id: 'q',
|
||||
question: 'q',
|
||||
topicLabel: 't',
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 4,
|
||||
explanation: 'e',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customTopicSchema', () => {
|
||||
it('accepts a polished custom topic', () => {
|
||||
expect(() =>
|
||||
customTopicSchema.parse({
|
||||
label: 'Pair Programming',
|
||||
type: 'process',
|
||||
description: 'Two engineers, one keyboard.',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphActionsSchema', () => {
|
||||
it('fills missing arrays with empty defaults', () => {
|
||||
const parsed = graphActionsSchema.parse({});
|
||||
expect(parsed.merges).toEqual([]);
|
||||
expect(parsed.deletions).toEqual([]);
|
||||
expect(parsed.newRelations).toEqual([]);
|
||||
expect(parsed.relevanceUpdates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proposeGraphDeltaSchema', () => {
|
||||
it('accepts a reason-only delta', () => {
|
||||
expect(() => proposeGraphDeltaSchema.parse({ reason: 'Nothing to add.' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('caps topics at three and relations at five', () => {
|
||||
const bigTopics = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `t-${i}`,
|
||||
label: `Topic ${i}`,
|
||||
type: 'concept',
|
||||
description: 'desc',
|
||||
}));
|
||||
expect(() =>
|
||||
proposeGraphDeltaSchema.parse({ reason: 'too many', topics: bigTopics }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
150
src/lib/api.js
150
src/lib/api.js
@@ -1,135 +1,39 @@
|
||||
import { storage } from './storage';
|
||||
|
||||
/**
|
||||
* Anthropic API Service
|
||||
* Handles communication with the /v1/messages endpoint via Nginx proxy.
|
||||
* Back-compatibility shim for the legacy `anthropicApi` interface.
|
||||
*
|
||||
* All real work lives in `./llm.js`. Existing callers (extractionPipeline,
|
||||
* learningService, testService, KnowledgeGraph, useChat) keep working
|
||||
* unchanged; new code should import `callLLM` from `./llm.js` directly.
|
||||
*/
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||
import { callLLM } from './llm';
|
||||
|
||||
export const anthropicApi = {
|
||||
async generateContent(systemPrompt, userMessage, maxRetries = 1) {
|
||||
// Check if simulation mode is on
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
console.log('[API] Simulation mode active. Mock data will be returned.');
|
||||
return await simulateResponse();
|
||||
}
|
||||
|
||||
// The API key is now securely injected by the Caddy reverse proxy via environment variables.
|
||||
|
||||
// Model is configurable from Admin > Settings, defaults to the original spec model
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
console.log(`[API] Calling with model: ${model}`);
|
||||
|
||||
let retries = 0;
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
const response = await fetch('/api/anthropic/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
max_tokens: 8192,
|
||||
temperature: 0,
|
||||
async generateContent(systemPrompt, userMessage /*, maxRetries */) {
|
||||
const { text } = await callLLM({
|
||||
task: 'legacy.generateContent',
|
||||
tier: 'standard',
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }]
|
||||
})
|
||||
user: userMessage,
|
||||
maxTokens: 8192,
|
||||
temperature: 0,
|
||||
});
|
||||
return text;
|
||||
},
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
console.error('[API] Error response:', errData);
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
|
||||
}
|
||||
|
||||
// Detect auth portal session expiry: the portal returns HTML instead of JSON
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Your session has expired. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.content[0].text;
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
retries++;
|
||||
if (retries > maxRetries) throw error;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-turn chat with optional tool use.
|
||||
* Returns the raw Anthropic response so callers can read both `text` and
|
||||
* `tool_use` content blocks.
|
||||
*
|
||||
* @param {string} systemPrompt
|
||||
* @param {Array<{role: 'user'|'assistant', content: string}>} messages
|
||||
* @param {{tools?: Array}} opts
|
||||
* @returns {Promise<{content: Array, stop_reason: string}>}
|
||||
*/
|
||||
anthropicApi.chat = async function chat(systemPrompt, messages, opts = {}) {
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.',
|
||||
}],
|
||||
stop_reason: 'end_turn',
|
||||
};
|
||||
}
|
||||
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
max_tokens: 1024,
|
||||
async chat(systemPrompt, messages, opts = {}) {
|
||||
const r = await callLLM({
|
||||
task: 'legacy.chat',
|
||||
tier: 'standard',
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
};
|
||||
if (opts.tools && opts.tools.length) body.tools = opts.tools;
|
||||
|
||||
const response = await fetch('/api/anthropic/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
tools: opts.tools,
|
||||
maxTokens: 1024,
|
||||
temperature: 0.3,
|
||||
});
|
||||
const content = [];
|
||||
if (r.text) content.push({ type: 'text', text: r.text });
|
||||
for (const tu of r.toolUses) content.push({ type: 'tool_use', name: tu.name, input: tu.input });
|
||||
return { content, stop_reason: r.stopReason };
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Your session has expired. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
async function simulateResponse() {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
return JSON.stringify({
|
||||
topics: [
|
||||
{ id: "radicale-transparantie", label: "Radicale Transparantie", type: "concept", description: "De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is." },
|
||||
{ id: "kennisbeheer", label: "Kennisbeheer", type: "process", description: "Het proces van het vastleggen en ontsluiten van organisatiekennis." },
|
||||
{ id: "wekelijkse-sessie", label: "Wekelijkse Leersessie", type: "process", description: "Elke week leren medewerkers via AI-gegenereerde vragen en quizzen." }
|
||||
],
|
||||
relations: [
|
||||
{ source: "kennisbeheer", target: "radicale-transparantie", type: "depends_on" },
|
||||
{ source: "wekelijkse-sessie", target: "kennisbeheer", type: "part_of" }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,10 +184,7 @@ export async function autoGenerateCurriculum(year) {
|
||||
const weeks = [];
|
||||
const reviewWeeks = [13, 26, 39, 52];
|
||||
|
||||
// Calculate available weeks (52 total minus review weeks)
|
||||
const availableWeeks = 52 - reviewWeeks.length; // 48
|
||||
|
||||
// Distribute topics across available weeks
|
||||
// Distribute topics across the 48 non-review weeks.
|
||||
let topicIndex = 0;
|
||||
|
||||
for (let w = 1; w <= 52; w++) {
|
||||
|
||||
@@ -65,7 +65,6 @@ Return a JSON object:
|
||||
Return JSON only. No markdown blocks or other text.`;
|
||||
|
||||
export async function analyzeHandbookDelta(fileContent, filePath) {
|
||||
try {
|
||||
const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
|
||||
|
||||
let extractedData;
|
||||
@@ -75,14 +74,11 @@ export async function analyzeHandbookDelta(fileContent, filePath) {
|
||||
extractedData = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
|
||||
throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`);
|
||||
throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`, { cause: e });
|
||||
}
|
||||
|
||||
await mergeKnowledgeGraph(extractedData);
|
||||
return { success: true, data: extractedData };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function chunkText(text, maxChunkSize = 4000) {
|
||||
const paragraphs = text.split(/\n+/);
|
||||
@@ -141,7 +137,7 @@ export async function processSourceText(textContent, sourceName) {
|
||||
extractedData = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.error(`[Pipeline] AI returned non-JSON response for chunk ${i + 1}:`, responseText?.substring(0, 500));
|
||||
throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`);
|
||||
throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`, { cause: e });
|
||||
}
|
||||
|
||||
if (extractedData.topics && Array.isArray(extractedData.topics)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anthropicApi } from './api';
|
||||
import * as db from './db';
|
||||
import { getCurriculumTopic, getCurriculumYear } from './curriculumService';
|
||||
import { getCurriculumTopic } from './curriculumService';
|
||||
|
||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||
You write training material for employees based on knowledge topics.
|
||||
@@ -138,7 +138,7 @@ ${instructions}`;
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
newContent = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
} catch (e) {
|
||||
throw new Error('AI could not generate valid learning content. Please try again.');
|
||||
throw new Error('AI could not generate valid learning content. Please try again.', { cause: e });
|
||||
}
|
||||
|
||||
const mergedContent = { ...(cached || {}), ...newContent };
|
||||
@@ -165,7 +165,7 @@ Apply the refinement and return the complete updated JSON object using the same
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
} catch (e) {
|
||||
throw new Error('AI could not process the refinement. Please try a different instruction.');
|
||||
throw new Error('AI could not process the refinement. Please try a different instruction.', { cause: e });
|
||||
}
|
||||
|
||||
await db.setContent(topic.id, content);
|
||||
@@ -198,7 +198,7 @@ Return ONLY a JSON object with this structure:
|
||||
newTopic = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
newTopic.id = 'custom_' + Date.now().toString(36);
|
||||
} catch (e) {
|
||||
throw new Error('Could not process custom topic. Please try again.');
|
||||
throw new Error('Could not process custom topic. Please try again.', { cause: e });
|
||||
}
|
||||
|
||||
await db.upsertTopic(newTopic);
|
||||
|
||||
366
src/lib/llm.js
Normal file
366
src/lib/llm.js
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Single Anthropic client used by every service module.
|
||||
*
|
||||
* Centralises model selection, retry, timeout/abort, structured-output
|
||||
* parsing, schema validation, and best-effort call telemetry. Callers
|
||||
* import `callLLM` from here — they must not reach `/api/anthropic` on
|
||||
* their own.
|
||||
*/
|
||||
|
||||
import { storage } from './storage';
|
||||
import { withRetry, RetryableError, parseRetryAfter, isRetryableStatus } from './llmRetry';
|
||||
import { toolSchemaRegistry } from './llmSchemas';
|
||||
import { pb } from './pb';
|
||||
|
||||
const ANTHROPIC_URL = '/api/anthropic/v1/messages';
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
const TIER_DEFAULTS = {
|
||||
fast: 'claude-haiku-4-5-20251001',
|
||||
standard: 'claude-sonnet-4-6',
|
||||
reasoning: 'claude-opus-4-7',
|
||||
};
|
||||
|
||||
export class LLMHttpError extends Error {
|
||||
constructor(status, statusText, body) {
|
||||
super(`API Error: ${status} ${statusText} - ${typeof body === 'string' ? body : JSON.stringify(body)}`);
|
||||
this.name = 'LLMHttpError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class LLMTruncatedError extends Error {
|
||||
constructor(task) {
|
||||
super(`LLM response truncated (stop_reason: max_tokens) for task "${task}". Increase max_tokens or shorten the input.`);
|
||||
this.name = 'LLMTruncatedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LLMOutputError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'LLMOutputError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LLMValidationError extends Error {
|
||||
constructor(task, zodError) {
|
||||
super(`LLM output failed schema validation for task "${task}": ${zodError?.message ?? zodError}`);
|
||||
this.name = 'LLMValidationError';
|
||||
this.cause = zodError;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveModel(tier) {
|
||||
const key = `admin:model:${tier}`;
|
||||
const override = storage.get(key);
|
||||
if (override) return String(override).trim();
|
||||
if (tier === 'standard') {
|
||||
const legacy = storage.get('admin:model');
|
||||
if (legacy) return String(legacy).trim();
|
||||
}
|
||||
return TIER_DEFAULTS[tier] ?? TIER_DEFAULTS.standard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the outermost balanced JSON value (object or array) from arbitrary
|
||||
* model output. Strips ```json fences first. Brace-matching ignores braces
|
||||
* inside strings; escapes inside strings are skipped.
|
||||
*/
|
||||
export function parseStructuredText(raw) {
|
||||
if (typeof raw !== 'string') throw new LLMOutputError('LLM returned no text.');
|
||||
let text = raw.trim();
|
||||
text = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch !== '{' && ch !== '[') continue;
|
||||
const open = ch;
|
||||
const close = ch === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
for (let j = i; j < text.length; j++) {
|
||||
const c = text[j];
|
||||
if (inString) {
|
||||
if (c === '\\') { j++; continue; }
|
||||
if (c === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') { inString = true; continue; }
|
||||
if (c === open) depth++;
|
||||
else if (c === close) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const slice = text.slice(i, j + 1);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new LLMOutputError('No balanced JSON value found in LLM output.');
|
||||
}
|
||||
|
||||
function buildMessages({ messages, user }) {
|
||||
if (Array.isArray(messages) && messages.length) return messages;
|
||||
if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }];
|
||||
throw new Error('callLLM requires either `messages` or `user`.');
|
||||
}
|
||||
|
||||
function logLlmCall(record) {
|
||||
try {
|
||||
pb.collection('llm_calls').create(record).catch(() => {});
|
||||
} catch {
|
||||
/* collection may not exist yet — swallow */
|
||||
}
|
||||
}
|
||||
|
||||
function isChatLikeTask(task) {
|
||||
if (!task) return false;
|
||||
return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
|
||||
}
|
||||
|
||||
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
|
||||
topics: [
|
||||
{ id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
|
||||
{ id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
|
||||
{ id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', type: 'process', description: 'Elke week leren medewerkers via AI-gegenereerde vragen en quizzen.', learning_relevance: 'standard' },
|
||||
],
|
||||
relations: [
|
||||
{ source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
|
||||
{ source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
|
||||
],
|
||||
});
|
||||
|
||||
const SIMULATION_CHAT_TEXT =
|
||||
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
|
||||
|
||||
async function simulatedResponse({ task }) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
if (isChatLikeTask(task)) {
|
||||
return {
|
||||
text: SIMULATION_CHAT_TEXT,
|
||||
toolUses: [],
|
||||
stopReason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
requestId: null,
|
||||
model: 'simulation',
|
||||
durationMs: 400,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: SIMULATION_EXTRACTION_PAYLOAD,
|
||||
toolUses: [],
|
||||
stopReason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
requestId: null,
|
||||
model: 'simulation',
|
||||
durationMs: 400,
|
||||
};
|
||||
}
|
||||
|
||||
function linkSignals(userSignal, timeoutSignal) {
|
||||
const controller = new AbortController();
|
||||
const abort = (reason) => controller.abort(reason);
|
||||
if (userSignal) {
|
||||
if (userSignal.aborted) controller.abort(userSignal.reason);
|
||||
else userSignal.addEventListener('abort', () => abort(userSignal.reason), { once: true });
|
||||
}
|
||||
if (timeoutSignal) {
|
||||
if (timeoutSignal.aborted) controller.abort(timeoutSignal.reason);
|
||||
else timeoutSignal.addEventListener('abort', () => abort(timeoutSignal.reason), { once: true });
|
||||
}
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function extractToolUses(content) {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((b) => b?.type === 'tool_use')
|
||||
.map((b) => ({ name: b.name, input: b.input }));
|
||||
}
|
||||
|
||||
function extractText(content) {
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function validateToolInputs(toolUses, task, toolSchemas) {
|
||||
const registry = { ...toolSchemaRegistry, ...(toolSchemas || {}) };
|
||||
for (const tu of toolUses) {
|
||||
const schema = registry[tu.name];
|
||||
if (!schema) continue;
|
||||
const result = schema.safeParse(tu.input);
|
||||
if (!result.success) throw new LLMValidationError(`${task}:${tu.name}`, result.error);
|
||||
tu.input = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CallLLMOptions
|
||||
* @property {string} task Logging label, e.g. 'extract.source'.
|
||||
* @property {'fast'|'standard'|'reasoning'} [tier='standard']
|
||||
* @property {string|Array<{type:'text',text:string,cache_control?:{type:'ephemeral'}}>} [system]
|
||||
* @property {Array<{role:'user'|'assistant',content:any}>} [messages]
|
||||
* @property {string} [user] Shorthand for a single user message.
|
||||
* @property {Array<object>} [tools] Anthropic tool definitions.
|
||||
* @property {object} [toolChoice] e.g. { type: 'tool', name: 'emit_knowledge_graph' }.
|
||||
* @property {import('zod').ZodTypeAny} [schema] For text→JSON validation.
|
||||
* @property {Record<string, import('zod').ZodTypeAny>} [toolSchemas] Overrides for tool_use input validation.
|
||||
* @property {number} [maxTokens=4096]
|
||||
* @property {number} [temperature=0]
|
||||
* @property {AbortSignal} [signal]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {CallLLMOptions} options
|
||||
*/
|
||||
export async function callLLM(options) {
|
||||
const {
|
||||
task,
|
||||
tier = 'standard',
|
||||
system,
|
||||
messages,
|
||||
user,
|
||||
tools,
|
||||
toolChoice,
|
||||
schema,
|
||||
toolSchemas,
|
||||
maxTokens = 4096,
|
||||
temperature = 0,
|
||||
signal,
|
||||
} = options;
|
||||
if (!task) throw new Error('callLLM requires a `task` label.');
|
||||
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) return simulatedResponse({ task });
|
||||
|
||||
const model = resolveModel(tier);
|
||||
const messagesPayload = buildMessages({ messages, user });
|
||||
|
||||
const body = {
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
messages: messagesPayload,
|
||||
};
|
||||
if (system !== undefined) body.system = system;
|
||||
if (tools && tools.length) body.tools = tools;
|
||||
if (toolChoice) body.tool_choice = toolChoice;
|
||||
|
||||
const start = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await withRetry(
|
||||
async () => {
|
||||
const timeoutCtl = signal ? null : new AbortController();
|
||||
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
|
||||
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
|
||||
|
||||
try {
|
||||
const response = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: fetchSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
if (isRetryableStatus(response.status)) {
|
||||
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||||
throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
|
||||
}
|
||||
throw new LLMHttpError(response.status, response.statusText, errBody);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Your session has expired. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (err) {
|
||||
logLlmCall({
|
||||
task,
|
||||
model,
|
||||
tier,
|
||||
duration_ms: Date.now() - start,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_create_tokens: 0,
|
||||
stop_reason: '',
|
||||
ok: false,
|
||||
error_msg: String(err?.message ?? err).slice(0, 500),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const stopReason = result.stop_reason || '';
|
||||
const toolUses = extractToolUses(result.content);
|
||||
const text = extractText(result.content);
|
||||
const usage = result.usage || {};
|
||||
|
||||
const truncationRequiresFailure =
|
||||
stopReason === 'max_tokens' && (Boolean(schema) || Boolean(toolChoice));
|
||||
|
||||
logLlmCall({
|
||||
task,
|
||||
model,
|
||||
tier,
|
||||
duration_ms: Date.now() - start,
|
||||
input_tokens: usage.input_tokens ?? 0,
|
||||
output_tokens: usage.output_tokens ?? 0,
|
||||
cache_read_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
cache_create_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
stop_reason: stopReason,
|
||||
ok: !truncationRequiresFailure,
|
||||
error_msg: truncationRequiresFailure ? 'max_tokens' : '',
|
||||
});
|
||||
|
||||
if (truncationRequiresFailure) throw new LLMTruncatedError(task);
|
||||
|
||||
if (toolUses.length) validateToolInputs(toolUses, task, toolSchemas);
|
||||
|
||||
let parsedFromText;
|
||||
if (schema && !toolUses.length) {
|
||||
const value = parseStructuredText(text);
|
||||
const parsed = schema.safeParse(value);
|
||||
if (!parsed.success) throw new LLMValidationError(task, parsed.error);
|
||||
parsedFromText = parsed.data;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
toolUses,
|
||||
stopReason,
|
||||
usage: {
|
||||
input_tokens: usage.input_tokens ?? 0,
|
||||
output_tokens: usage.output_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
},
|
||||
requestId: result.id ?? null,
|
||||
model: result.model ?? model,
|
||||
durationMs: Date.now() - start,
|
||||
parsed: parsedFromText,
|
||||
};
|
||||
}
|
||||
96
src/lib/llmRetry.js
Normal file
96
src/lib/llmRetry.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Retry policy for LLM calls.
|
||||
*
|
||||
* Exponential backoff with full jitter, base 1000ms, cap 16000ms. Only
|
||||
* retries on transient HTTP statuses (408, 425, 429, 5xx, 529). Honours a
|
||||
* `Retry-After` hint up to 60 seconds; longer waits fail fast. Never retries
|
||||
* on AbortError.
|
||||
*/
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const CAP_DELAY_MS = 16000;
|
||||
const MAX_RETRY_AFTER_MS = 60 * 1000;
|
||||
|
||||
export class RetryableError extends Error {
|
||||
constructor(status, retryAfterMs = null, message) {
|
||||
super(message || `Retryable HTTP ${status}`);
|
||||
this.name = 'RetryableError';
|
||||
this.status = status;
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRetryableStatus(status) {
|
||||
return RETRYABLE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function backoffWithJitter(attempt) {
|
||||
const exp = Math.min(CAP_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
||||
return Math.floor(Math.random() * exp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `Retry-After` header. Returns null when absent or unusable, or
|
||||
* a millisecond delay otherwise. Supports both seconds and HTTP-date forms.
|
||||
*/
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (/^\d+$/.test(s)) return Number(s) * 1000;
|
||||
const dateMs = Date.parse(s);
|
||||
if (Number.isFinite(dateMs)) {
|
||||
const delta = dateMs - now;
|
||||
return delta > 0 ? delta : 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
const t = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(t);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
|
||||
* a retry, or any other error to fail immediately.
|
||||
*
|
||||
* @template T
|
||||
* @param {(attempt:number) => Promise<T>} fn
|
||||
* @param {{ maxRetries?: number, signal?: AbortSignal }} [opts]
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function withRetry(fn, { maxRetries = 4, signal } = {}) {
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw err;
|
||||
const retryable = err instanceof RetryableError;
|
||||
if (!retryable || attempt >= maxRetries) throw err;
|
||||
|
||||
let delayMs;
|
||||
if (err.retryAfterMs != null) {
|
||||
if (err.retryAfterMs > MAX_RETRY_AFTER_MS) throw err;
|
||||
delayMs = err.retryAfterMs;
|
||||
} else {
|
||||
delayMs = backoffWithJitter(attempt);
|
||||
}
|
||||
await sleep(delayMs, signal);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/lib/llmSchemas.js
Normal file
202
src/lib/llmSchemas.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Zod schemas for every structured LLM output the platform consumes.
|
||||
*
|
||||
* Field names mirror what callers already produce — do not rename them
|
||||
* without migrating the corresponding service module.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const topicTypeEnum = z.enum(['concept', 'role', 'process']);
|
||||
const relationTypeStrict = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by']);
|
||||
const relationTypeLoose = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by', 'executes']);
|
||||
const learningRelevanceEnum = z.enum(['core', 'standard', 'peripheral', 'exclude']);
|
||||
|
||||
const extractionTopicSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
type: topicTypeEnum,
|
||||
description: z.string().min(1),
|
||||
learning_relevance: learningRelevanceEnum,
|
||||
});
|
||||
|
||||
const extractionRelationSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
target: z.string().min(1),
|
||||
type: relationTypeStrict,
|
||||
});
|
||||
|
||||
export const extractionResultSchema = z.object({
|
||||
topics: z.array(extractionTopicSchema),
|
||||
relations: z.array(extractionRelationSchema),
|
||||
});
|
||||
|
||||
const handbookTopicSchema = extractionTopicSchema.extend({
|
||||
metadata: z.object({ source: z.string() }).optional(),
|
||||
});
|
||||
|
||||
const handbookRelationSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
target: z.string().min(1),
|
||||
type: relationTypeLoose,
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const handbookResultSchema = z.object({
|
||||
topics: z.array(handbookTopicSchema),
|
||||
relations: z.array(handbookRelationSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalise legacy `executes` relations into the canonical `executed_by`
|
||||
* vocabulary by swapping source and target. The handbook prompt previously
|
||||
* emitted `role → executes → process`; the canonical form is
|
||||
* `process → executed_by → role`.
|
||||
*/
|
||||
export function normalizeHandbookResult(parsed) {
|
||||
return {
|
||||
...parsed,
|
||||
relations: parsed.relations.map((r) =>
|
||||
r.type === 'executes'
|
||||
? { ...r, type: 'executed_by', source: r.target, target: r.source }
|
||||
: r,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const articleSectionSchema = z.object({
|
||||
heading: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
});
|
||||
|
||||
const articleBodySchema = z.object({
|
||||
title: z.string().min(1),
|
||||
intro: z.string().min(1),
|
||||
sections: z.array(articleSectionSchema).min(1),
|
||||
keyTakeaways: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
export const learningArticleSchema = z.object({
|
||||
article: articleBodySchema,
|
||||
});
|
||||
|
||||
const slideSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
bullets: z.array(z.string().min(1)).min(1),
|
||||
speakerNote: z.string().min(1),
|
||||
});
|
||||
|
||||
export const learningSlidesSchema = z.object({
|
||||
slides: z.array(slideSchema).min(1),
|
||||
});
|
||||
|
||||
const infographicStatSchema = z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
});
|
||||
|
||||
const infographicStepSchema = z.object({
|
||||
number: z.number().int().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
});
|
||||
|
||||
const infographicBodySchema = z.object({
|
||||
headline: z.string().min(1),
|
||||
tagline: z.string().min(1),
|
||||
stats: z.array(infographicStatSchema).min(1),
|
||||
steps: z.array(infographicStepSchema).min(1),
|
||||
quote: z.string().min(1),
|
||||
colorTheme: z.string().min(1),
|
||||
});
|
||||
|
||||
export const learningInfographicSchema = z.object({
|
||||
infographic: infographicBodySchema,
|
||||
});
|
||||
|
||||
export const learningAllSchema = z.object({
|
||||
article: articleBodySchema,
|
||||
slides: z.array(slideSchema).min(1),
|
||||
infographic: infographicBodySchema,
|
||||
});
|
||||
|
||||
const quizQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
question: z.string().min(1),
|
||||
topicLabel: z.string().min(1),
|
||||
options: z.array(z.string().min(1)).length(4),
|
||||
correctIndex: z.number().int().min(0).max(3),
|
||||
explanation: z.string().min(1),
|
||||
});
|
||||
|
||||
export const quizQuestionsSchema = z.object({
|
||||
questions: z.array(quizQuestionSchema).min(1),
|
||||
});
|
||||
|
||||
export const customTopicSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
type: topicTypeEnum,
|
||||
description: z.string().min(1),
|
||||
});
|
||||
|
||||
const mergeActionSchema = z.object({
|
||||
keepId: z.string().min(1),
|
||||
deleteId: z.string().min(1),
|
||||
});
|
||||
|
||||
const newRelationSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
target: z.string().min(1),
|
||||
type: relationTypeStrict,
|
||||
});
|
||||
|
||||
const relevanceUpdateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
learning_relevance: learningRelevanceEnum,
|
||||
});
|
||||
|
||||
export const graphActionsSchema = z.object({
|
||||
merges: z.array(mergeActionSchema).optional().default([]),
|
||||
deletions: z.array(z.string().min(1)).optional().default([]),
|
||||
newRelations: z.array(newRelationSchema).optional().default([]),
|
||||
relevanceUpdates: z.array(relevanceUpdateSchema).optional().default([]),
|
||||
});
|
||||
|
||||
const deltaTopicSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
type: topicTypeEnum,
|
||||
description: z.string().min(1),
|
||||
});
|
||||
|
||||
const deltaRelationSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
target: z.string().min(1),
|
||||
type: relationTypeStrict,
|
||||
});
|
||||
|
||||
export const proposeGraphDeltaSchema = z.object({
|
||||
reason: z.string().min(1),
|
||||
topics: z.array(deltaTopicSchema).max(3).optional(),
|
||||
relations: z.array(deltaRelationSchema).max(5).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||
* override.
|
||||
*/
|
||||
export const toolSchemaRegistry = {
|
||||
emit_knowledge_graph: extractionResultSchema,
|
||||
emit_handbook_delta: handbookResultSchema,
|
||||
emit_learning_article: learningArticleSchema,
|
||||
emit_learning_slides: learningSlidesSchema,
|
||||
emit_learning_infographic: learningInfographicSchema,
|
||||
emit_learning_all: learningAllSchema,
|
||||
emit_quiz_questions: quizQuestionsSchema,
|
||||
emit_custom_topic: customTopicSchema,
|
||||
emit_graph_actions: graphActionsSchema,
|
||||
propose_graph_delta: proposeGraphDeltaSchema,
|
||||
};
|
||||
@@ -93,7 +93,7 @@ Rules:
|
||||
- Make questions specific and practical, not trivial.`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
|
||||
let newQuestions = [];
|
||||
let newQuestions;
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
@@ -103,7 +103,7 @@ Rules:
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to generate questions for topic', topic.label, e);
|
||||
throw new Error(`Could not generate questions for ${topic.label}`);
|
||||
throw new Error(`Could not generate questions for ${topic.label}`, { cause: e });
|
||||
}
|
||||
|
||||
bank = [...bank, ...newQuestions];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Tag from '../../components/ui/Tag';
|
||||
@@ -14,10 +14,18 @@ import TeamManager from '../../components/admin/TeamManager';
|
||||
import CurriculumManager from '../../components/admin/CurriculumManager';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
const TIER_PLACEHOLDERS = {
|
||||
fast: 'claude-haiku-4-5-20251001',
|
||||
standard: 'claude-sonnet-4-6',
|
||||
reasoning: 'claude-opus-4-7',
|
||||
};
|
||||
|
||||
const Admin = () => {
|
||||
const [activeTab, setActiveTab] = useState('sources');
|
||||
const [sources, setSources] = useState([]);
|
||||
const [model, setModel] = useState('');
|
||||
const [modelFast, setModelFast] = useState('');
|
||||
const [modelStandard, setModelStandard] = useState('');
|
||||
const [modelReasoning, setModelReasoning] = useState('');
|
||||
const [useSimulation, setUseSimulation] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
|
||||
@@ -31,14 +39,19 @@ const Admin = () => {
|
||||
loadSources();
|
||||
}
|
||||
if (activeTab === 'settings') {
|
||||
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
|
||||
const legacyStandard = storage.get('admin:model', '');
|
||||
setModelFast(storage.get('admin:model:fast', '') || '');
|
||||
setModelStandard(storage.get('admin:model:standard', '') || legacyStandard || '');
|
||||
setModelReasoning(storage.get('admin:model:reasoning', '') || '');
|
||||
setUseSimulation(storage.get('admin:use_simulation', false));
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const saveSettings = async (e) => {
|
||||
e.preventDefault();
|
||||
storage.set('admin:model', model.trim());
|
||||
storage.set('admin:model:fast', modelFast.trim());
|
||||
storage.set('admin:model:standard', modelStandard.trim());
|
||||
storage.set('admin:model:reasoning', modelReasoning.trim());
|
||||
storage.set('admin:use_simulation', useSimulation);
|
||||
setSaveStatus('Saved!');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
@@ -177,14 +190,37 @@ const Admin = () => {
|
||||
<form onSubmit={saveSettings} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">AI Configuration</h3>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Models are split into three tiers. Leave a field blank to use its default. Check your Anthropic Console for available model IDs.
|
||||
</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
label="Model ID"
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
label="Fast tier (short, cheap calls)"
|
||||
placeholder={TIER_PLACEHOLDERS.fast}
|
||||
value={modelFast}
|
||||
onChange={(e) => setModelFast(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Leave blank for the default. Check your Anthropic Console for available models.</p>
|
||||
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.fast}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Standard tier (most workloads)"
|
||||
placeholder={TIER_PLACEHOLDERS.standard}
|
||||
value={modelStandard}
|
||||
onChange={(e) => setModelStandard(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.standard}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Reasoning tier (complex graph + content tasks)"
|
||||
placeholder={TIER_PLACEHOLDERS.reasoning}
|
||||
value={modelReasoning}
|
||||
onChange={(e) => setModelReasoning(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Default: {TIER_PLACEHOLDERS.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted mt-4">
|
||||
Note: Your Anthropic API key is securely managed on the server side via environment variables.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import Input from '../components/ui/Input';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
|
||||
import * as db from '../lib/db';
|
||||
|
||||
@@ -27,7 +26,7 @@ const Leren = () => {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Custom Topic
|
||||
const [customTopicQuery, setCustomTopicQuery] = useState('');
|
||||
const [customTopicQuery] = useState('');
|
||||
|
||||
// Weekly status
|
||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||
@@ -107,26 +106,6 @@ const Leren = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCustom = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!customTopicQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setView('creating');
|
||||
setError(null);
|
||||
try {
|
||||
const newTopic = await generateCustomTopic(customTopicQuery);
|
||||
setAllTopics(await db.getTopics());
|
||||
setCustomTopicQuery('');
|
||||
handleOpenTopic(newTopic);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setView('overview');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doComplete = async () => {
|
||||
setSessionDone(true);
|
||||
if (!weeklyDone) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
CheckSquare, Loader, AlertCircle, Trophy, ArrowRight,
|
||||
Clock, CheckCircle, XCircle, BarChart2
|
||||
Clock, CheckCircle, XCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -9,7 +9,7 @@ import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
import { generateWeeklyQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const TIMER_SECONDS = 300; // 5 minutes
|
||||
@@ -61,13 +61,15 @@ const Testen = () => {
|
||||
}, [currentUser, weekNumber]);
|
||||
|
||||
// ── Timer ──
|
||||
const finishQuizRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'quiz') {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
finishQuiz();
|
||||
finishQuizRef.current?.();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
@@ -164,6 +166,10 @@ const Testen = () => {
|
||||
setPhase('results');
|
||||
}, [quiz, answers, timeLeft, currentUser, weekNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
finishQuizRef.current = finishQuiz;
|
||||
}, [finishQuiz]);
|
||||
|
||||
// ─── Intro / Start screen ────────────────────────────────
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import * as db from '../lib/db';
|
||||
|
||||
const AppContext = createContext();
|
||||
|
||||
Reference in New Issue
Block a user