14 Commits

Author SHA1 Message Date
雷汀岚
3f91e734fa docs: 更新隐私协议与用户使用协议 2026-02-02 18:58:12 +08:00
Hao
9dbba04408 Merge pull request 'Hao' (#8) from Hao into main
Reviewed-on: #8
2026-02-02 09:33:41 +00:00
1
64b8352ad3 fix:并 2026-02-02 17:33:20 +08:00
1
4b739dd194 feat: 完善启动分发逻辑,优化小组件背景适配与点赞图标交互 2026-02-02 17:17:10 +08:00
吕新雨
d045237952 chore: 清理 Python 运行产物并忽略
- 从仓库移除 __pycache__/ 与 *.pyc 等运行产物,避免 git pull 覆盖未跟踪文件导致合并失败
- 在 .gitignore 增加 Python 运行产物忽略规则
2026-02-02 16:52:22 +08:00
1
228fd7fd84 feat: 首页支持风景图与纯色背景随滑动自动切换,优化点赞图标样式与交互 2026-02-02 16:50:38 +08:00
3587a24115 Merge pull request '新功能:个性化推荐算法' (#7) from damer into main
Reviewed-on: #7
2026-02-02 08:48:11 +00:00
吕新雨
6dc4e2b943 新功能:个性化推荐算法 2026-02-02 16:47:37 +08:00
吕新雨
936094211b Merge remote-tracking branch 'origin/main' 2026-02-02 11:27:00 +08:00
吕新雨
be38d817d5 Merge branch 'damer' 2026-02-02 11:23:53 +08:00
吕新雨
58d17fc39f fix:更新算法 2026-02-02 11:22:35 +08:00
Hao
502a6ac500 Merge pull request 'Hao' (#5) from Hao into main
Reviewed-on: #5
2026-02-02 02:51:14 +00:00
f49cbb7186 Merge pull request 'fix:文明' (#4) from damer into main
Reviewed-on: #4
2026-02-02 02:49:27 +00:00
吕新雨
814b96edb6 fix:文明 2026-02-02 10:47:24 +08:00
140 changed files with 12298 additions and 401 deletions

View File

@@ -2,3 +2,4 @@
客户端请按照标准的RN架构目录写代码 客户端请按照标准的RN架构目录写代码
后端请按照标准的python FastAPI 架构目录写代码 后端请按照标准的python FastAPI 架构目录写代码
现在多语言仅支持 EN / TC 现在多语言仅支持 EN / TC
整个task.md执行完毕后需要在对应的overview.md标记并且说明变更的文件名

View File

@@ -9,6 +9,6 @@
- 输入/输出定义 - 输入/输出定义
- 验收标准(可验证) - 验收标准(可验证)
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。 3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
4. 保留大 spec.md 的高层背景/总览到 overview 部分。 4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序
5. 子模块之间按逻辑关系关联。 5. 子模块之间按逻辑关系关联。
6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。 6. 不生成 plan.md 或 tasks.md仅拆出子模块 spec。

View File

@@ -2,3 +2,4 @@
根据对应的plan.md 生成task.md 根据对应的plan.md 生成task.md
任务清单详细可执行 任务清单详细可执行
执行完要标记 执行完要标记
整个task.md执行完毕后需要在对应的overview.md标记

View File

@@ -0,0 +1 @@
使用测试工具完成集成测试,并给我一份简单的测试报告

View File

@@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。
输出时根据这个结构生成内容时,请保持文件职责清晰。 输出时根据这个结构生成内容时,请保持文件职责清晰。
简短记录项目的该层每个spec的内容 每次编码完成后更新overview.md 简短记录项目的该层每个spec的内容 每次编码完成后更新overview.md
可以通过nvm 切换node版本 可以通过nvm 切换node版本
在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@
.DS_Store .DS_Store
*.pem *.pem
# Python运行产物
__pycache__/
*.py[cod]
# Node / JS # Node / JS
node_modules/ node_modules/
npm-debug.* npm-debug.*

View File

@@ -1,11 +1,11 @@
{ {
"expo": { "expo": {
"name": "Hey Mama", "name": "client",
"slug": "hey-mama", "slug": "client",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "heymama", "scheme": "client",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": {
@@ -15,7 +15,7 @@
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.heymama.app" "bundleIdentifier": "com.anonymous.client"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react'; import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native'; import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from 'expo-router'; import { useNavigation, useFocusEffect } from 'expo-router';
import Animated, { import Animated, {
@@ -14,12 +14,20 @@ import Animated, {
import { MOCK_CONTENT } from '@/src/constants/mockContent'; import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { import {
addFavorite, addFavorite,
getRecoFeedCache,
getRecoFeedHistory,
getThemeMode, getThemeMode,
getUserProfile, getUserProfile,
getUserProfileScoring,
recordRecoFeedServed,
recordRecoFeedTouched,
setRecoFeedCache,
setReaction, setReaction,
setThemeMode, setThemeMode,
type RecoFeedCacheItem,
type ThemeMode, type ThemeMode,
} from '@/src/storage/appStorage'; } from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import ProfileModal from '@/components/home/ProfileModal'; import ProfileModal from '@/components/home/ProfileModal';
import ThemeModal from '@/components/home/ThemeModal'; import ThemeModal from '@/components/home/ThemeModal';
@@ -31,6 +39,40 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 预定义风景图列表
const NATURE_IMAGES = [
require('@/assets/theme/nature/1.png'),
require('@/assets/theme/nature/2.png'),
require('@/assets/theme/nature/3.png'),
require('@/assets/theme/nature/4.png'),
require('@/assets/theme/nature/5.png'),
require('@/assets/theme/nature/6.png'),
require('@/assets/theme/nature/7.png'),
require('@/assets/theme/nature/8.png'),
require('@/assets/theme/nature/9.png'),
require('@/assets/theme/nature/10.png'),
require('@/assets/theme/nature/11.png'),
require('@/assets/theme/nature/12.png'),
require('@/assets/theme/nature/13.png'),
require('@/assets/theme/nature/14.png'),
require('@/assets/theme/nature/15.png'),
require('@/assets/theme/nature/17.png'),
require('@/assets/theme/nature/18.png'),
require('@/assets/theme/nature/19.png'),
require('@/assets/theme/nature/20.png'),
require('@/assets/theme/nature/22.png'),
];
// 预定义颜色列表
const THEME_COLORS = [
'#F7D9BF',
'#CBF2D8',
'#F5CDDE',
'#F2ECCB',
'#E2CBF2',
'#CBD9F2',
];
export default function HomeScreen() { export default function HomeScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -41,8 +83,11 @@ export default function HomeScreen() {
const [profileName, setProfileName] = useState<string | undefined>(undefined); const [profileName, setProfileName] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [likeFilled, setLikeFilled] = useState(false); const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
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 // 动画相关 Shared Values
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
@@ -66,12 +111,75 @@ export default function HomeScreen() {
}, []) }, [])
); );
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2'; // 首次进入:先读缓存,再拉后端 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<string, unknown>,
});
await recordRecoFeedServed(out.items.map((x) => x.content_id));
}
} catch {
// 忽略:保持缓存/本地 mock
}
})();
return () => {
cancelled = true;
};
}, []);
const backgroundColor = useMemo(() => {
if (themeMode === 'color') {
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
return THEME_COLORS[colorIndex];
}
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
}, [themeMode, index]);
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
const natureImageIndex = useMemo(() => {
return Math.floor(index / 10) % NATURE_IMAGES.length;
}, [index]);
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
useLayoutEffect(() => { useLayoutEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerShadowVisible: false, headerShadowVisible: false,
headerStyle: { backgroundColor }, headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
headerTransparent: themeMode === 'scenery',
headerRight: () => ( headerRight: () => (
<View style={styles.headerRight}> <View style={styles.headerRight}>
<CircleIconButton <CircleIconButton
@@ -89,7 +197,7 @@ export default function HomeScreen() {
</View> </View>
), ),
}); });
}, [backgroundColor, navigation, t]); }, [backgroundColor, themeMode, navigation, t]);
const textAnimatedStyle = useAnimatedStyle(() => ({ const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
@@ -105,6 +213,11 @@ export default function HomeScreen() {
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
// 记录“看过/划过”的内容 id用于下一次向后端请求时去重/频控)
if (typeof currentContentId === 'number') {
void recordRecoFeedTouched(currentContentId);
}
// 1. 当前文案向上移动并消失 // 1. 当前文案向上移动并消失
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) }); translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => { opacity.value = withTiming(0, { duration: 300 }, (finished) => {
@@ -125,7 +238,7 @@ export default function HomeScreen() {
}); });
} }
}); });
}, [busy, index, translateY, opacity]); }, [busy, currentContentId, index, translateY, opacity]);
const lastTapRef = useRef<number>(0); const lastTapRef = useRef<number>(0);
@@ -176,10 +289,11 @@ export default function HomeScreen() {
// 2. 保存到收藏夹,包含当前背景信息 // 2. 保存到收藏夹,包含当前背景信息
await addFavorite({ await addFavorite({
favId: String(Date.now()), // 生成唯一 ID
id: item.id, id: item.id,
date: dateStr, date: dateStr,
themeMode: themeMode, themeMode: themeMode,
background: backgroundColor, // 目前存储的是颜色值 background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
}); });
// 3. 爱心缩放动画 // 3. 爱心缩放动画
@@ -202,8 +316,15 @@ export default function HomeScreen() {
return ( return (
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}> <View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
<Animated.View style={[styles.card, textAnimatedStyle]}> {themeMode === 'scenery' && (
<Text style={styles.text}>{item.text}</Text> <ImageBackground
source={currentNatureImage}
style={StyleSheet.absoluteFill}
resizeMode="cover"
/>
)}
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
</Animated.View> </Animated.View>
<View style={styles.actions}> <View style={styles.actions}>
@@ -216,9 +337,13 @@ export default function HomeScreen() {
style={styles.reactionInner} style={styles.reactionInner}
> >
{likeFilled ? ( {likeFilled ? (
<LikeFilledIcon width={35} height={36} /> <LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
) : ( ) : (
<LikeIcon width={35} height={36} /> <LikeIcon
width={35}
height={36}
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
/>
)} )}
</Pressable> </Pressable>
</Animated.View> </Animated.View>
@@ -277,9 +402,15 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
card: { card: {
paddingHorizontal: 30, position: 'absolute',
alignItems: 'center', top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 30,
zIndex: 5, // 降低层级,防止遮挡底部按钮
}, },
text: { text: {
fontSize: 22, fontSize: 22,
@@ -288,6 +419,16 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
textAlign: 'center', textAlign: 'center',
}, },
sceneryCard: {
// 风景模式下稍微收窄文案宽度,增加呼吸感
paddingHorizontal: 50,
},
sceneryText: {
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 4,
},
actions: { actions: {
position: 'absolute', position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16, bottom: SCREEN_HEIGHT * 0.16,
@@ -295,6 +436,7 @@ const styles = StyleSheet.create({
right: 0, right: 0,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
zIndex: 20, // 提升层级,确保在最顶层可点击
}, },
reactionButton: { reactionButton: {
alignItems: 'center', alignItems: 'center',

View File

@@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { NameInputStep } from '@/components/onboarding/NameInputStep';
import { SelectionStep } from '@/components/onboarding/SelectionStep'; import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep'; 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 = [ const STEPS = [
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' }, { id: 'name', type: 'name', title: '我可以怎么称呼你?' },
@@ -71,6 +80,40 @@ export default function OnboardingScreen() {
const { status } = await Notifications.requestPermissionsAsync(); const { status } = await Notifications.requestPermissionsAsync();
const pushEnabled = status === 'granted'; 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<string, unknown>,
});
await recordRecoFeedServed(items.map((x) => x.content_id));
} catch {
// 网络失败时使用首页本地 mock 兜底
}
await setUserProfile({ await setUserProfile({
name, name,
intents: Object.values(selections).flat() intents: Object.values(selections).flat()
@@ -97,20 +140,30 @@ export default function OnboardingScreen() {
} }
}; };
const onSkip = () => { const onSkip = async () => {
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用
const scoringProfile = buildUserProfileFromQuestionnaire({});
await setUserProfileScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
await setOnboardingCompleted(true);
router.replace('/(app)/home'); router.replace('/(app)/home');
}; };
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
const handleToggleSelection = (id: string) => { const handleToggleSelection = (id: string) => {
setSelections(prev => { setSelections(prev => {
const currentIds = prev[currentStep.id] || []; const currentIds = prev[currentStep.id] || [];
const nextIds = currentIds.includes(id) const nextIds = currentIds.includes(id) ? [] : [id];
? currentIds.filter(i => i !== id)
: [...currentIds, id];
return { ...prev, [currentStep.id]: nextIds }; return { ...prev, [currentStep.id]: nextIds };
}); });
}; };
const handleSkipStep = () => {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
return ( return (
<OnboardingLayout <OnboardingLayout
title={currentStep.title} title={currentStep.title}
@@ -134,6 +187,7 @@ export default function OnboardingScreen() {
selectedIds={selections[currentStep.id] || []} selectedIds={selections[currentStep.id] || []}
onToggle={handleToggleSelection} onToggle={handleToggleSelection}
onNext={onNext} onNext={onNext}
onSkip={handleSkipStep}
/> />
)} )}

View File

@@ -1,9 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage'; import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
/** /**
* 启动分发:根据 consent 和 onboarding 状态跳转 * 启动分发:根据 consent 和 onboarding 状态跳转
@@ -14,10 +13,6 @@ export default function Index() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
// 【完全重置】:清除本地存储的所有数据(收藏、设置、引导状态等)
await AsyncStorage.clear();
console.log('AsyncStorage has been cleared.');
// 1. 检查是否同意协议 // 1. 检查是否同意协议
const consentAccepted = await getConsentAccepted(); const consentAccepted = await getConsentAccepted();
if (cancelled) return; if (cancelled) return;
@@ -27,9 +22,17 @@ export default function Index() {
return; return;
} }
// 2. 检查 Onboarding // 2. 检查 Onboarding 是否已完成
const completed = await getOnboardingCompleted(); const completed = await getOnboardingCompleted();
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding'); if (cancelled) return;
if (completed) {
// 如果已经完成过流程,直接进 Home
router.replace('/(app)/home');
} else {
// 如果是首次进入(或未完成流程),进入 Onboarding
router.replace('/(onboarding)/onboarding');
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -46,4 +49,3 @@ export default function Index() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -50,6 +50,29 @@ type Props = {
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo'; type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
type NavDirection = 'forward' | 'back'; type NavDirection = 'forward' | 'back';
const NATURE_IMAGES = [
require('@/assets/theme/nature/1.png'),
require('@/assets/theme/nature/2.png'),
require('@/assets/theme/nature/3.png'),
require('@/assets/theme/nature/4.png'),
require('@/assets/theme/nature/5.png'),
require('@/assets/theme/nature/6.png'),
require('@/assets/theme/nature/7.png'),
require('@/assets/theme/nature/8.png'),
require('@/assets/theme/nature/9.png'),
require('@/assets/theme/nature/10.png'),
require('@/assets/theme/nature/11.png'),
require('@/assets/theme/nature/12.png'),
require('@/assets/theme/nature/13.png'),
require('@/assets/theme/nature/14.png'),
require('@/assets/theme/nature/15.png'),
require('@/assets/theme/nature/17.png'),
require('@/assets/theme/nature/18.png'),
require('@/assets/theme/nature/19.png'),
require('@/assets/theme/nature/20.png'),
require('@/assets/theme/nature/22.png'),
];
export default function ProfileModal({ visible, name: propName, onClose }: Props) { export default function ProfileModal({ visible, name: propName, onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -260,11 +283,11 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
setFavorites(list); setFavorites(list);
} }
async function handleRemove(id: string) { async function handleRemove(favId: string) {
// 1. 调用存储层移除收藏 // 1. 调用存储层移除收藏
await removeFavorite(id); await removeFavorite(favId);
// 2. 更新本地状态 // 2. 更新本地状态
setFavorites(prev => prev.filter(item => item.id !== id)); setFavorites(prev => prev.filter(item => item.favId !== favId));
} }
return ( return (
@@ -274,7 +297,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
) : ( ) : (
<FlatList <FlatList
data={favorites} data={favorites}
keyExtractor={(it) => it.id} keyExtractor={(it) => it.favId}
contentContainerStyle={styles.favList} contentContainerStyle={styles.favList}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
renderItem={({ item }) => ( renderItem={({ item }) => (
@@ -290,11 +313,30 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<View style={styles.favRight}> <View style={styles.favRight}>
<View style={[ <View style={[
styles.favThumb, styles.favThumb,
{ backgroundColor: item.background } // 动态同步 Home 页的背景 item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
]}> ]}>
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text> {item.themeMode === 'scenery' ? (
<View style={StyleSheet.absoluteFill}>
<Image
source={NATURE_IMAGES[parseInt(item.background)]}
style={{
width: width * 0.6,
height: 800, // 假设原图较高,设置一个较大的高度
position: 'absolute',
bottom: 0, // 关键:将图片底部对齐容器底部
}}
resizeMode="cover"
/>
</View>
) : null}
<Text style={[
styles.favThumbText,
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
]} numberOfLines={4}>
{item.text}
</Text>
<Pressable <Pressable
onPress={() => handleRemove(item.id)} onPress={() => handleRemove(item.favId)}
style={styles.favRemoveBtn} style={styles.favRemoveBtn}
hitSlop={10} hitSlop={10}
> >
@@ -765,6 +807,7 @@ const styles = StyleSheet.create({
position: 'relative', position: 'relative',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(119, 47, 0, 0.05)', borderColor: 'rgba(119, 47, 0, 0.05)',
overflow: 'hidden',
}, },
favThumbText: { favThumbText: {
fontSize: 15, fontSize: 15,

View File

@@ -18,9 +18,10 @@ interface SelectionStepProps {
selectedIds: string[]; selectedIds: string[];
onToggle: (id: string) => void; onToggle: (id: string) => void;
onNext: () => 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; const hasSelection = selectedIds.length > 0;
return ( return (
@@ -48,15 +49,19 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select
{/* 底部按钮:距离底部 12% 高度 */} {/* 底部按钮:距离底部 12% 高度 */}
<View style={styles.footer}> <View style={styles.footer}>
<TouchableOpacity <View style={styles.footerRow}>
onPress={onNext} {onSkip && (
disabled={!hasSelection} <TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
activeOpacity={0.8} <SerifText style={styles.skipText}></SerifText>
> </TouchableOpacity>
)}
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />} {hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View>
); );
} }
@@ -99,5 +104,20 @@ const styles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', 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,
},
}); });

View File

@@ -1,54 +1,28 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
// V2 + Small/Medium/Large + Home // V1Small/Medium/Large + Home
struct EmotionProvider: TimelineProvider { struct EmotionProvider: TimelineProvider {
private let quotes = [
"你已经很努力了,今天也值得被温柔对待。",
"轻轻呼吸,感受当下的每一刻。",
"所有的压力,都会在深呼吸中慢慢消散。",
"给生活一点留白,给自己一点温柔。",
"不要走得太快,等一等落下的灵魂。",
"世界虽嘈杂,但你可以拥有一颗宁静的心。",
"每一个瞬间,都是生命最好的安排。",
"抱抱自己,辛苦了,亲爱的。",
"慢一点也没关系,只要你在前行。",
"今天,你对自己微笑了吗?",
"愿你历经山河,仍觉得人间值得。",
"心简单,世界就简单;心平顺,生活就平顺。",
"即使生活偶尔晦暗,你也要成为自己的光。",
"别让琐事挤走生活的快乐,别让压力消磨奋斗的激情。"
]
func placeholder(in context: Context) -> EmotionEntry { func placeholder(in context: Context) -> EmotionEntry {
EmotionEntry(date: Date(), text: quotes[0]) EmotionEntry(date: Date())
} }
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) { func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
let entry = EmotionEntry(date: Date(), text: quotes.randomElement() ?? quotes[0]) completion(EmotionEntry(date: Date()))
completion(entry)
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) { func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
var entries: [EmotionEntry] = [] // V1
let currentDate = Date() let entry = EmotionEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
// 24 6 4 ?? Date().addingTimeInterval(60 * 60 * 24 * 7)
for hourOffset in 0..<6 { completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset * 4, to: currentDate)!
let entry = EmotionEntry(date: entryDate, text: quotes.randomElement() ?? quotes[0])
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
} }
} }
struct EmotionEntry: TimelineEntry { struct EmotionEntry: TimelineEntry {
let date: Date let date: Date
let text: String
} }
struct EmotionWidgetView: View { struct EmotionWidgetView: View {
@@ -56,37 +30,160 @@ struct EmotionWidgetView: View {
@Environment(\.widgetFamily) var family @Environment(\.widgetFamily) var family
private let title = "正念" private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home") private let deepLink = URL(string: "client:///(app)/home")
// #F7D9BF
private let backgroundColor = Color(red: 247/255, green: 217/255, blue: 191/255)
//
private let textColor = Color(red: 74/255, green: 52/255, blue: 40/255)
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 0) { switch family {
Spacer(minLength: 0) case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
}
}
Text(entry.text) // iOS 15
.font(.system(size: family == .systemSmall ? 17 : 20, weight: .medium)) private func cardBackground(colors: [Color]) -> some View {
.foregroundColor(textColor) ZStack {
.lineSpacing(6) LinearGradient(
.multilineTextAlignment(.center) colors: colors,
.minimumScaleFactor(0.7) startPoint: .topLeading,
.fixedSize(horizontal: false, vertical: true) endPoint: .bottomTrailing
)
//
RadialGradient(
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
center: .topTrailing,
startRadius: 10,
endRadius: 180
)
}
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.14), lineWidth: 1)
)
.cornerRadius(18)
}
private func chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
}
private func smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0) Spacer(minLength: 0)
if family != .systemSmall { Text("点我回到 App")
Text("Hey Mama") .font(.system(size: 11, weight: .medium))
.font(.system(size: 10, weight: .semibold)) .foregroundColor(Color.white.opacity(0.65))
.foregroundColor(textColor.opacity(0.3)) }
.padding(.bottom, 4) .padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
} }
} }
.padding(family == .systemSmall ? 16 : 24) .padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity) // }
.background(backgroundColor) // .widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
VStack(alignment: .leading, spacing: 14) {
HStack {
chip(title)
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
}
}
.padding(18)
}
.widgetURL(deepLink) .widgetURL(deepLink)
} }
} }
@@ -97,13 +194,7 @@ struct EmotionWidget: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
if #available(iOS 17.0, *) {
EmotionWidgetView(entry: entry) EmotionWidgetView(entry: entry)
.containerBackground(Color(red: 247/255, green: 217/255, blue: 191/255), for: .widget)
} else {
EmotionWidgetView(entry: entry)
.background(Color(red: 247/255, green: 217/255, blue: 191/255))
}
} }
.configurationDisplayName("情绪小组件") .configurationDisplayName("情绪小组件")
.description("一段温柔提醒,陪你回到当下。") .description("一段温柔提醒,陪你回到当下。")

View File

@@ -6,7 +6,8 @@
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web" "web": "expo start --web",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
@@ -41,7 +42,8 @@
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"react-test-renderer": "19.1.0", "react-test-renderer": "19.1.0",
"typescript": "~5.9.2" "typescript": "~5.9.2",
"vitest": "^4.0.18"
}, },
"private": true "private": true
} }

1220
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string {
return process.env[name] ?? fallback; return process.env[name] ?? fallback;
} }
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev'; export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL'); export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
function getApiBaseUrl(env: AppRuntimeEnv): string {
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL则优先使用不再强制要求 *_DEV/_PROD
const direct = process.env.EXPO_PUBLIC_API_BASE_URL;
if (direct && String(direct).trim()) return String(direct).trim();
// 约定local/dev/prod 三套域名分别配置,便于后续直接切环境而不改代码
if (env === 'local') {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
}
if (env === 'dev') {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
}
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
}
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
/** /**
* 默认语言策略: * 默认语言策略:
* - auto优先设备语言支持列表内时否则回退 zh-CN * - auto优先设备语言支持列表内时否则回退 en
* - zh-CN/en/es/pt/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化 * - en/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
*/ */
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto'); export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');

View File

@@ -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 });
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import {
buildUserProfileFromQuestionnaire,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from '../index';
describe('userProfileScoring V1.2', () => {
it('normalizeAnswers: 非法值按跳过处理', () => {
// @ts-expect-error: 模拟非法输入
const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' });
expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined });
});
it('computeTimeConfidence: 分段衰减', () => {
const gen = new Date('2026-01-01T00:00:00Z');
// 07 天1.0
expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0);
// 30 天以上0.5
expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5);
});
it('computeProfileConfidence: 完整度因子 + clamp', () => {
const confTime = 1.0;
// 全部跳过completion=0 → completionFactor=0.5 → 0.5
expect(
computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false })
).toBe(0.5);
// 全部作答completion=1 → completionFactor=1 → 1
expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0);
});
it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => {
const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' });
expect(p.profile_version).toBe('v1.2');
expect(p.profile_source).toBe('questionnaire');
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
expect(p.stage).toEqual({ unknown: 1 });
expect(p.emotion_score).toBeNull();
expect(p.context).toEqual({});
expect(p.need).toEqual({});
// conf_time=1completionFactor=0.5
expect(p.profile_confidence).toBe(0.5);
// unknown 会命中 unsafe_for_stage_unknown并带跨维度谓词
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown');
expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe(
true
);
});
it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => {
const p = buildUserProfileFromQuestionnaire(
{ mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' },
{ generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }
);
expect(p.emotion_score).toBe(0.2);
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low');
});
});

View File

@@ -0,0 +1,19 @@
export type {
BuildUserProfileOptions,
QuestionnaireAnswersV1_2,
UserProfileV1_2,
UserProfileV1_2_Extended,
} from './types';
export type { OnboardingSelections } from './onboardingMapping';
export {
buildUserProfileFromQuestionnaire,
computeProfileAnswered,
computeProfileConfidence,
computeTimeConfidence,
normalizeAnswers,
} from './scoring';
export { mapOnboardingSelectionsToQuestionnaireAnswers } from './onboardingMapping';

View File

@@ -0,0 +1,61 @@
import type { QuestionnaireAnswersV1_2 } from './types';
/**
* Onboarding UI 的选项 ID → 标准问卷枚举(可跳过)
*
* 说明:
* - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案
* - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null
*/
export type OnboardingSelections = Record<string, string[] | undefined>;
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;
}

View File

@@ -0,0 +1,233 @@
/**
* 用户画像打分User Profile ScoringV1.2
*
* 规则来源:
* - `spec_kit/User Profile Scoring/spec.md`
* - `设计说明文档/客戶端問卷打分規則.md`V1.2
*/
import type {
BuildUserProfileOptions,
ContextAnswer,
EmotionAnswer,
HardRules,
MomStageAnswer,
NeedAnswer,
ProfileAnswered,
QuestionnaireAnswersV1_2,
SparseOneHot,
UserProfileV1_2_Extended,
UserStageOneHot,
} from './types';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toDate(value: Date | string | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
const d = new Date(value);
return Number.isFinite(d.getTime()) ? d : null;
}
function isMomStageAnswer(v: unknown): v is MomStageAnswer {
return v === 'expecting' || v === 'parenting' || v === 'unknown';
}
function isEmotionAnswer(v: unknown): v is EmotionAnswer {
return (
v === 'low' ||
v === 'overwhelmed' ||
v === 'tired' ||
v === 'neutral' ||
v === 'calm' ||
v === 'joyful'
);
}
function isContextAnswer(v: unknown): v is ContextAnswer {
return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health';
}
function isNeedAnswer(v: unknown): v is NeedAnswer {
return (
v === 'emotional_support' ||
v === 'parenting_pressure' ||
v === 'self_worth' ||
v === 'anxiety_relief' ||
v === 'rest_balance'
);
}
/**
* 归一化答案:非法值按“跳过”处理(归一化为 undefined
* - `null` 保留,表示显式跳过/无值
*/
export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 {
const mom_stage =
raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined;
const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined;
const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined;
const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined;
return { mom_stage, emotion, context, need };
}
export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered {
return {
stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null,
emotion: normalized.emotion !== undefined && normalized.emotion !== null,
context: normalized.context !== undefined && normalized.context !== null,
need: normalized.need !== undefined && normalized.need !== null,
};
}
/**
* 时间衰减置信度conf_time
* - 07 天1.0
* - 730 天:线性衰减到 0.7(含第 30 天)
* - 30 天以上0.5
*/
export function computeTimeConfidence(generatedAt: Date, now: Date): number {
const deltaMs = now.getTime() - generatedAt.getTime();
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0;
const days = deltaMs / MS_PER_DAY;
if (days <= 7) return 1.0;
if (days <= 30) {
const t = (days - 7) / (30 - 7); // 0..1
return 1.0 - 0.3 * t; // 1 -> 0.7
}
return 0.5;
}
/**
* V1.2profile_confidenceconf_U
* conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
*/
export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number {
const answeredCount =
(answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0);
const completion = answeredCount / 4;
const completionFactor = 0.5 + 0.5 * completion;
return clamp(confTime * completionFactor, 0.2, 1.0);
}
function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot {
// V1.2mom_stage 跳过按安全策略输出 unknown=1
if (momStage === null || momStage === undefined) {
return { unknown: 1 };
}
return {
expecting: momStage === 'expecting' ? 1 : 0,
parenting: momStage === 'parenting' ? 1 : 0,
unknown: momStage === 'unknown' ? 1 : 0,
};
}
function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null {
if (emotion === null || emotion === undefined) return null;
switch (emotion) {
case 'low':
return 0.0;
case 'overwhelmed':
return 0.2;
case 'tired':
return 0.4;
case 'neutral':
return 0.6;
case 'calm':
return 0.8;
case 'joyful':
return 1.0;
}
}
function buildSparseOneHot(value: string | null | undefined): SparseOneHot {
if (value === null || value === undefined) return {};
return { [value]: 1 };
}
function computeRuleHitsAndHardRules(profile: {
stage: UserStageOneHot;
emotion_score: number | null;
}): { rule_hits: string[]; hard_rules: HardRules } {
const rule_hits: string[] = [];
const forbidden_risk_flags: string[] = [];
const stageUnknown = profile.stage.unknown === 1;
const stageParenting = profile.stage.parenting === 1;
if (stageUnknown) {
rule_hits.push('unsafe_for_stage_unknown');
forbidden_risk_flags.push('unsafe_for_stage_unknown');
}
if (stageParenting) {
rule_hits.push('unsafe_for_stage_parenting');
forbidden_risk_flags.push('unsafe_for_stage_parenting');
}
if (profile.emotion_score !== null && profile.emotion_score <= 0.2) {
rule_hits.push('unsafe_for_emotion_low');
forbidden_risk_flags.push('unsafe_for_emotion_low');
}
const forbidden_content_predicates = [];
if (stageUnknown) {
forbidden_content_predicates.push({
id: 'unknown_block_parenting_pressure_personalized',
when_user: { stage_unknown: true },
forbid_content: { need: 'parenting_pressure', personalization_power: 1 },
});
}
return {
rule_hits,
hard_rules: {
forbidden_risk_flags,
forbidden_content_predicates,
},
};
}
export function buildUserProfileFromQuestionnaire(
rawAnswers: QuestionnaireAnswersV1_2,
options: BuildUserProfileOptions = {}
): UserProfileV1_2_Extended {
const normalized = normalizeAnswers(rawAnswers);
const profile_answered = computeProfileAnswered(normalized);
const now = toDate(options.now) ?? new Date();
const generatedAt = toDate(options.generatedAt) ?? now;
const confTime = computeTimeConfidence(generatedAt, now);
const profile_confidence = computeProfileConfidence(confTime, profile_answered);
const stage = buildStageOneHot(normalized.mom_stage);
const emotion_score = mapEmotionScore(normalized.emotion);
const context = buildSparseOneHot(normalized.context);
const need = buildSparseOneHot(normalized.need);
const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score });
return {
profile_version: 'v1.2',
profile_source: 'questionnaire',
profile_generated_at: generatedAt.toISOString(),
profile_confidence,
profile_answered,
stage,
emotion_score,
context,
need,
rule_hits,
hard_rules,
};
}

View File

@@ -0,0 +1,95 @@
/**
* 用户画像打分User Profile ScoringV1.2 类型定义
*
* 说明:
* - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)
* - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`V1.2)为准
*/
export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown';
export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful';
export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health';
export type NeedAnswer =
| 'emotional_support'
| 'parenting_pressure'
| 'self_worth'
| 'anxiety_relief'
| 'rest_balance';
/**
* V1.2:每题可跳过
* - `undefined`:字段缺失(可能是“没传”)
* - `null`:显式跳过/无值(例如 UI 明确传 null
*/
export type QuestionnaireAnswersV1_2 = {
mom_stage?: MomStageAnswer | null;
emotion?: EmotionAnswer | null;
context?: ContextAnswer | null;
need?: NeedAnswer | null;
};
export type ProfileAnswered = {
stage: boolean;
emotion: boolean;
context: boolean;
need: boolean;
};
export type UserStageOneHot = {
expecting?: 0 | 1;
parenting?: 0 | 1;
unknown: 0 | 1;
};
export type SparseOneHot = Record<string, 1>;
export type UserProfileV1_2 = {
profile_version: 'v1.2';
profile_source: 'questionnaire';
profile_generated_at: string; // ISO8601
profile_confidence: number; // 01
profile_answered: ProfileAnswered;
stage: UserStageOneHot;
emotion_score: number | null;
context: SparseOneHot;
need: SparseOneHot;
};
export type ForbiddenContentPredicate = {
/**
* 谓词 ID用于可观测与回归测试
*/
id: string;
/**
* 触发条件(用户侧)
* 说明:这里刻意保持为 object便于未来接入规则引擎时做 schema 对齐。
*/
when_user: Record<string, unknown>;
/**
* 禁推条件(内容侧)
* 说明:本模块不判断内容的 `personalization_power`,只输出可执行条件。
*/
forbid_content: Record<string, unknown>;
};
export type HardRules = {
forbidden_risk_flags: string[];
forbidden_content_predicates: ForbiddenContentPredicate[];
};
export type UserProfileV1_2_Extended = UserProfileV1_2 & {
rule_hits: string[];
hard_rules: HardRules;
};
export type BuildUserProfileOptions = {
/**
* 画像生成时间;不传则使用当前时间
*/
generatedAt?: Date | string;
/**
* 当前时间(用于计算 time decay不传则使用当前时间
*/
now?: Date | string;
};

View File

@@ -4,30 +4,21 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import en from './locales/en.json'; import en from './locales/en.json';
import es from './locales/es.json';
import pt from './locales/pt.json';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json'; import zhTW from './locales/zh-TW.json';
/** /**
* 语言码约定: * 语言码约定:
* - 简体中文zh-CN
* - 繁体中文zh-TW * - 繁体中文zh-TW
* - 英语en * - 英语en
* - 西班牙语es
* - 葡萄牙语pt
*/ */
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt'; export type AppLanguage = 'zh-TW' | 'en';
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [ export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
'zh-CN',
'zh-TW', 'zh-TW',
'en', 'en',
'es',
'pt',
] as const; ] as const;
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN'; const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
const STORAGE_KEY_LANGUAGE = 'settings.language'; const STORAGE_KEY_LANGUAGE = 'settings.language';
function isSupportedLanguage(lang: string): lang is AppLanguage { function isSupportedLanguage(lang: string): lang is AppLanguage {
@@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage { function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase(); const tag = languageTag.toLowerCase();
// 中文:优先区分繁简 // 中文:当前仅支持繁体中文zh-TW
if (tag.startsWith('zh')) { if (tag.startsWith('zh')) {
// 常见繁体标记zh-TW / zh-HK / zh-Hant
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
return 'zh-TW'; return 'zh-TW';
} }
return 'zh-CN';
}
// 其他语言:按前缀匹配 // 其他语言:按前缀匹配(当前仅支持英文)
if (tag.startsWith('en')) return 'en'; if (tag.startsWith('en')) return 'en';
if (tag.startsWith('es')) return 'es';
if (tag.startsWith('pt')) return 'pt';
return DEFAULT_FALLBACK_LANGUAGE; return DEFAULT_FALLBACK_LANGUAGE;
} }
@@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise<void> {
* 语言选择优先级: * 语言选择优先级:
* 1) 用户设置(若存在) * 1) 用户设置(若存在)
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退) * 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
* 3) 默认回退(zh-CN * 3) 默认回退(en
*/ */
export async function initI18n(): Promise<void> { export async function initI18n(): Promise<void> {
if (i18n.isInitialized) return; if (i18n.isInitialized) return;
@@ -98,11 +83,8 @@ export async function initI18n(): Promise<void> {
await i18n.use(initReactI18next).init({ await i18n.use(initReactI18next).init({
resources: { resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW }, 'zh-TW': { translation: zhTW },
en: { translation: en }, en: { translation: en },
es: { translation: es },
pt: { translation: pt },
}, },
lng: initialLang, lng: initialLang,
fallbackLng: DEFAULT_FALLBACK_LANGUAGE, fallbackLng: DEFAULT_FALLBACK_LANGUAGE,

View File

@@ -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<string, unknown> | null;
};
export type RecoMeta = Record<string, unknown>;
export type RecoEngineResult = {
items: RecommendedItem[];
meta: RecoMeta;
};
export type RecoRequest = {
k?: number;
user_profile: UserProfileV1_2;
already_recommended_ids?: Array<string | number>;
touched_or_viewed_ids?: Array<string | number>;
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<RecoEngineResult> {
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;
}

View File

@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
/** /**
* 本地存储 key 统一管理,避免 UI 里散落硬编码 * 本地存储 key 统一管理,避免 UI 里散落硬编码
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
const KEY_FAVORITES_ITEMS = 'favorites.items'; const KEY_FAVORITES_ITEMS = 'favorites.items';
const KEY_CONSENT_ACCEPTED = 'consent.accepted'; const KEY_CONSENT_ACCEPTED = 'consent.accepted';
const KEY_USER_PROFILE = 'user.profile'; 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_UI_THEME_MODE = 'ui.theme.mode';
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
@@ -20,11 +24,42 @@ export type UserProfile = {
name?: string; name?: string;
intents?: string[]; intents?: string[];
}; };
/**
* 用户画像(问卷打分输出)
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
*/
export type UserProfileScoring = UserProfileV1_2_Extended;
export type DailyReminderSettings = { export type DailyReminderSettings = {
timesPerDay: number; timesPerDay: number;
pushEnabled: boolean; pushEnabled: boolean;
}; };
export type RecoFeedCacheItem = {
content_id: number;
text: string;
};
export type RecoFeedCache = {
saved_at: string; // ISO8601
items: RecoFeedCacheItem[];
meta?: Record<string, unknown>;
};
/**
* 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<T>(key: string, fallback: T): Promise<T> { async function getJson<T>(key: string, fallback: T): Promise<T> {
const raw = await AsyncStorage.getItem(key); const raw = await AsyncStorage.getItem(key);
@@ -70,6 +105,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
} }
export type FavoriteItem = { export type FavoriteItem = {
favId: string; // 唯一标识,支持重复点赞同一文案
id: string; id: string;
date: string; date: string;
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -82,15 +118,15 @@ export async function getFavorites(): Promise<FavoriteItem[]> {
export async function addFavorite(item: FavoriteItem): Promise<void> { export async function addFavorite(item: FavoriteItem): Promise<void> {
const list = await getFavorites(); const list = await getFavorites();
if (list.some(i => i.id === item.id)) return; // 允许重复点赞,不再根据 id 去重
const newList = [item, ...list]; const newList = [item, ...list];
console.log('Adding to favorites, new list size:', newList.length); console.log('Adding to favorites, new list size:', newList.length);
await setJson(KEY_FAVORITES_ITEMS, newList); await setJson(KEY_FAVORITES_ITEMS, newList);
} }
export async function removeFavorite(contentId: string): Promise<void> { export async function removeFavorite(favId: string): Promise<void> {
const list = await getFavorites(); const list = await getFavorites();
const next = list.filter(item => item.id !== contentId); const next = list.filter(item => item.favId !== favId);
await setJson(KEY_FAVORITES_ITEMS, next); await setJson(KEY_FAVORITES_ITEMS, next);
} }
@@ -122,6 +158,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
await setJson(KEY_USER_PROFILE, { ...current, ...profile }); await setJson(KEY_USER_PROFILE, { ...current, ...profile });
} }
export async function getUserProfileScoring(): Promise<UserProfileScoring | null> {
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<void> {
await setJson(KEY_USER_PROFILE_SCORING, profile);
}
export async function getRecoFeedCache(): Promise<RecoFeedCache | null> {
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<void> {
await setJson(KEY_RECO_FEED_CACHE, cache);
}
export async function getRecoFeedHistory(): Promise<RecoFeedHistory> {
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<RecoFeedHistory>;
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<void> {
await setJson(KEY_RECO_FEED_HISTORY, history);
}
function uniqKeepLatest(list: number[], max: number): number[] {
const seen = new Set<number>();
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<void> {
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<void> {
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<DailyReminderSettings> { export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, { const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
timesPerDay: 3, timesPerDay: 3,

View File

@@ -7,13 +7,13 @@ APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
# 数据库dev 指向 mindfulness_devprod 指向 mindfulness # 数据库dev 指向 mindfulness_devprod 指向 mindfulness
DATABASE_URL=mysql+aiomysql://<用户名>:<密码>@<MYSQL_HOST>:3306/mindfulness_dev?charset=utf8mb4 DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness_dev?charset=utf8mb4
# Redis使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀) # Redis使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀)
REDIS_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0 REDIS_URL=redis://dev_damer:damer@43.163.242.87:6379/0
# Celery默认不启用结果存储避免 Redis 内存压力) # Celery默认不启用结果存储避免 Redis 内存压力)
CELERY_BROKER_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0 CELERY_BROKER_URL=redis://dev_damer:damer@43.163.242.87:6379/0
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0 # CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
# 推送Expo # 推送Expo

20
server/.env.prod Normal file
View File

@@ -0,0 +1,20 @@
# 运行环境dev 或 prod
APP_ENV=prod
# Web 服务
APP_NAME=mindfulness-server
APP_HOST=0.0.0.0
APP_PORT=8000
# 数据库dev 指向 mindfulness_devprod 指向 mindfulness
DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness?charset=utf8mb4
# Redis使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀)
REDIS_URL=redis://prod_damer:damer@43.163.242.87:6379/0
# Celery默认不启用结果存储避免 Redis 内存压力)
CELERY_BROKER_URL=redis://prod_damer:damer@43.163.242.87:6379/0
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
# 推送Expo
# EXPO_ACCESS_TOKEN=

BIN
server/.test.db Normal file

Binary file not shown.

39
server/alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
# 注意:实际连接串由 alembic/env.py 从环境变量 DATABASE_URL 注入
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

40
server/alembic/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Alembic数据库迁移
## 1. 前置
-`server/` 下准备 `.env.dev`(或系统环境变量),至少包含:
- `DATABASE_URL=mysql+aiomysql://...`
> 注意:本仓库推荐使用 Python 虚拟环境venv。示例以 `server/.venv` 为准。
---
## 2. 安装依赖(一次性)
在仓库根目录:
```bash
python3 -m venv server/.venv
source server/.venv/bin/activate
pip install -r server/requirements.txt
```
---
## 3. 常用命令
`server/` 目录运行:
```bash
source .venv/bin/activate
alembic -c alembic.ini history
alembic -c alembic.ini upgrade head
```
---
## 4. 说明
- 连接串由 `alembic/env.py` 从环境变量 `DATABASE_URL`(或 `app/core/config.py`)读取。
- 初始迁移版本为:`0001_init_content_tables`(创建推荐系统最小内容表与画像表)。

137
server/alembic/env.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import asyncio
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
# 让 alembic 在 `server/` 下运行时也能 import app.*
SERVER_DIR = Path(__file__).resolve().parents[1] # .../server/alembic -> .../server
sys.path.append(str(SERVER_DIR))
from app.db.base import Base # noqa: E402
import app.db.models # noqa: F401,E402 # 确保模型被导入metadata 完整
# Alembic Config 对象
config = context.config
# 配置日志
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 目标 metadataautogenerate 依赖)
target_metadata = Base.metadata
def _read_env_kv(env_path: Path) -> dict[str, str]:
"""
读取 .env 文件中的 KEY=VALUE。
说明:
- 迁移阶段只需要 DATABASE_URL不应因为 Redis/Celery 等配置缺失而失败
- 这里不依赖 pydantic-settings 的 Settings 校验,避免“缺字段导致迁移不可用”
"""
data: dict[str, str] = {}
if not env_path.exists():
return data
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k:
data[k] = v
return data
def _get_database_url() -> str:
"""
获取数据库连接串。
约定:
- 优先读取环境变量 `DATABASE_URL`
- 若未设置,则按 `APP_ENV`(默认 dev读取 `server/.env.dev` 或 `server/.env.prod`
注意:迁移阶段仅依赖 DATABASE_URL不应强制要求 REDIS_URL / CELERY_BROKER_URL 等配置存在。
"""
# 允许在 alembic 命令时临时覆盖
env_url = os.getenv("DATABASE_URL")
if env_url:
return env_url
app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev"
env_file = SERVER_DIR / (".env.prod" if app_env == "prod" else ".env.dev")
kv = _read_env_kv(env_file)
url = kv.get("DATABASE_URL")
if url:
return url
raise RuntimeError(
"缺少 DATABASE_URL请设置环境变量 DATABASE_URL或在 server/.env.dev或 .env.prod中配置 DATABASE_URL。"
)
def run_migrations_offline() -> None:
"""离线模式:生成 SQL 脚本,不连接数据库。"""
url = _get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""在线模式:在已有连接上执行迁移。"""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""在线模式:使用异步引擎执行迁移。"""
url = _get_database_url()
config.set_main_option("sqlalchemy.url", url)
connectable = async_engine_from_config(
config.get_section(config.config_ini_section) or {},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,147 @@
"""init content tables
Revision ID: 0001_init_content_tables
Revises:
Create Date: 2026-02-01
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0001_init_content_tables"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"contents",
sa.Column(
"content_id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="文案唯一 ID自增文案微调时保持不变",
),
sa.Column("text_en", sa.Text(), nullable=True, comment="英文文案(可空;若为空则必须提供 text_tc"),
sa.Column("text_tc", sa.Text(), nullable=True, comment="繁体中文文案(可空;若为空则必须提供 text_en"),
sa.Column("author_id", sa.String(length=255), nullable=True, comment="作者/来源 ID可空用于多样性与频控"),
sa.Column("template_id", sa.String(length=255), nullable=True, comment="模板 ID可空用于多样性与频控"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
sa.CheckConstraint(
"(text_en IS NOT NULL) OR (text_tc IS NOT NULL)",
name="chk_contents_text_present",
),
mysql_charset="utf8mb4",
)
op.create_index("idx_contents_author_id", "contents", ["author_id"], unique=False)
op.create_index("idx_contents_template_id", "contents", ["template_id"], unique=False)
op.create_table(
"content_profiles",
sa.Column(
"content_id",
mysql.BIGINT(unsigned=True),
sa.ForeignKey("contents.content_id", ondelete="CASCADE"),
primary_key=True,
comment="FK -> contents.content_id",
),
sa.Column(
"stage",
sa.Enum("general", "expecting", "parenting", "unknown", name="content_stage"),
server_default="general",
nullable=False,
comment="母职阶段定位general/expecting/parenting/unknown",
),
sa.Column("emotion_score", sa.Numeric(3, 2), nullable=True, comment="情绪调性 0~1NULL 表示 general"),
sa.Column(
"context_suitability_json",
sa.JSON(),
nullable=False,
comment="各 context 的适配度JSON0/0.5/1必须包含 5 个 key",
),
sa.Column(
"need_suitability_json",
sa.JSON(),
nullable=False,
comment="各 need 的适配度JSON0/0.5/1必须包含 5 个 key",
),
sa.Column(
"personalization_power",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="个性化力度(约定只允许 0/5/10分别映射 0/0.5/1",
),
sa.Column(
"review_confidence",
sa.Numeric(3, 2),
nullable=True,
comment="标注置信度 0~1NULL 表示由推荐侧按 0.7 兜底",
),
sa.Column(
"is_safe_pool",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
comment="是否属于通用安全池L3 兜底)",
),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="画像更新时间"),
mysql_charset="utf8mb4",
)
op.create_index("idx_profiles_is_safe_pool", "content_profiles", ["is_safe_pool"], unique=False)
op.create_index("idx_profiles_personalization_power", "content_profiles", ["personalization_power"], unique=False)
op.create_index("idx_profiles_stage", "content_profiles", ["stage"], unique=False)
op.create_table(
"content_risk_flags",
sa.Column(
"id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="主键",
),
sa.Column(
"content_id",
mysql.BIGINT(unsigned=True),
sa.ForeignKey("contents.content_id", ondelete="CASCADE"),
nullable=False,
comment="FK -> contents.content_id",
),
sa.Column(
"flag",
sa.String(length=64),
nullable=False,
comment="风险标记unsafe_for_* / block_* / soft_*",
),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
sa.UniqueConstraint("content_id", "flag", name="uniq_content_flag"),
mysql_charset="utf8mb4",
)
op.create_index("idx_content_id", "content_risk_flags", ["content_id"], unique=False)
op.create_index("idx_flag", "content_risk_flags", ["flag"], unique=False)
def downgrade() -> None:
op.drop_index("idx_flag", table_name="content_risk_flags")
op.drop_index("idx_content_id", table_name="content_risk_flags")
op.drop_table("content_risk_flags")
op.drop_index("idx_profiles_stage", table_name="content_profiles")
op.drop_index("idx_profiles_personalization_power", table_name="content_profiles")
op.drop_index("idx_profiles_is_safe_pool", table_name="content_profiles")
op.drop_table("content_profiles")
op.drop_index("idx_contents_template_id", table_name="contents")
op.drop_index("idx_contents_author_id", table_name="contents")
op.drop_table("contents")

View File

@@ -0,0 +1,6 @@
"""
API 路由入口
说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。
"""

62
server/app/api/limits.py Normal file
View File

@@ -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 可接受)
- 窗口粒度:按分钟 bucketwindow_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:
"""
推荐接口限流:按 IP1 分钟 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())

View File

@@ -0,0 +1,4 @@
"""
V1 API 路由集合
"""

156
server/app/api/v1/reco.py Normal file
View File

@@ -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(),
)

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from fastapi import APIRouter
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
from app.features.user_profile_scoring.types import BuildUserProfileRequest, UserProfileV1_2_Extended
router = APIRouter(prefix="/v1/user-profile", tags=["user-profile"])
@router.post("/score", response_model=UserProfileV1_2_Extended)
async def score_user_profile(req: BuildUserProfileRequest) -> UserProfileV1_2_Extended:
"""
根据问卷答案生成用户画像V1.2
说明:
- 问卷题目允许跳过
- 允许注入 generated_at/now用于回归测试或离线批处理
"""
return build_user_profile_from_questionnaire(
req.answers,
generated_at=req.generated_at,
now=req.now,
)

View File

@@ -0,0 +1,14 @@
"""
数据库 ORM 模型集合。
说明:
- 该包用于集中定义 SQLAlchemy ORM models供 Alembic autogenerate 扫描。
- 需要在此处导入所有模型,确保 `Base.metadata` 完整。
"""
from app.db.models.content import Content
from app.db.models.content_profile import ContentProfile
from app.db.models.content_risk_flag import ContentRiskFlag
__all__ = ["Content", "ContentProfile", "ContentRiskFlag"]

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Index, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Content(Base):
"""
文案主体表。
多语言约束:
- 当前仅支持 EN / TC繁体中文
- 至少需要提供 `text_en` 或 `text_tc` 之一
"""
__tablename__ = "contents"
__table_args__ = (
CheckConstraint(
"(text_en IS NOT NULL) OR (text_tc IS NOT NULL)",
name="chk_contents_text_present",
),
Index("idx_contents_author_id", "author_id"),
Index("idx_contents_template_id", "template_id"),
)
content_id: Mapped[int] = mapped_column(
primary_key=True,
autoincrement=True,
comment="文案唯一 ID自增文案微调时保持不变",
)
text_en: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="英文文案(可空;若为空则必须提供 text_tc",
)
text_tc: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="繁体中文文案(可空;若为空则必须提供 text_en",
)
author_id: Mapped[str | None] = mapped_column(
nullable=True,
comment="作者/来源 ID可空用于多样性与频控",
)
template_id: Mapped[str | None] = mapped_column(
nullable=True,
comment="模板 ID可空用于多样性与频控",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="更新时间",
)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Numeric,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
ContentStage = Literal["general", "expecting", "parenting", "unknown"]
class ContentProfile(Base):
"""
内容画像表Content Profile / Cᵢ
字段语义必须严格对齐:
- `设计说明文档/句子文案打分規則.md`
"""
__tablename__ = "content_profiles"
__table_args__ = (
Index("idx_profiles_stage", "stage"),
Index("idx_profiles_personalization_power", "personalization_power"),
Index("idx_profiles_is_safe_pool", "is_safe_pool"),
)
content_id: Mapped[int] = mapped_column(
ForeignKey("contents.content_id", ondelete="CASCADE"),
primary_key=True,
comment="FK -> contents.content_id",
)
stage: Mapped[ContentStage] = mapped_column(
Enum("general", "expecting", "parenting", "unknown", name="content_stage"),
nullable=False,
server_default="general",
comment="母职阶段定位general/expecting/parenting/unknown",
)
emotion_score: Mapped[Optional[float]] = mapped_column(
Numeric(3, 2),
nullable=True,
comment="情绪调性 0~1NULL 表示 general",
)
context_suitability_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="各 context 的适配度JSON0/0.5/1必须包含 5 个 key",
)
need_suitability_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="各 need 的适配度JSON0/0.5/1必须包含 5 个 key",
)
personalization_power: Mapped[int] = mapped_column(
nullable=False,
server_default="0",
comment="个性化力度(约定只允许 0/5/10分别映射 0/0.5/1",
)
review_confidence: Mapped[Optional[float]] = mapped_column(
Numeric(3, 2),
nullable=True,
comment="标注置信度 0~1NULL 表示由推荐侧按 0.7 兜底",
)
is_safe_pool: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default="0",
comment="是否属于通用安全池L3 兜底)",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="画像更新时间",
)

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ContentRiskFlag(Base):
"""
内容风险标记risk_flags关联表。
命名约束(语义来源:句子文案打分规则):
- 仅允许 `unsafe_for_*` / `block_*` / `soft_*` 前缀
- 旧 flag如 `block_stage_unknown`)需在写入/读取层做映射
"""
__tablename__ = "content_risk_flags"
__table_args__ = (
UniqueConstraint("content_id", "flag", name="uniq_content_flag"),
Index("idx_flag", "flag"),
Index("idx_content_id", "content_id"),
)
id: Mapped[int] = mapped_column(
primary_key=True,
autoincrement=True,
comment="主键",
)
content_id: Mapped[int] = mapped_column(
ForeignKey("contents.content_id", ondelete="CASCADE"),
nullable=False,
comment="FK -> contents.content_id",
)
flag: Mapped[str] = mapped_column(
nullable=False,
comment="风险标记unsafe_for_* / block_* / soft_*",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="创建时间",
)

View File

@@ -0,0 +1,6 @@
"""
Personalized Reco个性化推荐功能模块集合。
该目录用于承载推荐引擎与其子模块(数据访问、打分、重排、可观测等)。
"""

View File

@@ -0,0 +1,8 @@
"""
Content Repository候选查询与数据访问层
说明:
- 本模块为推荐引擎提供可注入的数据访问接口(与 ORM/SQL 解耦)。
- 负责将 DB 存储形态规范化为上层稳定的 ContentProfile 结构。
"""

View File

@@ -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 跳过)。
"""

View File

@@ -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

View File

@@ -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_entc -> 必须 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]

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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 口径)。
规则:
- needuser_profile.need 为空对象 {} 或不存在
- contextuser_profile.context 为空对象 {} 或不存在
- emotionuser_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_reasonserved_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"

View File

@@ -0,0 +1,11 @@
"""
Reco Engine推荐引擎编排
该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline
并输出统一结构items + meta可观测字段
"""
from app.features.personalized_reco.reco_engine.orchestrator import recommend
__all__ = ["recommend"]

View File

@@ -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())

View File

@@ -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,
)

View File

@@ -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_idsalready/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())

View File

@@ -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 作者/模板冷却窗口内的历史集合(增强频控输入)
# 说明若不提供Nonererank_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

View File

@@ -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

View File

@@ -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",
]

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, Scene
_DEFAULTS: dict[Scene, RerankConfig] = {
# FeedMMR λ=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())

View File

@@ -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 + MMRPush/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)

View File

@@ -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):
"""
重排/频控配置(可调参)。
"""
# FeedMMR
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

View File

@@ -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 字典中取最大值 keyV1 用作代表标签)。
- 空字典/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:<stage>
- need:<argmax_key>
- context:<argmax_key>
说明:
- 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

View File

@@ -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",
]

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from app.features.personalized_reco.scoring.types import Scene, ScoreConfig
_DEFAULTS: dict[Scene, ScoreConfig] = {
# 来源:设计说明文档/個性化推薦算法規則.mdV1 建议权重)
"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())

View File

@@ -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
# 文案 generalemotion_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命中=1unknown对非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)

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
"""
User Profile Scoring用户画像打分V1.2
说明:
- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现
- 规则以 `spec_kit/User Profile Scoring/spec.md`V1.2)与
`设计说明文档/客戶端問卷打分規則.md`V1.2)为准
"""

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from app.features.user_profile_scoring.types import (
HardRules,
ProfileAnswered,
QuestionnaireAnswersV1_2,
UserProfileV1_2_Extended,
UserStageOneHot,
)
def _clamp(value: float, min_value: float, max_value: float) -> float:
if value != value: # NaN
return min_value
return max(min_value, min(max_value, value))
def normalize_answers(raw: QuestionnaireAnswersV1_2) -> QuestionnaireAnswersV1_2:
"""
归一化答案:
- Pydantic 已对枚举做了校验;此处仅统一 None/缺失的语义为“跳过”
"""
# 直接返回一份拷贝,保持纯函数语义
return QuestionnaireAnswersV1_2.model_validate(raw.model_dump())
def compute_profile_answered(answers: QuestionnaireAnswersV1_2) -> ProfileAnswered:
return ProfileAnswered(
stage=answers.mom_stage is not None,
emotion=answers.emotion is not None,
context=answers.context is not None,
need=answers.need is not None,
)
def compute_time_confidence(generated_at: datetime, now: datetime) -> float:
"""
时间衰减置信度conf_time
- 07 天1.0
- 730 天:线性衰减到 0.7(含第 30 天)
- 30 天以上0.5
"""
delta = (now - generated_at).total_seconds()
if delta <= 0:
return 1.0
days = delta / (24 * 60 * 60)
if days <= 7:
return 1.0
if days <= 30:
t = (days - 7) / (30 - 7) # 0..1
return 1.0 - 0.3 * t
return 0.5
def compute_profile_confidence(conf_time: float, answered: ProfileAnswered) -> float:
"""
V1.2profile_confidenceconf_U
conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
"""
answered_count = sum(
[
1 if answered.stage else 0,
1 if answered.emotion else 0,
1 if answered.context else 0,
1 if answered.need else 0,
]
)
completion = answered_count / 4
completion_factor = 0.5 + 0.5 * completion
return _clamp(float(conf_time) * float(completion_factor), 0.2, 1.0)
def _build_stage_one_hot(mom_stage: Optional[str]) -> UserStageOneHot:
# V1.2mom_stage 跳过按安全策略输出 unknown=1
if mom_stage is None:
return UserStageOneHot(unknown=1)
return UserStageOneHot(
expecting=1 if mom_stage == "expecting" else 0,
parenting=1 if mom_stage == "parenting" else 0,
unknown=1 if mom_stage == "unknown" else 0,
)
def _map_emotion_score(emotion: Optional[str]) -> Optional[float]:
if emotion is None:
return None
mapping = {
"low": 0.0,
"overwhelmed": 0.2,
"tired": 0.4,
"neutral": 0.6,
"calm": 0.8,
"joyful": 1.0,
}
return mapping.get(emotion)
def _build_sparse_one_hot(value: Optional[str]) -> dict[str, int]:
if value is None:
return {}
return {value: 1}
@dataclass(frozen=True)
class _RuleOutput:
rule_hits: list[str]
hard_rules: HardRules
def _compute_rule_output(stage: UserStageOneHot, emotion_score: Optional[float]) -> _RuleOutput:
rule_hits: list[str] = []
forbidden_risk_flags: list[str] = []
stage_unknown = stage.unknown == 1
stage_parenting = stage.parenting == 1
if stage_unknown:
rule_hits.append("unsafe_for_stage_unknown")
forbidden_risk_flags.append("unsafe_for_stage_unknown")
if stage_parenting:
rule_hits.append("unsafe_for_stage_parenting")
forbidden_risk_flags.append("unsafe_for_stage_parenting")
if emotion_score is not None and emotion_score <= 0.2:
rule_hits.append("unsafe_for_emotion_low")
forbidden_risk_flags.append("unsafe_for_emotion_low")
forbidden_content_predicates = []
if stage_unknown:
forbidden_content_predicates.append(
{
"id": "unknown_block_parenting_pressure_personalized",
"when_user": {"stage_unknown": True},
"forbid_content": {"need": "parenting_pressure", "personalization_power": 1},
}
)
return _RuleOutput(
rule_hits=rule_hits,
hard_rules=HardRules(
forbidden_risk_flags=forbidden_risk_flags,
forbidden_content_predicates=forbidden_content_predicates,
),
)
def build_user_profile_from_questionnaire(
raw_answers: QuestionnaireAnswersV1_2,
*,
generated_at: Optional[datetime] = None,
now: Optional[datetime] = None,
) -> UserProfileV1_2_Extended:
"""
主入口:问卷答案(可跳过)→ 用户画像V1.2+ 硬规则输出
"""
answers = normalize_answers(raw_answers)
answered = compute_profile_answered(answers)
now_dt = now or datetime.now(tz=timezone.utc)
gen_dt = generated_at or now_dt
conf_time = compute_time_confidence(gen_dt, now_dt)
conf_u = compute_profile_confidence(conf_time, answered)
stage = _build_stage_one_hot(answers.mom_stage)
emotion_score = _map_emotion_score(answers.emotion)
context = _build_sparse_one_hot(answers.context)
need = _build_sparse_one_hot(answers.need)
rule_out = _compute_rule_output(stage, emotion_score)
return UserProfileV1_2_Extended(
profile_generated_at=gen_dt,
profile_confidence=conf_u,
profile_answered=answered,
stage=stage,
emotion_score=emotion_score,
context=context, # type: ignore[arg-type]
need=need, # type: ignore[arg-type]
rule_hits=rule_out.rule_hits,
hard_rules=rule_out.hard_rules,
)

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
MomStageAnswer = Literal["expecting", "parenting", "unknown"]
EmotionAnswer = Literal["low", "overwhelmed", "tired", "neutral", "calm", "joyful"]
ContextAnswer = Literal["family", "work", "relationship", "friends", "health"]
NeedAnswer = Literal[
"emotional_support",
"parenting_pressure",
"self_worth",
"anxiety_relief",
"rest_balance",
]
class QuestionnaireAnswersV1_2(BaseModel):
"""
V1.2:每题可跳过
说明:
- `None` 表示题目被跳过/无值(与客户端的 `null` 对齐)
- 字段缺失(未传)也视为跳过
"""
mom_stage: Optional[MomStageAnswer] = None
emotion: Optional[EmotionAnswer] = None
context: Optional[ContextAnswer] = None
need: Optional[NeedAnswer] = None
class ProfileAnswered(BaseModel):
stage: bool
emotion: bool
context: bool
need: bool
class UserStageOneHot(BaseModel):
expecting: Optional[Literal[0, 1]] = None
parenting: Optional[Literal[0, 1]] = None
unknown: Literal[0, 1]
class ForbiddenContentPredicate(BaseModel):
"""
用于表达“需要同时看用户与内容字段才能执行”的规则(跨维度规则)。
"""
id: str
when_user: dict[str, Any] = Field(default_factory=dict)
forbid_content: dict[str, Any] = Field(default_factory=dict)
class HardRules(BaseModel):
forbidden_risk_flags: list[str] = Field(default_factory=list)
forbidden_content_predicates: list[ForbiddenContentPredicate] = Field(default_factory=list)
class UserProfileV1_2(BaseModel):
profile_version: Literal["v1.2"] = "v1.2"
profile_source: Literal["questionnaire"] = "questionnaire"
profile_generated_at: datetime
profile_confidence: float
profile_answered: ProfileAnswered
stage: UserStageOneHot
emotion_score: Optional[float] = None
context: dict[str, Literal[1]] = Field(default_factory=dict)
need: dict[str, Literal[1]] = Field(default_factory=dict)
class UserProfileV1_2_Extended(UserProfileV1_2):
rule_hits: list[str] = Field(default_factory=list)
hard_rules: HardRules = Field(default_factory=HardRules)
class BuildUserProfileRequest(BaseModel):
"""
API 请求体:问卷答案 + 可选时间注入(便于回归测试/服务端批处理)
"""
answers: QuestionnaireAnswersV1_2 = Field(default_factory=QuestionnaireAnswersV1_2)
generated_at: Optional[datetime] = None
now: Optional[datetime] = None

View File

@@ -1,6 +1,8 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.core.config import get_settings from app.core.config import get_settings
from app.api.v1.reco import router as reco_router
from app.api.v1.user_profile_scoring import router as user_profile_router
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -14,6 +16,10 @@ def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name)
# 业务路由
app.include_router(user_profile_router)
app.include_router(reco_router)
@app.get("/healthz") @app.get("/healthz")
async def healthz() -> dict: async def healthz() -> dict:
return {"status": "ok", "env": settings.app_env} return {"status": "ok", "env": settings.app_env}

168
server/app/tasks/reco.py Normal file
View File

@@ -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

View File

@@ -4,6 +4,8 @@ uvicorn[standard]>=0.27
# 数据库SQLAlchemy 2.x 异步 + MySQL # 数据库SQLAlchemy 2.x 异步 + MySQL
SQLAlchemy>=2.0 SQLAlchemy>=2.0
aiomysql>=0.2 aiomysql>=0.2
greenlet>=3.0
aiosqlite>=0.20
# 配置 # 配置
pydantic>=2.6 pydantic>=2.6
@@ -18,3 +20,5 @@ redis>=5.0
# 测试 # 测试
pytest>=8.0 pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27

145
server/run.sh Executable file
View File

@@ -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 <host> uvicorn host默认 0.0.0.0
--port <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[@]}"

Some files were not shown because too many files have changed in this diff Show More