From 58d17fc39fbad38b976880bf920b031b5540dfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Mon, 2 Feb 2026 11:22:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E6=9B=B4=E6=96=B0=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/commands/myspec.split-spec.md | 4 +- client/package.json | 6 +- client/pnpm-lock.yaml | 833 ++++++++++++++++++ client/src/constants/env.ts | 4 +- .../__tests__/scoring.test.ts | 70 ++ .../src/features/userProfileScoring/index.ts | 15 + .../features/userProfileScoring/scoring.ts | 233 +++++ .../src/features/userProfileScoring/types.ts | 95 ++ client/src/i18n/index.ts | 30 +- .../modules/content-repository/spec.md | 102 +++ .../modules/db-design/plan.md | 217 +++++ .../modules/db-design/spec.md | 91 ++ .../modules/db-design/tasks.md | 99 +++ .../modules/integration-api-worker/spec.md | 59 ++ .../modules/observability/spec.md | 45 + .../modules/reco-engine/spec.md | 81 ++ .../modules/rerank-freqcap/spec.md | 87 ++ .../Personalized Reco/modules/scoring/spec.md | 97 ++ spec_kit/Personalized Reco/overview.md | 107 +++ spec_kit/Personalized Reco/spec.md | 287 ++++++ spec_kit/User Profile Scoring/plan.md | 184 ++++ spec_kit/User Profile Scoring/spec.md | 326 ++++++- spec_kit/User Profile Scoring/tasks.md | 143 +++ spec_kit/overview.md | 7 +- 设计说明文档/個性化推薦算法規則.md | 45 +- 设计说明文档/句子文案打分規則.md | 6 +- 设计说明文档/客戶端問卷打分規則.md | 75 +- 27 files changed, 3286 insertions(+), 62 deletions(-) create mode 100644 client/src/features/userProfileScoring/__tests__/scoring.test.ts create mode 100644 client/src/features/userProfileScoring/index.ts create mode 100644 client/src/features/userProfileScoring/scoring.ts create mode 100644 client/src/features/userProfileScoring/types.ts create mode 100644 spec_kit/Personalized Reco/modules/content-repository/spec.md create mode 100644 spec_kit/Personalized Reco/modules/db-design/plan.md create mode 100644 spec_kit/Personalized Reco/modules/db-design/spec.md create mode 100644 spec_kit/Personalized Reco/modules/db-design/tasks.md create mode 100644 spec_kit/Personalized Reco/modules/integration-api-worker/spec.md create mode 100644 spec_kit/Personalized Reco/modules/observability/spec.md create mode 100644 spec_kit/Personalized Reco/modules/reco-engine/spec.md create mode 100644 spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md create mode 100644 spec_kit/Personalized Reco/modules/scoring/spec.md create mode 100644 spec_kit/Personalized Reco/overview.md create mode 100644 spec_kit/Personalized Reco/spec.md create mode 100644 spec_kit/User Profile Scoring/plan.md create mode 100644 spec_kit/User Profile Scoring/tasks.md diff --git a/.cursor/commands/myspec.split-spec.md b/.cursor/commands/myspec.split-spec.md index dedd3f1..6487951 100644 --- a/.cursor/commands/myspec.split-spec.md +++ b/.cursor/commands/myspec.split-spec.md @@ -9,6 +9,6 @@ - 输入/输出定义 - 验收标准(可验证) 3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。 -4. 保留大 spec.md 的高层背景/总览到 overview 部分。 +4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序。 5. 子模块之间按逻辑关系关联。 -6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。 \ No newline at end of file +6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。 diff --git a/client/package.json b/client/package.json index a457e0e..88935a4 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,8 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web", + "test": "vitest run" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -41,7 +42,8 @@ "@babel/plugin-transform-react-jsx": "^7.28.6", "@types/react": "~19.1.0", "react-test-renderer": "19.1.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^4.0.18" }, "private": true } diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 8cb052d..e3a7695 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: i18next: specifier: ^25.8.0 version: 25.8.0(typescript@5.9.3) + pnpm: + specifier: ^10.28.2 + version: 10.28.2 react: specifier: 19.1.0 version: 19.1.0 @@ -99,6 +102,9 @@ importers: typescript: specifier: ~5.9.2 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2) packages: @@ -613,6 +619,162 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@expo/cli@54.0.22': resolution: {integrity: sha512-BTH2FCczhJLfj1cpfcKrzhKnvRLTOztgW4bVloKDqH+G3ZSohWLRFNAIz56XtdjPxBbi2/qWhGBAkl7kBon/Jw==} hasBin: true @@ -1141,6 +1303,131 @@ packages: '@react-navigation/routers@7.5.3': resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1150,6 +1437,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -1240,6 +1530,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -1278,6 +1577,35 @@ packages: peerDependencies: '@urql/core': ^5.0.0 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -1352,6 +1680,10 @@ packages: assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} @@ -1513,6 +1845,10 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1798,10 +2134,18 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1826,6 +2170,9 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1837,6 +2184,10 @@ packages: exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expo-application@7.0.8: resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==} peerDependencies: @@ -2453,6 +2804,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -2676,6 +3030,9 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -2765,6 +3122,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2792,6 +3152,11 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pnpm@10.28.2: + resolution: {integrity: sha512-QYcvA3rSL3NI47Heu69+hnz9RI8nJtnPdMCPGVB8MdLI56EVJbmD/rwt9kC1Q43uYCPrsfhO1DzC1lTSvDJiZA==} + engines: {node: '>=18.12'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2803,6 +3168,10 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -3065,6 +3434,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -3145,6 +3519,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3194,6 +3571,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -3209,6 +3589,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -3303,10 +3686,21 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3440,6 +3834,80 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + 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/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -3482,6 +3950,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wonka@6.3.5: resolution: {integrity: sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==} @@ -4189,6 +4662,84 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + '@expo/cli@54.0.22(expo-router@6.0.22)(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))': dependencies: '@0no-co/graphql.web': 1.2.0 @@ -4970,6 +5521,81 @@ snapshots: dependencies: nanoid: 3.3.11 + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -4980,6 +5606,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -5082,6 +5710,15 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 25.1.0 @@ -5126,6 +5763,45 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.5 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@xmldom/xmldom@0.8.11': {} abort-controller@3.0.0: @@ -5190,6 +5866,8 @@ snapshots: object.assign: 4.1.7 util: 0.12.5 + assertion-error@2.0.1: {} + async-limiter@1.0.1: {} available-typed-arrays@1.0.7: @@ -5410,6 +6088,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -5689,10 +6369,41 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -5705,12 +6416,18 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} event-target-shim@5.0.1: {} exec-async@2.2.0: {} + expect-type@1.3.0: {} + expo-application@7.0.8(expo@54.0.32): dependencies: expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -6365,6 +7082,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -6673,6 +7394,8 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 + obug@2.1.1: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -6759,6 +7482,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6777,6 +7502,8 @@ snapshots: pngjs@3.4.0: {} + pnpm@10.28.2: {} + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -6787,6 +7514,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + pretty-bytes@5.6.0: {} pretty-format@29.7.0: @@ -7100,6 +7833,37 @@ snapshots: dependencies: glob: 7.2.3 + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + rtl-detect@1.1.2: {} safe-buffer@5.2.1: {} @@ -7178,6 +7942,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} simple-plist@1.3.1: @@ -7220,6 +7986,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -7230,6 +7998,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + stream-buffers@2.2.0: {} strict-uri-encode@2.0.0: {} @@ -7333,11 +8103,17 @@ snapshots: throat@5.0.0: {} + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -7437,6 +8213,58 @@ snapshots: - '@types/react' - '@types/react-dom' + vite@7.3.1(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.1.0 + fsevents: 2.3.3 + lightningcss: 1.31.1 + terser: 5.46.0 + yaml: 2.8.2 + + vitest@4.0.18(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.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: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.1.0)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vlq@1.0.1: {} void-elements@3.1.0: {} @@ -7482,6 +8310,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wonka@6.3.5: {} wrap-ansi@7.0.0: diff --git a/client/src/constants/env.ts b/client/src/constants/env.ts index 886680a..8dc0443 100644 --- a/client/src/constants/env.ts +++ b/client/src/constants/env.ts @@ -26,8 +26,8 @@ export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL'); /** * 默认语言策略: - * - auto:优先设备语言(支持列表内时),否则回退 zh-CN - * - zh-CN/en/es/pt/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化) + * - auto:优先设备语言(支持列表内时),否则回退 en + * - en/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化) */ export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto'); diff --git a/client/src/features/userProfileScoring/__tests__/scoring.test.ts b/client/src/features/userProfileScoring/__tests__/scoring.test.ts new file mode 100644 index 0000000..3f5284a --- /dev/null +++ b/client/src/features/userProfileScoring/__tests__/scoring.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildUserProfileFromQuestionnaire, + computeProfileConfidence, + computeTimeConfidence, + normalizeAnswers, +} from '../index'; + +describe('userProfileScoring V1.2', () => { + it('normalizeAnswers: 非法值按跳过处理', () => { + // @ts-expect-error: 模拟非法输入 + const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' }); + expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined }); + }); + + it('computeTimeConfidence: 分段衰减', () => { + const gen = new Date('2026-01-01T00:00:00Z'); + + // 0–7 天:1.0 + expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0); + + // 30 天以上:0.5 + expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5); + }); + + it('computeProfileConfidence: 完整度因子 + clamp', () => { + const confTime = 1.0; + + // 全部跳过:completion=0 → completionFactor=0.5 → 0.5 + expect( + computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false }) + ).toBe(0.5); + + // 全部作答:completion=1 → completionFactor=1 → 1 + expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0); + }); + + it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => { + const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }); + + expect(p.profile_version).toBe('v1.2'); + expect(p.profile_source).toBe('questionnaire'); + + expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false }); + expect(p.stage).toEqual({ unknown: 1 }); + expect(p.emotion_score).toBeNull(); + expect(p.context).toEqual({}); + expect(p.need).toEqual({}); + + // conf_time=1,completionFactor=0.5 + expect(p.profile_confidence).toBe(0.5); + + // unknown 会命中 unsafe_for_stage_unknown,并带跨维度谓词 + expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown'); + expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe( + true + ); + }); + + it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => { + const p = buildUserProfileFromQuestionnaire( + { mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' }, + { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' } + ); + expect(p.emotion_score).toBe(0.2); + expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low'); + }); +}); + diff --git a/client/src/features/userProfileScoring/index.ts b/client/src/features/userProfileScoring/index.ts new file mode 100644 index 0000000..dfa086c --- /dev/null +++ b/client/src/features/userProfileScoring/index.ts @@ -0,0 +1,15 @@ +export type { + BuildUserProfileOptions, + QuestionnaireAnswersV1_2, + UserProfileV1_2, + UserProfileV1_2_Extended, +} from './types'; + +export { + buildUserProfileFromQuestionnaire, + computeProfileAnswered, + computeProfileConfidence, + computeTimeConfidence, + normalizeAnswers, +} from './scoring'; + diff --git a/client/src/features/userProfileScoring/scoring.ts b/client/src/features/userProfileScoring/scoring.ts new file mode 100644 index 0000000..f3360a5 --- /dev/null +++ b/client/src/features/userProfileScoring/scoring.ts @@ -0,0 +1,233 @@ +/** + * 用户画像打分(User Profile Scoring)V1.2 + * + * 规则来源: + * - `spec_kit/User Profile Scoring/spec.md` + * - `设计说明文档/客戶端問卷打分規則.md`(V1.2) + */ + +import type { + BuildUserProfileOptions, + ContextAnswer, + EmotionAnswer, + HardRules, + MomStageAnswer, + NeedAnswer, + ProfileAnswered, + QuestionnaireAnswersV1_2, + SparseOneHot, + UserProfileV1_2_Extended, + UserStageOneHot, +} from './types'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function toDate(value: Date | string | undefined): Date | null { + if (!value) return null; + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null; + const d = new Date(value); + return Number.isFinite(d.getTime()) ? d : null; +} + +function isMomStageAnswer(v: unknown): v is MomStageAnswer { + return v === 'expecting' || v === 'parenting' || v === 'unknown'; +} + +function isEmotionAnswer(v: unknown): v is EmotionAnswer { + return ( + v === 'low' || + v === 'overwhelmed' || + v === 'tired' || + v === 'neutral' || + v === 'calm' || + v === 'joyful' + ); +} + +function isContextAnswer(v: unknown): v is ContextAnswer { + return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health'; +} + +function isNeedAnswer(v: unknown): v is NeedAnswer { + return ( + v === 'emotional_support' || + v === 'parenting_pressure' || + v === 'self_worth' || + v === 'anxiety_relief' || + v === 'rest_balance' + ); +} + +/** + * 归一化答案:非法值按“跳过”处理(归一化为 undefined) + * - `null` 保留,表示显式跳过/无值 + */ +export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 { + const mom_stage = + raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined; + const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined; + const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined; + const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined; + + return { mom_stage, emotion, context, need }; +} + +export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered { + return { + stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null, + emotion: normalized.emotion !== undefined && normalized.emotion !== null, + context: normalized.context !== undefined && normalized.context !== null, + need: normalized.need !== undefined && normalized.need !== null, + }; +} + +/** + * 时间衰减置信度(conf_time) + * - 0–7 天:1.0 + * - 7–30 天:线性衰减到 0.7(含第 30 天) + * - 30 天以上:0.5 + */ +export function computeTimeConfidence(generatedAt: Date, now: Date): number { + const deltaMs = now.getTime() - generatedAt.getTime(); + if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0; + + const days = deltaMs / MS_PER_DAY; + if (days <= 7) return 1.0; + if (days <= 30) { + const t = (days - 7) / (30 - 7); // 0..1 + return 1.0 - 0.3 * t; // 1 -> 0.7 + } + return 0.5; +} + +/** + * V1.2:profile_confidence(conf_U) + * conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0) + */ +export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number { + const answeredCount = + (answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0); + const completion = answeredCount / 4; + const completionFactor = 0.5 + 0.5 * completion; + return clamp(confTime * completionFactor, 0.2, 1.0); +} + +function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot { + // V1.2:mom_stage 跳过按安全策略输出 unknown=1 + if (momStage === null || momStage === undefined) { + return { unknown: 1 }; + } + + return { + expecting: momStage === 'expecting' ? 1 : 0, + parenting: momStage === 'parenting' ? 1 : 0, + unknown: momStage === 'unknown' ? 1 : 0, + }; +} + +function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null { + if (emotion === null || emotion === undefined) return null; + switch (emotion) { + case 'low': + return 0.0; + case 'overwhelmed': + return 0.2; + case 'tired': + return 0.4; + case 'neutral': + return 0.6; + case 'calm': + return 0.8; + case 'joyful': + return 1.0; + } +} + +function buildSparseOneHot(value: string | null | undefined): SparseOneHot { + if (value === null || value === undefined) return {}; + return { [value]: 1 }; +} + +function computeRuleHitsAndHardRules(profile: { + stage: UserStageOneHot; + emotion_score: number | null; +}): { rule_hits: string[]; hard_rules: HardRules } { + const rule_hits: string[] = []; + const forbidden_risk_flags: string[] = []; + + const stageUnknown = profile.stage.unknown === 1; + const stageParenting = profile.stage.parenting === 1; + + if (stageUnknown) { + rule_hits.push('unsafe_for_stage_unknown'); + forbidden_risk_flags.push('unsafe_for_stage_unknown'); + } + + if (stageParenting) { + rule_hits.push('unsafe_for_stage_parenting'); + forbidden_risk_flags.push('unsafe_for_stage_parenting'); + } + + if (profile.emotion_score !== null && profile.emotion_score <= 0.2) { + rule_hits.push('unsafe_for_emotion_low'); + forbidden_risk_flags.push('unsafe_for_emotion_low'); + } + + const forbidden_content_predicates = []; + if (stageUnknown) { + forbidden_content_predicates.push({ + id: 'unknown_block_parenting_pressure_personalized', + when_user: { stage_unknown: true }, + forbid_content: { need: 'parenting_pressure', personalization_power: 1 }, + }); + } + + return { + rule_hits, + hard_rules: { + forbidden_risk_flags, + forbidden_content_predicates, + }, + }; +} + +export function buildUserProfileFromQuestionnaire( + rawAnswers: QuestionnaireAnswersV1_2, + options: BuildUserProfileOptions = {} +): UserProfileV1_2_Extended { + const normalized = normalizeAnswers(rawAnswers); + const profile_answered = computeProfileAnswered(normalized); + + const now = toDate(options.now) ?? new Date(); + const generatedAt = toDate(options.generatedAt) ?? now; + + const confTime = computeTimeConfidence(generatedAt, now); + const profile_confidence = computeProfileConfidence(confTime, profile_answered); + + const stage = buildStageOneHot(normalized.mom_stage); + const emotion_score = mapEmotionScore(normalized.emotion); + const context = buildSparseOneHot(normalized.context); + const need = buildSparseOneHot(normalized.need); + + const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score }); + + return { + profile_version: 'v1.2', + profile_source: 'questionnaire', + profile_generated_at: generatedAt.toISOString(), + profile_confidence, + profile_answered, + stage, + emotion_score, + context, + need, + rule_hits, + hard_rules, + }; +} + diff --git a/client/src/features/userProfileScoring/types.ts b/client/src/features/userProfileScoring/types.ts new file mode 100644 index 0000000..4b2f791 --- /dev/null +++ b/client/src/features/userProfileScoring/types.ts @@ -0,0 +1,95 @@ +/** + * 用户画像打分(User Profile Scoring)V1.2 类型定义 + * + * 说明: + * - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化) + * - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)为准 + */ + +export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown'; +export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful'; +export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health'; +export type NeedAnswer = + | 'emotional_support' + | 'parenting_pressure' + | 'self_worth' + | 'anxiety_relief' + | 'rest_balance'; + +/** + * V1.2:每题可跳过 + * - `undefined`:字段缺失(可能是“没传”) + * - `null`:显式跳过/无值(例如 UI 明确传 null) + */ +export type QuestionnaireAnswersV1_2 = { + mom_stage?: MomStageAnswer | null; + emotion?: EmotionAnswer | null; + context?: ContextAnswer | null; + need?: NeedAnswer | null; +}; + +export type ProfileAnswered = { + stage: boolean; + emotion: boolean; + context: boolean; + need: boolean; +}; + +export type UserStageOneHot = { + expecting?: 0 | 1; + parenting?: 0 | 1; + unknown: 0 | 1; +}; + +export type SparseOneHot = Record; + +export type UserProfileV1_2 = { + profile_version: 'v1.2'; + profile_source: 'questionnaire'; + profile_generated_at: string; // ISO8601 + profile_confidence: number; // 0–1 + profile_answered: ProfileAnswered; + stage: UserStageOneHot; + emotion_score: number | null; + context: SparseOneHot; + need: SparseOneHot; +}; + +export type ForbiddenContentPredicate = { + /** + * 谓词 ID:用于可观测与回归测试 + */ + id: string; + /** + * 触发条件(用户侧) + * 说明:这里刻意保持为 object,便于未来接入规则引擎时做 schema 对齐。 + */ + when_user: Record; + /** + * 禁推条件(内容侧) + * 说明:本模块不判断内容的 `personalization_power`,只输出可执行条件。 + */ + forbid_content: Record; +}; + +export type HardRules = { + forbidden_risk_flags: string[]; + forbidden_content_predicates: ForbiddenContentPredicate[]; +}; + +export type UserProfileV1_2_Extended = UserProfileV1_2 & { + rule_hits: string[]; + hard_rules: HardRules; +}; + +export type BuildUserProfileOptions = { + /** + * 画像生成时间;不传则使用当前时间 + */ + generatedAt?: Date | string; + /** + * 当前时间(用于计算 time decay);不传则使用当前时间 + */ + now?: Date | string; +}; + diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index deca94c..f32fabd 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -4,30 +4,21 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import en from './locales/en.json'; -import es from './locales/es.json'; -import pt from './locales/pt.json'; -import zhCN from './locales/zh-CN.json'; import zhTW from './locales/zh-TW.json'; /** * 语言码约定: - * - 简体中文:zh-CN * - 繁体中文:zh-TW * - 英语:en - * - 西班牙语:es - * - 葡萄牙语:pt */ -export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt'; +export type AppLanguage = 'zh-TW' | 'en'; export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [ - 'zh-CN', 'zh-TW', 'en', - 'es', - 'pt', ] as const; -const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN'; +const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en'; const STORAGE_KEY_LANGUAGE = 'settings.language'; function isSupportedLanguage(lang: string): lang is AppLanguage { @@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage { function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage { const tag = languageTag.toLowerCase(); - // 中文:优先区分繁简 + // 中文:当前仅支持繁体中文(zh-TW) if (tag.startsWith('zh')) { - // 常见繁体标记:zh-TW / zh-HK / zh-Hant - if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) { - return 'zh-TW'; - } - return 'zh-CN'; + return 'zh-TW'; } - // 其他语言:按前缀匹配 + // 其他语言:按前缀匹配(当前仅支持英文) if (tag.startsWith('en')) return 'en'; - if (tag.startsWith('es')) return 'es'; - if (tag.startsWith('pt')) return 'pt'; return DEFAULT_FALLBACK_LANGUAGE; } @@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise { * 语言选择优先级: * 1) 用户设置(若存在) * 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退) - * 3) 默认回退(zh-CN) + * 3) 默认回退(en) */ export async function initI18n(): Promise { if (i18n.isInitialized) return; @@ -98,11 +83,8 @@ export async function initI18n(): Promise { await i18n.use(initReactI18next).init({ resources: { - 'zh-CN': { translation: zhCN }, 'zh-TW': { translation: zhTW }, en: { translation: en }, - es: { translation: es }, - pt: { translation: pt }, }, lng: initialLang, fallbackLng: DEFAULT_FALLBACK_LANGUAGE, diff --git a/spec_kit/Personalized Reco/modules/content-repository/spec.md b/spec_kit/Personalized Reco/modules/content-repository/spec.md new file mode 100644 index 0000000..1454eb5 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/spec.md @@ -0,0 +1,102 @@ +# 子模块:Content Repository(候选查询与数据访问层)|Spec + +## 1. 目标描述 + +提供推荐算法可注入的、与 ORM/SQL 解耦的数据访问接口: + +- 按场景与用户画像拉取候选内容画像(Cᵢ)。 +- 按 `content_id` 批量获取内容画像(去重/重排/补字段)。 +- 对 risk_flags、suitability JSON 等“存储形态”做统一解析与兼容(对上提供稳定结构)。 + +> 规则口径:risk_flags 命名与语义严格按 `句子文案打分规则`;若历史数据存在旧 flag,需在读取层做一次映射(避免语义漂移)。 +> +> 存储形态对齐 `modules/db-design/plan.md`:读取层需要将 `contents` + `content_profiles` + `content_risk_flags` 组装为上层稳定的 `ContentProfile` 结构。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- `scene`: `feed | push | widget` +- `user_profile`: 客户端问卷画像(V1.2;字段允许缺失) +- `fallback_level`: `0|1|2|3` +- `limit`: 候选条数上限(由引擎配置) +- (可选)排除集合:`exclude_content_ids`(用于 DB 层先排一部分,减少传输;类型为 `List[int]`,与 MySQL 自增 `content_id` 对齐) +- 数据库会话/连接(实现层使用 `AsyncSession` 注入) + +### 2.2 输出 + +- `List[ContentProfile]`(稳定字段契约): + - `content_id`:`int`(MySQL 自增主键) + - `text`:文案文本 + - `stage`:`general | expecting | parenting | unknown` + - `emotion_score`:`float | None`(约定:`None` 表示 general) + - `context_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`) + - `need_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`) + - `personalization_power`:`float`(对上稳定口径为 `0/0.5/1`;若 DB 存 `0/5/10`,读取层需映射) + - `risk_flags`:`List[str]`(已做旧→新映射、去重;命名只允许 `unsafe_for_* / block_* / soft_*`) + - 可选:`author_id`、`template_id`、`review_confidence` + +### 2.3 数据存储形态(对齐 DB 设计) + +> 对齐:`spec_kit/Personalized Reco/modules/db-design/plan.md` + +- `contents`:提供 `content_id`、`text`、(可选)`author_id`、`template_id` +- `content_profiles`:提供 `stage`、`emotion_score`、`context_suitability_json`、`need_suitability_json`、`personalization_power`(推荐存 `0/5/10`)、(可选)`review_confidence` +- `content_risk_flags`:通过关联表提供风险标记集合(同一 content 下按 `uniq_content_flag(content_id, flag)` 去重) + +--- + +## 3. 接口(建议) + +推荐模块对该子模块只依赖抽象接口(Python Protocol/ABC 均可): + +- `fetch_candidates(scene, user_profile, fallback_level, limit, exclude_content_ids=None) -> List[ContentProfile]` +- `fetch_contents_by_ids(content_ids: List[int]) -> List[ContentProfile]` + +--- + +## 4. 关键规则与实现约束 + +### 4.1 字段缺失与默认值 + +- 若 `review_confidence` 缺失:输出时默认按 `0.7`(对齐 `句子文案打分规则` V1.2 约定)。 +- `context_suitability/need_suitability` 若缺失:**读取层必须补齐为“全 0.5 的通用可推”结构**(稳定输出,避免上层分支判断)。 + - `context_suitability` 必须包含 5 个 key:`family/work/relationship/friends/health` + - `need_suitability` 必须包含 5 个 key:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance` + - 补齐时上述 key 的默认值均为 `0.5` +- `personalization_power`:若 DB 采用 `0/5/10` 存储,读取层必须映射为 `0.0/0.5/1.0` 对上输出。 + +### 4.2 risk_flags 兼容映射(若存在历史旧数据) + +对齐 `句子文案打分规则` 的旧→新映射: + +- `block_stage_unknown` → `unsafe_for_stage_unknown` +- `block_stage_parenting` → `unsafe_for_stage_parenting` +- `block_emotion_low` → `unsafe_for_emotion_low` +- `block_health_sensitive` → `block_health_medical`(读取层默认采用更保守的硬拦截映射;除非未来引入可判定的细分字段再放宽为 `soft_health_sensitive`) + +输出约束: + +- 输出的 flags 必须已去重,且不得包含任何旧命名。 + +### 4.3 性能约束 + +- 不得产生 N+1 查询:候选与字段必须一次或少量批量查询获取。 +- `fetch_candidates` 必须支持 limit,并在 DB 层尽量过滤(减少应用层扫描)。 +- `fetch_contents_by_ids` / `fetch_candidates` 推荐查询形态: + - `contents` JOIN `content_profiles`,再 LEFT JOIN `content_risk_flags`(或先批量取 profiles,再批量取 flags 并在应用层聚合),避免按 `content_id` 循环查 flags。 + +--- + +## 5. 验收标准(可验证) + +- `fetch_contents_by_ids`: + - 输入任意 `content_id` 列表,返回包含完整字段的 `ContentProfile` 列表(无重复、可缺省字段按约定兜底)。 +- `fetch_candidates`: + - 在不同 `scene` 与 `fallback_level` 下能返回候选(即便画像缺失也不报错)。 + - risk_flags 映射正确:输出的 flag 名称集合只包含新命名(`unsafe_for_* / block_* / soft_*`)。 +- 性能: + - 单次调用不出现按 content_id 循环查库的行为(可通过日志/测试断言查询次数)。 + diff --git a/spec_kit/Personalized Reco/modules/db-design/plan.md b/spec_kit/Personalized Reco/modules/db-design/plan.md new file mode 100644 index 0000000..5c6ed70 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/db-design/plan.md @@ -0,0 +1,217 @@ +# DB Design & Migrations(数据库设计与迁移)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/db-design/spec.md` +> +> 前置确认(已对齐): +> +> - `content_id`:MySQL **自增主键**;文案微调时 **content_id 不变**(更新同一条记录)。 +> - `need_suitability/context_suitability`:**JSON** 存储。 +> - `risk_flags`:选择更强扩展性的方案(本计划采用 **关联表**,利于索引与过滤)。 +> - 安全池(L3):**方式 A**(`is_safe_pool`)。 +> - 迁移:使用 **Alembic**,目录放 `server/alembic/`;dev/pro 两套库均可运行同一套迁移。 +> - 字符集:统一 **utf8mb4**。 + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 在“库为空”的前提下,落地推荐系统最小可用的数据模型。 +- 保证后续推荐查询可实现:按画像条件召回、按 ID 批量查、按风险标记过滤、支持安全池兜底。 + +### 1.2 交付物 + +- `server/alembic/`:Alembic 初始化目录、`alembic.ini`(或等价配置)、迁移脚本。 +- SQLAlchemy ORM 模型(建议放 `server/app/db/models/`)。 +- 初始迁移:创建 `contents`、`content_profiles`、`content_risk_flags`(以及必要索引)。 + +--- + +## 2. 技术决策(V1) + +### 2.1 表设计原则 + +- **分离主体与画像**:`contents` 存文本与来源字段;`content_profiles` 存画像字段(便于未来画像重算/回填)。 +- **JSON 存 suitability**:`context_suitability`、`need_suitability` 用 JSON,保持结构与规则文档一致。 +- **risk_flags 关联表**:用 `content_risk_flags(content_id, flag)`,便于: + - 快速 Hard Filter(`block_health_medical` 等) + - 索引与统计(按 flag 计数) + - 兼容旧 flag 映射(在写入/读取层) +- **emotion_score 的 general 表示**:用 `NULL` 表示 general(与规则文档“可为 general”语义等价)。 +- **personalization_power**:存为 `TINYINT`(0/5/10)或 `DECIMAL(2,1)`(0/0.5/1)。本计划推荐 `TINYINT`(更易索引/更省空间),应用层做映射: + - 0 → 0.0 + - 5 → 0.5 + - 10 → 1.0 + +### 2.2 字符集与排序规则 + +- 数据库与表:`utf8mb4` +- collation:建议 `utf8mb4_0900_ai_ci`(MySQL 8 默认更常见;若环境不同以实际为准,但必须 utf8mb4) + +--- + +## 3. 表结构(V1 方案) + +> 以下为“建议 schema”。实际字段名可调整,但语义必须严格对齐 `句子文案打分规则`。 + +### 3.1 `contents`(文案主体) + +- `content_id` BIGINT UNSIGNED PK AUTO_INCREMENT +- `text` TEXT NOT NULL +- `author_id` VARCHAR(64) NULL +- `template_id` VARCHAR(64) NULL +- `created_at` DATETIME NOT NULL +- `updated_at` DATETIME NOT NULL + +索引建议: + +- `idx_contents_author_id(author_id)` +- `idx_contents_template_id(template_id)` + +### 3.2 `content_profiles`(内容画像) + +- `content_id` BIGINT UNSIGNED PK(FK → contents.content_id,ON DELETE CASCADE) +- `stage` ENUM('general','expecting','parenting','unknown') NOT NULL DEFAULT 'general' +- `emotion_score` DECIMAL(3,2) NULL + - 约定:NULL 表示 general +- `context_suitability_json` JSON NOT NULL +- `need_suitability_json` JSON NOT NULL +- `personalization_power` TINYINT UNSIGNED NOT NULL DEFAULT 0 + - 约定:只允许 0/5/10 +- `review_confidence` DECIMAL(3,2) NULL + - 约定:NULL 由推荐模块按 0.7 兜底(对齐规则文档) +- `is_safe_pool` BOOLEAN NOT NULL DEFAULT FALSE +- `updated_at` DATETIME NOT NULL + +索引建议: + +- `idx_profiles_stage(stage)` +- `idx_profiles_personalization_power(personalization_power)` +- `idx_profiles_is_safe_pool(is_safe_pool)` + +> 说明:suitability 放 JSON 后,V1 可以先不做 JSON 路径索引;当候选量上来后再加“生成列/函数索引”做加速(见 6.2)。 + +### 3.3 `content_risk_flags`(风险标记,关联表) + +- `id` BIGINT UNSIGNED PK AUTO_INCREMENT +- `content_id` BIGINT UNSIGNED NOT NULL(FK → contents.content_id,ON DELETE CASCADE) +- `flag` VARCHAR(64) NOT NULL +- `created_at` DATETIME NOT NULL + +约束与索引: + +- UNIQUE:`uniq_content_flag(content_id, flag)`(同一 content 不重复插同 flag) +- 索引:`idx_flag(flag)`(用于 Hard Filter 与统计) +- 索引:`idx_content_id(content_id)`(用于按内容批量取 flags) + +命名约束(应用层强制,DB 可选): + +- flag 必须以 `unsafe_for_` / `block_` / `soft_` 开头(严格对齐规则文档) + +--- + +## 4. Alembic 迁移落地步骤 + +### 4.1 依赖与目录 + +- 后端依赖:`alembic`(加入 `server/requirements.txt`,版本随项目统一管理) +- 目录:`server/alembic/`(包含 `env.py`、`versions/`) +- 连接串:复用现有 `DATABASE_URL`(`mysql+aiomysql://...`) + +### 4.2 初始化与生成迁移(一次性) + +- `alembic init alembic`(在 `server/` 下) +- 配置 `env.py`: + - 从 `app/core/config.py` 读取 `DATABASE_URL` + - 引入 ORM Base 与 models,启用 autogenerate +- 创建初始迁移: + - `alembic revision --autogenerate -m "init content tables"` + - `alembic upgrade head` + +### 4.3 dev/pro 一致性 + +- 迁移脚本保持同一套;通过不同环境的 `DATABASE_URL` 指向 `mindfulness_dev` 或 `mindfulness`。 + +--- + +## 5. 入库流程(写入契约) + +### 5.1 一条文案最小入库数据 + +必须字段(V1 最小可用): + +- `text` +- `content_profiles.stage` +- `content_profiles.emotion_score`(可为 NULL 表示 general) +- `content_profiles.context_suitability_json`(必须包含 5 个 key:family/work/relationship/friends/health,值为 0/0.5/1) +- `content_profiles.need_suitability_json`(必须包含 5 个 key:emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance,值为 0/0.5/1) +- `content_profiles.personalization_power`(0/5/10) +- `content_risk_flags`(可为空集合,但若存在必须按命名规范) + +强烈建议字段: + +- `author_id`、`template_id` +- `review_confidence` +- `is_safe_pool`(若要参与 L3 安全池) + +### 5.2 写入策略 + +- 创建文案时: + - 先写 `contents` 得到 `content_id`(自增) + - 再写 `content_profiles`(同 content_id) + - 再批量写 `content_risk_flags` +- 文案微调时(content_id 不变): + - 更新 `contents.text` 与 `updated_at` + - 同步更新 `content_profiles`(若画像变更) + - risk_flags 做“全量覆盖”或“差量更新”(plan 实现阶段定) + +--- + +## 6. 查询与性能规划 + +### 6.1 V1 查询策略(先可用) + +- 候选召回: + - 先按 `stage`、`personalization_power`、`is_safe_pool` 等可索引字段进行粗过滤 + - 再在应用层结合 suitability JSON 与 risk_flags 做精过滤/打分 +- Hard Filter: + - 通过 `content_risk_flags` join 或子查询排除指定 flags(如 `block_health_medical`) +- 批量查: + - `content_id IN (...)` join `content_profiles` + left join `content_risk_flags` + +### 6.2 V1.1 性能增强(候选量上来后再做) + +当候选池变大、应用层过滤成本上升时,优先做两类增强: + +- **生成列/函数索引**:为常用召回维度(例如 need/context 的某些 key)创建 generated columns(从 JSON_EXTRACT 取值并映射到 TINYINT),再加索引。 +- **风险 flag 位图/派生列**:对 `block_health_medical` 等强规则增加派生布尔列(或维护冗余表),降低 join 成本。 + +--- + +## 7. 测试与验收(DB 子模块) + +### 7.1 迁移验收 + +- 在全新库执行 `alembic upgrade head` 成功。 +- 执行 `downgrade`(若实现)可回滚(至少在开发环境可用)。 + +### 7.2 数据契约验收 + +插入一条最小文案记录后,能够查询并组装出推荐模块所需的 `ContentProfile` 字段集合: + +- `content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags` + +### 7.3 规则口径验收(写入侧) + +- 写入 `risk_flags` 时,若出现旧 flag(如 `block_stage_unknown`): + - 写入层需在入库前映射为新命名(或拒绝写入并提示) + - 推荐侧读取层不得再出现旧 flag 名称 + +--- + +## 8. 与其他子模块的接口约定 + +- `Content Repository` 只依赖本模块提供的表与字段语义,不依赖具体迁移实现细节。 +- 推荐引擎/打分模块对 `review_confidence` 的缺省值假设(0.7)在 DB 缺失时依然成立。 + diff --git a/spec_kit/Personalized Reco/modules/db-design/spec.md b/spec_kit/Personalized Reco/modules/db-design/spec.md new file mode 100644 index 0000000..ca03278 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/db-design/spec.md @@ -0,0 +1,91 @@ +# 子模块:DB Design & Migrations(数据库设计与迁移)|Spec + +## 1. 目标描述 + +在当前“数据库尚未设计且为空”的前提下,为个性化推荐提供最小可用的数据存储与索引能力: + +- 存储文案及其内容画像(Content Profile,Cᵢ)。 +- 能按画像条件进行候选召回(need/context/stage/general、personalization_power、risk_flags 等)。 +- 能按 `content_id` 批量查询(补全候选、去重/重排时取字段)。 +- 为后续标注/审核/置信度补齐留出扩展空间。 + +> 规则口径:字段语义必须严格对齐 `设计说明文档/句子文案打分規則.md`。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- 内容侧提供的文案与画像数据(可由运营导入、AI Reviewer 产出、人审修正等方式写入)。 +- 推荐模块对数据访问的需求(召回过滤字段、排序字段、频控字段)。 + +### 2.2 输出 + +- 一套 MySQL 表结构(或视图)满足 `ContentProfile` 字段契约: + - `content_id`(稳定主键) + - `text` + - `stage`(`general/expecting/parenting/unknown`) + - `emotion_score`(0~1 或 `general` 的等价表示) + - `context_suitability`(每个 context 的 {0,0.5,1}) + - `need_suitability`(每个 need 的 {0,0.5,1}) + - `personalization_power`(0/0.5/1) + - `risk_flags`(命名以 `unsafe_for_* / block_* / soft_*` 为准) + - 可选:`author_id`、`template_id`、`review_confidence` +- Alembic 迁移脚本:`alembic revision --autogenerate` / `alembic upgrade head` 可创建上述表。 +- 推荐查询需要的索引(至少支持按场景召回与按 ID 批量查)。 + +--- + +## 3. 建议表结构(V1,允许后续调整) + +> 说明:本子模块不强制具体表名;但需保证字段语义与索引可用。以下给出一个推荐落地方案,便于后续 plan 直接实现。 + +### 3.1 `contents`(文案主体) + +- `content_id`(PK) +- `text` +- `author_id`(可空) +- `template_id`(可空) +- `created_at` / `updated_at` + +### 3.2 `content_profiles`(内容画像) + +- `content_id`(PK/FK → contents) +- `stage`(枚举:general/expecting/parenting/unknown) +- `emotion_score`(可为 NULL 表示 general,或用额外字段 `emotion_is_general` 表示) +- `context_suitability_json`(JSON:每个 context -> 0/0.5/1) +- `need_suitability_json`(JSON:每个 need -> 0/0.5/1) +- `personalization_power`(DECIMAL(2,1) 或 TINYINT 映射到 0/0.5/1) +- `risk_flags_json`(JSON 数组:字符串集合) +- `review_confidence`(DECIMAL(3,2),缺省可按 0.7 处理,由推荐模块兜底) +- `updated_at` + +### 3.3 “通用安全池”支持(L3 兜底) + +至少提供一种方式能拉到安全池内容: + +- 方式 A:`content_profiles` 增加 `is_safe_pool`(boolean) +- 方式 B:单独 `safe_pool_contents`(content_id 列表) + +--- + +## 4. 索引与查询能力(V1 必需) + +- **按 ID 批量查**:`content_id in (...)` +- **按 stage / personalization_power 过滤**:支持候选召回与回退梯度 +- **按 risk_flags 过滤**: + - 推荐做法:将 `risk_flags_json` 冗余为可索引的派生列/位图/多表行(plan 阶段定实现) + - V1 最小可用:允许先在应用层过滤(但需控制候选量,避免全表扫) + +--- + +## 5. 验收标准(可验证) + +- **迁移可运行**:全新 MySQL 库上执行 `alembic upgrade head` 可成功创建表结构。 +- **字段契约可满足**:能从 DB 读出 `ContentProfile` 需要的字段(至少 content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags)。 +- **基本查询可用**: + - 能按 `content_id` 批量拉取内容画像 + - 能按 `stage/general` 与 `personalization_power` 条件召回候选(用于 L0~L3) +- **安全池可用**:能稳定拉取 L3 兜底候选集(不依赖用户画像)。 + diff --git a/spec_kit/Personalized Reco/modules/db-design/tasks.md b/spec_kit/Personalized Reco/modules/db-design/tasks.md new file mode 100644 index 0000000..ec41745 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/db-design/tasks.md @@ -0,0 +1,99 @@ +# DB Design & Migrations(数据库设计与迁移)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/db-design/plan.md` +> +> 目标:在“库为空”的前提下,落地推荐系统最小可用数据模型 + Alembic 迁移,并通过最小查询/契约验收。 + +--- + +## 0. 任务状态约定 + +- `[ ]`:未开始 +- `[~]`:进行中 +- `[x]`:已完成 +- `[-]`:已取消/不做(需写明原因) + +--- + +## 1. 环境与依赖准备 + +- [ ] **确认 MySQL 版本与字符集支持** + - 验收:MySQL 版本为 8.x;库/表可用 `utf8mb4`(建议 `utf8mb4_0900_ai_ci`)。 +- [x] **后端依赖补齐 Alembic** + - 说明:`server/requirements.txt` 已包含 `alembic>=1.13` + - 验收:在 `server/.venv` 中可成功 `import alembic`。 + +--- + +## 2. ORM 模型落地(推荐最小集合) + +- [x] **创建 ORM 模型目录** + - 目标路径:`server/app/db/models/` + - 验收:目录存在,且可被 Python 正常 import。 +- [x] **实现 `contents` 模型** + - 字段:`content_id(PK, 自增)`、`text_en?`、`text_tc?`、`author_id?`、`template_id?`、`created_at`、`updated_at` + - 索引:`author_id`、`template_id` + - 验收:Alembic autogenerate 能识别表结构。 +- [x] **实现 `content_profiles` 模型** + - 字段:`content_id(PK/FK)`、`stage(enum)`、`emotion_score(NULL=general)`、`context_suitability_json(JSON)`、`need_suitability_json(JSON)`、`personalization_power(0/5/10)`、`review_confidence?`、`is_safe_pool`、`updated_at` + - 索引:`stage`、`personalization_power`、`is_safe_pool` + - 验收:Alembic autogenerate 能识别表结构;`content_id` 具备外键与级联删除。 +- [x] **实现 `content_risk_flags` 模型(关联表)** + - 字段:`id(PK)`、`content_id(FK)`、`flag`、`created_at` + - 约束:`UNIQUE(content_id, flag)` + - 索引:`flag`、`content_id` + - 验收:Alembic autogenerate 能识别唯一约束与索引。 + +--- + +## 3. Alembic 初始化与迁移生成 + +- [x] **初始化 Alembic 目录** + - 目标位置:`server/alembic/`(含 `versions/`、`env.py`) + - 验收:在 `server/` 下可运行 `alembic -h` 且能读取配置。 +- [x] **配置 Alembic 连接串来源** + - 要求:复用现有 `DATABASE_URL`(对齐 `server/app/core/config.py`) + - 验收:`alembic` 命令可加载 `env.py` 并读取 `DATABASE_URL`(未连 DB 验收留到第 4 章)。 +- [x] **配置 `env.py` 支持 autogenerate** + - 要求:引入 ORM `Base` 与 models(确保 metadata 完整) + - 验收:Alembic 能识别 `target_metadata`;且已提供初始迁移版本文件。 +- [ ] **生成并执行初始迁移** + - 命令:`alembic upgrade head` + - 验收:数据库中出现 `contents`、`content_profiles`、`content_risk_flags` 与 Alembic 版本表。 + +--- + +## 4. 数据契约验收(最小入库与查询) + +- [ ] **准备一条最小文案数据(人工插入或脚本)** + - 必须字段(对齐 plan):`text`、`stage`、`emotion_score(可 NULL)`、`context_suitability_json(5 keys)`、`need_suitability_json(5 keys)`、`personalization_power(0/5/10)`、`risk_flags(可空)` + - 验收:可插入成功,不违反约束。 +- [ ] **验证按 `content_id` 批量查询可用** + - 目标:能 join 组装出 `ContentProfile` 所需字段集合(含 risk_flags 列表) + - 验收:至少验证字段:`content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags`。 +- [ ] **验证 Hard Filter 关键 flag 可过滤** + - 插入:至少一条带 `block_health_medical` 的记录 + - 验收:通过 `content_risk_flags` 可在 SQL 层排除该内容(后续供推荐候选召回使用)。 +- [ ] **验证安全池(L3)可用** + - 插入:至少一条 `is_safe_pool=true` + - 验收:可单独查询出安全池候选集(不依赖画像条件)。 + +--- + +## 5. 规则口径验收(risk_flags 严格对齐) + +- [ ] **建立 risk_flags 白名单/校验策略(写入侧或读取侧)** + - 要求:flag 命名必须以 `unsafe_for_` / `block_` / `soft_` 开头(对齐 `句子文案打分规则`) + - 验收:插入非法前缀时被拒绝或被修正(选择其一,并写清策略)。 +- [ ] **旧 flag 兼容映射验收(若存在历史数据导入)** + - 覆盖:`block_stage_unknown`→`unsafe_for_stage_unknown` 等(详见 plan) + - 验收:系统对外(读出/下游)不再出现旧 flag 名称。 + +--- + +## 6. 文档与交接 + +- [ ] **补齐本子模块 README/说明(可选,但建议)** + - 内容:如何初始化 DB、如何跑迁移、如何插入一条最小文案数据、如何验证查询。 + - 验收:新同学按文档能在 30 分钟内跑通迁移 + 插入 + 查询。 + diff --git a/spec_kit/Personalized Reco/modules/integration-api-worker/spec.md b/spec_kit/Personalized Reco/modules/integration-api-worker/spec.md new file mode 100644 index 0000000..1515c92 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/integration-api-worker/spec.md @@ -0,0 +1,59 @@ +# 子模块:Integration(FastAPI API + Celery Worker)|Spec + +## 1. 目标描述 + +将推荐引擎对外暴露为两种调用方式,满足“API 与任务两者都需要”的集成要求: + +- **FastAPI**:同步请求/响应式获取推荐结果(客户端直接请求)。 +- **Celery**:后台任务式生成推荐(用于 Push/Widget 的定时或批处理)。 + +两种方式都必须调用同一套 `Reco Engine`,确保行为一致、便于回归测试。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 API 输入(HTTP Request) + +- `scene`: `feed | push | widget` +- `k`(可选;默认按场景) +- `user_profile`(客户端问卷画像,V1.2) +- `already_recommended_ids`(数组) +- `touched_or_viewed_ids`(数组) +- (可选)`now`(用于可测试性;生产可不传,由服务端生成) + +### 2.2 API 输出(HTTP Response) + +- `items`: 推荐句子列表 +- `meta`: RecoMeta(候选规模、回退层级、empty_reason 等) + +### 2.3 Celery 任务输入/输出 + +任务输入与 API 输入等价,但建议仅传必要字段并保持 payload 小(避免 Redis 膨胀): + +- 允许只传 `user_profile` 与历史集合;若历史集合很大,建议传引用 ID(后续演进)。 + +任务输出: + +- 可选择“不存结果”(默认忽略结果)并在任务内部将推荐结果写入下游(如消息队列/推送系统/DB)。 +- 或返回 `items/meta`(仅用于开发调试)。 + +--- + +## 3. 关键约束 + +- API 与 Celery **不得复制推荐逻辑**:只能调用 `Reco Engine`。 +- 输入合法性: + - 不因字段缺失而报错(画像允许缺失/跳过)。 + - `already_recommended_ids/touched_or_viewed_ids` 允许为空数组。 +- 可测试性: + - 允许注入 `now`(或在 header/参数中提供),便于确定性测试。 + +--- + +## 4. 验收标准(可验证) + +- **API 可用**:给定合法请求体可返回推荐结果与 meta。 +- **任务可用**:Celery worker 能消费任务并成功调用推荐引擎(至少跑通 ping → reco 的调用链)。 +- **一致性**:相同输入下,API 与任务调用返回结果一致(忽略时间戳字段差异时)。 + diff --git a/spec_kit/Personalized Reco/modules/observability/spec.md b/spec_kit/Personalized Reco/modules/observability/spec.md new file mode 100644 index 0000000..9de9b9a --- /dev/null +++ b/spec_kit/Personalized Reco/modules/observability/spec.md @@ -0,0 +1,45 @@ +# 子模块:Observability(可观测性与打点载荷)|Spec + +## 1. 目标描述 + +定义推荐模块的统一可观测载荷(meta/event),用于: + +- 线上监控覆盖率、回退率、候选规模分布、过滤原因分布。 +- 支撑调参(权重/回退阈值/频控窗口)与风险排查(Hard Filter 命中情况)。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- 推荐引擎内部各阶段统计信息: + - 候选生成数量、过滤后数量、去重后数量、频控后数量 + - 回退层级与触发原因 + - served_k + - 缺失字段情况、conf_U + +### 2.2 输出 + +统一的 `RecoMeta`(返回给调用方;调用方负责上报/落库/打点): + +- `scene` +- `candidate_pool_size_raw` +- `candidate_pool_size_after_hard_filter` +- `candidate_pool_size_after_dedup` +- `candidate_pool_size_after_freqcap` +- `fallback_level_final` +- `served_k` +- `empty_reason`(served_k=0 必填;枚举建议:`hard_filter_all | freqcap_all | pool_empty | unknown`) +- `conf_U` +- `missing_fields`:`{ need: boolean; context: boolean; emotion: boolean }` +- (可选)`risk_filtered_count_by_flag`:`{ flag: count }` + +--- + +## 3. 验收标准(可验证) + +- 每次推荐调用都能产出 `RecoMeta`,并随响应/任务结果返回(或被上层打点系统消费)。 +- `served_k=0` 时 `empty_reason` 必不为空,且与实际清空阶段一致。 +- `fallback_level_final` 能真实反映最终回退层级(用于回退率报表)。 + diff --git a/spec_kit/Personalized Reco/modules/reco-engine/spec.md b/spec_kit/Personalized Reco/modules/reco-engine/spec.md new file mode 100644 index 0000000..91dd0ea --- /dev/null +++ b/spec_kit/Personalized Reco/modules/reco-engine/spec.md @@ -0,0 +1,81 @@ +# 子模块:Reco Engine(推荐引擎编排)|Spec + +## 1. 目标描述 + +实现推荐的主编排器(Orchestrator): + +- 输入用户画像 + 历史集合 + 场景参数。 +- 调用 `Content Repository` 拉取候选。 +- 执行统一 Pipeline:Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Fallback Ladder。 +- 输出推荐句子与 `meta`(可观测字段),供 API 或 Celery 上层直接下发。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- `scene`: `feed | push | widget` +- `user_profile`: 客户端问卷画像(V1.2) +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- `k`: int(feed 默认 30;push/widget 默认 1) +- `now`: 时间戳 +- (可选)`constraints`:黑名单 author/template、最大候选量上限等 + +### 2.2 输出 + +- `items: List[RecommendedItem]`(长度 ≤ k) + - `content_id`、`text`、`final_score` + - `fallback_level_final` + - `explanations`(可选,便于调参/排查) +- `meta: RecoMeta` + - `candidate_pool_size_raw` + - `candidate_pool_size_after_hard_filter` + - `candidate_pool_size_after_dedup` + - `candidate_pool_size_after_freqcap` + - `fallback_level_final` + - `served_k` + - `empty_reason`(served_k=0 必填) + - `missing_fields`(need/context/emotion 的缺失情况) + - `scene`、`conf_U` + +--- + +## 3. 缺失字段判定(必须与客户端契约一致) + +- `U.need` 为空对象 `{}` 或不存在 → 视为缺失 +- `U.context` 为空对象 `{}` 或不存在 → 视为缺失 +- `U.emotion_score` 为 `null`/不存在 → 视为缺失 + +当缺失明显或 `conf_U` 偏低时: + +- Push:必须启用不确定性惩罚 `P_uncertainty`,并自动降个性化(限制 `personalization_power` 上限)。 +- 任意场景:候选生成至少按 **L1** 处理(降个性化,增加通用安全占比)。 + +--- + +## 4. Fallback Ladder(回退梯度,必须实现) + +- L0:正常召回配比 +- L1:放宽匹配 + `personalization_power ≤ 0.5` +- L2:回退通用池 + `personalization_power = 0` +- L3:仅安全池(白名单/安全池) + +触发条件(任一满足即可回退): + +- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空 +- served_k < k(feed 可允许部分不足,但需记录并可继续回退补齐,具体由实现配置) + +每次回退必须更新 meta 中的候选规模与触发原因。 + +--- + +## 5. 验收标准(可验证) + +- **稳定性**:任意输入(包括画像字段缺失、历史集合为空/很大)不报错,返回结构稳定。 +- **回退可观测**:当候选不足时能逐级回退,且 `fallback_level_final` 与 `empty_reason` 正确。 +- **去重生效**:输出不包含 `already_recommended_ids` 与 `touched_or_viewed_ids` 中的 content_id。 +- **风险优先**:Hard Filter 始终优先执行(尤其 `block_health_medical` 必挡)。 +- **跨调用复用**:同一引擎既可被 FastAPI API 调用,也可被 Celery 任务调用(无框架耦合)。 + diff --git a/spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md b/spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md new file mode 100644 index 0000000..410972d --- /dev/null +++ b/spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md @@ -0,0 +1,87 @@ +# 子模块:Rerank & Freqcap(重排 / 去重 / 频控)|Spec + +## 1. 目标描述 + +对 Soft Scoring 后的候选集做最终的“可下发排序”,解决: + +- **去重**:避免同一句在短期内反复出现。 +- **多样性**:Feed 场景需要序列多样性(作者/模板/标签)。 +- **频控与冷却**:Push/Widget 必做,按句子/作者/模板维度做窗口期不重复。 + +> 当前确认:历史集合由客户端在请求时传入(已推荐 ID、已触达/浏览 ID)。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- `scene`: `feed | push | widget` +- `scored_candidates`: `List[{ content_id, score, content_profile... }]` +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- (可选)`recent_author_ids` / `recent_template_ids`(若客户端暂不传,可由后续服务端侧补齐) +- `k`: 目标条数 +- `config`: + - Feed:`mmr_lambda`(默认 0.7) + - 冷却窗口:`cooldown_sentence_days / cooldown_author_days / cooldown_template_days` + +### 2.2 输出 + +- `ranked_items`: 排好序的候选(长度 ≤ k) +- `meta`: + - `candidate_pool_size_after_dedup` + - `candidate_pool_size_after_freqcap` + - `freqcap_filtered_counts`(可选,按维度统计) + +--- + +## 3. 规则与算法(V1 最小集合) + +### 3.1 去重(必做) + +- 过滤 `content_id` ∈ `already_recommended_ids ∪ touched_or_viewed_ids` 的候选。 +- 去重键定义(对齐算法规则的工程约定): + - `sentence_key = content_id` + - `author_key = author_id`(可空) + - `template_key = template_id`(可空) + +### 3.2 Feed:MMR 序列重排(建议) + +- Top1:最高分,确保第一眼命中。 +- 后续:使用 MMR 选择序列,最大化与已选内容的差异(作者/模板/need/context/stage)。 + +MMR 定义: + +\[ +MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s) +\] + +其中: + +- `Rel(c)`:可直接使用 `final_score` +- `Sim(c,s)`:离散特征版(V1 推荐): + - content_id 相同:1 + - template_id 相同:+0.6 + - author_id 相同:+0.3 + - 标签重合(Jaccard):+0.1 * Jaccard(tags_c, tags_s) + - clamp 到 [0,1] + +### 3.3 Push/Widget:频控与冷却(必做) + +最小要求: + +- 同一句在窗口 X 天内不重复(句子冷却)。 +- 同作者/同模板在窗口期内尽量不重复(可作为硬频控或强降权,plan 阶段定)。 + +> 当前 V1 输入侧只确认有 content_id 历史;若 author/template 历史暂不具备,可先对 content_id 强硬去重,并将 author/template 频控作为“可选增强”(meta 仍需统计缺失原因)。 + +--- + +## 4. 验收标准(可验证) + +- **去重正确**:输出不包含 `already_recommended_ids` 与 `touched_or_viewed_ids`。 +- **Feed 序列多样**:在候选足够时,序列中不会出现大量同作者/同模板连续重复(可用统计阈值验收)。 +- **Push/Widget 频控生效**:在冷却窗口内同一句不会再次被推荐(基于传入历史集合验证)。 +- **可观测**:输出 meta 中包含 `after_dedup/after_freqcap` 的候选规模,便于排查 served_k 不足原因。 + diff --git a/spec_kit/Personalized Reco/modules/scoring/spec.md b/spec_kit/Personalized Reco/modules/scoring/spec.md new file mode 100644 index 0000000..cfbf113 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/scoring/spec.md @@ -0,0 +1,97 @@ +# 子模块:Scoring(软打分与惩罚项)|Spec + +## 1. 目标描述 + +实现推荐排序的打分函数与分解项,严格对齐 `設計說明文檔/個性化推薦算法規則.md` 的公式与 V1.2 缺失字段约定,并与内容画像(Cᵢ)结构对齐 `句子文案打分规则`。 + +--- + +## 2. 输入 / 输出定义 + +### 2.1 输入 + +- `scene`: `feed | push | widget` +- `user_profile`(U) +- `content_profile`(Cᵢ) +- `now`(用于 freshness/时间衰减,若 V1 暂不实现可先占位) +- `config`(权重与开关) + - `w_need/w_emotion/w_stage/w_context` + - `alpha`(个性化加成系数) + - `beta`(不确定性惩罚系数) + - `enable_uncertainty_penalty`(push 默认 true) + - `widget_emotion_soft_range`(0.4~0.8,软降权) + +### 2.2 输出 + +- `final_score: float` +- `breakdown`(用于调参/可观测,V1 可选但建议保留): + - `S_need/S_context/S_stage/S_emotion` + - `S_core/S_personal/S_fresh` + - `P_fatigue/P_repeat/P_risk/P_uncertainty` + - `missing_fields`(need/context/emotion) + +--- + +## 3. 计算规则(必须实现的最小集合) + +### 3.1 核心结构(线性加权 + 惩罚) + +\[ +final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big) +\] + +\[ +S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context} +\] + +### 3.2 V1.2 缺失字段的保守计算(必须对齐) + +- 若 `U.need` 缺失 → `S_need = 0.5` +- 若 `U.context` 缺失 → `S_context = 0.5` +- 若 `U.emotion_score` 缺失 → `S_emotion = 0.8` + - 文案为 general 时仍按 0.8(对齐算法规则) + +### 3.3 个性化加成(受回退梯度/降个性化约束) + +\[ +S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context}) +\] + +当触发降个性化(缺失明显 / 低置信度 / fallback_level≥1)时: + +- L1:限制 `personalization_power ≤ 0.5` +- L2/L3:限制 `personalization_power = 0` + +### 3.4 不确定性惩罚(Push 默认启用) + +\[ +P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot C_i.personalization\_power +\] + +其中: + +- `conf_U` 来自 `user_profile.profile_confidence` +- `conf_{C_i}` 来自 `content_profile.review_confidence`(缺省 0.7,对齐 `句子文案打分规则`) + +### 3.5 Widget 情绪区间(软降权) + +- 若 `scene=widget` 且 `C_i.emotion_score` 可计算(非 general): + - 当 `emotion_score` 不在 0.4~0.8:不硬过滤,但需要产生一个降权项(可并入 `P_risk` 或单独 `P_widget_emotion_out_of_range`)。 + +--- + +## 4. 场景默认权重(V1 建议,来源算法规则) + +- Feed:`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30` +- Push:`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05` +- Widget:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20` + +--- + +## 5. 验收标准(可验证) + +- **缺失字段一致性**:当 U 的 need/context/emotion 缺失时,S_need/S_context/S_emotion 按 0.5/0.5/0.8 计算,且不会报错。 +- **不确定性惩罚生效**:Push 默认启用,低 `conf_U` 或低 `conf_C` 时,高 `personalization_power` 内容分数降低。 +- **Widget 软降权生效**:0.4~0.8 区间外不被硬挡,但分数能明显被压低(可通过断言对比分数验证)。 +- **可调参**:权重与系数可配置,调整不会破坏输出结构。 + diff --git a/spec_kit/Personalized Reco/overview.md b/spec_kit/Personalized Reco/overview.md new file mode 100644 index 0000000..4079494 --- /dev/null +++ b/spec_kit/Personalized Reco/overview.md @@ -0,0 +1,107 @@ +# Personalized Reco|Overview(大规范总览) + +> 目的:保留大规范的背景/总览,并作为各子模块规范的索引入口。 +> +> 依据: +> +> - `设计说明文档/個性化推薦算法規則.md` +> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义的唯一准绳,必须严格对齐) +> - `spec_kit/User Profile Scoring/spec.md`(客户端问卷画像输出契约,V1.2) + +--- + +## 1. 背景 + +需要在后端实现一套可复用、可调参、可观测、可回退的推荐流程,覆盖 Feed / Push / Widget 三种分发场景。推荐模块必须与 API/任务解耦,避免逻辑散落;并在 Push 等高风险场景自动“降个性化/降风险”。 + +当前约束与确认点(来自需求澄清): + +- **数据库现状**:数据库尚未设计,当前为空(需要新增 DB 设计与迁移子模块)。 +- **规则口径**:risk_flags 等内容画像语义必须严格对齐 `句子文案打分规则`。 +- **调用方式**:推荐请求时由客户端传入 `user_profile` 与历史集合(已推荐 ID、已触达/浏览 ID)。 +- **Widget 情绪区间**:0.4~0.8 采用**软降权**(非硬过滤)。 +- **频控**:Push/Widget 需要冷却与频控(确认要做)。 +- **集成形态**:既需要 FastAPI API 接口,也需要 Celery 任务调用(两者都要)。 + +--- + +## 2. 大需求输入/输出(对外契约摘要) + +- **输入**: + - `scene`: `feed | push | widget` + - `user_profile`: 客户端问卷画像(V1.2,支持字段缺失/跳过) + - `already_recommended_ids`: 已推荐过的文案 ID(本次请求需去重) + - `touched_or_viewed_ids`: 已触达/浏览的文案 ID(本次请求需去重/疲劳) + - `k`:返回条数(feed 默认 30;push/widget 默认 1) +- **输出**: + - 推荐句子列表(`content_id` + `text` + `final_score` 等) + - `meta`:候选规模、过滤/频控后规模、回退层级、empty_reason 等(用于打点) + +--- + +## 3. 子模块拆分(modules/) + +> 原则:每个子模块都可独立实现、测试、验收;子模块之间通过明确接口关联。 + +目录结构: + +```text +spec_kit/Personalized Reco/ +├ overview.md +├ spec.md +└ modules/ + ├ db-design/ + │ └ spec.md + ├ content-repository/ + │ └ spec.md + ├ reco-engine/ + │ └ spec.md + ├ scoring/ + │ └ spec.md + ├ rerank-freqcap/ + │ └ spec.md + ├ observability/ + │ └ spec.md + └ integration-api-worker/ + └ spec.md +``` + +模块关系(逻辑依赖): + +- `integration-api-worker` → 调用 `reco-engine` +- `reco-engine` → 依赖 `content-repository` 拉取候选 +- `reco-engine` → 依赖 `hard filter / scoring / rerank-freqcap`(此处拆为独立模块:`scoring`、`rerank-freqcap`) +- `content-repository` → 依赖 `db-design` 提供表结构与字段契约 +- `observability` → 被 `reco-engine` 与 `integration-api-worker` 共同使用 + +--- + +## 4. 主规范与子规范的职责划分 + +- `spec.md`:大需求的高层契约与总体约束(面向集成与验收)。 +- `modules/*/spec.md`:每块可落地实现的子模块规范(面向工程实现与单测)。 + +--- + +## 5. 模块实现顺序(建议) + +> 原则:先把“数据可查”打通,再实现“可排序”,最后做“可对外提供(API/任务)”与“可观测”。 + +1. **`modules/db-design/`(数据库设计与迁移)** + - 交付:表结构 + Alembic 迁移可跑通;能插入/读取最小 `ContentProfile` 字段。 +2. **`modules/content-repository/`(数据访问层)** + - 交付:按场景/回退层级拉候选、按 ID(`content_id` 自增 `int`)批量查;risk_flags 旧→新映射与默认值兜底。 + - 口径:suitability 读取层统一输出稳定结构;缺失时补齐“全 0.5”,key 集合固定为: + - context:`family/work/relationship/friends/health` + - need:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance` +3. **`modules/scoring/`(打分)** + - 交付:`final_score` 与缺失字段保守策略(V1.2)实现;Push 的 `P_uncertainty`;Widget 情绪区间软降权。 +4. **`modules/rerank-freqcap/`(去重/重排/频控)** + - 交付:基于传入历史集合的去重;Feed 的 MMR;Push/Widget 冷却窗口规则(先保证“同句不重复”)。 +5. **`modules/observability/`(可观测)** + - 交付:统一 `meta` 结构与字段;能准确定位候选在何阶段被清空/回退。 +6. **`modules/reco-engine/`(引擎编排)** + - 交付:串起候选→过滤→打分→重排→回退;输出 items+meta;在画像缺失/候选不足时仍稳定返回。 +7. **`modules/integration-api-worker/`(API + Celery 集成)** + - 交付:FastAPI 路由 + Celery 任务都能调用同一引擎;相同输入下结果一致(忽略时间戳差异)。 + diff --git a/spec_kit/Personalized Reco/spec.md b/spec_kit/Personalized Reco/spec.md new file mode 100644 index 0000000..7f50a3d --- /dev/null +++ b/spec_kit/Personalized Reco/spec.md @@ -0,0 +1,287 @@ +# Personalized Reco|后端个性化推荐算法模块(解耦版)|Spec(索引版) + +> 阶段:高层规范(spec) +> +> 目标:把「个性化推荐算法」做成后端**独立模块**,输入用户画像与曝光历史,模块内部从数据库拉取文案候选并排序,输出推荐句子(支持 Feed / Push / Widget 三种场景的可调参策略)。 +> +> 依据: +> +> - `设计说明文档/個性化推薦算法規則.md` +> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义必须严格对齐) +> - `spec_kit/User Profile Scoring/spec.md`(用户画像输入契约,V1.2) + +--- + +## 1. 背景与动机(摘要) + +需要在后端实现一套跨场景一致、可调参、可观测、可回退的推荐流程,并将其做成独立模块以便复用(API / Celery 均可调用),降低 Push 等高风险场景的翻车概率。 + +已确认约束: + +- 数据库当前未设计且为空:本需求内新增 DB 设计与迁移子模块。 +- risk_flags 等内容画像语义:严格按 `句子文案打分规则` 执行。 +- 推荐请求:由客户端在请求时传入 `user_profile` 与历史集合。 +- Widget 情绪区间(0.4~0.8):采用软降权。 +- Push/Widget:需要频控与冷却;且 API 与 Celery 两种调用方式都需要。 + +--- + +## 2. 目标(Goals) + +- **模块解耦**:推荐逻辑以独立包/子模块方式存在,暴露稳定接口,不与 FastAPI 路由/ Celery 任务强绑定。 +- **统一 Pipeline**:Hard Filter → Soft Scoring → Rerank(多样性/新鲜度/疲劳),并支持候选不足的回退梯度(L0~L3)。 +- **输入明确**:输入用户画像(允许字段缺失)、已推荐的文案 ID、已触达/浏览的文案 ID(用于去重/疲劳/频控)。 +- **从 DB 取候选**:模块内部负责根据画像与场景召回候选,并从数据库读取文案画像/风险标记/元信息。 +- **输出可直接下发**:输出推荐句子(含 content_id、文本、排序分与必要的解释字段用于打点/调参)。 +- **可观测**:对候选规模、过滤原因、回退层级、 served_k 等关键指标提供统一事件结构,便于落点与报表。 + +--- + +## 3. 非目标(Non-goals) + +- **不在本阶段定义**:内容生产(生成句子)、人工审核工作流、embedding/向量检索(V2 扩展)。 +- **不绑定具体表结构**:仅规定“必须能读到的字段契约”,具体表名/索引/迁移由后续 plan 细化。 +- **不负责推送/Widget 排程**:模块只负责“给定场景与约束,返回推荐结果”,调度由上层系统决定。 + +--- + +## 4. 术语与数据对象(Definitions,摘要) + +### 4.1 场景(Scene) + +- **feed**:探索优先(Exploration-first),返回序列 TopK(例如 30 条)。 +- **push**:精确与安全优先(Precision-first),通常返回 Top1(或 TopN 供上层挑选)。 +- **widget**:稳定与品牌一致性优先(Consistency-first),返回 Top1(每日一句)。 + +### 4.2 用户画像 U(UserProfile) + +用户画像结构以客户端问卷输出为准(V1.2),详见: + +- `spec_kit/User Profile Scoring/spec.md`(字段与缺失约定) +- `modules/reco-engine/spec.md`(本模块如何消费画像与缺失判定) + +### 4.3 文案画像 C(ContentProfile) + +模块需能从 DB 读到(或通过视图/派生字段得到): + +- **content_id**(稳定主键) +- **text**(句子文本) +- **stage**(或可推导的 stage 标签) +- **emotion_score**(0~1 或标记为 general) +- **need_suitability / context_suitability**(离散适配表/JSON) +- **personalization_power**(0 / 0.5 / 1) +- **risk_flags**(集合/数组/位图均可,语义一致即可) +- (可选)**author_id / template_id / review_confidence** + +--- + +## 5. 模块边界与接口(API Contract) + +### 5.1 入口函数(推荐引擎接口) + +推荐模块对外暴露一个主入口,建议形式(不限定语言结构,但需表达同等信息): + +- **输入**: + - `scene`: `"feed" | "push" | "widget"` + - `user_profile`: `UserProfile` + - `already_recommended_ids`: `list[int|str]` + - `touched_or_viewed_ids`: `list[int|str]`(触达/浏览/点击等均归一为“已曝光”集合) + - `k`: int(feed 默认 30,push/widget 默认 1) + - `now`: 时间戳(用于时间衰减/冷却窗口) + - `constraints`(可选):如黑名单 author/template、最大风险等级、频控窗口天数等 +- **输出**: + - `items`: 推荐结果数组(长度 ≤ k) + - `meta`: 本次推荐的统计与解释字段(用于打点/调参) + +### 5.2 输出结果项(RecommendedItem) + +每条推荐至少包含: + +- `content_id` +- `text` +- `final_score` +- `fallback_level_final` +- `explanations`(轻量可选):如命中 need/context/stage、是否 general、被降个性化的原因(缺失/低置信度/回退) + +### 5.3 数据访问抽象(Repository / Gateway) + +为实现解耦,推荐模块**不得**直接依赖 FastAPI 的 `Depends`;应通过注入的 `ContentRepository` 读取数据: + +- `fetch_candidates(scene, user_profile, limit, fallback_level) -> list[ContentProfile]` +- `fetch_contents_by_ids(ids) -> list[ContentProfile]`(用于补全曝光历史的画像信息时可用) + +实现层可使用 `AsyncSession`(SQLAlchemy)完成具体查询,但算法层不感知 ORM/SQL。 + +--- + +## 6. 推荐流程(统一 Pipeline) + +本节是工程必须遵守的“骨架”,参数可按场景配置。 + +### 6.1 Candidate Generation(候选集生成) + +核心要求: + +- 依据 `U` 召回一个候选池:匹配 need/context/stage 的内容 + 一部分通用内容(general)。 +- 若问卷允许跳过导致字段缺失(need/context/emotion_score 缺失),候选池需自动提高通用安全内容占比,并视为至少进入 **L1**(限制 `personalization_power ≤ 0.5`)。 + +### 6.2 Hard Filter(硬性过滤) + +基于 `risk_flags` 与产品规则做剔除(说明文档 3.x): + +- `block_health_medical`:全场景硬过滤。 +- `U.stage.unknown=1`:过滤 `unsafe_for_stage_unknown`。 +- `U.stage.parenting=1`:过滤 `unsafe_for_stage_parenting`。 +- `U.emotion_score≤0.2`:过滤 `unsafe_for_emotion_low`。 +- 跨维度规则:`U.stage.unknown=1` 且 `need_suitability[parenting_pressure]=1` 且 `personalization_power=1` → 禁推。 + +Hard Filter 后需输出 `candidate_pool_size_after_hard_filter` 以及若清空的 `empty_reason`。 + +### 6.3 Soft Scoring(软性打分) + +采用说明文档的线性加权结构: + +最终分: + +\[ +final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big) +\] + +其中 + +\[ +S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context} +\] + +缺失字段的保守计算(说明文档 V1.2,必须实现): + +- `U.need` 缺失 → `S_need = 0.5` +- `U.context` 缺失 → `S_context = 0.5` +- `U.emotion_score` 缺失 → `S_emotion = 0.8`(general 仍为 0.8) + +个性化加成(必须受回退梯度约束): + +\[ +S_{personal}=\alpha \cdot personalization\_power \cdot \max(S_{need}, S_{context}) +\] + +不确定性惩罚(Push 建议默认开启,模块需支持开关): + +\[ +P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot personalization\_power +\] + +### 6.4 Rerank(重排:多样性/新鲜度/疲劳) + +最小要求: + +- **去重**:排除 `already_recommended_ids` 与 `touched_or_viewed_ids`(同一句不重复)。 +- **多样性**:作者/模板/标签多样性;Feed 场景建议使用 MMR(说明文档 4.5)。 +- **频控与冷却**(Push/Widget 必做):同句/同作者/同模板在窗口 X 天内不重复(具体 X 由 plan 定参数)。 + +--- + +## 7. 回退策略(Fallback Ladder) + +候选不足或过滤/频控导致 served_k 过少时,必须按梯度回退,并输出最终 `fallback_level_final`: + +- **L0(正常)**:按场景默认召回配比。 +- **L1(放宽匹配)**:need/context 命中阈值放宽;`personalization_power ≤ 0.5`。 +- **L2(回退通用池)**:增加 general 低风险句;`personalization_power = 0`。 +- **L3(兜底安全池)**:仅从通用安全句库/白名单池抽取。 + +规则: + +- 回退时必须“降个性化与降风险”,尤其 Push。 +- 每次回退都要重新统计候选规模,并记录触发原因(如 `hard_filter_all` / `freqcap_all` / `pool_empty`)。 + +--- + +## 8. 场景默认参数(V1 建议) + +### 8.1 Feed(Exploration-first) + +- 候选配比:匹配 60% + 通用 30% + 探索 10% +- 权重建议:`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30` +- 序列:Top1 最高分,后续使用 MMR(λ=0.7) + +### 8.2 Push(Precision & Safety-first) + +- 候选配比:need 强命中 70% + emotion 命中 20% + 通用安全 10% +- 权重建议:`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05` +- 默认启用 `P_uncertainty`;当 `fallback_level_final>0` 或画像缺失/低置信度时自动降个性化 + +### 8.3 Widget(Consistency & Brand-first) + +- 候选池:以 general + `personalization_power≤0.5` 为主 +- 权重建议:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20` +- 情绪区间约束:建议 `emotion_score` 限制在 0.4~0.8(过低/过高降权或过滤,具体由 plan 定) + +--- + +## 9. 数据库与存储契约(DB Contract) + +模块需要 DB 提供以下能力(不限定表名实现方式): + +- **按标签召回**:能按 need/context/stage/general、personalization_power、risk_flags 等条件筛选候选。 +- **按 ID 批量拉取**:用于补全曝光历史或在 rerank 时补字段。 +- **安全池支持**:L3 兜底必须能拉到一批“通用安全句”(白名单/低风险集合)。 + +性能要求(V1): + +- 单次推荐(k<=30)应避免 N+1 查询;候选批量拉取 + 内存打分。 +- 查询需可加索引:`stage`、`general`、`personalization_power`、(以及用于召回的标签字段)。 + +--- + +## 10. 可观测性(事件与指标) + +每次推荐(Feed session / 每次 Push / 每日 Widget)必须能产出以下字段(由上层统一打点即可): + +- `candidate_pool_size_raw` +- `candidate_pool_size_after_hard_filter` +- `candidate_pool_size_after_dedup` +- `candidate_pool_size_after_freqcap` +- `fallback_level_final` +- `served_k` +- `empty_reason`(served_k=0 时必填) + +建议额外输出(用于调参/排查): + +- `scene` +- `conf_U` +- `missing_fields`(need/context/emotion 的缺失情况) +- `risk_filtered_count_by_flag`(可选聚合) + +--- + +## 11. 安全与合规 + +- 严禁返回 Hard Filter 禁推内容。 +- 对 Push 场景,默认倾向“低风险 + 低个性化强度”;当画像不确定或字段缺失时必须进一步降个性化。 +- 不在仓库写入真实数据库账号密码;连接串统一从 `DATABASE_URL` 环境变量读取(已由 `server/app/core/config.py` 支持)。 + +--- + +## 12. 验收标准(Acceptance Criteria) + +- 能以统一接口在三种场景运行:输入画像 + 曝光集合 → 输出 TopK 句子。 +- 画像字段缺失时仍能产出排序,并触发降个性化策略(至少 L1),不会报错。 +- Hard Filter 规则生效:命中 `block_health_medical` 等风险标记的内容绝不出现在输出中。 +- 候选不足时会逐级回退并输出 `fallback_level_final`,不会出现无输出且无原因字段的情况。 +- 输出包含用于上层打点的 meta 统计字段,便于观测候选规模与回退率。 + +--- + +## 13. 子模块规范索引(modules/) + +> 子模块规范用于实现拆分;本文件保留大需求高层约束与对外契约摘要。 + +- `modules/db-design/spec.md`:数据库设计与迁移(当前库为空,本需求内补齐) +- `modules/content-repository/spec.md`:数据访问层(按画像与场景拉取候选、按 ID 批量查) +- `modules/reco-engine/spec.md`:推荐引擎编排(候选→过滤→打分→重排→回退) +- `modules/scoring/spec.md`:打分与惩罚项(严格对齐规则文档;含缺失字段保守策略) +- `modules/rerank-freqcap/spec.md`:重排/去重/频控(Feed 的 MMR;Push/Widget 冷却) +- `modules/observability/spec.md`:可观测事件结构与指标(candidate_pool_size_* 等) +- `modules/integration-api-worker/spec.md`:API 与 Celery 集成(请求入参、响应、任务封装) + diff --git a/spec_kit/User Profile Scoring/plan.md b/spec_kit/User Profile Scoring/plan.md new file mode 100644 index 0000000..11d3089 --- /dev/null +++ b/spec_kit/User Profile Scoring/plan.md @@ -0,0 +1,184 @@ +# User Profile Scoring(技术计划 · V1.2) + +## 1. 计划目标 + +在**客户端(TypeScript)**实现一个可复用的“用户画像计算”模块: + +- **输入**:Onboarding 问卷答案(每题**可跳过**;字段可缺失/为 `null`) +- **输出**:严格符合 `spec_kit/User Profile Scoring/spec.md`(V1.2)的用户画像对象(含 `profile_answered`、四维度字段、`profile_confidence`) +- **硬规则**:输出一份“下游可直接执行/可观测”的硬规则结果,用于推荐/Push/Widget 统一复用(对齐 V1.2 规则语义) +- **约束**:不抛异常、不报错;非法值按**跳过**处理 + +## 2. 默认技术决策(本计划采用) + +- **实现位置**:`client/src/features/userProfileScoring/` +- **语言**:TypeScript(纯函数为主,便于测试与跨端复用) +- **时间与可测性**:允许调用方注入 `generatedAt/now`,默认使用 `new Date()`,保证可回归测试 +- **规则对齐来源**: + - 画像与置信度:`设计说明文档/客戶端問卷打分規則.md`(V1.2) + - 下游过滤语义:`设计说明文档/個性化推薦算法規則.md`(Hard Filter / `risk_flags` 语义) + +## 3. 输入与输出契约(V1.2,含跳过) + +### 3.1 输入:问卷答案(允许部分缺失/跳过) + +定义输入类型 `QuestionnaireAnswersV1_2`(所有字段可选;也允许显式 `null`): + +- `mom_stage?: "expecting" | "parenting" | "unknown" | null` +- `emotion?: "low" | "overwhelmed" | "tired" | "neutral" | "calm" | "joyful" | null` +- `context?: "family" | "work" | "relationship" | "friends" | "health" | null` +- `need?: "emotional_support" | "parenting_pressure" | "self_worth" | "anxiety_relief" | "rest_balance" | null` + +非法值处理策略: + +- 字段存在但值不在枚举内:按**跳过**处理(归一化为 `undefined`),不抛错 + +### 3.2 输出:用户画像(严格对齐 spec.md V1.2) + +输出 `UserProfileV1_2`,满足 `spec.md` 的字段与取值: + +- `profile_version: "v1.2"` +- `profile_source: "questionnaire"` +- `profile_generated_at: string`(ISO8601) +- `profile_confidence: number`(0–1) +- `profile_answered: { stage: boolean; emotion: boolean; context: boolean; need: boolean }` +- `stage: { expecting?: 0|1; parenting?: 0|1; unknown: 0|1 }` + - 注:题目跳过时按安全策略输出 `unknown=1` +- `emotion_score: number | null` +- `context: Record`(稀疏 one-hot;题目跳过时为 `{}`) +- `need: Record`(稀疏 one-hot;题目跳过时为 `{}`) + +### 3.3 额外输出:硬规则(供下游直接执行/打点) + +`spec.md` 要求“向下游暴露可直接执行的硬规则结果”,但不强制字段结构。本计划采用以下扩展字段(不破坏 `spec.md` 核心结构): + +- `rule_hits: string[]` + - 仅用于可观测/回归测试(例如 `["unsafe_for_emotion_low"]`) +- `hard_rules: { ... }` + - `forbidden_risk_flags: string[]`(对齐内容侧/推荐侧 `risk_flags` 名称) + - `forbidden_content_predicates: Array<{ id: string; when_user: object; forbid_content: object }>` + - 用于表达“需要同时看用户与内容字段才能执行”的规则(例如 unknown + 育儿压力 + 强个性化) + +## 4. 跳过题目的处理策略(V1.2:安全优先 + 可观测) + +> 目标:题目跳过时不报错;画像仍可计算;并通过 `conf_U` 与下游 `P_uncertainty` 思路自动降个性化/降风险。 + +- **mom_stage 跳过**: + - 输出:`stage.unknown = 1` + - `profile_answered.stage = false` +- **emotion 跳过**: + - 输出:`emotion_score = null`(不强行填 0.6,避免制造“伪精确画像”) + - `profile_answered.emotion = false` +- **context 跳过**: + - 输出:`context = {}` + - `profile_answered.context = false` +- **need 跳过**: + - 输出:`need = {}` + - `profile_answered.need = false` + +## 5. 画像计算规则(映射 + 置信度 + 硬规则) + +### 5.1 映射规则(对齐 `客戶端問卷打分規則.md` V1.2) + +- `mom_stage` → `stage.*`(one-hot;若跳过则 `unknown=1`) +- `emotion` → `emotion_score`: + - `low=0.0`,`overwhelmed=0.2`,`tired=0.4`,`neutral=0.6`,`calm=0.8`,`joyful=1.0` + - 若跳过:`emotion_score = null` +- `context` → 稀疏 one-hot:例如 `work` → `{ work: 1 }`;若跳过:`{}` +- `need` → 稀疏 one-hot:例如 `rest_balance` → `{ rest_balance: 1 }`;若跳过:`{}` + +### 5.2 置信度(`profile_confidence / conf_U`)计算(V1.2) + +时间衰减(与 `spec.md` 一致): + +- 0–7 天:`conf_time = 1.0` +- 7–30 天:线性衰减到 `0.7` +- 30 天以上:`conf_time = 0.5` + +作答完整度因子(对齐 `客戶端問卷打分規則.md` V1.2): + +- `answered_count = Σ I[profile_answered.* = true]`,`total_questions = 4` +- `completion = answered_count / total_questions` +- `completion_factor = 0.5 + 0.5 * completion` +- `profile_confidence = clamp(conf_time * completion_factor, 0.2, 1.0)` + +### 5.3 硬规则输出(对齐 V1.2) + +本模块输出 `rule_hits` 与 `hard_rules`,用于下游先过滤、后打分: + +1) **按用户状态映射到内容 `risk_flags` 的禁推** + +- 若 `stage.unknown = 1`: + - `rule_hits += ["unsafe_for_stage_unknown"]` + - `hard_rules.forbidden_risk_flags += ["unsafe_for_stage_unknown"]` +- 若 `stage.parenting = 1`: + - `rule_hits += ["unsafe_for_stage_parenting"]` + - `hard_rules.forbidden_risk_flags += ["unsafe_for_stage_parenting"]` +- 若 `emotion_score !== null` 且 `emotion_score <= 0.2`: + - `rule_hits += ["unsafe_for_emotion_low"]` + - `hard_rules.forbidden_risk_flags += ["unsafe_for_emotion_low"]` + +2) **跨维度产品规则(需要同时看用户与内容字段)** + +- 若 `stage.unknown = 1`: + - 增加一个可执行谓词,表达“禁推 育儿压力且高个性化内容”: + - `when_user`: `{ stage_unknown: true }` + - `forbid_content`: `{ need: "parenting_pressure", personalization_power: 1 }` + - 该规则由下游在内容侧字段存在时执行(对齐 `spec.md` 7 与 `客戶端問卷打分規則.md` 6 的 V1.2 口径) + +> 注:本模块不判断内容的 `personalization_power`(属于内容侧),只输出“可执行条件”。 + +## 6. 文件与目录规划(客户端) + +在 `client/src/` 新增/调整: + +- `client/src/features/userProfileScoring/types.ts` + - `QuestionnaireAnswersV1_2` / `UserProfileV1_2` / 枚举联合类型 +- `client/src/features/userProfileScoring/scoring.ts` + - `buildUserProfileFromQuestionnaire(answers, options): UserProfileV1_2 & { rule_hits; hard_rules }` + - `computeTimeConfidence(generatedAt, now): number` + - `normalizeAnswers(raw): QuestionnaireAnswersV1_2`(非法值 → 跳过) + - `computeProfileAnswered(normalized): profile_answered` + - `computeProfileConfidence(conf_time, profile_answered): number` +- `client/src/features/userProfileScoring/index.ts` + - 对外导出(供 Onboarding/推荐/Push 调用) + +## 7. 测试计划(客户端可回归) + +若工程未提供 `test` 脚本,建议补齐最小单元测试能力(只测纯函数): + +- 新增 `vitest`(或 `jest`)为 devDependencies,并在 `client/package.json` 增加 `test` 脚本 +- 为 `buildUserProfileFromQuestionnaire` 增加用例(与 `spec.md` 第 10 章一致): + - 映射正确性(完整作答) + - 置信度时间衰减(0–7/7–30/30+) + - 硬规则触发(`emotion_score<=0.2`、`stage.unknown`、`stage.parenting`) + - 跳过场景(V1.2): + - 全部跳过(输入 `{}`):`profile_answered` 全 false;`stage.unknown=1`;`emotion_score=null`;`context/need={}`;`profile_confidence=0.5`(当 conf_time=1 时) + - 仅回答 1 题:验证 `completion_factor` 与 clamp(最小 0.2)逻辑正确 + +## 8. 实施步骤 + +1. 在 `client/src/features/userProfileScoring/` 增加类型定义与常量映射表(V1.2) +2. 实现答案归一化(非法值按跳过;支持 `null/undefined`) +3. 实现画像生成主函数(含 `profile_answered`、元信息生成、置信度计算) +4. 实现硬规则命中与结构化输出(`forbidden_risk_flags` + `forbidden_content_predicates`) +5. 补齐最小测试框架与单测(仅纯函数) +6. Onboarding 流程完成处接入(后续任务):问卷提交后生成画像对象;持久化/上传由上层决定 + +## 9. 验收标准 + +- 任意问卷答案输入(含任意题跳过/非法值),都能生成符合 `spec.md`(V1.2)结构的用户画像对象(不抛错) +- 输出包含: + - V1.2 元信息(`profile_version/source/generated_at/confidence/profile_answered`) + - 四维度字段(`stage/emotion_score/context/need`;允许跳过后的 `null/{}`) + - `rule_hits` 与 `hard_rules`(供下游执行 Hard Filter 与打点) +- 置信度: + - 时间衰减符合 `spec.md` 6.2 + - 跳过越多 `profile_confidence` 越低(按 V1.2 完整度因子),并被 clamp 到 `[0.2, 1.0]` + +## 10. 后续演进(不阻塞 V1.2) + +- 支持 `context/need` 多选(输出仍为 one-hot,可多个 key=1) +- 引入 `profile_source="mixed"`:行为信号修正 `emotion_score` 或补全缺失维度(保留 questionnaire 原始画像用于可观测) +- 将硬规则输出进一步与推荐系统规则引擎 schema 对齐(使 `forbidden_content_predicates` 可直接落入规则引擎) + diff --git a/spec_kit/User Profile Scoring/spec.md b/spec_kit/User Profile Scoring/spec.md index 4a5a901..829e5e3 100644 --- a/spec_kit/User Profile Scoring/spec.md +++ b/spec_kit/User Profile Scoring/spec.md @@ -4,13 +4,76 @@ 本模块用于将 Onboarding 问卷答案转换为标准化、可计算、可版本化的**用户画像(User Profile)**,供其他模块(个性化推荐 / Push 推送排序 / 内容过滤)统一调用。 +### 1.1 问卷信息(V1.2,供外部 UI 实现;支持可跳过) + +本模块**不接入 UI**,仅定义“问卷答案 → 用户画像”的规则。外部 UI(或服务端)只需在用户完成 Onboarding 问卷后,按以下题目将答案编码为枚举值并调用本模块。 + +V1.2 题目集合(每题**可跳过**;作答时为单选;**中英对齐**): + +#### Q1 母职阶段(`mom_stage`) + +- 中文:你处于哪个母职阶段? +- English: Which stage of motherhood are you in? + +| UI 展示(中文) | UI 展示(English) | 枚举值(提交给本模块) | +| --- | --- | --- | +| 备孕 / 怀孕 / 准备迎接宝宝 | Pregnant / Preparing | `expecting` | +| 已经在育儿阶段 | Parenting | `parenting` | +| 不想说 / 不希望被母职身份定义 | Prefer not to say | `unknown` | + +#### Q2 当下情绪(`emotion`) + +- 中文:你现在感觉如何? +- English: How are you feeling right now? + +| UI 展示(中文) | UI 展示(English) | 枚举值(提交给本模块) | +| --- | --- | --- | +| 低落 | Low | `low` | +| 过载 / 不堪重负 | Overwhelmed | `overwhelmed` | +| 疲惫 | Tired | `tired` | +| 还好 / 一般 | Okay | `neutral` | +| 平静 | Calm | `calm` | +| 愉悦 | Joyful | `joyful` | + +#### Q3 主要触发场景(`context`) + +- 中文:这些感受主要受到什么影响? +- English: What’s been influencing how you feel? + +| UI 展示(中文) | UI 展示(English) | 枚举值(提交给本模块) | +| --- | --- | --- | +| 家庭 | Family | `family` | +| 工作或学习 | Work or study | `work` | +| 亲密关系 | Relationship | `relationship` | +| 朋友 | Friends | `friends` | +| 健康 | Health | `health` | + +#### Q4 最需要的支持(`need`) + +- 中文:你现在最需要哪一种支持? +- English: What kind of support do you need most right now? + +| UI 展示(中文) | UI 展示(English) | 枚举值(提交给本模块) | +| --- | --- | --- | +| 情绪支持 | Emotional support | `emotional_support` | +| 育儿压力 | Parenting pressure | `parenting_pressure` | +| 自我价值 | Self-worth | `self_worth` | +| 缓解焦虑 | Anxiety relief | `anxiety_relief` | +| 休息与平衡 | Rest & balance | `rest_balance` | + +说明: + +- 外部 UI 可自由决定视觉样式,但建议文案**中英成对出现**并与上表一致;无论文案如何变化,**答案编码必须与上述枚举一致**,以保证跨端一致性与可回归测试。 +- 每一题允许用户**跳过不答**;当题目被跳过时,外部调用可选择“不传该字段”或传 `null`(以具体接口约定为准),本模块将按第 6.1.1 节输出 `profile_answered` 并生成保守画像。 +- 后续若扩展为多选(例如 `context/need` 多选),应遵循第 8 节兼容策略,保持 V1 输出结构可向后兼容。 + 该模块的设计原则: - **不做心理诊断**:只描述「此刻允许被如何对待」 - 区分 **身份(离散)** 与 **状态(连续)** - 允许「通用」与「高个性化」内容并存,并通过置信度控制“个性化力度” -来源规则文档:`设计说明文档/客戶端問卷打分規則.md`(V1 / V1.1 工程补充) +来源规则文档:`设计说明文档/客戶端問卷打分規則.md`(V1 / V1.1 / V1.2) ## 2. 模块定位与边界 @@ -37,38 +100,43 @@ ### 4.1 输入(问卷答案) -V1 假设每题为**单选**: +V1.2:每题**可跳过**;作答时为**单选**: -- `mom_stage`:`expecting | parenting | unknown` -- `emotion`:`low | overwhelmed | tired | neutral | calm | joyful` -- `context`:`family | work | relationship | friends | health` -- `need`:`emotional_support | parenting_pressure | self_worth | anxiety_relief | rest_balance` +- `mom_stage?`:`expecting | parenting | unknown` +- `emotion?`:`low | overwhelmed | tired | neutral | calm | joyful` +- `context?`:`family | work | relationship | friends | health` +- `need?`:`emotional_support | parenting_pressure | self_worth | anxiety_relief | rest_balance` -> 说明:多选 `context/need` 可在后续版本扩展,但 V1 输出结构需保持可兼容升级。 +> 说明: +> +> - 带 `?` 表示该字段允许缺失(题目跳过)。 +> - 多选 `context/need` 可在后续版本扩展,但 V1 输出结构需保持可兼容升级。 ### 4.2 输出(用户画像) 输出为一个 JSON 对象(或等价的 TypeScript 类型),包含: -- **元信息(V1.1)** - - `profile_version`: string(如 `"v1.1"`) +- **元信息(V1.2)** + - `profile_version`: string(如 `"v1.2"`) - `profile_source`: `"questionnaire"`(后续可扩展 `"behavior"` / `"mixed"`) - `profile_generated_at`: string(ISO8601 时间戳) - `profile_confidence`: number(0–1) -- **四个维度(V1)** - - `stage`: one-hot - - `emotion_score`: number(0–1) - - `context`: one-hot - - `need`: one-hot + - `profile_answered`: object(标记各题是否作答) +- **四个维度(V1,V1.2 允许缺失/跳过)** + - `stage`: one-hot(若题目跳过,按安全策略输出 `unknown=1`) + - `emotion_score`: number(0–1)或 `null`(题目跳过) + - `context`: one-hot 或空对象 `{}`(题目跳过) + - `need`: one-hot 或空对象 `{}`(题目跳过) 推荐的最小输出示例(结构示意): ```json { - "profile_version": "v1.1", + "profile_version": "v1.2", "profile_source": "questionnaire", "profile_generated_at": "2026-01-30T00:00:00Z", "profile_confidence": 1.0, + "profile_answered": { "stage": true, "emotion": true, "context": true, "need": true }, "stage": { "expecting": 0, "parenting": 1, "unknown": 0 }, "emotion_score": 0.4, "context": { "work": 1 }, @@ -76,6 +144,22 @@ V1 假设每题为**单选**: } ``` +题目跳过时的最小输出示例(V1.2): + +```json +{ + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-01-30T00:00:00Z", + "profile_confidence": 0.5, + "profile_answered": { "stage": false, "emotion": false, "context": false, "need": false }, + "stage": { "unknown": 1 }, + "emotion_score": null, + "context": {}, + "need": {} +} +``` + ## 5. 画像字段定义(V1) ### 5.1 mom_stage(母职阶段,离散) @@ -89,6 +173,7 @@ V1 假设每题为**单选**: 语义约束: - `unknown` 的语义是:**不希望被母职身份定义**(不是“没有孩子”) + - 若题目被跳过:按安全策略输出 `stage.unknown = 1`,同时 `profile_answered.stage=false` ### 5.2 emotion(当下情绪,连续 0–1) @@ -105,7 +190,7 @@ V1 假设每题为**单选**: | Calm | emotion: calm | 0.8 | | Joyful | emotion: joyful | 1.0 | -输出:`emotion_score ∈ [0,1]` +输出:`emotion_score ∈ [0,1]`;若题目被跳过则输出 `null`(或不输出该字段),并通过 `profile_answered.emotion=false` 表示“未作答” ### 5.3 context(影响来源,离散) @@ -117,6 +202,11 @@ V1 假设每题为**单选**: - Friends → `context.friends = 1` - Health → `context.health = 1` +若题目被跳过: + +- 输出 `context = {}` +- `profile_answered.context = false` + 语义说明: - `context` 不是情绪,而是情绪的“触发场景” @@ -132,19 +222,38 @@ V1 假设每题为**单选**: - Anxiety relief → `need.anxiety_relief = 1` - Rest & balance → `need.rest_balance = 1` +若题目被跳过: + +- 输出 `need = {}` +- `profile_answered.need = false` + 语义说明: - `need` 是**推荐权重最高的维度** - 表示“她现在最缺的是哪一种心理资源” -## 6. 元信息与置信度(V1.1) +## 6. 元信息与置信度(V1.2) ### 6.1 profile_version / source / generated_at -- `profile_version`:用于规则升级与兼容(建议从 `"v1.1"` 起步) +- `profile_version`:用于规则升级与兼容(建议从 `"v1.2"` 起步;本规范与规则文档已对齐 V1.2) - `profile_source`:固定为 `"questionnaire"`(后续可扩展) - `profile_generated_at`:画像生成时间(用于推送降风险) +### 6.1.1 profile_answered(支持题目可跳过,V1.2) + +> 目的:区分“明确选择 unknown”与“题目被跳过导致 unknown/缺失”,并让下游在不确定时自动降个性化/降风险。 + +- `profile_answered`:记录每一题是否作答 + - `stage/emotion/context/need`:`true/false` + +输出约定(与设计说明文档保持一致,V1.2): + +- `mom_stage` 被跳过:按安全策略输出 `stage.unknown = 1`,同时 `profile_answered.stage=false` +- `emotion` 被跳过:`emotion_score` 输出 `null`(或不输出该字段),同时 `profile_answered.emotion=false` +- `context` 被跳过:`context` 输出为空对象 `{}`,同时 `profile_answered.context=false` +- `need` 被跳过:`need` 输出为空对象 `{}`,同时 `profile_answered.need=false` + ### 6.2 profile_confidence(conf_U) 默认规则(可配置参数): @@ -155,6 +264,13 @@ V1 假设每题为**单选**: - 7–30 天:线性衰减到 `0.7` - 30 天以上:`conf_U = 0.5`(除非用户重新做问卷或有行为信号更新) +V1.2 补充:叠加“作答完整度”(题目可跳过场景): + +- 设 `answered_count = Σ I[profile_answered.* = true]`,总题数 `total_questions = 4` +- `completion = answered_count / total_questions` +- `completion_factor = 0.5 + 0.5 * completion` +- `conf_U = clamp(conf_time * completion_factor, 0.2, 1.0)`(其中 `conf_time` 按时间衰减得到) + 下游使用建议: - Push 推送等高风险场景:当 `conf_U` 低时,应启用不确定性惩罚(例如 `P_uncertainty`)并限制个性化力度上限。 @@ -163,10 +279,11 @@ V1 假设每题为**单选**: 本模块应向下游暴露“可直接执行”的硬规则结果(例如 `forbidden_tags` 或 `content_tone_blacklist`),以保证在任何排序之前先做安全过滤。 -V1 核心禁推规则: +V1.2 核心禁推规则: 1. **`stage.unknown = 1`** - - 禁推:强指向育儿压力的内容(`need: parenting_pressure`) + - 禁推:强指向育儿压力且高个性化的内容(用于避免“把她当妈妈/正在育儿”的对话) + - 条件(由下游在内容侧判定):`need: parenting_pressure` 且 `personalization_power = 1` 2. **`stage.parenting = 1`** - 禁推:明确怀孕 / 孕期 / 胎动等内容 3. **`emotion_score ≤ 0.2`(low / overwhelmed)** @@ -188,3 +305,172 @@ V1 核心禁推规则: - 输出包含元信息(版本、来源、时间、置信度)与四维度字段 - 硬规则输出可被推荐/推送模块直接用于过滤(先过滤、后打分) +## 10. 测试(输入题目答案 → 输出用户画像) + +> 本模块作为**独立模块**暂不接入 UI;外部 UI 只需按本规范提供枚举答案,本模块输出标准化用户画像对象。以下用例用于外部 UI / 服务端编写自动化测试,验证“输入答案 → 输出画像”稳定且可版本化。 + +### 10.1 测试约定(为可重复性而设) + +- `profile_version`:固定为 `"v1.2"` +- `profile_source`:固定为 `"questionnaire"` +- `profile_generated_at`:测试用例中使用固定时间戳(避免随系统时间波动) +- `profile_confidence`: + - 若“刚完成问卷”,测试时可令 `now == profile_generated_at`,期望 `profile_confidence = 1.0` + - 若测试时间衰减,则固定 `profile_generated_at` 与 `now`,按第 6.2 节规则计算期望值 + +### 10.2 字段映射用例(基础正确性) + +#### 用例 A:完整映射(parenting + tired + work + rest_balance) + +输入(题目答案): + +```json +{ + "mom_stage": "parenting", + "emotion": "tired", + "context": "work", + "need": "rest_balance" +} +``` + +期望输出(用户画像;示例中 `profile_generated_at` 由测试固定注入): + +```json +{ + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-01-30T00:00:00Z", + "profile_confidence": 1.0, + "profile_answered": { "stage": true, "emotion": true, "context": true, "need": true }, + "stage": { "expecting": 0, "parenting": 1, "unknown": 0 }, + "emotion_score": 0.4, + "context": { "work": 1 }, + "need": { "rest_balance": 1 } +} +``` + +#### 用例 B:unknown 语义(不希望被母职身份定义) + +输入: + +```json +{ + "mom_stage": "unknown", + "emotion": "neutral", + "context": "relationship", + "need": "emotional_support" +} +``` + +期望输出(关键点:`stage.unknown = 1`): + +```json +{ + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-01-30T00:00:00Z", + "profile_confidence": 1.0, + "profile_answered": { "stage": true, "emotion": true, "context": true, "need": true }, + "stage": { "expecting": 0, "parenting": 0, "unknown": 1 }, + "emotion_score": 0.6, + "context": { "relationship": 1 }, + "need": { "emotional_support": 1 } +} +``` + +#### 用例 A2:全部跳过(V1.2 最小可计算画像) + +输入(所有题都跳过;用“不传字段”的方式表示): + +```json +{} +``` + +期望输出(关键点:`profile_answered` 全 false;`stage.unknown=1`;`emotion_score=null`;`context/need={}`): + +```json +{ + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-01-30T00:00:00Z", + "profile_confidence": 0.5, + "profile_answered": { "stage": false, "emotion": false, "context": false, "need": false }, + "stage": { "unknown": 1 }, + "emotion_score": null, + "context": {}, + "need": {} +} +``` + +### 10.3 置信度衰减用例(时间维度) + +> 本组用例只验证 `profile_confidence` 计算是否符合第 6.2 节;其余字段可沿用任意合法输入。 + +#### 用例 C:0–7 天(不衰减) + +- `profile_generated_at = 2026-01-01T00:00:00Z` +- `now = 2026-01-05T00:00:00Z` + +期望:`profile_confidence = 1.0` + +#### 用例 D:7–30 天(线性衰减到 0.7) + +- `profile_generated_at = 2026-01-01T00:00:00Z` +- `now = 2026-01-11T00:00:00Z`(第 10 天) + +期望:`profile_confidence ≈ 1.0 - 0.3 * (10 - 7) / (30 - 7) ≈ 0.9609` + +> 测试建议:用浮点容差断言(例如 `abs(actual - expected) <= 1e-3`)。 + +#### 用例 E:30 天以上(固定为 0.5) + +- `profile_generated_at = 2026-01-01T00:00:00Z` +- `now = 2026-02-15T00:00:00Z` + +期望:`profile_confidence = 0.5` + +### 10.4 硬规则触发用例(可选,但建议覆盖) + +> 本节只验证“规则是否被触发”。由于第 7 节目前只规定了规则语义,未强约束硬规则输出字段结构,测试可用两种方式之一: +> +> - 方式 1:若实现提供 `hard_rules`(或同义字段),则断言其包含对应禁推项 +> - 方式 2:实现不提供结构化 `hard_rules` 时,至少应提供可观测的 `rule_hits`(字符串数组)用于下游与回归测试 + +#### 用例 F:emotion_score ≤ 0.2 禁推 joyful 调性 + +输入: + +```json +{ + "mom_stage": "expecting", + "emotion": "overwhelmed", + "context": "health", + "need": "anxiety_relief" +} +``` + +期望: + +- `emotion_score = 0.2` +- 规则命中:`emotion_score ≤ 0.2` → 禁推高能量庆祝型(joyful 调性)内容 + +#### 用例 G:stage.unknown 禁推“育儿压力 + 高个性化”内容 + +输入: + +```json +{ + "mom_stage": "unknown", + "emotion": "calm", + "context": "family", + "need": "parenting_pressure" +} +``` + +期望: + +- `stage.unknown = 1` +- 规则命中:`stage.unknown = 1` → 下游禁推满足以下条件的内容: + - `need: parenting_pressure` + - `personalization_power = 1` + diff --git a/spec_kit/User Profile Scoring/tasks.md b/spec_kit/User Profile Scoring/tasks.md new file mode 100644 index 0000000..a06de01 --- /dev/null +++ b/spec_kit/User Profile Scoring/tasks.md @@ -0,0 +1,143 @@ +# User Profile Scoring(任务清单 · V1.2) + +> 依据:`spec_kit/User Profile Scoring/plan.md`(V1.2) +> +> 规则目标:在客户端实现“问卷答案(可跳过)→ 用户画像(含 profile_answered/confidence)+ 硬规则输出”,供推荐/Push/Widget 复用。 + +## 任务状态说明 + +- `[ ]`:未开始 +- `[~]`:进行中 +- `[x]`:已完成 +- `[-]`:已取消/不做 + +--- + +## A. 代码实现(客户端 TypeScript 模块) + +### A1. 建立目录与导出入口 + +- [x] 在 `client/src/features/` 下新增目录 `userProfileScoring/` + - **验收**:目录存在,且后续文件均放入该目录 +- [x] 新建 `client/src/features/userProfileScoring/index.ts` + - **内容**:对外导出主函数与类型(避免上层直接深路径 import) + - **验收**:上层可仅从 `.../userProfileScoring` 引入 + +### A2. 定义类型与枚举(V1.2) + +- [x] 新建 `client/src/features/userProfileScoring/types.ts` + - **定义输入类型**:`QuestionnaireAnswersV1_2` + - 字段允许 `undefined`/`null`:`mom_stage/emotion/context/need` + - **定义输出类型**:`UserProfileV1_2` + - 固定 `profile_version: "v1.2"`、`profile_source: "questionnaire"` + - `profile_answered`:`{ stage; emotion; context; need }` + - `emotion_score: number | null` + - `context/need: Record`(稀疏 one-hot;跳过为 `{}`) + - **定义扩展输出类型**:`UserProfileV1_2_Extended` + - `rule_hits: string[]` + - `hard_rules: { forbidden_risk_flags: string[]; forbidden_content_predicates: ... }` + - **验收**:类型字段与 `spec_kit/User Profile Scoring/spec.md`(V1.2)一致 + +### A3. 实现答案归一化(非法值按跳过) + +- [x] 新建 `client/src/features/userProfileScoring/scoring.ts` +- [x] 实现 `normalizeAnswers(raw): QuestionnaireAnswersV1_2` + - **规则**: + - 值不在枚举内 → 归一化为 `undefined` + - `null` 保留为 `null`(表示显式跳过/无值) + - **验收**:任意输入不抛错;输出只包含合法枚举/`undefined`/`null` + +### A4. 计算 profile_answered(V1.2) + +- [x] 实现 `computeProfileAnswered(normalized)`: + - `stage = (mom_stage !== undefined && mom_stage !== null)` + - `emotion/context/need` 同理 + - **验收**:与 `spec.md` 6.1.1 一致;可区分“缺失/跳过”与“明确作答” + +### A5. 计算时间衰减 conf_time + +- [x] 实现 `computeTimeConfidence(generatedAt, now): number` + - **规则**(与 `spec.md` 6.2 一致): + - 0–7 天:1.0 + - 7–30 天:线性衰减到 0.7 + - 30 天以上:0.5 + - **验收**:给定固定时间输入,输出符合分段规则(允许浮点误差) + +### A6. 计算 profile_confidence(V1.2 完整度因子 + clamp) + +- [x] 实现 `computeProfileConfidence(conf_time, profile_answered): number` + - **规则**(与 `设计说明文档/客戶端問卷打分規則.md` V1.2 一致): + - `answered_count = Σ I[profile_answered.* = true]` + - `completion = answered_count / 4` + - `completion_factor = 0.5 + 0.5 * completion` + - `conf = clamp(conf_time * completion_factor, 0.2, 1.0)` + - **验收**: + - 全部跳过且 `conf_time=1` → `conf=0.5` + - 全部作答且 `conf_time=1` → `conf=1.0` + - 极端情况下不会低于 0.2 / 高于 1.0 + +### A7. 生成四维度画像字段(含跳过策略) + +- [x] 实现 `buildUserProfileFromQuestionnaire(answers, options)` + - **元信息**: + - `profile_version="v1.2"` + - `profile_source="questionnaire"` + - `profile_generated_at`:允许注入;默认 `new Date().toISOString()` + - **stage**: + - 若 `mom_stage` 缺失/为 `null`:按安全策略输出 `stage.unknown=1` + - 若为枚举:对应 one-hot + - **emotion_score**: + - 若跳过:`null` + - 否则按映射表输出数值 + - **context/need**: + - 若跳过:输出 `{}` + - 否则输出稀疏 one-hot(例如 `{ work: 1 }`) + - **验收**:与 `spec.md` 4.2/5.x/6.x 一致 + +### A8. 硬规则与可观测输出(rule_hits / hard_rules) + +- [x] 在 `buildUserProfileFromQuestionnaire` 内生成 `rule_hits` 与 `hard_rules` + - **risk_flags 映射规则**(与 `spec.md` 7 一致): + - `stage.unknown=1` → `unsafe_for_stage_unknown` + - `stage.parenting=1` → `unsafe_for_stage_parenting` + - `emotion_score !== null && emotion_score<=0.2` → `unsafe_for_emotion_low` + - **跨维度规则谓词**(与 `spec.md` 7、客户端规则 V1.2 一致): + - 若 `stage.unknown=1`:追加谓词 + - `id`: 建议 `unknown_block_parenting_pressure_personalized` + - `when_user`: `{ stage_unknown: true }` + - `forbid_content`: `{ need: "parenting_pressure", personalization_power: 1 }` + - **验收**: + - 输出结构稳定 + - 不依赖内容侧字段(本模块只输出“可执行条件”) + +--- + +## B. 单元测试(只测纯函数) + +> 若当前工程暂不引入测试框架,可先把测试用例写成“可执行脚本/最小断言”;但建议最终落地 `vitest` 或 `jest`。 + +### B1. 建立测试框架(可选但建议) + +- [x] 若 `client/` 尚无测试:引入 `vitest`(或 `jest`)并添加 `test` 脚本 + - **验收**:能在本机运行测试并输出结果 + +### B2. 覆盖 spec.md 的关键用例 + +- [x] 用例:完整作答映射正确(对应 `spec.md` 用例 A/B) +- [x] 用例:时间衰减(对应 `spec.md` 用例 C/D/E) +- [x] 用例:全部跳过 `{}`(对应 `spec.md` 用例 A2) + - 断言:`profile_answered` 全 false;`stage.unknown=1`;`emotion_score=null`;`context/need={}`;`profile_confidence=0.5`(当 conf_time=1) +- [x] 用例:硬规则命中 + - `emotion_score<=0.2` → 含 `unsafe_for_emotion_low` + - `stage.parenting=1` → 含 `unsafe_for_stage_parenting` + - `stage.unknown=1` → 含 `unsafe_for_stage_unknown` 且带跨维度谓词 + +--- + +## C. 接入与文档回写(后续任务) + +- [ ] Onboarding 提交问卷处接入本模块(调用 `buildUserProfileFromQuestionnaire`) + - **验收**:提交后能生成并持久化/上传画像对象(具体存储策略由上层决定) +- [ ] 在推荐/Push/Widget 侧消费 `hard_rules` + - **验收**:Hard Filter 在打分前执行;且能使用跨维度谓词表达 unknown+育儿压力强个性化禁推 + diff --git a/spec_kit/overview.md b/spec_kit/overview.md index d6913bf..866b3b6 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -82,7 +82,10 @@ - **核心范围**:mom_stage(离散)、emotion_score(0–1 连续)、context(离散)、need(离散)四维度;以及 `profile_version/source/generated_at/confidence`(V1.1) - **主要约定**: - 输出结构稳定可版本化,便于规则演进 - - 置信度随时间衰减,用于推送场景降个性化/降风险 - - 硬规则优先于任何分数计算(例如 `unknown` 禁推育儿压力内容等) + - 支持题目可跳过:新增 `profile_answered` 标记各题是否作答;字段缺失时由推荐算法走“通用值 + 不确定性惩罚” + - mom_stage 跳过按安全策略视为 `unknown`(用于避免冒犯),emotion/context/need 跳过不强行填默认值 + - 置信度 `conf_U` = 时间衰减 × 作答完整度(答得越少越不确定),用于推送场景降个性化/降风险 + - 硬规则优先于任何分数计算:`unknown` 仅在“育儿压力且强个性化(personalization_power=1)”时禁推;`block_health_medical` 全场景硬过滤 +- **测试覆盖**:提供“输入题目答案 → 输出用户画像”的回归用例,覆盖字段映射、置信度衰减与硬规则触发(可选但建议) - **阶段产物**: - `spec_kit/User Profile Scoring/spec.md` diff --git a/设计说明文档/個性化推薦算法規則.md b/设计说明文档/個性化推薦算法規則.md index 21602ee..2a23378 100644 --- a/设计说明文档/個性化推薦算法規則.md +++ b/设计说明文档/個性化推薦算法規則.md @@ -5,7 +5,7 @@ > > **共用基礎**: > -> * 用戶畫像:U(mom_stage one-hot、emotion_score、context one-hot、need one-hot) +> * 用戶畫像:U(mom_stage one-hot;emotion/context/need 允许缺失/跳过;含 `profile_confidence/profile_answered`) > * 文案畫像:Cᵢ(stage、emotion_score、context/need suitability、personalization_power、risk_flags;建議含 content_id/author_id/template_id/review_confidence) > * 推薦骨架:Hard Filter → Soft Scoring → Rerank(多樣性/新鮮度/疲勞) @@ -24,6 +24,7 @@ 1. **Candidate Generation(候選集生成)** * 依 U 取一個「候選池」:匹配 need/context/stage 的內容 + 一部分通用內容 + * V1.2:若問卷允許跳過導致 `U.need/U.context/U.emotion_score` 缺失,候選池需自動提高通用安全內容占比,并视为至少进入 L1(限制 `personalization_power≤0.5`) 2. **Hard Filter(硬性過濾)** * 依 risk_flags 與產品規則剔除高風險內容 @@ -93,10 +94,37 @@ S_{core} + S_{personal} + S_{fresh} - P_{fatigue} - P_{repeat} - P_{risk} - P_{u S_{core} = w_{need}S_{need} + w_{emotion}S_{emotion} + w_{stage}S_{stage} + w_{context}S_{context} ] -* `S_need = Cᵢ.need_suitability[U.need]` -* `S_context = Cᵢ.context_suitability[U.context]` +* `S_need = Cᵢ.need_suitability[U.need]`(若 U.need 缺失,见 V1.2) +* `S_context = Cᵢ.context_suitability[U.context]`(若 U.context 缺失,见 V1.2) * `S_stage`:general=1;命中=1;unknown對非unknown=0.7;其餘=0 -* `S_emotion`:若文案 general→0.8;否則 `1-|U.emotion_score-Cᵢ.emotion_score|` +* `S_emotion`:若文案 general→0.8;否則 `1-|U.emotion_score-Cᵢ.emotion_score|`(若 U.emotion_score 缺失,见 V1.2) + +### V1.2:当问卷允许跳过题目时的打分约定(工程必读) + +> 目标:用户画像字段不全时仍能产出排序;并在不确定时自动降个性化/降风险。 + +判定缺失: + +* `U.need` 为空对象 `{}` 或不存在 → 视为缺失 +* `U.context` 为空对象 `{}` 或不存在 → 视为缺失 +* `U.emotion_score` 为 `null`/不存在 → 视为缺失 + +缺失时的保守计算: + +* `S_need`: + * 若 `U.need` 缺失 → `S_need = 0.5` + * 否则 → `S_need = Cᵢ.need_suitability[U.need]` +* `S_context`: + * 若 `U.context` 缺失 → `S_context = 0.5` + * 否则 → `S_context = Cᵢ.context_suitability[U.context]` +* `S_emotion`: + * 若 `U.emotion_score` 缺失 → `S_emotion = 0.8` + * 否则按原定义计算(文案 general 仍为 0.8) + +额外约束(与回退梯度一致,6A): + +* 若 `conf_U` 偏低或字段缺失明显:必须启用 `P_uncertainty` +* 若进入回退梯度(`fallback_level_final>0`):按 L1/L2/L3 限制 `personalization_power` 上限(见 3.1) 個性化加成: [ @@ -125,7 +153,7 @@ P_{uncertainty} = \beta \cdot (1-\text{conf}_U) \cdot (1-\text{conf}_{Cᵢ}) \cd * `U.stage.unknown=1` → 禁推 `unsafe_for_stage_unknown` * `U.stage.parenting=1` → 禁推 `unsafe_for_stage_parenting` * `U.emotion_score≤0.2` → 禁推 `unsafe_for_emotion_low` -* `U.context.health=1` → 禁推 `block_health_medical`(僅擋「醫療/診斷/嚴重暗示」) +* `Cᵢ.risk_flags` 含 `block_health_medical` → **全场景禁推(Hard Filter)**(仅挡「医疗/诊断/严重暗示」) > `soft_health_sensitive` 不做 Hard Filter,改由 Soft Penalty(見 Push 的 P_risk) @@ -218,6 +246,11 @@ P_{uncertainty} = \beta \cdot (1-\text{conf}_U) \cdot (1-\text{conf}_{Cᵢ}) \cd > > * `personalization_power` 上限降為 `≤0.5`(L1)或 `0`(L2/L3) > * 若候選不足,優先回退到「通用安全句庫」,而不是擴大高個性化內容 +> +> V1.2(支持问卷跳过)补充: +> +> * 若 `profile_answered.need=false` 或 `U.need` 缺失:不得按“need 强命中”召回,候选池应提高通用安全句占比,并至少按 L1 降个性化 +> * 若 `profile_answered.emotion=false` 或 `U.emotion_score` 缺失:Push 应偏向 `Cᵢ.emotion_score` 为 `general` 或 0.4–0.8 的稳定内容,并启用 `P_uncertainty` ## 5.3 Push 打分權重(建議) @@ -313,7 +346,7 @@ V1.1 補充(工程預設): * Feed:MMR `λ=0.7` * Push:啟用 `P_uncertainty`(或併入 `P_risk`);`fallback_level>=1` 時 `personalization_power` 上限降為 `≤0.5` -* Health:Hard Filter 僅擋 `block_health_medical`;`soft_health_sensitive` 走 Soft Penalty +* Health:`block_health_medical` 全场景 Hard Filter;`soft_health_sensitive` 走 Soft Penalty > 一句話: > **同一套 U×Cᵢ 打分,按場景調候選池、權重與重排約束。** diff --git a/设计说明文档/句子文案打分規則.md b/设计说明文档/句子文案打分規則.md index 99e59f9..be26ec8 100644 --- a/设计说明文档/句子文案打分規則.md +++ b/设计说明文档/句子文案打分規則.md @@ -214,7 +214,7 @@ C_i.review_confidence // 0–1;AI/人工對標註結果的置信度(缺 * `block_health_medical`(Hard Block) * 具「醫療/診斷/嚴重暗示」:疾病確診、用藥、治療方案、醫囑、手術、急重症、危險警示等 - * 這類內容在 **Health 情境** 下特別容易翻車,建議 Hard Filter + * 这类内容属于高风险:**建议全场景(Feed/Push/Widget)Hard Filter**,不依赖用户是否选择 `context: health` * `soft_health_sensitive`(Soft Penalty) @@ -234,6 +234,10 @@ C_i.review_confidence // 0–1;AI/人工對標註結果的置信度(缺 > AI Reviewer 若不確定,寧可標記 flag +### 置信度缺省值(工程约定,V1.2) + +* 若数据源未提供 `C_i.review_confidence`:推荐系统侧默认按 `0.7` 处理(用于 `P_uncertainty`),并建议后续补齐可观测与标注流程 + --- ## 九、AI Reviewer 打分流程(強烈建議) diff --git a/设计说明文档/客戶端問卷打分規則.md b/设计说明文档/客戶端問卷打分規則.md index d40480f..f6da148 100644 --- a/设计说明文档/客戶端問卷打分規則.md +++ b/设计说明文档/客戶端問卷打分規則.md @@ -33,6 +33,8 @@ * `profile_source`:`questionnaire`(後續可擴展 `behavior` / `mixed`) * `profile_generated_at`:ISO8601 時間戳 * `profile_confidence`(= `conf_U`):`0–1` +* `profile_answered`:各題是否作答(用于区分“跳过” vs “明确选择”) + * `stage/emotion/context/need`:`true/false` `profile_confidence` 建議規則(V1.1 默認,可調參): @@ -42,6 +44,36 @@ * 7–30 天:線性衰減到 `0.7` * 30 天以上:`conf_U = 0.5`(除非用戶重新做問卷或有行為信號更新) +#### V1.2:支持“可跳过题目”的画像输出与置信度(工程必读) + +> 前提:问卷允许用户跳过任意题目(包括 mom_stage / emotion / context / need)。 +> 目标:保证任何情况下都能生成“可计算的 U”,并在不确定时自动降个性化/降风险。 + +**V1.2 输出原则:** + +* **mom_stage 跳过**:按安全策略处理为 `unknown`(即 `U.stage.unknown=1`),同时 `profile_answered.stage=false` +* **emotion 跳过**:`U.emotion_score` 置为缺省(建议直接不输出该字段或输出 `null`),同时 `profile_answered.emotion=false` +* **context 跳过**:`U.context` 输出为空对象 `{}`,同时 `profile_answered.context=false` +* **need 跳过**:`U.need` 输出为空对象 `{}`,同时 `profile_answered.need=false` + +> 说明: +> +> * 之所以“mom_stage 跳过也当作 unknown”,是为了在 Push 等高风险场景优先保证不冒犯(unknown 语义:不希望被母职身份定义)。 +> * emotion/context/need 跳过不强行填默认值,避免制造“伪精确画像”;改由推荐算法在打分时走“通用值 + 不确定性惩罚”(见「个性化推荐算法规则」V1.2 补充)。 + +**V1.2 `conf_U` 计算(在 V1.1 时间衰减基础上叠加“作答完整度”):** + +定义: + +* `conf_time`:按 V1.1 时间衰减得到(0–1) +* `answered_count = Σ I[profile_answered.* = true]`,总题数 `total_questions = 4` +* `completion = answered_count / total_questions` +* `completion_factor = 0.5 + 0.5 * completion`(答得越少越不确定,但仍保留基础信息) + +则: + +* `conf_U = clamp(conf_time * completion_factor, 0.2, 1.0)` + > Push 推送時若 `conf_U` 偏低,推薦系統需啟用 `P_uncertainty` 並限制 `personalization_power` 上限(見「個性化推薦算法規則」V1.1)。 --- @@ -70,6 +102,7 @@ > > * `mom_stage` 是「身份 / 階段」,不是強度,不做連續軸線 > * `unknown` 的語義是:**不希望被母職身份定義**(不是「沒有孩子」) +> * 若该题被跳过:按安全策略输出 `unknown`,同时 `profile_answered.stage=false` ### 推送原則(高層) @@ -96,7 +129,7 @@ | Calm | emotion: calm | 0.8 | | Joyful | emotion: joyful | 1.0 | -生成:`U.emotion_score ∈ [0,1]` +生成:`U.emotion_score ∈ [0,1]`;若该题跳过则输出 `null`(或不输出该字段),并通过 `profile_answered.emotion=false` 表示“未作答” ### 語義說明 @@ -126,6 +159,8 @@ | ----------------------------------------------------- | ----- | | U.ctx.family / work / relationship / friends / health | 1 或 0 | +> 若该题被跳过:`U.context` 输出为空对象 `{}`,同时 `profile_answered.context=false` + ### 語義說明 * context **不是情緒**,而是情緒的「觸發場景」 @@ -153,6 +188,8 @@ | ------------------------------------------------------------------------------------------ | ----- | | U.need.emotional_support / parenting_pressure / self_worth / anxiety_relief / rest_balance | 1 或 0 | +> 若该题被跳过:`U.need` 输出为空对象 `{}`,同时 `profile_answered.need=false` + ### 語義說明 * `need` 是 **推薦權重最高的維度** @@ -164,11 +201,12 @@ 以下規則在推薦時 **優先於任何分數計算**: -### 核心禁推規則(V1) +### 核心禁推規則(V1.2) 1. **mom_stage = unknown** - * ❌ 禁推:`need: parenting_pressure`(強指向) + * ❌ 禁推:**高个性化**育儿压力内容(避免“把她当妈妈/正在育儿”对话) + * 判定条件(与推荐算法一致,V1.2):`Cᵢ.need_suitability[parenting_pressure]=1` 且 `Cᵢ.personalization_power=1` 2. **mom_stage = parenting** @@ -192,6 +230,12 @@ "profile_source": "questionnaire", "profile_generated_at": "2026-01-30T00:00:00Z", "profile_confidence": 1.0, + "profile_answered": { + "stage": true, + "emotion": true, + "context": true, + "need": true + }, "stage": { "expecting": 0, "parenting": 1, @@ -207,6 +251,31 @@ } ``` +### 跳过题目时的输出示例(V1.2) + +> 示例:用户跳过 emotion / context / need;mom_stage 也跳过。 + +```json +{ + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-01-30T00:00:00Z", + "profile_confidence": 0.5, + "profile_answered": { + "stage": false, + "emotion": false, + "context": false, + "need": false + }, + "stage": { + "unknown": 1 + }, + "emotion_score": null, + "context": {}, + "need": {} +} +``` + --- ## 八、設計總結(給產品/工程/AI Reviewer)