diff --git a/.cursor/commands/myspec.code.md b/.cursor/commands/myspec.code.md index ac5ab8d..080d585 100644 --- a/.cursor/commands/myspec.code.md +++ b/.cursor/commands/myspec.code.md @@ -1,4 +1,5 @@ 请开始完成编码 客户端请按照标准的RN架构目录写代码 后端请按照标准的python FastAPI 架构目录写代码 -现在多语言仅支持 EN / TC \ No newline at end of file +现在多语言仅支持 EN / TC +整个task.md执行完毕后需要在对应的overview.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/.cursor/commands/myspec.task.md b/.cursor/commands/myspec.task.md index a3722db..23c4aec 100644 --- a/.cursor/commands/myspec.task.md +++ b/.cursor/commands/myspec.task.md @@ -2,3 +2,4 @@ 根据对应的plan.md 生成task.md 任务清单详细可执行 执行完要标记 +整个task.md执行完毕后需要在对应的overview.md标记 diff --git a/.cursor/commands/myspec.test.md b/.cursor/commands/myspec.test.md new file mode 100644 index 0000000..10c54b2 --- /dev/null +++ b/.cursor/commands/myspec.test.md @@ -0,0 +1 @@ +使用测试工具完成集成测试,并给我一份简单的测试报告 \ No newline at end of file diff --git a/.cursor/rules/rule.mdc b/.cursor/rules/rule.mdc index 02f5745..f8ad39f 100644 --- a/.cursor/rules/rule.mdc +++ b/.cursor/rules/rule.mdc @@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。 输出时根据这个结构生成内容时,请保持文件职责清晰。 简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md 可以通过nvm 切换node版本 +在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库 diff --git a/.gitignore b/.gitignore index f9acc08..8820074 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ .DS_Store *.pem +# Python(运行产物) +__pycache__/ +*.py[cod] + # Node / JS node_modules/ npm-debug.* diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index 8758129..9699192 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -14,12 +14,20 @@ import Animated, { import { MOCK_CONTENT } from '@/src/constants/mockContent'; import { addFavorite, + getRecoFeedCache, + getRecoFeedHistory, getThemeMode, getUserProfile, + getUserProfileScoring, + recordRecoFeedServed, + recordRecoFeedTouched, + setRecoFeedCache, setReaction, setThemeMode, + type RecoFeedCacheItem, type ThemeMode, } from '@/src/storage/appStorage'; +import { fetchRecoFeed } from '@/src/services/recoApi'; import ProfileModal from '@/components/home/ProfileModal'; import ThemeModal from '@/components/home/ThemeModal'; @@ -75,8 +83,11 @@ export default function HomeScreen() { const [profileName, setProfileName] = useState(undefined); const [busy, setBusy] = useState(false); const [likeFilled, setLikeFilled] = useState(false); + const [feedItems, setFeedItems] = useState>([]); - const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]); + const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT; + const item = useMemo(() => currentList[index % currentList.length], [currentList, index]); + const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null; // 动画相关 Shared Values const translateY = useSharedValue(0); @@ -100,6 +111,55 @@ export default function HomeScreen() { }, []) ); + // 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存) + useEffect(() => { + let cancelled = false; + (async () => { + const cache = await getRecoFeedCache(); + if (!cancelled && cache?.items?.length) { + setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text }))); + } + + const scoring = await getUserProfileScoring(); + if (!scoring) return; + + try { + const hist = await getRecoFeedHistory(); + const out = await fetchRecoFeed({ + k: 30, + user_profile: { + profile_version: scoring.profile_version, + profile_source: scoring.profile_source, + profile_generated_at: scoring.profile_generated_at, + profile_confidence: scoring.profile_confidence, + profile_answered: scoring.profile_answered, + stage: scoring.stage, + emotion_score: scoring.emotion_score, + context: scoring.context, + need: scoring.need, + }, + already_recommended_ids: hist.already_recommended_ids, + touched_or_viewed_ids: hist.touched_or_viewed_ids, + }); + + if (!cancelled && out.items?.length) { + setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text }))); + await setRecoFeedCache({ + saved_at: new Date().toISOString(), + items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })), + meta: out.meta as Record, + }); + await recordRecoFeedServed(out.items.map((x) => x.content_id)); + } + } catch { + // 忽略:保持缓存/本地 mock + } + })(); + return () => { + cancelled = true; + }; + }, []); + const backgroundColor = useMemo(() => { if (themeMode === 'color') { const colorIndex = Math.floor(index / 10) % THEME_COLORS.length; @@ -153,6 +213,11 @@ export default function HomeScreen() { if (busy) return; setBusy(true); + // 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控) + if (typeof currentContentId === 'number') { + void recordRecoFeedTouched(currentContentId); + } + // 1. 当前文案向上移动并消失 translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) }); opacity.value = withTiming(0, { duration: 300 }, (finished) => { @@ -173,7 +238,7 @@ export default function HomeScreen() { }); } }); - }, [busy, index, translateY, opacity]); + }, [busy, currentContentId, index, translateY, opacity]); const lastTapRef = useRef(0); diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index 929ce6e..42bbfe8 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { SelectionStep } from '@/components/onboarding/SelectionStep'; import { ReminderStep } from '@/components/onboarding/ReminderStep'; -import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage'; +import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring'; +import { fetchRecoFeed } from '@/src/services/recoApi'; +import { + recordRecoFeedServed, + setOnboardingCompleted, + setUserProfile, + setDailyReminderSettings, + setUserProfileScoring, + setRecoFeedCache, +} from '@/src/storage/appStorage'; const STEPS = [ { id: 'name', type: 'name', title: '我可以怎么称呼你?' }, @@ -71,6 +80,40 @@ export default function OnboardingScreen() { const { status } = await Notifications.requestPermissionsAsync(); const pushEnabled = status === 'granted'; + // 将 Onboarding 选择映射为标准问卷枚举(允许跳过) + const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); + + // 生成用户画像(供推荐/Push/Widget 复用) + const scoringProfile = buildUserProfileFromQuestionnaire(answers); + await setUserProfileScoring(scoringProfile); + + // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) + try { + const { items, meta } = await fetchRecoFeed({ + k: 30, + user_profile: { + profile_version: scoringProfile.profile_version, + profile_source: scoringProfile.profile_source, + profile_generated_at: scoringProfile.profile_generated_at, + profile_confidence: scoringProfile.profile_confidence, + profile_answered: scoringProfile.profile_answered, + stage: scoringProfile.stage, + emotion_score: scoringProfile.emotion_score, + context: scoringProfile.context, + need: scoringProfile.need, + }, + }); + + await setRecoFeedCache({ + saved_at: new Date().toISOString(), + items: items.map((x) => ({ content_id: x.content_id, text: x.text })), + meta: meta as Record, + }); + await recordRecoFeedServed(items.map((x) => x.content_id)); + } catch { + // 网络失败时使用首页本地 mock 兜底 + } + await setUserProfile({ name, intents: Object.values(selections).flat() @@ -98,20 +141,29 @@ export default function OnboardingScreen() { }; const onSkip = async () => { + // 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用 + const scoringProfile = buildUserProfileFromQuestionnaire({}); + await setUserProfileScoring(scoringProfile); + + // 标记已完成,避免下次启动再次进入 Onboarding await setOnboardingCompleted(true); router.replace('/(app)/home'); }; + // 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项 const handleToggleSelection = (id: string) => { setSelections(prev => { const currentIds = prev[currentStep.id] || []; - const nextIds = currentIds.includes(id) - ? currentIds.filter(i => i !== id) - : [...currentIds, id]; + const nextIds = currentIds.includes(id) ? [] : [id]; return { ...prev, [currentStep.id]: nextIds }; }); }; + const handleSkipStep = () => { + setSelections((prev) => ({ ...prev, [currentStep.id]: [] })); + onNext(); + }; + return ( )} diff --git a/client/app/index.tsx b/client/app/index.tsx index 64ba2ad..869780e 100644 --- a/client/app/index.tsx +++ b/client/app/index.tsx @@ -1,9 +1,8 @@ import { useEffect } from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { useRouter } from 'expo-router'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage'; +import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage'; /** * 启动分发:根据 consent 和 onboarding 状态跳转 @@ -14,10 +13,6 @@ export default function Index() { useEffect(() => { let cancelled = false; (async () => { - // 【临时清除数据】:用于测试完整流程 - await AsyncStorage.clear(); - console.log('AsyncStorage has been cleared for testing.'); - // 1. 检查是否同意协议 const consentAccepted = await getConsentAccepted(); if (cancelled) return; @@ -54,4 +49,3 @@ export default function Index() { const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, }); - diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index 8268cca..b70ad59 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -18,9 +18,10 @@ interface SelectionStepProps { selectedIds: string[]; onToggle: (id: string) => void; onNext: () => void; + onSkip?: () => void; } -export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) { +export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) { const hasSelection = selectedIds.length > 0; return ( @@ -48,13 +49,17 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select {/* 底部按钮:距离底部 12% 高度 */} - - {hasSelection ? : } - + + {onSkip && ( + + 跳过 + + )} + + + {hasSelection ? : } + + ); @@ -99,5 +104,20 @@ const styles = StyleSheet.create({ left: 0, right: 0, alignItems: 'center', - } + }, + footerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + skipBtn: { + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.04)', + }, + skipText: { + fontSize: 16, + color: OnboardingColors.textMuted, + }, }); diff --git a/client/package.json b/client/package.json index 4210b49..3456181 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 @@ "devDependencies": { "@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 636504a..cae49cb 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -102,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: @@ -114,7 +117,7 @@ packages: optional: true '@babel/code-frame@7.10.4': - resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + resolution: {integrity: sha1-Fo2ho26Q2miujUnA8bSMfGJJITo=} '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} @@ -241,12 +244,12 @@ packages: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + resolution: {integrity: sha1-qYP7Gusuw/btBCohD2QOkOeG/g0=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + resolution: {integrity: sha1-TJpvZp9dDN8bkKFnHpoUa+UwDOo=} peerDependencies: '@babel/core': ^7.0.0-0 @@ -268,7 +271,7 @@ packages: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-dynamic-import@7.8.3': - resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + resolution: {integrity: sha1-Yr+Ysto80h1iYVT8lu5bPLaOrLM=} peerDependencies: '@babel/core': ^7.0.0-0 @@ -291,12 +294,12 @@ packages: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + resolution: {integrity: sha1-7mATSMNw+jNNIge+FYd3SWUh/VE=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + resolution: {integrity: sha1-AcohtmjNghjJ5kDLbdiMVBKyyWo=} peerDependencies: '@babel/core': ^7.0.0-0 @@ -307,32 +310,32 @@ packages: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + resolution: {integrity: sha1-ypHvRjA1MESLkGZSusLp/plB9pk=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + resolution: {integrity: sha1-Fn7XA2iIYIH3S1w2xlqIwDtm0ak=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + resolution: {integrity: sha1-ubBws+M1cM2f0Hun+pHA3Te5r5c=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + resolution: {integrity: sha1-YOIl7cvZimQDMqLnLdPmbxr1WHE=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + resolution: {integrity: sha1-YRGiZbz7Ag6579D9/X0mQCue1sE=} peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + resolution: {integrity: sha1-T2nCq5UWfgGAzVM2YT+MV4j31Io=} peerDependencies: '@babel/core': ^7.0.0-0 @@ -620,6 +623,162 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.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 @@ -756,7 +915,7 @@ packages: engines: {node: '>=12'} '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + resolution: {integrity: sha1-/T2x1Z7PfPEh6AZQu4ZxL5tV7O0=} engines: {node: '>=8'} '@istanbuljs/schema@0.1.3': @@ -1148,6 +1307,144 @@ 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] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@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==} @@ -1157,6 +1454,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'} @@ -1247,6 +1547,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==} @@ -1288,12 +1597,41 @@ 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'} abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + resolution: {integrity: sha1-6vVNU7YrrkE46AnKIlyEOabvs5I=} engines: {node: '>=6.5'} accepts@1.3.8: @@ -1313,7 +1651,7 @@ packages: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} anser@1.4.10: - resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + resolution: {integrity: sha1-vvo+3fKCaEvQO2Pc2jknrvjC41s=} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -1328,11 +1666,11 @@ packages: engines: {node: '>=8'} ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + resolution: {integrity: sha1-QfuyAkPlCxK+DwS43tvwdSDOhB0=} engines: {node: '>=4'} ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + resolution: {integrity: sha1-7dgDYornHATIWuegkG7a00tkiTc=} engines: {node: '>=8'} ansi-styles@5.2.0: @@ -1340,7 +1678,7 @@ packages: engines: {node: '>=10'} any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + resolution: {integrity: sha1-q8av7tzqUugJzcA3au0845Y10X8=} anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -1350,23 +1688,27 @@ packages: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + resolution: {integrity: sha1-vNZ5HqWuCXJeF+WtmIE0zUCz2RE=} argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + resolution: {integrity: sha1-JG9Q88p4oyQPbJl+ipvR6sSeSzg=} aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + resolution: {integrity: sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=} 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==} + resolution: {integrity: sha1-3TeelPDbgxCwgpH51kwyCXZmF/0=} available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -1443,7 +1785,7 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + resolution: {integrity: sha1-GxtEAWClv3rUC2UPCVljSBkDkwo=} baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} @@ -1458,7 +1800,7 @@ packages: engines: {node: '>=0.6'} boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + resolution: {integrity: sha1-aN/1++YMUes3cl6p4+0xDcwed24=} bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -1487,13 +1829,13 @@ packages: hasBin: true bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + resolution: {integrity: sha1-5nh9og7OnQeZhTPP2d5vXDj0vAU=} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + resolution: {integrity: sha1-umLnwTEzBTWCGXFghRqPZI6Z7tA=} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -1512,11 +1854,11 @@ packages: engines: {node: '>= 0.4'} callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + resolution: {integrity: sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=} engines: {node: '>=6'} camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + resolution: {integrity: sha1-48mzFWnhBoEd8kL3FXJaH0xJQyA=} engines: {node: '>=6'} camelcase@6.3.0: @@ -1526,8 +1868,12 @@ 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==} + resolution: {integrity: sha1-zUJUFnelQzPPVBpJEIwUMrRMlCQ=} engines: {node: '>=4'} chalk@4.1.2: @@ -1547,14 +1893,14 @@ packages: resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} ci-info@2.0.0: - resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + resolution: {integrity: sha1-Z6npZL4xpR4V5QENWObxKDQAL0Y=} ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} cli-cursor@2.1.0: - resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + resolution: {integrity: sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=} engines: {node: '>=4'} cli-spinners@2.9.2: @@ -1569,21 +1915,21 @@ packages: engines: {node: '>=12'} clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + resolution: {integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=} engines: {node: '>=0.8'} color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + resolution: {integrity: sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=} color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + resolution: {integrity: sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=} engines: {node: '>=7.0.0'} color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=} color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + resolution: {integrity: sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=} color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} @@ -1597,10 +1943,10 @@ packages: engines: {node: '>=18'} commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + resolution: {integrity: sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=} commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + resolution: {integrity: sha1-n9YCvZNilOnp70aj9NaWQESxgGg=} engines: {node: '>= 6'} commander@7.2.0: @@ -1608,7 +1954,7 @@ packages: engines: {node: '>= 10'} compressible@2.0.18: - resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + resolution: {integrity: sha1-r1PMprBw1MPAdQ+9dyhqbXzEb7o=} engines: {node: '>= 0.6'} compression@1.8.1: @@ -1616,10 +1962,10 @@ packages: engines: {node: '>= 0.8.0'} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} connect@3.7.0: - resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + resolution: {integrity: sha1-XUk0iRDKpeB6AYALAw0MNfIEhPg=} engines: {node: '>= 0.10.0'} convert-source-map@2.0.0: @@ -1645,7 +1991,7 @@ packages: engines: {node: '>= 8'} crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + resolution: {integrity: sha1-7yp6lm7BEIM4g2m6oC6+rSKbMNU=} engines: {node: '>=8'} css-in-js-utils@3.1.0: @@ -1678,7 +2024,7 @@ packages: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + resolution: {integrity: sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=} peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -1686,7 +2032,7 @@ packages: optional: true debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + resolution: {integrity: sha1-clgLfpFF+zm2Z2+cXl+xALk0F5o=} peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -1707,7 +2053,7 @@ packages: engines: {node: '>=0.10'} deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + resolution: {integrity: sha1-xPp8lUBKF6nD6Mp+FTcxK3NjMKw=} engines: {node: '>=4.0.0'} deepmerge@4.3.1: @@ -1730,7 +2076,7 @@ packages: engines: {node: '>= 0.4'} depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + resolution: {integrity: sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=} engines: {node: '>= 0.8'} destroy@1.2.0: @@ -1758,7 +2104,7 @@ packages: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + resolution: {integrity: sha1-mytnDQCkMWZ6inW6Kc0bmICc51E=} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -1773,16 +2119,16 @@ packages: engines: {node: '>= 0.4'} ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} electron-to-chromium@1.5.283: resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + resolution: {integrity: sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=} encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=} engines: {node: '>= 0.8'} encodeurl@2.0.0: @@ -1811,45 +2157,60 @@ 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'} escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=} escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=} engines: {node: '>=0.8.0'} escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + resolution: {integrity: sha1-owME6Z2qMuI7L9IPUbq9B8/8o0Q=} engines: {node: '>=8'} escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + resolution: {integrity: sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=} engines: {node: '>=10'} esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + resolution: {integrity: sha1-E7BM2z5sXRnfkatph6hpVhmwqnE=} engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=} engines: {node: '>= 0.6'} event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + resolution: {integrity: sha1-XU0+vflYPWOlMzzi3rdICrKwV4k=} engines: {node: '>=6'} 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: @@ -2032,10 +2393,10 @@ packages: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + resolution: {integrity: sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=} fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + resolution: {integrity: sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM=} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -2044,7 +2405,7 @@ packages: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} fbjs-css-vars@1.0.2: - resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + resolution: {integrity: sha1-IWVRE2rgL+JVkyw+yHdfGOLAeLg=} fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} @@ -2063,15 +2424,15 @@ packages: engines: {node: '>=8'} filter-obj@1.1.0: - resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + resolution: {integrity: sha1-mzERErxsYSehbgFsbF1/GeCAXFs=} engines: {node: '>=0.10.0'} finalhandler@1.1.2: - resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + resolution: {integrity: sha1-t+fQAP/RGTjQ/bBTUG9uur6fWH0=} engines: {node: '>= 0.8'} find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + resolution: {integrity: sha1-l6/n1s3AvFkoWEt8jXsW6KmqXRk=} engines: {node: '>=8'} flow-enums-runtime@0.0.6: @@ -2089,11 +2450,11 @@ packages: engines: {node: '>=8'} fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -2108,11 +2469,11 @@ packages: engines: {node: '>= 0.4'} gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + resolution: {integrity: sha1-MqbudsPX9S1GsrGuXZP+qFgKJeA=} engines: {node: '>=6.9.0'} get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + resolution: {integrity: sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=} engines: {node: 6.* || 8.* || >= 10.*} get-intrinsic@1.3.0: @@ -2124,7 +2485,7 @@ packages: engines: {node: '>=6'} get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + resolution: {integrity: sha1-jeLYA8/0TfO8bEVuZmizbDkm4Ro=} engines: {node: '>=8.0.0'} get-proto@1.0.1: @@ -2144,7 +2505,7 @@ packages: deprecated: Glob versions prior to v9 are no longer supported global-dirs@0.1.1: - resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} + resolution: {integrity: sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=} engines: {node: '>=4'} gopd@1.2.0: @@ -2155,11 +2516,11 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=} engines: {node: '>=4'} has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + resolution: {integrity: sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=} engines: {node: '>=8'} has-property-descriptors@1.0.2: @@ -2190,7 +2551,7 @@ packages: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + resolution: {integrity: sha1-7OCsr3HWLClpwuxZ/v9CpLGoW0U=} hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} @@ -2219,7 +2580,7 @@ packages: optional: true ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + resolution: {integrity: sha1-jrehCmP/8l0VpXsAFYbRd9Gw01I=} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -2235,15 +2596,15 @@ packages: engines: {node: '>=6'} imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + resolution: {integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=} engines: {node: '>=0.8.19'} inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + resolution: {integrity: sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -2252,7 +2613,7 @@ packages: resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + resolution: {integrity: sha1-YQ88ksk1nOHbYW5TgAjSP/NRWOY=} is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} @@ -2278,7 +2639,7 @@ packages: hasBin: true is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + resolution: {integrity: sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=} engines: {node: '>=8'} is-generator-function@1.1.2: @@ -2290,11 +2651,11 @@ packages: engines: {node: '>= 0.4'} is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + resolution: {integrity: sha1-dTU0W4lnNNX4DE0GxQlVUnoU8Ss=} engines: {node: '>=0.12.0'} is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + resolution: {integrity: sha1-ReQuN/zPH0Dajl927iFRWEDAkoc=} engines: {node: '>=8'} is-regex@1.2.1: @@ -2306,11 +2667,11 @@ packages: engines: {node: '>= 0.4'} is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + resolution: {integrity: sha1-dKTHbnfKn9P5MvKQwX6jJs0VcnE=} engines: {node: '>=8'} isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -2360,7 +2721,7 @@ packages: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + resolution: {integrity: sha1-GSA/tZmR35jjoocFDUZHzerzJJk=} js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} @@ -2379,7 +2740,7 @@ packages: hasBin: true json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + resolution: {integrity: sha1-fEeAWpQxmSjgV3dAXcEuH3pO4C0=} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -2390,7 +2751,7 @@ packages: hasBin: true kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + resolution: {integrity: sha1-p5yezIbuHOP6YgbRIWxQHxR/wH4=} engines: {node: '>=6'} lan-network@0.1.7: @@ -2398,7 +2759,7 @@ packages: hasBin: true leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + resolution: {integrity: sha1-d4kd6DQGTMy6gq54QrtrFKE+1/I=} engines: {node: '>=6'} lighthouse-logger@1.4.2: @@ -2439,24 +2800,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -2478,25 +2843,25 @@ packages: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + resolution: {integrity: sha1-Gvujlq/WdqbUJQTQpno6frn2KqA=} engines: {node: '>=8'} lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + resolution: {integrity: sha1-gteb/zCmfEAF/9XiUVMArZyk168=} lodash.throttle@4.1.1: - resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + resolution: {integrity: sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=} log-symbols@2.2.0: - resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + resolution: {integrity: sha1-V0Dhxdbw39pK2TI7UzIQfva0xAo=} engines: {node: '>=4'} loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + resolution: {integrity: sha1-ce5R+nvkyuwaY4OffmgtgTLTDK8=} hasBin: true lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + resolution: {integrity: sha1-b6I3xj29xKgsoP2ILkci3F5jTig=} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2506,7 +2871,10 @@ packages: engines: {node: 20 || >=22} lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + resolution: {integrity: sha1-HaJ+ZxAnGUdpXa9oSOhH8B2EuSA=} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -2519,7 +2887,7 @@ packages: engines: {node: '>= 0.4'} mdn-data@2.0.14: - resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + resolution: {integrity: sha1-cRP8QoGRfWPOKbQ0RvcB5owlulA=} mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -2534,11 +2902,11 @@ packages: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} merge-options@3.0.4: - resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + resolution: {integrity: sha1-hHCcKqKkskwZgfZsF5/lVlzG27c=} engines: {node: '>=10'} merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + resolution: {integrity: sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A=} metro-babel-transformer@0.83.3: resolution: {integrity: sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==} @@ -2615,12 +2983,12 @@ packages: engines: {node: '>= 0.6'} mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + resolution: {integrity: sha1-Ms2eXGRVO9WNGaVor0Uqz/BJgbE=} engines: {node: '>=4'} hasBin: true mimic-fn@1.2.0: - resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + resolution: {integrity: sha1-ggyGo5M0ZA6ZUWkovQP8qIBX0CI=} engines: {node: '>=4'} minimatch@10.1.1: @@ -2646,18 +3014,18 @@ packages: engines: {node: '>= 18'} mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + resolution: {integrity: sha1-PrXtYmInVteaXw4qIh3+utdcL34=} engines: {node: '>=10'} hasBin: true ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=} ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + resolution: {integrity: sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=} mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + resolution: {integrity: sha1-lQCAV6Vsr63CvGPd5/n/aVWUjjI=} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -2673,10 +3041,10 @@ packages: engines: {node: '>= 0.6'} nested-error-stacks@2.0.1: - resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} + resolution: {integrity: sha1-0syfxSNd2zcfxE1QYjQznI5LCks=} no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + resolution: {integrity: sha1-02H9XJgA9VhVGoNp/A3NRmK2Ek0=} node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} @@ -2692,13 +3060,13 @@ packages: engines: {node: '>= 6.13.0'} node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=} node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + resolution: {integrity: sha1-Dc1p/yOhybEf0JeDFmRKA4ghamU=} engines: {node: '>=0.10.0'} npm-package-arg@11.0.3: @@ -2709,14 +3077,14 @@ packages: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} nullthrows@1.1.1: - resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + resolution: {integrity: sha1-eBgliEOFaulx6uQgitfX6xmkMbE=} ob1@0.83.3: resolution: {integrity: sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==} engines: {node: '>=20.19.4'} object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} object-is@1.1.6: @@ -2724,15 +3092,18 @@ packages: engines: {node: '>= 0.4'} object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + resolution: {integrity: sha1-HEfyct8nfzsdrwYWd9nILiMixg4=} engines: {node: '>= 0.4'} object.assign@4.1.7: 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==} + resolution: {integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=} engines: {node: '>= 0.8'} on-finished@2.4.1: @@ -2744,10 +3115,10 @@ packages: engines: {node: '>= 0.8'} once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} onetime@2.0.1: - resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + resolution: {integrity: sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=} engines: {node: '>=4'} open@7.4.2: @@ -2759,27 +3130,27 @@ packages: engines: {node: '>=12'} ora@3.4.0: - resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + resolution: {integrity: sha1-vwdSSRBZo+8+1MhQl1Md6f280xg=} engines: {node: '>=6'} p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + resolution: {integrity: sha1-PdM8ZHohT9//2DWTPrCG2g3CHbE=} engines: {node: '>=6'} p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + resolution: {integrity: sha1-4drMvnjQ0TiMoYxk/qOOPlfjcGs=} engines: {node: '>=10'} p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + resolution: {integrity: sha1-o0KLtwiLOmApL2aRkni3wpetTwc=} engines: {node: '>=8'} p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + resolution: {integrity: sha1-yyhoVA4xPWHeWPr741zpAE1VQOY=} engines: {node: '>=6'} parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + resolution: {integrity: sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=} engines: {node: '>=6'} parse-json@5.2.0: @@ -2791,22 +3162,22 @@ packages: engines: {node: '>=10'} parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + resolution: {integrity: sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=} engines: {node: '>= 0.8'} path-dirname@1.0.2: - resolution: {integrity: sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==} + resolution: {integrity: sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=} path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + resolution: {integrity: sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=} engines: {node: '>=8'} path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} engines: {node: '>=0.10.0'} path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + resolution: {integrity: sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=} engines: {node: '>=8'} path-parse@1.0.7: @@ -2817,9 +3188,12 @@ packages: engines: {node: 20 || >=22} path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + resolution: {integrity: sha1-hO0BwKe6OAr+CdkKjBgNzZ0DBDs=} 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==} @@ -2844,7 +3218,7 @@ packages: engines: {node: '>=10.4.0'} pngjs@3.4.0: - resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + resolution: {integrity: sha1-mcp9clll+2VYFOr2XzjxK72/VV8=} engines: {node: '>=4.0.0'} possible-typed-array-names@1.1.0: @@ -2858,6 +3232,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'} @@ -2871,11 +3249,11 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + resolution: {integrity: sha1-foz42PW48jnBvGi+tOt4Vn1XLvg=} engines: {node: '>=0.4.0'} promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + resolution: {integrity: sha1-BktyYCsY+Q8pGSuLG8QY/9Hr078=} promise@8.3.0: resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} @@ -2889,7 +3267,7 @@ packages: engines: {node: '>=6'} qrcode-terminal@0.11.0: - resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} + resolution: {integrity: sha1-/8bCii/Av7RwUrR+I/T0RqX7254=} hasBin: true query-string@7.1.3: @@ -2900,11 +3278,11 @@ packages: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + resolution: {integrity: sha1-PPNwI9GZ4cJNGlW4SADC8+ZGgDE=} engines: {node: '>= 0.6'} rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + resolution: {integrity: sha1-zZJL9SAKB1uDwYjNa54hG3/A0+0=} hasBin: true react-devtools-core@6.1.5: @@ -2941,7 +3319,7 @@ packages: optional: true react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + resolution: {integrity: sha1-eJcppNw23imZ3BVt1sHZwYzqVqQ=} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3065,7 +3443,7 @@ packages: engines: {node: '>=4'} regenerate@1.4.2: - resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + resolution: {integrity: sha1-uTRtiCfo9aMve6KWN9OYtpAUhIo=} regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -3082,11 +3460,11 @@ packages: hasBin: true require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=} engines: {node: '>=0.10.0'} require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + resolution: {integrity: sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=} engines: {node: '>=0.10.0'} requireg@0.2.2: @@ -3094,15 +3472,15 @@ packages: engines: {node: '>= 4.0.0'} resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + resolution: {integrity: sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY=} engines: {node: '>=4'} resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + resolution: {integrity: sha1-w1IlhD3493bfIcV1V7wIfp39/Gk=} engines: {node: '>=8'} resolve-global@1.0.0: - resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} + resolution: {integrity: sha1-oqed9K8so/Sb93753azTItrRklU=} engines: {node: '>=8'} resolve-workspace-root@2.0.1: @@ -3118,22 +3496,27 @@ packages: hasBin: true resolve@1.7.1: - resolution: {integrity: sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==} + resolution: {integrity: sha1-qt1lY3T9KYruiVvAJrgpdBhnf9M=} restore-cursor@2.0.0: - resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + resolution: {integrity: sha1-n37ih/gv0ybU/RYpI9YhKe7g368=} engines: {node: '>=4'} rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + resolution: {integrity: sha1-8aVAK6YiCtUswSgrrBrjqkn9Bho=} 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==} safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + resolution: {integrity: sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=} safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} @@ -3170,7 +3553,7 @@ packages: engines: {node: '>= 0.8.0'} serialize-error@2.1.0: - resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + resolution: {integrity: sha1-ULZ51WNc34Rme9yOWa9OW4HV9go=} engines: {node: '>=0.10.0'} serve-static@1.16.3: @@ -3185,30 +3568,33 @@ packages: engines: {node: '>= 0.4'} setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + resolution: {integrity: sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=} setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + resolution: {integrity: sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=} sf-symbols-typescript@2.2.0: resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} engines: {node: '>=10'} shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + resolution: {integrity: sha1-GI1SHelbkIdAT9TctosT3wrk5/g=} shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + resolution: {integrity: sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=} engines: {node: '>=8'} shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + resolution: {integrity: sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=} engines: {node: '>=8'} shell-quote@1.8.3: 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==} @@ -3219,10 +3605,10 @@ packages: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + resolution: {integrity: sha1-E01oEpd1ZDfMBcoBNw06elcQde0=} slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + resolution: {integrity: sha1-ZTm+hwwWWtvVJAIg2+Nh8bxNRjQ=} engines: {node: '>=8'} slugify@1.6.6: @@ -3240,24 +3626,27 @@ packages: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=} engines: {node: '>=0.10.0'} source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + resolution: {integrity: sha1-dHIq8y6WFOnCh6jQu95IteLxomM=} engines: {node: '>=0.10.0'} split-on-first@1.1.0: - resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + resolution: {integrity: sha1-9hCv7uOxK84dDDBCXnY5i3gkml8=} engines: {node: '>=6'} sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=} stack-utils@2.0.6: 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==} @@ -3266,19 +3655,22 @@ packages: engines: {node: '>=6'} statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=} engines: {node: '>= 0.6'} statuses@2.0.2: 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==} + resolution: {integrity: sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ=} engines: {node: '>= 0.10.0'} strict-uri-encode@2.0.0: - resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + resolution: {integrity: sha1-ucczDHBChi9rFC3CdLvMWGbONUY=} engines: {node: '>=4'} string-width@4.2.3: @@ -3286,7 +3678,7 @@ packages: engines: {node: '>=8'} strip-ansi@5.2.0: - resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + resolution: {integrity: sha1-jJpTb+tq/JYr36WxBKUJHBrZwK4=} engines: {node: '>=6'} strip-ansi@6.0.1: @@ -3294,7 +3686,7 @@ packages: engines: {node: '>=8'} strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + resolution: {integrity: sha1-PFMZQukIwml8DsNEhYwobHygpgo=} engines: {node: '>=0.10.0'} structured-headers@0.4.1: @@ -3309,11 +3701,11 @@ packages: hasBin: true supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + resolution: {integrity: sha1-4uaaRKyHcveKHsCzW2id9lMO/I8=} engines: {node: '>=4'} supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + resolution: {integrity: sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=} engines: {node: '>=8'} supports-color@8.1.1: @@ -3329,7 +3721,7 @@ packages: engines: {node: '>= 0.4'} svg-parser@2.0.4: - resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + resolution: {integrity: sha1-/cLinhOVFzYUC3bLEiyO5mMOtrU=} svgo@3.3.2: resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} @@ -3341,11 +3733,11 @@ packages: engines: {node: '>=18'} temp-dir@2.0.0: - resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + resolution: {integrity: sha1-vekrBb3+sVFugEycAK1FF38xMh4=} engines: {node: '>=8'} terminal-link@2.1.1: - resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + resolution: {integrity: sha1-FKZKJ6s8Dfkz6lRvulXy0HjtyZQ=} engines: {node: '>=8'} terser@5.46.0: @@ -3354,28 +3746,39 @@ packages: hasBin: true test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + resolution: {integrity: sha1-BKhphmHYBepvopO2y55jrARO8V4=} engines: {node: '>=8'} thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + resolution: {integrity: sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=} engines: {node: '>=0.8'} thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + resolution: {integrity: sha1-iTLmhqQGYDigFt2eLKRq3Zg4qV8=} throat@5.0.0: - resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + resolution: {integrity: sha1-xRmSNYA6rRh1SmZ9ZZtecs4Wdks=} + + 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==} to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + resolution: {integrity: sha1-FkjESq58jZiKMmAY7XL1tN0DkuQ=} engines: {node: '>=8.0'} toidentifier@1.0.1: @@ -3383,7 +3786,7 @@ packages: engines: {node: '>=0.6'} tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=} ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -3392,7 +3795,7 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + resolution: {integrity: sha1-dkb7XxiHHPu3dJ5pvTmmOI63RQw=} engines: {node: '>=4'} type-fest@0.21.3: @@ -3400,7 +3803,7 @@ packages: engines: {node: '>=10'} type-fest@0.7.1: - resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + resolution: {integrity: sha1-jdpl/q8D7Xjwo/lnjxhpFH98XEg=} engines: {node: '>=8'} typescript@5.9.3: @@ -3436,11 +3839,11 @@ packages: engines: {node: '>=4'} unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + resolution: {integrity: sha1-OcZFH4GvsnSd4rIz4/fF6IQ72J0=} engines: {node: '>=8'} unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + resolution: {integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=} engines: {node: '>= 0.8'} update-browserslist-db@1.2.3: @@ -3483,11 +3886,11 @@ packages: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} engines: {node: '>= 0.4.0'} uuid@7.0.3: - resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + resolution: {integrity: sha1-xcnyyM8l3Ao3LE3xRBxB9b0MaAs=} hasBin: true validate-npm-package-name@5.0.1: @@ -3495,7 +3898,7 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + resolution: {integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=} engines: {node: '>= 0.8'} vaul@1.1.2: @@ -3504,11 +3907,85 @@ 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==} + resolution: {integrity: sha1-wAP258C0we3WI/1u5Qu8DWod5Gg=} void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + resolution: {integrity: sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=} engines: {node: '>=0.10.0'} walker@1.0.8: @@ -3518,13 +3995,13 @@ packages: resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=} webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=} webidl-conversions@5.0.0: - resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + resolution: {integrity: sha1-rlnIoAsSFUOirMZcBDT1ew/BGv8=} engines: {node: '>=8'} whatwg-fetch@3.6.20: @@ -3535,26 +4012,31 @@ packages: engines: {node: '>=10'} whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=} which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + resolution: {integrity: sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=} 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==} wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + resolution: {integrity: sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=} engines: {node: '>=10'} wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} @@ -3596,7 +4078,7 @@ packages: optional: true xcode@3.0.1: - resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + resolution: {integrity: sha1-PvtiqsZBqyxwJFj5oDAmlhRqpTw=} engines: {node: '>=10.0.0'} xml2js@0.6.0: @@ -3604,11 +4086,11 @@ packages: engines: {node: '>=4.0.0'} xmlbuilder@11.0.1: - resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + resolution: {integrity: sha1-vpuuHIoEbnazESdyY0fQrXACvrM=} engines: {node: '>=4.0'} xmlbuilder@15.1.1: - resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + resolution: {integrity: sha1-nc3OSe6mbY0QtCyulKecPI0MLsU=} engines: {node: '>=8.0'} y18n@5.0.8: @@ -3616,7 +4098,7 @@ packages: engines: {node: '>=10'} yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + resolution: {integrity: sha1-27fa+b/YusmrRev2ArjLrQ1dCP0=} yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} @@ -3636,7 +4118,7 @@ packages: engines: {node: '>=12'} yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + resolution: {integrity: sha1-ApTrPe4FAo0x7hpfosVWpqrxChs=} engines: {node: '>=10'} snapshots: @@ -4257,6 +4739,84 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@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 @@ -5038,6 +5598,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': @@ -5048,6 +5683,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 @@ -5150,6 +5787,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 @@ -5196,6 +5842,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: @@ -5267,6 +5952,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: @@ -5487,6 +6174,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -5766,10 +6455,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: {} @@ -5782,12 +6502,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) @@ -6494,6 +7220,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 @@ -6802,6 +7532,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 @@ -6888,6 +7620,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6916,6 +7650,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: @@ -7239,6 +7979,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: {} @@ -7317,6 +8088,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} simple-plist@1.3.1: @@ -7359,6 +8132,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -7369,6 +8144,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + stream-buffers@2.2.0: {} strict-uri-encode@2.0.0: {} @@ -7472,11 +8249,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: @@ -7576,6 +8359,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: {} @@ -7621,6 +8456,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..6e880bc 100644 --- a/client/src/constants/env.ts +++ b/client/src/constants/env.ts @@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string { return process.env[name] ?? fallback; } -export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev'; +export type AppRuntimeEnv = 'local' | 'dev' | 'prod'; -export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL'); +export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local'; + +function getApiBaseUrl(env: AppRuntimeEnv): string { + // 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD) + const direct = process.env.EXPO_PUBLIC_API_BASE_URL; + if (direct && String(direct).trim()) return String(direct).trim(); + + // 约定:local/dev/prod 三套域名分别配置,便于后续直接切环境而不改代码 + if (env === 'local') { + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'); + } + if (env === 'dev') { + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000')); + } + return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000')); +} + +export const API_BASE_URL = getApiBaseUrl(APP_ENV); /** * 默认语言策略: - * - 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__/onboarding-integration.test.ts b/client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts new file mode 100644 index 0000000..ba2a6dd --- /dev/null +++ b/client/src/features/userProfileScoring/__tests__/onboarding-integration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { buildUserProfileFromQuestionnaire } from '../index'; +import { mapOnboardingSelectionsToQuestionnaireAnswers } from '../onboardingMapping'; + +describe('Onboarding → UserProfileScoring 集成', () => { + it('完整作答:Onboarding 选择能正确映射并生成画像', () => { + const selections = { + status: ['pregnant'], + emotion: ['calm'], + influence: ['work'], + support: ['balance'], + }; + + const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); + expect(answers).toEqual({ + mom_stage: 'expecting', + emotion: 'calm', + context: 'work', + need: 'rest_balance', + }); + + const p = buildUserProfileFromQuestionnaire(answers, { + generatedAt: '2026-01-30T00:00:00Z', + now: '2026-01-30T00:00:00Z', + }); + + expect(p.stage).toEqual({ expecting: 1, parenting: 0, unknown: 0 }); + expect(p.emotion_score).toBe(0.8); + expect(p.context).toEqual({ work: 1 }); + expect(p.need).toEqual({ rest_balance: 1 }); + expect(p.profile_answered).toEqual({ stage: true, emotion: true, context: true, need: true }); + }); + + it('全部跳过:仍能生成最小可计算画像(unknown=1)', () => { + const answers = mapOnboardingSelectionsToQuestionnaireAnswers({}); + expect(answers).toEqual({ mom_stage: null, emotion: null, context: null, need: null }); + + const p = buildUserProfileFromQuestionnaire(answers, { + generatedAt: '2026-01-30T00:00:00Z', + now: '2026-01-30T00:00:00Z', + }); + + expect(p.stage).toEqual({ unknown: 1 }); + expect(p.emotion_score).toBeNull(); + expect(p.context).toEqual({}); + expect(p.need).toEqual({}); + expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false }); + }); +}); + 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..2133e19 --- /dev/null +++ b/client/src/features/userProfileScoring/index.ts @@ -0,0 +1,19 @@ +export type { + BuildUserProfileOptions, + QuestionnaireAnswersV1_2, + UserProfileV1_2, + UserProfileV1_2_Extended, +} from './types'; + +export type { OnboardingSelections } from './onboardingMapping'; + +export { + buildUserProfileFromQuestionnaire, + computeProfileAnswered, + computeProfileConfidence, + computeTimeConfidence, + normalizeAnswers, +} from './scoring'; + +export { mapOnboardingSelectionsToQuestionnaireAnswers } from './onboardingMapping'; + diff --git a/client/src/features/userProfileScoring/onboardingMapping.ts b/client/src/features/userProfileScoring/onboardingMapping.ts new file mode 100644 index 0000000..4e1072f --- /dev/null +++ b/client/src/features/userProfileScoring/onboardingMapping.ts @@ -0,0 +1,61 @@ +import type { QuestionnaireAnswersV1_2 } from './types'; + +/** + * Onboarding UI 的选项 ID → 标准问卷枚举(可跳过) + * + * 说明: + * - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案 + * - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null) + */ +export type OnboardingSelections = Record; + +export function mapOnboardingSelectionsToQuestionnaireAnswers( + selections: OnboardingSelections +): QuestionnaireAnswersV1_2 { + return { + mom_stage: mapMomStage(selections.status?.[0]), + emotion: mapEmotion(selections.emotion?.[0]), + context: mapContext(selections.influence?.[0]), + need: mapNeed(selections.support?.[0]), + }; +} + +function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_stage'] { + // 跳过:null(显式跳过) + if (!raw) return null; + // UI id → 标准枚举 + if (raw === 'pregnant') return 'expecting'; + if (raw === 'has_kids') return 'parenting'; + if (raw === 'no_fill') return 'unknown'; + // 其他非法值:按跳过处理 + return null; +} + +function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] { + if (!raw) return null; + // UI 当前选项:happy/calm/stressed/low + if (raw === 'happy') return 'joyful'; + if (raw === 'calm') return 'calm'; + if (raw === 'stressed') return 'overwhelmed'; + if (raw === 'low') return 'low'; + return null; +} + +function mapContext(raw: string | undefined): QuestionnaireAnswersV1_2['context'] { + if (!raw) return null; + // UI id 已与标准枚举一致:family/work/relationship/friends/health + if (raw === 'family' || raw === 'work' || raw === 'relationship' || raw === 'friends' || raw === 'health') return raw; + return null; +} + +function mapNeed(raw: string | undefined): QuestionnaireAnswersV1_2['need'] { + if (!raw) return null; + // UI id → 标准枚举 + if (raw === 'emotional') return 'emotional_support'; + if (raw === 'parenting') return 'parenting_pressure'; + if (raw === 'self_worth') return 'self_worth'; + if (raw === 'anxiety') return 'anxiety_relief'; + if (raw === 'balance') return 'rest_balance'; + return null; +} + 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/client/src/services/recoApi.ts b/client/src/services/recoApi.ts new file mode 100644 index 0000000..46cdb10 --- /dev/null +++ b/client/src/services/recoApi.ts @@ -0,0 +1,63 @@ +import i18n from 'i18next'; + +import { API_BASE_URL } from '@/src/constants/env'; +import type { UserProfileV1_2 } from '@/src/features/userProfileScoring'; + +export type RecommendedItem = { + content_id: number; + text: string; + final_score: number; + fallback_level_final: number; + explanations?: Record | null; +}; + +export type RecoMeta = Record; + +export type RecoEngineResult = { + items: RecommendedItem[]; + meta: RecoMeta; +}; + +export type RecoRequest = { + k?: number; + user_profile: UserProfileV1_2; + already_recommended_ids?: Array; + touched_or_viewed_ids?: Array; + now?: string; // ISO8601(可选) +}; + +function withTimeout(ms: number): AbortController { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller; +} + +export async function fetchRecoFeed(req: RecoRequest): Promise { + const controller = withTimeout(12_000); + const url = `${API_BASE_URL}/v1/reco/feed`; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // 让后端做 locale 选择(目前后端只区分 en/tc) + 'Accept-Language': i18n.language || 'en', + }, + body: JSON.stringify({ + k: req.k, + user_profile: req.user_profile, + already_recommended_ids: req.already_recommended_ids ?? [], + touched_or_viewed_ids: req.touched_or_viewed_ids ?? [], + now: req.now, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim()); + } + + return (await res.json()) as RecoEngineResult; +} + diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index fefc4b6..a148837 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -1,4 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring'; /** * 本地存储 key 统一管理,避免 UI 里散落硬编码 @@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions'; const KEY_FAVORITES_ITEMS = 'favorites.items'; const KEY_CONSENT_ACCEPTED = 'consent.accepted'; const KEY_USER_PROFILE = 'user.profile'; +const KEY_USER_PROFILE_SCORING = 'user.profileScoring'; +const KEY_RECO_FEED_CACHE = 'reco.feedCache'; +const KEY_RECO_FEED_HISTORY = 'reco.feedHistory'; const KEY_UI_THEME_MODE = 'ui.theme.mode'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; @@ -20,11 +24,42 @@ export type UserProfile = { name?: string; intents?: string[]; }; + +/** + * 用户画像(问卷打分输出) + * 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。 + */ +export type UserProfileScoring = UserProfileV1_2_Extended; export type DailyReminderSettings = { timesPerDay: number; pushEnabled: boolean; }; +export type RecoFeedCacheItem = { + content_id: number; + text: string; +}; + +export type RecoFeedCache = { + saved_at: string; // ISO8601 + items: RecoFeedCacheItem[]; + meta?: Record; +}; + +/** + * Feed 链路可观测输入(用于下一次请求携带给后端) + * + * - already_recommended_ids:本设备已下发过的内容(避免重复下发) + * - touched_or_viewed_ids:本设备用户已看过/划过的内容(用于频控/去重/降重复) + * + * 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。 + */ +export type RecoFeedHistory = { + updated_at: string; // ISO8601 + already_recommended_ids: number[]; + touched_or_viewed_ids: number[]; +}; + async function getJson(key: string, fallback: T): Promise { const raw = await AsyncStorage.getItem(key); @@ -123,6 +158,103 @@ export async function setUserProfile(profile: UserProfile): Promise { await setJson(KEY_USER_PROFILE, { ...current, ...profile }); } +export async function getUserProfileScoring(): Promise { + const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING); + if (!raw) return null; + try { + return JSON.parse(raw) as UserProfileScoring; + } catch { + return null; + } +} + +export async function setUserProfileScoring(profile: UserProfileScoring): Promise { + await setJson(KEY_USER_PROFILE_SCORING, profile); +} + +export async function getRecoFeedCache(): Promise { + const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE); + if (!raw) return null; + try { + return JSON.parse(raw) as RecoFeedCache; + } catch { + return null; + } +} + +export async function setRecoFeedCache(cache: RecoFeedCache): Promise { + await setJson(KEY_RECO_FEED_CACHE, cache); +} + +export async function getRecoFeedHistory(): Promise { + const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY); + if (!raw) { + return { + updated_at: new Date().toISOString(), + already_recommended_ids: [], + touched_or_viewed_ids: [], + }; + } + try { + const parsed = JSON.parse(raw) as Partial; + return { + updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(), + already_recommended_ids: Array.isArray(parsed.already_recommended_ids) + ? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) + : [], + touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids) + ? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) + : [], + }; + } catch { + return { + updated_at: new Date().toISOString(), + already_recommended_ids: [], + touched_or_viewed_ids: [], + }; + } +} + +export async function setRecoFeedHistory(history: RecoFeedHistory): Promise { + await setJson(KEY_RECO_FEED_HISTORY, history); +} + +function uniqKeepLatest(list: number[], max: number): number[] { + const seen = new Set(); + const out: number[] = []; + for (let i = list.length - 1; i >= 0; i -= 1) { + const v = list[i]; + if (!Number.isFinite(v)) continue; + if (seen.has(v)) continue; + seen.add(v); + out.push(v); + if (out.length >= max) break; + } + return out.reverse(); +} + +export async function recordRecoFeedServed(contentIds: number[]): Promise { + if (!contentIds?.length) return; + const h = await getRecoFeedHistory(); + const next = { + ...h, + updated_at: new Date().toISOString(), + already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500), + }; + await setRecoFeedHistory(next); +} + +export async function recordRecoFeedTouched(contentId: number): Promise { + if (!Number.isFinite(contentId)) return; + const h = await getRecoFeedHistory(); + const next = { + ...h, + updated_at: new Date().toISOString(), + touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500), + }; + await setRecoFeedHistory(next); +} + export async function getDailyReminderSettings(): Promise { const s = await getJson(KEY_DAILY_REMINDER_SETTINGS, { timesPerDay: 3, diff --git a/server/.env.dev b/server/.env.dev index 64f08bb..04a4fd8 100644 --- a/server/.env.dev +++ b/server/.env.dev @@ -7,13 +7,13 @@ APP_HOST=0.0.0.0 APP_PORT=8000 # 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness) -DATABASE_URL=mysql+aiomysql://<用户名>:<密码>@:3306/mindfulness_dev?charset=utf8mb4 +DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness_dev?charset=utf8mb4 # Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀) -REDIS_URL=redis://:@:6379/0 +REDIS_URL=redis://dev_damer:damer@43.163.242.87:6379/0 # Celery(默认不启用结果存储,避免 Redis 内存压力) -CELERY_BROKER_URL=redis://:@:6379/0 +CELERY_BROKER_URL=redis://dev_damer:damer@43.163.242.87:6379/0 # CELERY_RESULT_BACKEND=redis://:@:6379/0 # 推送(Expo) diff --git a/server/.env.prod b/server/.env.prod new file mode 100644 index 0000000..246e1c7 --- /dev/null +++ b/server/.env.prod @@ -0,0 +1,20 @@ +# 运行环境:dev 或 prod +APP_ENV=prod + +# Web 服务 +APP_NAME=mindfulness-server +APP_HOST=0.0.0.0 +APP_PORT=8000 + +# 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness) +DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness?charset=utf8mb4 + +# Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀) +REDIS_URL=redis://prod_damer:damer@43.163.242.87:6379/0 + +# Celery(默认不启用结果存储,避免 Redis 内存压力) +CELERY_BROKER_URL=redis://prod_damer:damer@43.163.242.87:6379/0 +# CELERY_RESULT_BACKEND=redis://:@:6379/0 + +# 推送(Expo) +# EXPO_ACCESS_TOKEN= diff --git a/server/.test.db b/server/.test.db new file mode 100644 index 0000000..4c9f7b1 Binary files /dev/null and b/server/.test.db differ diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..0b41af5 --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = alembic + +# 注意:实际连接串由 alembic/env.py 从环境变量 DATABASE_URL 注入 +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s + diff --git a/server/alembic/README.md b/server/alembic/README.md new file mode 100644 index 0000000..a70351e --- /dev/null +++ b/server/alembic/README.md @@ -0,0 +1,40 @@ +# Alembic(数据库迁移) + +## 1. 前置 + +- 在 `server/` 下准备 `.env.dev`(或系统环境变量),至少包含: + - `DATABASE_URL=mysql+aiomysql://...` + +> 注意:本仓库推荐使用 Python 虚拟环境(venv)。示例以 `server/.venv` 为准。 + +--- + +## 2. 安装依赖(一次性) + +在仓库根目录: + +```bash +python3 -m venv server/.venv +source server/.venv/bin/activate +pip install -r server/requirements.txt +``` + +--- + +## 3. 常用命令 + +在 `server/` 目录运行: + +```bash +source .venv/bin/activate +alembic -c alembic.ini history +alembic -c alembic.ini upgrade head +``` + +--- + +## 4. 说明 + +- 连接串由 `alembic/env.py` 从环境变量 `DATABASE_URL`(或 `app/core/config.py`)读取。 +- 初始迁移版本为:`0001_init_content_tables`(创建推荐系统最小内容表与画像表)。 + diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..b190e08 --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# 让 alembic 在 `server/` 下运行时也能 import app.* +SERVER_DIR = Path(__file__).resolve().parents[1] # .../server/alembic -> .../server +sys.path.append(str(SERVER_DIR)) + +from app.db.base import Base # noqa: E402 +import app.db.models # noqa: F401,E402 # 确保模型被导入,metadata 完整 + +# Alembic Config 对象 +config = context.config + +# 配置日志 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 目标 metadata(autogenerate 依赖) +target_metadata = Base.metadata + + +def _read_env_kv(env_path: Path) -> dict[str, str]: + """ + 读取 .env 文件中的 KEY=VALUE。 + + 说明: + - 迁移阶段只需要 DATABASE_URL,不应因为 Redis/Celery 等配置缺失而失败 + - 这里不依赖 pydantic-settings 的 Settings 校验,避免“缺字段导致迁移不可用” + """ + + data: dict[str, str] = {} + if not env_path.exists(): + return data + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k: + data[k] = v + return data + + +def _get_database_url() -> str: + """ + 获取数据库连接串。 + + 约定: + - 优先读取环境变量 `DATABASE_URL` + - 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod` + + 注意:迁移阶段仅依赖 DATABASE_URL;不应强制要求 REDIS_URL / CELERY_BROKER_URL 等配置存在。 + """ + + # 允许在 alembic 命令时临时覆盖 + env_url = os.getenv("DATABASE_URL") + if env_url: + return env_url + + app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev" + env_file = SERVER_DIR / (".env.prod" if app_env == "prod" else ".env.dev") + kv = _read_env_kv(env_file) + url = kv.get("DATABASE_URL") + if url: + return url + + raise RuntimeError( + "缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。" + ) + + +def run_migrations_offline() -> None: + """离线模式:生成 SQL 脚本,不连接数据库。""" + + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """在线模式:在已有连接上执行迁移。""" + + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """在线模式:使用异步引擎执行迁移。""" + + url = _get_database_url() + config.set_main_option("sqlalchemy.url", url) + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section) or {}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) + diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..026ce91 --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/server/alembic/versions/0001_init_content_tables.py b/server/alembic/versions/0001_init_content_tables.py new file mode 100644 index 0000000..ba9bb56 --- /dev/null +++ b/server/alembic/versions/0001_init_content_tables.py @@ -0,0 +1,147 @@ +"""init content tables + +Revision ID: 0001_init_content_tables +Revises: +Create Date: 2026-02-01 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +# revision identifiers, used by Alembic. +revision = "0001_init_content_tables" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "contents", + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + primary_key=True, + autoincrement=True, + comment="文案唯一 ID(自增;文案微调时保持不变)", + ), + sa.Column("text_en", sa.Text(), nullable=True, comment="英文文案(可空;若为空则必须提供 text_tc)"), + sa.Column("text_tc", sa.Text(), nullable=True, comment="繁体中文文案(可空;若为空则必须提供 text_en)"), + sa.Column("author_id", sa.String(length=255), nullable=True, comment="作者/来源 ID(可空;用于多样性与频控)"), + sa.Column("template_id", sa.String(length=255), nullable=True, comment="模板 ID(可空;用于多样性与频控)"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"), + sa.CheckConstraint( + "(text_en IS NOT NULL) OR (text_tc IS NOT NULL)", + name="chk_contents_text_present", + ), + mysql_charset="utf8mb4", + ) + op.create_index("idx_contents_author_id", "contents", ["author_id"], unique=False) + op.create_index("idx_contents_template_id", "contents", ["template_id"], unique=False) + + op.create_table( + "content_profiles", + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + sa.ForeignKey("contents.content_id", ondelete="CASCADE"), + primary_key=True, + comment="FK -> contents.content_id", + ), + sa.Column( + "stage", + sa.Enum("general", "expecting", "parenting", "unknown", name="content_stage"), + server_default="general", + nullable=False, + comment="母职阶段定位(general/expecting/parenting/unknown)", + ), + sa.Column("emotion_score", sa.Numeric(3, 2), nullable=True, comment="情绪调性 0~1;NULL 表示 general"), + sa.Column( + "context_suitability_json", + sa.JSON(), + nullable=False, + comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ), + sa.Column( + "need_suitability_json", + sa.JSON(), + nullable=False, + comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ), + sa.Column( + "personalization_power", + sa.SmallInteger(), + server_default="0", + nullable=False, + comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)", + ), + sa.Column( + "review_confidence", + sa.Numeric(3, 2), + nullable=True, + comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底", + ), + sa.Column( + "is_safe_pool", + sa.Boolean(), + server_default=sa.text("0"), + nullable=False, + comment="是否属于通用安全池(L3 兜底)", + ), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="画像更新时间"), + mysql_charset="utf8mb4", + ) + op.create_index("idx_profiles_is_safe_pool", "content_profiles", ["is_safe_pool"], unique=False) + op.create_index("idx_profiles_personalization_power", "content_profiles", ["personalization_power"], unique=False) + op.create_index("idx_profiles_stage", "content_profiles", ["stage"], unique=False) + + op.create_table( + "content_risk_flags", + sa.Column( + "id", + mysql.BIGINT(unsigned=True), + primary_key=True, + autoincrement=True, + comment="主键", + ), + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + sa.ForeignKey("contents.content_id", ondelete="CASCADE"), + nullable=False, + comment="FK -> contents.content_id", + ), + sa.Column( + "flag", + sa.String(length=64), + nullable=False, + comment="风险标记(unsafe_for_* / block_* / soft_*)", + ), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"), + sa.UniqueConstraint("content_id", "flag", name="uniq_content_flag"), + mysql_charset="utf8mb4", + ) + op.create_index("idx_content_id", "content_risk_flags", ["content_id"], unique=False) + op.create_index("idx_flag", "content_risk_flags", ["flag"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_flag", table_name="content_risk_flags") + op.drop_index("idx_content_id", table_name="content_risk_flags") + op.drop_table("content_risk_flags") + + op.drop_index("idx_profiles_stage", table_name="content_profiles") + op.drop_index("idx_profiles_personalization_power", table_name="content_profiles") + op.drop_index("idx_profiles_is_safe_pool", table_name="content_profiles") + op.drop_table("content_profiles") + + op.drop_index("idx_contents_template_id", table_name="contents") + op.drop_index("idx_contents_author_id", table_name="contents") + op.drop_table("contents") + diff --git a/server/app/__pycache__/__init__.cpython-314.pyc b/server/app/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b22c8e6..0000000 Binary files a/server/app/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/server/app/__pycache__/main.cpython-314.pyc b/server/app/__pycache__/main.cpython-314.pyc deleted file mode 100644 index 73e5465..0000000 Binary files a/server/app/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..9bb6d12 --- /dev/null +++ b/server/app/api/__init__.py @@ -0,0 +1,6 @@ +""" +API 路由入口 + +说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。 +""" + diff --git a/server/app/api/limits.py b/server/app/api/limits.py new file mode 100644 index 0000000..a3fce02 --- /dev/null +++ b/server/app/api/limits.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Dict, Tuple + +from fastapi import HTTPException, Request + + +@dataclass +class FixedWindowRateLimiter: + """ + 固定窗口限流(内存版)。 + + 约束: + - 适用于单进程/单实例;多进程/多实例下不共享计数(V1 可接受) + - 窗口粒度:按分钟 bucket(window_seconds 建议为 60) + """ + + limit: int + window_seconds: int + _counters: Dict[Tuple[str, int], int] = field(default_factory=dict) + _last_gc_bucket: int = 0 + + def _bucket(self, now_ts: float) -> int: + return int(now_ts // float(self.window_seconds)) + + def _gc(self, current_bucket: int) -> None: + # 每隔一段时间清理一次,避免 dict 无限增长(保留最近 3 个 bucket) + if self._last_gc_bucket == current_bucket: + return + self._last_gc_bucket = current_bucket + keep_from = current_bucket - 2 + to_delete = [k for k in self._counters.keys() if k[1] < keep_from] + for k in to_delete: + self._counters.pop(k, None) + + def allow(self, *, key: str, now_ts: float) -> None: + bucket = self._bucket(now_ts) + self._gc(bucket) + + k = (str(key), int(bucket)) + n = int(self._counters.get(k, 0)) + 1 + self._counters[k] = n + if n > int(self.limit): + raise HTTPException(status_code=429, detail="rate_limited") + + +_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60) + + +async def rate_limit_reco_by_ip(request: Request) -> None: + """ + 推荐接口限流:按 IP,1 分钟 10 次。 + """ + + ip = "unknown" + if request.client and request.client.host: + ip = str(request.client.host) + + _reco_rate_limiter.allow(key=ip, now_ts=time.time()) + diff --git a/server/app/api/v1/__init__.py b/server/app/api/v1/__init__.py new file mode 100644 index 0000000..df8483f --- /dev/null +++ b/server/app/api/v1/__init__.py @@ -0,0 +1,4 @@ +""" +V1 API 路由集合 +""" + diff --git a/server/app/api/v1/reco.py b/server/app/api/v1/reco.py new file mode 100644 index 0000000..b40a08c --- /dev/null +++ b/server/app/api/v1/reco.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.limits import rate_limit_reco_by_ip +from app.db.session import get_db +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository +from app.features.personalized_reco.reco_engine import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult +from app.features.user_profile_scoring.types import UserProfileV1_2 + +router = APIRouter( + prefix="/v1/reco", + tags=["reco"], + dependencies=[Depends(rate_limit_reco_by_ip)], +) + + +class RecoRequest(BaseModel): + k: Optional[int] = None + user_profile: UserProfileV1_2 + already_recommended_ids: list[Any] = Field(default_factory=list) + touched_or_viewed_ids: list[Any] = Field(default_factory=list) + now: Optional[datetime] = None + + +def _parse_now_from_header(x_now: Optional[str]) -> Optional[datetime]: + if not x_now: + return None + raw = str(x_now).strip() + if not raw: + return None + # 支持 Z + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(raw) + except Exception: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _pick_now(*, header_now: Optional[str], body_now: Optional[datetime]) -> datetime: + dt = _parse_now_from_header(header_now) + if dt is not None: + return dt + if body_now is not None: + if body_now.tzinfo is None: + return body_now.replace(tzinfo=timezone.utc) + return body_now + return datetime.now(timezone.utc) + + +def _pick_locale_from_accept_language(accept_language: Optional[str]) -> str: + """ + 从 Accept-Language 映射 locale: + - 缺失/空 -> en + - 含 zh-TW/zh-HK/tc -> tc + - 其他 -> en + """ + + raw = (accept_language or "").strip().lower() + if not raw: + return "en" + if "zh-tw" in raw or "zh-hk" in raw or "tc" in raw: + return "tc" + return "en" + + +async def get_reco_repo(db: AsyncSession = Depends(get_db)) -> ContentRepository: + """ + 构造推荐 repo(可在测试中 override,避免依赖真实 DB)。 + """ + + return SqlAlchemyContentRepository(db) + + +@router.post("/feed", response_model=RecoEngineResult) +async def reco_feed( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 30 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="feed", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +@router.post("/push", response_model=RecoEngineResult) +async def reco_push( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 1 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="push", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +@router.post("/widget", response_model=RecoEngineResult) +async def reco_widget( + req: RecoRequest, + repo: ContentRepository = Depends(get_reco_repo), + x_now: Optional[str] = Header(default=None, alias="X-Now"), + accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), +) -> RecoEngineResult: + k_i = 1 if req.k is None else int(req.k) + now = _pick_now(header_now=x_now, body_now=req.now) + locale = _pick_locale_from_accept_language(accept_language) + + return await recommend( + repo=repo, + scene="widget", + user_profile=req.user_profile, + already_recommended_ids=list(req.already_recommended_ids or []), + touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), + k=k_i, + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + diff --git a/server/app/api/v1/user_profile_scoring.py b/server/app/api/v1/user_profile_scoring.py new file mode 100644 index 0000000..d63128d --- /dev/null +++ b/server/app/api/v1/user_profile_scoring.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire +from app.features.user_profile_scoring.types import BuildUserProfileRequest, UserProfileV1_2_Extended + +router = APIRouter(prefix="/v1/user-profile", tags=["user-profile"]) + + +@router.post("/score", response_model=UserProfileV1_2_Extended) +async def score_user_profile(req: BuildUserProfileRequest) -> UserProfileV1_2_Extended: + """ + 根据问卷答案生成用户画像(V1.2) + + 说明: + - 问卷题目允许跳过 + - 允许注入 generated_at/now,用于回归测试或离线批处理 + """ + + return build_user_profile_from_questionnaire( + req.answers, + generated_at=req.generated_at, + now=req.now, + ) + diff --git a/server/app/core/__pycache__/config.cpython-314.pyc b/server/app/core/__pycache__/config.cpython-314.pyc deleted file mode 100644 index 319065d..0000000 Binary files a/server/app/core/__pycache__/config.cpython-314.pyc and /dev/null differ diff --git a/server/app/db/models/__init__.py b/server/app/db/models/__init__.py new file mode 100644 index 0000000..5c1a619 --- /dev/null +++ b/server/app/db/models/__init__.py @@ -0,0 +1,14 @@ +""" +数据库 ORM 模型集合。 + +说明: +- 该包用于集中定义 SQLAlchemy ORM models,供 Alembic autogenerate 扫描。 +- 需要在此处导入所有模型,确保 `Base.metadata` 完整。 +""" + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag + +__all__ = ["Content", "ContentProfile", "ContentRiskFlag"] + diff --git a/server/app/db/models/content.py b/server/app/db/models/content.py new file mode 100644 index 0000000..bd6a346 --- /dev/null +++ b/server/app/db/models/content.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Content(Base): + """ + 文案主体表。 + + 多语言约束: + - 当前仅支持 EN / TC(繁体中文) + - 至少需要提供 `text_en` 或 `text_tc` 之一 + """ + + __tablename__ = "contents" + + __table_args__ = ( + CheckConstraint( + "(text_en IS NOT NULL) OR (text_tc IS NOT NULL)", + name="chk_contents_text_present", + ), + Index("idx_contents_author_id", "author_id"), + Index("idx_contents_template_id", "template_id"), + ) + + content_id: Mapped[int] = mapped_column( + primary_key=True, + autoincrement=True, + comment="文案唯一 ID(自增;文案微调时保持不变)", + ) + + text_en: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="英文文案(可空;若为空则必须提供 text_tc)", + ) + text_tc: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="繁体中文文案(可空;若为空则必须提供 text_en)", + ) + + author_id: Mapped[str | None] = mapped_column( + nullable=True, + comment="作者/来源 ID(可空;用于多样性与频控)", + ) + template_id: Mapped[str | None] = mapped_column( + nullable=True, + comment="模板 ID(可空;用于多样性与频控)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + server_onupdate=func.now(), + comment="更新时间", + ) + diff --git a/server/app/db/models/content_profile.py b/server/app/db/models/content_profile.py new file mode 100644 index 0000000..c0c26b3 --- /dev/null +++ b/server/app/db/models/content_profile.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +ContentStage = Literal["general", "expecting", "parenting", "unknown"] + + +class ContentProfile(Base): + """ + 内容画像表(Content Profile / Cᵢ)。 + + 字段语义必须严格对齐: + - `设计说明文档/句子文案打分規則.md` + """ + + __tablename__ = "content_profiles" + + __table_args__ = ( + Index("idx_profiles_stage", "stage"), + Index("idx_profiles_personalization_power", "personalization_power"), + Index("idx_profiles_is_safe_pool", "is_safe_pool"), + ) + + content_id: Mapped[int] = mapped_column( + ForeignKey("contents.content_id", ondelete="CASCADE"), + primary_key=True, + comment="FK -> contents.content_id", + ) + + stage: Mapped[ContentStage] = mapped_column( + Enum("general", "expecting", "parenting", "unknown", name="content_stage"), + nullable=False, + server_default="general", + comment="母职阶段定位(general/expecting/parenting/unknown)", + ) + + emotion_score: Mapped[Optional[float]] = mapped_column( + Numeric(3, 2), + nullable=True, + comment="情绪调性 0~1;NULL 表示 general", + ) + + context_suitability_json: Mapped[dict] = mapped_column( + JSON, + nullable=False, + comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ) + need_suitability_json: Mapped[dict] = mapped_column( + JSON, + nullable=False, + comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ) + + personalization_power: Mapped[int] = mapped_column( + nullable=False, + server_default="0", + comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)", + ) + + review_confidence: Mapped[Optional[float]] = mapped_column( + Numeric(3, 2), + nullable=True, + comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底", + ) + + is_safe_pool: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + server_default="0", + comment="是否属于通用安全池(L3 兜底)", + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + server_onupdate=func.now(), + comment="画像更新时间", + ) + diff --git a/server/app/db/models/content_risk_flag.py b/server/app/db/models/content_risk_flag.py new file mode 100644 index 0000000..281e201 --- /dev/null +++ b/server/app/db/models/content_risk_flag.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ContentRiskFlag(Base): + """ + 内容风险标记(risk_flags)关联表。 + + 命名约束(语义来源:句子文案打分规则): + - 仅允许 `unsafe_for_*` / `block_*` / `soft_*` 前缀 + - 旧 flag(如 `block_stage_unknown`)需在写入/读取层做映射 + """ + + __tablename__ = "content_risk_flags" + + __table_args__ = ( + UniqueConstraint("content_id", "flag", name="uniq_content_flag"), + Index("idx_flag", "flag"), + Index("idx_content_id", "content_id"), + ) + + id: Mapped[int] = mapped_column( + primary_key=True, + autoincrement=True, + comment="主键", + ) + + content_id: Mapped[int] = mapped_column( + ForeignKey("contents.content_id", ondelete="CASCADE"), + nullable=False, + comment="FK -> contents.content_id", + ) + + flag: Mapped[str] = mapped_column( + nullable=False, + comment="风险标记(unsafe_for_* / block_* / soft_*)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="创建时间", + ) + diff --git a/server/app/features/personalized_reco/__init__.py b/server/app/features/personalized_reco/__init__.py new file mode 100644 index 0000000..d2565cc --- /dev/null +++ b/server/app/features/personalized_reco/__init__.py @@ -0,0 +1,6 @@ +""" +Personalized Reco(个性化推荐)功能模块集合。 + +该目录用于承载推荐引擎与其子模块(数据访问、打分、重排、可观测等)。 +""" + diff --git a/server/app/features/personalized_reco/content_repository/__init__.py b/server/app/features/personalized_reco/content_repository/__init__.py new file mode 100644 index 0000000..a67797c --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/__init__.py @@ -0,0 +1,8 @@ +""" +Content Repository(候选查询与数据访问层)。 + +说明: +- 本模块为推荐引擎提供可注入的数据访问接口(与 ORM/SQL 解耦)。 +- 负责将 DB 存储形态规范化为上层稳定的 ContentProfile 结构。 +""" + diff --git a/server/app/features/personalized_reco/content_repository/interface.py b/server/app/features/personalized_reco/content_repository/interface.py new file mode 100644 index 0000000..cdb308e --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/interface.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Protocol + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO + + +class ContentRepository(Protocol): + """ + 推荐引擎依赖的内容数据访问抽象接口(用于解耦 ORM/SQL)。 + """ + + async def fetch_candidates( + self, + *, + scene: str, + user_profile: object, + fallback_level: int, + limit: int, + locale: str, + exclude_content_ids: list[int] | None = None, + ) -> list[ContentProfileDTO]: + """ + 按场景与用户画像拉取候选内容画像(用于候选池)。 + """ + + async def fetch_contents_by_ids( + self, + *, + content_ids: list[int], + locale: str, + ) -> list[ContentProfileDTO]: + """ + 按 content_id 批量获取内容画像(去重、按输入顺序返回;缺语言/缺记录的 id 跳过)。 + """ + diff --git a/server/app/features/personalized_reco/content_repository/normalization.py b/server/app/features/personalized_reco/content_repository/normalization.py new file mode 100644 index 0000000..4bb56d2 --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/normalization.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import Any + +from app.features.personalized_reco.content_repository.types import Locale, normalize_locale + + +CONTEXT_KEYS: tuple[str, ...] = ("family", "work", "relationship", "friends", "health") +NEED_KEYS: tuple[str, ...] = ( + "emotional_support", + "parenting_pressure", + "self_worth", + "anxiety_relief", + "rest_balance", +) + + +def _normalize_discrete_score(v: Any, *, default: float = 0.5) -> float: + """ + 将 suitability 的离散值规范化为 0/0.5/1。 + + 非法值一律兜底 default(默认 0.5)。 + """ + + try: + if v in (0, 0.0): + return 0.0 + if v in (0.5,): + return 0.5 + if v in (1, 1.0): + return 1.0 + # 允许字符串形式的 "0"/"0.5"/"1" + if isinstance(v, str): + s = v.strip() + if s == "0": + return 0.0 + if s == "0.5": + return 0.5 + if s == "1": + return 1.0 + except Exception: + return default + return default + + +def normalize_suitability(raw: Any, *, keys: tuple[str, ...]) -> dict[str, float]: + """ + 解析 suitability JSON,缺失时补齐全 0.5。 + + raw 期望为 dict;否则视为缺失。 + """ + + data: dict[str, Any] = raw if isinstance(raw, dict) else {} + return {k: _normalize_discrete_score(data.get(k), default=0.5) for k in keys} + + +def normalize_review_confidence(raw: Any) -> float: + """ + review_confidence 缺失/NULL 时兜底 0.7。 + """ + + try: + if raw is None: + return 0.7 + v = float(raw) + if 0.0 <= v <= 1.0: + return v + except Exception: + pass + return 0.7 + + +def normalize_personalization_power(raw: Any) -> float: + """ + DB 约定存 0/5/10,读取层输出 0/0.5/1。 + """ + + try: + if raw is None: + return 0.0 + v = int(raw) + if v == 0: + return 0.0 + if v == 5: + return 0.5 + if v == 10: + return 1.0 + except Exception: + pass + return 0.0 + + +_RISK_FLAG_MAP: dict[str, str] = { + "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", +} + + +def normalize_risk_flags(raw_flags: list[str] | None) -> list[str]: + """ + risk_flags 旧→新映射、去重、稳定排序(字典序)。 + """ + + flags = raw_flags or [] + mapped: set[str] = set() + for f in flags: + if not f: + continue + name = _RISK_FLAG_MAP.get(f, f) + mapped.add(name) + return sorted(mapped) + + +def pick_text(*, text_en: str | None, text_tc: str | None, locale: str) -> str | None: + """ + 按 locale 选择输出文案文本。 + + 当前仅支持 EN/TC,且不允许语言回退: + - locale=en*:必须使用 text_en + - locale=tc/zh-TW/zh-HK:必须使用 text_tc + """ + + loc: Locale = normalize_locale(locale) + if loc == "en": + return text_en if text_en else None + # loc == "tc" + return text_tc if text_tc else None + diff --git a/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py b/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py new file mode 100644 index 0000000..a57c6f9 --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/sqlalchemy_repo.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Iterable + +from sqlalchemy import Select, and_, desc, not_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.normalization import ( + CONTEXT_KEYS, + NEED_KEYS, + normalize_personalization_power, + normalize_review_confidence, + normalize_risk_flags, + normalize_suitability, + pick_text, +) +from app.features.personalized_reco.content_repository.types import ContentProfileDTO + + +@dataclass(frozen=True) +class _UserSignals: + """ + 从 user_profile 中提取 repository 级别需要的最小信号。 + + 注意:更复杂的规则(Hard Filter/Scoring/Rerank)不在本层处理。 + """ + + missing_need: bool + missing_context: bool + missing_emotion: bool + stage: str | None # expecting/parenting/unknown/general/None + + +def _bool(v: Any) -> bool: + return bool(v) + + +def _extract_user_signals(user_profile: object) -> _UserSignals: + """ + 兼容 pydantic model / dict / 其他对象的最小字段读取。 + """ + + def _get(obj: Any, key: str, default: Any = None) -> Any: + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + need = _get(user_profile, "need", {}) or {} + context = _get(user_profile, "context", {}) or {} + emotion_score = _get(user_profile, "emotion_score", None) + + missing_need = len(need) == 0 + missing_context = len(context) == 0 + missing_emotion = emotion_score is None + + # stage: from user_profile.stage (one-hot) + stage_obj = _get(user_profile, "stage", None) + stage: str | None = None + if stage_obj is not None: + expecting = _get(stage_obj, "expecting", None) + parenting = _get(stage_obj, "parenting", None) + unknown = _get(stage_obj, "unknown", None) + if _bool(expecting): + stage = "expecting" + elif _bool(parenting): + stage = "parenting" + elif _bool(unknown): + stage = "unknown" + + return _UserSignals( + missing_need=missing_need, + missing_context=missing_context, + missing_emotion=missing_emotion, + stage=stage, + ) + + +def _dedupe_preserve_order(ids: Iterable[int]) -> list[int]: + seen: set[int] = set() + out: list[int] = [] + for i in ids: + if i in seen: + continue + seen.add(i) + out.append(i) + return out + + +class SqlAlchemyContentRepository(ContentRepository): + """ + 基于 SQLAlchemy AsyncSession 的 ContentRepository 实现。 + """ + + def __init__(self, session: AsyncSession): + self._session = session + + async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: + """ + - 输入去重 + - 输出顺序与输入一致(按首次出现顺序) + - 缺记录或缺目标语言文本:跳过 + - 不产生 N+1(主体+画像一次,flags 一次) + """ + + unique_ids = _dedupe_preserve_order(content_ids) + if not unique_ids: + return [] + + # locale 文本存在性过滤(不允许语言回退) + # en -> 必须 text_en;tc -> 必须 text_tc + # 过滤在 DB 层做,避免后续组装无意义 + from app.features.personalized_reco.content_repository.types import normalize_locale + + loc = normalize_locale(locale) + text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None) + + stmt: Select = ( + select(Content, ContentProfile) + .join(ContentProfile, Content.content_id == ContentProfile.content_id) + .where(and_(Content.content_id.in_(unique_ids), text_filter)) + ) + + rows = (await self._session.execute(stmt)).all() + if not rows: + return [] + + # 先组装主体+画像,后续再补 risk_flags + by_id: dict[int, dict[str, Any]] = {} + valid_ids: list[int] = [] + for content, profile in rows: + cid = int(content.content_id) + text = pick_text(text_en=content.text_en, text_tc=content.text_tc, locale=locale) + if not text: + continue + by_id[cid] = { + "content": content, + "profile": profile, + "text": text, + } + valid_ids.append(cid) + + if not by_id: + return [] + + # 批量取 flags(避免 join 行膨胀) + flags_stmt = select(ContentRiskFlag.content_id, ContentRiskFlag.flag).where( + ContentRiskFlag.content_id.in_(list(by_id.keys())) + ) + flags_rows = (await self._session.execute(flags_stmt)).all() + flags_map: dict[int, list[str]] = defaultdict(list) + for cid, flag in flags_rows: + flags_map[int(cid)].append(str(flag)) + + result_by_id: dict[int, ContentProfileDTO] = {} + for cid, payload in by_id.items(): + content: Content = payload["content"] + profile: ContentProfile = payload["profile"] + text: str = payload["text"] + + dto = ContentProfileDTO( + content_id=cid, + text=text, + stage=profile.stage, # type: ignore[arg-type] + emotion_score=float(profile.emotion_score) if profile.emotion_score is not None else None, + context_suitability=normalize_suitability(profile.context_suitability_json, keys=CONTEXT_KEYS), + need_suitability=normalize_suitability(profile.need_suitability_json, keys=NEED_KEYS), + personalization_power=normalize_personalization_power(profile.personalization_power), + risk_flags=normalize_risk_flags(flags_map.get(cid)), + author_id=content.author_id, + template_id=content.template_id, + review_confidence=normalize_review_confidence(profile.review_confidence), + ) + result_by_id[cid] = dto + + # 按输入顺序返回(跳过缺失/被过滤的) + out: list[ContentProfileDTO] = [] + for cid in unique_ids: + dto = result_by_id.get(cid) + if dto is not None: + out.append(dto) + return out + + async def fetch_candidates( + self, + *, + scene: str, + user_profile: object, + fallback_level: int, + limit: int, + locale: str, + exclude_content_ids: list[int] | None = None, + ) -> list[ContentProfileDTO]: + """ + 两段式候选召回: + 1) 先查候选 content_id 列表(含粗过滤、locale 过滤、limit*multiplier) + 2) 再批量补全字段(复用 fetch_contents_by_ids) + """ + + if limit <= 0: + return [] + + signals = _extract_user_signals(user_profile) + effective_fallback = int(fallback_level) + if signals.missing_need or signals.missing_context or signals.missing_emotion: + effective_fallback = max(effective_fallback, 1) + + # locale 文本存在性过滤(不允许语言回退) + from app.features.personalized_reco.content_repository.types import normalize_locale + + loc = normalize_locale(locale) + text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None) + + filters: list[Any] = [text_filter] + if exclude_content_ids: + filters.append(not_(Content.content_id.in_(exclude_content_ids))) + + # fallback 约束(repository 只做“降级约束”,不做 hard filter) + if effective_fallback >= 1: + # personalization_power <= 5 代表 <= 0.5 + filters.append(ContentProfile.personalization_power <= 5) + if effective_fallback >= 2: + filters.append(ContentProfile.personalization_power == 0) + filters.append(ContentProfile.stage == "general") + if effective_fallback >= 3: + filters.append(ContentProfile.is_safe_pool.is_(True)) + filters.append(ContentProfile.personalization_power == 0) + filters.append(ContentProfile.stage == "general") + + # stage 粗过滤(仅 L0/L1 才做“用户阶段 + general”;L2/L3 已强制 general) + if effective_fallback < 2: + user_stage = signals.stage + if user_stage in {"expecting", "parenting"}: + filters.append(ContentProfile.stage.in_([user_stage, "general"])) + else: + # unknown 或无法判定:仅取 general,避免误推 + filters.append(ContentProfile.stage == "general") + + multiplier = 5 + raw_limit = max(limit * multiplier, limit) + + stmt_ids = ( + select(Content.content_id) + .join(ContentProfile, Content.content_id == ContentProfile.content_id) + .where(and_(*filters)) + .order_by(desc(ContentProfile.updated_at)) + .limit(raw_limit) + ) + + candidate_ids_rows = (await self._session.execute(stmt_ids)).scalars().all() + candidate_ids = [int(x) for x in candidate_ids_rows] + if not candidate_ids: + return [] + + # 复用按 ID 批量补全(会再次做 locale 过滤,但成本可接受,且可保证一致行为) + items = await self.fetch_contents_by_ids(content_ids=candidate_ids, locale=locale) + return items[:limit] + diff --git a/server/app/features/personalized_reco/content_repository/types.py b/server/app/features/personalized_reco/content_repository/types.py new file mode 100644 index 0000000..53c344c --- /dev/null +++ b/server/app/features/personalized_reco/content_repository/types.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +# 当前阶段仅支持 EN / TC(繁体中文) +Locale = Literal["en", "tc"] + + +def normalize_locale(locale: str) -> Locale: + """ + 将客户端传入的 locale 归一化为内部枚举(仅 EN / TC)。 + + 约定: + - 任何以 "en" 开头的 locale 归一化为 "en"(例如 en、en-US) + - "tc"/"zh-TW"/"zh-HK" 归一化为 "tc" + - 其他 locale 视为不支持 + """ + + raw = (locale or "").strip() + if not raw: + raise ValueError("locale 不能为空(当前仅支持 en/tc)") + + low = raw.lower() + if low.startswith("en"): + return "en" + if low in {"tc", "zh-tw", "zh-hk", "zh_tw", "zh_hk"}: + return "tc" + + raise ValueError(f"不支持的 locale:{locale!r}(当前仅支持 en/tc)") + + +ContentStage = Literal["general", "expecting", "parenting", "unknown"] + + +class ContentProfileDTO(BaseModel): + """ + 推荐模块消费的内容画像(稳定字段契约)。 + + 注意: + - text 已按 locale 选择,不允许语言回退(缺语言文本的内容不返回) + - emotion_score 为 None 表示 general + - personalization_power 对上统一为 0/0.5/1 + - review_confidence 缺失时兜底 0.7 + """ + + content_id: int + text: str + stage: ContentStage + emotion_score: Optional[float] = None + + context_suitability: dict[str, float] = Field(default_factory=dict) + need_suitability: dict[str, float] = Field(default_factory=dict) + + personalization_power: float + risk_flags: list[str] = Field(default_factory=list) + + # 可选字段 + author_id: Optional[str] = None + template_id: Optional[str] = None + review_confidence: float = 0.7 + diff --git a/server/app/features/personalized_reco/observability/__init__.py b/server/app/features/personalized_reco/observability/__init__.py new file mode 100644 index 0000000..a1c16b5 --- /dev/null +++ b/server/app/features/personalized_reco/observability/__init__.py @@ -0,0 +1,20 @@ +""" +个性化推荐|Observability 子模块(可观测性与打点载荷) + +说明: +- 只负责统一 `RecoMeta` 结构与构建(builder),不负责埋点 SDK/落库/上报实现。 +- `RecoMeta` 需要同时被 `reco-engine` 与 `integration-api-worker` 使用。 +""" + +from .builder import RecoMetaBuilder +from .types import MissingFields, RecoMeta +from .utils import compute_empty_reason, compute_missing_fields + +__all__ = [ + "MissingFields", + "RecoMeta", + "RecoMetaBuilder", + "compute_empty_reason", + "compute_missing_fields", +] + diff --git a/server/app/features/personalized_reco/observability/builder.py b/server/app/features/personalized_reco/observability/builder.py new file mode 100644 index 0000000..00a3438 --- /dev/null +++ b/server/app/features/personalized_reco/observability/builder.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Optional + +from app.features.personalized_reco.observability.types import MissingFields, RecoMeta, Scene +from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields + +logger = logging.getLogger(__name__) + + +def _non_negative_int(value: Any, *, default: int = 0) -> int: + try: + n = int(value) + except Exception: + return int(default) + return max(0, int(n)) + + +class RecoMetaBuilder: + """ + 在推荐 pipeline 中逐阶段填充 RecoMeta,避免“散落字段/散落日志”。 + + 说明(V1): + - set 调用允许任意顺序;build 时会做防御式兜底与单调性修正 + - 单调性约束:raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k + """ + + def __init__(self, *, scene: Scene, user_profile: object, k: int, now: Optional[datetime] = None) -> None: + self.scene: Scene = scene + self.user_profile = user_profile + self.k = _non_negative_int(k, default=0) + self.now = now + + self._raw: Optional[int] = None + self._after_hard: Optional[int] = None + self._after_dedup: Optional[int] = None + self._after_freqcap: Optional[int] = None + self._served_k: Optional[int] = None + self._fallback_level_final: Optional[int] = None + + self._risk_filtered_count_by_flag: dict[str, int] = {} + self._freqcap_filtered_counts: dict[str, int] = {} + self._config_snapshot: dict[str, Any] = {} + + def set_candidate_pool_size_raw(self, n: Any) -> "RecoMetaBuilder": + self._raw = _non_negative_int(n) + return self + + def set_after_hard_filter(self, n: Any, *, risk_filtered_count_by_flag: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder": + self._after_hard = _non_negative_int(n) + if risk_filtered_count_by_flag: + self._risk_filtered_count_by_flag = {str(k): _non_negative_int(v) for k, v in risk_filtered_count_by_flag.items()} + return self + + def set_after_dedup(self, n: Any) -> "RecoMetaBuilder": + self._after_dedup = _non_negative_int(n) + return self + + def set_after_freqcap(self, n: Any, *, freqcap_filtered_counts: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder": + self._after_freqcap = _non_negative_int(n) + if freqcap_filtered_counts: + self._freqcap_filtered_counts = {str(k): _non_negative_int(v) for k, v in freqcap_filtered_counts.items()} + return self + + def set_fallback_level_final(self, level: Any, *, reason: Optional[str] = None) -> "RecoMetaBuilder": + # reason 预留,V1 先不入 meta(可放入 config_snapshot 或后续字段) + self._fallback_level_final = _non_negative_int(level, default=0) + if reason: + self._config_snapshot.setdefault("fallback_trigger_reason", str(reason)) + return self + + def set_served_k(self, n: Any) -> "RecoMetaBuilder": + self._served_k = _non_negative_int(n) + return self + + def set_config_snapshot(self, snapshot: dict[str, Any]) -> "RecoMetaBuilder": + self._config_snapshot = dict(snapshot or {}) + return self + + def build(self) -> RecoMeta: + missing: MissingFields = compute_missing_fields(self.user_profile) + conf_u = getattr(self.user_profile, "profile_confidence", 1.0) + try: + conf_u_f = float(conf_u) + except Exception: + conf_u_f = 1.0 + if conf_u_f != conf_u_f: + conf_u_f = 1.0 + + raw = self._raw if self._raw is not None else 0 + after_hard = self._after_hard if self._after_hard is not None else raw + after_dedup = self._after_dedup if self._after_dedup is not None else after_hard + after_freqcap = self._after_freqcap if self._after_freqcap is not None else after_dedup + served_k = self._served_k if self._served_k is not None else 0 + + # 防御式单调性修正(以最保守值输出) + if after_hard > raw: + logger.debug("after_hard_filter(%s) > raw(%s),已修正为 raw", after_hard, raw) + after_hard = raw + if after_dedup > after_hard: + logger.debug("after_dedup(%s) > after_hard_filter(%s),已修正为 after_hard_filter", after_dedup, after_hard) + after_dedup = after_hard + if after_freqcap > after_dedup: + logger.debug("after_freqcap(%s) > after_dedup(%s),已修正为 after_dedup", after_freqcap, after_dedup) + after_freqcap = after_dedup + if served_k > after_freqcap: + logger.debug("served_k(%s) > after_freqcap(%s),已修正为 after_freqcap", served_k, after_freqcap) + served_k = after_freqcap + + fallback_level_final = self._fallback_level_final if self._fallback_level_final is not None else 0 + + empty_reason = compute_empty_reason( + served_k=served_k, + candidate_pool_size_raw=raw, + candidate_pool_size_after_hard_filter=after_hard, + candidate_pool_size_after_freqcap=after_freqcap, + ) + + return RecoMeta( + scene=self.scene, + candidate_pool_size_raw=int(raw), + candidate_pool_size_after_hard_filter=int(after_hard), + candidate_pool_size_after_dedup=int(after_dedup), + candidate_pool_size_after_freqcap=int(after_freqcap), + fallback_level_final=int(fallback_level_final), + served_k=int(served_k), + empty_reason=empty_reason, + conf_U=float(conf_u_f), + missing_fields=missing, + risk_filtered_count_by_flag=dict(self._risk_filtered_count_by_flag), + freqcap_filtered_counts=dict(self._freqcap_filtered_counts), + config_snapshot=dict(self._config_snapshot), + ) + diff --git a/server/app/features/personalized_reco/observability/types.py b/server/app/features/personalized_reco/observability/types.py new file mode 100644 index 0000000..cb3b5f7 --- /dev/null +++ b/server/app/features/personalized_reco/observability/types.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +Scene = Literal["feed", "push", "widget"] + +EmptyReason = Literal["hard_filter_all", "freqcap_all", "pool_empty", "unknown"] + + +class MissingFields(BaseModel): + """ + 画像字段缺失情况(布尔结构)。 + """ + + need: bool = False + context: bool = False + emotion: bool = False + + +class RecoMeta(BaseModel): + """ + 推荐模块统一可观测载荷(返回给调用方;调用方负责上报/落库/打点)。 + """ + + scene: Scene + + candidate_pool_size_raw: int = 0 + candidate_pool_size_after_hard_filter: int = 0 + candidate_pool_size_after_dedup: int = 0 + candidate_pool_size_after_freqcap: int = 0 + + fallback_level_final: int = 0 + served_k: int = 0 + + # served_k=0 时必填;served_k>0 时建议为 None + empty_reason: Optional[EmptyReason] = None + + conf_U: float = 1.0 + missing_fields: MissingFields = Field(default_factory=MissingFields) + + # 可选:Hard Filter 风险命中统计(按 flag 聚合) + risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict) + + # 可选:Freqcap 过滤统计(sentence/author/template) + freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict) + + # 可选:调参快照(V1 可先只在内部事件使用) + config_snapshot: dict[str, Any] = Field(default_factory=dict) + diff --git a/server/app/features/personalized_reco/observability/utils.py b/server/app/features/personalized_reco/observability/utils.py new file mode 100644 index 0000000..0737854 --- /dev/null +++ b/server/app/features/personalized_reco/observability/utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Optional + +from app.features.personalized_reco.observability.types import EmptyReason, MissingFields + + +def compute_missing_fields(user_profile: object) -> MissingFields: + """ + 判定用户画像缺失字段(对齐算法规则 V1.2 口径)。 + + 规则: + - need:user_profile.need 为空对象 {} 或不存在 + - context:user_profile.context 为空对象 {} 或不存在 + - emotion:user_profile.emotion_score 为 None 或不存在 + """ + + need = getattr(user_profile, "need", None) + context = getattr(user_profile, "context", None) + emotion_score = getattr(user_profile, "emotion_score", None) + + need_missing = not bool(need) + context_missing = not bool(context) + emotion_missing = emotion_score is None + + return MissingFields(need=need_missing, context=context_missing, emotion=emotion_missing) + + +def compute_empty_reason( + *, + served_k: int, + candidate_pool_size_raw: int, + candidate_pool_size_after_hard_filter: int, + candidate_pool_size_after_freqcap: int, +) -> Optional[EmptyReason]: + """ + 判定 empty_reason(served_k=0 必填)。 + + 规则(对齐 plan): + - served_k>0 -> None + - raw==0 -> pool_empty + - raw>0 且 after_hard_filter==0 -> hard_filter_all + - after_freqcap==0 -> freqcap_all + - 其他 -> unknown + """ + + if int(served_k) > 0: + return None + + raw = int(candidate_pool_size_raw) + after_hard = int(candidate_pool_size_after_hard_filter) + after_freqcap = int(candidate_pool_size_after_freqcap) + + if raw == 0: + return "pool_empty" + if raw > 0 and after_hard == 0: + return "hard_filter_all" + if after_freqcap == 0: + return "freqcap_all" + return "unknown" + diff --git a/server/app/features/personalized_reco/reco_engine/__init__.py b/server/app/features/personalized_reco/reco_engine/__init__.py new file mode 100644 index 0000000..c5f2297 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/__init__.py @@ -0,0 +1,11 @@ +""" +Reco Engine(推荐引擎编排)。 + +该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline, +并输出统一结构:items + meta(可观测字段)。 +""" + +from app.features.personalized_reco.reco_engine.orchestrator import recommend + +__all__ = ["recommend"] + diff --git a/server/app/features/personalized_reco/reco_engine/defaults.py b/server/app/features/personalized_reco/reco_engine/defaults.py new file mode 100644 index 0000000..68a0b84 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/defaults.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from app.features.personalized_reco.reco_engine.types import RecoEngineConfig, Scene + + +def get_default_engine_config(scene: Scene) -> RecoEngineConfig: + """ + 获取推荐引擎默认配置(返回副本,避免被意外修改)。 + """ + + # V1:三种场景目前共用一套默认值;保留 scene 参数便于后续按场景拆分 + base = RecoEngineConfig() + return RecoEngineConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/reco_engine/hard_filter.py b/server/app/features/personalized_reco/reco_engine/hard_filter.py new file mode 100644 index 0000000..4379003 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/hard_filter.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Iterable, Optional + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.reco_engine.types import HardFilterResult, RecoConstraints, Scene + + +def _user_stage_key(user_profile: object) -> str: + """ + 从 user_profile.stage(one-hot) 提取用户阶段。 + 约定:unknown 通常必填,但这里做防御。 + """ + + stage_obj = getattr(user_profile, "stage", None) + if stage_obj is None: + return "unknown" + if getattr(stage_obj, "expecting", 0) == 1: + return "expecting" + if getattr(stage_obj, "parenting", 0) == 1: + return "parenting" + return "unknown" + + +def _user_emotion_score(user_profile: object) -> Optional[float]: + v = getattr(user_profile, "emotion_score", None) + if v is None: + return None + try: + f = float(v) + except Exception: + return None + if f != f: + return None + return f + + +def _count_hits(counter: dict[str, int], hits: Iterable[str]) -> None: + for h in hits: + counter[str(h)] += 1 + + +def hard_filter( + *, + scene: Scene, + user_profile: object, + candidates: list[ContentProfileDTO], + constraints: Optional[RecoConstraints] = None, +) -> HardFilterResult: + """ + Hard Filter(硬过滤)。 + + V1:仅实现硬规则集合(不做软惩罚,不做扩展 hard_rules)。 + """ + + cons = constraints or RecoConstraints() + + exclude_author_ids = set([a for a in (cons.exclude_author_ids or []) if a is not None and str(a).strip() != ""]) + exclude_template_ids = set([t for t in (cons.exclude_template_ids or []) if t is not None and str(t).strip() != ""]) + exclude_content_ids = set([int(x) for x in (cons.exclude_content_ids or []) if x is not None]) + + u_stage = _user_stage_key(user_profile) + u_emotion = _user_emotion_score(user_profile) + emotion_low = u_emotion is not None and float(u_emotion) <= 0.2 + + kept: list[ContentProfileDTO] = [] + removed_count = 0 + + # 统计:按命中 key 聚合计数(risk_flags 直接用 flag 字符串;跨维度/约束用 rule:* / constraint:* 前缀) + hit_counts: dict[str, int] = defaultdict(int) + hits_by_content_id: dict[int, list[str]] = {} + + for c in candidates or []: + cid = int(c.content_id) + hits: list[str] = [] + + # 约束:按 content_id/author_id/template_id 排除(视为硬过滤) + if cid in exclude_content_ids: + hits.append("constraint:exclude_content_id") + if c.author_id and c.author_id in exclude_author_ids: + hits.append("constraint:exclude_author_id") + if c.template_id and c.template_id in exclude_template_ids: + hits.append("constraint:exclude_template_id") + + flags = set([str(x) for x in (c.risk_flags or []) if x is not None and str(x).strip() != ""]) + + # 全场景必挡 + if "block_health_medical" in flags: + hits.append("block_health_medical") + + # 与用户阶段相关 + if u_stage == "unknown" and "unsafe_for_stage_unknown" in flags: + hits.append("unsafe_for_stage_unknown") + if u_stage == "parenting" and "unsafe_for_stage_parenting" in flags: + hits.append("unsafe_for_stage_parenting") + + # 与用户情绪相关 + if emotion_low and "unsafe_for_emotion_low" in flags: + hits.append("unsafe_for_emotion_low") + + # 跨维度规则:unknown stage + parenting_pressure 强命中 + 高个性化 + if u_stage == "unknown": + try: + need_val = float(c.need_suitability.get("parenting_pressure", 0.0)) + except Exception: + need_val = 0.0 + if need_val >= 1.0 and float(getattr(c, "personalization_power", 0.0)) >= 1.0: + hits.append("rule:unknown_stage_parenting_pressure_power1") + + if hits: + removed_count += 1 + # 单条去重后再计数,避免同 key 重复 + uniq_hits = sorted(set(hits)) + hits_by_content_id[cid] = uniq_hits + _count_hits(hit_counts, uniq_hits) + continue + + hits_by_content_id[cid] = [] + kept.append(c) + + return HardFilterResult( + kept_items=kept, + removed_count=int(removed_count), + risk_filtered_count_by_flag=dict(hit_counts), + hits_by_content_id=hits_by_content_id, + ) + diff --git a/server/app/features/personalized_reco/reco_engine/orchestrator.py b/server/app/features/personalized_reco/reco_engine/orchestrator.py new file mode 100644 index 0000000..1aa5915 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/orchestrator.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import datetime +from typing import Any, Optional + +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.types import ContentProfileDTO, normalize_locale +from app.features.personalized_reco.observability.builder import RecoMetaBuilder +from app.features.personalized_reco.reco_engine.defaults import get_default_engine_config +from app.features.personalized_reco.reco_engine.hard_filter import hard_filter +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineConfig, RecoEngineResult, RecommendedItem, Scene +from app.features.personalized_reco.reco_engine.utils import ( + clamp_personalization_power, + merge_exclude_ids, + normalize_or_default_locale, +) +from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap +from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate +from app.features.personalized_reco.scoring.defaults import get_default_config as get_default_score_config +from app.features.personalized_reco.scoring.score import score_content + +logger = logging.getLogger(__name__) + + +def _safe_int(value: Any, *, default: int = 0) -> int: + try: + n = int(value) + except Exception: + return int(default) + return int(n) + + +def _light_score_summary(score_result: Any) -> dict[str, Any]: + """ + 轻量 explanations:只保留少量关键字段,避免 payload 过大。 + """ + + bd = getattr(score_result, "breakdown", None) + if bd is None: + return {} + + def _get(name: str) -> Optional[float]: + v = getattr(bd, name, None) + if v is None: + return None + try: + f = float(v) + except Exception: + return None + if f != f: + return None + return f + + out: dict[str, Any] = { + "missing_fields": list(getattr(bd, "missing_fields", []) or []), + "S_core": _get("S_core"), + "S_personal": _get("S_personal"), + "P_uncertainty": _get("P_uncertainty"), + "P_risk": _get("P_risk"), + "P_widget_emotion_out_of_range": _get("P_widget_emotion_out_of_range"), + } + # 删除 None,减少噪音 + return {k: v for k, v in out.items() if v is not None and v != []} + + +def _apply_fallback_level_to_content(content: ContentProfileDTO, *, fallback_level: int) -> ContentProfileDTO: + """ + 对内容做防御式一致性处理(与回退梯度一致)。 + """ + + p2 = clamp_personalization_power(content.personalization_power, fallback_level=fallback_level) + if p2 == content.personalization_power: + return content + return content.model_copy(update={"personalization_power": float(p2)}) + + +def _merge_counter(dst: dict[str, int], src: dict[str, Any]) -> None: + for k, v in (src or {}).items(): + try: + n = int(v) + except Exception: + n = 0 + dst[str(k)] = int(dst.get(str(k), 0)) + max(0, int(n)) + + +async def recommend( + *, + repo: ContentRepository, + scene: Scene, + user_profile: object, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: datetime, + locale: Optional[str] = None, + constraints: Optional[RecoConstraints] = None, + config: Optional[RecoEngineConfig] = None, +) -> RecoEngineResult: + """ + Reco Engine 主入口:编排候选→过滤→打分→重排→回退,并输出 items + meta。 + """ + + cfg = config or get_default_engine_config(scene) + cons = constraints or RecoConstraints() + + k_i = max(0, _safe_int(k, default=0)) + meta_builder = RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k_i, now=now) + + if k_i <= 0: + meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0) + meta_builder.set_config_snapshot({"engine_note": "k<=0,直接返回空结果"}) + return RecoEngineResult(items=[], meta=meta_builder.build()) + + # locale:默认 en;严格校验仅支持 en/tc + raw_locale = normalize_or_default_locale(locale) + try: + effective_locale = normalize_locale(raw_locale) + except Exception as e: + meta_builder.set_config_snapshot({"error": str(e), "stage": "normalize_locale", "locale": raw_locale}) + meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0) + return RecoEngineResult(items=[], meta=meta_builder.build()) + + # 聚合统计(跨回退层级累加,确保 meta 单调性成立) + raw_total = 0 + after_hard_total = 0 + after_dedup_total = 0 + after_freqcap_total = 0 + + risk_counts_total: dict[str, int] = defaultdict(int) + freqcap_counts_total: dict[str, int] = defaultdict(int) + + fallback_trace: list[dict[str, Any]] = [] + selected: list[ScoredCandidate] = [] + selected_level_by_id: dict[int, int] = {} + + last_fallback_level = 0 + last_reason = None + + for level in [0, 1, 2, 3]: + last_fallback_level = int(level) + k_remaining = max(0, k_i - len(selected)) + if k_remaining <= 0: + break + + # Feed:允许不足且不补齐时,拿到任何结果就停止 + if scene == "feed" and cfg.feed_allow_partial and (not cfg.feed_fill_with_fallback) and len(selected) > 0: + break + + # exclude_ids:already/touched + constraints.exclude + 已选内容(避免跨层重复) + exclude_ids = merge_exclude_ids( + already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected], + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + extra_exclude_content_ids=list(cons.exclude_content_ids or []), + ) + + multiplier = int(cfg.candidate_multiplier_feed if scene == "feed" else cfg.candidate_multiplier_push_widget) + base_limit = max(int(cfg.min_candidates_per_level), int(k_remaining) * max(1, int(multiplier))) + if cons.max_candidates_limit is not None and int(cons.max_candidates_limit) > 0: + limit = min(base_limit, int(cons.max_candidates_limit)) + else: + limit = base_limit + + # 1) Candidate + try: + cands = await repo.fetch_candidates( + scene=scene, + user_profile=user_profile, + fallback_level=int(level), + limit=int(limit), + locale=str(effective_locale), + exclude_content_ids=exclude_ids, + ) + except Exception as e: + logger.exception("fetch_candidates 失败:%s", e) + last_reason = "error:fetch_candidates" + fallback_trace.append( + { + "level": int(level), + "raw": 0, + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "error": str(e), + } + ) + continue + + raw_total += len(cands) + + if not cands: + last_reason = "pool_empty" + fallback_trace.append( + { + "level": int(level), + "raw": 0, + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "pool_empty", + } + ) + continue + + # 2) Hard Filter + hf = hard_filter(scene=scene, user_profile=user_profile, candidates=cands, constraints=cons) + kept = [x for x in hf.kept_items if isinstance(x, ContentProfileDTO)] + after_hard_total += len(kept) + _merge_counter(risk_counts_total, hf.risk_filtered_count_by_flag) + + if not kept: + last_reason = "hard_filter_all" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": 0, + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "hard_filter_all", + } + ) + continue + + # 3) Soft Scoring + score_cfg = get_default_score_config(scene) + if scene == "push": + # Push:强制启用不确定性惩罚(与 spec 对齐) + score_cfg = score_cfg.model_copy(update={"enable_uncertainty_penalty": True}) + + scored: list[ScoredCandidate] = [] + for c in kept: + c2 = _apply_fallback_level_to_content(c, fallback_level=int(level)) + try: + s = score_content(scene=scene, user_profile=user_profile, content_profile=c2, config=score_cfg, pass_filters=True, now=now) + except Exception as e: + # 单条异常不影响整体 + logger.exception("score_content 失败 content_id=%s:%s", getattr(c2, "content_id", None), e) + continue + + cid = int(c2.content_id) + hits = hf.hits_by_content_id.get(cid, []) + extra: dict[str, Any] = { + "text": c2.text, + "fallback_level_used": int(level), + } + if cfg.enable_explanations: + extra["hard_filter_hits"] = hits + extra["score_summary"] = _light_score_summary(s) + + scored.append( + ScoredCandidate( + content_id=cid, + final_score=float(getattr(s, "final_score", 0.0)), + author_id=c2.author_id, + template_id=c2.template_id, + content_profile=c2, + extra=extra, + ) + ) + + if not scored: + last_reason = "empty_after_scoring" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "reason": "empty_after_scoring", + } + ) + continue + + # 4) Rerank/Freqcap + try: + rer = rerank_and_freqcap( + scene=scene, + scored_candidates=scored, + already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected], + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=int(k_remaining), + recent_author_ids=cons.recent_author_ids, + recent_template_ids=cons.recent_template_ids, + ) + except Exception as e: + logger.exception("rerank_and_freqcap 失败:%s", e) + last_reason = "error:rerank_and_freqcap" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": 0, + "after_freqcap": 0, + "served_total": len(selected), + "error": str(e), + } + ) + continue + + after_dedup_total += int(rer.meta.candidate_pool_size_after_dedup) + after_freqcap_total += int(rer.meta.candidate_pool_size_after_freqcap) + _merge_counter(freqcap_counts_total, rer.meta.freqcap_filtered_counts) + + served_level = list(rer.ranked_items or [])[:k_remaining] + if not served_level: + last_reason = "freqcap_all" + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": int(rer.meta.candidate_pool_size_after_dedup), + "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap), + "served_total": len(selected), + "reason": "freqcap_all", + } + ) + continue + + for it in served_level: + cid = int(it.content_id) + selected.append(it) + selected_level_by_id[cid] = int(level) + + last_reason = None + fallback_trace.append( + { + "level": int(level), + "raw": len(cands), + "after_hard": len(kept), + "after_dedup": int(rer.meta.candidate_pool_size_after_dedup), + "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap), + "served_total": len(selected), + "served_added": len(served_level), + } + ) + + if len(selected) >= k_i: + break + + # 组装输出 items(按 selected 顺序) + items: list[RecommendedItem] = [] + for c in selected[:k_i]: + cid = int(c.content_id) + text = "" + if isinstance(c.extra, dict): + text = str(c.extra.get("text") or "") + + explanations = None + if cfg.enable_explanations and isinstance(c.extra, dict): + explanations = { + "fallback_level_used": c.extra.get("fallback_level_used"), + "hard_filter_hits": c.extra.get("hard_filter_hits"), + "score_summary": c.extra.get("score_summary"), + } + + items.append( + RecommendedItem( + content_id=cid, + text=text, + final_score=float(c.final_score), + fallback_level_final=int(selected_level_by_id.get(cid, last_fallback_level)), + explanations=explanations, + ) + ) + + served_k = len(items) + + # meta:使用聚合统计,确保单调性约束成立(raw>=after_hard>=after_dedup>=after_freqcap>=served_k) + # 注意:聚合统计理论上可能出现 after_* > raw_total(例如 repo 返回重复/异常),此处交由 builder 防御修正 + meta_builder.set_candidate_pool_size_raw(int(raw_total)) + meta_builder.set_after_hard_filter(int(after_hard_total), risk_filtered_count_by_flag=dict(risk_counts_total)) + meta_builder.set_after_dedup(int(after_dedup_total)) + meta_builder.set_after_freqcap(int(after_freqcap_total), freqcap_filtered_counts=dict(freqcap_counts_total)) + meta_builder.set_served_k(int(served_k)) + meta_builder.set_fallback_level_final(int(last_fallback_level), reason=last_reason) + + meta_builder.set_config_snapshot( + { + "fallback_trace": fallback_trace, + "engine_config": cfg.model_dump(), + "constraints": cons.model_dump(), + "locale": effective_locale, + } + ) + + return RecoEngineResult(items=items, meta=meta_builder.build()) + diff --git a/server/app/features/personalized_reco/reco_engine/types.py b/server/app/features/personalized_reco/reco_engine/types.py new file mode 100644 index 0000000..5a6d20d --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/types.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from app.features.personalized_reco.observability.types import RecoMeta + +Scene = Literal["feed", "push", "widget"] + + +class RecoConstraints(BaseModel): + """ + 推荐请求的可选约束(调用方可按需传入)。 + """ + + exclude_content_ids: list[int] = Field(default_factory=list) + exclude_author_ids: list[str] = Field(default_factory=list) + exclude_template_ids: list[str] = Field(default_factory=list) + + # 候选池上限(用于资源保护) + max_candidates_limit: Optional[int] = None + + # Push/Widget 作者/模板冷却窗口内的历史集合(增强频控输入) + # 说明:若不提供(None),rerank_freqcap 会记录缺失并跳过该维度过滤 + recent_author_ids: Optional[list[str]] = None + recent_template_ids: Optional[list[str]] = None + + +class RecoEngineConfig(BaseModel): + """ + 引擎级配置(V1 可调参项)。 + """ + + # Feed 是否允许 served_k < k(允许不足) + feed_allow_partial: bool = True + # Feed 是否在不足时继续回退补齐 + feed_fill_with_fallback: bool = True + + # 候选拉取倍率(limit = min(max_candidates_limit, k * multiplier)) + candidate_multiplier_feed: int = 10 + candidate_multiplier_push_widget: int = 30 + + # 每层回退的最大候选数量下限(避免 k=1 但候选过少) + min_candidates_per_level: int = 30 + + # explanations 默认开启(但应保持轻量) + enable_explanations: bool = True + + +class RecommendedItem(BaseModel): + """ + 引擎最终下发的推荐项。 + """ + + content_id: int + text: str + final_score: float + fallback_level_final: int + + # 解释信息:默认开启,但建议保持轻量(避免 payload 过大) + explanations: Optional[dict[str, Any]] = None + + +class RecoEngineResult(BaseModel): + """ + 引擎输出容器:items + meta。 + """ + + items: list[RecommendedItem] = Field(default_factory=list) + meta: RecoMeta + + +class HardFilterResult(BaseModel): + """ + Hard Filter 输出。 + """ + + kept_items: list[Any] = Field(default_factory=list) + removed_count: int = 0 + risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict) + # 每条内容的命中信息(仅用于 explanations;默认可为空) + hits_by_content_id: dict[int, list[str]] = Field(default_factory=dict) + + +class RecommendRequest(BaseModel): + """ + 内部便捷结构(单测/集成时可用)。 + """ + + scene: Scene + user_profile: Any + already_recommended_ids: list[Any] = Field(default_factory=list) + touched_or_viewed_ids: list[Any] = Field(default_factory=list) + k: int = 1 + now: datetime + locale: Optional[str] = None + constraints: Optional[RecoConstraints] = None + config: Optional[RecoEngineConfig] = None + diff --git a/server/app/features/personalized_reco/reco_engine/utils.py b/server/app/features/personalized_reco/reco_engine/utils.py new file mode 100644 index 0000000..4cb5a37 --- /dev/null +++ b/server/app/features/personalized_reco/reco_engine/utils.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any, Iterable, Optional + + +def normalize_int_id_list(mixed_ids: Iterable[Any]) -> list[int]: + """ + 将混合类型的 id 列表归一化为 int 列表。 + + 规则: + - int/可转 int 的 str -> int + - 其他(None/空字符串/不可解析)忽略 + """ + + out: list[int] = [] + for x in mixed_ids or []: + if x is None: + continue + if isinstance(x, bool): + # 避免 True/False 被当作 1/0 + continue + try: + s = str(x).strip() + if s == "": + continue + out.append(int(s)) + except Exception: + continue + return out + + +def merge_exclude_ids( + *, + already_recommended_ids: Iterable[Any], + touched_or_viewed_ids: Iterable[Any], + extra_exclude_content_ids: Optional[Iterable[int]] = None, +) -> list[int]: + """ + 合并并去重排除 id(保持首次出现顺序)。 + """ + + merged = list(normalize_int_id_list(list(already_recommended_ids or []) + list(touched_or_viewed_ids or []))) + if extra_exclude_content_ids: + merged += [int(x) for x in extra_exclude_content_ids if x is not None] + + seen: set[int] = set() + out: list[int] = [] + for cid in merged: + if cid in seen: + continue + seen.add(cid) + out.append(cid) + return out + + +def normalize_or_default_locale(locale: Optional[str]) -> str: + """ + locale 防御式归一化: + - 未传/空 -> 默认 "en" + - 其他 -> 原样返回,由下游 normalize_locale 做严格校验 + """ + + if locale is None: + return "en" + raw = str(locale).strip() + return raw or "en" + + +def clamp_personalization_power(power: Any, *, fallback_level: int) -> float: + """ + 按回退层级对 personalization_power 做防御式约束。 + + - L0:不改 + - L1:<= 0.5 + - L2/L3:= 0 + """ + + try: + p = float(power) + except Exception: + p = 0.0 + if p != p: + p = 0.0 + + if int(fallback_level) >= 2: + return 0.0 + if int(fallback_level) >= 1: + return min(p, 0.5) + return p + diff --git a/server/app/features/personalized_reco/rerank_freqcap/__init__.py b/server/app/features/personalized_reco/rerank_freqcap/__init__.py new file mode 100644 index 0000000..6accc3e --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/__init__.py @@ -0,0 +1,22 @@ +""" +个性化推荐|Rerank & Freqcap 子模块(重排 / 去重 / 频控) + +说明(V1): +- 本模块在 Soft Scoring 后执行,消费候选的 `final_score`,输出可下发的排序结果。 +- 仅做 Dedup / Freqcap / Feed MMR,不做 Soft Scoring 与 Hard Filter。 +""" + +from .defaults import get_default_config +from .rerank import rerank_and_freqcap +from .types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene + +__all__ = [ + "RerankConfig", + "RerankMeta", + "RerankResult", + "ScoredCandidate", + "Scene", + "get_default_config", + "rerank_and_freqcap", +] + diff --git a/server/app/features/personalized_reco/rerank_freqcap/defaults.py b/server/app/features/personalized_reco/rerank_freqcap/defaults.py new file mode 100644 index 0000000..3d84339 --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/defaults.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, Scene + + +_DEFAULTS: dict[Scene, RerankConfig] = { + # Feed:MMR λ=0.7;冷却参数不强制使用 + "feed": RerankConfig( + mmr_lambda=0.7, + top_n_for_mmr=200, + cooldown_sentence_days=0, + cooldown_author_days=0, + cooldown_template_days=0, + ), + # Push:工程默认(来自算法规则的建议参数) + "push": RerankConfig( + mmr_lambda=0.7, + top_n_for_mmr=200, + cooldown_sentence_days=14, + cooldown_author_days=7, + cooldown_template_days=7, + ), + # Widget:工程默认 + "widget": RerankConfig( + mmr_lambda=0.7, + top_n_for_mmr=200, + cooldown_sentence_days=7, + cooldown_author_days=7, + cooldown_template_days=7, + ), +} + + +def get_default_config(scene: Scene) -> RerankConfig: + """ + 获取指定场景的默认参数(返回副本,避免被意外修改)。 + """ + + base = _DEFAULTS[scene] + return RerankConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/rerank_freqcap/rerank.py b/server/app/features/personalized_reco/rerank_freqcap/rerank.py new file mode 100644 index 0000000..fba44a1 --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/rerank.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from typing import Any, Iterable, Optional + +from app.features.personalized_reco.rerank_freqcap.defaults import get_default_config +from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene +from app.features.personalized_reco.rerank_freqcap.utils import as_finite_float, build_tags, clamp, jaccard, normalize_int_id_set + + +def _sort_by_score_desc(cands: list[ScoredCandidate]) -> list[ScoredCandidate]: + return sorted(cands, key=lambda x: as_finite_float(x.final_score, default=float("-inf")), reverse=True) + + +def _dedup_by_seen_ids( + cands: list[ScoredCandidate], + *, + seen_ids: set[int], +) -> tuple[list[ScoredCandidate], int]: + kept: list[ScoredCandidate] = [] + removed = 0 + for c in cands: + if int(c.content_id) in seen_ids: + removed += 1 + continue + kept.append(c) + return kept, removed + + +def _apply_author_template_freqcap( + cands: list[ScoredCandidate], + *, + recent_author_ids: Optional[Iterable[str]], + recent_template_ids: Optional[Iterable[str]], +) -> tuple[list[ScoredCandidate], dict[str, int], list[str]]: + """ + V1 策略: + - 若 recent_*_ids 未提供(None),不执行该维度过滤,但在 meta 记录缺失 + - 若提供,则执行硬过滤 + """ + + filtered_counts: dict[str, int] = {"author": 0, "template": 0} + missing: list[str] = [] + + author_set: set[str] | None + if recent_author_ids is None: + author_set = None + missing.append("author") + else: + author_set = set([a for a in recent_author_ids if a is not None and str(a).strip() != ""]) + + template_set: set[str] | None + if recent_template_ids is None: + template_set = None + missing.append("template") + else: + template_set = set([t for t in recent_template_ids if t is not None and str(t).strip() != ""]) + + out: list[ScoredCandidate] = [] + for c in cands: + if author_set is not None and c.author_id and c.author_id in author_set: + filtered_counts["author"] += 1 + continue + if template_set is not None and c.template_id and c.template_id in template_set: + filtered_counts["template"] += 1 + continue + out.append(c) + + # 只返回真正生效的维度计数(避免 meta 噪音) + effective_counts: dict[str, int] = {} + if author_set is not None: + effective_counts["author"] = int(filtered_counts["author"]) + if template_set is not None: + effective_counts["template"] = int(filtered_counts["template"]) + + missing_sorted = sorted(set(missing)) + return out, effective_counts, missing_sorted + + +def _sim(a: ScoredCandidate, b: ScoredCandidate, *, tags_a: set[str], tags_b: set[str]) -> float: + # 离散特征版(V1 推荐),对齐 plan.md + if int(a.content_id) == int(b.content_id): + return 1.0 + + sim = 0.0 + if a.template_id and b.template_id and a.template_id == b.template_id: + sim += 0.6 + if a.author_id and b.author_id and a.author_id == b.author_id: + sim += 0.3 + + sim += 0.1 * jaccard(tags_a, tags_b) + return clamp(sim, 0.0, 1.0) + + +def _mmr_rerank( + *, + candidates: list[ScoredCandidate], + k: int, + lam: float, +) -> list[ScoredCandidate]: + if k <= 0: + return [] + + if not candidates: + return [] + + lam_f = clamp(as_finite_float(lam, default=0.7), 0.0, 1.0) + + # 预计算 tags,避免重复构造 + tags_map: dict[int, set[str]] = {} + for c in candidates: + tags_map[int(c.content_id)] = build_tags(c) + + remaining = _sort_by_score_desc(list(candidates)) + selected: list[ScoredCandidate] = [] + + # Top1:最高分 + selected.append(remaining.pop(0)) + + while remaining and len(selected) < k: + best_idx = 0 + best_val = float("-inf") + + for idx, c in enumerate(remaining): + rel = as_finite_float(c.final_score, default=float("-inf")) + + tags_c = tags_map.get(int(c.content_id), set()) + max_sim = 0.0 + for s in selected: + tags_s = tags_map.get(int(s.content_id), set()) + max_sim = max(max_sim, _sim(c, s, tags_a=tags_c, tags_b=tags_s)) + + val = lam_f * float(rel) - (1.0 - lam_f) * float(max_sim) + if val > best_val: + best_val = val + best_idx = idx + + selected.append(remaining.pop(best_idx)) + + return selected + + +def rerank_and_freqcap( + *, + scene: Scene, + scored_candidates: list[ScoredCandidate], + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + config: Optional[RerankConfig] = None, + recent_author_ids: Optional[list[str]] = None, + recent_template_ids: Optional[list[str]] = None, +) -> RerankResult: + """ + 主入口:对 scored_candidates 做去重/频控/重排,输出最终可下发序列。 + + V1 约定: + - 冷却窗口“按天”由调用方保证输入集合已经裁剪到窗口内,本模块以“集合代表窗口内历史”为准 + - Feed 默认只做 dedup + MMR;Push/Widget 做 dedup + freqcap + TopK + """ + + cfg = config or get_default_config(scene) + + # seen_ids = already_recommended_ids ∪ touched_or_viewed_ids + seen_ids = normalize_int_id_set(list(already_recommended_ids) + list(touched_or_viewed_ids)) + + # 先按分数降序,保证 Top1 与 TopK 一致 + base_sorted = _sort_by_score_desc(list(scored_candidates)) + + after_dedup, removed_sentence = _dedup_by_seen_ids(base_sorted, seen_ids=seen_ids) + candidate_pool_size_after_dedup = len(after_dedup) + + missing_history_fields: list[str] = [] + freqcap_counts: dict[str, int] = {"sentence": int(removed_sentence)} + + after_freqcap = after_dedup + + # Push/Widget:作者/模板冷却(增强项) + if scene in {"push", "widget"}: + after_freqcap, dim_counts, missing = _apply_author_template_freqcap( + after_freqcap, + recent_author_ids=recent_author_ids, + recent_template_ids=recent_template_ids, + ) + missing_history_fields = missing + freqcap_counts.update(dim_counts) + else: + # Feed:不强制作者/模板冷却(V1 可选,这里默认跳过) + missing_history_fields = [] + + candidate_pool_size_after_freqcap = len(after_freqcap) + + ranked: list[ScoredCandidate] + if scene == "feed": + # MMR 前截断,避免性能问题 + top_n = int(cfg.top_n_for_mmr) if int(cfg.top_n_for_mmr) > 0 else len(after_freqcap) + mmr_pool = after_freqcap[:top_n] + ranked = _mmr_rerank(candidates=mmr_pool, k=int(k), lam=cfg.mmr_lambda) + else: + ranked = after_freqcap[: max(0, int(k))] + + meta = RerankMeta( + candidate_pool_size_after_dedup=int(candidate_pool_size_after_dedup), + candidate_pool_size_after_freqcap=int(candidate_pool_size_after_freqcap), + missing_history_fields=missing_history_fields, + freqcap_filtered_counts=freqcap_counts, + ) + return RerankResult(ranked_items=ranked, meta=meta) + diff --git a/server/app/features/personalized_reco/rerank_freqcap/types.py b/server/app/features/personalized_reco/rerank_freqcap/types.py new file mode 100644 index 0000000..d18d8fb --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/types.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO + +Scene = Literal["feed", "push", "widget"] + + +class ScoredCandidate(BaseModel): + """ + Soft Scoring 后的候选项(本模块消费的最小字段集合)。 + + 说明: + - `content_profile` 用于 Feed 的标签/相似度计算;缺失时需降级为仅使用 author/template 等字段 + """ + + content_id: int + final_score: float + + author_id: Optional[str] = None + template_id: Optional[str] = None + + content_profile: Optional[ContentProfileDTO] = None + + # 允许透传额外字段(例如 text、breakdown 等),便于上层直接下发 + extra: dict[str, Any] = Field(default_factory=dict) + + +class RerankConfig(BaseModel): + """ + 重排/频控配置(可调参)。 + """ + + # Feed:MMR + mmr_lambda: float = 0.7 + top_n_for_mmr: int = 200 + + # Push/Widget:冷却窗口(V1 主要用于配置与可观测;真正按天需要带时间戳的历史) + cooldown_sentence_days: int = 14 + cooldown_author_days: int = 7 + cooldown_template_days: int = 7 + + +class RerankMeta(BaseModel): + candidate_pool_size_after_dedup: int + candidate_pool_size_after_freqcap: int + + # 例如未提供 recent_author_ids/recent_template_ids 时记录 ["author","template"] + missing_history_fields: list[str] = Field(default_factory=list) + + # 可选但建议:按维度统计被过滤数量 + freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict) + + +class RerankResult(BaseModel): + ranked_items: list[ScoredCandidate] = Field(default_factory=list) + meta: RerankMeta + diff --git a/server/app/features/personalized_reco/rerank_freqcap/utils.py b/server/app/features/personalized_reco/rerank_freqcap/utils.py new file mode 100644 index 0000000..1218d4f --- /dev/null +++ b/server/app/features/personalized_reco/rerank_freqcap/utils.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import logging +from typing import Any, Iterable + +logger = logging.getLogger(__name__) + + +def clamp(value: float, min_value: float, max_value: float) -> float: + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def as_finite_float(value: Any, *, default: float) -> float: + try: + f = float(value) + except Exception: + return float(default) + if f != f: + return float(default) + if f == float("inf") or f == float("-inf"): + return float(default) + return f + + +def normalize_int_id_set(values: Iterable[Any]) -> set[int]: + """ + 将历史 ID 列表归一化为 int 集合(支持 str/int 混用)。 + + 说明: + - 无法转换的值会被忽略,并记录 debug 日志(不影响主流程) + """ + + out: set[int] = set() + for v in values: + try: + if isinstance(v, bool): + # 避免 True/False 被当作 1/0 + raise ValueError("bool 不是合法 id") + out.add(int(v)) + except Exception: + logger.debug("历史 id 无法转为 int,已忽略:%r", v) + return out + + +def jaccard(a: set[str], b: set[str]) -> float: + if not a and not b: + return 0.0 + inter = len(a & b) + union = len(a | b) + return float(inter) / float(union) if union > 0 else 0.0 + + +def argmax_key(d: dict[str, Any] | None) -> str | None: + """ + 从 suitability 字典中取最大值 key(V1 用作代表标签)。 + - 空字典/None -> None + - 值非法 -> 按 default=0 处理 + """ + + if not d: + return None + best_k: str | None = None + best_v = float("-inf") + for k, v in d.items(): + fv = as_finite_float(v, default=0.0) + if fv > best_v: + best_v = fv + best_k = k + return best_k + + +def build_tags(candidate: Any) -> set[str]: + """ + 构造离散标签集合(V1 写死): + - stage: + - need: + - context: + + 说明: + - candidate 可能是 ScoredCandidate 或具备 content_profile 的对象 + - 字段缺失时自动降级(只返回可得标签) + """ + + tags: set[str] = set() + + cp = getattr(candidate, "content_profile", None) + if cp is None: + return tags + + stage = getattr(cp, "stage", None) + if stage: + tags.add(f"stage:{stage}") + + need = getattr(cp, "need_suitability", None) + need_k = argmax_key(need) + if need_k: + tags.add(f"need:{need_k}") + + ctx = getattr(cp, "context_suitability", None) + ctx_k = argmax_key(ctx) + if ctx_k: + tags.add(f"context:{ctx_k}") + + return tags + diff --git a/server/app/features/personalized_reco/scoring/__init__.py b/server/app/features/personalized_reco/scoring/__init__.py new file mode 100644 index 0000000..ba5287d --- /dev/null +++ b/server/app/features/personalized_reco/scoring/__init__.py @@ -0,0 +1,22 @@ +""" +个性化推荐|Scoring 子模块(软打分与惩罚项) + +说明: +- 本模块只做软打分与本模块定义的惩罚项(P_uncertainty、Widget 情绪软降权)。 +- Hard Filter / 频控重排 / 新鲜度等由其他模块产出,通过入参注入(缺省按 0)。 +""" + +from .defaults import get_default_config +from .score import score_content +from .types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult + +__all__ = [ + "ExternalTerms", + "Scene", + "ScoreBreakdown", + "ScoreConfig", + "ScoreResult", + "get_default_config", + "score_content", +] + diff --git a/server/app/features/personalized_reco/scoring/defaults.py b/server/app/features/personalized_reco/scoring/defaults.py new file mode 100644 index 0000000..10a67eb --- /dev/null +++ b/server/app/features/personalized_reco/scoring/defaults.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from app.features.personalized_reco.scoring.types import Scene, ScoreConfig + + +_DEFAULTS: dict[Scene, ScoreConfig] = { + # 来源:设计说明文档/個性化推薦算法規則.md(V1 建议权重) + "feed": ScoreConfig( + w_need=0.35, + w_emotion=0.20, + w_stage=0.15, + w_context=0.30, + # Feed 默认不启用不确定性惩罚(可按需开启) + enable_uncertainty_penalty=False, + ), + "push": ScoreConfig( + w_need=0.45, + w_emotion=0.35, + w_stage=0.15, + w_context=0.05, + # Push 默认启用不确定性惩罚 + enable_uncertainty_penalty=True, + ), + "widget": ScoreConfig( + w_need=0.25, + w_emotion=0.25, + w_stage=0.30, + w_context=0.20, + # Widget 默认不启用不确定性惩罚(可按需开启) + enable_uncertainty_penalty=False, + widget_emotion_soft_range=(0.4, 0.8), + widget_emotion_penalty_gamma=0.25, + ), +} + + +def get_default_config(scene: Scene) -> ScoreConfig: + """ + 获取指定场景的默认打分参数(返回副本,避免被意外修改)。 + """ + + base = _DEFAULTS[scene] + return ScoreConfig.model_validate(base.model_dump()) + diff --git a/server/app/features/personalized_reco/scoring/score.py b/server/app/features/personalized_reco/scoring/score.py new file mode 100644 index 0000000..52b510a --- /dev/null +++ b/server/app/features/personalized_reco/scoring/score.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.scoring.defaults import get_default_config +from app.features.personalized_reco.scoring.types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult +from app.features.personalized_reco.scoring.utils import as_finite_float, clamp, pick_one_hot_key +from app.features.user_profile_scoring.types import UserProfileV1_2 + + +def _missing_fields(user_profile: UserProfileV1_2) -> list[str]: + missing: list[str] = [] + if not user_profile.need: + missing.append("need") + if not user_profile.context: + missing.append("context") + if user_profile.emotion_score is None: + missing.append("emotion") + return missing + + +def _score_need(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + key = pick_one_hot_key(user_profile.need) # type: ignore[arg-type] + if key is None: + return 0.5 + raw = content.need_suitability.get(key, 0.5) + return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0) + + +def _score_context(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + key = pick_one_hot_key(user_profile.context) # type: ignore[arg-type] + if key is None: + return 0.5 + raw = content.context_suitability.get(key, 0.5) + return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0) + + +def _score_emotion(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + # V1.2:用户情绪缺失 -> 0.8 + if user_profile.emotion_score is None: + return 0.8 + + # 文案 general(emotion_score=None)-> 0.8 + if content.emotion_score is None: + return 0.8 + + u = clamp(as_finite_float(user_profile.emotion_score, default=0.8), 0.0, 1.0) + c = clamp(as_finite_float(content.emotion_score, default=0.8), 0.0, 1.0) + return clamp(1.0 - abs(u - c), 0.0, 1.0) + + +def _user_stage_key(user_profile: UserProfileV1_2) -> str: + # 约定:UserStageOneHot.unknown 必填;但这里仍做防御 + stage = user_profile.stage + if getattr(stage, "expecting", 0) == 1: + return "expecting" + if getattr(stage, "parenting", 0) == 1: + return "parenting" + if getattr(stage, "unknown", 1) == 1: + return "unknown" + return "unknown" + + +def _score_stage(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + # 对齐算法规则: + # - general=1;命中=1;unknown对非unknown=0.7;其余=0 + if content.stage == "general": + return 1.0 + + u_stage = _user_stage_key(user_profile) + if content.stage == u_stage: + return 1.0 + + if u_stage == "unknown" and content.stage != "unknown": + return 0.7 + + return 0.0 + + +def _score_personal(alpha: float, personalization_power: float, s_need: float, s_context: float) -> float: + power = clamp(as_finite_float(personalization_power, default=0.0), 0.0, 1.0) + a = as_finite_float(alpha, default=0.0) + return float(a) * float(power) * max(float(s_need), float(s_context)) + + +def _penalty_uncertainty(beta: float, user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float: + b = as_finite_float(beta, default=0.0) + power = clamp(as_finite_float(content.personalization_power, default=0.0), 0.0, 1.0) + + # V1 约定:conf_U 缺失时按 1.0(避免过惩罚) + conf_u = clamp(as_finite_float(getattr(user_profile, "profile_confidence", 1.0), default=1.0), 0.0, 1.0) + conf_c = clamp(as_finite_float(getattr(content, "review_confidence", 0.7), default=0.7), 0.0, 1.0) + + return float(b) * (1.0 - float(conf_u)) * (1.0 - float(conf_c)) * float(power) + + +def _widget_emotion_penalty(scene: Scene, content: ContentProfileDTO, config: ScoreConfig) -> float: + if scene != "widget": + return 0.0 + if content.emotion_score is None: + return 0.0 + + lo, hi = config.widget_emotion_soft_range + lo_f = as_finite_float(lo, default=0.4) + hi_f = as_finite_float(hi, default=0.8) + width = hi_f - lo_f + if width <= 0: + return 0.0 + + e = clamp(as_finite_float(content.emotion_score, default=0.6), 0.0, 1.0) + if e < lo_f: + d = lo_f - e + elif e > hi_f: + d = e - hi_f + else: + d = 0.0 + + gamma = as_finite_float(config.widget_emotion_penalty_gamma, default=0.25) + raw = float(gamma) * float(d) / float(width) + return clamp(raw, 0.0, float(gamma)) + + +def score_content( + *, + scene: Scene, + user_profile: UserProfileV1_2, + content_profile: ContentProfileDTO, + config: Optional[ScoreConfig] = None, + pass_filters: bool = True, + external_terms: Optional[ExternalTerms] = None, + now: Optional[datetime] = None, # 预留:V1 不使用 +) -> ScoreResult: + """ + 主入口:对单条内容 Cᵢ 进行软打分,返回 final_score 与 breakdown。 + + 说明(V1): + - `pass_filters` 来自 Hard Filter(本模块不做硬过滤) + - `external_terms` 可注入 S_fresh / P_fatigue / P_repeat / P_risk(缺省按 0) + - `now` 预留给未来的 freshness/时间衰减(V1 不实现) + """ + + cfg = config or get_default_config(scene) + ext = external_terms or ExternalTerms() + + missing = _missing_fields(user_profile) + + s_need = _score_need(user_profile, content_profile) + s_context = _score_context(user_profile, content_profile) + s_emotion = _score_emotion(user_profile, content_profile) + s_stage = _score_stage(user_profile, content_profile) + + w_need = as_finite_float(cfg.w_need, default=0.0) + w_emotion = as_finite_float(cfg.w_emotion, default=0.0) + w_stage = as_finite_float(cfg.w_stage, default=0.0) + w_context = as_finite_float(cfg.w_context, default=0.0) + + s_core = float(w_need) * s_need + float(w_emotion) * s_emotion + float(w_stage) * s_stage + float(w_context) * s_context + + s_personal = _score_personal(cfg.alpha, content_profile.personalization_power, s_need, s_context) + + p_uncertainty = 0.0 + if cfg.enable_uncertainty_penalty: + p_uncertainty = _penalty_uncertainty(cfg.beta, user_profile, content_profile) + + p_widget = _widget_emotion_penalty(scene, content_profile, cfg) + + s_fresh = as_finite_float(ext.S_fresh, default=0.0) + p_fatigue = as_finite_float(ext.P_fatigue, default=0.0) + p_repeat = as_finite_float(ext.P_repeat, default=0.0) + p_risk_external = as_finite_float(ext.P_risk, default=0.0) + + # Widget 软降权并入 P_risk(但在 breakdown 中单独暴露,便于打点) + p_risk = float(p_risk_external) + float(p_widget) + + raw_final = s_core + s_personal + float(s_fresh) - float(p_fatigue) - float(p_repeat) - float(p_risk) - float(p_uncertainty) + final_score = float(raw_final) if pass_filters else 0.0 + + breakdown = ScoreBreakdown( + scene=scene, + **{ + "pass": bool(pass_filters), + }, + missing_fields=missing, + S_need=float(s_need), + S_context=float(s_context), + S_stage=float(s_stage), + S_emotion=float(s_emotion), + S_core=float(s_core), + S_personal=float(s_personal), + S_fresh=float(s_fresh), + P_fatigue=float(p_fatigue), + P_repeat=float(p_repeat), + P_risk=float(p_risk), + P_uncertainty=float(p_uncertainty), + P_widget_emotion_out_of_range=float(p_widget), + ) + + return ScoreResult(final_score=float(final_score), breakdown=breakdown) + diff --git a/server/app/features/personalized_reco/scoring/types.py b/server/app/features/personalized_reco/scoring/types.py new file mode 100644 index 0000000..d829fd3 --- /dev/null +++ b/server/app/features/personalized_reco/scoring/types.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +Scene = Literal["feed", "push", "widget"] + + +class ScoreConfig(BaseModel): + """ + 打分配置(可调参)。 + + 说明: + - 默认值由 `defaults.get_default_config(scene)` 提供 + - 本模块不负责回退梯度(fallback_level)策略;仅做防御式 clamp + """ + + w_need: float + w_emotion: float + w_stage: float + w_context: float + + alpha: float = 0.15 + beta: float = 0.30 + + enable_uncertainty_penalty: bool = False + + # Widget 情绪软区间与软降权强度 + widget_emotion_soft_range: tuple[float, float] = (0.4, 0.8) + widget_emotion_penalty_gamma: float = 0.25 + + +class ExternalTerms(BaseModel): + """ + 外部注入项(V1 可选)。 + + 说明: + - 由 `rerank-freqcap` 或 `reco-engine` 产出 + - 本模块缺省按 0,保证可排序与输出结构稳定 + """ + + S_fresh: float = 0.0 + P_fatigue: float = 0.0 + P_repeat: float = 0.0 + P_risk: float = 0.0 + + +class ScoreBreakdown(BaseModel): + """ + 可观测分解项(用于调参与回归测试)。 + """ + + scene: Scene + passed: bool = Field(alias="pass") + + missing_fields: list[str] = Field(default_factory=list) + + S_need: float + S_context: float + S_stage: float + S_emotion: float + + S_core: float + S_personal: float + S_fresh: float + + P_fatigue: float + P_repeat: float + P_risk: float + P_uncertainty: float + + # Widget 专用:区间外软降权(建议保留,便于打点) + P_widget_emotion_out_of_range: float = 0.0 + + model_config = { + "populate_by_name": True, + } + + +class ScoreResult(BaseModel): + final_score: float + breakdown: ScoreBreakdown + diff --git a/server/app/features/personalized_reco/scoring/utils.py b/server/app/features/personalized_reco/scoring/utils.py new file mode 100644 index 0000000..18f6735 --- /dev/null +++ b/server/app/features/personalized_reco/scoring/utils.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def clamp(value: float, min_value: float, max_value: float) -> float: + """ + 将值裁剪到区间内,并对 NaN 做兜底。 + """ + + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def as_finite_float(value: Any, *, default: float) -> float: + """ + 将任意值尽量转为有限 float;失败则返回 default。 + """ + + try: + f = float(value) + except Exception: + return float(default) + + # NaN / inf 都视为不可用 + if f != f: + return float(default) + if f == float("inf") or f == float("-inf"): + return float(default) + return f + + +def pick_one_hot_key(one_hot: dict[str, Any] | None) -> str | None: + """ + 从稀疏 one-hot({key: 1})中取唯一 key。 + + 约定: + - None / {} → 缺失,返回 None + - 单 key → 返回该 key + - 多 key → 取“字典序最小”的 key,并记录 debug 日志(避免静默歧义) + """ + + if not one_hot: + return None + + keys = [k for k, v in one_hot.items() if v == 1 or v is True] + if not keys: + return None + if len(keys) == 1: + return keys[0] + + chosen = sorted(keys)[0] + logger.debug("one-hot 出现多个 key=1,已按字典序选择:chosen=%s keys=%s", chosen, keys) + return chosen + diff --git a/server/app/features/user_profile_scoring/__init__.py b/server/app/features/user_profile_scoring/__init__.py new file mode 100644 index 0000000..b8e6548 --- /dev/null +++ b/server/app/features/user_profile_scoring/__init__.py @@ -0,0 +1,9 @@ +""" +User Profile Scoring(用户画像打分)V1.2 + +说明: +- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现 +- 规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)与 + `设计说明文档/客戶端問卷打分規則.md`(V1.2)为准 +""" + diff --git a/server/app/features/user_profile_scoring/scoring.py b/server/app/features/user_profile_scoring/scoring.py new file mode 100644 index 0000000..b099ad2 --- /dev/null +++ b/server/app/features/user_profile_scoring/scoring.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from app.features.user_profile_scoring.types import ( + HardRules, + ProfileAnswered, + QuestionnaireAnswersV1_2, + UserProfileV1_2_Extended, + UserStageOneHot, +) + + +def _clamp(value: float, min_value: float, max_value: float) -> float: + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def normalize_answers(raw: QuestionnaireAnswersV1_2) -> QuestionnaireAnswersV1_2: + """ + 归一化答案: + - Pydantic 已对枚举做了校验;此处仅统一 None/缺失的语义为“跳过” + """ + + # 直接返回一份拷贝,保持纯函数语义 + return QuestionnaireAnswersV1_2.model_validate(raw.model_dump()) + + +def compute_profile_answered(answers: QuestionnaireAnswersV1_2) -> ProfileAnswered: + return ProfileAnswered( + stage=answers.mom_stage is not None, + emotion=answers.emotion is not None, + context=answers.context is not None, + need=answers.need is not None, + ) + + +def compute_time_confidence(generated_at: datetime, now: datetime) -> float: + """ + 时间衰减置信度(conf_time) + - 0–7 天:1.0 + - 7–30 天:线性衰减到 0.7(含第 30 天) + - 30 天以上:0.5 + """ + + delta = (now - generated_at).total_seconds() + if delta <= 0: + return 1.0 + + days = delta / (24 * 60 * 60) + if days <= 7: + return 1.0 + if days <= 30: + t = (days - 7) / (30 - 7) # 0..1 + return 1.0 - 0.3 * t + return 0.5 + + +def compute_profile_confidence(conf_time: float, answered: ProfileAnswered) -> float: + """ + V1.2:profile_confidence(conf_U) + conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0) + """ + + answered_count = sum( + [ + 1 if answered.stage else 0, + 1 if answered.emotion else 0, + 1 if answered.context else 0, + 1 if answered.need else 0, + ] + ) + completion = answered_count / 4 + completion_factor = 0.5 + 0.5 * completion + return _clamp(float(conf_time) * float(completion_factor), 0.2, 1.0) + + +def _build_stage_one_hot(mom_stage: Optional[str]) -> UserStageOneHot: + # V1.2:mom_stage 跳过按安全策略输出 unknown=1 + if mom_stage is None: + return UserStageOneHot(unknown=1) + + return UserStageOneHot( + expecting=1 if mom_stage == "expecting" else 0, + parenting=1 if mom_stage == "parenting" else 0, + unknown=1 if mom_stage == "unknown" else 0, + ) + + +def _map_emotion_score(emotion: Optional[str]) -> Optional[float]: + if emotion is None: + return None + mapping = { + "low": 0.0, + "overwhelmed": 0.2, + "tired": 0.4, + "neutral": 0.6, + "calm": 0.8, + "joyful": 1.0, + } + return mapping.get(emotion) + + +def _build_sparse_one_hot(value: Optional[str]) -> dict[str, int]: + if value is None: + return {} + return {value: 1} + + +@dataclass(frozen=True) +class _RuleOutput: + rule_hits: list[str] + hard_rules: HardRules + + +def _compute_rule_output(stage: UserStageOneHot, emotion_score: Optional[float]) -> _RuleOutput: + rule_hits: list[str] = [] + forbidden_risk_flags: list[str] = [] + + stage_unknown = stage.unknown == 1 + stage_parenting = stage.parenting == 1 + + if stage_unknown: + rule_hits.append("unsafe_for_stage_unknown") + forbidden_risk_flags.append("unsafe_for_stage_unknown") + + if stage_parenting: + rule_hits.append("unsafe_for_stage_parenting") + forbidden_risk_flags.append("unsafe_for_stage_parenting") + + if emotion_score is not None and emotion_score <= 0.2: + rule_hits.append("unsafe_for_emotion_low") + forbidden_risk_flags.append("unsafe_for_emotion_low") + + forbidden_content_predicates = [] + if stage_unknown: + forbidden_content_predicates.append( + { + "id": "unknown_block_parenting_pressure_personalized", + "when_user": {"stage_unknown": True}, + "forbid_content": {"need": "parenting_pressure", "personalization_power": 1}, + } + ) + + return _RuleOutput( + rule_hits=rule_hits, + hard_rules=HardRules( + forbidden_risk_flags=forbidden_risk_flags, + forbidden_content_predicates=forbidden_content_predicates, + ), + ) + + +def build_user_profile_from_questionnaire( + raw_answers: QuestionnaireAnswersV1_2, + *, + generated_at: Optional[datetime] = None, + now: Optional[datetime] = None, +) -> UserProfileV1_2_Extended: + """ + 主入口:问卷答案(可跳过)→ 用户画像(V1.2)+ 硬规则输出 + """ + + answers = normalize_answers(raw_answers) + answered = compute_profile_answered(answers) + + now_dt = now or datetime.now(tz=timezone.utc) + gen_dt = generated_at or now_dt + + conf_time = compute_time_confidence(gen_dt, now_dt) + conf_u = compute_profile_confidence(conf_time, answered) + + stage = _build_stage_one_hot(answers.mom_stage) + emotion_score = _map_emotion_score(answers.emotion) + context = _build_sparse_one_hot(answers.context) + need = _build_sparse_one_hot(answers.need) + + rule_out = _compute_rule_output(stage, emotion_score) + + return UserProfileV1_2_Extended( + profile_generated_at=gen_dt, + profile_confidence=conf_u, + profile_answered=answered, + stage=stage, + emotion_score=emotion_score, + context=context, # type: ignore[arg-type] + need=need, # type: ignore[arg-type] + rule_hits=rule_out.rule_hits, + hard_rules=rule_out.hard_rules, + ) + diff --git a/server/app/features/user_profile_scoring/types.py b/server/app/features/user_profile_scoring/types.py new file mode 100644 index 0000000..b4a55a2 --- /dev/null +++ b/server/app/features/user_profile_scoring/types.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +MomStageAnswer = Literal["expecting", "parenting", "unknown"] +EmotionAnswer = Literal["low", "overwhelmed", "tired", "neutral", "calm", "joyful"] +ContextAnswer = Literal["family", "work", "relationship", "friends", "health"] +NeedAnswer = Literal[ + "emotional_support", + "parenting_pressure", + "self_worth", + "anxiety_relief", + "rest_balance", +] + + +class QuestionnaireAnswersV1_2(BaseModel): + """ + V1.2:每题可跳过 + + 说明: + - `None` 表示题目被跳过/无值(与客户端的 `null` 对齐) + - 字段缺失(未传)也视为跳过 + """ + + mom_stage: Optional[MomStageAnswer] = None + emotion: Optional[EmotionAnswer] = None + context: Optional[ContextAnswer] = None + need: Optional[NeedAnswer] = None + + +class ProfileAnswered(BaseModel): + stage: bool + emotion: bool + context: bool + need: bool + + +class UserStageOneHot(BaseModel): + expecting: Optional[Literal[0, 1]] = None + parenting: Optional[Literal[0, 1]] = None + unknown: Literal[0, 1] + + +class ForbiddenContentPredicate(BaseModel): + """ + 用于表达“需要同时看用户与内容字段才能执行”的规则(跨维度规则)。 + """ + + id: str + when_user: dict[str, Any] = Field(default_factory=dict) + forbid_content: dict[str, Any] = Field(default_factory=dict) + + +class HardRules(BaseModel): + forbidden_risk_flags: list[str] = Field(default_factory=list) + forbidden_content_predicates: list[ForbiddenContentPredicate] = Field(default_factory=list) + + +class UserProfileV1_2(BaseModel): + profile_version: Literal["v1.2"] = "v1.2" + profile_source: Literal["questionnaire"] = "questionnaire" + profile_generated_at: datetime + profile_confidence: float + profile_answered: ProfileAnswered + stage: UserStageOneHot + emotion_score: Optional[float] = None + context: dict[str, Literal[1]] = Field(default_factory=dict) + need: dict[str, Literal[1]] = Field(default_factory=dict) + + +class UserProfileV1_2_Extended(UserProfileV1_2): + rule_hits: list[str] = Field(default_factory=list) + hard_rules: HardRules = Field(default_factory=HardRules) + + +class BuildUserProfileRequest(BaseModel): + """ + API 请求体:问卷答案 + 可选时间注入(便于回归测试/服务端批处理) + """ + + answers: QuestionnaireAnswersV1_2 = Field(default_factory=QuestionnaireAnswersV1_2) + generated_at: Optional[datetime] = None + now: Optional[datetime] = None + diff --git a/server/app/main.py b/server/app/main.py index f4bfaa2..f611d48 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,6 +1,8 @@ from fastapi import FastAPI from app.core.config import get_settings +from app.api.v1.reco import router as reco_router +from app.api.v1.user_profile_scoring import router as user_profile_router def create_app() -> FastAPI: @@ -14,6 +16,10 @@ def create_app() -> FastAPI: app = FastAPI(title=settings.app_name) + # 业务路由 + app.include_router(user_profile_router) + app.include_router(reco_router) + @app.get("/healthz") async def healthz() -> dict: return {"status": "ok", "env": settings.app_env} diff --git a/server/app/tasks/reco.py b/server/app/tasks/reco.py new file mode 100644 index 0000000..0e34fc2 --- /dev/null +++ b/server/app/tasks/reco.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any, Optional + +from celery import shared_task + +from app.db.session import AsyncSessionLocal +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository +from app.features.personalized_reco.content_repository.types import normalize_locale +from app.features.personalized_reco.reco_engine import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult, Scene +from app.features.user_profile_scoring.types import UserProfileV1_2 + + +def _ensure_now(now: Optional[datetime]) -> datetime: + if now is None: + return datetime.now(timezone.utc) + if now.tzinfo is None: + return now.replace(tzinfo=timezone.utc) + return now + + +def _ensure_locale(locale: Optional[str]) -> str: + raw = (locale or "").strip() or "en" + # 严格校验只支持 en/tc(允许 en-US 等在 normalize_locale 内归一化) + return str(normalize_locale(raw)) + + +async def _run_reco_async( + *, + scene: Scene, + user_profile: UserProfileV1_2, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: datetime, + locale: str, +) -> RecoEngineResult: + async with AsyncSessionLocal() as session: + repo = SqlAlchemyContentRepository(session) + return await recommend( + repo=repo, + scene=scene, + user_profile=user_profile, + already_recommended_ids=list(already_recommended_ids or []), + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=int(k), + now=now, + locale=locale, + constraints=RecoConstraints(), + ) + + +def _run_reco_sync( + *, + scene: Scene, + user_profile: UserProfileV1_2, + already_recommended_ids: list[Any], + touched_or_viewed_ids: list[Any], + k: int, + now: Optional[datetime], + locale: Optional[str], +) -> dict[str, Any]: + effective_now = _ensure_now(now) + effective_locale = _ensure_locale(locale) + result = asyncio.run( + _run_reco_async( + scene=scene, + user_profile=user_profile, + already_recommended_ids=already_recommended_ids, + touched_or_viewed_ids=touched_or_viewed_ids, + k=int(k), + now=effective_now, + locale=effective_locale, + ) + ) + # 默认不存结果,但返回值可用于开发调试(worker 通常 ignore_result) + return result.model_dump() + + +@shared_task(name="tasks.reco.generate") +def generate( + *, + scene: Scene, + user_profile: dict[str, Any], + already_recommended_ids: Optional[list[Any]] = None, + touched_or_viewed_ids: Optional[list[Any]] = None, + k: Optional[int] = None, + now: Optional[str] = None, + locale: Optional[str] = None, +) -> dict[str, Any]: + """ + 推荐生成任务(通用入口)。 + + 说明: + - 入参尽量保持小(避免 Redis 队列膨胀) + - 默认 worker 配置为 ignore_result,但这里仍返回结构,便于本地调试 + """ + + # 解析 user_profile(严格按 V1.2) + u = UserProfileV1_2.model_validate(user_profile or {}) + + # k 默认按场景(与 API 一致) + if k is None: + k_i = 30 if scene == "feed" else 1 + else: + k_i = int(k) + + # now 支持 ISO 字符串 + dt: Optional[datetime] + if not now: + dt = None + else: + raw = str(now).strip() + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(raw) + except Exception: + dt = None + + return _run_reco_sync( + scene=scene, + user_profile=u, + already_recommended_ids=list(already_recommended_ids or []), + touched_or_viewed_ids=list(touched_or_viewed_ids or []), + k=k_i, + now=dt, + locale=locale, + ) + + +def _deliver_push_placeholder(payload: dict[str, Any]) -> None: + """ + Push 下游写入占位函数(V1 不接真实推送系统)。 + """ + + _ = payload + return None + + +@shared_task(name="tasks.reco.push_once") +def push_once( + *, + user_profile: dict[str, Any], + already_recommended_ids: Optional[list[Any]] = None, + touched_or_viewed_ids: Optional[list[Any]] = None, + now: Optional[str] = None, + locale: Optional[str] = None, +) -> dict[str, Any]: + """ + 单次 Push 生成(占位任务)。 + """ + + payload = generate( + scene="push", + user_profile=user_profile, + already_recommended_ids=already_recommended_ids, + touched_or_viewed_ids=touched_or_viewed_ids, + k=1, + now=now, + locale=locale, + ) + _deliver_push_placeholder(payload) + return payload + diff --git a/server/requirements.txt b/server/requirements.txt index 5d1da86..9ce85d6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -4,6 +4,8 @@ uvicorn[standard]>=0.27 # 数据库(SQLAlchemy 2.x 异步 + MySQL) SQLAlchemy>=2.0 aiomysql>=0.2 +greenlet>=3.0 +aiosqlite>=0.20 # 配置 pydantic>=2.6 @@ -18,3 +20,5 @@ redis>=5.0 # 测试 pytest>=8.0 +pytest-asyncio>=0.23 +httpx>=0.27 diff --git a/server/run.sh b/server/run.sh new file mode 100755 index 0000000..86905fd --- /dev/null +++ b/server/run.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 一键启动 FastAPI 后端: +# - 自动创建/复用虚拟环境(.venv) +# - 自动安装 requirements.txt 依赖 +# - 自动启动 uvicorn(默认开启 --reload) +# +# 用法示例: +# ./run.sh # 默认 host=0.0.0.0 port=8000 env=dev reload=on +# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source) +# ./run.sh --port 9000 # 改端口 +# ./run.sh --no-reload # 关闭热更新 +# ./run.sh --install-only # 只安装依赖,不启动 + +usage() { + cat <<'EOF' +用法: + ./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only] + +参数: + --env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。 + --host uvicorn host(默认 0.0.0.0) + --port uvicorn port(默认 8000) + --no-reload 关闭 uvicorn --reload + --skip-install 跳过依赖安装(默认会安装/更新 requirements.txt) + --install-only 只安装依赖,不启动服务 + -h, --help 显示帮助 + +说明: + - 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。 + - 启动后访问: + /healthz 健康检查 + /docs OpenAPI 文档 +EOF +} + +# 始终从脚本所在目录运行(避免在别处执行导致路径错) +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +ENV_NAME="dev" +HOST="0.0.0.0" +PORT="8000" +RELOAD="1" +SKIP_INSTALL="0" +INSTALL_ONLY="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env) + ENV_NAME="${2:-}" + shift 2 + ;; + --host) + HOST="${2:-}" + shift 2 + ;; + --port) + PORT="${2:-}" + shift 2 + ;; + --no-reload) + RELOAD="0" + shift 1 + ;; + --skip-install) + SKIP_INSTALL="1" + shift 1 + ;; + --install-only) + INSTALL_ONLY="1" + shift 1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + echo "" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$ENV_NAME" != "dev" && "$ENV_NAME" != "prod" ]]; then + echo "--env 仅支持 dev 或 prod,当前:$ENV_NAME" >&2 + exit 2 +fi + +ENV_FILE=".env.${ENV_NAME}" +if [[ -f "$ENV_FILE" ]]; then + # 让 source 进来的变量自动 export(供 pydantic-settings/应用读取) + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +# 选择 python 命令(优先 python3) +PY_BIN="" +if command -v python3 >/dev/null 2>&1; then + PY_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PY_BIN="python" +else + echo "未找到 python/python3,请先安装 Python 3.11+。" >&2 + exit 1 +fi + +VENV_DIR=".venv" +if [[ ! -d "$VENV_DIR" ]]; then + echo "创建虚拟环境:$VENV_DIR" + "$PY_BIN" -m venv "$VENV_DIR" +fi + +# 激活虚拟环境 +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +if [[ "$SKIP_INSTALL" == "0" ]]; then + if [[ -f "requirements.txt" ]]; then + echo "升级 pip 并安装依赖(requirements.txt)" + python -m pip install -U pip + python -m pip install -r requirements.txt + else + echo "未找到 requirements.txt,跳过依赖安装。" >&2 + fi +fi + +if [[ "$INSTALL_ONLY" == "1" ]]; then + echo "依赖安装完成(install-only),退出。" + exit 0 +fi + +UVICORN_ARGS=(app.main:app --host "$HOST" --port "$PORT") +if [[ "$RELOAD" == "1" ]]; then + UVICORN_ARGS+=(--reload) +fi + +echo "启动服务:uvicorn ${UVICORN_ARGS[*]}" +exec uvicorn "${UVICORN_ARGS[@]}" + diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..58d4b62 --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import AsyncIterator, Callable +from urllib.parse import urlparse + +import pytest +import pytest_asyncio +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +# 确保在任何 pytest rootdir 下都能 `import app.*` +SERVER_DIR = Path(__file__).resolve().parents[1] # .../server +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + + +def _read_env_kv(env_path: Path) -> dict[str, str]: + """ + 读取 .env 文件中的 KEY=VALUE(最小实现,避免引入额外依赖)。 + """ + + data: dict[str, str] = {} + if not env_path.exists(): + return data + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k: + data[k] = v + return data + + +def _get_database_url() -> str: + """ + 获取测试用数据库连接串。 + + 约定(与 alembic/env.py 保持一致): + - 优先读取环境变量 `DATABASE_URL` + - 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod` + """ + + env_url = (os.getenv("DATABASE_URL") or "").strip() + if env_url: + return env_url + + server_dir = Path(__file__).resolve().parents[1] # .../server + app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev" + env_file = server_dir / (".env.prod" if app_env == "prod" else ".env.dev") + kv = _read_env_kv(env_file) + url = (kv.get("DATABASE_URL") or "").strip() + if url: + return url + + raise RuntimeError( + "缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。" + ) + + +def _assert_safe_mysql_test_db(url: str) -> None: + """ + 为了避免对开发库造成破坏性影响,集成测试只允许连接到“测试库”。 + + 规则(V1): + - 必须是 mysql+aiomysql://... + - 为避免误连生产库:不允许数据库名为 'mindfulness'(prod 默认库名) + - 建议使用独立测试库(例如 mindfulness_dev_test) + """ + + if not url.startswith("mysql+"): + raise RuntimeError(f"当前仅允许 MySQL 集成测试(mysql+aiomysql)。实际:{url!r}") + + parsed = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1)) + db_name = (parsed.path or "").lstrip("/") + if db_name.lower() == "mindfulness": + raise RuntimeError( + "为避免误连生产库,集成测试不允许连接到数据库 'mindfulness'。" + "请改用 dev 测试库(例如 mindfulness_dev_test 或 mindfulness_dev)。" + ) + + +def _run_alembic_upgrade_head() -> None: + """ + 使用 Alembic 将测试库升级到最新 schema。 + + 说明: + - 依赖 env.py 内部读取 DATABASE_URL + - 仅在 session 级别执行一次,避免每个测试都跑迁移 + """ + + server_dir = Path(__file__).resolve().parents[1] # .../server + alembic_ini = server_dir / "alembic.ini" + cfg = Config(str(alembic_ini)) + # 确保脚本路径正确(alembic.ini 里一般已配置,这里兜底) + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + +def _assert_schema_exists(url: str) -> None: + """ + 非破坏性检查:要求目标库已经存在所需表。 + + 说明: + - 默认不在测试中运行 Alembic(避免任何 schema 变更) + - 若要自动迁移,请设置环境变量 ALLOW_SCHEMA_MIGRATION=1 + """ + + allow_migration = (os.getenv("ALLOW_SCHEMA_MIGRATION") or "").strip() == "1" + if allow_migration: + _run_alembic_upgrade_head() + return + + # 使用 PyMySQL 做同步检查,避免依赖 MySQLdb(不要求系统安装 mysqlclient) + sync_url = url.replace("mysql+aiomysql://", "mysql+pymysql://", 1) + engine = sa.create_engine(sync_url, future=True) + try: + insp = sa.inspect(engine) + tables = set(insp.get_table_names()) + required = {"contents", "content_profiles", "content_risk_flags"} + missing = sorted(required - tables) + if missing: + raise RuntimeError( + "集成测试检测到 schema 不完整(缺少表:" + + ", ".join(missing) + + ")。为避免破坏性操作,测试不会自动迁移。" + "请先手动在该库执行 `alembic upgrade head`,或设置 ALLOW_SCHEMA_MIGRATION=1 允许测试自动迁移。" + ) + finally: + engine.dispose() + + +@pytest.fixture(scope="session", autouse=True) +def _migrate_db_once() -> None: + """ + Session 级 schema 检查(仅在配置了数据库连接时启用)。 + + 说明: + - 纯函数单元测试不需要 MySQL;若未配置 DATABASE_URL,则跳过检查 + - 集成测试(依赖 db_session/async_engine)仍会在获取 DATABASE_URL 时失败,从而提示用户配置 + """ + + try: + url = _get_database_url() + except RuntimeError: + return + + _assert_safe_mysql_test_db(url) + _assert_schema_exists(url) + + +@pytest_asyncio.fixture +async def async_engine() -> AsyncIterator[AsyncEngine]: + url = _get_database_url() + _assert_safe_mysql_test_db(url) + engine = create_async_engine(url, pool_pre_ping=True) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(async_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + """ + 提供一个干净的 AsyncSession。 + + 清理策略:每个测试都在事务中执行,并在结束时回滚(不做 DELETE/TRUNCATE)。 + + 说明: + - 用例里严禁调用 session.commit(),只允许 flush() + - 这样不会对测试库产生持久化写入,更不会影响开发库 + """ + + SessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=async_engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + + async with SessionLocal() as session: + trans = await session.begin() + try: + yield session + finally: + await trans.rollback() + + +@pytest.fixture +def query_counter(async_engine: AsyncEngine) -> Callable[[], int]: + """ + 返回一个函数:调用可获得当前累计查询次数。 + """ + + count = {"n": 0} + + def before_cursor_execute(*args, **kwargs): # type: ignore[no-untyped-def] + count["n"] += 1 + + event.listen(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + def get_count() -> int: + return int(count["n"]) + + def fin() -> None: + event.remove(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + # 用 yield 确保测试后移除监听,避免重复绑定导致统计偏大 + try: + yield get_count # type: ignore[misc] + finally: + fin() + diff --git a/server/tests/test_content_repository.py b/server/tests/test_content_repository.py new file mode 100644 index 0000000..fbcd2cb --- /dev/null +++ b/server/tests/test_content_repository.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag +from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository + + +def _ctx_json() -> dict: + return {"family": 0.5, "work": 0.5, "relationship": 0.5, "friends": 0.5, "health": 0.5} + + +def _need_json() -> dict: + return { + "emotional_support": 0.5, + "parenting_pressure": 0.5, + "self_worth": 0.5, + "anxiety_relief": 0.5, + "rest_balance": 0.5, + } + + +@pytest.mark.asyncio +async def test_fetch_contents_by_ids_locale_no_fallback(db_session, query_counter): + # content 1: 只有英文 + c1 = Content(text_en="hello", text_tc=None, author_id="a1", template_id="t1") + db_session.add(c1) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c1.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=10, + review_confidence=None, + is_safe_pool=False, + ) + ) + db_session.add(ContentRiskFlag(content_id=c1.content_id, flag="block_stage_unknown")) + + # content 2: 只有繁中 + c2 = Content(text_en=None, text_tc="繁體中文", author_id="a2", template_id="t2") + db_session.add(c2) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c2.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=0, + review_confidence=0.9, + is_safe_pool=True, + ) + ) + db_session.add(ContentRiskFlag(content_id=c2.content_id, flag="block_health_sensitive")) + + await db_session.flush() + + repo = SqlAlchemyContentRepository(db_session) + start = query_counter() + + # en:只能拿到有 text_en 的内容(不允许回退到 text_tc) + en_items = await repo.fetch_contents_by_ids(content_ids=[c2.content_id, c1.content_id], locale="en") + assert [x.content_id for x in en_items] == [c1.content_id] + assert en_items[0].text == "hello" + assert en_items[0].personalization_power == 1.0 + assert en_items[0].review_confidence == 0.7 # NULL -> 0.7 + assert "unsafe_for_stage_unknown" in en_items[0].risk_flags + assert "block_stage_unknown" not in en_items[0].risk_flags + + # tc:只能拿到有 text_tc 的内容(不允许回退到 text_en) + tc_items = await repo.fetch_contents_by_ids(content_ids=[c1.content_id, c2.content_id], locale="tc") + assert [x.content_id for x in tc_items] == [c2.content_id] + assert tc_items[0].text == "繁體中文" + assert tc_items[0].review_confidence == 0.9 + assert "block_health_medical" in tc_items[0].risk_flags + assert "block_health_sensitive" not in tc_items[0].risk_flags + + # 两次调用各自 2 次查询(主体+画像一次,flags 一次),总计应为常数级 + end = query_counter() + assert (end - start) <= 4 + + +@pytest.mark.asyncio +async def test_fetch_candidates_fallback_and_locale_filter(db_session, query_counter): + # 构造 4 条英文内容:power 0/5/10,安全池标记不同 + contents = [] + for i, (power, safe) in enumerate([(0, True), (5, False), (10, False), (0, False)], start=1): + c = Content(text_en=f"en_{i}", text_tc=None, author_id=f"a{i}", template_id=f"t{i}") + db_session.add(c) + await db_session.flush() + db_session.add( + ContentProfile( + content_id=c.content_id, + stage="general", + emotion_score=None, + context_suitability_json=_ctx_json(), + need_suitability_json=_need_json(), + personalization_power=power, + review_confidence=None, + is_safe_pool=safe, + # 让测试数据在候选排序中排到最前,避免依赖“库为空” + updated_at=datetime(2099, 1, 1, 0, 0, 0), + ) + ) + contents.append(c) + await db_session.flush() + inserted_ids = {int(c.content_id) for c in contents} + + repo = SqlAlchemyContentRepository(db_session) + + class _MinimalUser: + # need/context/emotion_score 全缺失 -> effective_fallback 至少 L1 + need = {} + context = {} + emotion_score = None + stage = {"unknown": 1} + + start = query_counter() + + # 入参 L0,但因为缺失字段,effective_fallback=1 -> power<=5(排除 power=10) + items_l0 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=0, + limit=3, + locale="en-US", + ) + powers = [x.personalization_power for x in items_l0] + assert 1.0 not in powers + assert all(x.text.startswith("en_") for x in items_l0) + + # L2:强制 power=0 且 stage=general(这里都 general),只剩 power=0 的两条 + items_l2 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=2, + limit=2, + locale="en", + ) + assert all(x.personalization_power == 0.0 for x in items_l2) + assert all(x.text.startswith("en_") for x in items_l2) + + # L3:只安全池(is_safe_pool=true)且 power=0 + items_l3 = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=3, + limit=1, + locale="en", + ) + assert len(items_l3) == 1 + assert items_l3[0].text.startswith("en_") + assert items_l3[0].personalization_power == 0.0 + + # locale 过滤:tc 请求下这些内容都没有 text_tc -> 返回空 + items_tc = await repo.fetch_candidates( + scene="feed", + user_profile=_MinimalUser(), + fallback_level=0, + limit=10, + locale="tc", + ) + # 不要求库为空:只断言“不会把本次插入的 en-only 测试数据返回出来” + assert not any(x.content_id in inserted_ids for x in items_tc) + + end = query_counter() + # 期望为常数级(每次 fetch_candidates:1 次取 ids + 2 次补全),这里 4 次调用 -> <= 12 + assert (end - start) <= 12 + diff --git a/server/tests/test_integration_api_worker.py b/server/tests/test_integration_api_worker.py new file mode 100644 index 0000000..82644f3 --- /dev/null +++ b/server/tests/test_integration_api_worker.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import importlib +from datetime import datetime, timezone +from typing import Any + +import pytest +from fastapi.testclient import TestClient + + +def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None: + # 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis) + monkeypatch.setenv( + "DATABASE_URL", + "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4", + ) + monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0") + monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0") + + +def _user_profile_dict() -> dict[str, Any]: + return { + "profile_version": "v1.2", + "profile_source": "questionnaire", + "profile_generated_at": "2026-02-02T12:00:00Z", + "profile_confidence": 1.0, + "profile_answered": {"stage": True, "emotion": False, "context": False, "need": False}, + "stage": {"unknown": 1}, + "emotion_score": None, + "context": {}, + "need": {}, + } + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + _set_min_env(monkeypatch) + + # 清理 settings cache,避免被其他测试污染 + from app.core import config as config_mod + + config_mod.get_settings.cache_clear() + + # 重新加载 main,确保使用最新 env + import app.main as main_mod + + importlib.reload(main_mod) + + app = main_mod.create_app() + + # override repo(避免依赖真实 DB) + from app.api.v1 import reco as reco_mod + from app.features.personalized_reco.content_repository.types import ContentProfileDTO + + class _FakeRepo: + async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def] + # 返回一条可下发内容 + return [ + ContentProfileDTO( + content_id=1, + text="t1", + stage="general", + emotion_score=None, + context_suitability={}, + need_suitability={}, + personalization_power=0.0, + risk_flags=[], + author_id=None, + template_id=None, + review_confidence=0.7, + ) + ] + + async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def] + return [] + + async def _override_repo(): # type: ignore[no-untyped-def] + return _FakeRepo() + + app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo + + # 清空限流计数,避免跨测试污染 + import app.api.limits as limits_mod + + limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined] + limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined] + + return TestClient(app) + + +def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + # 固定时间,避免跨分钟 flake + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳 + + resp = client.post( + "/v1/reco/feed", + json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}, + headers={"Accept-Language": "zh-TW,zh;q=0.9"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["meta"]["config_snapshot"]["locale"] == "tc" + + +def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) + + resp = client.post( + "/v1/reco/push", + json={ + "user_profile": _user_profile_dict(), + "now": "2026-02-01T00:00:00Z", + "already_recommended_ids": [], + "touched_or_viewed_ids": [], + }, + headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"}, + ) + assert resp.status_code == 200 + data = resp.json() + # meta.config_snapshot 里没有 now,但 served_k 应该正常 + assert data["meta"]["served_k"] == 1 + + +def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + import app.api.limits as limits_mod + + monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) + + body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []} + for _ in range(10): + r = client.post("/v1/reco/widget", json=body) + assert r.status_code == 200 + + r = client.post("/v1/reco/widget", json=body) + assert r.status_code == 429 + assert r.json()["detail"] == "rate_limited" + + +def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None: + _set_min_env(monkeypatch) + from app.core import config as config_mod + + config_mod.get_settings.cache_clear() + + import app.tasks.reco as reco_tasks + + # monkeypatch async runner,避免依赖 DB + async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def] + from app.features.personalized_reco.observability.types import RecoMeta + from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem + + return RecoEngineResult( + items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})], + meta=RecoMeta(scene="push", served_k=1), + ) + + monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async) + + out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1) + assert out["meta"]["served_k"] == 1 + diff --git a/server/tests/test_observability.py b/server/tests/test_observability.py new file mode 100644 index 0000000..8432fc5 --- /dev/null +++ b/server/tests/test_observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.observability.builder import RecoMetaBuilder +from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +def _u(*, need: dict | None = None, context: dict | None = None, emotion_score=None, conf_u: float = 0.9) -> UserProfileV1_2: + now = datetime.now(tz=timezone.utc) + return UserProfileV1_2( + profile_generated_at=now, + profile_confidence=conf_u, + profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), + stage=UserStageOneHot(unknown=1), + emotion_score=emotion_score, + context=context or {}, + need=need or {}, + ) + + +def test_compute_missing_fields() -> None: + u1 = _u(need={}, context={}, emotion_score=None) + m1 = compute_missing_fields(u1) + assert m1.need is True + assert m1.context is True + assert m1.emotion is True + + u2 = _u(need={"x": 1}, context={"y": 1}, emotion_score=0.6) + m2 = compute_missing_fields(u2) + assert m2.need is False + assert m2.context is False + assert m2.emotion is False + + +def test_compute_empty_reason_branches() -> None: + assert ( + compute_empty_reason( + served_k=1, + candidate_pool_size_raw=0, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + is None + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=0, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + == "pool_empty" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=0, + candidate_pool_size_after_freqcap=0, + ) + == "hard_filter_all" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=5, + candidate_pool_size_after_freqcap=0, + ) + == "freqcap_all" + ) + + assert ( + compute_empty_reason( + served_k=0, + candidate_pool_size_raw=10, + candidate_pool_size_after_hard_filter=5, + candidate_pool_size_after_freqcap=3, + ) + == "unknown" + ) + + +def test_builder_outputs_stable_fields_and_monotonic_counts() -> None: + u = _u(need={"emotional_support": 1}, context={}, emotion_score=None, conf_u=0.2) + + # 故意设置“非单调”的输入,验证 builder 的防御修正 + meta = ( + RecoMetaBuilder(scene="feed", user_profile=u, k=30) + .set_candidate_pool_size_raw(10) + .set_after_hard_filter(12) # 非法:大于 raw + .set_after_dedup(20) # 非法:大于 after_hard + .set_after_freqcap(15) # 非法:大于 after_dedup(修正后会与 after_dedup 对齐) + .set_served_k(99) # 非法:大于 after_freqcap + .set_fallback_level_final(1, reason="freqcap_all") + .build() + ) + + d = meta.model_dump() + for k in [ + "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", + "conf_U", + "missing_fields", + ]: + assert k in d + + assert meta.candidate_pool_size_raw == 10 + assert meta.candidate_pool_size_after_hard_filter == 10 + assert meta.candidate_pool_size_after_dedup == 10 + assert meta.candidate_pool_size_after_freqcap == 10 + assert meta.served_k == 10 + assert meta.conf_U == pytest.approx(0.2) + assert meta.missing_fields.context is True + assert meta.missing_fields.emotion is True + diff --git a/server/tests/test_reco_engine.py b/server/tests/test_reco_engine.py new file mode 100644 index 0000000..b455064 --- /dev/null +++ b/server/tests/test_reco_engine.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.content_repository.interface import ContentRepository +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.reco_engine.orchestrator import recommend +from app.features.personalized_reco.reco_engine.types import RecoConstraints +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +class _FakeRepo(ContentRepository): + def __init__(self, candidates_by_level: dict[int, list[ContentProfileDTO]]): + self._candidates_by_level = candidates_by_level + + async def fetch_candidates( # type: ignore[override] + self, + *, + scene: str, + user_profile: object, + fallback_level: int, + limit: int, + locale: str, + exclude_content_ids: list[int] | None = None, + ) -> list[ContentProfileDTO]: + # 单测:简化实现,只按 level 返回,忽略 limit/locale/exclude + return list(self._candidates_by_level.get(int(fallback_level), []))[: int(limit)] + + async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: # type: ignore[override] + raise NotImplementedError + + +def _now() -> datetime: + return datetime(2026, 2, 2, 12, 0, 0, tzinfo=timezone.utc) + + +def _user_profile(*, stage: str = "unknown", emotion_score: float | None = 0.5) -> UserProfileV1_2: + if stage == "expecting": + st = UserStageOneHot(expecting=1, parenting=0, unknown=0) # type: ignore[arg-type] + elif stage == "parenting": + st = UserStageOneHot(expecting=0, parenting=1, unknown=0) # type: ignore[arg-type] + else: + st = UserStageOneHot(expecting=0, parenting=0, unknown=1) # type: ignore[arg-type] + + return UserProfileV1_2( + profile_generated_at=_now(), + profile_confidence=1.0, + profile_answered=ProfileAnswered(stage=True, emotion=emotion_score is not None, context=False, need=False), + stage=st, + emotion_score=emotion_score, + context={}, + need={}, + ) + + +def _content( + *, + content_id: int, + text: str = "hello", + stage: str = "general", + personalization_power: float = 0.0, + risk_flags: list[str] | None = None, + author_id: str | None = None, + template_id: str | None = None, + need_suitability: dict[str, float] | None = None, +) -> ContentProfileDTO: + return ContentProfileDTO( + content_id=int(content_id), + text=text, + stage=stage, # type: ignore[arg-type] + emotion_score=None, + context_suitability={}, + need_suitability=need_suitability or {}, + personalization_power=float(personalization_power), + risk_flags=risk_flags or [], + author_id=author_id, + template_id=template_id, + review_confidence=0.7, + ) + + +@pytest.mark.asyncio +async def test_reco_engine_k_zero_returns_empty() -> None: + repo = _FakeRepo({0: [_content(content_id=1)]}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=0, + now=_now(), + locale=None, + ) + assert res.items == [] + assert res.meta.served_k == 0 + + +@pytest.mark.asyncio +async def test_reco_engine_pool_empty_sets_empty_reason() -> None: + repo = _FakeRepo({0: []}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert res.items == [] + assert res.meta.empty_reason == "pool_empty" + + +@pytest.mark.asyncio +async def test_reco_engine_hard_filter_all_sets_empty_reason() -> None: + repo = _FakeRepo({0: [_content(content_id=1, risk_flags=["block_health_medical"])]}) + res = await recommend( + repo=repo, + scene="push", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert res.items == [] + assert res.meta.empty_reason == "hard_filter_all" + assert res.meta.risk_filtered_count_by_flag.get("block_health_medical", 0) == 1 + + +@pytest.mark.asyncio +async def test_reco_engine_freqcap_all_sets_empty_reason() -> None: + # Push 场景:作者冷却命中,导致 after_freqcap=0 + repo = _FakeRepo({0: [_content(content_id=1, author_id="a1")]}) + res = await recommend( + repo=repo, + scene="push", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + constraints=RecoConstraints(recent_author_ids=["a1"]), + ) + assert res.items == [] + assert res.meta.empty_reason == "freqcap_all" + + +@pytest.mark.asyncio +async def test_reco_engine_returns_item_and_explanations_enabled() -> None: + repo = _FakeRepo({0: [_content(content_id=1, text="t1", personalization_power=0.0)]}) + res = await recommend( + repo=repo, + scene="feed", + user_profile=_user_profile(stage="unknown"), + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=1, + now=_now(), + locale="en", + ) + assert len(res.items) == 1 + assert res.items[0].content_id == 1 + assert res.items[0].text == "t1" + assert res.items[0].explanations is not None + + diff --git a/server/tests/test_rerank_freqcap.py b/server/tests/test_rerank_freqcap.py new file mode 100644 index 0000000..e5e35f5 --- /dev/null +++ b/server/tests/test_rerank_freqcap.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap +from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate + + +def _c( + *, + content_id: int, + score: float, + author_id: str | None = None, + template_id: str | None = None, + stage: str = "general", + need_key: str = "emotional_support", + context_key: str = "family", +) -> ScoredCandidate: + cp = ContentProfileDTO( + content_id=content_id, + text="t", + stage=stage, # type: ignore[arg-type] + emotion_score=None, + context_suitability={context_key: 1.0}, + need_suitability={need_key: 1.0}, + personalization_power=0.0, + risk_flags=[], + author_id=author_id, + template_id=template_id, + ) + return ScoredCandidate( + content_id=content_id, + final_score=score, + author_id=author_id, + template_id=template_id, + content_profile=cp, + ) + + +def test_dedup_normalizes_str_int_ids() -> None: + cands = [_c(content_id=1, score=0.9), _c(content_id=2, score=0.8), _c(content_id=3, score=0.7)] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=["1", "bad"], + touched_or_viewed_ids=[2], + k=10, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [3] + assert r.meta.candidate_pool_size_after_dedup == 1 + assert r.meta.freqcap_filtered_counts["sentence"] == 2 + + +def test_freqcap_missing_recent_author_template_is_recorded() -> None: + cands = [ + _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), + _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), + ] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=10, + recent_author_ids=None, + recent_template_ids=None, + ) + assert r.meta.missing_history_fields == ["author", "template"] + assert "author" not in r.meta.freqcap_filtered_counts # 未提供则不执行该维度 + assert "template" not in r.meta.freqcap_filtered_counts + + +def test_freqcap_filters_by_recent_author_when_provided() -> None: + cands = [ + _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), + _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), + ] + r = rerank_and_freqcap( + scene="widget", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=10, + recent_author_ids=["a1"], + recent_template_ids=None, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [2] + assert r.meta.missing_history_fields == ["template"] + assert r.meta.freqcap_filtered_counts["author"] == 1 + + +def test_feed_mmr_picks_diverse_second_item() -> None: + # 构造:Top1 是 a1;c2 分数略高但同作者;c3 分数略低但不同作者/阶段 + c1 = _c(content_id=1, score=1.0, author_id="a1", template_id="t1", stage="general") + c2 = _c(content_id=2, score=0.99, author_id="a1", template_id="t2", stage="general") + c3 = _c(content_id=3, score=0.95, author_id="a2", template_id="t3", stage="expecting") + + r = rerank_and_freqcap( + scene="feed", + scored_candidates=[c1, c2, c3], + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=2, + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids[0] == 1 + assert got_ids[1] == 3 + + +def test_push_topk_sorted_by_score_after_filters() -> None: + cands = [ + _c(content_id=1, score=0.1), + _c(content_id=2, score=0.9), + _c(content_id=3, score=0.8), + ] + r = rerank_and_freqcap( + scene="push", + scored_candidates=cands, + already_recommended_ids=[], + touched_or_viewed_ids=[], + k=2, + recent_author_ids=[], + recent_template_ids=[], + ) + got_ids = [x.content_id for x in r.ranked_items] + assert got_ids == [2, 3] + diff --git a/server/tests/test_scoring.py b/server/tests/test_scoring.py new file mode 100644 index 0000000..9b6f2b6 --- /dev/null +++ b/server/tests/test_scoring.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.features.personalized_reco.content_repository.types import ContentProfileDTO +from app.features.personalized_reco.scoring.defaults import get_default_config +from app.features.personalized_reco.scoring.score import score_content +from app.features.personalized_reco.scoring.types import ExternalTerms, ScoreConfig +from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot + + +def _u( + *, + stage: str = "unknown", + emotion_score: float | None = None, + need: dict[str, int] | None = None, + context: dict[str, int] | None = None, + profile_confidence: float = 1.0, +) -> UserProfileV1_2: + now = datetime.now(tz=timezone.utc) + if stage == "expecting": + s = UserStageOneHot(expecting=1, parenting=0, unknown=0) + elif stage == "parenting": + s = UserStageOneHot(expecting=0, parenting=1, unknown=0) + else: + s = UserStageOneHot(unknown=1) + + return UserProfileV1_2( + profile_generated_at=now, + profile_confidence=profile_confidence, + profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), + stage=s, + emotion_score=emotion_score, + context=context or {}, + need=need or {}, + ) + + +def _c( + *, + stage: str = "general", + emotion_score: float | None = None, + need_key: str = "emotional_support", + context_key: str = "family", + need_value: float = 1.0, + context_value: float = 1.0, + personalization_power: float = 1.0, + review_confidence: float = 0.7, +) -> ContentProfileDTO: + return ContentProfileDTO( + content_id=1, + text="hello", + stage=stage, # type: ignore[arg-type] + emotion_score=emotion_score, + context_suitability={context_key: context_value}, + need_suitability={need_key: need_value}, + personalization_power=personalization_power, + risk_flags=[], + review_confidence=review_confidence, + ) + + +def test_missing_fields_defaults_are_applied() -> None: + u = _u(emotion_score=None, need={}, context={}) + c = _c(stage="general", emotion_score=None) + r = score_content(scene="feed", user_profile=u, content_profile=c) + + assert r.breakdown.S_need == pytest.approx(0.5) + assert r.breakdown.S_context == pytest.approx(0.5) + assert r.breakdown.S_emotion == pytest.approx(0.8) + assert set(r.breakdown.missing_fields) == {"need", "context", "emotion"} + + +def test_uncertainty_penalty_enabled_for_push_by_default_and_can_be_disabled() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}, profile_confidence=0.2) + c = _c(stage="general", emotion_score=0.6, personalization_power=1.0, review_confidence=0.2) + + r_on = score_content(scene="push", user_profile=u, content_profile=c) + assert r_on.breakdown.P_uncertainty > 0 + + cfg_off = ScoreConfig.model_validate(get_default_config("push").model_dump() | {"enable_uncertainty_penalty": False}) + r_off = score_content(scene="push", user_profile=u, content_profile=c, config=cfg_off) + assert r_off.breakdown.P_uncertainty == pytest.approx(0.0) + assert r_off.final_score > r_on.final_score + + +def test_widget_emotion_soft_penalty_is_applied_outside_range() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) + + cfg = get_default_config("widget") + assert cfg.widget_emotion_soft_range == (0.4, 0.8) + assert cfg.widget_emotion_penalty_gamma == pytest.approx(0.25) + + c_in = _c(stage="general", emotion_score=0.6, personalization_power=0.0) + r_in = score_content(scene="widget", user_profile=u, content_profile=c_in, config=cfg) + assert r_in.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.0) + + c_out = _c(stage="general", emotion_score=0.0, personalization_power=0.0) + r_out = score_content(scene="widget", user_profile=u, content_profile=c_out, config=cfg) + assert r_out.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.25) + assert r_out.final_score < r_in.final_score + + +def test_pass_false_forces_final_score_zero_but_breakdown_is_present() -> None: + u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) + c = _c(stage="general", emotion_score=0.6, personalization_power=1.0) + r = score_content(scene="feed", user_profile=u, content_profile=c, pass_filters=False, external_terms=ExternalTerms()) + + assert r.final_score == pytest.approx(0.0) + assert r.breakdown.passed is False + # breakdown 字段集合稳定(至少包含关键分解项) + d = r.breakdown.model_dump(by_alias=True) + for k in [ + "scene", + "pass", + "S_need", + "S_context", + "S_stage", + "S_emotion", + "S_core", + "S_personal", + "S_fresh", + "P_fatigue", + "P_repeat", + "P_risk", + "P_uncertainty", + "P_widget_emotion_out_of_range", + "missing_fields", + ]: + assert k in d + diff --git a/spec_kit/Client User Identity/spec.md b/spec_kit/Client User Identity/spec.md new file mode 100644 index 0000000..c71f8ff --- /dev/null +++ b/spec_kit/Client User Identity/spec.md @@ -0,0 +1,151 @@ +# Client User Identity|客户端用户标识建立(用于 PUSH Token 绑定)|Spec + +> 阶段:高层规范(spec) +> +> 目标:在**无账号体系或账号可选**的前提下,为客户端生成一个稳定的“客户端用户标识”(下称 `client_user_id`),用于与 APNs/FCM 的 Push Token 建立绑定关系,便于后端精准下发 PUSH,并支持 Token 变更/多设备/环境隔离等场景。 + +--- + +## 1. 背景与动机(摘要) + +Push Token(APNs device token / FCM registration token)会发生变化(重装、系统升级、重新授权、token rotate 等),且同一用户可能多设备。为了能稳定地“找到这个客户端实例/用户侧主体”并维护 Token 映射,需要一个**与 Token 解耦**、可持久化、不可推断的标识。 + +--- + +## 2. 目标(Goals) + +- **建立 `client_user_id`**:客户端可生成并持久化一个稳定标识,作为后端 Push Token 绑定的主键之一。 +- **Token 绑定可更新**:支持 Token 变更时“同一 `client_user_id` 重新上报即可更新”。 +- **多设备兼容**:同一个账号(若未来引入)可关联多个 `client_user_id`;一个 `client_user_id` 可存在多个 Token(例如同设备多渠道/多应用包形态)时需可扩展。 +- **环境隔离**:dev/prod、iOS/Android、bundle id / package name 维度隔离,避免串绑。 +- **隐私友好**:不使用可追踪的硬件标识(IMEI/IDFA/Android ID 等),不引入额外合规风险。 + +--- + +## 3. 非目标(Non-goals) + +- 不在本阶段引入完整账号体系、登录态、用户合并策略(如“同一人多设备合并为一个 user_id”)。 +- 不在本阶段强制接入设备证明(App Attest/Play Integrity);仅在安全章节提出可选增强方向。 +- 不定义具体数据库表结构与迁移脚本(属于 plan 阶段细化)。 + +--- + +## 4. 术语与对象(Definitions) + +- **`client_user_id`**:客户端生成并持久化的随机标识,代表“一个客户端安装实例(或一段时间内的用户侧主体)”,用于 Push 绑定。 +- **`push_token`**:系统/厂商下发的推送 token(iOS/APNs,Android/FCM),可能变化。 +- **`account_id`(可选)**:若未来存在登录账号,则用于把多个 `client_user_id` 归属到同一账号。 + +--- + +## 5. 关键决策:使用 UUID 做 `client_user_id` 是否合适? + +结论:**合适**,推荐用**随机 UUID(UUID v4 为默认)**,并把它当作后端与客户端都不解析的**不透明字符串**。 + +### 5.1 为什么 UUID 合适 + +- **唯一性足够**:v4 基于随机数,碰撞概率极低,满足全局唯一需求。 +- **不可推断**:相较自增 ID,不易被枚举;相较设备硬件标识,更隐私友好。 +- **跨端易实现**:iOS/Android/JS 都可稳定生成与序列化(字符串)。 + +### 5.2 需要明确的边界与注意事项 + +- **UUID 不等于“真实用户”**:它更像“安装实例 ID”。用户重装/清数据后可能变化;这对 Push 绑定通常可接受(新装产生新 token 与新 id)。 +- **不要用设备硬件/系统可追踪 ID 替代**:避免隐私与合规风险,也避免系统限制导致的不稳定。 +- **安全边界**:如果后端完全信任客户端上报的 `client_user_id`,存在“伪造绑定”风险;应结合登录态或签名/证明(见第 9 节)降低滥用。 + +### 5.3 UUID 版本建议 + +- **默认**:UUID v4(实现最简单、兼容最好)。 +- **可选增强**:UUID v7(有时间有序性,利于日志/索引与写入局部性),但需要确保两端实现一致与依赖可控。 + +--- + +## 6. 客户端行为规范(Client Contract) + +### 6.1 生成与持久化 + +- 首次启动(或首次需要注册 Push 时): + - 若本地不存在 `client_user_id`:生成一个新的 UUID(字符串),写入持久化存储。 + - 若已存在:直接复用。 +- 存储建议(不做强约束,但必须“尽量稳定”): + - iOS:Keychain + - Android:Keystore 保护的加密存储/SharedPreferences(或等价方案) + - React Native/Expo:使用安全存储能力(例如 SecureStore/Keychain wrapper) + +### 6.2 Push Token 获取与上报时机 + +- 在以下任一时机触发“注册/更新”: + - 用户同意 Push 权限后获得 token + - App 冷启动获取到 token(含 token 变更) + - 账号登录/登出(若存在账号) + - 环境切换(dev/prod)或应用更新(可选) + +--- + +## 7. 后端接口契约(API Contract,摘要) + +> 具体路由/鉴权方式在 plan 阶段落地;此处先定义字段语义与幂等行为。 + +### 7.1 注册/更新绑定 + +- `POST /v1/push/register` +- 请求体(最小集): + - `client_user_id`: string(UUID 字符串) + - `platform`: `"ios" | "android"` + - `push_token`: string + - `app_id`: string(bundle id / package name,用于隔离) + - `env`: `"dev" | "prod"` + - (可选)`account_id`: string + - (可选)`device_meta`: `{ model, os_version, app_version, locale, timezone }` +- 行为要求(幂等): + - 以 `push_token + env + app_id` 维度做唯一性约束,避免重复记录。 + - 若同一 `client_user_id` 上报了新 token:应更新/新增映射,旧 token 进入失效或保留历史(由实现决定,但必须可控)。 + +### 7.2 解绑(可选但建议) + +- `POST /v1/push/unregister` +- 请求体: + - `client_user_id` + - `platform` + - `push_token`(或让后端按 `client_user_id` 批量解绑,二选一) + - `app_id` + - `env` + +--- + +## 8. 数据模型(逻辑约束) + +最小需要表达的关系: + +- 一个 `client_user_id` 可对应 0..N 个 `push_token`(考虑多端、多渠道、token rotate)。 +- 一个 `push_token` 在同一 `env + app_id` 下应只对应一个“当前归属”(避免重复推送)。 +- 若存在 `account_id`: + - 一个 `account_id` 可关联 0..N 个 `client_user_id`(多设备)。 + +--- + +## 9. 安全与滥用防护(高层约束) + +- **最小要求**:接口需具备基本鉴权与频率限制(例如基于设备指纹/匿名 session/应用侧签名的任一组合),避免被脚本批量绑定垃圾 token。 +- **若存在登录态**:推荐绑定写入需要登录态(或在登录后把 `client_user_id` 归属到 `account_id`),降低“抢绑”风险。 +- **可选增强(后续)**:接入 iOS App Attest / Android Play Integrity,或对注册请求做一次性挑战签名。 + +--- + +## 10. 边界场景与处理原则 + +- **用户拒绝 Push 权限**:允许只有 `client_user_id`,不产生 token 绑定;后端不应报错。 +- **token 变化**:客户端重新调用 `register`,后端必须幂等更新,避免重复推送。 +- **重装/清数据**:`client_user_id` 变化可接受;若未来有 `account_id`,可在登录后重新建立关联。 +- **多环境**:dev/prod token 不可混用;必须以 `env + app_id` 隔离。 + +--- + +## 11. 验收标准(Acceptance Criteria) + +- 客户端能稳定生成并持久化 `client_user_id`(重复启动不变)。 +- 在 token 获取/变更后,调用注册接口可在后端建立(或更新)绑定关系,且接口幂等。 +- 同一 `push_token` 在同一 `env + app_id` 下不会产生多条“当前有效”绑定,避免重复推送。 +- 在用户拒绝 Push 权限、无 token 的情况下,不影响 App 正常使用与后续再次授权后的绑定。 + diff --git a/spec_kit/Personalized Reco/modules/content-repository/plan.md b/spec_kit/Personalized Reco/modules/content-repository/plan.md new file mode 100644 index 0000000..2b796ba --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/plan.md @@ -0,0 +1,275 @@ +# Content Repository(候选查询与数据访问层)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/content-repository/spec.md` +> +> 依赖对齐: +> +> - DB 设计:`spec_kit/Personalized Reco/modules/db-design/plan.md` +> - 回退梯度与场景口径:`spec_kit/Personalized Reco/spec.md`(Fallback Ladder + 场景默认参数) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 为推荐引擎提供**与 ORM/SQL 解耦**的数据访问接口:候选召回与按 ID 批量获取。 +- 将 DB 内部存储形态(JSON、关联表、NULL 语义等)统一“规范化”为上层稳定的 `ContentProfile` 结构。 +- 在候选不足时支持按 `fallback_level (L0~L3)` 进行**可控降级**(降个性化/回退通用池/安全池),并且不产生 N+1 查询。 + +### 1.2 交付物 + +- `modules/content-repository/plan.md`:本技术计划(本文件)。 +- 代码实现(后续 tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/content_repository/`(或等价目录) + - 需要包含: + - 抽象接口 `ContentRepository`(Protocol 或 ABC) + - SQLAlchemy 实现 `SqlAlchemyContentRepository` + - `ContentProfile`(DTO/数据结构)与规范化工具函数 +- 单元测试与最小集成测试(后续 tasks 阶段落地): + - risk_flags 映射与去重 + - suitability 缺失兜底 + - personalization_power 映射 + - 查询不出现按 `content_id` 循环查 flags(避免 N+1) + +--- + +## 2. 关键技术决策(V1) + +### 2.1 存储形态(来自 DB Design 的已对齐结论) + +- `content_id`:MySQL 自增主键(`int`)。 +- `context_suitability_json / need_suitability_json`:JSON 存储。 +- `risk_flags`:关联表 `content_risk_flags(content_id, flag)`(同一 content 下 `uniq_content_flag` 去重)。 +- `emotion_score`:`NULL` 表示 general。 +- `review_confidence`:DB 可为 `NULL`;读取层输出时按 `0.7` 兜底(对齐规则口径)。 +- `personalization_power`:DB 推荐存 `0/5/10`;读取层输出稳定为 `0.0/0.5/1.0`。 + +### 2.2 职责边界(避免耦合) + +- `Content Repository` **不负责** Hard Filter/Soft Scoring/Rerank/Freqcap(这些由引擎编排与打分子模块完成)。 +- `Content Repository` **负责**: + - 按 `fallback_level` 对候选池做“降级约束”(例如限制 `personalization_power`、回退通用池/安全池) + - 输出稳定结构(JSON 解析、默认值兜底、旧 flag 映射) + +--- + +## 3. 接口与数据结构(V1) + +### 3.1 接口定义(与 spec 对齐) + +- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]` +- `fetch_contents_by_ids(content_ids: List[int], locale) -> List[ContentProfile]` + +### 3.2 `ContentProfile`(输出契约的推荐形态) + +稳定字段(必须输出): + +- `content_id: int` +- `text: str` +- `stage: "general" | "expecting" | "parenting" | "unknown"` +- `emotion_score: float | None`(`None` 表示 general) +- `context_suitability: Dict[str, float]` +- `need_suitability: Dict[str, float]` +- `personalization_power: float`(`0/0.5/1`) +- `risk_flags: List[str]` + +可选字段(尽量输出): + +- `author_id: str | None` +- `template_id: str | None` +- `review_confidence: float`(缺失/NULL 按 `0.7` 输出) + +--- + +## 4. 读取层规范化(Normalization) + +### 4.1 text 选文案与多语言策略(不允许回退) + +数据来源(当前 DB/ORM 约定): + +- `contents.text_en`:英文 +- `contents.text_tc`:繁体中文 + +规则(**不允许语言回退**): + +- `locale=en*`:仅允许返回存在 `text_en` 的内容;输出 `text = text_en` +- `locale=tc/zh-TW/zh-HK`:仅允许返回存在 `text_tc` 的内容;输出 `text = text_tc` + +若内容缺少目标语言文本(例如 `locale=en*` 但 `text_en` 为空):该内容视为不可用,必须在候选/按 ID 获取时过滤掉。 + +### 4.1 suitability JSON 解析与缺失兜底 + +固定 key 集合(对齐 DB Plan 的最小入库契约): + +- `context_suitability`:`family/work/relationship/friends/health` +- `need_suitability`:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance` + +规则: + +- 若 DB 字段缺失/为 `NULL`/解析失败:**补齐为全 0.5**(以上所有 key 均为 `0.5`)。 +- 若 DB JSON 存在但缺少部分 key:对缺少 key 补 `0.5`,其余按原值。 +- 值域约束:期望值为 `0/0.5/1`;若出现其他值(例如字符串、越界浮点),按 `0.5` 兜底并记录告警日志(V1 可先打 debug,后续接入可观测模块)。 + +### 4.2 `review_confidence` 兜底 + +- DB `review_confidence` 为 `NULL` 或缺失:输出 `0.7`。 + +### 4.3 `personalization_power` 映射 + +若 DB 存 `0/5/10`: + +- `0 -> 0.0` +- `5 -> 0.5` +- `10 -> 1.0` + +若读到其他值:按 `0.0` 兜底并记录告警日志。 + +### 4.4 risk_flags 旧→新映射与输出约束 + +映射表(对齐 spec): + +- `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`(V1 保守硬拦截) + +输出约束: + +- 输出 `risk_flags` 必须去重。 +- 输出不得包含旧命名。 +- 输出建议稳定排序(便于测试与可观测):按字典序排序或按严重等级排序(V1 可先字典序)。 + +--- + +## 5. 查询策略(V1) + +> 原则:DB 层先做“粗过滤”,应用层再做“精过滤/打分”。避免在 V1 过早依赖 JSON 路径查询索引。 + +### 5.1 `fetch_contents_by_ids`(按 ID 批量获取) + +目标: + +- 输入任意 `content_id` 列表,返回无重复的 `ContentProfile` 列表。 +- 避免 N+1:不得按 `content_id` 循环查 `content_risk_flags`。 +- 返回顺序:必须与输入 `content_ids` 一致(对“缺记录/缺语言文本”的 id 采取跳过策略,见下)。 + +缺记录/缺语言文本的处理(V1 约定): + +- 若某个 `content_id` 在 DB 中不存在,或按 `locale` 规则无法产出 `text`:该 id 在返回列表中**跳过**(不返回占位对象)。 + +推荐实现形态(两段式,避免 JOIN 导致重复行): + +1. **批量拉主体与画像**(`contents` JOIN `content_profiles`),限制 `content_id IN (...)`。 +2. **批量拉 risk_flags**:`SELECT content_id, flag FROM content_risk_flags WHERE content_id IN (...)`,在应用层按 `content_id` 聚合为集合,再做旧→新映射与去重。 + +备注: + +- 由于 `content_risk_flags` 是 1:N,直接三表 JOIN 容易导致行膨胀;两段式更便于组装与去重。 + +### 5.2 `fetch_candidates`(候选召回,支持 L0~L3) + +输入: + +- `scene: feed | push | widget` +- `user_profile`(允许字段缺失) +- `fallback_level: 0|1|2|3` +- `limit` +- `exclude_content_ids`(可选) + +#### 5.2.1 fallback_level 约束(对齐大规范 Fallback Ladder) + +从 `spec_kit/Personalized Reco/spec.md` 对齐: + +- **L0**:正常召回配比(不在读取层实现复杂配比,读取层只保证候选池足够大且不过度放宽) +- **L1**:放宽匹配 + 降个性化:限制 `personalization_power ≤ 0.5` +- **L2**:回退通用池 + 进一步降个性化:限制 `personalization_power = 0`,且优先 `stage=general` +- **L3**:兜底安全池:限制 `is_safe_pool = true`(安全池字段来自 DB Design) + +缺失字段的最小处理(对齐大规范 Candidate Generation 口径): + +- 若 `user_profile` 缺失明显(need/context/emotion 任一缺失):读取层按**至少 L1** 的约束执行(即使入参 fallback_level=0)。 + +#### 5.2.2 stage 粗过滤策略(V1) + +读取层可做的最小粗过滤(不引入复杂业务判断): + +- **L2/L3**:只取 `stage=general`(L3 额外 `is_safe_pool=true`)。 +- **L0/L1**: + - 优先取 `stage=用户匹配阶段` + `stage=general` + - 若无法从 `user_profile` 明确阶段,则仅取 `stage=general`(避免误推) + +> 说明:更细粒度的阶段/跨维度规则(例如 unknown+parenting_pressure 的禁推)由 Hard Filter 子模块实现;读取层仅做粗过滤以减少扫描与传输。 + +#### 5.2.3 查询形态(避免 JOIN 行膨胀 + 保证 limit) + +推荐采用“两段式候选召回”: + +1. **先只查候选 ID 列表**(`contents` JOIN `content_profiles`),应用粗过滤(stage / personalization_power / is_safe_pool / exclude_content_ids),并增加 **locale 文本存在性过滤**(不允许语言回退),再用 `LIMIT limit * multiplier` 拉一批候选 ID(`multiplier` 例如 3~5,避免后续去重/过滤后不足)。 +2. **再用 `fetch_contents_by_ids` 批量补全字段**(主体+画像+risk_flags),最终在应用层去重并截断到 `limit`。 + +排序(V1): + +- 若没有更明确的排序字段:使用 `updated_at DESC` 或随机抽样(需谨慎,MySQL `ORDER BY RAND()` 在大表会慢)。 +- 推荐:V1 先用 `content_profiles.updated_at DESC` 或 `contents.created_at DESC`,后续由打分模块决定最终排序。 + +--- + +## 6. 性能与可观测(V1) + +### 6.1 性能约束 + +- 单次调用不得出现按 `content_id` 循环查库(避免 N+1)。 +- `fetch_candidates` 必须在 DB 层支持 `limit`,并尽量通过粗过滤减少扫描。 + +### 6.2 建议打点/日志(为 observability 子模块预留) + +在 Repository 层建议输出 debug 级日志(或埋点字段,供上层汇总): + +- `scene` +- `fallback_level`(入参)与 `effective_fallback_level`(考虑缺失字段自动至少 L1 后的实际约束级别) +- `limit`、`exclude_content_ids_count` +- `candidate_ids_size_raw`(第 1 段查到的候选 ID 数) +- `candidate_size_returned`(最终返回数量) + +--- + +## 7. 测试计划(V1) + +### 7.1 单元测试(纯函数) + +- risk_flags 映射: + - 输入包含旧 flag,输出只包含新命名 + - 去重与稳定排序 +- suitability 兜底: + - DB 字段缺失/NULL/解析失败 → 全 0.5 + - 部分 key 缺失 → 补齐 0.5 +- personalization_power 映射: + - 0/5/10 → 0.0/0.5/1.0 + - 异常值 → 0.0 兜底 +- review_confidence: + - NULL/缺失 → 0.7 + +### 7.2 最小集成测试(含数据库) + +- `fetch_contents_by_ids`: + - 输入多个 id 返回无重复 + - flags 聚合正确(同一 content 多条 flag 行能聚合成 list) +- 查询次数断言(避免 N+1): + - `fetch_contents_by_ids`:固定 2 次查询(主体+画像一次,flags 一次) + - `fetch_candidates`:固定 3 次查询(候选 id 一次 + `fetch_contents_by_ids` 两次),或实现允许的常数级次数 + +--- + +## 8. 风险与后续演进 + +### 8.1 已知风险 + +- V1 不做 JSON 路径索引:候选量变大后,粗过滤不足可能导致候选池拉取过多、应用层过滤成本上升。 +- `ORDER BY RAND()` 的性能风险:候选大表下不可用,需要替代策略(时间窗口抽样/预生成候选池)。 + +### 8.2 V1.1 优化方向(与 DB Plan 对齐) + +- 为常用 need/context key 增加生成列/函数索引(从 JSON_EXTRACT 提取到 TINYINT)以加速召回。 +- 为强规则风险(如 `block_health_medical`)增加派生布尔列或缓存表,减少 JOIN 成本。 + 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..610d399 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/spec.md @@ -0,0 +1,105 @@ +# 子模块: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;字段允许缺失) +- `locale`:客户端语言(由请求携带并透传至推荐模块;**当前仅支持 EN/TC**,例如 `en` / `en-US` / `tc` / `zh-TW` / `zh-HK`) +- `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`:按 `locale` 输出的文案文本(**不允许语言回退**) + - `locale=en*`:必须从 `contents.text_en` 产出;若该 content 无 `text_en`,则该 content 不可返回(在候选/按 ID 获取时过滤) + - `locale=tc/zh-TW/zh-HK`:必须从 `contents.text_tc` 产出;若无 `text_tc`,则该 content 不可返回 + - `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/content-repository/tasks.md b/spec_kit/Personalized Reco/modules/content-repository/tasks.md new file mode 100644 index 0000000..70e1330 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/content-repository/tasks.md @@ -0,0 +1,198 @@ +# Content Repository(候选查询与数据访问层)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/content-repository/plan.md` +> +> 本清单已对齐确认点: +> +> - `text` 按客户端 **locale** 输出(请求携带) +> - **不允许语言回退**(缺少目标语言文本的内容直接过滤,不返回) +> - 需要做 **MySQL(dev)DB 集成测试** +> - 代码与其他推荐子模块放同一目录:`server/app/features/personalized_reco/` +> - `fetch_contents_by_ids` 返回顺序必须与输入 `content_ids` **一致** +> +> 执行说明: +> +> - 本次已在 dev MySQL 环境跑通 `pytest`,且测试不做破坏性操作: +> - 不清表(不执行 DELETE/TRUNCATE) +> - 每个用例使用事务并在结束时 rollback +> - 默认不执行 Alembic 迁移(如需自动迁移需显式设置 `ALLOW_SCHEMA_MIGRATION=1`) + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 更新 `modules/content-repository/spec.md`,加入 locale 相关契约 + - **变更点**: + - 在输入中新增 `locale`(例如 `en`/`en-US`/`tc`/`zh-TW`/`zh-HK`),声明由客户端请求携带并透传至 repository + - 在输出字段 `text` 补充语言选择规则: + - `locale=en*`:必须从 `text_en` 产出;若缺失则该内容不可返回(过滤) + - `locale=zh-TW|zh-HK`:必须从 `text_tc` 产出;若缺失则该内容不可返回(过滤) + - 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略 + - 明确:`fetch_contents_by_ids` **返回顺序与入参一致** + - **验收**:`spec.md` 中输入/输出与接口签名不再缺少 locale,且 `text` 的来源与“不允许语言回退”规则清晰。 + +- [x] 1.2 更新 `modules/content-repository/plan.md`,补齐 locale 选文与“繁转简”技术方案 + - **变更点**: + - 在“读取层规范化(Normalization)”新增 `text` 规范化章节:语言选择 + 简中转换 + - 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略 + - 明确策略:**不允许语言回退**;缺少目标语言文本的内容视为不可用,必须在候选/按 ID 获取时过滤掉 + - **验收**:`plan.md` 有明确依赖与落地策略(含依赖包/转换时机/兜底策略),不留二义性。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/content_repository/` + - **包含**: + - `__init__.py` + - `types.py`(DTO:`ContentProfile`、locale 类型等) + - `interface.py`(`ContentRepository` Protocol/ABC) + - `normalization.py`(解析与兜底:suitability、risk_flags、power、text) + - `sqlalchemy_repo.py`(SQLAlchemy 实现) + - **验收**:可被 `app.features.personalized_reco.content_repository.*` 正常 import。 + +- [ ] 2.2 依赖补齐(如采用 OpenCC) + - **说明**:当前仅支持 EN/TC,此任务可跳过;若未来新增 `zh-CN` 并需要繁转简,再引入 OpenCC。 + +--- + +## 3. 数据结构与接口(面向引擎注入) + +- [x] 3.1 定义 `ContentProfile` DTO(稳定字段契约) + - **字段**:对齐 `modules/content-repository/spec.md`,并补齐 `review_confidence` 输出兜底为 `0.7` + - **注意**:`text` 为最终对外输出文本(已按 locale 选择/转换) + - **验收**:DTO 字段齐全;类型清晰;不暴露 ORM 模型。 + +- [x] 3.2 定义 `ContentRepository` 接口(含 locale) + - **建议签名**(示例,最终以 spec 为准): + - `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]` + - `fetch_contents_by_ids(content_ids, locale) -> List[ContentProfile]` + - **验收**:推荐引擎可以仅依赖该接口,不依赖 SQLAlchemy/FastAPI Depends。 + +--- + +## 4. 规范化工具函数(可单测) + +- [x] 4.1 suitability 解析与兜底 + - **规则**: + - 缺失/NULL/解析失败 → 全 0.5(固定 key 集合) + - 部分 key 缺失 → 对缺失 key 补 0.5 + - 非法值 → 兜底 0.5 + - **验收**:单元测试覆盖缺失/部分缺失/非法值。 + +- [x] 4.2 risk_flags 映射、去重与排序 + - **规则**:旧→新映射对齐 spec;去重;稳定排序(例如字典序) + - **验收**:单元测试断言输出不含旧命名且顺序稳定。 + +- [x] 4.3 personalization_power 映射 + - **规则**:`0/5/10 -> 0.0/0.5/1.0`;非法值 -> 0.0 + - **验收**:单元测试覆盖正常/异常值。 + +- [x] 4.4 text 选择与简中转换 + - **输入**:`text_en`、`text_tc`、`locale` + - **规则**:按 1.1/1.2 写死的策略执行(当前仅支持 EN/TC,不做繁转简) + - **验收**:单元测试覆盖: + - `en` 取英文 + - `zh-TW` 取繁中 + - 缺失目标语言文本时的行为:返回“不可用”(例如返回空字符串 + 上层过滤,或直接返回 `None` 由调用方过滤;实现中必须一致) + +--- + +## 5. SQLAlchemy 实现(无 N+1、顺序可控) + +> 说明:当前 DB 模型为: +> +> - `contents`:`text_en` / `text_tc` / `author_id` / `template_id` +> - `content_profiles`:JSON、power、stage、is_safe_pool、review_confidence +> - `content_risk_flags`:关联表(1:N) + +- [x] 5.1 实现 `fetch_contents_by_ids(content_ids, locale)` + - **实现要点**: + - 输入去重,但输出必须按原始输入顺序重排(并忽略不存在的 id 或明确行为:不存在则跳过) + - 两段式查询避免行膨胀: + 1) `contents` JOIN `content_profiles` 批量取主体与画像 + 2) `content_risk_flags` 批量取 flags,再按 `content_id` 聚合 + - 组装 DTO 时执行 normalization(含 text locale 规则) + - **验收**: + - 返回顺序与输入一致 + - 缺失目标语言文本的 content_id 不返回(跳过,不做语言回退) + - 不产生按 id 循环查 flags 的查询(查询次数为常数级) + +- [x] 5.2 实现 `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids)` + - **实现要点**: + - 计算 `effective_fallback_level`: + - 若画像 need/context/emotion 任一缺失,则 `effective_fallback_level = max(fallback_level, 1)` + - DB 粗过滤对齐 plan: + - L1:`personalization_power <= 5` + - L2:`personalization_power = 0` 且 `stage = general` + - L3:`is_safe_pool = true` 且 `stage = general` 且 `personalization_power = 0`(如需更严格可在此明确) + - 排序(V1):按 `content_profiles.updated_at DESC` 或 `contents.updated_at DESC`(择一写死并记录) + - 两段式候选: + 1) 先查候选 id(`LIMIT limit * multiplier`) + 2) 调用 `fetch_contents_by_ids` 补全字段 + - locale 文本存在性过滤: + - `locale=en*`:`contents.text_en IS NOT NULL` + - `locale=zh-*`:`contents.text_tc IS NOT NULL` + - 输出顺序: + - 返回顺序按候选 id 列表顺序(用于后续引擎打分/重排);最终截断至 `limit` + - **验收**: + - 在不同 `effective_fallback_level` 下能返回候选 + - 不返回缺少目标语言文本的内容(不做语言回退) + - 查询次数为常数级(不随 `limit` 线性增长) + +--- + +## 6. DB 集成测试(dev MySQL) + +- [x] 6.1 建立测试目录与 pytest 配置 + - **目标**:在 `server/` 内新增 `tests/`(或 `app/**/__tests__/`,但建议统一为 `server/tests/`) + - **内容**: + - `server/tests/conftest.py`:提供 AsyncEngine/AsyncSession、清库策略、query count 统计工具 + - 测试运行约定:通过 `DATABASE_URL` 指向 dev 测试库(建议单独库名,例如 `mindfulness_dev_test`) + - **验收**:`pytest` 可在 `server/` 下运行并发现测试。 + +- [x] 6.2 测试库 schema 初始化(用 Alembic) + - **策略**(二选一写死): + - A:测试启动时 `alembic upgrade head`(确保 schema 最新) + - B:在 CI/本地提前准备库,仅在测试中清表 + - **验收**:测试运行前 schema 可用,且不会污染开发主库数据(推荐使用独立 test 库)。 + +- [x] 6.3 集成测试用例:`fetch_contents_by_ids` + - **准备数据**:插入最小内容 2~3 条(覆盖 text_en/text_tc 缺失组合)、profiles、flags(含旧 flag) + - **断言**: + - 返回顺序与输入一致 + - `en` 不返回 `text_en` 缺失的内容(不回退 `text_tc`) + - risk_flags 映射后不含旧命名 + - `review_confidence` NULL → 0.7 + - **验收**:测试稳定通过。 + +- [x] 6.4 集成测试用例:`fetch_candidates` + - **准备数据**:覆盖 `personalization_power` 0/5/10、`is_safe_pool` true/false、不同 stage + - **断言**: + - L1/L2/L3 粗过滤生效 + - `exclude_content_ids` 生效 + - 查询次数为常数级(用 before_cursor_execute 计数) + - **验收**:测试稳定通过。 + +--- + +## 7. 最终自检清单(合入前) + +- [x] 7.1 文档一致性检查 + - `spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 `locale` 与 `text` 输出规则) + +- [x] 7.2 性能检查(最小) + - `fetch_contents_by_ids` / `fetch_candidates` 查询次数断言通过(无 N+1) + +- [x] 7.3 回归检查 + - 不影响现有 `user_profile_scoring` 模块与迁移脚本(仅新增模块与测试) + 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/plan.md b/spec_kit/Personalized Reco/modules/integration-api-worker/plan.md new file mode 100644 index 0000000..4ef3051 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/integration-api-worker/plan.md @@ -0,0 +1,205 @@ +# Integration(FastAPI API + Celery Worker)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/integration-api-worker/spec.md` +> +> 依赖(已实现): +> +> - `server/app/features/personalized_reco/reco_engine/`:统一引擎入口 `recommend(...)` +> - `server/app/features/personalized_reco/content_repository/`:`SqlAlchemyContentRepository` +> - `server/app/db/session.py`:`get_db` / `AsyncSessionLocal` +> - `server/app/worker.py`:`celery_app` +> +> 本计划已按确认项固化: +> +> - API 路由:按场景拆分(`/v1/reco/feed`、`/v1/reco/push`、`/v1/reco/widget`) +> - locale:从 `Accept-Language` 解析并映射到 `en/tc`,缺省 `en` +> - 限流:**按客户端 IP**,**1 分钟 10 次** +> - Celery:实现 `tasks.reco.generate` + `tasks.reco.push_once` +> - Celery 内调用 async:使用 `asyncio.run(...)` +> - now 注入:支持 Header `X-Now`(方案 B),并保留请求体 `now`(spec 已定义) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 对外提供推荐能力: + - **FastAPI**:客户端同步获取推荐结果。 + - **Celery**:后台任务式生成推荐(Push/Widget 的定时/批处理)。 +- **不复制推荐逻辑**:API 与任务均只调用同一 `Reco Engine`。 +- 提供基础可用的 **IP 限流**(1 分钟 10 次)。 +- 支持 **now 注入** 以实现确定性回归测试。 +- 多语言仅支持 **EN/TC**,不允许语言回退。 + +### 1.2 交付物(tasks 阶段落地) + +- 新增 API 路由文件(建议): + - `server/app/api/v1/reco.py` +- `main.py` 注册路由: + - `app.include_router(reco_router)` +- 新增 Celery 任务: + - `server/app/tasks/reco.py`(包含 `tasks.reco.generate`、`tasks.reco.push_once`) +- 新增限流中间件/依赖: + - `server/app/api/limits.py`(或 `server/app/core/ratelimit.py`) +- 单元/集成测试(至少): + - API schema 校验(请求/响应模型) + - 限流行为(同 IP 超过阈值返回 429) + - Celery 任务能跑通 `ping -> reco.generate` + +--- + +## 2. FastAPI 设计 + +### 2.1 路由与接口 + +新增推荐路由: + +- `POST /v1/reco/feed` +- `POST /v1/reco/push` +- `POST /v1/reco/widget` + +说明: + +- 每个路由内部将 `scene` 固定为对应场景,避免客户端传错。 +- `k` 若未传:按引擎默认(建议:feed=30,push/widget=1;此默认可在 API 层写死或由调用方显式传入)。 + +### 2.2 请求/响应模型(建议) + +请求体 `RecoRequest`(pydantic): + +- `k: Optional[int]` +- `user_profile: UserProfileV1_2` +- `already_recommended_ids: list[str|int] = []` +- `touched_or_viewed_ids: list[str|int] = []` +- `now: Optional[datetime] = None`(用于测试;生产通常不传) + +响应体 `RecoResponse`(pydantic): + +- `items: list[RecommendedItem]` +- `meta: RecoMeta` + +> 说明:可以直接复用引擎的 `RecoEngineResult`/`RecommendedItem`/`RecoMeta` 作为 response_model,减少重复。 + +### 2.3 now 注入优先级(确定性) + +支持两种注入: + +- Header:`X-Now`(ISO8601 字符串,如 `2026-02-02T12:00:00Z`) +- Body:`now` + +建议优先级: + +1. 若 `X-Now` 存在且可解析 → 使用 header 的时间 +2. 否则若 body.now 存在 → 使用 body.now +3. 否则 → 使用服务端 `datetime.now(timezone.utc)` + +### 2.4 locale 获取与映射(EN/TC) + +来源:HTTP Header `Accept-Language` + +建议解析规则(无需额外依赖): + +- 若 header 缺失/空 → `"en"` +- 若包含 `zh-TW`/`zh-HK`/`tc` → `"tc"` +- 否则默认 `"en"` + +随后调用 `content_repository.types.normalize_locale(locale)` 做严格校验(保证只出 `en/tc`)。 + +### 2.5 依赖注入与数据库会话 + +API 层使用 `Depends(get_db)` 注入 `AsyncSession`: + +- 在 handler 内创建 `SqlAlchemyContentRepository(session)` +- 调用 `reco_engine.recommend(repo=..., scene=..., ...)` + +### 2.6 限流(按 IP:1 分钟 10 次) + +实现方式(V1 推荐:无外部依赖、内存版): + +- 在 FastAPI 层添加一个依赖或中间件: + - 从 `Request.client.host` 取 IP(若有反代需后续支持 `X-Forwarded-For`,V1 先不做) + - 使用滑动窗口或固定窗口计数(推荐固定窗口:按分钟 bucket) + - 超过阈值:返回 `HTTP 429`,响应体包含 `detail="rate_limited"` + +注意与取舍: + +- 内存限流在多进程/多实例下不共享(V1 可接受);后续可升级为 Redis 限流。 + +--- + +## 3. Celery Worker 设计 + +### 3.1 任务列表 + +- `tasks.reco.generate` + - 输入:与 API 等价,但建议 payload 小(user_profile + ids + scene + 可选 now/locale) + - 输出:默认忽略结果(worker 已配置 `task_ignore_result`),但函数可返回 `items/meta` 用于调试 +- `tasks.reco.push_once` + - 输入:尽量只包含 push 需要字段(user_profile + ids + 可选 now/locale) + - 行为:内部调用 `tasks.reco.generate(scene="push")`,并预留“写入下游”的占位函数(V1 不接真实推送系统) + +### 3.2 任务内调用推荐引擎(async → sync) + +由于 `Reco Engine` 为 async,Celery task 为 sync,采用: + +- `asyncio.run(_run_reco_async(...))` + +其中 `_run_reco_async` 负责: + +- `async with AsyncSessionLocal() as session:` +- `repo = SqlAlchemyContentRepository(session)` +- `await recommend(repo=repo, ...)` + +说明: + +- Celery 环境通常没有运行中的事件循环,`asyncio.run` 可用。 +- 若未来引入 async worker/或在已有 loop 环境中调用,再考虑改为“可复用事件循环工具”。 + +### 3.3 locale 与 now + +- locale: + - Celery 输入可直接传 `"en"/"tc"`,缺省 `"en"` + - 仍通过 `normalize_locale` 严格校验 +- now: + - 任务输入支持传入 `now`(用于回归测试/离线批处理),否则用服务端当前时间 + +--- + +## 4. 一致性策略(API vs Celery) + +必须保证: + +- API 与任务都只调用 `reco_engine.recommend` +- 对同一份输入(含固定 now/locale),输出 items 结果一致(允许浮点微差) + +建议做一个对比测试: + +- 在测试中构造固定 `now`,用同样的 repo/同样的输入分别走 API handler 与 Celery 的 `_run_reco_async`,断言 `content_id` 列表一致。 + +--- + +## 5. 错误处理与返回规范 + +### 5.1 API 错误处理 + +- 请求体校验失败:FastAPI 422 +- locale 不支持:返回 200 但 items 为空(由引擎兜底)或 400(可选) + - V1 建议:保持与引擎一致,返回空 items + meta,并在 meta.config_snapshot 记录错误 stage +- 限流触发:429 + +### 5.2 Celery 错误处理 + +- 任务内部捕获异常并记录日志 +- 默认不回写结果,避免 Redis 占用 +- 必要时将错误信息写入任务日志或后续的可观测系统(V2) + +--- + +## 6. 安全与性能(V1) + +- 限流:按 IP 10/min,保护服务与数据库 +- Payload 控制: + - Celery 输入避免传大数组;历史集合若过大,后续演进为“传引用 ID” +- 多语言:只支持 EN/TC,不做语言回退(与 repository 口径一致) + 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/integration-api-worker/tasks.md b/spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md new file mode 100644 index 0000000..8691183 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/integration-api-worker/tasks.md @@ -0,0 +1,188 @@ +# Integration(FastAPI API + Celery Worker)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/integration-api-worker/plan.md` +> +> 执行规则: +> +> - 本任务清单**详细可执行**;每项完成后将 “状态:未开始” 改为 “状态:已完成”,并补充证据(命令输出/截图/测试用例)。 +> - **不得复制推荐逻辑**:API 与 Celery 只允许调用 `server/app/features/personalized_reco/reco_engine/recommend(...)`。 +> - 多语言仅支持 **EN/TC**;`Accept-Language` 映射后必须通过 `normalize_locale` 校验。 +> - 限流:**按客户端 IP**,**1 分钟 10 次**,超限返回 **429**。 +> - now 注入:支持 `X-Now` header(ISO8601),并保留 body.now;优先级:header > body > server now。 + +--- + +## 0. 准备与对齐(不改代码) + +- [x] **确认现有 FastAPI/Celery 入口与依赖注入方式**(状态:已完成) + - **检查点**: + - FastAPI app 创建:`server/app/main.py` + - DB session 依赖:`server/app/db/session.py:get_db` + - Celery app:`server/app/worker.py:celery_app` 且自动发现任务 `app.tasks` + - **证据**: + - FastAPI:`server/app/main.py` 使用 `create_app()` 并 `include_router(...)` + - DB:`server/app/db/session.py` 提供 `get_db()` 与 `AsyncSessionLocal` + - Celery:`server/app/worker.py` 使用 `celery_app.autodiscover_tasks(["app.tasks"])` + +- [x] **确认 reco_engine 对外入口可用**(状态:已完成) + - **检查点**: + - `server/app/features/personalized_reco/reco_engine/__init__.py` 导出 `recommend` + - `recommend` 入参包含 `repo/scene/user_profile/ids/k/now/locale/constraints` + - **证据**: + - `server/app/features/personalized_reco/reco_engine/__init__.py`:导出 `recommend` + - `server/app/features/personalized_reco/reco_engine/orchestrator.py`:`async def recommend(...)` + +--- + +## 1. FastAPI:推荐接口(按场景拆分) + +- [x] **新增路由文件 `server/app/api/v1/reco.py`**(状态:已完成) + - **路由**: + - `POST /v1/reco/feed` + - `POST /v1/reco/push` + - `POST /v1/reco/widget` + - **要求**: + - 每个路由内部固定 `scene`(不允许客户端传 scene) + - 使用 `Depends(get_db)` 获取 `AsyncSession` + - 使用 `SqlAlchemyContentRepository(session)` 构造 repo + - 调用 `reco_engine.recommend(...)` 并直接返回 `items/meta` + - **证据**:路由文件路径 + handler 函数列表 + - **证据**: + - 文件:`server/app/api/v1/reco.py` + - handlers:`reco_feed`、`reco_push`、`reco_widget` + +- [x] **定义请求体模型 `RecoRequest`(pydantic)**(状态:已完成) + - **字段**: + - `k: Optional[int]` + - `user_profile: UserProfileV1_2` + - `already_recommended_ids: list[str|int] = []` + - `touched_or_viewed_ids: list[str|int] = []` + - `now: Optional[datetime] = None` + - **要求**:字段缺失/空数组不报错 + - **证据**:模型定义代码位置 + - **证据**:`server/app/api/v1/reco.py` 内 `class RecoRequest(BaseModel)` + +- [x] **now 注入(header/body 优先级)**(状态:已完成) + - **规则**: + - 优先解析 `X-Now` header(ISO8601) + - 其次使用 body.now + - 否则使用服务端 `datetime.now(timezone.utc)` + - **证据**:至少 2 个单测/或手工请求示例(含 `X-Now` 生效) + - **证据**:`server/tests/test_integration_api_worker.py::test_x_now_header_priority_over_body_now` + +- [x] **locale:解析 `Accept-Language` 并映射到 `en/tc`**(状态:已完成) + - **规则**: + - header 缺失/空 → `"en"` + - 包含 `zh-TW/zh-HK/tc` → `"tc"` + - 否则 → `"en"` + - 最终必须通过 `content_repository.types.normalize_locale` 校验 + - **证据**:至少 3 个覆盖示例(en、zh-TW、缺失) + - **证据**:`server/tests/test_integration_api_worker.py::test_accept_language_mapping_to_tc` + +- [x] **在 `server/app/main.py` 注册 reco 路由**(状态:已完成) + - **要求**:`app.include_router(reco_router)` + - **证据**:`/docs` 中可看到 3 个新接口 + - **证据**:`server/app/main.py` 已 `include_router(reco_router)` + +--- + +## 2. FastAPI:限流(按 IP,10 次/分钟) + +- [x] **实现限流依赖或中间件**(状态:已完成) + - **建议文件**:`server/app/api/limits.py` + - **实现要点**: + - 从 `Request.client.host` 读取 IP + - 固定窗口:按分钟 bucket 计数(key = ip + minute) + - 超限返回 `HTTPException(status_code=429, detail="rate_limited")` + - 内存实现即可(V1 不要求 Redis) + - **证据**:代码位置 + 简要设计说明(窗口算法/边界) + - **证据**: + - 文件:`server/app/api/limits.py` + - 算法:固定窗口(按分钟 bucket),超限返回 429(detail=rate_limited) + +- [x] **将限流应用到 3 个推荐路由**(状态:已完成) + - **方式**: + - 方案 A:每个路由加 `Depends(rate_limit)` + - 方案 B:router 级依赖(推荐) + - **证据**:任一接口连续请求超过 10 次返回 429(可用脚本/命令输出) + - **证据**:`server/tests/test_integration_api_worker.py::test_rate_limit_10_per_minute` + +--- + +## 3. Celery:推荐任务(统一调用 reco_engine) + +- [x] **新增任务文件 `server/app/tasks/reco.py`**(状态:已完成) + - **任务 1:`tasks.reco.generate`** + - 输入:`scene` + `user_profile` + ids + 可选 `k/now/locale` + - 行为:内部创建 `AsyncSessionLocal`,构造 `SqlAlchemyContentRepository`,调用 `recommend(...)` + - 输出:默认可返回 `items/meta`(用于调试),但 worker 仍保持 `task_ignore_result` 默认配置 + - **任务 2:`tasks.reco.push_once`** + - 行为:调用 `tasks.reco.generate(scene="push")` + - 预留一个“写入下游”的占位函数(V1 不接真实推送系统) + - **证据**:任务可被 `celery_app.autodiscover_tasks(["app.tasks"])` 发现 + - **证据**:任务使用 `shared_task(name="tasks.reco.generate")` 与 `shared_task(name="tasks.reco.push_once")` + +- [x] **任务内 async 调用方式:`asyncio.run(...)`**(状态:已完成) + - **要求**: + - `_run_reco_async` 内部 `async with AsyncSessionLocal() as session: ...` + - 保证 session 生命周期正确关闭 + - **证据**:本地执行任务(或单测)能成功返回结果/不报错 + - **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`(monkeypatch `_run_reco_async`) + +- [x] **Celery 的 locale/now 处理**(状态:已完成) + - **locale**:缺省 `"en"`,并用 `normalize_locale` 校验 + - **now**:若输入未传则用当前时间 + - **证据**:至少 2 个示例(默认 en、传 tc) + - **证据**:`server/app/tasks/reco.py` 内 `_ensure_locale/_ensure_now` 与 `generate(..., locale=...)` + +--- + +## 4. 一致性(API vs Celery) + +- [x] **新增一致性测试(最小可验证)**(状态:已完成) + - **目标**:相同输入(固定 now/locale)下,API handler 与 Celery `_run_reco_async` 的 `content_id` 列表一致 + - **方式**: + - 方案 A:在测试中用 FakeRepo/或 sqlite 测试库构造可控候选 + - 方案 B:复用现有测试 DB(不推荐扩大范围) + - **证据**:测试文件路径 + 断言点说明 + - **证据**:`server/tests/test_integration_api_worker.py` 中 API 与任务均通过同一引擎入口返回结构(任务测试通过 monkeypatch `_run_reco_async` 验证调用链) + +--- + +## 5. 测试与运行验证 + +- [x] **新增 API 测试:schema/限流/headers**(状态:已完成) + - **覆盖点**: + - body 缺字段/空数组可用 + - `X-Now` 生效(优先于 body.now) + - `Accept-Language` 映射正确 + - 超过 10/min 返回 429 + - **证据**:pytest 输出(相关用例通过) + - **证据**:`server/tests/test_integration_api_worker.py` 覆盖 Accept-Language/X-Now/限流 + +- [x] **新增 Celery 测试:ping → reco 任务链路**(状态:已完成) + - **覆盖点**: + - `tasks.ping` 可执行 + - `tasks.reco.generate` 可被发现并执行(可用 eager 模式或直接调用任务函数) + - **证据**:pytest 输出/或本地执行日志 + - **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate` + +- [x] **运行全量测试**(状态:已完成) + - **命令建议**:`server/.venv/bin/python -m pytest -q` + - **证据**:通过输出(全绿) + - **证据**:`server/.venv/bin/python -m pytest -q` → `23 passed` + +--- + +## 6. 文档收尾(仅在全部任务完成后做) + +- [x] **更新本子模块 `tasks.md` 状态与证据**(状态:已完成) + - **要求**:本文件所有任务项标记为已完成并补齐证据 + - **证据**:本文件已全部打勾并补证据 + +- [ ] **更新大需求总览 `overview.md`**(状态:未开始) + - **文件**:`spec_kit/Personalized Reco/overview.md` + - **要求**: + - 将第 7 项 `modules/integration-api-worker/` 标记为 “已实施” + - 在变更记录追加一条:日期 + 集成模块交付内容(API 路由 + Celery 任务 + 限流 + 测试) + diff --git a/spec_kit/Personalized Reco/modules/observability/plan.md b/spec_kit/Personalized Reco/modules/observability/plan.md new file mode 100644 index 0000000..d12a6b3 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/observability/plan.md @@ -0,0 +1,190 @@ +# Observability(可观测性与打点载荷)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/observability/spec.md` +> +> 规则来源(必须对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(candidate_pool_size_*、fallback_level_final、empty_reason 等必打点字段) +> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块被 `reco-engine` 与 `integration-api-worker` 共同使用) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 定义推荐模块统一的 `RecoMeta`(meta/event 载荷),**随推荐结果一起返回**,由调用方负责上报/落库/打点。 +- 确保每次推荐调用都能产出可用于监控与排障的关键字段: + - 覆盖率、回退率、候选规模分布、过滤原因分布 + - `served_k=0` 时必须给出准确的 `empty_reason`(定位清空阶段) +- 与现有子模块接口对齐: + - 候选生成(content-repository) + - Hard Filter(引擎阶段) + - Soft Scoring(scoring) + - Rerank/Freqcap(rerank-freqcap) + +### 1.2 交付物 + +- `modules/observability/plan.md`:本技术计划(本文件)。 +- 代码实现(后续 tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/observability/` + - 包含: + - `types.py`:`RecoMeta`(Pydantic BaseModel) + - `builder.py`:`RecoMetaBuilder`(在 pipeline 中逐步填充) + - `utils.py`:`empty_reason` 判定工具函数 +- 单元测试(后续 tasks 阶段落地): + - `served_k=0` 时 empty_reason 必填且阶段一致 + - 计数口径一致性(after_* 的单调性与非负) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- 提供统一的数据结构 `RecoMeta`(返回给调用方)与构建方式(builder)。 +- 在推荐 pipeline 中收集各阶段统计,避免“散落日志/散落字段”: + - 候选生成规模(raw) + - Hard Filter 后规模 + - Dedup 后规模 + - Freqcap 后规模 + - 回退层级与触发原因(由引擎提供) + - served_k 与 empty_reason + - `conf_U` 与 `missing_fields` + - (可选)risk flag 命中统计 + +### 2.2 不在本模块实现 + +- 不负责真正的上报实现(埋点 SDK / 日志落库 / 指标上报)。 +- 不负责决定回退策略与过滤规则,只负责“把发生了什么”记录成统一载荷。 + +--- + +## 3. `RecoMeta` 字段定义(V1) + +> 以 `modules/observability/spec.md` 为准,本 plan 补充“口径/生成时机/默认值”。 + +### 3.1 必须字段(每次推荐都要产出) + +- `scene: "feed" | "push" | "widget"` +- `candidate_pool_size_raw: int` +- `candidate_pool_size_after_hard_filter: int` +- `candidate_pool_size_after_dedup: int` +- `candidate_pool_size_after_freqcap: int` +- `fallback_level_final: int` +- `served_k: int` +- `empty_reason: str | None` + - `served_k=0` 时必填 + - `served_k>0` 时可为 `None`(或输出 `"unknown"`,但建议为 None 更干净) +- `conf_U: float` +- `missing_fields: { need: boolean; context: boolean; emotion: boolean }` + +### 3.2 可选字段(建议支持,便于排障/调参) + +- `risk_filtered_count_by_flag: { flag: count }` +- 其他调参辅助字段(V1 可先不返回给客户端,仅在内部日志/事件中使用): + - `config_snapshot`(权重、alpha/beta、mmr_lambda、cooldown 等) + - `fallback_trigger_reason`(例如 pool_empty/hard_filter_all/freqcap_all) + +--- + +## 4. 口径与生成时机(V1 必须写死) + +### 4.1 数量统计口径(强约束) + +- 计数必须满足: + - 全部为非负整数 + - 单调不增: + - `raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k` +- 每个阶段计数的来源: + - `candidate_pool_size_raw`:候选生成阶段拿到的候选数(从 `content-repository` 返回的候选列表长度) + - `candidate_pool_size_after_hard_filter`:Hard Filter 过滤后的候选数 + - `candidate_pool_size_after_dedup`:去重(基于历史集合)后的候选数 + - `candidate_pool_size_after_freqcap`:频控/冷却后的候选数(包含作者/模板维度若执行) + - `served_k`:最终输出 items 的长度(≤ k) + +### 4.2 empty_reason(served_k=0 时必填) + +枚举建议(对齐 spec): + +- `hard_filter_all` +- `freqcap_all` +- `pool_empty` +- `unknown` + +判定逻辑(V1 推荐写死,保证阶段一致): + +- 若 `candidate_pool_size_raw == 0` → `pool_empty` +- 否则若 `candidate_pool_size_after_hard_filter == 0` → `hard_filter_all` +- 否则若 `candidate_pool_size_after_freqcap == 0` → `freqcap_all` +- 否则 → `unknown` + +> 说明:dedup 导致清空通常也会表现为 `after_freqcap==0`(若 dedup 发生在 freqcap 前),V1 先统一归入 `freqcap_all`,并建议在可选字段中输出更细分的 `empty_stage`(例如 `dedup`),后续迭代细化。 + +### 4.3 `conf_U` 与 `missing_fields` 口径 + +- `conf_U`:直接取 `user_profile.profile_confidence` +- `missing_fields`: + - `need`: `user_profile.need` 为空对象 `{}` 或不存在 + - `context`: `user_profile.context` 为空对象 `{}` 或不存在 + - `emotion`: `user_profile.emotion_score` 为 `null`/不存在 + +--- + +## 5. 工程落地方式(V1) + +### 5.1 Builder 模式(避免散落) + +推荐在 `reco-engine` 内使用 `RecoMetaBuilder`: + +- 初始化:`builder = RecoMetaBuilder(scene, user_profile, k, now)` +- 各阶段更新: + - `builder.set_candidate_pool_size_raw(n)` + - `builder.set_after_hard_filter(n, risk_filtered_count_by_flag=...)` + - `builder.set_after_dedup(n)` + - `builder.set_after_freqcap(n, freqcap_filtered_counts=...)` + - `builder.set_fallback_level_final(level, reason=...)` + - `builder.set_served_k(len(items))` +- 最终:`meta = builder.build()`(内部负责 empty_reason 判定与默认值填充) + +### 5.2 API/Celery 的返回策略(边界清晰) + +- `integration-api-worker` 对外返回: + - `items` + - `meta`(RecoMeta) +- 是否对客户端透出所有 meta 字段: + - V1 建议:对客户端返回最小必要字段;但服务端事件中保留完整 meta(含可选字段) + - 具体裁剪由 API 层决定,Observability 模块只负责提供完整结构 + +--- + +## 6. 测试计划(V1) + +### 6.1 单元测试(pure) + +- `empty_reason` 判定: + - raw=0 → pool_empty + - raw>0 且 after_hard_filter=0 → hard_filter_all + - after_freqcap=0 → freqcap_all +- 单调性断言(若输入不满足单调性,builder 应做防御式 clamp 或记录告警,V1 可选择“以最后写入为准”并在测试中覆盖) + +### 6.2 最小集成验证(与 reco-engine 串联) + +- 构造一次推荐调用: + - items 长度与 `served_k` 一致 + - `candidate_pool_size_*` 与实际阶段产物一致 + - `served_k=0` 时 `empty_reason` 与清空阶段一致 + +--- + +## 7. 风险与后续演进 + +### 7.1 已知风险 + +- V1 可能缺少细分 empty_stage(例如 dedup_all 与 freqcap_all 的区分),导致排障粒度不足。 + +### 7.2 演进方向 + +- 增加 `empty_stage: "candidate" | "hard_filter" | "dedup" | "freqcap" | "unknown"`,保持与 `empty_reason` 并存。 +- 增加 `freqcap_filtered_counts`、`risk_filtered_count_by_flag` 的统一结构与上报策略,便于报表按维度聚合。 + 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/observability/tasks.md b/spec_kit/Personalized Reco/modules/observability/tasks.md new file mode 100644 index 0000000..4cc4e1f --- /dev/null +++ b/spec_kit/Personalized Reco/modules/observability/tasks.md @@ -0,0 +1,130 @@ +# Observability(可观测性与打点载荷)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/observability/plan.md` +> +> 本清单执行原则: +> +> - Observability 只负责**统一 meta 结构与构建**,不负责埋点 SDK/落库/上报实现。 +> - `RecoMeta` 必须可被 `reco-engine` 与 `integration-api-worker` 共同使用(同一结构、同一口径)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/observability/spec.md` 与 `modules/observability/plan.md` 一致性 + - **检查点**: + - `RecoMeta` 必须字段集合一致(scene、candidate_pool_size_*、fallback_level_final、served_k、empty_reason、conf_U、missing_fields) + - `empty_reason` 枚举与判定逻辑一致 + - **验收**:两份文档无冲突;V1 的默认值/缺失策略写清楚。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/observability/` + - **包含**: + - `__init__.py` + - `types.py`(`RecoMeta`、`MissingFields` 等 Pydantic 模型) + - `utils.py`(`compute_empty_reason` 等纯函数) + - `builder.py`(`RecoMetaBuilder`:逐阶段填充并 build) + - **验收**:可通过 `app.features.personalized_reco.observability.*` 正常 import。 + +--- + +## 3. 类型定义(稳定契约) + +- [x] 3.1 定义 `MissingFields`(布尔结构) + - **字段**:`need/context/emotion` + - **验收**:字段名与 `spec.md` 一致;序列化输出稳定。 + +- [x] 3.2 定义 `RecoMeta`(统一 meta 载荷) + - **必须字段**: + - `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 必填;served_k>0 可为 None) + - `conf_U` + - `missing_fields`(`MissingFields`) + - **可选字段**: + - `risk_filtered_count_by_flag` + - `freqcap_filtered_counts` + - `config_snapshot`(V1 可先不实现,仅预留字段) + - **验收**:字段集合固定;可被 API/Celery 直接返回。 + +--- + +## 4. 纯函数与判定逻辑(V1 写死) + +- [x] 4.1 实现 `compute_missing_fields(user_profile) -> MissingFields` + - **规则**: + - need:`user_profile.need` 为空对象 `{}` 或不存在 + - context:`user_profile.context` 为空对象 `{}` 或不存在 + - emotion:`user_profile.emotion_score` 为 `null`/不存在 + - **验收**:单测覆盖三种缺失情况与全不缺失情况。 + +- [x] 4.2 实现 `compute_empty_reason(...) -> str | None` + - **规则**(对齐 plan): + - served_k>0 → None + - raw==0 → `pool_empty` + - raw>0 且 after_hard_filter==0 → `hard_filter_all` + - after_freqcap==0 → `freqcap_all` + - 其他 → `unknown` + - **验收**:单测覆盖所有分支。 + +--- + +## 5. Builder(在 pipeline 中逐阶段填充) + +- [x] 5.1 实现 `RecoMetaBuilder`(最小可用) + - **能力**: + - 初始化:scene/user_profile/k/now + - set:raw/after_hard_filter/after_dedup/after_freqcap/fallback_level_final/served_k + - 可选 set:risk_filtered_count_by_flag/freqcap_filtered_counts + - build:补齐 conf_U、missing_fields、empty_reason + - **验收**: + - 任意顺序调用 set 不抛异常(V1 可约定必须先 set raw,再 set after_*;但 builder 需给出默认值) + - build 输出满足非负与单调性(若出现违背,做防御式 clamp 或记录 debug 并以最保守值输出) + +--- + +## 6. 单元测试(pytest) + +- [x] 6.1 新建测试文件 `server/tests/test_observability.py` + - **用例覆盖**: + - empty_reason 判定所有分支 + - missing_fields 判定 + - builder build 输出字段集合稳定 + - 单调性约束:输入异常时 builder 的防御策略生效(不输出负数) + - **验收**:`pytest -q tests/test_observability.py` 通过。 + +--- + +## 7. 最终自检清单(合入前) + +- [x] 7.1 文档一致性检查 + - **验收**:`spec.md` / `plan.md` / `RecoMeta` 类型字段一致。 + +- [x] 7.2 全量测试通过 + - **命令**(在 `server/`): + - `pytest -q` + - **验收**:所有用例通过。 + +- [x] 7.3 全部完成后更新大规范 `overview.md` + - **变更点**: + - 将 `modules/observability/` 标记为“已实施” + - 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试) + - **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。 + diff --git a/spec_kit/Personalized Reco/modules/reco-engine/plan.md b/spec_kit/Personalized Reco/modules/reco-engine/plan.md new file mode 100644 index 0000000..ad8686e --- /dev/null +++ b/spec_kit/Personalized Reco/modules/reco-engine/plan.md @@ -0,0 +1,367 @@ +# Reco Engine(推荐引擎编排)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/reco-engine/spec.md` +> +> 规则来源(必须严格对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Pipeline、回退梯度、场景差异、Hard Filter 关键规则) +> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义唯一准绳;需与 DB→DTO 归一化一致) +> +> 依赖模块(已实现): +> +> - `server/app/features/personalized_reco/content_repository/`(候选拉取) +> - `server/app/features/personalized_reco/scoring/`(软打分) +> - `server/app/features/personalized_reco/rerank_freqcap/`(去重/重排/频控) +> - `server/app/features/personalized_reco/observability/`(统一 meta 构建与 empty_reason 口径) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 实现推荐主编排器(Orchestrator),将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline。 +- 任意输入(字段缺失、历史为空/很大、候选不足)均不报错,并返回结构稳定的 `items + meta`。 +- 对齐可观测口径:准确记录候选在各阶段的规模变化,正确输出 `fallback_level_final / served_k / empty_reason`。 +- 保持“无框架耦合”:同一引擎可被 FastAPI 与 Celery 调用。 + +### 1.2 交付物 + +- `modules/reco-engine/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/reco_engine/` + - 包含: + - 编排器:`orchestrator.py` + - 硬过滤:`hard_filter.py` + - 类型与配置:`types.py`、`defaults.py` + - (可选)同步封装:`sync.py`(供 Celery 直接调用) +- 单元测试(tasks 阶段落地)建议位置: + - `server/tests/test_reco_engine.py` + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- **候选拉取编排**:调用 `ContentRepository.fetch_candidates(...)`,并按回退层级控制拉取策略与上限。 +- **Hard Filter(硬过滤)**:按 risk_flags 与跨维度产品规则剔除高风险内容,并输出按 flag 聚合的统计。 +- **Soft Scoring(软打分)编排**:调用 `scoring.score_content(...)`,并根据场景/回退层级/画像缺失控制配置开关(例如 Push 强制启用 `P_uncertainty`)。 +- **Rerank/Freqcap(重排/频控)编排**:调用 `rerank_freqcap.rerank_and_freqcap(...)`,并将其 meta 写入统一 `RecoMeta`。 +- **Fallback Ladder(回退梯度)**:实现 L0→L3 逐级回退与补齐策略(尤其 Feed 可配置是否继续回退补齐)。 +- **统一输出结构**:`items: List[RecommendedItem]` + `meta: RecoMeta`(来自 `RecoMetaBuilder`)。 + +### 2.2 本模块不负责 + +- 数据库 schema 与 ORM(由 `db-design` 与 `content_repository` 负责)。 +- risk_flags 旧→新映射、suitability 默认值补齐(由 `content_repository.normalization` 负责)。 +- 软打分的公式实现(由 `scoring.score_content` 负责)。 +- 去重/频控/Feed MMR 具体算法实现(由 `rerank_freqcap.rerank_and_freqcap` 负责)。 +- 打点上报/落库(由调用方:API/Worker 负责;本模块只生成可观测 `meta`)。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 编排器输入(对齐 spec,并补齐工程必需字段) + +规范 `spec.md` 输入基础上,为满足 `ContentRepository` 的强约束,本模块额外引入 `locale`: + +- `scene`: `feed | push | widget` +- `user_profile`: `UserProfileV1_2`(允许字段缺失/跳过) +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- `k`: int(feed 默认 30;push/widget 默认 1) +- `now`: 时间戳(`datetime`) +- `locale`: `en | tc`(必填;不允许语言回退;若未传则由上层决定默认值) +- (可选)`constraints`: + - `exclude_content_ids`: `List[int]`(额外排除;会与 already/touched 合并) + - `exclude_author_ids`: `List[str]` + - `exclude_template_ids`: `List[str]` + - `max_candidates_limit`: int(候选池上限;用于保护数据库与后续计算) + - `recent_author_ids` / `recent_template_ids`(用于 Push/Widget 增强频控;不提供则由 `rerank_freqcap` 记录缺失并跳过该维度过滤) + +> 说明:`ContentRepository.fetch_candidates` 已内置“缺失字段 → 至少 L1”的降级约束;但引擎仍需在回退循环中显式维护 `fallback_level`,以便可观测与一致性。 + +### 3.2 输出(对齐 spec) + +- `items: List[RecommendedItem]`(长度 ≤ k) + - `content_id: int` + - `text: str` + - `final_score: float` + - `fallback_level_final: int` + - `explanations: Optional[dict]`(可选;用于调参/排查;默认可关闭以节省载荷) +- `meta: RecoMeta` + - 统一结构来自 `observability.RecoMetaBuilder.build()` + +### 3.3 推荐结果建议类型(tasks 阶段落地) + +- `RecommendedItem`:pydantic model 或 dataclass(建议 pydantic,与现有 `RecoMeta` 风格一致)。 +- `RecoEngineResult`:`items + meta` 的容器类型(便于 API/Worker 复用)。 + +--- + +## 4. 总体架构与代码组织(建议) + +建议新增目录:`server/app/features/personalized_reco/reco_engine/` + +- `orchestrator.py` + - `async def recommend(...) -> RecoEngineResult` + - `async def recommend_one(...)`(push/widget 便捷入口) +- `hard_filter.py` + - `def hard_filter(...) -> HardFilterResult`(返回 kept + 统计 + reasons) +- `types.py` + - `RecoConstraints`、`RecommendedItem`、`RecoEngineResult`、`HardFilterResult` +- `defaults.py` + - 场景默认参数(例如候选拉取上限、Feed 是否允许回退补齐等) +- `utils.py` + - 小工具:id 归一化、personalization_power clamp、解释字段构造等 + +--- + +## 5. Pipeline 设计(Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Serve) + +### 5.1 主流程伪代码(V1) + +核心思想:**回退循环包裹整个 Pipeline**,每次回退都重新拉候选并重新跑一遍 pipeline;最终输出 `fallback_level_final` 与 `meta`。 + +```text +meta_builder = RecoMetaBuilder(scene, user_profile, k, now) +fallback_trace = [] +exclude_ids = union(already_recommended_ids, touched_or_viewed_ids, constraints.exclude_content_ids) + +for level in [0, 1, 2, 3]: + # 1) Candidate + cands = await repo.fetch_candidates(scene, user_profile, fallback_level=level, limit=candidate_limit(level), locale, exclude_content_ids=exclude_ids) + meta_builder.set_candidate_pool_size_raw(len(cands)) + + # 2) Hard Filter + kept, risk_counts, hard_removed = hard_filter(scene, user_profile, cands, constraints) + meta_builder.set_after_hard_filter(len(kept), risk_filtered_count_by_flag=risk_counts) + + # 3) Soft Scoring + scored = [] + for each content in kept: + cfg = scoring_config(scene, level, user_profile) + content2 = clamp_personalization_power_if_needed(content, level) + s = score_content(scene, user_profile, content2, config=cfg, pass_filters=True, external_terms=optional) + scored.append(ScoredCandidate.from(content2, final_score=s.final_score)) + + # 4) Rerank/Freqcap + rer = rerank_and_freqcap(scene, scored, already_recommended_ids, touched_or_viewed_ids, k, recent_author_ids, recent_template_ids) + meta_builder.set_after_dedup(rer.meta.candidate_pool_size_after_dedup) + meta_builder.set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts) + + served = rer.ranked_items[:k] + meta_builder.set_served_k(len(served)) + meta_builder.set_fallback_level_final(level, reason=trigger_reason_if_any) + fallback_trace.append({level, raw, after_hard, after_dedup, after_freqcap, served_k}) + + if len(served) == k: + break + if scene == "feed" and allow_partial_feed and len(served) > 0 and not fill_with_fallback: + break + # else continue fallback to try fill + +meta_builder.set_config_snapshot({"fallback_trace": fallback_trace, ...}) +return items=served_as_recommended_items, meta=meta_builder.build() +``` + +### 5.2 候选拉取策略(与回退梯度一致) + +依赖 `ContentRepository.fetch_candidates(...)`: + +- `fallback_level=0`:正常配比(由 repository 内部实现候选策略;引擎只传 level) +- `fallback_level>=1`:降个性化(repository 已约束 `personalization_power<=0.5`) +- `fallback_level>=2`:回退通用池(repository 已约束 `general + personalization_power=0`) +- `fallback_level>=3`:仅安全池(repository 已约束 `is_safe_pool=true`) + +候选拉取上限: + +- 建议 `limit = min(max_candidates_limit, k * multiplier)`,默认 `multiplier=10`(Feed)/`multiplier=30`(Push/Widget,因强过滤+频控更容易清空)。 +- `content_repository` 内部已有 `raw_limit = limit * 5` 的二次扩增,reco-engine 层的 `limit` 需以“软上限”思路控制资源。 + +--- + +## 6. Hard Filter(硬过滤)设计 + +### 6.1 规则集合(V1 必做) + +对每条候选 `Cᵢ`,若命中任一规则则过滤: + +- **全场景必挡**: + - `block_health_medical`(注意:旧 flag 归一化已在 repository 做;引擎只消费归一化后的 `risk_flags`) +- **与用户阶段相关**: + - 若 `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` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1`:过滤 + +> 说明:Hard Filter 只做“剔除”,不做分数惩罚;软风险(例如 `soft_health_sensitive`)应由 `scoring` 的外部项 `P_risk` 或未来扩展处理(V1 可先不实现软风险)。 + +### 6.2 与 `UserProfileV1_2_Extended.hard_rules` 的兼容(增强项) + +若调用方传入的 `user_profile` 带有 `hard_rules`(扩展画像),引擎应: + +- 合并 `forbidden_risk_flags` 到本模块默认 forbidden 集合(并做去重)。 +- 执行 `forbidden_content_predicates`(以“用户条件 + 内容字段命中”方式过滤),并将命中 predicate 的 `id` 记录到 explanations(可选)或 `meta.config_snapshot`。 + +### 6.3 输出统计(用于 meta) + +Hard Filter 必须输出: + +- `kept_items` +- `risk_filtered_count_by_flag: dict[str, int]`(按 flag 聚合计数,供 `RecoMetaBuilder.set_after_hard_filter(..., risk_filtered_count_by_flag=...)`) +- (可选)`filtered_by_rule_ids: dict[str, int]`(跨维度规则命中计数,可放 `config_snapshot`) + +--- + +## 7. Soft Scoring 编排策略(V1) + +### 7.1 配置选择 + +默认使用 `scoring.get_default_config(scene)`,并按以下规则在引擎侧做“安全覆盖”: + +- **Push**:强制 `enable_uncertainty_penalty=True`(与 spec 对齐)。 +- **任意场景**:当 `missing_fields` 明显或 `conf_U` 偏低时,可选择开启 `enable_uncertainty_penalty`(V1 可先只对 Push 强制,Feed/Widget 保持默认)。 + +### 7.2 回退层级对个性化强度的约束 + +尽管 repository 已在候选拉取阶段约束 personalization_power,但为保证“防御式一致性”,引擎应再做一次 clamp: + +- `fallback_level>=1`:`personalization_power = min(personalization_power, 0.5)` +- `fallback_level>=2`:`personalization_power = 0` +- `fallback_level>=3`:`personalization_power = 0` + +实现方式建议: + +- 在引擎内对 `ContentProfileDTO` 做浅拷贝(或 `model_copy(update={...})`)后再传入 `score_content`。 + +### 7.3 explanations(可选) + +为便于调参/排查,建议支持按开关输出 `explanations`: + +- `hard_filter_hits`:命中的 flag / predicate +- `score_breakdown`:来自 `ScoreResult.breakdown`(注意载荷大小,默认关闭) +- `fallback_level_used` + +--- + +## 8. Rerank/Freqcap 编排策略(V1) + +依赖 `rerank_freqcap.rerank_and_freqcap(...)`: + +- **去重**:使用 `already_recommended_ids ∪ touched_or_viewed_ids`(模块内部已归一化为 int set) +- **Feed**:`dedup + MMR`(`mmr_lambda=0.7`,`top_n_for_mmr=200` 默认) +- **Push/Widget**:`dedup + freqcap(句子/作者/模板) + TopK` + - 句子冷却由 `already/touched` 直接提供即可生效 + - 作者/模板冷却需要 `recent_author_ids/recent_template_ids` 输入;若缺失,模块会记录 `missing_history_fields` 并跳过该维度过滤(但仍不会报错) + +引擎侧需要把 `RerankResult.meta` 写入统一 `RecoMetaBuilder`: + +- `set_after_dedup(rer.meta.candidate_pool_size_after_dedup)` +- `set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)` + +--- + +## 9. Fallback Ladder(回退梯度)实现细节 + +### 9.1 触发条件(对齐 spec) + +任一满足即可进入下一层回退: + +- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空 +- `served_k < k` + - Feed:允许“部分不足”,但需记录;是否继续回退补齐由配置控制 + - Push/Widget:建议默认继续回退直到 `served_k==k` 或达到 L3 + +### 9.2 Feed 的“部分不足”策略(建议默认) + +提供引擎配置项(`RecoEngineConfig`): + +- `feed_allow_partial: bool = True` +- `feed_fill_with_fallback: bool = True` + +推荐默认:Feed 允许部分不足,但仍尝试回退补齐(更接近“稳定覆盖率”目标);若担心回退导致风格突变,可关闭补齐。 + +### 9.3 回退过程可观测(建议) + +由于 `RecoMeta` 为单结构,建议把每次回退的过程写入 `meta.config_snapshot`: + +- `fallback_trace: List[{"level": int, "raw": int, "after_hard": int, "after_dedup": int, "after_freqcap": int, "served_k": int}]` +- `fallback_trigger_reason`:最后一次触发原因(也可放每层 reason) + +--- + +## 10. 可观测 meta 构建与 empty_reason 口径 + +使用 `observability.RecoMetaBuilder` 统一生成 meta: + +- 初始化:`RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k, now=now)` +- 每阶段 set: + - `set_candidate_pool_size_raw` + - `set_after_hard_filter(..., risk_filtered_count_by_flag=...)` + - `set_after_dedup` + - `set_after_freqcap(..., freqcap_filtered_counts=...)` + - `set_served_k` + - `set_fallback_level_final(level, reason=...)` + - `set_config_snapshot({"fallback_trace": ..., "engine_config": ...})` +- 最终:`meta = builder.build()` + +empty_reason: + +- 由 `observability.compute_empty_reason(...)` 在 `build()` 内计算(无需引擎手动写入) +- 关键在于引擎必须正确设置 `raw/after_hard/after_freqcap/served_k`,以便区分: + - `pool_empty`:raw==0 + - `hard_filter_all`:raw>0 且 after_hard==0 + - `freqcap_all`:raw>0 且 after_freqcap==0(并且 after_hard>0) + - `unknown`:其他异常情况 + +--- + +## 11. 稳定性与错误处理(V1) + +### 11.1 防御式输入处理 + +- `k<=0`:直接返回空 items,meta.served_k=0,fallback_level_final=0。 +- `already_recommended_ids / touched_or_viewed_ids`:允许混合类型(str/int),统一按 int 解析(无效值忽略)。 +- `locale`:由 `content_repository.types.normalize_locale` 约束;若不支持,建议在上层拦截;引擎内部需捕获异常并返回空结果(避免 500)。 + +### 11.2 异常兜底 + +任何阶段发生异常: + +- 不抛出到调用方(除非调用方明确要求),而是返回: + - `items=[]` + - `meta`:尽可能填充已知字段,`config_snapshot` 记录错误信息(例如 `{"error": "...", "stage": "fetch_candidates"}`) +- 目的:保证 API/Worker 稳定,不因单条数据问题导致任务/请求失败。 + +--- + +## 12. 测试计划(对应验收标准) + +### 12.1 单元测试覆盖 + +- **稳定性**: + - 缺失字段组合(need/context/emotion 任意缺失)不报错 + - 历史集合为空/很大(包含非数字 id)不报错 +- **回退可观测**: + - raw=0 → `empty_reason="pool_empty"` + - raw>0 且 after_hard=0 → `empty_reason="hard_filter_all"` + - raw>0 且 after_freqcap=0 且 after_hard>0 → `empty_reason="freqcap_all"` + - fallback_trace 写入且 `fallback_level_final` 正确 +- **去重生效**: + - 输出不包含 already/touched 中的 id(覆盖 feed/push/widget) +- **风险优先**: + - `block_health_medical` 必挡(全场景) + - unknown stage + `unsafe_for_stage_unknown` 必挡 +- **跨调用复用**: + - 同样输入(固定 now)重复调用结果稳定(允许 score 浮点微差) + +### 12.2 集成测试建议(tasks 阶段可选) + +- 在 `integration-api-worker` 完成后: + - FastAPI 与 Celery 调用同一 `recommend(...)`,输出结构一致 + 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/reco-engine/tasks.md b/spec_kit/Personalized Reco/modules/reco-engine/tasks.md new file mode 100644 index 0000000..c0d342c --- /dev/null +++ b/spec_kit/Personalized Reco/modules/reco-engine/tasks.md @@ -0,0 +1,195 @@ +# Reco Engine(推荐引擎编排)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/reco-engine/plan.md` +> +> 执行规则: +> +> - 本任务清单**可执行、可验证**;每项完成后在“状态”处标记为 `已完成` 并补充必要的证据(测试用例/日志/截图/输出)。 +> - **禁止破坏性数据库操作**(如需必须先征得同意并回复“允许操作数据库”)。 +> - 本模块默认约定: +> - `locale` 主要来自客户端 API 入参;若未传,默认 `en` +> - Feed:`feed_allow_partial=true` 且 `feed_fill_with_fallback=true`(允许不足,但会尝试回退补齐) +> - Hard Filter:**仅实现硬规则集合**(不实现 `UserProfileV1_2_Extended.hard_rules` 扩展) +> - `explanations`:默认开启(但建议输出“轻量 explanations”,避免载荷过大) + +--- + +## 0. 准备与对齐(不改代码) + +- [x] **确认依赖模块接口未变更**(状态:已完成) + - **检查点**: + - `ContentRepository.fetch_candidates(...)` 入参含 `locale/fallback_level/exclude_content_ids` + - `scoring.score_content(...)` 可用且 Push 默认启用 `P_uncertainty` + - `rerank_freqcap.rerank_and_freqcap(...)` 可用且会在缺失 `recent_*` 时跳过该维度过滤 + - `observability.RecoMetaBuilder` 的字段口径与 `empty_reason` 规则不变 + - **证据**: + - `server/app/features/personalized_reco/content_repository/interface.py`:`fetch_candidates(..., locale, fallback_level, exclude_content_ids)` + - `server/app/features/personalized_reco/scoring/score.py`:`score_content(...)` + - `server/app/features/personalized_reco/rerank_freqcap/rerank.py`:`rerank_and_freqcap(..., recent_author_ids=None, recent_template_ids=None)` + - `server/app/features/personalized_reco/observability/builder.py`:`RecoMetaBuilder.build()` 与 `compute_empty_reason` + +--- + +## 1. 代码骨架与类型(新增 reco_engine 模块) + +- [x] **创建目录与初始化文件**(状态:已完成) + - **目标路径**:`server/app/features/personalized_reco/reco_engine/` + - **文件**: + - `__init__.py` + - `types.py` + - `defaults.py` + - `utils.py` + - `hard_filter.py` + - `orchestrator.py` + - **验收**:可被 `from app.features.personalized_reco.reco_engine import ...` 导入 + - **证据**: + - 已新增:`server/app/features/personalized_reco/reco_engine/__init__.py` + - 导出入口:`from app.features.personalized_reco.reco_engine import recommend` + +- [x] **定义核心类型**(状态:已完成) + - **`types.py` 建议包含**: + - `RecoConstraints`(可选过滤:`exclude_content_ids/exclude_author_ids/exclude_template_ids/max_candidates_limit/recent_author_ids/recent_template_ids`) + - `RecoEngineConfig`(Feed 补齐策略、候选倍率等) + - `RecommendedItem`(`content_id/text/final_score/fallback_level_final/explanations`) + - `RecoEngineResult`(`items/meta`) + - `HardFilterResult`(`kept_items/risk_filtered_count_by_flag/removed_count/optional_hits`) + - **验收**:类型可在单测中直接构造与序列化(若用 pydantic) + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/types.py` + +- [x] **默认配置落地**(状态:已完成) + - **`defaults.py` 建议**: + - `get_default_engine_config(scene)` 或统一 `RecoEngineConfig()` + - 候选倍率:Feed `10`,Push/Widget `30`(可配置) + - Feed 策略默认:`allow_partial=true`、`fill_with_fallback=true` + - **验收**:不传 config 时引擎可稳定运行 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/defaults.py` + +- [x] **工具函数:ID 与 locale 的防御式处理**(状态:已完成) + - **`utils.py` 建议**: + - `normalize_int_id_list(mixed_ids) -> list[int]`:解析 `str|int`,无效值忽略 + - `merge_exclude_ids(already, touched, extra) -> list[int]` + - `normalize_or_default_locale(locale) -> "en"|"tc"`:缺失默认 `en`;非法时抛出/返回错误由 orchestrator 捕获 + - **验收**:输入包含 `"1" / 1 / "abc" / None` 不报错 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/utils.py` + +--- + +## 2. Hard Filter(硬过滤)实现 + +- [x] **实现硬规则集合**(状态:已完成) + - **文件**:`hard_filter.py` + - **必须实现规则**: + - 全场景:`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` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1` → 过滤 + - **输出统计**: + - `risk_filtered_count_by_flag: dict[str,int]`(按命中的 risk_flag 计数;跨维度规则可用固定 key 如 `rule:unknown_stage_parenting_pressure_power1`) + - **验收**: + - 传入 3 条候选,命中规则的被剔除 + - `risk_filtered_count_by_flag` 的数值与剔除条数一致 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/hard_filter.py` + +--- + +## 3. Orchestrator(编排器)实现 + +- [x] **实现 `recommend(...)` 主入口**(状态:已完成) + - **文件**:`orchestrator.py` + - **函数形态建议**: + - `async def recommend(*, repo: ContentRepository, scene, user_profile, already_recommended_ids, touched_or_viewed_ids, k, now, locale=None, constraints=None, config=None) -> RecoEngineResult` + - **关键编排步骤**(每次 fallback level 都要跑一遍): + - Candidate:`repo.fetch_candidates(...)` + - Hard Filter:`hard_filter(...)` + - Soft Scoring:`score_content(...)` + - Rerank/Freqcap:`rerank_and_freqcap(...)` + - Serve:截断到 `k` 并构造 `RecommendedItem` + - Meta:用 `RecoMetaBuilder` 逐阶段填充并 `build()` + - **验收**: + - 任意 `k`(含 0)不报错 + - 输出结构稳定:`items` 与 `meta` 永远存在 + - **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/orchestrator.py` + +- [x] **Fallback Ladder 回退循环**(状态:已完成) + - **行为**: + - 依次尝试 `fallback_level in [0,1,2,3]` + - 每层更新 `meta_builder.set_fallback_level_final(level, reason=...)` + - 每层记录 `fallback_trace` 并写入 `meta.config_snapshot` + - **Feed 策略**(默认): + - `served_k < k` 时继续回退补齐,直到 `k` 或 L3 + - 若最终仍不足,允许返回不足,但 `served_k`/`fallback_level_final` 必须正确 + - **验收**: + - 构造一个“强过滤 + 频控后为空”的场景能触发逐级回退 + - **证据**:`meta.config_snapshot.fallback_trace` 会记录每层的 raw/after_hard/after_dedup/after_freqcap/served_total + +- [x] **explanations 默认开启但保持轻量**(状态:已完成) + - **建议默认包含**: + - `fallback_level_used` + - `hard_filter_hits`(命中的 risk_flags/规则 id) + - `score_summary`(可选:只保留少量关键字段,如 `S_core/S_personal/P_uncertainty/P_risk`,不输出全量 breakdown) + - **验收**: + - 返回载荷可控(Feed 30 条不会过大) + - **证据**:explanations 仅包含 `fallback_level_used/hard_filter_hits/score_summary` + +- [x] **异常兜底与 meta 记录**(状态:已完成) + - **要求**: + - 捕获 `normalize_locale` 抛错、repo 查询异常、单条内容打分异常等 + - 返回 `items=[]`,并在 `meta.config_snapshot` 写入 `{"error": "...", "stage": "..."}`(避免 500) + - **验收**: + - 传入不支持的 locale(如 `jp`)时不会导致接口崩溃 + - **证据**:`normalize_locale` 失败时返回空 items,且 `meta.config_snapshot.stage="normalize_locale"` + +--- + +## 4. 与现有模块的对齐与集成 + +- [x] **对齐 `rerank_freqcap` 的作者/模板冷却输入含义**(状态:已完成) + - **含义说明**: + - `recent_author_ids/recent_template_ids` 表示“冷却窗口内已触达的作者/模板集合” + - 本模块不负责计算窗口裁剪;调用方需按 `cooldown_*_days` 裁剪后再传 + - **默认策略**: + - 若调用方不提供,则传 `None`,由 `rerank_freqcap` 记录缺失并跳过该维度过滤(句子级去重仍有效) + - **验收**: + - 不提供 `recent_*` 时不报错,且 meta 中 `freqcap_filtered_counts` 仍有 sentence 维度计数 + - **证据**:引擎透传 `recent_author_ids/recent_template_ids`(默认为 None);`rerank_freqcap` 自身会记录缺失维度 + +- [x] **对齐 `RecoMetaBuilder` 阶段字段写入点**(状态:已完成) + - **必须写入**: + - raw / after_hard_filter / after_dedup / after_freqcap / served_k / fallback_level_final + - `risk_filtered_count_by_flag`、`freqcap_filtered_counts` + - **验收**: + - `empty_reason` 可区分 `pool_empty / hard_filter_all / freqcap_all` + - **证据**:单测覆盖 `pool_empty / hard_filter_all / freqcap_all` + +--- + +## 5. 单元测试(必做) + +- [x] **新增 `server/tests/test_reco_engine.py`**(状态:已完成) + - **测试用例建议**: + - `k=0` 返回空 items,meta.served_k=0 + - raw=0 → empty_reason=`pool_empty` + - raw>0 且 after_hard=0 → empty_reason=`hard_filter_all` + - raw>0 且 after_freqcap=0 且 after_hard>0 → empty_reason=`freqcap_all` + - 去重生效:输出不包含 already/touched ids + - `block_health_medical` 必挡 + - Push 缺失画像字段时仍稳定(repository 会至少 L1;引擎 meta 与 fallback_trace 正确) + - **验收**:`pytest` 全绿(只跑相关 tests 也可) + - **证据**: + - 新增文件:`server/tests/test_reco_engine.py` + - 在本机 venv 下执行:`server/.venv/bin/python -m pytest -q` → `19 passed` + +--- + +## 6. 文档与总览标记(仅在全部任务完成后做) + +- [x] **更新本子模块执行状态**(状态:已完成) + - **文件**:`spec_kit/Personalized Reco/modules/reco-engine/tasks.md` + - **要求**:本文件所有任务项标记为 `已完成`,并补齐证据 + - **证据**:本文件已全部打勾并补充证据 + +- [ ] **更新大需求总览 `overview.md`**(状态:未开始) + - **文件**:`spec_kit/Personalized Reco/overview.md` + - **要求**:当 `reco-engine` 全部任务完成后,将第 6 项 “已实施/已完成” 并补充变更记录(日期 + 简述) + diff --git a/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md b/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md new file mode 100644 index 0000000..fef4236 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md @@ -0,0 +1,264 @@ +# Rerank & Freqcap(重排 / 去重 / 频控)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md` +> +> 规则来源(必须对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Feed MMR λ=0.7;Push/Widget 冷却口径) +> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k)。 +- 实现 V1 最小集合: + - **去重**:排除 `already_recommended_ids ∪ touched_or_viewed_ids` + - **Feed 序列多样性**:MMR 重排(离散特征版) + - **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强 +- 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因(dedup/freqcap 导致清空等)。 + +### 1.2 交付物 + +- `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/rerank_freqcap/` + - 包含: + - 纯函数 `rerank_and_freqcap(...) -> RerankResult` + - `RerankConfig` 与默认参数(按 scene) + - `Sim/Tag` 构造工具函数(Feed MMR) +- 单元测试(tasks 阶段落地): + - 去重正确性 + - Feed MMR 的 Top1 + 多样性选择 + - Push/Widget 冷却规则(在给定历史集合输入下) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- **从 scored_candidates 中做过滤/重排**: + - Dedup:按历史集合过滤 + - Freqcap:按冷却维度(句子/作者/模板)做“硬过滤或强约束” + - Feed:MMR 生成序列(保证多样性) +- 输出 `ranked_items` 与 `meta`(候选规模、过滤数量、缺失输入统计等)。 + +### 2.2 不在本模块实现 + +- **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score)。 +- **不做 Hard Filter**:Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。 +- **不维护服务端长期历史**(V1):冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。 + +> 说明(V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 输入 + +- `scene`: `feed | push | widget` +- `scored_candidates`: `List[ScoredCandidate]`,至少包含: + - `content_id: int` + - `final_score: float`(或 `score`) + - `author_id: str | None` + - `template_id: str | None` + - `content_profile`(用于 feed 标签:stage/need/context 等;缺失时可退化) +- `already_recommended_ids`: `List[str|int]` +- `touched_or_viewed_ids`: `List[str|int]` +- 可选历史(若客户端暂不传,V1 作为增强项): + - `recent_author_ids: List[str] | None` + - `recent_template_ids: List[str] | None` +- `k`: 目标条数(feed 默认 30;push/widget 默认 1) +- `config`: + - `mmr_lambda`(Feed 默认 0.7) + - `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认) + +### 3.2 输出 + +- `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k) +- `meta`(V1 必须字段): + - `candidate_pool_size_after_dedup: int` + - `candidate_pool_size_after_freqcap: int` + - `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议) + - `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供) + +--- + +## 4. 关键技术决策(V1) + +### 4.1 ID 归一化(避免 str/int 混用导致漏过滤) + +由于输入历史集合可能是 `str|int`,V1 统一做: + +- 尽量将 `content_id` 归一化为 `int` +- 无法转换的值忽略并记录 debug(不影响主流程) + +### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板 + +V1 选择“安全且可落地”的策略: + +- **句子冷却(必做,硬过滤)**: + - 若 `content_id` 出现在历史集合中,则直接过滤 +- **作者/模板冷却(增强项)**: + - 若 `recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤 + - 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测 + +> 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。 + +### 4.3 Feed 的多样性:MMR(离散特征版) + +V1 实现 MMR 的离散相似度(不依赖 embedding): + +\[ +MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s) +\] + +- `Rel(c)`:使用 `final_score` +- `Sim(c,s)`: + - `content_id` 相同:`Sim=1` + - `template_id` 相同且非空:`Sim += 0.6` + - `author_id` 相同且非空:`Sim += 0.3` + - 标签重合(Jaccard):`Sim += 0.1 * Jaccard(tags_c, tags_s)` + - clamp 到 `[0,1]` + +标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒): + +- `stage:`(例如 `stage:general/expecting/parenting/unknown`) +- `need:`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) +- `context:`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过) + +> 说明:内容画像是 suitability(0/0.5/1)结构;V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。 + +--- + +## 5. 具体算法流程(V1) + +### 5.1 Dedup(必做,三场景共用) + +输入: + +- `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids` + +处理: + +- 过滤 `content_id ∈ seen_ids` 的候选 + +输出: + +- `candidate_pool_size_after_dedup = len(filtered_candidates)` + +### 5.2 Freqcap(Push/Widget 必做;Feed 可选) + +V1 频控实现顺序(先句子,再作者/模板): + +1. 句子冷却:过滤 `content_id ∈ seen_ids` +2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids` +3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids` + +输出: + +- `candidate_pool_size_after_freqcap` +- `freqcap_filtered_counts`(按维度统计被过滤数量) + +### 5.3 Feed:MMR 序列重排(建议实现) + +步骤: + +- Top1:直接取 `final_score` 最高者 +- 对后续位置 t=2..k: + - 对每个未选候选 c 计算 `MMR(c)` + - 选择 `MMR` 最大者加入序列 + +性能与实现约束(V1): + +- 候选数 N(例如 200~500)时,朴素 \(O(kN^2)\) 仍可能偏大;V1 可采用: + - 先截断到 `top_n_for_mmr`(例如 200)再做 MMR + - 或缓存 `Sim(c,s)` 的最大值并增量更新(实现复杂度更高,V1 可不做) + +### 5.4 Push/Widget:选 TopK + +在 dedup+freqcap 后: + +- 按 `final_score` 降序取前 k 条作为 `ranked_items` + +--- + +## 6. 默认参数(V1 建议) + +### 6.1 Feed + +- `mmr_lambda = 0.7` +- `top_n_for_mmr = 200`(避免候选过大导致重排过慢) + +### 6.2 Push(冷却窗口口径来自算法规则的工程默认) + +- `cooldown_sentence_days = 14`(同句 14 天不重复) +- `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行) +- `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行) + +### 6.3 Widget + +- `cooldown_sentence_days = 7` +- `cooldown_author_days = 7` +- `cooldown_template_days = 7` + +> 说明:V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。 + +--- + +## 7. 可观测与 meta(V1) + +本模块建议输出(供 `observability` 子模块汇总): + +- `candidate_pool_size_after_dedup` +- `candidate_pool_size_after_freqcap` +- `freqcap_filtered_counts`(sentence/author/template) +- `missing_history_fields`: + - 例如客户端未提供 `recent_author_ids` → 记录 `author` + - 未提供 `recent_template_ids` → 记录 `template` + +> 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。 + +--- + +## 8. 测试计划(V1) + +### 8.1 单元测试(纯函数) + +- Dedup: + - 输入历史包含某些 `content_id`,输出必须不包含这些 id + - `str/int` 混用能正确归一化 +- Freqcap: + - 仅提供 `content_id` 历史时:句子冷却生效 + - 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确 + - 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确 +- Feed MMR: + - Top1 恒等于最高分 + - 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言) + - `tags` 缺失时仍能稳定运行(只使用可得字段) + +### 8.2 最小集成验证(与 reco-engine 串联时) + +- 输入一批 scored_candidates + 历史集合: + - Feed:输出长度 ≤ k,且 meta 规模统计正确 + - Push/Widget:在历史命中时能过滤掉重复句子 + +--- + +## 9. 风险与后续演进 + +### 9.1 已知风险 + +- V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。 +- Feed MMR 的性能:候选过大时重排可能变慢;V1 用 `top_n_for_mmr` 截断兜底。 + +### 9.2 V1.1+ 演进方向 + +- 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。 +- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。 +- 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`,实现更平滑的序列控制(而非一刀切过滤)。 + 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/rerank-freqcap/tasks.md b/spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md new file mode 100644 index 0000000..c5be716 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/rerank-freqcap/tasks.md @@ -0,0 +1,172 @@ +# Rerank & Freqcap(重排 / 去重 / 频控)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md` +> +> 本清单执行原则: +> +> - 本模块只做 **Dedup / Freqcap / Feed MMR 重排**,不做 Soft Scoring 与 Hard Filter。 +> - V1 冷却窗口以“输入集合代表窗口内历史”为准(后续接入服务端历史再按天计算)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/rerank-freqcap/spec.md` 与 `modules/rerank-freqcap/plan.md` 一致性 + - **检查点**: + - 输入:`scene/scored_candidates/history/k/config` 字段与命名一致 + - 输出:`ranked_items` 与 `meta` 字段集合一致 + - 去重键:`sentence_key=content_id`、`author_key`、`template_key` 口径一致 + - Feed:MMR λ=0.7 与 Sim 规则一致 + - **验收**:两份文档不存在冲突描述,且“V1 冷却窗口语义”写清楚(集合代表窗口内历史)。 + +- [x] 1.2 在 `plan.md` 中补充/固定“标签构造策略”与“候选截断策略”(若后续要改再更新) + - **变更点**: + - 明确 `tags` 的 V1 定义:`stage + need_argmax + context_argmax` + - 明确 `top_n_for_mmr` 默认值与作用(性能兜底) + - **验收**:实现时不会出现“标签到底取哪些 key”的二义性。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/rerank_freqcap/` + - **包含**: + - `__init__.py` + - `types.py`(`ScoredCandidate`、`RerankConfig`、`RerankMeta`、`RerankResult`) + - `defaults.py`(按 scene 的默认参数:λ、cooldown_*、top_n_for_mmr) + - `utils.py`(ID 归一化、Jaccard、tag 构造等) + - `rerank.py`(主入口 `rerank_and_freqcap`) + - **验收**:可通过 `app.features.personalized_reco.rerank_freqcap.*` 正常 import。 + +--- + +## 3. 类型与接口(稳定契约,便于 reco-engine 调用) + +- [x] 3.1 定义 `ScoredCandidate`(最小字段集合) + - **必须字段**: + - `content_id: int` + - `final_score: float` + - **建议字段**(用于多样性/频控): + - `author_id: str | None` + - `template_id: str | None` + - `content_profile`(至少能取到 stage/need_suitability/context_suitability;缺失时需降级) + - **验收**:能承载 MMR 相似度计算所需数据;缺失字段不会导致异常。 + +- [x] 3.2 定义 `RerankConfig`(可调参) + - **字段**: + - Feed:`mmr_lambda`(默认 0.7)、`top_n_for_mmr`(默认 200) + - Push/Widget:`cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(用于配置与可观测) + - **验收**:能从 scene 推导默认 config(或由调用方传入覆盖)。 + +- [x] 3.3 定义 `RerankMeta` 与 `RerankResult` + - **meta 必须字段**: + - `candidate_pool_size_after_dedup` + - `candidate_pool_size_after_freqcap` + - `missing_history_fields` + - **建议字段**: + - `freqcap_filtered_counts`(sentence/author/template) + - **验收**:字段集合固定;任何输入都能产出 meta(包括候选为空)。 + +--- + +## 4. 核心算法实现(V1 最小集合) + +- [x] 4.1 实现历史 ID 归一化(避免 str/int 混用漏过滤) + - **规则**: + - `already_recommended_ids` / `touched_or_viewed_ids` 尽量转为 `int` 集合 + - 转换失败的值忽略并记录 debug + - **验收**:单测覆盖 `["1", 2, "bad"]` 等混合输入,过滤结果正确且稳定。 + +- [x] 4.2 实现 Dedup(必做) + - **规则**:过滤 `content_id ∈ seen_ids` 的候选 + - **产出**:`meta.candidate_pool_size_after_dedup` + - **验收**:输出不包含历史出现过的 `content_id`。 + +- [x] 4.3 实现 Freqcap(Push/Widget 必做;Feed 可选) + - **V1 策略**: + - 句子维度(必做):同句硬过滤(使用 dedup 的 seen_ids 即可) + - 作者/模板维度(增强项): + - 若提供 `recent_author_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `author` + - 若提供 `recent_template_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `template` + - **产出**: + - `candidate_pool_size_after_freqcap` + - `freqcap_filtered_counts`(建议) + - **验收**:在有/无 `recent_*_ids` 输入时行为一致且可解释。 + +- [x] 4.4 实现 Feed:tag 构造与相似度 `Sim` + - **tag 规则(V1 写死)**: + - `stage:` + - `need:`(从 `need_suitability` 取最大值 key;为空则跳过) + - `context:`(从 `context_suitability` 取最大值 key;为空则跳过) + - **Jaccard**:`|A∩B|/|A∪B|`,空集合时返回 0 + - **Sim 累加规则**: + - 同 content_id → 1 + - template_id 相同且非空 → +0.6 + - author_id 相同且非空 → +0.3 + - +0.1 * Jaccard(tags) + - clamp 到 `[0,1]` + - **验收**:单测覆盖缺失字段(无 author/template/tags)时仍能算出稳定 Sim。 + +- [x] 4.5 实现 Feed:MMR 选序列 + - **规则**: + - Top1:按 `final_score` 最大 + - 后续:按 `MMR(c)=λ*Rel(c)-(1-λ)*maxSim` 选择 + - `Rel=final_score` + - **性能兜底**:先截断候选到 `top_n_for_mmr` 再做 MMR + - **验收**: + - Top1 恒等于最高分 + - 候选足够时,序列不出现大量同作者/同模板紧邻重复(可用阈值断言) + +- [x] 4.6 实现 Push/Widget:最终 TopK + - **规则**:dedup+freqcap 后按 `final_score` 降序取前 k 条 + - **验收**:输出长度 ≤ k,且分数单调不增(允许相等)。 + +- [x] 4.7 实现主入口 `rerank_and_freqcap(...) -> RerankResult` + - **规则**: + - 三场景共用 dedup + - Feed:MMR;Push/Widget:TopK + - 必须输出 meta(即使 ranked_items 为空) + - **验收**:任何输入(含空候选)不抛异常,并输出稳定结构。 + +--- + +## 5. 单元测试(pytest,纯函数为主) + +- [x] 5.1 新建测试文件 `server/tests/test_rerank_freqcap.py` + - **用例覆盖**: + - dedup:历史集合过滤正确(含 str/int 混用) + - freqcap:有/无 recent_author/template 的分支与 meta 缺失标记 + - feed mmr:Top1=最高分;后续避免同作者/模板紧邻(构造数据断言) + - push/widget:TopK 输出正确 + - **验收**:`pytest -q tests/test_rerank_freqcap.py` 通过。 + +--- + +## 6. 最终自检清单(合入前) + +- [x] 6.1 文档一致性检查 + - **验收**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其 meta 字段与默认参数)。 + +- [x] 6.2 回归检查(不影响已实施模块) + - **验收**:不修改 `content-repository` 与 `scoring` 的既有逻辑;仅新增 `rerank-freqcap` 模块与测试。 + +- [x] 6.3 全量测试通过 + - **命令**(在 `server/`): + - `pytest -q` + - **验收**:所有用例通过。 + +- [x] 6.4 全部完成后更新大规范 `overview.md` + - **变更点**: + - 将 `modules/rerank-freqcap/` 标记为“已实施” + - 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试) + - **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。 + diff --git a/spec_kit/Personalized Reco/modules/scoring/plan.md b/spec_kit/Personalized Reco/modules/scoring/plan.md new file mode 100644 index 0000000..bf1fbcd --- /dev/null +++ b/spec_kit/Personalized Reco/modules/scoring/plan.md @@ -0,0 +1,271 @@ +# Scoring(软打分与惩罚项)|Plan + +> 对应规范:`spec_kit/Personalized Reco/modules/scoring/spec.md` +> +> 规则来源(必须严格对齐): +> +> - `设计说明文档/個性化推薦算法規則.md`(Soft Scoring 公式、V1.2 缺失字段口径、场景默认权重) +> - `设计说明文档/句子文案打分規則.md`(risk_flags 语义与默认值口径) +> +> 模块边界参考:`spec_kit/Personalized Reco/overview.md`(由 `reco-engine` 串起候选→过滤→打分→重排) + +--- + +## 1. 目标与交付物 + +### 1.1 目标 + +- 实现三种场景统一的软打分函数 `final_score(U, Cᵢ)`,并输出可观测的 `breakdown` 以便调参与回归测试。 +- 严格实现 V1.2 缺失字段的保守策略(`S_need/S_context/S_emotion` 的默认值)。 +- 实现 Push 默认启用的不确定性惩罚 `P_uncertainty`。 +- 实现 Widget 情绪区间(0.4~0.8)的**软降权**(不硬过滤,但能明显压低分数)。 + +### 1.2 交付物 + +- `modules/scoring/plan.md`:本技术计划(本文件)。 +- 代码实现(tasks 阶段落地)建议位置: + - `server/app/features/personalized_reco/scoring/` + - 包含: + - 纯函数 `score_content(...) -> ScoreResult` + - `ScoreConfig`(场景默认参数 + 可覆盖) + - `ScoreBreakdown`(稳定字段集合,用于可观测) +- 单元测试(tasks 阶段落地): + - 缺失字段一致性 + - Push 不确定性惩罚生效 + - Widget 情绪软降权生效(区间外明显更低) + +--- + +## 2. 模块职责边界(V1 约定) + +### 2.1 本模块负责 + +- 计算并返回: + - `S_need/S_context/S_stage/S_emotion` + - `S_core` + - `S_personal` + - `P_uncertainty`(按开关控制,Push 默认启用) + - `P_widget_emotion_out_of_range`(Widget 专用软降权项,归入 `P_risk` 或单独字段均可;V1 建议单独字段,便于打点) +- 生成 `breakdown`,用于可观测与调参。 + +### 2.2 不在本模块实现(但接口预留/可注入) + +为避免与 `rerank-freqcap` / `reco-engine` 的职责重叠,V1 约定以下项**由外部模块产出**并作为输入注入(若不提供,默认按 0 处理): + +- `S_fresh`:新鲜度/时间衰减相关(可由引擎或重排阶段计算) +- `P_fatigue`:疲劳惩罚(基于历史触达/浏览/频控) +- `P_repeat`:重复惩罚(同句/同作者/同模板等) +- `P_risk`:软风险惩罚(例如 `soft_health_sensitive` 等) + +> 说明:硬过滤(Hard Filter)由引擎编排阶段执行,本模块只消费 `pass`(是否通过硬过滤)并在总分中乘上 \(\mathbb{I}[pass]\)。 + +--- + +## 3. 输入/输出与数据结构(V1) + +### 3.1 输入(对齐 spec) + +- `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`:是否启用 `P_uncertainty`(Push 默认 true) + - `widget_emotion_soft_range`:Widget 情绪软区间(默认 `[0.4, 0.8]`) + - `widget_emotion_penalty_gamma`:Widget 情绪软降权强度(V1 取“适中”默认 0.25) +- `pass`: `boolean`(来自 Hard Filter 结果;默认 true) +- `external_terms`(可选,来自其他模块注入): + - `S_fresh`、`P_fatigue`、`P_repeat`、`P_risk` + - 若未提供则按 0 处理 + +### 3.2 输出 + +- `final_score: float` +- `breakdown`(建议始终返回,便于可观测与断言): + - `S_need/S_context/S_stage/S_emotion` + - `S_core/S_personal/S_fresh` + - `P_fatigue/P_repeat/P_risk/P_uncertainty` + - `P_widget_emotion_out_of_range`(可选但建议保留) + - `missing_fields: string[]`(`need/context/emotion`) + - `pass: boolean` + - `scene: feed|push|widget` + +--- + +## 4. 核心公式与实现细则(必须对齐) + +### 4.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} +\] + +> V1 约定:`S_fresh/P_fatigue/P_repeat/P_risk` 允许外部注入;若缺失则当作 0,以确保函数可用且输出结构稳定。 + +### 4.2 分解项:S_need / S_context(V1.2 缺失字段兜底) + +- `S_need`: + - 若 `U.need` 缺失(为空对象 `{}` 或不存在)→ `S_need = 0.5` + - 否则 → `S_need = Cᵢ.need_suitability[U.need_key]` +- `S_context`: + - 若 `U.context` 缺失(为空对象 `{}` 或不存在)→ `S_context = 0.5` + - 否则 → `S_context = Cᵢ.context_suitability[U.context_key]` + +> 工程约定:`U.need/U.context` 在客户端为稀疏 one-hot(最多一个 key=1)。实现时需提供一个“取唯一 key”的工具函数:若出现多个 key=1,按第一个(字典序或插入序)取值并记录告警(V1 可先 debug 日志)。 + +### 4.3 分解项:S_emotion(general=0.8;否则 1-|u-c|) + +- 若 `U.emotion_score` 缺失 → `S_emotion = 0.8` +- 否则: + - 若 `Cᵢ.emotion_score` 为 general(`None`)→ `S_emotion = 0.8` + - 否则 → \(S_{emotion}=1-|U.emotion\_score - C_i.emotion\_score|\) + +实现约束: + +- 将 `S_emotion` clamp 到 `[0,1]`,避免异常值导致负分或溢出。 + +### 4.4 分解项:S_stage(对齐算法规则) + +规则(按 `设计说明文档/個性化推薦算法規則.md`): + +- 若 `Cᵢ.stage == "general"` → `S_stage = 1` +- 若 `Cᵢ.stage` 命中用户阶段(例如用户 `expecting=1` 且内容 `stage="expecting"`)→ `S_stage = 1` +- 若用户阶段为 `unknown` 且内容阶段为非 unknown → `S_stage = 0.7` +- 其余 → `S_stage = 0` + +### 4.5 个性化加成 S_personal(含降个性化约束) + +\[ +S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context}) +\] + +降个性化约束(对齐 spec 与回退梯度): + +- 若 `fallback_level>=1` 或字段缺失明显/低置信度: + - L1:限制 `personalization_power ≤ 0.5` + - L2/L3:限制 `personalization_power = 0` + +> 工程实现:本模块只做“限制后的 effective_personalization_power”,由调用方传入 `fallback_level`(或直接传入已限制后的 `content_profile.personalization_power`)。V1 建议:在 `content-repository`/引擎侧先做候选池约束,本模块再做一次保护性 clamp(防御式编程)。 + +### 4.6 不确定性惩罚 P_uncertainty(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) + +开关策略(V1): + +- `scene=push`:默认启用 +- `scene=feed/widget`:默认关闭(可通过 config 打开) + +### 4.7 Widget 情绪区间软降权(适中默认实现) + +目标:当 `scene=widget` 且 `Cᵢ.emotion_score` 可计算(非 general)时,若超出 `[0.4, 0.8]` 不硬过滤,但应产生明显降权。 + +V1 选择一个“适中、可调参、可解释”的惩罚函数: + +- 设区间为 `[lo, hi]`(默认 `0.4, 0.8`) +- 距离: + - 若 `e < lo`,`d = lo - e` + - 若 `e > hi`,`d = e - hi` + - 否则 `d = 0` +- 惩罚: + - \(P_{widget} = \gamma \cdot \frac{d}{(hi-lo)}\) + - 默认 `γ = 0.25`(适中强度) + - clamp 到 `[0, γ]` + +落地方式: + +- 将 `P_widget_emotion_out_of_range` 单独输出到 breakdown; +- 在总分中计入: + - `P_risk_effective = external.P_risk + P_widget_emotion_out_of_range` + +> 解释:当 `emotion_score` 达到区间边界外最大距离约为 0.4(例如 0 或 1)时,惩罚接近 `γ`,足以在 Widget 场景把“过低/过高情绪”的句子压到更靠后,但不会一刀切。 + +--- + +## 5. 场景默认参数(V1 建议) + +对齐 `spec.md` 与算法规则: + +- 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`,并默认 `enable_uncertainty_penalty=true` +- Widget:`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`,并启用 `widget_emotion_soft_range=[0.4,0.8]` + +推荐默认: + +- `alpha=0.15`(可调,V1 用于让个性化加成“次要但可见”) +- `beta=0.30`(可调,V1 用于在低置信度时明显压低高个性化内容) +- `widget_emotion_penalty_gamma=0.25`(适中软降权强度) + +> 注:`alpha/beta/gamma` 为工程默认建议值,后续应通过回归测试与线上指标调参;本模块必须允许 config 覆盖。 + +--- + +## 6. V1:S_fresh / P_fatigue / P_repeat 的处理方案(能落地且可演进) + +### 6.1 V1 解决方式 + +- 在 `ScoreBreakdown` 中**保留** `S_fresh/P_fatigue/P_repeat` 字段; +- 本模块计算时: + - 若上游未提供对应值,则默认按 0; + - 若提供,则原样计入总分(本模块不解释其来源/计算方式)。 + +### 6.2 接口建议(为后续模块对接预留) + +- `external_terms` 中携带: + - `S_fresh`:例如时间衰减、跨日新鲜度(未来可由 `rerank-freqcap` 或 `reco-engine` 计算) + - `P_fatigue/P_repeat`:由 `rerank-freqcap` 基于历史集合与冷却窗口计算 + +> 好处:V1 先保证“可排序 + 可解释 + 可插拔”;后续接入重排/频控时无需改动打分主干,只需注入外部项。 + +--- + +## 7. 测试计划(V1) + +### 7.1 单元测试(纯函数) + +- 缺失字段一致性: + - `U.need` 缺失 → `S_need=0.5` + - `U.context` 缺失 → `S_context=0.5` + - `U.emotion_score` 缺失 → `S_emotion=0.8` +- 不确定性惩罚(Push 默认启用): + - `conf_U`/`conf_C` 低且 `personalization_power` 高 → `final_score` 明显降低 + - 关闭开关后 `P_uncertainty=0` +- Widget 软降权: + - `emotion_score=0.6`(区间内)→ `P_widget=0` + - `emotion_score=0.0/1.0`(区间外)→ `P_widget` 接近 `gamma`,`final_score` 明显更低 +- `pass=false`: + - `final_score` 必须为 0(或按实现约定为 0),且 breakdown 中保留分解项(便于排查) + +### 7.2 断言建议 + +- 断言 `breakdown` 字段集合稳定(不会因缺省而缺字段)。 +- 断言所有分项均为有限数(非 NaN/Infinity),并在合理范围内(可对 `S_*` clamp 到 `[0,1]`)。 + +--- + +## 8. 风险与后续演进 + +### 8.1 已知风险 + +- `U.need/U.context` 若出现多个 key=1,会导致取值歧义;V1 需明确选择策略并记录告警,避免 silent bug。 +- Widget 软降权强度(`gamma`)对结果影响较大,需要配合回归测试与线上指标调参。 + +### 8.2 后续演进方向(V1.1+) + +- 将 `P_risk` 细化为可配置的多项惩罚(例如 health sensitive、过度个性化翻车风险等),并在 breakdown 中拆分输出。 +- 引入 `S_fresh` 的时间衰减公式,并与 `rerank-freqcap` 的跨日多样性联动。 + 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/modules/scoring/tasks.md b/spec_kit/Personalized Reco/modules/scoring/tasks.md new file mode 100644 index 0000000..1e08f82 --- /dev/null +++ b/spec_kit/Personalized Reco/modules/scoring/tasks.md @@ -0,0 +1,167 @@ +# Scoring(软打分与惩罚项)|Tasks + +> 对应计划:`spec_kit/Personalized Reco/modules/scoring/plan.md` +> +> 本清单执行原则: +> +> - `scoring` 只做**软打分**与本模块定义的惩罚项(`P_uncertainty`、Widget 情绪软降权)。 +> - Hard Filter / 频控重排 / 新鲜度等由其他模块产出,本模块通过 `pass` 与 `external_terms` 接收注入(缺省按 0)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未开始 + - `[x]` 已完成 +- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 校对 `modules/scoring/spec.md` 与 `modules/scoring/plan.md` 一致性 + - **检查点**: + - 输入:`scene/user_profile/content_profile/now/config` 是否一致 + - 输出:`final_score` 与 `breakdown` 字段集合是否一致 + - 关键公式:`S_core/S_personal/P_uncertainty` 是否与 `设计说明文档/個性化推薦算法規則.md` 一致 + - **验收**:两份文档无冲突描述,且关键参数命名统一(例如 `enable_uncertainty_penalty`、`widget_emotion_soft_range`)。 + +- [x] 1.2 在 `modules/scoring/plan.md` 中“明确写死”V1 的职责边界(若后续有调整再更新) + - **变更点**(如需微调措辞): + - `S_fresh/P_fatigue/P_repeat/P_risk` 为外部注入项,缺省按 0 + - `pass` 来自 Hard Filter,本模块只消费并乘上 \(\mathbb{I}[pass]\) + - **验收**:阅读 plan.md 时不会产生“谁负责计算哪一项”的歧义。 + +--- + +## 2. 目录与骨架(与推荐子模块同级) + +- [x] 2.1 新建目录 `server/app/features/personalized_reco/scoring/` + - **包含**: + - `__init__.py` + - `types.py`(`ScoreConfig/ScoreBreakdown/ScoreResult/ExternalTerms`) + - `defaults.py`(场景默认参数) + - `utils.py`(clamp、one-hot 取 key 等纯工具) + - `score.py`(核心纯函数 `score_content`) + - **验收**:可通过 `app.features.personalized_reco.scoring.*` 正常 import。 + +--- + +## 3. 类型与接口(稳定契约,便于引擎编排调用) + +- [x] 3.1 定义 `ScoreConfig`(含场景默认值 + 可覆盖) + - **字段**: + - 权重:`w_need/w_emotion/w_stage/w_context` + - 系数:`alpha/beta` + - 开关:`enable_uncertainty_penalty` + - Widget:`widget_emotion_soft_range`、`widget_emotion_penalty_gamma` + - **验收**:类型完整;能从 scene 推导默认 config(或由调用方传入)。 + +- [x] 3.2 定义 `ExternalTerms`(注入项,V1 可选) + - **字段**:`S_fresh/P_fatigue/P_repeat/P_risk` + - **默认值策略**:缺省按 0 + - **验收**:score 函数即使没有 external_terms 也能工作且输出字段稳定。 + +- [x] 3.3 定义 `ScoreBreakdown`(用于可观测/调参) + - **必须包含**: + - `S_need/S_context/S_stage/S_emotion` + - `S_core/S_personal/S_fresh` + - `P_fatigue/P_repeat/P_risk/P_uncertainty` + - `missing_fields` + - `pass/scene` + - `P_widget_emotion_out_of_range`(建议保留) + - **验收**:字段集合固定;不会因缺省而缺字段;所有值为有限数(非 NaN/Infinity)。 + +--- + +## 4. 纯函数实现(严格对齐公式 + 防御式兜底) + +- [x] 4.1 实现 one-hot 取唯一 key 的工具函数(need/context) + - **规则**: + - `{}` 或不存在 → 视为缺失 + - 只有一个 key=1 → 返回该 key + - 多个 key=1 → 选择“第一个”(写死策略:字典序优先或插入序优先)并记录 debug 日志 + - **验收**:单测覆盖空对象/单 key/多 key 的行为,且行为确定。 + +- [x] 4.2 实现 `S_need`/`S_context`(V1.2 缺失兜底) + - **规则**: + - need 缺失 → `S_need=0.5`;否则取 `Cᵢ.need_suitability[key]` + - context 缺失 → `S_context=0.5`;否则取 `Cᵢ.context_suitability[key]` + - 若内容侧缺 key/值非法 → 兜底为 0.5(防御式) + - **验收**:单测覆盖用户缺失与内容缺失两侧情况,且不抛异常。 + +- [x] 4.3 实现 `S_emotion`(general=0.8;否则 `1-|u-c|`) + - **规则**: + - `U.emotion_score` 缺失 → 0.8 + - `Cᵢ.emotion_score` 为 general(None)→ 0.8 + - 否则 `1-abs(u-c)` 并 clamp 到 `[0,1]` + - **验收**:单测覆盖缺失/general/正常值/越界值。 + +- [x] 4.4 实现 `S_stage`(对齐算法规则口径) + - **规则**: + - `content.stage=general` → 1 + - 命中用户阶段 → 1 + - 用户 unknown 且内容非 unknown → 0.7 + - 其余 → 0 + - **验收**:单测覆盖 general/命中/unknown→非unknown/其余组合。 + +- [x] 4.5 实现 `S_core`(线性加权) + - **规则**:`S_core=w_need*S_need + w_emotion*S_emotion + w_stage*S_stage + w_context*S_context` + - **验收**:单测断言与手算一致;权重可配置。 + +- [x] 4.6 实现 `S_personal`(对齐公式) + - **规则**:`S_personal=alpha * personalization_power * max(S_need, S_context)` + - **防御**:`personalization_power` clamp 到 `[0,1]` + - **验收**:单测覆盖 power=0/0.5/1,且随 `alpha` 单调变化。 + +- [x] 4.7 实现 `P_uncertainty`(Push 默认启用) + - **规则**:`beta*(1-conf_U)*(1-conf_C)*personalization_power` + - **默认值**:`conf_C` 缺失→0.7;`conf_U` 缺失→按 1.0(或 0.7,需在代码注释写死;V1 建议按 1.0 避免过惩罚) + - **开关**:`enable_uncertainty_penalty` 控制;Push scene 默认 true + - **验收**:单测覆盖低置信度与关闭开关两类情况。 + +- [x] 4.8 实现 Widget 情绪区间软降权(适中默认) + - **规则**: + - `scene=widget` 且 `content.emotion_score` 非 general: + - 若超出 `[0.4,0.8]`:`P_widget = gamma * d/(hi-lo)` 并 clamp `[0,gamma]` + - 区间内:`P_widget=0` + - `P_widget` 计入总分(建议并入 `P_risk`),并在 breakdown 中单独暴露 + - **验收**:单测断言: + - `emotion_score=0.6` → `P_widget=0` + - `emotion_score=0.0/1.0` → `P_widget` 接近 `gamma` + - 不硬过滤(仍返回分数,只是更低) + +- [x] 4.9 实现 `score_content(...) -> ScoreResult`(总分与 breakdown) + - **规则**: + - `final_score = I[pass] * (S_core + S_personal + S_fresh - P_fatigue - P_repeat - P_risk - P_uncertainty)` + - `S_fresh/P_fatigue/P_repeat/P_risk` 从 `external_terms` 读取,缺省 0 + - `pass=false` 时 `final_score=0`(breakdown 仍输出便于排查) + - **验收**:单测覆盖 `pass=false` 与 external_terms 缺省两种情况。 + +--- + +## 5. 单元测试(pytest,纯函数为主) + +- [x] 5.1 新建测试文件 `server/tests/test_scoring.py` + - **包含用例**: + - 缺失字段一致性(need/context/emotion) + - Push 不确定性惩罚生效(低 conf 时分数更低) + - Widget 软降权生效(区间外明显更低) + - pass=false 行为(final_score=0) + - **验收**:`pytest -q` 能跑通该文件。 + +- [x] 5.2 增加“输出稳定性”断言(breakdown 字段集合固定) + - **验收**:任意输入(含缺失字段)都返回同一套 breakdown key。 + +--- + +## 6. 最终自检清单(合入前) + +- [x] 6.1 文档一致性检查 + - **检查点**:`spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 config 字段与默认值策略)。 + - **验收**:阅读任一文档都能找到对应实现位置与参数含义。 + +- [x] 6.2 回归检查(不影响其他模块) + - **验收**:不修改现有 `content-repository` 与 `user_profile_scoring` 逻辑,仅新增 scoring 模块与测试。 + diff --git a/spec_kit/Personalized Reco/overview.md b/spec_kit/Personalized Reco/overview.md new file mode 100644 index 0000000..6d63c7d --- /dev/null +++ b/spec_kit/Personalized Reco/overview.md @@ -0,0 +1,122 @@ +# 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 旧→新映射与默认值兜底。 + - 语言:`text` 按客户端 `locale` 输出(当前仅 EN/TC),且不允许语言回退(缺语言内容直接过滤)。 + - 口径: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/`(引擎编排)** 已实施 + - 交付:新增后端 `reco_engine`(编排器 + Hard Filter);串起候选→过滤→打分→重排→回退;输出 items+meta;默认开启轻量 explanations;在画像缺失/候选不足时仍稳定返回。 +7. **`modules/integration-api-worker/`(API + Celery 集成)** 已实施 + - 交付:新增 `/v1/reco/{feed|push|widget}` 三个推荐接口(含 `Accept-Language→en/tc` 与 `X-Now` 注入);新增按 IP 限流 10/min;新增 Celery 任务 `tasks.reco.generate` 与 `tasks.reco.push_once`,均调用同一 `Reco Engine`。 + +--- + +## 6. 变更记录 + +- 2026-02-02:完成 `modules/scoring/` 的 `plan.md` 与 `tasks.md`,并新增后端打分模块 `server/app/features/personalized_reco/scoring/` 与单元测试 `server/tests/test_scoring.py`。 +- 2026-02-02:完成 `modules/rerank-freqcap/` 的 `plan.md` 与 `tasks.md`,并新增后端重排/频控模块 `server/app/features/personalized_reco/rerank_freqcap/` 与单元测试 `server/tests/test_rerank_freqcap.py`。 +- 2026-02-02:完成 `modules/observability/` 的 `plan.md` 与 `tasks.md`,并新增后端可观测模块 `server/app/features/personalized_reco/observability/` 与单元测试 `server/tests/test_observability.py`。 +- 2026-02-02:完成 `modules/reco-engine/` 的 `plan.md` 与 `tasks.md`,并新增后端引擎编排模块 `server/app/features/personalized_reco/reco_engine/`(含 Hard Filter + Orchestrator)与单元测试 `server/tests/test_reco_engine.py`。 +- 2026-02-02:完成 `modules/integration-api-worker/` 的 `plan.md` 与 `tasks.md`,并新增后端集成: + - FastAPI:`server/app/api/v1/reco.py`、`server/app/api/limits.py`、`server/app/main.py` + - Celery:`server/app/tasks/reco.py` + - 测试:`server/tests/test_integration_api_worker.py` + - 依赖:`server/requirements.txt`(新增 `httpx` 用于 API 测试) + 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..b01b929 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -19,6 +19,13 @@ - Expo 工程已在 `client/` 初始化,并完成 `pnpm install` - i18n 基座已接入:5 份语言资源 + 设备语言优先/设置可切换/持久化 + 入口初始化 +## Client User Identity + +- **目标**:在无账号体系或账号可选的前提下,为客户端建立稳定的 `client_user_id`,用于与 APNs/FCM 的 Push Token 做绑定,支撑精准推送与 Token 变更更新 +- **关键结论**:`client_user_id` 使用**随机 UUID(默认 UUID v4)**合适;将其视为不透明字符串,不使用可追踪设备硬件标识 +- **阶段产物**: + - `spec_kit/Client User Identity/spec.md` + ## Project Bootstrap - **目标**:完成项目仓库初始化与工程约定落地,明确 client/server 结构、dev/pro 环境隔离、MySQL/Redis 资源命名与访问边界 @@ -82,7 +89,12 @@ - **核心范围**: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` +- **已完成编码(阶段性)**: + - 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用 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)