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.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/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index c0fb964..10c1fb8 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'; @@ -41,8 +49,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); @@ -66,6 +77,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 = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2'; useLayoutEffect(() => { @@ -105,6 +165,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) => { @@ -125,7 +190,7 @@ export default function HomeScreen() { }); } }); - }, [busy, index, translateY, opacity]); + }, [busy, currentContentId, index, translateY, opacity]); const lastTapRef = useRef(0); @@ -176,7 +241,7 @@ export default function HomeScreen() { // 2. 保存到收藏夹,包含当前背景信息 await addFavorite({ - id: item.id, + id: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).id, date: dateStr, themeMode: themeMode, background: backgroundColor, // 目前存储的是颜色值 diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index df0ef33..5104f72 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,19 +141,30 @@ export default function OnboardingScreen() { }; const onSkip = () => { + // 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用 + const scoringProfile = buildUserProfileFromQuestionnaire({}); + void setUserProfileScoring(scoringProfile); + + // 标记已完成,避免下次启动再次进入 Onboarding + void 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 e9a5ae3..45f2198 100644 --- a/client/app/index.tsx +++ b/client/app/index.tsx @@ -14,9 +14,8 @@ export default function Index() { useEffect(() => { let cancelled = false; (async () => { - // 【完全重置】:清除本地存储的所有数据(收藏、设置、引导状态等) - await AsyncStorage.clear(); - console.log('AsyncStorage has been cleared.'); + // 注意:不要在启动时无条件清空存储,否则 Onboarding/画像等数据无法持久化。 + // 如需调试重置,请在开发期手动清空或自行加调试开关。 // 1. 检查是否同意协议 const consentAccepted = await getConsentAccepted(); 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/ios/Podfile.lock b/client/ios/Podfile.lock index 87fc2f5..23e87b7 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -3,9 +3,6 @@ PODS: - ExpoModulesCore - EXConstants (18.0.13): - ExpoModulesCore - - EXJSONUtils (0.15.0) - - EXManifests (1.0.10): - - ExpoModulesCore - EXNotifications (0.32.16): - ExpoModulesCore - Expo (54.0.32): @@ -33,177 +30,6 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-client (6.0.20): - - EXManifests - - expo-dev-launcher - - expo-dev-menu - - expo-dev-menu-interface - - EXUpdatesInterface - - expo-dev-launcher (6.0.20): - - EXManifests - - expo-dev-launcher/Main (= 6.0.20) - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-launcher/Main (6.0.20): - - EXManifests - - expo-dev-launcher/Unsafe - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-launcher/Unsafe (6.0.20): - - EXManifests - - expo-dev-menu - - expo-dev-menu-interface - - ExpoModulesCore - - EXUpdatesInterface - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTAppDelegate - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactAppDependencyProvider - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu (7.0.18): - - expo-dev-menu/Main (= 7.0.18) - - expo-dev-menu/ReactNativeCompatibles (= 7.0.18) - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu-interface (2.0.0) - - expo-dev-menu/Main (7.0.18): - - EXManifests - - expo-dev-menu-interface - - ExpoModulesCore - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - - expo-dev-menu/ReactNativeCompatibles (7.0.18): - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - ExpoAsset (12.0.12): - ExpoModulesCore - ExpoFileSystem (19.0.21): @@ -248,8 +74,6 @@ PODS: - ExpoModulesCore - ExpoWebBrowser (15.0.10): - ExpoModulesCore - - EXUpdatesInterface (2.0.0): - - ExpoModulesCore - FBLazyVector (0.81.5) - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) @@ -1974,28 +1798,6 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNGestureHandler (2.30.0): - - hermes-engine - - RCTRequired - - RCTTypeSafety - - React-Core - - React-Core-prebuilt - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - ReactNativeDependencies - - Yoga - RNReanimated (4.1.6): - hermes-engine - RCTRequired @@ -2238,26 +2040,19 @@ PODS: DEPENDENCIES: - "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)" - "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)" - - "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)" - - "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)" - - "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios`)" - - "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo`)" - - "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)" - - "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)" - - "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)" - - "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)" + - "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)" + - "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)" - "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)" - "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)" - "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)" - - "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios`)" + - "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)" - "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)" - - "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios`)" + - "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)" - "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)" - "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)" - "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)" - "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)" - "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)" - - "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)" - "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)" - "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)" - "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" @@ -2294,7 +2089,7 @@ DEPENDENCIES: - "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)" - "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)" - - "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context`)" + - "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)" - "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" - "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)" - "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)" @@ -2326,12 +2121,11 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" - "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" - - "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage`)" - - "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler`)" - - "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated`)" + - "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)" + - "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)" - "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)" - "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)" - - "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets`)" + - "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)" - "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)" EXTERNAL SOURCES: @@ -2339,22 +2133,10 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios" EXConstants: :path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios" - EXJSONUtils: - :path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios" - EXManifests: - :path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios" EXNotifications: - :path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios" + :path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios" Expo: - :path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo" - expo-dev-client: - :path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios" - expo-dev-launcher: - :path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher" - expo-dev-menu: - :path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu" - expo-dev-menu-interface: - :path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios" + :path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo" ExpoAsset: :path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios" ExpoFileSystem: @@ -2362,11 +2144,11 @@ EXTERNAL SOURCES: ExpoFont: :path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios" ExpoHead: - :path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios" + :path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios" ExpoKeepAwake: :path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios" ExpoLinearGradient: - :path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios" + :path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios" ExpoLinking: :path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios" ExpoLocalization: @@ -2377,8 +2159,6 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios" ExpoWebBrowser: :path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios" - EXUpdatesInterface: - :path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios" FBLazyVector: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector" hermes-engine: @@ -2451,7 +2231,7 @@ EXTERNAL SOURCES: React-microtasksnativemodule: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-safe-area-context: - :path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context" + :path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context" React-NativeModulesApple: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -2515,43 +2295,34 @@ EXTERNAL SOURCES: ReactNativeDependencies: :podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" RNCAsyncStorage: - :path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage" - RNGestureHandler: - :path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler" + :path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage" RNReanimated: - :path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated" + :path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens" RNSVG: :path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg" RNWorklets: - :path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets" + :path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets" Yoga: :path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f - EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 - EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd - EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b - EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597 - Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710 - expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d - expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3 - expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d - expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 - ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 - ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 - ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 - ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780 - ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 - ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58 - ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 - ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd - ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 - ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d - ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 - EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 + EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186 + EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e + EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a + Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80 + ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b + ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad + ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 + ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 + ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0 + ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86 + ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959 + ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485 + ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798 + ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f + ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 @@ -2559,72 +2330,71 @@ SPEC CHECKSUMS: RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c React: 914f8695f9bf38e6418228c2ffb70021e559f92f React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 - React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 - React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 - React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 - React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da + React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 + React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 + React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac + React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-debug: 6328c2228e268846161f10082e80dc69eac2e90a - React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 - React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 - React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 - React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a - React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 - React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 - React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f - React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 - React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b - React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 - React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e - React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d - React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de - React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 - React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f - React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 - React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f - React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e - React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a - React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf - React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 - React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e - React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b - react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 - React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa + React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c + React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d + React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 + React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 + React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 + React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d + React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 + React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 + React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf + React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc + React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 + React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 + React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 + React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 + React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 + React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 + React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb + React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 + React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca + React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a + React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd + React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 + React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c + react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd + React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c - React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 - React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 + React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f + React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 - React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e - React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 - React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 - React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea - React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 - React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 - React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b - React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 - React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 - React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 - React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b - React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 + React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af + React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 + React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 + React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 + React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 + React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac + React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 + React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 + React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b + React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a + React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 + React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d - React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 - React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 - React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 - React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 - React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e - React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 - React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f - React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 - React-utils: 6e2035b53d087927768649a11a26c4e092448e34 - ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: fffa79906f5866f6a5ab5d98480b375191190271 - ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 + React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 + React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 + React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 + React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 + React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c + React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 + React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 + React-timing: 03c7217455d2bff459b27a3811be25796b600f47 + React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a + ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee + ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f + ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a - RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 - RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f - RNReanimated: 9c6a550b41de91cf374e60afd79db93a362f1126 - RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 - RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 - RNWorklets: 1b50cb7595142f95e70518196ba247ad7f46a52e + RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 + RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c + RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 + RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 + RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3 diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index 37c23a4..4a11c49 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -374,8 +374,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -389,8 +387,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/client/src/constants/env.ts b/client/src/constants/env.ts index 8dc0443..6e880bc 100644 --- a/client/src/constants/env.ts +++ b/client/src/constants/env.ts @@ -20,9 +20,26 @@ 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); /** * 默认语言策略: 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/index.ts b/client/src/features/userProfileScoring/index.ts index dfa086c..2133e19 100644 --- a/client/src/features/userProfileScoring/index.ts +++ b/client/src/features/userProfileScoring/index.ts @@ -5,6 +5,8 @@ export type { UserProfileV1_2_Extended, } from './types'; +export type { OnboardingSelections } from './onboardingMapping'; + export { buildUserProfileFromQuestionnaire, computeProfileAnswered, @@ -13,3 +15,5 @@ export { 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/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 455038a..c30c995 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); @@ -122,6 +157,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.devs b/server/.env.dev similarity index 100% rename from server/.env.devs rename to server/.env.dev 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/app/__pycache__/main.cpython-313.pyc b/server/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..5d626d7 Binary files /dev/null and b/server/app/__pycache__/main.cpython-313.pyc differ diff --git a/server/app/api/__pycache__/__init__.cpython-313.pyc b/server/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5248b76 Binary files /dev/null and b/server/app/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/api/__pycache__/limits.cpython-313.pyc b/server/app/api/__pycache__/limits.cpython-313.pyc new file mode 100644 index 0000000..524957c Binary files /dev/null and b/server/app/api/__pycache__/limits.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3ee1056 Binary files /dev/null and b/server/app/api/v1/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/api/v1/__pycache__/reco.cpython-313.pyc b/server/app/api/v1/__pycache__/reco.cpython-313.pyc new file mode 100644 index 0000000..d2c99b0 Binary files /dev/null and b/server/app/api/v1/__pycache__/reco.cpython-313.pyc differ diff --git a/server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc b/server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc new file mode 100644 index 0000000..78f639e Binary files /dev/null and b/server/app/api/v1/__pycache__/user_profile_scoring.cpython-313.pyc differ 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/db/__pycache__/session.cpython-313.pyc b/server/app/db/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000..fa65eb9 Binary files /dev/null and b/server/app/db/__pycache__/session.cpython-313.pyc differ diff --git a/server/app/db/models/__pycache__/content_profile.cpython-313.pyc b/server/app/db/models/__pycache__/content_profile.cpython-313.pyc index 7e220eb..addf96c 100644 Binary files a/server/app/db/models/__pycache__/content_profile.cpython-313.pyc and b/server/app/db/models/__pycache__/content_profile.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0b7ae0b Binary files /dev/null and b/server/app/features/personalized_reco/__pycache__/__init__.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3559bcf Binary files /dev/null and b/server/app/features/personalized_reco/content_repository/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc new file mode 100644 index 0000000..3372a09 Binary files /dev/null and b/server/app/features/personalized_reco/content_repository/__pycache__/interface.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/content_repository/__pycache__/normalization.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/normalization.cpython-313.pyc new file mode 100644 index 0000000..21889a1 Binary files /dev/null and b/server/app/features/personalized_reco/content_repository/__pycache__/normalization.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/content_repository/__pycache__/sqlalchemy_repo.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/sqlalchemy_repo.cpython-313.pyc new file mode 100644 index 0000000..8b4b65a Binary files /dev/null and b/server/app/features/personalized_reco/content_repository/__pycache__/sqlalchemy_repo.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/content_repository/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/content_repository/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..d482a86 Binary files /dev/null and b/server/app/features/personalized_reco/content_repository/__pycache__/types.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e05858c Binary files /dev/null and b/server/app/features/personalized_reco/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc new file mode 100644 index 0000000..841bac1 Binary files /dev/null and b/server/app/features/personalized_reco/observability/__pycache__/builder.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..c499882 Binary files /dev/null and b/server/app/features/personalized_reco/observability/__pycache__/types.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..a29b432 Binary files /dev/null and b/server/app/features/personalized_reco/observability/__pycache__/utils.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3a59f9d Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000..f729ebf Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/defaults.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/hard_filter.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/hard_filter.cpython-313.pyc new file mode 100644 index 0000000..0d61a62 Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/hard_filter.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc new file mode 100644 index 0000000..2abe645 Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/orchestrator.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..13f2238 Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/types.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/reco_engine/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/reco_engine/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..51a8ea0 Binary files /dev/null and b/server/app/features/personalized_reco/reco_engine/__pycache__/utils.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..7c19f18 Binary files /dev/null and b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/defaults.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000..6818a27 Binary files /dev/null and b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/defaults.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc new file mode 100644 index 0000000..6cad5b1 Binary files /dev/null and b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/rerank.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..202cb02 Binary files /dev/null and b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/types.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/rerank_freqcap/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..65c226f Binary files /dev/null and b/server/app/features/personalized_reco/rerank_freqcap/__pycache__/utils.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..025ca82 Binary files /dev/null and b/server/app/features/personalized_reco/scoring/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000..91ae80e Binary files /dev/null and b/server/app/features/personalized_reco/scoring/__pycache__/defaults.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc new file mode 100644 index 0000000..44d277a Binary files /dev/null and b/server/app/features/personalized_reco/scoring/__pycache__/score.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..1283dac Binary files /dev/null and b/server/app/features/personalized_reco/scoring/__pycache__/types.cpython-313.pyc differ diff --git a/server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc b/server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..dc4b833 Binary files /dev/null and b/server/app/features/personalized_reco/scoring/__pycache__/utils.cpython-313.pyc differ 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/__pycache__/__init__.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d7ceb7a Binary files /dev/null and b/server/app/features/user_profile_scoring/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc new file mode 100644 index 0000000..553856e Binary files /dev/null and b/server/app/features/user_profile_scoring/__pycache__/scoring.cpython-313.pyc differ diff --git a/server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc b/server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..6b4d131 Binary files /dev/null and b/server/app/features/user_profile_scoring/__pycache__/types.cpython-313.pyc differ diff --git a/server/app/main.py b/server/app/main.py index 988e867..f611d48 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,6 +1,7 @@ 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 @@ -17,6 +18,7 @@ def create_app() -> FastAPI: # 业务路由 app.include_router(user_profile_router) + app.include_router(reco_router) @app.get("/healthz") async def healthz() -> dict: diff --git a/server/app/tasks/__pycache__/__init__.cpython-313.pyc b/server/app/tasks/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..181caaa Binary files /dev/null and b/server/app/tasks/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/tasks/__pycache__/reco.cpython-313.pyc b/server/app/tasks/__pycache__/reco.cpython-313.pyc new file mode 100644 index 0000000..70900b2 Binary files /dev/null and b/server/app/tasks/__pycache__/reco.cpython-313.pyc differ 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 ad0748f..9ce85d6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -5,6 +5,7 @@ uvicorn[standard]>=0.27 SQLAlchemy>=2.0 aiomysql>=0.2 greenlet>=3.0 +aiosqlite>=0.20 # 配置 pydantic>=2.6 @@ -19,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/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..3783d2f Binary files /dev/null and b/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..1800d81 Binary files /dev/null and b/server/tests/__pycache__/test_content_repository.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_integration_api_worker.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_integration_api_worker.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..40c983d Binary files /dev/null and b/server/tests/__pycache__/test_integration_api_worker.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..7654722 Binary files /dev/null and b/server/tests/__pycache__/test_observability.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_reco_engine.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_reco_engine.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..a663384 Binary files /dev/null and b/server/tests/__pycache__/test_reco_engine.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..f330060 Binary files /dev/null and b/server/tests/__pycache__/test_rerank_freqcap.cpython-313-pytest-9.0.2.pyc differ diff --git a/server/tests/__pycache__/test_scoring.cpython-313-pytest-9.0.2.pyc b/server/tests/__pycache__/test_scoring.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..a523139 Binary files /dev/null and b/server/tests/__pycache__/test_scoring.cpython-313-pytest-9.0.2.pyc differ 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 index 1454eb5..610d399 100644 --- a/spec_kit/Personalized Reco/modules/content-repository/spec.md +++ b/spec_kit/Personalized Reco/modules/content-repository/spec.md @@ -20,6 +20,7 @@ - `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` 对齐) @@ -29,7 +30,9 @@ - `List[ContentProfile]`(稳定字段契约): - `content_id`:`int`(MySQL 自增主键) - - `text`:文案文本 + - `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`) 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/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/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/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/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/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/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 index 4079494..6d63c7d 100644 --- a/spec_kit/Personalized Reco/overview.md +++ b/spec_kit/Personalized Reco/overview.md @@ -87,21 +87,36 @@ spec_kit/Personalized Reco/ > 原则:先把“数据可查”打通,再实现“可排序”,最后做“可对外提供(API/任务)”与“可观测”。 -1. **`modules/db-design/`(数据库设计与迁移)** +1. **`modules/db-design/`(数据库设计与迁移)**已实施 - 交付:表结构 + Alembic 迁移可跑通;能插入/读取最小 `ContentProfile` 字段。 -2. **`modules/content-repository/`(数据访问层)** +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/`(打分)** +3. **`modules/scoring/`(打分)** 已实施 - 交付:`final_score` 与缺失字段保守策略(V1.2)实现;Push 的 `P_uncertainty`;Widget 情绪区间软降权。 -4. **`modules/rerank-freqcap/`(去重/重排/频控)** +4. **`modules/rerank-freqcap/`(去重/重排/频控)** 已实施 - 交付:基于传入历史集合的去重;Feed 的 MMR;Push/Widget 冷却窗口规则(先保证“同句不重复”)。 -5. **`modules/observability/`(可观测)** +5. **`modules/observability/`(可观测)** 已实施 - 交付:统一 `meta` 结构与字段;能准确定位候选在何阶段被清空/回退。 -6. **`modules/reco-engine/`(引擎编排)** - - 交付:串起候选→过滤→打分→重排→回退;输出 items+meta;在画像缺失/候选不足时仍稳定返回。 -7. **`modules/integration-api-worker/`(API + Celery 集成)** - - 交付:FastAPI 路由 + Celery 任务都能调用同一引擎;相同输入下结果一致(忽略时间戳差异)。 +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/overview.md b/spec_kit/overview.md index 866b3b6..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 资源命名与访问边界 @@ -89,3 +96,5 @@ - **测试覆盖**:提供“输入题目答案 → 输出用户画像”的回归用例,覆盖字段映射、置信度衰减与硬规则触发(可选但建议) - **阶段产物**: - `spec_kit/User Profile Scoring/spec.md` +- **已完成编码(阶段性)**: + - 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用