Compare commits
11 Commits
4b739dd194
...
Hao
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996999453a | ||
|
|
64b8352ad3 | ||
|
|
d045237952 | ||
| 3587a24115 | |||
|
|
6dc4e2b943 | ||
|
|
936094211b | ||
|
|
be38d817d5 | ||
|
|
58d17fc39f | ||
| 502a6ac500 | |||
| f49cbb7186 | |||
|
|
814b96edb6 |
@@ -2,3 +2,4 @@
|
||||
客户端请按照标准的RN架构目录写代码
|
||||
后端请按照标准的python FastAPI 架构目录写代码
|
||||
现在多语言仅支持 EN / TC
|
||||
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
- 输入/输出定义
|
||||
- 验收标准(可验证)
|
||||
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
|
||||
4. 保留大 spec.md 的高层背景/总览到 overview 部分。
|
||||
4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序。
|
||||
5. 子模块之间按逻辑关系关联。
|
||||
6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。
|
||||
@@ -2,3 +2,4 @@
|
||||
根据对应的plan.md 生成task.md
|
||||
任务清单详细可执行
|
||||
执行完要标记
|
||||
整个task.md执行完毕后需要在对应的overview.md标记
|
||||
|
||||
1
.cursor/commands/myspec.test.md
Normal file
1
.cursor/commands/myspec.test.md
Normal file
@@ -0,0 +1 @@
|
||||
使用测试工具完成集成测试,并给我一份简单的测试报告
|
||||
@@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。
|
||||
输出时根据这个结构生成内容时,请保持文件职责清晰。
|
||||
简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md
|
||||
可以通过nvm 切换node版本
|
||||
在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Python(运行产物)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Node / JS
|
||||
node_modules/
|
||||
npm-debug.*
|
||||
|
||||
@@ -18,9 +18,16 @@ import {
|
||||
getUserProfile,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
getRecoFeedCache,
|
||||
setRecoFeedCache,
|
||||
getUserProfileScoring,
|
||||
getRecoFeedHistory,
|
||||
recordRecoFeedServed,
|
||||
type ThemeMode,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
import ThemeModal from '@/components/home/ThemeModal';
|
||||
|
||||
@@ -65,8 +72,12 @@ const THEME_COLORS = [
|
||||
'#CBD9F2',
|
||||
];
|
||||
|
||||
type FeedItem = { content_id: string; text: string };
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isEnglish = i18n.language?.startsWith('en');
|
||||
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
const navigation = useNavigation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
@@ -75,29 +86,113 @@ export default function HomeScreen() {
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||
const isFetchingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
feedItemsRef.current = feedItems;
|
||||
}, [feedItems]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
|
||||
// 动画相关 Shared Values
|
||||
const translateY = useSharedValue(0);
|
||||
const opacity = useSharedValue(1);
|
||||
const likeScale = useSharedValue(1);
|
||||
|
||||
// 每次进入页面或页面获得焦点时刷新个人信息
|
||||
// 统一文案对象结构
|
||||
const currentFeed = useMemo(() => {
|
||||
if (feedItems.length > 0) {
|
||||
return feedItems;
|
||||
}
|
||||
return MOCK_CONTENT.map(item => ({
|
||||
content_id: item.id,
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
}, [feedItems, t]);
|
||||
|
||||
const item = useMemo(() => {
|
||||
const data = currentFeed[index % currentFeed.length];
|
||||
return {
|
||||
id: String(data.content_id),
|
||||
text: data.text
|
||||
};
|
||||
}, [currentFeed, index]);
|
||||
|
||||
// 异步拉取新文案
|
||||
const fetchNewFeed = useCallback(async () => {
|
||||
if (isFetchingRef.current) return;
|
||||
isFetchingRef.current = true;
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const scoringProfile = await getUserProfileScoring();
|
||||
if (!scoringProfile) return;
|
||||
|
||||
const history = await getRecoFeedHistory();
|
||||
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: scoringProfile,
|
||||
already_recommended_ids: history.already_recommended_ids,
|
||||
touched_or_viewed_ids: history.touched_or_viewed_ids,
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
const wasEmpty = feedItemsRef.current.length === 0;
|
||||
const newCache = {
|
||||
saved_at: new Date().toISOString(),
|
||||
lang: recoLang,
|
||||
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: meta as Record<string, unknown>,
|
||||
};
|
||||
await setRecoFeedCache(newCache);
|
||||
await recordRecoFeedServed(items.map((x) => x.content_id));
|
||||
setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||
// 如果当前是 mock 数据,切换到新数据的第一条
|
||||
if (wasEmpty) {
|
||||
setIndex(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch new feed:', error);
|
||||
} finally {
|
||||
isFetchingRef.current = false;
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [recoLang]);
|
||||
|
||||
// 每次进入页面或页面获得焦点时刷新个人信息和缓存文案
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
const cache = await getRecoFeedCache();
|
||||
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
|
||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||
} else {
|
||||
// 语言不匹配或没有缓存:先清空回落到本地 mock(会立即随语言切换),再拉取对应语言的推荐文案
|
||||
setFeedItems([]);
|
||||
setIndex(0);
|
||||
fetchNewFeed();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [])
|
||||
}, [fetchNewFeed, recoLang])
|
||||
);
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
@@ -118,8 +213,10 @@ export default function HomeScreen() {
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShadowVisible: false,
|
||||
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||
headerTransparent: themeMode === 'scenery',
|
||||
// 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header
|
||||
// 颜色主题下 Header 透明也不会影响观感(背景就是纯色)
|
||||
headerStyle: { backgroundColor: 'transparent' },
|
||||
headerTransparent: true,
|
||||
headerRight: () => (
|
||||
<View style={styles.headerRight}>
|
||||
<CircleIconButton
|
||||
@@ -161,6 +258,11 @@ export default function HomeScreen() {
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
|
||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||
runOnJS(fetchNewFeed)();
|
||||
}
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
|
||||
@@ -173,7 +275,7 @@ export default function HomeScreen() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, translateY, opacity]);
|
||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
@@ -223,20 +325,28 @@ export default function HomeScreen() {
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
await addFavorite({
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||
});
|
||||
};
|
||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||
await addFavorite(favItem);
|
||||
|
||||
// 3. 爱心缩放动画
|
||||
// 3. 记录到后端 Reaction(喜欢)
|
||||
console.log('Home: Triggering setReaction', item.id);
|
||||
await setReaction(item.id, 'like');
|
||||
|
||||
// 4. 爱心缩放动画
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
if (finished) {
|
||||
console.log('Home: Like animation finished, triggering next content');
|
||||
runOnJS(triggerNextContent)();
|
||||
}
|
||||
})
|
||||
@@ -259,7 +369,9 @@ export default function HomeScreen() {
|
||||
/>
|
||||
)}
|
||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
@@ -354,6 +466,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
paddingHorizontal: 50,
|
||||
|
||||
@@ -1,76 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||
import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage';
|
||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
setUserProfile,
|
||||
setDailyReminderSettings,
|
||||
setUserProfileScoring,
|
||||
setRecoFeedCache,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
||||
{
|
||||
id: 'status',
|
||||
type: 'selection',
|
||||
title: '媽媽的狀態?',
|
||||
options: [
|
||||
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
|
||||
{ id: 'has_kids', label: '已經有孩子' },
|
||||
{ id: 'no_fill', label: '不想填寫' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'emotion',
|
||||
type: 'selection',
|
||||
title: '當下情緒狀態?',
|
||||
options: [
|
||||
{ id: 'happy', label: '愉悅、滿足' },
|
||||
{ id: 'calm', label: '平靜、安穩' },
|
||||
{ id: 'stressed', label: '被壓得有點喘不過氣' },
|
||||
{ id: 'low', label: '情緒低落' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'influence',
|
||||
type: 'selection',
|
||||
title: '是什麼影響了你最近的感受?',
|
||||
options: [
|
||||
{ id: 'family', label: '家庭與孩子' },
|
||||
{ id: 'work', label: '工作或學習' },
|
||||
{ id: 'relationship', label: '親密關係' },
|
||||
{ id: 'friends', label: '朋友與人際' },
|
||||
{ id: 'health', label: '身心健康' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
type: 'selection',
|
||||
title: '最需要什麼支持?',
|
||||
options: [
|
||||
{ id: 'emotional', label: '情緒支持' },
|
||||
{ id: 'parenting', label: '育兒壓力' },
|
||||
{ id: 'self_worth', label: '自我價值' },
|
||||
{ id: 'anxiety', label: '焦慮舒緩' },
|
||||
{ id: 'balance', label: '休息與平衡' },
|
||||
]
|
||||
},
|
||||
{ id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' },
|
||||
type Step =
|
||||
| { id: 'name'; type: 'name' }
|
||||
| { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] }
|
||||
| { id: 'reminder'; type: 'reminder' };
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ id: 'name', type: 'name' },
|
||||
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
||||
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] },
|
||||
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
||||
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
||||
{ id: 'reminder', type: 'reminder' },
|
||||
];
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [name, setName] = useState('');
|
||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||
const [reminderTimes, setReminderTimes] = useState(3);
|
||||
|
||||
const currentStep = STEPS[stepIndex];
|
||||
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||
const currentOptions = useMemo(() => {
|
||||
if (currentStep.type !== 'selection') return [];
|
||||
return currentStep.optionIds.map((optId) => ({
|
||||
id: optId,
|
||||
label: t(`onboardingSurvey.steps.${currentStep.id}.options.${optId}`),
|
||||
}));
|
||||
}, [t, currentStep]);
|
||||
|
||||
async function onFinish() {
|
||||
// 请求推送权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
const pushEnabled = status === 'granted';
|
||||
|
||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||
|
||||
// 生成用户画像(供推荐/Push/Widget 复用)
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||
try {
|
||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
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(),
|
||||
lang,
|
||||
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({
|
||||
name,
|
||||
intents: Object.values(selections).flat()
|
||||
@@ -98,10 +117,16 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const onSkip = async () => {
|
||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
};
|
||||
|
||||
// 题目为多选:点击切换选中状态
|
||||
const handleToggleSelection = (id: string) => {
|
||||
setSelections(prev => {
|
||||
const currentIds = prev[currentStep.id] || [];
|
||||
@@ -112,9 +137,14 @@ export default function OnboardingScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipStep = () => {
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title={currentStep.title}
|
||||
title={currentTitle}
|
||||
currentStep={stepIndex}
|
||||
totalSteps={STEPS.length - 1}
|
||||
onSkip={onSkip}
|
||||
@@ -131,7 +161,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
{currentStep.type === 'selection' && (
|
||||
<SelectionStep
|
||||
options={currentStep.options!}
|
||||
options={currentOptions}
|
||||
selectedIds={selections[currentStep.id] || []}
|
||||
onToggle={handleToggleSelection}
|
||||
onNext={onNext}
|
||||
|
||||
@@ -34,7 +34,7 @@ SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
const [i18nReady, setI18nReady] = useState(false);
|
||||
@@ -48,7 +48,7 @@ export default function RootLayout() {
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||
console.error('i18n 初始化失败', e);
|
||||
console.error('i18n init failed', e);
|
||||
})
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
|
||||
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
|
||||
|
||||
/**
|
||||
* 启动分发:根据 consent 和 onboarding 状态跳转
|
||||
@@ -14,10 +13,6 @@ export default function Index() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// 【临时清除数据】:用于测试完整流程
|
||||
await AsyncStorage.clear();
|
||||
console.log('AsyncStorage has been cleared for testing.');
|
||||
|
||||
// 1. 检查是否同意协议
|
||||
const consentAccepted = await getConsentAccepted();
|
||||
if (cancelled) return;
|
||||
@@ -54,4 +49,3 @@ export default function Index() {
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
|
||||
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="#5E2A28" stroke-width="2"/>
|
||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="#5E2A28" stroke="#5E2A28" stroke-width="2"/>
|
||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="currentColor" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="#5E2A28" stroke-width="2"/>
|
||||
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import { getFavorites } from '@/src/storage/appStorage';
|
||||
import { getFavorites, getRecoFeedCache, type FavoriteItem } from '@/src/storage/appStorage';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -13,25 +13,50 @@ type Props = {
|
||||
|
||||
export default function FavoritesModal({ visible, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [items, setItems] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||
|
||||
// 每次打开时刷新一次,确保展示最新“喜欢”
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await getFavorites();
|
||||
if (!cancelled) setIds(list);
|
||||
const [favList, cache] = await Promise.all([
|
||||
getFavorites(),
|
||||
getRecoFeedCache()
|
||||
]);
|
||||
console.log('FavoritesModal: Loaded favList', favList.length, 'items');
|
||||
console.log('FavoritesModal: Loaded cache items', cache?.items?.length || 0);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// 建立文案查找表
|
||||
const textMap = new Map<string, string>();
|
||||
|
||||
// 1. 放入 Mock 数据
|
||||
MOCK_CONTENT.forEach(c => textMap.set(String(c.id), t(c.textKey)));
|
||||
|
||||
// 2. 放入缓存数据
|
||||
if (cache?.items) {
|
||||
cache.items.forEach(c => textMap.set(String(c.content_id), c.text));
|
||||
}
|
||||
|
||||
// 3. 组装最终展示列表
|
||||
const enriched = favList.map(fav => {
|
||||
const favIdStr = String(fav.id);
|
||||
const text = fav.text || textMap.get(favIdStr);
|
||||
console.log(`FavoritesModal: Matching fav.id=${favIdStr}, found text=${!!text}, textValue=${text?.substring(0, 10)}...`);
|
||||
return {
|
||||
...fav,
|
||||
text: text || t('favorites.unknownText')
|
||||
};
|
||||
});
|
||||
|
||||
setItems(enriched);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
|
||||
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
|
||||
}, [ids]);
|
||||
}, [visible, t]);
|
||||
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
|
||||
@@ -41,7 +66,7 @@ export default function FavoritesModal({ visible, onClose }: Props) {
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(it) => it.id}
|
||||
keyExtractor={(it) => it.favId}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getFavorites,
|
||||
setDailyReminderSettings,
|
||||
removeFavorite,
|
||||
getRecoFeedCache,
|
||||
getUserProfile,
|
||||
type DailyReminderSettings,
|
||||
type FavoriteItem,
|
||||
@@ -271,14 +272,20 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
|
||||
async function refreshFavorites() {
|
||||
const storedFavs = await getFavorites();
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
||||
const textMap = new Map<string, string>();
|
||||
|
||||
const list = storedFavs
|
||||
.map((fav) => ({
|
||||
// 1) Mock 文案
|
||||
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
|
||||
|
||||
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
|
||||
const cache = await getRecoFeedCache();
|
||||
cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text));
|
||||
|
||||
// 3) 组装:优先使用收藏时写入的 text,其次从 map 回填
|
||||
const list = storedFavs.map((fav) => ({
|
||||
...fav,
|
||||
text: map.get(fav.id) || ''
|
||||
}))
|
||||
.filter(item => item.text !== '');
|
||||
text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'),
|
||||
}));
|
||||
|
||||
setFavorites(list);
|
||||
}
|
||||
@@ -390,7 +397,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (settings.status === 'denied') {
|
||||
Alert.alert(
|
||||
t('common.notice'),
|
||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||
t('permissions.notificationsDenied')
|
||||
);
|
||||
setPushEnabled(false);
|
||||
return;
|
||||
@@ -607,12 +614,12 @@ function WidgetHowToPage() {
|
||||
}
|
||||
|
||||
function LanguagePage() {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
|
||||
const languages = [
|
||||
{ id: 'zh-TW', label: '繁体' },
|
||||
{ id: 'en', label: 'English' },
|
||||
{ id: 'zh-TW', label: t('language.zhTW') },
|
||||
{ id: 'en', label: t('language.en') },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SerifText } from './SerifText';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
|
||||
export const INTENTS = [
|
||||
{ id: 'love', label: '爱情', icon: '❤️' },
|
||||
{ id: 'life', label: '生活', icon: '⛅' },
|
||||
{ id: 'travel', label: '旅游', icon: '🌴' },
|
||||
{ id: 'work', label: '职场', icon: '💼' },
|
||||
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
||||
{ id: 'life', labelKey: 'intent.life', icon: '⛅' },
|
||||
{ id: 'travel', labelKey: 'intent.travel', icon: '🌴' },
|
||||
{ id: 'work', labelKey: 'intent.work', icon: '💼' },
|
||||
];
|
||||
|
||||
interface IntentSelectionStepProps {
|
||||
@@ -16,9 +17,10 @@ interface IntentSelectionStepProps {
|
||||
}
|
||||
|
||||
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SerifText style={styles.title}>你希望得到什么帮助?</SerifText>
|
||||
<SerifText style={styles.title}>{t('intent.title')}</SerifText>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{INTENTS.map((intent) => {
|
||||
@@ -35,7 +37,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
||||
>
|
||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
||||
{intent.label}
|
||||
{t(intent.labelKey)}
|
||||
</SerifText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,10 @@ interface SelectionStepProps {
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onNext: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) {
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
|
||||
return (
|
||||
@@ -48,11 +49,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!hasSelection}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -99,5 +96,20 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
}
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
skipBtn: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
color: OnboardingColors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -27,6 +28,7 @@ type Props = {
|
||||
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
||||
*/
|
||||
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||
@@ -121,7 +123,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={leftIcon ? "返回" : "关闭"}
|
||||
accessibilityLabel={leftIcon ? t('common.back') : t('common.close')}
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
style={styles.close}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
@@ -41,7 +42,8 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
1220
client/pnpm-lock.yaml
generated
1220
client/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev';
|
||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||
|
||||
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
||||
|
||||
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||
const direct = process.env.EXPO_PUBLIC_API_BASE_URL;
|
||||
if (direct && String(direct).trim()) return String(direct).trim();
|
||||
|
||||
// 约定:local/dev/prod 三套域名分别配置,便于后续直接切环境而不改代码
|
||||
if (env === 'local') {
|
||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
||||
}
|
||||
if (env === 'dev') {
|
||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
||||
}
|
||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
||||
}
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||
|
||||
/**
|
||||
* 默认语言策略:
|
||||
* - auto:优先设备语言(支持列表内时),否则回退 zh-CN
|
||||
* - zh-CN/en/es/pt/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
||||
* - auto:优先设备语言(支持列表内时),否则回退 en
|
||||
* - en/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
||||
*/
|
||||
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export type MockContentItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
textKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地 mock 内容(后续接后端时可替换)
|
||||
*/
|
||||
export const MOCK_CONTENT: MockContentItem[] = [
|
||||
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
|
||||
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
|
||||
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
|
||||
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
|
||||
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
|
||||
{ id: 'c1', textKey: 'mock.c1' },
|
||||
{ id: 'c2', textKey: 'mock.c2' },
|
||||
{ id: 'c3', textKey: 'mock.c3' },
|
||||
{ id: 'c4', textKey: 'mock.c4' },
|
||||
{ id: 'c5', textKey: 'mock.c5' }
|
||||
];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUserProfileFromQuestionnaire,
|
||||
computeProfileConfidence,
|
||||
computeTimeConfidence,
|
||||
normalizeAnswers,
|
||||
} from '../index';
|
||||
|
||||
describe('userProfileScoring V1.2', () => {
|
||||
it('normalizeAnswers: 非法值按跳过处理', () => {
|
||||
// @ts-expect-error: 模拟非法输入
|
||||
const out = normalizeAnswers({ mom_stage: 'xxx', emotion: 'yyy', context: 'zzz', need: 'ooo' });
|
||||
expect(out).toEqual({ mom_stage: undefined, emotion: undefined, context: undefined, need: undefined });
|
||||
});
|
||||
|
||||
it('computeTimeConfidence: 分段衰减', () => {
|
||||
const gen = new Date('2026-01-01T00:00:00Z');
|
||||
|
||||
// 0–7 天:1.0
|
||||
expect(computeTimeConfidence(gen, new Date('2026-01-05T00:00:00Z'))).toBe(1.0);
|
||||
|
||||
// 30 天以上:0.5
|
||||
expect(computeTimeConfidence(gen, new Date('2026-02-15T00:00:00Z'))).toBe(0.5);
|
||||
});
|
||||
|
||||
it('computeProfileConfidence: 完整度因子 + clamp', () => {
|
||||
const confTime = 1.0;
|
||||
|
||||
// 全部跳过:completion=0 → completionFactor=0.5 → 0.5
|
||||
expect(
|
||||
computeProfileConfidence(confTime, { stage: false, emotion: false, context: false, need: false })
|
||||
).toBe(0.5);
|
||||
|
||||
// 全部作答:completion=1 → completionFactor=1 → 1
|
||||
expect(computeProfileConfidence(confTime, { stage: true, emotion: true, context: true, need: true })).toBe(1.0);
|
||||
});
|
||||
|
||||
it('buildUserProfileFromQuestionnaire: 全部跳过输出最小可计算画像', () => {
|
||||
const p = buildUserProfileFromQuestionnaire({}, { generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' });
|
||||
|
||||
expect(p.profile_version).toBe('v1.2');
|
||||
expect(p.profile_source).toBe('questionnaire');
|
||||
|
||||
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
|
||||
expect(p.stage).toEqual({ unknown: 1 });
|
||||
expect(p.emotion_score).toBeNull();
|
||||
expect(p.context).toEqual({});
|
||||
expect(p.need).toEqual({});
|
||||
|
||||
// conf_time=1,completionFactor=0.5
|
||||
expect(p.profile_confidence).toBe(0.5);
|
||||
|
||||
// unknown 会命中 unsafe_for_stage_unknown,并带跨维度谓词
|
||||
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_stage_unknown');
|
||||
expect(p.hard_rules.forbidden_content_predicates.some((x) => x.id === 'unknown_block_parenting_pressure_personalized')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('buildUserProfileFromQuestionnaire: emotion<=0.2 命中 unsafe_for_emotion_low', () => {
|
||||
const p = buildUserProfileFromQuestionnaire(
|
||||
{ mom_stage: 'expecting', emotion: 'overwhelmed', context: 'health', need: 'anxiety_relief' },
|
||||
{ generatedAt: '2026-01-30T00:00:00Z', now: '2026-01-30T00:00:00Z' }
|
||||
);
|
||||
expect(p.emotion_score).toBe(0.2);
|
||||
expect(p.hard_rules.forbidden_risk_flags).toContain('unsafe_for_emotion_low');
|
||||
});
|
||||
});
|
||||
|
||||
19
client/src/features/userProfileScoring/index.ts
Normal file
19
client/src/features/userProfileScoring/index.ts
Normal 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';
|
||||
|
||||
61
client/src/features/userProfileScoring/onboardingMapping.ts
Normal file
61
client/src/features/userProfileScoring/onboardingMapping.ts
Normal 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;
|
||||
}
|
||||
|
||||
233
client/src/features/userProfileScoring/scoring.ts
Normal file
233
client/src/features/userProfileScoring/scoring.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 用户画像打分(User Profile Scoring)V1.2
|
||||
*
|
||||
* 规则来源:
|
||||
* - `spec_kit/User Profile Scoring/spec.md`
|
||||
* - `设计说明文档/客戶端問卷打分規則.md`(V1.2)
|
||||
*/
|
||||
|
||||
import type {
|
||||
BuildUserProfileOptions,
|
||||
ContextAnswer,
|
||||
EmotionAnswer,
|
||||
HardRules,
|
||||
MomStageAnswer,
|
||||
NeedAnswer,
|
||||
ProfileAnswered,
|
||||
QuestionnaireAnswersV1_2,
|
||||
SparseOneHot,
|
||||
UserProfileV1_2_Extended,
|
||||
UserStageOneHot,
|
||||
} from './types';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
|
||||
const d = new Date(value);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function isMomStageAnswer(v: unknown): v is MomStageAnswer {
|
||||
return v === 'expecting' || v === 'parenting' || v === 'unknown';
|
||||
}
|
||||
|
||||
function isEmotionAnswer(v: unknown): v is EmotionAnswer {
|
||||
return (
|
||||
v === 'low' ||
|
||||
v === 'overwhelmed' ||
|
||||
v === 'tired' ||
|
||||
v === 'neutral' ||
|
||||
v === 'calm' ||
|
||||
v === 'joyful'
|
||||
);
|
||||
}
|
||||
|
||||
function isContextAnswer(v: unknown): v is ContextAnswer {
|
||||
return v === 'family' || v === 'work' || v === 'relationship' || v === 'friends' || v === 'health';
|
||||
}
|
||||
|
||||
function isNeedAnswer(v: unknown): v is NeedAnswer {
|
||||
return (
|
||||
v === 'emotional_support' ||
|
||||
v === 'parenting_pressure' ||
|
||||
v === 'self_worth' ||
|
||||
v === 'anxiety_relief' ||
|
||||
v === 'rest_balance'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化答案:非法值按“跳过”处理(归一化为 undefined)
|
||||
* - `null` 保留,表示显式跳过/无值
|
||||
*/
|
||||
export function normalizeAnswers(raw: QuestionnaireAnswersV1_2): QuestionnaireAnswersV1_2 {
|
||||
const mom_stage =
|
||||
raw.mom_stage === null ? null : isMomStageAnswer(raw.mom_stage) ? raw.mom_stage : undefined;
|
||||
const emotion = raw.emotion === null ? null : isEmotionAnswer(raw.emotion) ? raw.emotion : undefined;
|
||||
const context = raw.context === null ? null : isContextAnswer(raw.context) ? raw.context : undefined;
|
||||
const need = raw.need === null ? null : isNeedAnswer(raw.need) ? raw.need : undefined;
|
||||
|
||||
return { mom_stage, emotion, context, need };
|
||||
}
|
||||
|
||||
export function computeProfileAnswered(normalized: QuestionnaireAnswersV1_2): ProfileAnswered {
|
||||
return {
|
||||
stage: normalized.mom_stage !== undefined && normalized.mom_stage !== null,
|
||||
emotion: normalized.emotion !== undefined && normalized.emotion !== null,
|
||||
context: normalized.context !== undefined && normalized.context !== null,
|
||||
need: normalized.need !== undefined && normalized.need !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间衰减置信度(conf_time)
|
||||
* - 0–7 天:1.0
|
||||
* - 7–30 天:线性衰减到 0.7(含第 30 天)
|
||||
* - 30 天以上:0.5
|
||||
*/
|
||||
export function computeTimeConfidence(generatedAt: Date, now: Date): number {
|
||||
const deltaMs = now.getTime() - generatedAt.getTime();
|
||||
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 1.0;
|
||||
|
||||
const days = deltaMs / MS_PER_DAY;
|
||||
if (days <= 7) return 1.0;
|
||||
if (days <= 30) {
|
||||
const t = (days - 7) / (30 - 7); // 0..1
|
||||
return 1.0 - 0.3 * t; // 1 -> 0.7
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.2:profile_confidence(conf_U)
|
||||
* conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
|
||||
*/
|
||||
export function computeProfileConfidence(confTime: number, answered: ProfileAnswered): number {
|
||||
const answeredCount =
|
||||
(answered.stage ? 1 : 0) + (answered.emotion ? 1 : 0) + (answered.context ? 1 : 0) + (answered.need ? 1 : 0);
|
||||
const completion = answeredCount / 4;
|
||||
const completionFactor = 0.5 + 0.5 * completion;
|
||||
return clamp(confTime * completionFactor, 0.2, 1.0);
|
||||
}
|
||||
|
||||
function buildStageOneHot(momStage: MomStageAnswer | null | undefined): UserStageOneHot {
|
||||
// V1.2:mom_stage 跳过按安全策略输出 unknown=1
|
||||
if (momStage === null || momStage === undefined) {
|
||||
return { unknown: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
expecting: momStage === 'expecting' ? 1 : 0,
|
||||
parenting: momStage === 'parenting' ? 1 : 0,
|
||||
unknown: momStage === 'unknown' ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmotionScore(emotion: EmotionAnswer | null | undefined): number | null {
|
||||
if (emotion === null || emotion === undefined) return null;
|
||||
switch (emotion) {
|
||||
case 'low':
|
||||
return 0.0;
|
||||
case 'overwhelmed':
|
||||
return 0.2;
|
||||
case 'tired':
|
||||
return 0.4;
|
||||
case 'neutral':
|
||||
return 0.6;
|
||||
case 'calm':
|
||||
return 0.8;
|
||||
case 'joyful':
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSparseOneHot(value: string | null | undefined): SparseOneHot {
|
||||
if (value === null || value === undefined) return {};
|
||||
return { [value]: 1 };
|
||||
}
|
||||
|
||||
function computeRuleHitsAndHardRules(profile: {
|
||||
stage: UserStageOneHot;
|
||||
emotion_score: number | null;
|
||||
}): { rule_hits: string[]; hard_rules: HardRules } {
|
||||
const rule_hits: string[] = [];
|
||||
const forbidden_risk_flags: string[] = [];
|
||||
|
||||
const stageUnknown = profile.stage.unknown === 1;
|
||||
const stageParenting = profile.stage.parenting === 1;
|
||||
|
||||
if (stageUnknown) {
|
||||
rule_hits.push('unsafe_for_stage_unknown');
|
||||
forbidden_risk_flags.push('unsafe_for_stage_unknown');
|
||||
}
|
||||
|
||||
if (stageParenting) {
|
||||
rule_hits.push('unsafe_for_stage_parenting');
|
||||
forbidden_risk_flags.push('unsafe_for_stage_parenting');
|
||||
}
|
||||
|
||||
if (profile.emotion_score !== null && profile.emotion_score <= 0.2) {
|
||||
rule_hits.push('unsafe_for_emotion_low');
|
||||
forbidden_risk_flags.push('unsafe_for_emotion_low');
|
||||
}
|
||||
|
||||
const forbidden_content_predicates = [];
|
||||
if (stageUnknown) {
|
||||
forbidden_content_predicates.push({
|
||||
id: 'unknown_block_parenting_pressure_personalized',
|
||||
when_user: { stage_unknown: true },
|
||||
forbid_content: { need: 'parenting_pressure', personalization_power: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rule_hits,
|
||||
hard_rules: {
|
||||
forbidden_risk_flags,
|
||||
forbidden_content_predicates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserProfileFromQuestionnaire(
|
||||
rawAnswers: QuestionnaireAnswersV1_2,
|
||||
options: BuildUserProfileOptions = {}
|
||||
): UserProfileV1_2_Extended {
|
||||
const normalized = normalizeAnswers(rawAnswers);
|
||||
const profile_answered = computeProfileAnswered(normalized);
|
||||
|
||||
const now = toDate(options.now) ?? new Date();
|
||||
const generatedAt = toDate(options.generatedAt) ?? now;
|
||||
|
||||
const confTime = computeTimeConfidence(generatedAt, now);
|
||||
const profile_confidence = computeProfileConfidence(confTime, profile_answered);
|
||||
|
||||
const stage = buildStageOneHot(normalized.mom_stage);
|
||||
const emotion_score = mapEmotionScore(normalized.emotion);
|
||||
const context = buildSparseOneHot(normalized.context);
|
||||
const need = buildSparseOneHot(normalized.need);
|
||||
|
||||
const { rule_hits, hard_rules } = computeRuleHitsAndHardRules({ stage, emotion_score });
|
||||
|
||||
return {
|
||||
profile_version: 'v1.2',
|
||||
profile_source: 'questionnaire',
|
||||
profile_generated_at: generatedAt.toISOString(),
|
||||
profile_confidence,
|
||||
profile_answered,
|
||||
stage,
|
||||
emotion_score,
|
||||
context,
|
||||
need,
|
||||
rule_hits,
|
||||
hard_rules,
|
||||
};
|
||||
}
|
||||
|
||||
95
client/src/features/userProfileScoring/types.ts
Normal file
95
client/src/features/userProfileScoring/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 用户画像打分(User Profile Scoring)V1.2 类型定义
|
||||
*
|
||||
* 说明:
|
||||
* - 本模块用于:问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)
|
||||
* - 字段与规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)为准
|
||||
*/
|
||||
|
||||
export type MomStageAnswer = 'expecting' | 'parenting' | 'unknown';
|
||||
export type EmotionAnswer = 'low' | 'overwhelmed' | 'tired' | 'neutral' | 'calm' | 'joyful';
|
||||
export type ContextAnswer = 'family' | 'work' | 'relationship' | 'friends' | 'health';
|
||||
export type NeedAnswer =
|
||||
| 'emotional_support'
|
||||
| 'parenting_pressure'
|
||||
| 'self_worth'
|
||||
| 'anxiety_relief'
|
||||
| 'rest_balance';
|
||||
|
||||
/**
|
||||
* V1.2:每题可跳过
|
||||
* - `undefined`:字段缺失(可能是“没传”)
|
||||
* - `null`:显式跳过/无值(例如 UI 明确传 null)
|
||||
*/
|
||||
export type QuestionnaireAnswersV1_2 = {
|
||||
mom_stage?: MomStageAnswer | null;
|
||||
emotion?: EmotionAnswer | null;
|
||||
context?: ContextAnswer | null;
|
||||
need?: NeedAnswer | null;
|
||||
};
|
||||
|
||||
export type ProfileAnswered = {
|
||||
stage: boolean;
|
||||
emotion: boolean;
|
||||
context: boolean;
|
||||
need: boolean;
|
||||
};
|
||||
|
||||
export type UserStageOneHot = {
|
||||
expecting?: 0 | 1;
|
||||
parenting?: 0 | 1;
|
||||
unknown: 0 | 1;
|
||||
};
|
||||
|
||||
export type SparseOneHot = Record<string, 1>;
|
||||
|
||||
export type UserProfileV1_2 = {
|
||||
profile_version: 'v1.2';
|
||||
profile_source: 'questionnaire';
|
||||
profile_generated_at: string; // ISO8601
|
||||
profile_confidence: number; // 0–1
|
||||
profile_answered: ProfileAnswered;
|
||||
stage: UserStageOneHot;
|
||||
emotion_score: number | null;
|
||||
context: SparseOneHot;
|
||||
need: SparseOneHot;
|
||||
};
|
||||
|
||||
export type ForbiddenContentPredicate = {
|
||||
/**
|
||||
* 谓词 ID:用于可观测与回归测试
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* 触发条件(用户侧)
|
||||
* 说明:这里刻意保持为 object,便于未来接入规则引擎时做 schema 对齐。
|
||||
*/
|
||||
when_user: Record<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;
|
||||
};
|
||||
|
||||
22
client/src/i18n/ALL_COPY.md
Normal file
22
client/src/i18n/ALL_COPY.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## 应用文案总表(请在此文件对应的 JSON 中修改)
|
||||
|
||||
**单一文案源文件**:`client/src/i18n/locales/all.json`
|
||||
|
||||
- **English**:`all.json` 的 `en`
|
||||
- **繁体中文**:`all.json` 的 `zh-TW`
|
||||
|
||||
> 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源)。
|
||||
|
||||
### 快速索引(高频文案)
|
||||
|
||||
- **Home**:`home.*`
|
||||
- **Push 提示**:`push.*`
|
||||
- **主题**:`theme.*`
|
||||
- **我的/Profile**:`profile.*`
|
||||
- **收藏**:`favorites.*`
|
||||
- **设置**:`settings.*`
|
||||
- **Onboarding(问卷)**:`onboardingSurvey.steps.*`
|
||||
- **Onboarding(兴趣)**:`intent.*`
|
||||
- **Mock 文案**:`mock.*`
|
||||
|
||||
|
||||
@@ -3,31 +3,23 @@ import * as Localization from 'expo-localization';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import pt from './locales/pt.json';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* 语言码约定:
|
||||
* - 简体中文:zh-CN
|
||||
* - 繁体中文:zh-TW
|
||||
* - 英语:en
|
||||
* - 西班牙语:es
|
||||
* - 葡萄牙语:pt
|
||||
*/
|
||||
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
|
||||
export type AppLanguage = 'zh-TW' | 'en';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
'es',
|
||||
'pt',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
|
||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
|
||||
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
||||
|
||||
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
@@ -37,19 +29,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||
const tag = languageTag.toLowerCase();
|
||||
|
||||
// 中文:优先区分繁简
|
||||
// 中文:当前仅支持繁体中文(zh-TW)
|
||||
if (tag.startsWith('zh')) {
|
||||
// 常见繁体标记:zh-TW / zh-HK / zh-Hant
|
||||
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
// 其他语言:按前缀匹配
|
||||
// 其他语言:按前缀匹配(当前仅支持英文)
|
||||
if (tag.startsWith('en')) return 'en';
|
||||
if (tag.startsWith('es')) return 'es';
|
||||
if (tag.startsWith('pt')) return 'pt';
|
||||
|
||||
return DEFAULT_FALLBACK_LANGUAGE;
|
||||
}
|
||||
@@ -87,7 +73,7 @@ export async function clearLanguagePreference(): Promise<void> {
|
||||
* 语言选择优先级:
|
||||
* 1) 用户设置(若存在)
|
||||
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
||||
* 3) 默认回退(zh-CN)
|
||||
* 3) 默认回退(en)
|
||||
*/
|
||||
export async function initI18n(): Promise<void> {
|
||||
if (i18n.isInitialized) return;
|
||||
@@ -98,11 +84,8 @@ export async function initI18n(): Promise<void> {
|
||||
|
||||
await i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
pt: { translation: pt },
|
||||
'zh-TW': { translation: all['zh-TW'] as any },
|
||||
en: { translation: all.en as any },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||
|
||||
314
client/src/i18n/locales/all.json
Normal file
314
client/src/i18n/locales/all.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"en": {
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"error": "Error",
|
||||
"openLinkError": "Cannot open link",
|
||||
"back": "Back",
|
||||
"close": "Close"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Welcome",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "Next",
|
||||
"skip": "Skip",
|
||||
"skipAll": "Skip onboarding",
|
||||
"q1Title": "How are you feeling lately?",
|
||||
"q1Desc": "No right or wrong. You can skip and adjust later.",
|
||||
"q2Title": "What kind of support do you want?",
|
||||
"q2Desc": "For example: gentle reminders, mindfulness, emotional support.",
|
||||
"q3Title": "When do you need comfort the most?",
|
||||
"q3Desc": "Morning, afternoon, late night, or specific moments.",
|
||||
"q4Title": "A gentle sentence for yourself",
|
||||
"q4Desc": "You can skip. We’ll stay with you along the way."
|
||||
},
|
||||
"onboardingSurvey": {
|
||||
"steps": {
|
||||
"name": { "title": "What should I call you?" },
|
||||
"status": {
|
||||
"title": "Your current stage?",
|
||||
"options": {
|
||||
"pregnant": "Pregnant / preparing for motherhood",
|
||||
"has_kids": "Already have kids",
|
||||
"no_fill": "Prefer not to say"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "How are you feeling right now?",
|
||||
"options": {
|
||||
"happy": "Happy / satisfied",
|
||||
"calm": "Calm / grounded",
|
||||
"stressed": "Stressed / overwhelmed",
|
||||
"low": "Down / low mood"
|
||||
}
|
||||
},
|
||||
"influence": {
|
||||
"title": "What has been affecting you lately?",
|
||||
"options": {
|
||||
"family": "Family & kids",
|
||||
"work": "Work or study",
|
||||
"relationship": "Intimate relationship",
|
||||
"friends": "Friends & social life",
|
||||
"health": "Mental & physical health"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "What support do you need most?",
|
||||
"options": {
|
||||
"emotional": "Emotional support",
|
||||
"parenting": "Parenting stress",
|
||||
"self_worth": "Self-worth",
|
||||
"anxiety": "Anxiety relief",
|
||||
"balance": "Rest & balance"
|
||||
}
|
||||
},
|
||||
"reminder": { "title": "How many reminders do you want per day?" }
|
||||
}
|
||||
},
|
||||
"intent": {
|
||||
"title": "What kind of help do you want?",
|
||||
"love": "Love",
|
||||
"life": "Life",
|
||||
"travel": "Travel",
|
||||
"work": "Career"
|
||||
},
|
||||
"push": {
|
||||
"title": "Notifications",
|
||||
"cardTitle": "Turn on gentle reminders",
|
||||
"cardDesc": "We’ll send a short mindful phrase when you may need it. You can change this anytime in Settings.",
|
||||
"enable": "Enable",
|
||||
"later": "Later",
|
||||
"loading": "Working…",
|
||||
"errorTitle": "Notice",
|
||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||
},
|
||||
"home": {
|
||||
"title": "Mindfulness",
|
||||
"like": "Like",
|
||||
"dislike": "Dislike",
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings",
|
||||
"theme": "Theme",
|
||||
"profile": "Me"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"scenery": "Scenery",
|
||||
"color": "Color"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Me",
|
||||
"favorites": "My Likes",
|
||||
"widget": "Widget",
|
||||
"dailyReminder": "Daily Reminder",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Use",
|
||||
"language": "Language",
|
||||
"todoTitle": "Notice",
|
||||
"todoDesc": "This feature is a placeholder for this iteration."
|
||||
},
|
||||
"dailyReminder": {
|
||||
"title": "Daily Reminder",
|
||||
"timesUnit": "times",
|
||||
"pushLabel": "Push Reminder",
|
||||
"ok": "Ok",
|
||||
"minus": "Decrease",
|
||||
"plus": "Increase"
|
||||
},
|
||||
"widget": {
|
||||
"lockScreen": "Lock Screen Widget",
|
||||
"homeScreen": "Home Screen Widget",
|
||||
"previewDate": "Thu, Jan 29",
|
||||
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites",
|
||||
"empty": "No favorites yet.",
|
||||
"unknownText": "This quote is no longer available."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"version": "Version",
|
||||
"widgetTitle": "iOS Widget",
|
||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
||||
},
|
||||
"consent": {
|
||||
"title": "You Are Perfect.",
|
||||
"subtitle": "Everything Will Be Better.",
|
||||
"agree": "Agree & Continue",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Use"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
||||
},
|
||||
"language": {
|
||||
"zhTW": "繁體中文",
|
||||
"en": "English"
|
||||
},
|
||||
"mock": {
|
||||
"c1": "You’ve been trying your best. You deserve kindness today.",
|
||||
"c2": "Take three deep breaths and return to the present moment.",
|
||||
"c3": "It’s okay to slow down. Emotions pass like clouds.",
|
||||
"c4": "You don’t need to be perfect. You are enough.",
|
||||
"c5": "Place a hand on your heart and say: You did well today."
|
||||
}
|
||||
},
|
||||
"zh-TW": {
|
||||
"common": {
|
||||
"ok": "確定",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"close": "關閉"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "歡迎",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "下一步",
|
||||
"skip": "跳過",
|
||||
"skipAll": "跳過整個引導",
|
||||
"q1Title": "你最近的感受更接近哪一種?",
|
||||
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
|
||||
"q2Title": "你更希望獲得哪種支持?",
|
||||
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
|
||||
"q3Title": "你通常在什麼時候最需要被安慰?",
|
||||
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
|
||||
"q4Title": "給自己一句溫柔的話",
|
||||
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
|
||||
},
|
||||
"onboardingSurvey": {
|
||||
"steps": {
|
||||
"name": { "title": "我可以怎麼稱呼你?" },
|
||||
"status": {
|
||||
"title": "媽媽的狀態?",
|
||||
"options": {
|
||||
"pregnant": "懷孕中/準備成為媽媽",
|
||||
"has_kids": "已經有孩子",
|
||||
"no_fill": "不想填寫"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "當下情緒狀態?",
|
||||
"options": {
|
||||
"happy": "愉悅、滿足",
|
||||
"calm": "平靜、安穩",
|
||||
"stressed": "被壓得有點喘不過氣",
|
||||
"low": "情緒低落"
|
||||
}
|
||||
},
|
||||
"influence": {
|
||||
"title": "是什麼影響了你最近的感受?",
|
||||
"options": {
|
||||
"family": "家庭與孩子",
|
||||
"work": "工作或學習",
|
||||
"relationship": "親密關係",
|
||||
"friends": "朋友與人際",
|
||||
"health": "身心健康"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "最需要什麼支持?",
|
||||
"options": {
|
||||
"emotional": "情緒支持",
|
||||
"parenting": "育兒壓力",
|
||||
"self_worth": "自我價值",
|
||||
"anxiety": "焦慮舒緩",
|
||||
"balance": "休息與平衡"
|
||||
}
|
||||
},
|
||||
"reminder": { "title": "你需要每天幾次提醒?" }
|
||||
}
|
||||
},
|
||||
"intent": {
|
||||
"title": "你希望得到什麼幫助?",
|
||||
"love": "愛情",
|
||||
"life": "生活",
|
||||
"travel": "旅遊",
|
||||
"work": "職場"
|
||||
},
|
||||
"push": {
|
||||
"title": "通知",
|
||||
"cardTitle": "開啟溫柔提醒",
|
||||
"cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。",
|
||||
"enable": "立即開啟",
|
||||
"later": "稍後",
|
||||
"loading": "處理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
"like": "喜歡",
|
||||
"dislike": "不喜歡",
|
||||
"favorites": "收藏",
|
||||
"settings": "設定",
|
||||
"theme": "主題",
|
||||
"profile": "我的"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主題",
|
||||
"scenery": "風景",
|
||||
"color": "顏色"
|
||||
},
|
||||
"profile": {
|
||||
"title": "我的",
|
||||
"favorites": "我的喜歡",
|
||||
"widget": "小工具",
|
||||
"dailyReminder": "每日提醒",
|
||||
"privacy": "隱私政策",
|
||||
"terms": "使用條款",
|
||||
"language": "語言",
|
||||
"todoTitle": "提示",
|
||||
"todoDesc": "此功能本期先占位,後續迭代補齊。"
|
||||
},
|
||||
"dailyReminder": {
|
||||
"title": "每日提醒",
|
||||
"timesUnit": "次",
|
||||
"pushLabel": "推送提醒",
|
||||
"ok": "確定",
|
||||
"minus": "減少次數",
|
||||
"plus": "增加次數"
|
||||
},
|
||||
"widget": {
|
||||
"lockScreen": "鎖屏小工具",
|
||||
"homeScreen": "桌面小工具",
|
||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏夾",
|
||||
"empty": "這裡還沒有收藏內容。",
|
||||
"unknownText": "這條文案暫時無法顯示。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"language": "語言",
|
||||
"version": "版本",
|
||||
"widgetTitle": "iOS 小工具",
|
||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||
},
|
||||
"consent": {
|
||||
"agree": "同意並繼續",
|
||||
"privacy": "隱私協議",
|
||||
"terms": "用戶使用協議"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||
},
|
||||
"language": {
|
||||
"zhTW": "繁體中文",
|
||||
"en": "English"
|
||||
},
|
||||
"mock": {
|
||||
"c1": "你已經很努力了,今天也值得被溫柔對待。",
|
||||
"c2": "深呼吸三次,把注意力帶回當下。",
|
||||
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
|
||||
"c4": "你不需要完美,你已經足夠好。",
|
||||
"c5": "把手放在心口,對自己說一句:辛苦了。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
client/src/services/recoApi.ts
Normal file
64
client/src/services/recoApi.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': acceptLanguage,
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
||||
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
||||
const KEY_USER_PROFILE = 'user.profile';
|
||||
const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
|
||||
@@ -20,11 +24,48 @@ export type UserProfile = {
|
||||
name?: string;
|
||||
intents?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户画像(问卷打分输出)
|
||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
timesPerDay: number;
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
export type RecoFeedCacheItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RecoFeedCache = {
|
||||
saved_at: string; // ISO8601
|
||||
/**
|
||||
* 缓存文案的语言(后端目前只区分 en / tc)
|
||||
* - en: English
|
||||
* - tc: 繁体中文
|
||||
*/
|
||||
lang?: 'en' | 'tc';
|
||||
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> {
|
||||
const raw = await AsyncStorage.getItem(key);
|
||||
@@ -72,6 +113,10 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
||||
export type FavoriteItem = {
|
||||
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||
id: string;
|
||||
/**
|
||||
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
||||
*/
|
||||
text?: string;
|
||||
date: string;
|
||||
themeMode: ThemeMode;
|
||||
background: string; // 颜色值或图片路径
|
||||
@@ -85,7 +130,7 @@ export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
// 允许重复点赞,不再根据 id 去重
|
||||
const newList = [item, ...list];
|
||||
console.log('Adding to favorites, new list size:', newList.length);
|
||||
console.log('Adding to favorites:', JSON.stringify(item));
|
||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||
}
|
||||
|
||||
@@ -123,6 +168,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
|
||||
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> {
|
||||
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay: 3,
|
||||
|
||||
@@ -7,13 +7,13 @@ APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
|
||||
# 数据库(dev 指向 mindfulness_dev;prod 指向 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_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_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
|
||||
|
||||
# 推送(Expo)
|
||||
|
||||
20
server/.env.prod
Normal file
20
server/.env.prod
Normal 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_dev;prod 指向 mindfulness)
|
||||
DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness?charset=utf8mb4
|
||||
|
||||
# Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀)
|
||||
REDIS_URL=redis://prod_damer:damer@43.163.242.87:6379/0
|
||||
|
||||
# Celery(默认不启用结果存储,避免 Redis 内存压力)
|
||||
CELERY_BROKER_URL=redis://prod_damer:damer@43.163.242.87:6379/0
|
||||
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
|
||||
# 推送(Expo)
|
||||
# EXPO_ACCESS_TOKEN=
|
||||
BIN
server/.test.db
Normal file
BIN
server/.test.db
Normal file
Binary file not shown.
39
server/alembic.ini
Normal file
39
server/alembic.ini
Normal 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
40
server/alembic/README.md
Normal 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
137
server/alembic/env.py
Normal 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)
|
||||
|
||||
# 目标 metadata(autogenerate 依赖)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _read_env_kv(env_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
读取 .env 文件中的 KEY=VALUE。
|
||||
|
||||
说明:
|
||||
- 迁移阶段只需要 DATABASE_URL,不应因为 Redis/Celery 等配置缺失而失败
|
||||
- 这里不依赖 pydantic-settings 的 Settings 校验,避免“缺字段导致迁移不可用”
|
||||
"""
|
||||
|
||||
data: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return data
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
data[k] = v
|
||||
return data
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""
|
||||
获取数据库连接串。
|
||||
|
||||
约定:
|
||||
- 优先读取环境变量 `DATABASE_URL`
|
||||
- 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod`
|
||||
|
||||
注意:迁移阶段仅依赖 DATABASE_URL;不应强制要求 REDIS_URL / CELERY_BROKER_URL 等配置存在。
|
||||
"""
|
||||
|
||||
# 允许在 alembic 命令时临时覆盖
|
||||
env_url = os.getenv("DATABASE_URL")
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev"
|
||||
env_file = SERVER_DIR / (".env.prod" if app_env == "prod" else ".env.dev")
|
||||
kv = _read_env_kv(env_file)
|
||||
url = kv.get("DATABASE_URL")
|
||||
if url:
|
||||
return url
|
||||
|
||||
raise RuntimeError(
|
||||
"缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。"
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""离线模式:生成 SQL 脚本,不连接数据库。"""
|
||||
|
||||
url = _get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""在线模式:在已有连接上执行迁移。"""
|
||||
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
"""在线模式:使用异步引擎执行迁移。"""
|
||||
|
||||
url = _get_database_url()
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section) or {},
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
|
||||
27
server/alembic/script.py.mako
Normal file
27
server/alembic/script.py.mako
Normal 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"}
|
||||
|
||||
147
server/alembic/versions/0001_init_content_tables.py
Normal file
147
server/alembic/versions/0001_init_content_tables.py
Normal 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~1;NULL 表示 general"),
|
||||
sa.Column(
|
||||
"context_suitability_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
),
|
||||
sa.Column(
|
||||
"need_suitability_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
),
|
||||
sa.Column(
|
||||
"personalization_power",
|
||||
sa.SmallInteger(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)",
|
||||
),
|
||||
sa.Column(
|
||||
"review_confidence",
|
||||
sa.Numeric(3, 2),
|
||||
nullable=True,
|
||||
comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底",
|
||||
),
|
||||
sa.Column(
|
||||
"is_safe_pool",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
comment="是否属于通用安全池(L3 兜底)",
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="画像更新时间"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_profiles_is_safe_pool", "content_profiles", ["is_safe_pool"], unique=False)
|
||||
op.create_index("idx_profiles_personalization_power", "content_profiles", ["personalization_power"], unique=False)
|
||||
op.create_index("idx_profiles_stage", "content_profiles", ["stage"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"content_risk_flags",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column(
|
||||
"content_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
sa.ForeignKey("contents.content_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="FK -> contents.content_id",
|
||||
),
|
||||
sa.Column(
|
||||
"flag",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="风险标记(unsafe_for_* / block_* / soft_*)",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
|
||||
sa.UniqueConstraint("content_id", "flag", name="uniq_content_flag"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_content_id", "content_risk_flags", ["content_id"], unique=False)
|
||||
op.create_index("idx_flag", "content_risk_flags", ["flag"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_flag", table_name="content_risk_flags")
|
||||
op.drop_index("idx_content_id", table_name="content_risk_flags")
|
||||
op.drop_table("content_risk_flags")
|
||||
|
||||
op.drop_index("idx_profiles_stage", table_name="content_profiles")
|
||||
op.drop_index("idx_profiles_personalization_power", table_name="content_profiles")
|
||||
op.drop_index("idx_profiles_is_safe_pool", table_name="content_profiles")
|
||||
op.drop_table("content_profiles")
|
||||
|
||||
op.drop_index("idx_contents_template_id", table_name="contents")
|
||||
op.drop_index("idx_contents_author_id", table_name="contents")
|
||||
op.drop_table("contents")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
6
server/app/api/__init__.py
Normal file
6
server/app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API 路由入口
|
||||
|
||||
说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。
|
||||
"""
|
||||
|
||||
62
server/app/api/limits.py
Normal file
62
server/app/api/limits.py
Normal 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 可接受)
|
||||
- 窗口粒度:按分钟 bucket(window_seconds 建议为 60)
|
||||
"""
|
||||
|
||||
limit: int
|
||||
window_seconds: int
|
||||
_counters: Dict[Tuple[str, int], int] = field(default_factory=dict)
|
||||
_last_gc_bucket: int = 0
|
||||
|
||||
def _bucket(self, now_ts: float) -> int:
|
||||
return int(now_ts // float(self.window_seconds))
|
||||
|
||||
def _gc(self, current_bucket: int) -> None:
|
||||
# 每隔一段时间清理一次,避免 dict 无限增长(保留最近 3 个 bucket)
|
||||
if self._last_gc_bucket == current_bucket:
|
||||
return
|
||||
self._last_gc_bucket = current_bucket
|
||||
keep_from = current_bucket - 2
|
||||
to_delete = [k for k in self._counters.keys() if k[1] < keep_from]
|
||||
for k in to_delete:
|
||||
self._counters.pop(k, None)
|
||||
|
||||
def allow(self, *, key: str, now_ts: float) -> None:
|
||||
bucket = self._bucket(now_ts)
|
||||
self._gc(bucket)
|
||||
|
||||
k = (str(key), int(bucket))
|
||||
n = int(self._counters.get(k, 0)) + 1
|
||||
self._counters[k] = n
|
||||
if n > int(self.limit):
|
||||
raise HTTPException(status_code=429, detail="rate_limited")
|
||||
|
||||
|
||||
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
"""
|
||||
推荐接口限流:按 IP,1 分钟 10 次。
|
||||
"""
|
||||
|
||||
ip = "unknown"
|
||||
if request.client and request.client.host:
|
||||
ip = str(request.client.host)
|
||||
|
||||
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
4
server/app/api/v1/__init__.py
Normal file
4
server/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
V1 API 路由集合
|
||||
"""
|
||||
|
||||
156
server/app/api/v1/reco.py
Normal file
156
server/app/api/v1/reco.py
Normal 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(),
|
||||
)
|
||||
|
||||
26
server/app/api/v1/user_profile_scoring.py
Normal file
26
server/app/api/v1/user_profile_scoring.py
Normal 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,
|
||||
)
|
||||
|
||||
Binary file not shown.
14
server/app/db/models/__init__.py
Normal file
14
server/app/db/models/__init__.py
Normal 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"]
|
||||
|
||||
70
server/app/db/models/content.py
Normal file
70
server/app/db/models/content.py
Normal 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="更新时间",
|
||||
)
|
||||
|
||||
95
server/app/db/models/content_profile.py
Normal file
95
server/app/db/models/content_profile.py
Normal 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~1;NULL 表示 general",
|
||||
)
|
||||
|
||||
context_suitability_json: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
)
|
||||
need_suitability_json: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
)
|
||||
|
||||
personalization_power: Mapped[int] = mapped_column(
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)",
|
||||
)
|
||||
|
||||
review_confidence: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(3, 2),
|
||||
nullable=True,
|
||||
comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底",
|
||||
)
|
||||
|
||||
is_safe_pool: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="是否属于通用安全池(L3 兜底)",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
server_onupdate=func.now(),
|
||||
comment="画像更新时间",
|
||||
)
|
||||
|
||||
51
server/app/db/models/content_risk_flag.py
Normal file
51
server/app/db/models/content_risk_flag.py
Normal 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="创建时间",
|
||||
)
|
||||
|
||||
6
server/app/features/personalized_reco/__init__.py
Normal file
6
server/app/features/personalized_reco/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Personalized Reco(个性化推荐)功能模块集合。
|
||||
|
||||
该目录用于承载推荐引擎与其子模块(数据访问、打分、重排、可观测等)。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Content Repository(候选查询与数据访问层)。
|
||||
|
||||
说明:
|
||||
- 本模块为推荐引擎提供可注入的数据访问接口(与 ORM/SQL 解耦)。
|
||||
- 负责将 DB 存储形态规范化为上层稳定的 ContentProfile 结构。
|
||||
"""
|
||||
|
||||
@@ -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 跳过)。
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import Select, and_, desc, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.content import Content
|
||||
from app.db.models.content_profile import ContentProfile
|
||||
from app.db.models.content_risk_flag import ContentRiskFlag
|
||||
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
||||
from app.features.personalized_reco.content_repository.normalization import (
|
||||
CONTEXT_KEYS,
|
||||
NEED_KEYS,
|
||||
normalize_personalization_power,
|
||||
normalize_review_confidence,
|
||||
normalize_risk_flags,
|
||||
normalize_suitability,
|
||||
pick_text,
|
||||
)
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _UserSignals:
|
||||
"""
|
||||
从 user_profile 中提取 repository 级别需要的最小信号。
|
||||
|
||||
注意:更复杂的规则(Hard Filter/Scoring/Rerank)不在本层处理。
|
||||
"""
|
||||
|
||||
missing_need: bool
|
||||
missing_context: bool
|
||||
missing_emotion: bool
|
||||
stage: str | None # expecting/parenting/unknown/general/None
|
||||
|
||||
|
||||
def _bool(v: Any) -> bool:
|
||||
return bool(v)
|
||||
|
||||
|
||||
def _extract_user_signals(user_profile: object) -> _UserSignals:
|
||||
"""
|
||||
兼容 pydantic model / dict / 其他对象的最小字段读取。
|
||||
"""
|
||||
|
||||
def _get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
need = _get(user_profile, "need", {}) or {}
|
||||
context = _get(user_profile, "context", {}) or {}
|
||||
emotion_score = _get(user_profile, "emotion_score", None)
|
||||
|
||||
missing_need = len(need) == 0
|
||||
missing_context = len(context) == 0
|
||||
missing_emotion = emotion_score is None
|
||||
|
||||
# stage: from user_profile.stage (one-hot)
|
||||
stage_obj = _get(user_profile, "stage", None)
|
||||
stage: str | None = None
|
||||
if stage_obj is not None:
|
||||
expecting = _get(stage_obj, "expecting", None)
|
||||
parenting = _get(stage_obj, "parenting", None)
|
||||
unknown = _get(stage_obj, "unknown", None)
|
||||
if _bool(expecting):
|
||||
stage = "expecting"
|
||||
elif _bool(parenting):
|
||||
stage = "parenting"
|
||||
elif _bool(unknown):
|
||||
stage = "unknown"
|
||||
|
||||
return _UserSignals(
|
||||
missing_need=missing_need,
|
||||
missing_context=missing_context,
|
||||
missing_emotion=missing_emotion,
|
||||
stage=stage,
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_preserve_order(ids: Iterable[int]) -> list[int]:
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for i in ids:
|
||||
if i in seen:
|
||||
continue
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
class SqlAlchemyContentRepository(ContentRepository):
|
||||
"""
|
||||
基于 SQLAlchemy AsyncSession 的 ContentRepository 实现。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]:
|
||||
"""
|
||||
- 输入去重
|
||||
- 输出顺序与输入一致(按首次出现顺序)
|
||||
- 缺记录或缺目标语言文本:跳过
|
||||
- 不产生 N+1(主体+画像一次,flags 一次)
|
||||
"""
|
||||
|
||||
unique_ids = _dedupe_preserve_order(content_ids)
|
||||
if not unique_ids:
|
||||
return []
|
||||
|
||||
# locale 文本存在性过滤(不允许语言回退)
|
||||
# en -> 必须 text_en;tc -> 必须 text_tc
|
||||
# 过滤在 DB 层做,避免后续组装无意义
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
|
||||
loc = normalize_locale(locale)
|
||||
text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None)
|
||||
|
||||
stmt: Select = (
|
||||
select(Content, ContentProfile)
|
||||
.join(ContentProfile, Content.content_id == ContentProfile.content_id)
|
||||
.where(and_(Content.content_id.in_(unique_ids), text_filter))
|
||||
)
|
||||
|
||||
rows = (await self._session.execute(stmt)).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# 先组装主体+画像,后续再补 risk_flags
|
||||
by_id: dict[int, dict[str, Any]] = {}
|
||||
valid_ids: list[int] = []
|
||||
for content, profile in rows:
|
||||
cid = int(content.content_id)
|
||||
text = pick_text(text_en=content.text_en, text_tc=content.text_tc, locale=locale)
|
||||
if not text:
|
||||
continue
|
||||
by_id[cid] = {
|
||||
"content": content,
|
||||
"profile": profile,
|
||||
"text": text,
|
||||
}
|
||||
valid_ids.append(cid)
|
||||
|
||||
if not by_id:
|
||||
return []
|
||||
|
||||
# 批量取 flags(避免 join 行膨胀)
|
||||
flags_stmt = select(ContentRiskFlag.content_id, ContentRiskFlag.flag).where(
|
||||
ContentRiskFlag.content_id.in_(list(by_id.keys()))
|
||||
)
|
||||
flags_rows = (await self._session.execute(flags_stmt)).all()
|
||||
flags_map: dict[int, list[str]] = defaultdict(list)
|
||||
for cid, flag in flags_rows:
|
||||
flags_map[int(cid)].append(str(flag))
|
||||
|
||||
result_by_id: dict[int, ContentProfileDTO] = {}
|
||||
for cid, payload in by_id.items():
|
||||
content: Content = payload["content"]
|
||||
profile: ContentProfile = payload["profile"]
|
||||
text: str = payload["text"]
|
||||
|
||||
dto = ContentProfileDTO(
|
||||
content_id=cid,
|
||||
text=text,
|
||||
stage=profile.stage, # type: ignore[arg-type]
|
||||
emotion_score=float(profile.emotion_score) if profile.emotion_score is not None else None,
|
||||
context_suitability=normalize_suitability(profile.context_suitability_json, keys=CONTEXT_KEYS),
|
||||
need_suitability=normalize_suitability(profile.need_suitability_json, keys=NEED_KEYS),
|
||||
personalization_power=normalize_personalization_power(profile.personalization_power),
|
||||
risk_flags=normalize_risk_flags(flags_map.get(cid)),
|
||||
author_id=content.author_id,
|
||||
template_id=content.template_id,
|
||||
review_confidence=normalize_review_confidence(profile.review_confidence),
|
||||
)
|
||||
result_by_id[cid] = dto
|
||||
|
||||
# 按输入顺序返回(跳过缺失/被过滤的)
|
||||
out: list[ContentProfileDTO] = []
|
||||
for cid in unique_ids:
|
||||
dto = result_by_id.get(cid)
|
||||
if dto is not None:
|
||||
out.append(dto)
|
||||
return out
|
||||
|
||||
async def fetch_candidates(
|
||||
self,
|
||||
*,
|
||||
scene: str,
|
||||
user_profile: object,
|
||||
fallback_level: int,
|
||||
limit: int,
|
||||
locale: str,
|
||||
exclude_content_ids: list[int] | None = None,
|
||||
) -> list[ContentProfileDTO]:
|
||||
"""
|
||||
两段式候选召回:
|
||||
1) 先查候选 content_id 列表(含粗过滤、locale 过滤、limit*multiplier)
|
||||
2) 再批量补全字段(复用 fetch_contents_by_ids)
|
||||
"""
|
||||
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
signals = _extract_user_signals(user_profile)
|
||||
effective_fallback = int(fallback_level)
|
||||
if signals.missing_need or signals.missing_context or signals.missing_emotion:
|
||||
effective_fallback = max(effective_fallback, 1)
|
||||
|
||||
# locale 文本存在性过滤(不允许语言回退)
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
|
||||
loc = normalize_locale(locale)
|
||||
text_filter = Content.text_en.is_not(None) if loc == "en" else Content.text_tc.is_not(None)
|
||||
|
||||
filters: list[Any] = [text_filter]
|
||||
if exclude_content_ids:
|
||||
filters.append(not_(Content.content_id.in_(exclude_content_ids)))
|
||||
|
||||
# fallback 约束(repository 只做“降级约束”,不做 hard filter)
|
||||
if effective_fallback >= 1:
|
||||
# personalization_power <= 5 代表 <= 0.5
|
||||
filters.append(ContentProfile.personalization_power <= 5)
|
||||
if effective_fallback >= 2:
|
||||
filters.append(ContentProfile.personalization_power == 0)
|
||||
filters.append(ContentProfile.stage == "general")
|
||||
if effective_fallback >= 3:
|
||||
filters.append(ContentProfile.is_safe_pool.is_(True))
|
||||
filters.append(ContentProfile.personalization_power == 0)
|
||||
filters.append(ContentProfile.stage == "general")
|
||||
|
||||
# stage 粗过滤(仅 L0/L1 才做“用户阶段 + general”;L2/L3 已强制 general)
|
||||
if effective_fallback < 2:
|
||||
user_stage = signals.stage
|
||||
if user_stage in {"expecting", "parenting"}:
|
||||
filters.append(ContentProfile.stage.in_([user_stage, "general"]))
|
||||
else:
|
||||
# unknown 或无法判定:仅取 general,避免误推
|
||||
filters.append(ContentProfile.stage == "general")
|
||||
|
||||
multiplier = 5
|
||||
raw_limit = max(limit * multiplier, limit)
|
||||
|
||||
stmt_ids = (
|
||||
select(Content.content_id)
|
||||
.join(ContentProfile, Content.content_id == ContentProfile.content_id)
|
||||
.where(and_(*filters))
|
||||
.order_by(desc(ContentProfile.updated_at))
|
||||
.limit(raw_limit)
|
||||
)
|
||||
|
||||
candidate_ids_rows = (await self._session.execute(stmt_ids)).scalars().all()
|
||||
candidate_ids = [int(x) for x in candidate_ids_rows]
|
||||
if not candidate_ids:
|
||||
return []
|
||||
|
||||
# 复用按 ID 批量补全(会再次做 locale 过滤,但成本可接受,且可保证一致行为)
|
||||
items = await self.fetch_contents_by_ids(content_ids=candidate_ids, locale=locale)
|
||||
return items[:limit]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
136
server/app/features/personalized_reco/observability/builder.py
Normal file
136
server/app/features/personalized_reco/observability/builder.py
Normal 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),
|
||||
)
|
||||
|
||||
51
server/app/features/personalized_reco/observability/types.py
Normal file
51
server/app/features/personalized_reco/observability/types.py
Normal 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)
|
||||
|
||||
61
server/app/features/personalized_reco/observability/utils.py
Normal file
61
server/app/features/personalized_reco/observability/utils.py
Normal 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 口径)。
|
||||
|
||||
规则:
|
||||
- need:user_profile.need 为空对象 {} 或不存在
|
||||
- context:user_profile.context 为空对象 {} 或不存在
|
||||
- emotion:user_profile.emotion_score 为 None 或不存在
|
||||
"""
|
||||
|
||||
need = getattr(user_profile, "need", None)
|
||||
context = getattr(user_profile, "context", None)
|
||||
emotion_score = getattr(user_profile, "emotion_score", None)
|
||||
|
||||
need_missing = not bool(need)
|
||||
context_missing = not bool(context)
|
||||
emotion_missing = emotion_score is None
|
||||
|
||||
return MissingFields(need=need_missing, context=context_missing, emotion=emotion_missing)
|
||||
|
||||
|
||||
def compute_empty_reason(
|
||||
*,
|
||||
served_k: int,
|
||||
candidate_pool_size_raw: int,
|
||||
candidate_pool_size_after_hard_filter: int,
|
||||
candidate_pool_size_after_freqcap: int,
|
||||
) -> Optional[EmptyReason]:
|
||||
"""
|
||||
判定 empty_reason(served_k=0 必填)。
|
||||
|
||||
规则(对齐 plan):
|
||||
- served_k>0 -> None
|
||||
- raw==0 -> pool_empty
|
||||
- raw>0 且 after_hard_filter==0 -> hard_filter_all
|
||||
- after_freqcap==0 -> freqcap_all
|
||||
- 其他 -> unknown
|
||||
"""
|
||||
|
||||
if int(served_k) > 0:
|
||||
return None
|
||||
|
||||
raw = int(candidate_pool_size_raw)
|
||||
after_hard = int(candidate_pool_size_after_hard_filter)
|
||||
after_freqcap = int(candidate_pool_size_after_freqcap)
|
||||
|
||||
if raw == 0:
|
||||
return "pool_empty"
|
||||
if raw > 0 and after_hard == 0:
|
||||
return "hard_filter_all"
|
||||
if after_freqcap == 0:
|
||||
return "freqcap_all"
|
||||
return "unknown"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Reco Engine(推荐引擎编排)。
|
||||
|
||||
该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline,
|
||||
并输出统一结构:items + meta(可观测字段)。
|
||||
"""
|
||||
|
||||
from app.features.personalized_reco.reco_engine.orchestrator import recommend
|
||||
|
||||
__all__ = ["recommend"]
|
||||
|
||||
@@ -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())
|
||||
|
||||
128
server/app/features/personalized_reco/reco_engine/hard_filter.py
Normal file
128
server/app/features/personalized_reco/reco_engine/hard_filter.py
Normal 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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO, normalize_locale
|
||||
from app.features.personalized_reco.observability.builder import RecoMetaBuilder
|
||||
from app.features.personalized_reco.reco_engine.defaults import get_default_engine_config
|
||||
from app.features.personalized_reco.reco_engine.hard_filter import hard_filter
|
||||
from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineConfig, RecoEngineResult, RecommendedItem, Scene
|
||||
from app.features.personalized_reco.reco_engine.utils import (
|
||||
clamp_personalization_power,
|
||||
merge_exclude_ids,
|
||||
normalize_or_default_locale,
|
||||
)
|
||||
from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap
|
||||
from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate
|
||||
from app.features.personalized_reco.scoring.defaults import get_default_config as get_default_score_config
|
||||
from app.features.personalized_reco.scoring.score import score_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_int(value: Any, *, default: int = 0) -> int:
|
||||
try:
|
||||
n = int(value)
|
||||
except Exception:
|
||||
return int(default)
|
||||
return int(n)
|
||||
|
||||
|
||||
def _light_score_summary(score_result: Any) -> dict[str, Any]:
|
||||
"""
|
||||
轻量 explanations:只保留少量关键字段,避免 payload 过大。
|
||||
"""
|
||||
|
||||
bd = getattr(score_result, "breakdown", None)
|
||||
if bd is None:
|
||||
return {}
|
||||
|
||||
def _get(name: str) -> Optional[float]:
|
||||
v = getattr(bd, name, None)
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except Exception:
|
||||
return None
|
||||
if f != f:
|
||||
return None
|
||||
return f
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"missing_fields": list(getattr(bd, "missing_fields", []) or []),
|
||||
"S_core": _get("S_core"),
|
||||
"S_personal": _get("S_personal"),
|
||||
"P_uncertainty": _get("P_uncertainty"),
|
||||
"P_risk": _get("P_risk"),
|
||||
"P_widget_emotion_out_of_range": _get("P_widget_emotion_out_of_range"),
|
||||
}
|
||||
# 删除 None,减少噪音
|
||||
return {k: v for k, v in out.items() if v is not None and v != []}
|
||||
|
||||
|
||||
def _apply_fallback_level_to_content(content: ContentProfileDTO, *, fallback_level: int) -> ContentProfileDTO:
|
||||
"""
|
||||
对内容做防御式一致性处理(与回退梯度一致)。
|
||||
"""
|
||||
|
||||
p2 = clamp_personalization_power(content.personalization_power, fallback_level=fallback_level)
|
||||
if p2 == content.personalization_power:
|
||||
return content
|
||||
return content.model_copy(update={"personalization_power": float(p2)})
|
||||
|
||||
|
||||
def _merge_counter(dst: dict[str, int], src: dict[str, Any]) -> None:
|
||||
for k, v in (src or {}).items():
|
||||
try:
|
||||
n = int(v)
|
||||
except Exception:
|
||||
n = 0
|
||||
dst[str(k)] = int(dst.get(str(k), 0)) + max(0, int(n))
|
||||
|
||||
|
||||
async def recommend(
|
||||
*,
|
||||
repo: ContentRepository,
|
||||
scene: Scene,
|
||||
user_profile: object,
|
||||
already_recommended_ids: list[Any],
|
||||
touched_or_viewed_ids: list[Any],
|
||||
k: int,
|
||||
now: datetime,
|
||||
locale: Optional[str] = None,
|
||||
constraints: Optional[RecoConstraints] = None,
|
||||
config: Optional[RecoEngineConfig] = None,
|
||||
) -> RecoEngineResult:
|
||||
"""
|
||||
Reco Engine 主入口:编排候选→过滤→打分→重排→回退,并输出 items + meta。
|
||||
"""
|
||||
|
||||
cfg = config or get_default_engine_config(scene)
|
||||
cons = constraints or RecoConstraints()
|
||||
|
||||
k_i = max(0, _safe_int(k, default=0))
|
||||
meta_builder = RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k_i, now=now)
|
||||
|
||||
if k_i <= 0:
|
||||
meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
|
||||
meta_builder.set_config_snapshot({"engine_note": "k<=0,直接返回空结果"})
|
||||
return RecoEngineResult(items=[], meta=meta_builder.build())
|
||||
|
||||
# locale:默认 en;严格校验仅支持 en/tc
|
||||
raw_locale = normalize_or_default_locale(locale)
|
||||
try:
|
||||
effective_locale = normalize_locale(raw_locale)
|
||||
except Exception as e:
|
||||
meta_builder.set_config_snapshot({"error": str(e), "stage": "normalize_locale", "locale": raw_locale})
|
||||
meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
|
||||
return RecoEngineResult(items=[], meta=meta_builder.build())
|
||||
|
||||
# 聚合统计(跨回退层级累加,确保 meta 单调性成立)
|
||||
raw_total = 0
|
||||
after_hard_total = 0
|
||||
after_dedup_total = 0
|
||||
after_freqcap_total = 0
|
||||
|
||||
risk_counts_total: dict[str, int] = defaultdict(int)
|
||||
freqcap_counts_total: dict[str, int] = defaultdict(int)
|
||||
|
||||
fallback_trace: list[dict[str, Any]] = []
|
||||
selected: list[ScoredCandidate] = []
|
||||
selected_level_by_id: dict[int, int] = {}
|
||||
|
||||
last_fallback_level = 0
|
||||
last_reason = None
|
||||
|
||||
for level in [0, 1, 2, 3]:
|
||||
last_fallback_level = int(level)
|
||||
k_remaining = max(0, k_i - len(selected))
|
||||
if k_remaining <= 0:
|
||||
break
|
||||
|
||||
# Feed:允许不足且不补齐时,拿到任何结果就停止
|
||||
if scene == "feed" and cfg.feed_allow_partial and (not cfg.feed_fill_with_fallback) and len(selected) > 0:
|
||||
break
|
||||
|
||||
# exclude_ids:already/touched + constraints.exclude + 已选内容(避免跨层重复)
|
||||
exclude_ids = merge_exclude_ids(
|
||||
already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
|
||||
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||
extra_exclude_content_ids=list(cons.exclude_content_ids or []),
|
||||
)
|
||||
|
||||
multiplier = int(cfg.candidate_multiplier_feed if scene == "feed" else cfg.candidate_multiplier_push_widget)
|
||||
base_limit = max(int(cfg.min_candidates_per_level), int(k_remaining) * max(1, int(multiplier)))
|
||||
if cons.max_candidates_limit is not None and int(cons.max_candidates_limit) > 0:
|
||||
limit = min(base_limit, int(cons.max_candidates_limit))
|
||||
else:
|
||||
limit = base_limit
|
||||
|
||||
# 1) Candidate
|
||||
try:
|
||||
cands = await repo.fetch_candidates(
|
||||
scene=scene,
|
||||
user_profile=user_profile,
|
||||
fallback_level=int(level),
|
||||
limit=int(limit),
|
||||
locale=str(effective_locale),
|
||||
exclude_content_ids=exclude_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("fetch_candidates 失败:%s", e)
|
||||
last_reason = "error:fetch_candidates"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": 0,
|
||||
"after_hard": 0,
|
||||
"after_dedup": 0,
|
||||
"after_freqcap": 0,
|
||||
"served_total": len(selected),
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
raw_total += len(cands)
|
||||
|
||||
if not cands:
|
||||
last_reason = "pool_empty"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": 0,
|
||||
"after_hard": 0,
|
||||
"after_dedup": 0,
|
||||
"after_freqcap": 0,
|
||||
"served_total": len(selected),
|
||||
"reason": "pool_empty",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# 2) Hard Filter
|
||||
hf = hard_filter(scene=scene, user_profile=user_profile, candidates=cands, constraints=cons)
|
||||
kept = [x for x in hf.kept_items if isinstance(x, ContentProfileDTO)]
|
||||
after_hard_total += len(kept)
|
||||
_merge_counter(risk_counts_total, hf.risk_filtered_count_by_flag)
|
||||
|
||||
if not kept:
|
||||
last_reason = "hard_filter_all"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": len(cands),
|
||||
"after_hard": 0,
|
||||
"after_dedup": 0,
|
||||
"after_freqcap": 0,
|
||||
"served_total": len(selected),
|
||||
"reason": "hard_filter_all",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# 3) Soft Scoring
|
||||
score_cfg = get_default_score_config(scene)
|
||||
if scene == "push":
|
||||
# Push:强制启用不确定性惩罚(与 spec 对齐)
|
||||
score_cfg = score_cfg.model_copy(update={"enable_uncertainty_penalty": True})
|
||||
|
||||
scored: list[ScoredCandidate] = []
|
||||
for c in kept:
|
||||
c2 = _apply_fallback_level_to_content(c, fallback_level=int(level))
|
||||
try:
|
||||
s = score_content(scene=scene, user_profile=user_profile, content_profile=c2, config=score_cfg, pass_filters=True, now=now)
|
||||
except Exception as e:
|
||||
# 单条异常不影响整体
|
||||
logger.exception("score_content 失败 content_id=%s:%s", getattr(c2, "content_id", None), e)
|
||||
continue
|
||||
|
||||
cid = int(c2.content_id)
|
||||
hits = hf.hits_by_content_id.get(cid, [])
|
||||
extra: dict[str, Any] = {
|
||||
"text": c2.text,
|
||||
"fallback_level_used": int(level),
|
||||
}
|
||||
if cfg.enable_explanations:
|
||||
extra["hard_filter_hits"] = hits
|
||||
extra["score_summary"] = _light_score_summary(s)
|
||||
|
||||
scored.append(
|
||||
ScoredCandidate(
|
||||
content_id=cid,
|
||||
final_score=float(getattr(s, "final_score", 0.0)),
|
||||
author_id=c2.author_id,
|
||||
template_id=c2.template_id,
|
||||
content_profile=c2,
|
||||
extra=extra,
|
||||
)
|
||||
)
|
||||
|
||||
if not scored:
|
||||
last_reason = "empty_after_scoring"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": len(cands),
|
||||
"after_hard": len(kept),
|
||||
"after_dedup": 0,
|
||||
"after_freqcap": 0,
|
||||
"served_total": len(selected),
|
||||
"reason": "empty_after_scoring",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# 4) Rerank/Freqcap
|
||||
try:
|
||||
rer = rerank_and_freqcap(
|
||||
scene=scene,
|
||||
scored_candidates=scored,
|
||||
already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
|
||||
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||
k=int(k_remaining),
|
||||
recent_author_ids=cons.recent_author_ids,
|
||||
recent_template_ids=cons.recent_template_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("rerank_and_freqcap 失败:%s", e)
|
||||
last_reason = "error:rerank_and_freqcap"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": len(cands),
|
||||
"after_hard": len(kept),
|
||||
"after_dedup": 0,
|
||||
"after_freqcap": 0,
|
||||
"served_total": len(selected),
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
after_dedup_total += int(rer.meta.candidate_pool_size_after_dedup)
|
||||
after_freqcap_total += int(rer.meta.candidate_pool_size_after_freqcap)
|
||||
_merge_counter(freqcap_counts_total, rer.meta.freqcap_filtered_counts)
|
||||
|
||||
served_level = list(rer.ranked_items or [])[:k_remaining]
|
||||
if not served_level:
|
||||
last_reason = "freqcap_all"
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": len(cands),
|
||||
"after_hard": len(kept),
|
||||
"after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
|
||||
"after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
|
||||
"served_total": len(selected),
|
||||
"reason": "freqcap_all",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
for it in served_level:
|
||||
cid = int(it.content_id)
|
||||
selected.append(it)
|
||||
selected_level_by_id[cid] = int(level)
|
||||
|
||||
last_reason = None
|
||||
fallback_trace.append(
|
||||
{
|
||||
"level": int(level),
|
||||
"raw": len(cands),
|
||||
"after_hard": len(kept),
|
||||
"after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
|
||||
"after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
|
||||
"served_total": len(selected),
|
||||
"served_added": len(served_level),
|
||||
}
|
||||
)
|
||||
|
||||
if len(selected) >= k_i:
|
||||
break
|
||||
|
||||
# 组装输出 items(按 selected 顺序)
|
||||
items: list[RecommendedItem] = []
|
||||
for c in selected[:k_i]:
|
||||
cid = int(c.content_id)
|
||||
text = ""
|
||||
if isinstance(c.extra, dict):
|
||||
text = str(c.extra.get("text") or "")
|
||||
|
||||
explanations = None
|
||||
if cfg.enable_explanations and isinstance(c.extra, dict):
|
||||
explanations = {
|
||||
"fallback_level_used": c.extra.get("fallback_level_used"),
|
||||
"hard_filter_hits": c.extra.get("hard_filter_hits"),
|
||||
"score_summary": c.extra.get("score_summary"),
|
||||
}
|
||||
|
||||
items.append(
|
||||
RecommendedItem(
|
||||
content_id=cid,
|
||||
text=text,
|
||||
final_score=float(c.final_score),
|
||||
fallback_level_final=int(selected_level_by_id.get(cid, last_fallback_level)),
|
||||
explanations=explanations,
|
||||
)
|
||||
)
|
||||
|
||||
served_k = len(items)
|
||||
|
||||
# meta:使用聚合统计,确保单调性约束成立(raw>=after_hard>=after_dedup>=after_freqcap>=served_k)
|
||||
# 注意:聚合统计理论上可能出现 after_* > raw_total(例如 repo 返回重复/异常),此处交由 builder 防御修正
|
||||
meta_builder.set_candidate_pool_size_raw(int(raw_total))
|
||||
meta_builder.set_after_hard_filter(int(after_hard_total), risk_filtered_count_by_flag=dict(risk_counts_total))
|
||||
meta_builder.set_after_dedup(int(after_dedup_total))
|
||||
meta_builder.set_after_freqcap(int(after_freqcap_total), freqcap_filtered_counts=dict(freqcap_counts_total))
|
||||
meta_builder.set_served_k(int(served_k))
|
||||
meta_builder.set_fallback_level_final(int(last_fallback_level), reason=last_reason)
|
||||
|
||||
meta_builder.set_config_snapshot(
|
||||
{
|
||||
"fallback_trace": fallback_trace,
|
||||
"engine_config": cfg.model_dump(),
|
||||
"constraints": cons.model_dump(),
|
||||
"locale": effective_locale,
|
||||
}
|
||||
)
|
||||
|
||||
return RecoEngineResult(items=items, meta=meta_builder.build())
|
||||
|
||||
101
server/app/features/personalized_reco/reco_engine/types.py
Normal file
101
server/app/features/personalized_reco/reco_engine/types.py
Normal 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 作者/模板冷却窗口内的历史集合(增强频控输入)
|
||||
# 说明:若不提供(None),rerank_freqcap 会记录缺失并跳过该维度过滤
|
||||
recent_author_ids: Optional[list[str]] = None
|
||||
recent_template_ids: Optional[list[str]] = None
|
||||
|
||||
|
||||
class RecoEngineConfig(BaseModel):
|
||||
"""
|
||||
引擎级配置(V1 可调参项)。
|
||||
"""
|
||||
|
||||
# Feed 是否允许 served_k < k(允许不足)
|
||||
feed_allow_partial: bool = True
|
||||
# Feed 是否在不足时继续回退补齐
|
||||
feed_fill_with_fallback: bool = True
|
||||
|
||||
# 候选拉取倍率(limit = min(max_candidates_limit, k * multiplier))
|
||||
candidate_multiplier_feed: int = 10
|
||||
candidate_multiplier_push_widget: int = 30
|
||||
|
||||
# 每层回退的最大候选数量下限(避免 k=1 但候选过少)
|
||||
min_candidates_per_level: int = 30
|
||||
|
||||
# explanations 默认开启(但应保持轻量)
|
||||
enable_explanations: bool = True
|
||||
|
||||
|
||||
class RecommendedItem(BaseModel):
|
||||
"""
|
||||
引擎最终下发的推荐项。
|
||||
"""
|
||||
|
||||
content_id: int
|
||||
text: str
|
||||
final_score: float
|
||||
fallback_level_final: int
|
||||
|
||||
# 解释信息:默认开启,但建议保持轻量(避免 payload 过大)
|
||||
explanations: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class RecoEngineResult(BaseModel):
|
||||
"""
|
||||
引擎输出容器:items + meta。
|
||||
"""
|
||||
|
||||
items: list[RecommendedItem] = Field(default_factory=list)
|
||||
meta: RecoMeta
|
||||
|
||||
|
||||
class HardFilterResult(BaseModel):
|
||||
"""
|
||||
Hard Filter 输出。
|
||||
"""
|
||||
|
||||
kept_items: list[Any] = Field(default_factory=list)
|
||||
removed_count: int = 0
|
||||
risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict)
|
||||
# 每条内容的命中信息(仅用于 explanations;默认可为空)
|
||||
hits_by_content_id: dict[int, list[str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecommendRequest(BaseModel):
|
||||
"""
|
||||
内部便捷结构(单测/集成时可用)。
|
||||
"""
|
||||
|
||||
scene: Scene
|
||||
user_profile: Any
|
||||
already_recommended_ids: list[Any] = Field(default_factory=list)
|
||||
touched_or_viewed_ids: list[Any] = Field(default_factory=list)
|
||||
k: int = 1
|
||||
now: datetime
|
||||
locale: Optional[str] = None
|
||||
constraints: Optional[RecoConstraints] = None
|
||||
config: Optional[RecoEngineConfig] = None
|
||||
|
||||
90
server/app/features/personalized_reco/reco_engine/utils.py
Normal file
90
server/app/features/personalized_reco/reco_engine/utils.py
Normal 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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, Scene
|
||||
|
||||
|
||||
_DEFAULTS: dict[Scene, RerankConfig] = {
|
||||
# Feed:MMR λ=0.7;冷却参数不强制使用
|
||||
"feed": RerankConfig(
|
||||
mmr_lambda=0.7,
|
||||
top_n_for_mmr=200,
|
||||
cooldown_sentence_days=0,
|
||||
cooldown_author_days=0,
|
||||
cooldown_template_days=0,
|
||||
),
|
||||
# Push:工程默认(来自算法规则的建议参数)
|
||||
"push": RerankConfig(
|
||||
mmr_lambda=0.7,
|
||||
top_n_for_mmr=200,
|
||||
cooldown_sentence_days=14,
|
||||
cooldown_author_days=7,
|
||||
cooldown_template_days=7,
|
||||
),
|
||||
# Widget:工程默认
|
||||
"widget": RerankConfig(
|
||||
mmr_lambda=0.7,
|
||||
top_n_for_mmr=200,
|
||||
cooldown_sentence_days=7,
|
||||
cooldown_author_days=7,
|
||||
cooldown_template_days=7,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_default_config(scene: Scene) -> RerankConfig:
|
||||
"""
|
||||
获取指定场景的默认参数(返回副本,避免被意外修改)。
|
||||
"""
|
||||
|
||||
base = _DEFAULTS[scene]
|
||||
return RerankConfig.model_validate(base.model_dump())
|
||||
|
||||
208
server/app/features/personalized_reco/rerank_freqcap/rerank.py
Normal file
208
server/app/features/personalized_reco/rerank_freqcap/rerank.py
Normal 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 + MMR;Push/Widget 做 dedup + freqcap + TopK
|
||||
"""
|
||||
|
||||
cfg = config or get_default_config(scene)
|
||||
|
||||
# seen_ids = already_recommended_ids ∪ touched_or_viewed_ids
|
||||
seen_ids = normalize_int_id_set(list(already_recommended_ids) + list(touched_or_viewed_ids))
|
||||
|
||||
# 先按分数降序,保证 Top1 与 TopK 一致
|
||||
base_sorted = _sort_by_score_desc(list(scored_candidates))
|
||||
|
||||
after_dedup, removed_sentence = _dedup_by_seen_ids(base_sorted, seen_ids=seen_ids)
|
||||
candidate_pool_size_after_dedup = len(after_dedup)
|
||||
|
||||
missing_history_fields: list[str] = []
|
||||
freqcap_counts: dict[str, int] = {"sentence": int(removed_sentence)}
|
||||
|
||||
after_freqcap = after_dedup
|
||||
|
||||
# Push/Widget:作者/模板冷却(增强项)
|
||||
if scene in {"push", "widget"}:
|
||||
after_freqcap, dim_counts, missing = _apply_author_template_freqcap(
|
||||
after_freqcap,
|
||||
recent_author_ids=recent_author_ids,
|
||||
recent_template_ids=recent_template_ids,
|
||||
)
|
||||
missing_history_fields = missing
|
||||
freqcap_counts.update(dim_counts)
|
||||
else:
|
||||
# Feed:不强制作者/模板冷却(V1 可选,这里默认跳过)
|
||||
missing_history_fields = []
|
||||
|
||||
candidate_pool_size_after_freqcap = len(after_freqcap)
|
||||
|
||||
ranked: list[ScoredCandidate]
|
||||
if scene == "feed":
|
||||
# MMR 前截断,避免性能问题
|
||||
top_n = int(cfg.top_n_for_mmr) if int(cfg.top_n_for_mmr) > 0 else len(after_freqcap)
|
||||
mmr_pool = after_freqcap[:top_n]
|
||||
ranked = _mmr_rerank(candidates=mmr_pool, k=int(k), lam=cfg.mmr_lambda)
|
||||
else:
|
||||
ranked = after_freqcap[: max(0, int(k))]
|
||||
|
||||
meta = RerankMeta(
|
||||
candidate_pool_size_after_dedup=int(candidate_pool_size_after_dedup),
|
||||
candidate_pool_size_after_freqcap=int(candidate_pool_size_after_freqcap),
|
||||
missing_history_fields=missing_history_fields,
|
||||
freqcap_filtered_counts=freqcap_counts,
|
||||
)
|
||||
return RerankResult(ranked_items=ranked, meta=meta)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
|
||||
Scene = Literal["feed", "push", "widget"]
|
||||
|
||||
|
||||
class ScoredCandidate(BaseModel):
|
||||
"""
|
||||
Soft Scoring 后的候选项(本模块消费的最小字段集合)。
|
||||
|
||||
说明:
|
||||
- `content_profile` 用于 Feed 的标签/相似度计算;缺失时需降级为仅使用 author/template 等字段
|
||||
"""
|
||||
|
||||
content_id: int
|
||||
final_score: float
|
||||
|
||||
author_id: Optional[str] = None
|
||||
template_id: Optional[str] = None
|
||||
|
||||
content_profile: Optional[ContentProfileDTO] = None
|
||||
|
||||
# 允许透传额外字段(例如 text、breakdown 等),便于上层直接下发
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RerankConfig(BaseModel):
|
||||
"""
|
||||
重排/频控配置(可调参)。
|
||||
"""
|
||||
|
||||
# Feed:MMR
|
||||
mmr_lambda: float = 0.7
|
||||
top_n_for_mmr: int = 200
|
||||
|
||||
# Push/Widget:冷却窗口(V1 主要用于配置与可观测;真正按天需要带时间戳的历史)
|
||||
cooldown_sentence_days: int = 14
|
||||
cooldown_author_days: int = 7
|
||||
cooldown_template_days: int = 7
|
||||
|
||||
|
||||
class RerankMeta(BaseModel):
|
||||
candidate_pool_size_after_dedup: int
|
||||
candidate_pool_size_after_freqcap: int
|
||||
|
||||
# 例如未提供 recent_author_ids/recent_template_ids 时记录 ["author","template"]
|
||||
missing_history_fields: list[str] = Field(default_factory=list)
|
||||
|
||||
# 可选但建议:按维度统计被过滤数量
|
||||
freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RerankResult(BaseModel):
|
||||
ranked_items: list[ScoredCandidate] = Field(default_factory=list)
|
||||
meta: RerankMeta
|
||||
|
||||
107
server/app/features/personalized_reco/rerank_freqcap/utils.py
Normal file
107
server/app/features/personalized_reco/rerank_freqcap/utils.py
Normal 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 字典中取最大值 key(V1 用作代表标签)。
|
||||
- 空字典/None -> None
|
||||
- 值非法 -> 按 default=0 处理
|
||||
"""
|
||||
|
||||
if not d:
|
||||
return None
|
||||
best_k: str | None = None
|
||||
best_v = float("-inf")
|
||||
for k, v in d.items():
|
||||
fv = as_finite_float(v, default=0.0)
|
||||
if fv > best_v:
|
||||
best_v = fv
|
||||
best_k = k
|
||||
return best_k
|
||||
|
||||
|
||||
def build_tags(candidate: Any) -> set[str]:
|
||||
"""
|
||||
构造离散标签集合(V1 写死):
|
||||
- stage:<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
|
||||
|
||||
22
server/app/features/personalized_reco/scoring/__init__.py
Normal file
22
server/app/features/personalized_reco/scoring/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
44
server/app/features/personalized_reco/scoring/defaults.py
Normal file
44
server/app/features/personalized_reco/scoring/defaults.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.features.personalized_reco.scoring.types import Scene, ScoreConfig
|
||||
|
||||
|
||||
_DEFAULTS: dict[Scene, ScoreConfig] = {
|
||||
# 来源:设计说明文档/個性化推薦算法規則.md(V1 建议权重)
|
||||
"feed": ScoreConfig(
|
||||
w_need=0.35,
|
||||
w_emotion=0.20,
|
||||
w_stage=0.15,
|
||||
w_context=0.30,
|
||||
# Feed 默认不启用不确定性惩罚(可按需开启)
|
||||
enable_uncertainty_penalty=False,
|
||||
),
|
||||
"push": ScoreConfig(
|
||||
w_need=0.45,
|
||||
w_emotion=0.35,
|
||||
w_stage=0.15,
|
||||
w_context=0.05,
|
||||
# Push 默认启用不确定性惩罚
|
||||
enable_uncertainty_penalty=True,
|
||||
),
|
||||
"widget": ScoreConfig(
|
||||
w_need=0.25,
|
||||
w_emotion=0.25,
|
||||
w_stage=0.30,
|
||||
w_context=0.20,
|
||||
# Widget 默认不启用不确定性惩罚(可按需开启)
|
||||
enable_uncertainty_penalty=False,
|
||||
widget_emotion_soft_range=(0.4, 0.8),
|
||||
widget_emotion_penalty_gamma=0.25,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_default_config(scene: Scene) -> ScoreConfig:
|
||||
"""
|
||||
获取指定场景的默认打分参数(返回副本,避免被意外修改)。
|
||||
"""
|
||||
|
||||
base = _DEFAULTS[scene]
|
||||
return ScoreConfig.model_validate(base.model_dump())
|
||||
|
||||
201
server/app/features/personalized_reco/scoring/score.py
Normal file
201
server/app/features/personalized_reco/scoring/score.py
Normal 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
|
||||
|
||||
# 文案 general(emotion_score=None)-> 0.8
|
||||
if content.emotion_score is None:
|
||||
return 0.8
|
||||
|
||||
u = clamp(as_finite_float(user_profile.emotion_score, default=0.8), 0.0, 1.0)
|
||||
c = clamp(as_finite_float(content.emotion_score, default=0.8), 0.0, 1.0)
|
||||
return clamp(1.0 - abs(u - c), 0.0, 1.0)
|
||||
|
||||
|
||||
def _user_stage_key(user_profile: UserProfileV1_2) -> str:
|
||||
# 约定:UserStageOneHot.unknown 必填;但这里仍做防御
|
||||
stage = user_profile.stage
|
||||
if getattr(stage, "expecting", 0) == 1:
|
||||
return "expecting"
|
||||
if getattr(stage, "parenting", 0) == 1:
|
||||
return "parenting"
|
||||
if getattr(stage, "unknown", 1) == 1:
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _score_stage(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
# 对齐算法规则:
|
||||
# - general=1;命中=1;unknown对非unknown=0.7;其余=0
|
||||
if content.stage == "general":
|
||||
return 1.0
|
||||
|
||||
u_stage = _user_stage_key(user_profile)
|
||||
if content.stage == u_stage:
|
||||
return 1.0
|
||||
|
||||
if u_stage == "unknown" and content.stage != "unknown":
|
||||
return 0.7
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _score_personal(alpha: float, personalization_power: float, s_need: float, s_context: float) -> float:
|
||||
power = clamp(as_finite_float(personalization_power, default=0.0), 0.0, 1.0)
|
||||
a = as_finite_float(alpha, default=0.0)
|
||||
return float(a) * float(power) * max(float(s_need), float(s_context))
|
||||
|
||||
|
||||
def _penalty_uncertainty(beta: float, user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
b = as_finite_float(beta, default=0.0)
|
||||
power = clamp(as_finite_float(content.personalization_power, default=0.0), 0.0, 1.0)
|
||||
|
||||
# V1 约定:conf_U 缺失时按 1.0(避免过惩罚)
|
||||
conf_u = clamp(as_finite_float(getattr(user_profile, "profile_confidence", 1.0), default=1.0), 0.0, 1.0)
|
||||
conf_c = clamp(as_finite_float(getattr(content, "review_confidence", 0.7), default=0.7), 0.0, 1.0)
|
||||
|
||||
return float(b) * (1.0 - float(conf_u)) * (1.0 - float(conf_c)) * float(power)
|
||||
|
||||
|
||||
def _widget_emotion_penalty(scene: Scene, content: ContentProfileDTO, config: ScoreConfig) -> float:
|
||||
if scene != "widget":
|
||||
return 0.0
|
||||
if content.emotion_score is None:
|
||||
return 0.0
|
||||
|
||||
lo, hi = config.widget_emotion_soft_range
|
||||
lo_f = as_finite_float(lo, default=0.4)
|
||||
hi_f = as_finite_float(hi, default=0.8)
|
||||
width = hi_f - lo_f
|
||||
if width <= 0:
|
||||
return 0.0
|
||||
|
||||
e = clamp(as_finite_float(content.emotion_score, default=0.6), 0.0, 1.0)
|
||||
if e < lo_f:
|
||||
d = lo_f - e
|
||||
elif e > hi_f:
|
||||
d = e - hi_f
|
||||
else:
|
||||
d = 0.0
|
||||
|
||||
gamma = as_finite_float(config.widget_emotion_penalty_gamma, default=0.25)
|
||||
raw = float(gamma) * float(d) / float(width)
|
||||
return clamp(raw, 0.0, float(gamma))
|
||||
|
||||
|
||||
def score_content(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: UserProfileV1_2,
|
||||
content_profile: ContentProfileDTO,
|
||||
config: Optional[ScoreConfig] = None,
|
||||
pass_filters: bool = True,
|
||||
external_terms: Optional[ExternalTerms] = None,
|
||||
now: Optional[datetime] = None, # 预留:V1 不使用
|
||||
) -> ScoreResult:
|
||||
"""
|
||||
主入口:对单条内容 Cᵢ 进行软打分,返回 final_score 与 breakdown。
|
||||
|
||||
说明(V1):
|
||||
- `pass_filters` 来自 Hard Filter(本模块不做硬过滤)
|
||||
- `external_terms` 可注入 S_fresh / P_fatigue / P_repeat / P_risk(缺省按 0)
|
||||
- `now` 预留给未来的 freshness/时间衰减(V1 不实现)
|
||||
"""
|
||||
|
||||
cfg = config or get_default_config(scene)
|
||||
ext = external_terms or ExternalTerms()
|
||||
|
||||
missing = _missing_fields(user_profile)
|
||||
|
||||
s_need = _score_need(user_profile, content_profile)
|
||||
s_context = _score_context(user_profile, content_profile)
|
||||
s_emotion = _score_emotion(user_profile, content_profile)
|
||||
s_stage = _score_stage(user_profile, content_profile)
|
||||
|
||||
w_need = as_finite_float(cfg.w_need, default=0.0)
|
||||
w_emotion = as_finite_float(cfg.w_emotion, default=0.0)
|
||||
w_stage = as_finite_float(cfg.w_stage, default=0.0)
|
||||
w_context = as_finite_float(cfg.w_context, default=0.0)
|
||||
|
||||
s_core = float(w_need) * s_need + float(w_emotion) * s_emotion + float(w_stage) * s_stage + float(w_context) * s_context
|
||||
|
||||
s_personal = _score_personal(cfg.alpha, content_profile.personalization_power, s_need, s_context)
|
||||
|
||||
p_uncertainty = 0.0
|
||||
if cfg.enable_uncertainty_penalty:
|
||||
p_uncertainty = _penalty_uncertainty(cfg.beta, user_profile, content_profile)
|
||||
|
||||
p_widget = _widget_emotion_penalty(scene, content_profile, cfg)
|
||||
|
||||
s_fresh = as_finite_float(ext.S_fresh, default=0.0)
|
||||
p_fatigue = as_finite_float(ext.P_fatigue, default=0.0)
|
||||
p_repeat = as_finite_float(ext.P_repeat, default=0.0)
|
||||
p_risk_external = as_finite_float(ext.P_risk, default=0.0)
|
||||
|
||||
# Widget 软降权并入 P_risk(但在 breakdown 中单独暴露,便于打点)
|
||||
p_risk = float(p_risk_external) + float(p_widget)
|
||||
|
||||
raw_final = s_core + s_personal + float(s_fresh) - float(p_fatigue) - float(p_repeat) - float(p_risk) - float(p_uncertainty)
|
||||
final_score = float(raw_final) if pass_filters else 0.0
|
||||
|
||||
breakdown = ScoreBreakdown(
|
||||
scene=scene,
|
||||
**{
|
||||
"pass": bool(pass_filters),
|
||||
},
|
||||
missing_fields=missing,
|
||||
S_need=float(s_need),
|
||||
S_context=float(s_context),
|
||||
S_stage=float(s_stage),
|
||||
S_emotion=float(s_emotion),
|
||||
S_core=float(s_core),
|
||||
S_personal=float(s_personal),
|
||||
S_fresh=float(s_fresh),
|
||||
P_fatigue=float(p_fatigue),
|
||||
P_repeat=float(p_repeat),
|
||||
P_risk=float(p_risk),
|
||||
P_uncertainty=float(p_uncertainty),
|
||||
P_widget_emotion_out_of_range=float(p_widget),
|
||||
)
|
||||
|
||||
return ScoreResult(final_score=float(final_score), breakdown=breakdown)
|
||||
|
||||
84
server/app/features/personalized_reco/scoring/types.py
Normal file
84
server/app/features/personalized_reco/scoring/types.py
Normal 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
|
||||
|
||||
59
server/app/features/personalized_reco/scoring/utils.py
Normal file
59
server/app/features/personalized_reco/scoring/utils.py
Normal 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
|
||||
|
||||
9
server/app/features/user_profile_scoring/__init__.py
Normal file
9
server/app/features/user_profile_scoring/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
User Profile Scoring(用户画像打分)V1.2
|
||||
|
||||
说明:
|
||||
- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现
|
||||
- 规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)与
|
||||
`设计说明文档/客戶端問卷打分規則.md`(V1.2)为准
|
||||
"""
|
||||
|
||||
194
server/app/features/user_profile_scoring/scoring.py
Normal file
194
server/app/features/user_profile_scoring/scoring.py
Normal 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)
|
||||
- 0–7 天:1.0
|
||||
- 7–30 天:线性衰减到 0.7(含第 30 天)
|
||||
- 30 天以上:0.5
|
||||
"""
|
||||
|
||||
delta = (now - generated_at).total_seconds()
|
||||
if delta <= 0:
|
||||
return 1.0
|
||||
|
||||
days = delta / (24 * 60 * 60)
|
||||
if days <= 7:
|
||||
return 1.0
|
||||
if days <= 30:
|
||||
t = (days - 7) / (30 - 7) # 0..1
|
||||
return 1.0 - 0.3 * t
|
||||
return 0.5
|
||||
|
||||
|
||||
def compute_profile_confidence(conf_time: float, answered: ProfileAnswered) -> float:
|
||||
"""
|
||||
V1.2:profile_confidence(conf_U)
|
||||
conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0)
|
||||
"""
|
||||
|
||||
answered_count = sum(
|
||||
[
|
||||
1 if answered.stage else 0,
|
||||
1 if answered.emotion else 0,
|
||||
1 if answered.context else 0,
|
||||
1 if answered.need else 0,
|
||||
]
|
||||
)
|
||||
completion = answered_count / 4
|
||||
completion_factor = 0.5 + 0.5 * completion
|
||||
return _clamp(float(conf_time) * float(completion_factor), 0.2, 1.0)
|
||||
|
||||
|
||||
def _build_stage_one_hot(mom_stage: Optional[str]) -> UserStageOneHot:
|
||||
# V1.2:mom_stage 跳过按安全策略输出 unknown=1
|
||||
if mom_stage is None:
|
||||
return UserStageOneHot(unknown=1)
|
||||
|
||||
return UserStageOneHot(
|
||||
expecting=1 if mom_stage == "expecting" else 0,
|
||||
parenting=1 if mom_stage == "parenting" else 0,
|
||||
unknown=1 if mom_stage == "unknown" else 0,
|
||||
)
|
||||
|
||||
|
||||
def _map_emotion_score(emotion: Optional[str]) -> Optional[float]:
|
||||
if emotion is None:
|
||||
return None
|
||||
mapping = {
|
||||
"low": 0.0,
|
||||
"overwhelmed": 0.2,
|
||||
"tired": 0.4,
|
||||
"neutral": 0.6,
|
||||
"calm": 0.8,
|
||||
"joyful": 1.0,
|
||||
}
|
||||
return mapping.get(emotion)
|
||||
|
||||
|
||||
def _build_sparse_one_hot(value: Optional[str]) -> dict[str, int]:
|
||||
if value is None:
|
||||
return {}
|
||||
return {value: 1}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RuleOutput:
|
||||
rule_hits: list[str]
|
||||
hard_rules: HardRules
|
||||
|
||||
|
||||
def _compute_rule_output(stage: UserStageOneHot, emotion_score: Optional[float]) -> _RuleOutput:
|
||||
rule_hits: list[str] = []
|
||||
forbidden_risk_flags: list[str] = []
|
||||
|
||||
stage_unknown = stage.unknown == 1
|
||||
stage_parenting = stage.parenting == 1
|
||||
|
||||
if stage_unknown:
|
||||
rule_hits.append("unsafe_for_stage_unknown")
|
||||
forbidden_risk_flags.append("unsafe_for_stage_unknown")
|
||||
|
||||
if stage_parenting:
|
||||
rule_hits.append("unsafe_for_stage_parenting")
|
||||
forbidden_risk_flags.append("unsafe_for_stage_parenting")
|
||||
|
||||
if emotion_score is not None and emotion_score <= 0.2:
|
||||
rule_hits.append("unsafe_for_emotion_low")
|
||||
forbidden_risk_flags.append("unsafe_for_emotion_low")
|
||||
|
||||
forbidden_content_predicates = []
|
||||
if stage_unknown:
|
||||
forbidden_content_predicates.append(
|
||||
{
|
||||
"id": "unknown_block_parenting_pressure_personalized",
|
||||
"when_user": {"stage_unknown": True},
|
||||
"forbid_content": {"need": "parenting_pressure", "personalization_power": 1},
|
||||
}
|
||||
)
|
||||
|
||||
return _RuleOutput(
|
||||
rule_hits=rule_hits,
|
||||
hard_rules=HardRules(
|
||||
forbidden_risk_flags=forbidden_risk_flags,
|
||||
forbidden_content_predicates=forbidden_content_predicates,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_user_profile_from_questionnaire(
|
||||
raw_answers: QuestionnaireAnswersV1_2,
|
||||
*,
|
||||
generated_at: Optional[datetime] = None,
|
||||
now: Optional[datetime] = None,
|
||||
) -> UserProfileV1_2_Extended:
|
||||
"""
|
||||
主入口:问卷答案(可跳过)→ 用户画像(V1.2)+ 硬规则输出
|
||||
"""
|
||||
|
||||
answers = normalize_answers(raw_answers)
|
||||
answered = compute_profile_answered(answers)
|
||||
|
||||
now_dt = now or datetime.now(tz=timezone.utc)
|
||||
gen_dt = generated_at or now_dt
|
||||
|
||||
conf_time = compute_time_confidence(gen_dt, now_dt)
|
||||
conf_u = compute_profile_confidence(conf_time, answered)
|
||||
|
||||
stage = _build_stage_one_hot(answers.mom_stage)
|
||||
emotion_score = _map_emotion_score(answers.emotion)
|
||||
context = _build_sparse_one_hot(answers.context)
|
||||
need = _build_sparse_one_hot(answers.need)
|
||||
|
||||
rule_out = _compute_rule_output(stage, emotion_score)
|
||||
|
||||
return UserProfileV1_2_Extended(
|
||||
profile_generated_at=gen_dt,
|
||||
profile_confidence=conf_u,
|
||||
profile_answered=answered,
|
||||
stage=stage,
|
||||
emotion_score=emotion_score,
|
||||
context=context, # type: ignore[arg-type]
|
||||
need=need, # type: ignore[arg-type]
|
||||
rule_hits=rule_out.rule_hits,
|
||||
hard_rules=rule_out.hard_rules,
|
||||
)
|
||||
|
||||
89
server/app/features/user_profile_scoring/types.py
Normal file
89
server/app/features/user_profile_scoring/types.py
Normal 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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.api.v1.reco import router as reco_router
|
||||
from app.api.v1.user_profile_scoring import router as user_profile_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -14,6 +16,10 @@ def create_app() -> FastAPI:
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
# 业务路由
|
||||
app.include_router(user_profile_router)
|
||||
app.include_router(reco_router)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
return {"status": "ok", "env": settings.app_env}
|
||||
|
||||
168
server/app/tasks/reco.py
Normal file
168
server/app/tasks/reco.py
Normal 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
|
||||
|
||||
@@ -4,6 +4,8 @@ uvicorn[standard]>=0.27
|
||||
# 数据库(SQLAlchemy 2.x 异步 + MySQL)
|
||||
SQLAlchemy>=2.0
|
||||
aiomysql>=0.2
|
||||
greenlet>=3.0
|
||||
aiosqlite>=0.20
|
||||
|
||||
# 配置
|
||||
pydantic>=2.6
|
||||
@@ -18,3 +20,5 @@ redis>=5.0
|
||||
|
||||
# 测试
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
httpx>=0.27
|
||||
|
||||
145
server/run.sh
Executable file
145
server/run.sh
Executable 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[@]}"
|
||||
|
||||
223
server/tests/conftest.py
Normal file
223
server/tests/conftest.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
# 确保在任何 pytest rootdir 下都能 `import app.*`
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] # .../server
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
|
||||
def _read_env_kv(env_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
读取 .env 文件中的 KEY=VALUE(最小实现,避免引入额外依赖)。
|
||||
"""
|
||||
|
||||
data: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return data
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
data[k] = v
|
||||
return data
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""
|
||||
获取测试用数据库连接串。
|
||||
|
||||
约定(与 alembic/env.py 保持一致):
|
||||
- 优先读取环境变量 `DATABASE_URL`
|
||||
- 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod`
|
||||
"""
|
||||
|
||||
env_url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
server_dir = Path(__file__).resolve().parents[1] # .../server
|
||||
app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev"
|
||||
env_file = server_dir / (".env.prod" if app_env == "prod" else ".env.dev")
|
||||
kv = _read_env_kv(env_file)
|
||||
url = (kv.get("DATABASE_URL") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
|
||||
raise RuntimeError(
|
||||
"缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。"
|
||||
)
|
||||
|
||||
|
||||
def _assert_safe_mysql_test_db(url: str) -> None:
|
||||
"""
|
||||
为了避免对开发库造成破坏性影响,集成测试只允许连接到“测试库”。
|
||||
|
||||
规则(V1):
|
||||
- 必须是 mysql+aiomysql://...
|
||||
- 为避免误连生产库:不允许数据库名为 'mindfulness'(prod 默认库名)
|
||||
- 建议使用独立测试库(例如 mindfulness_dev_test)
|
||||
"""
|
||||
|
||||
if not url.startswith("mysql+"):
|
||||
raise RuntimeError(f"当前仅允许 MySQL 集成测试(mysql+aiomysql)。实际:{url!r}")
|
||||
|
||||
parsed = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1))
|
||||
db_name = (parsed.path or "").lstrip("/")
|
||||
if db_name.lower() == "mindfulness":
|
||||
raise RuntimeError(
|
||||
"为避免误连生产库,集成测试不允许连接到数据库 'mindfulness'。"
|
||||
"请改用 dev 测试库(例如 mindfulness_dev_test 或 mindfulness_dev)。"
|
||||
)
|
||||
|
||||
|
||||
def _run_alembic_upgrade_head() -> None:
|
||||
"""
|
||||
使用 Alembic 将测试库升级到最新 schema。
|
||||
|
||||
说明:
|
||||
- 依赖 env.py 内部读取 DATABASE_URL
|
||||
- 仅在 session 级别执行一次,避免每个测试都跑迁移
|
||||
"""
|
||||
|
||||
server_dir = Path(__file__).resolve().parents[1] # .../server
|
||||
alembic_ini = server_dir / "alembic.ini"
|
||||
cfg = Config(str(alembic_ini))
|
||||
# 确保脚本路径正确(alembic.ini 里一般已配置,这里兜底)
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
def _assert_schema_exists(url: str) -> None:
|
||||
"""
|
||||
非破坏性检查:要求目标库已经存在所需表。
|
||||
|
||||
说明:
|
||||
- 默认不在测试中运行 Alembic(避免任何 schema 变更)
|
||||
- 若要自动迁移,请设置环境变量 ALLOW_SCHEMA_MIGRATION=1
|
||||
"""
|
||||
|
||||
allow_migration = (os.getenv("ALLOW_SCHEMA_MIGRATION") or "").strip() == "1"
|
||||
if allow_migration:
|
||||
_run_alembic_upgrade_head()
|
||||
return
|
||||
|
||||
# 使用 PyMySQL 做同步检查,避免依赖 MySQLdb(不要求系统安装 mysqlclient)
|
||||
sync_url = url.replace("mysql+aiomysql://", "mysql+pymysql://", 1)
|
||||
engine = sa.create_engine(sync_url, future=True)
|
||||
try:
|
||||
insp = sa.inspect(engine)
|
||||
tables = set(insp.get_table_names())
|
||||
required = {"contents", "content_profiles", "content_risk_flags"}
|
||||
missing = sorted(required - tables)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"集成测试检测到 schema 不完整(缺少表:"
|
||||
+ ", ".join(missing)
|
||||
+ ")。为避免破坏性操作,测试不会自动迁移。"
|
||||
"请先手动在该库执行 `alembic upgrade head`,或设置 ALLOW_SCHEMA_MIGRATION=1 允许测试自动迁移。"
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _migrate_db_once() -> None:
|
||||
"""
|
||||
Session 级 schema 检查(仅在配置了数据库连接时启用)。
|
||||
|
||||
说明:
|
||||
- 纯函数单元测试不需要 MySQL;若未配置 DATABASE_URL,则跳过检查
|
||||
- 集成测试(依赖 db_session/async_engine)仍会在获取 DATABASE_URL 时失败,从而提示用户配置
|
||||
"""
|
||||
|
||||
try:
|
||||
url = _get_database_url()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
_assert_safe_mysql_test_db(url)
|
||||
_assert_schema_exists(url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_engine() -> AsyncIterator[AsyncEngine]:
|
||||
url = _get_database_url()
|
||||
_assert_safe_mysql_test_db(url)
|
||||
engine = create_async_engine(url, pool_pre_ping=True)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
||||
"""
|
||||
提供一个干净的 AsyncSession。
|
||||
|
||||
清理策略:每个测试都在事务中执行,并在结束时回滚(不做 DELETE/TRUNCATE)。
|
||||
|
||||
说明:
|
||||
- 用例里严禁调用 session.commit(),只允许 flush()
|
||||
- 这样不会对测试库产生持久化写入,更不会影响开发库
|
||||
"""
|
||||
|
||||
SessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
trans = await session.begin()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await trans.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def query_counter(async_engine: AsyncEngine) -> Callable[[], int]:
|
||||
"""
|
||||
返回一个函数:调用可获得当前累计查询次数。
|
||||
"""
|
||||
|
||||
count = {"n": 0}
|
||||
|
||||
def before_cursor_execute(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
count["n"] += 1
|
||||
|
||||
event.listen(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
|
||||
|
||||
def get_count() -> int:
|
||||
return int(count["n"])
|
||||
|
||||
def fin() -> None:
|
||||
event.remove(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
|
||||
|
||||
# 用 yield 确保测试后移除监听,避免重复绑定导致统计偏大
|
||||
try:
|
||||
yield get_count # type: ignore[misc]
|
||||
finally:
|
||||
fin()
|
||||
|
||||
178
server/tests/test_content_repository.py
Normal file
178
server/tests/test_content_repository.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models.content import Content
|
||||
from app.db.models.content_profile import ContentProfile
|
||||
from app.db.models.content_risk_flag import ContentRiskFlag
|
||||
from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository
|
||||
|
||||
|
||||
def _ctx_json() -> dict:
|
||||
return {"family": 0.5, "work": 0.5, "relationship": 0.5, "friends": 0.5, "health": 0.5}
|
||||
|
||||
|
||||
def _need_json() -> dict:
|
||||
return {
|
||||
"emotional_support": 0.5,
|
||||
"parenting_pressure": 0.5,
|
||||
"self_worth": 0.5,
|
||||
"anxiety_relief": 0.5,
|
||||
"rest_balance": 0.5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_contents_by_ids_locale_no_fallback(db_session, query_counter):
|
||||
# content 1: 只有英文
|
||||
c1 = Content(text_en="hello", text_tc=None, author_id="a1", template_id="t1")
|
||||
db_session.add(c1)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
ContentProfile(
|
||||
content_id=c1.content_id,
|
||||
stage="general",
|
||||
emotion_score=None,
|
||||
context_suitability_json=_ctx_json(),
|
||||
need_suitability_json=_need_json(),
|
||||
personalization_power=10,
|
||||
review_confidence=None,
|
||||
is_safe_pool=False,
|
||||
)
|
||||
)
|
||||
db_session.add(ContentRiskFlag(content_id=c1.content_id, flag="block_stage_unknown"))
|
||||
|
||||
# content 2: 只有繁中
|
||||
c2 = Content(text_en=None, text_tc="繁體中文", author_id="a2", template_id="t2")
|
||||
db_session.add(c2)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
ContentProfile(
|
||||
content_id=c2.content_id,
|
||||
stage="general",
|
||||
emotion_score=None,
|
||||
context_suitability_json=_ctx_json(),
|
||||
need_suitability_json=_need_json(),
|
||||
personalization_power=0,
|
||||
review_confidence=0.9,
|
||||
is_safe_pool=True,
|
||||
)
|
||||
)
|
||||
db_session.add(ContentRiskFlag(content_id=c2.content_id, flag="block_health_sensitive"))
|
||||
|
||||
await db_session.flush()
|
||||
|
||||
repo = SqlAlchemyContentRepository(db_session)
|
||||
start = query_counter()
|
||||
|
||||
# en:只能拿到有 text_en 的内容(不允许回退到 text_tc)
|
||||
en_items = await repo.fetch_contents_by_ids(content_ids=[c2.content_id, c1.content_id], locale="en")
|
||||
assert [x.content_id for x in en_items] == [c1.content_id]
|
||||
assert en_items[0].text == "hello"
|
||||
assert en_items[0].personalization_power == 1.0
|
||||
assert en_items[0].review_confidence == 0.7 # NULL -> 0.7
|
||||
assert "unsafe_for_stage_unknown" in en_items[0].risk_flags
|
||||
assert "block_stage_unknown" not in en_items[0].risk_flags
|
||||
|
||||
# tc:只能拿到有 text_tc 的内容(不允许回退到 text_en)
|
||||
tc_items = await repo.fetch_contents_by_ids(content_ids=[c1.content_id, c2.content_id], locale="tc")
|
||||
assert [x.content_id for x in tc_items] == [c2.content_id]
|
||||
assert tc_items[0].text == "繁體中文"
|
||||
assert tc_items[0].review_confidence == 0.9
|
||||
assert "block_health_medical" in tc_items[0].risk_flags
|
||||
assert "block_health_sensitive" not in tc_items[0].risk_flags
|
||||
|
||||
# 两次调用各自 2 次查询(主体+画像一次,flags 一次),总计应为常数级
|
||||
end = query_counter()
|
||||
assert (end - start) <= 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_candidates_fallback_and_locale_filter(db_session, query_counter):
|
||||
# 构造 4 条英文内容:power 0/5/10,安全池标记不同
|
||||
contents = []
|
||||
for i, (power, safe) in enumerate([(0, True), (5, False), (10, False), (0, False)], start=1):
|
||||
c = Content(text_en=f"en_{i}", text_tc=None, author_id=f"a{i}", template_id=f"t{i}")
|
||||
db_session.add(c)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
ContentProfile(
|
||||
content_id=c.content_id,
|
||||
stage="general",
|
||||
emotion_score=None,
|
||||
context_suitability_json=_ctx_json(),
|
||||
need_suitability_json=_need_json(),
|
||||
personalization_power=power,
|
||||
review_confidence=None,
|
||||
is_safe_pool=safe,
|
||||
# 让测试数据在候选排序中排到最前,避免依赖“库为空”
|
||||
updated_at=datetime(2099, 1, 1, 0, 0, 0),
|
||||
)
|
||||
)
|
||||
contents.append(c)
|
||||
await db_session.flush()
|
||||
inserted_ids = {int(c.content_id) for c in contents}
|
||||
|
||||
repo = SqlAlchemyContentRepository(db_session)
|
||||
|
||||
class _MinimalUser:
|
||||
# need/context/emotion_score 全缺失 -> effective_fallback 至少 L1
|
||||
need = {}
|
||||
context = {}
|
||||
emotion_score = None
|
||||
stage = {"unknown": 1}
|
||||
|
||||
start = query_counter()
|
||||
|
||||
# 入参 L0,但因为缺失字段,effective_fallback=1 -> power<=5(排除 power=10)
|
||||
items_l0 = await repo.fetch_candidates(
|
||||
scene="feed",
|
||||
user_profile=_MinimalUser(),
|
||||
fallback_level=0,
|
||||
limit=3,
|
||||
locale="en-US",
|
||||
)
|
||||
powers = [x.personalization_power for x in items_l0]
|
||||
assert 1.0 not in powers
|
||||
assert all(x.text.startswith("en_") for x in items_l0)
|
||||
|
||||
# L2:强制 power=0 且 stage=general(这里都 general),只剩 power=0 的两条
|
||||
items_l2 = await repo.fetch_candidates(
|
||||
scene="feed",
|
||||
user_profile=_MinimalUser(),
|
||||
fallback_level=2,
|
||||
limit=2,
|
||||
locale="en",
|
||||
)
|
||||
assert all(x.personalization_power == 0.0 for x in items_l2)
|
||||
assert all(x.text.startswith("en_") for x in items_l2)
|
||||
|
||||
# L3:只安全池(is_safe_pool=true)且 power=0
|
||||
items_l3 = await repo.fetch_candidates(
|
||||
scene="feed",
|
||||
user_profile=_MinimalUser(),
|
||||
fallback_level=3,
|
||||
limit=1,
|
||||
locale="en",
|
||||
)
|
||||
assert len(items_l3) == 1
|
||||
assert items_l3[0].text.startswith("en_")
|
||||
assert items_l3[0].personalization_power == 0.0
|
||||
|
||||
# locale 过滤:tc 请求下这些内容都没有 text_tc -> 返回空
|
||||
items_tc = await repo.fetch_candidates(
|
||||
scene="feed",
|
||||
user_profile=_MinimalUser(),
|
||||
fallback_level=0,
|
||||
limit=10,
|
||||
locale="tc",
|
||||
)
|
||||
# 不要求库为空:只断言“不会把本次插入的 en-only 测试数据返回出来”
|
||||
assert not any(x.content_id in inserted_ids for x in items_tc)
|
||||
|
||||
end = query_counter()
|
||||
# 期望为常数级(每次 fetch_candidates:1 次取 ids + 2 次补全),这里 4 次调用 -> <= 12
|
||||
assert (end - start) <= 12
|
||||
|
||||
165
server/tests/test_integration_api_worker.py
Normal file
165
server/tests/test_integration_api_worker.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis)
|
||||
monkeypatch.setenv(
|
||||
"DATABASE_URL",
|
||||
"mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4",
|
||||
)
|
||||
monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
|
||||
|
||||
|
||||
def _user_profile_dict() -> dict[str, Any]:
|
||||
return {
|
||||
"profile_version": "v1.2",
|
||||
"profile_source": "questionnaire",
|
||||
"profile_generated_at": "2026-02-02T12:00:00Z",
|
||||
"profile_confidence": 1.0,
|
||||
"profile_answered": {"stage": True, "emotion": False, "context": False, "need": False},
|
||||
"stage": {"unknown": 1},
|
||||
"emotion_score": None,
|
||||
"context": {},
|
||||
"need": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch):
|
||||
_set_min_env(monkeypatch)
|
||||
|
||||
# 清理 settings cache,避免被其他测试污染
|
||||
from app.core import config as config_mod
|
||||
|
||||
config_mod.get_settings.cache_clear()
|
||||
|
||||
# 重新加载 main,确保使用最新 env
|
||||
import app.main as main_mod
|
||||
|
||||
importlib.reload(main_mod)
|
||||
|
||||
app = main_mod.create_app()
|
||||
|
||||
# override repo(避免依赖真实 DB)
|
||||
from app.api.v1 import reco as reco_mod
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
|
||||
class _FakeRepo:
|
||||
async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def]
|
||||
# 返回一条可下发内容
|
||||
return [
|
||||
ContentProfileDTO(
|
||||
content_id=1,
|
||||
text="t1",
|
||||
stage="general",
|
||||
emotion_score=None,
|
||||
context_suitability={},
|
||||
need_suitability={},
|
||||
personalization_power=0.0,
|
||||
risk_flags=[],
|
||||
author_id=None,
|
||||
template_id=None,
|
||||
review_confidence=0.7,
|
||||
)
|
||||
]
|
||||
|
||||
async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def]
|
||||
return []
|
||||
|
||||
async def _override_repo(): # type: ignore[no-untyped-def]
|
||||
return _FakeRepo()
|
||||
|
||||
app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo
|
||||
|
||||
# 清空限流计数,避免跨测试污染
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined]
|
||||
limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined]
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# 固定时间,避免跨分钟 flake
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳
|
||||
|
||||
resp = client.post(
|
||||
"/v1/reco/feed",
|
||||
json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []},
|
||||
headers={"Accept-Language": "zh-TW,zh;q=0.9"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["meta"]["config_snapshot"]["locale"] == "tc"
|
||||
|
||||
|
||||
def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/reco/push",
|
||||
json={
|
||||
"user_profile": _user_profile_dict(),
|
||||
"now": "2026-02-01T00:00:00Z",
|
||||
"already_recommended_ids": [],
|
||||
"touched_or_viewed_ids": [],
|
||||
},
|
||||
headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# meta.config_snapshot 里没有 now,但 served_k 应该正常
|
||||
assert data["meta"]["served_k"] == 1
|
||||
|
||||
|
||||
def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
|
||||
|
||||
body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}
|
||||
for _ in range(10):
|
||||
r = client.post("/v1/reco/widget", json=body)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/v1/reco/widget", json=body)
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == "rate_limited"
|
||||
|
||||
|
||||
def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_min_env(monkeypatch)
|
||||
from app.core import config as config_mod
|
||||
|
||||
config_mod.get_settings.cache_clear()
|
||||
|
||||
import app.tasks.reco as reco_tasks
|
||||
|
||||
# monkeypatch async runner,避免依赖 DB
|
||||
async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def]
|
||||
from app.features.personalized_reco.observability.types import RecoMeta
|
||||
from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem
|
||||
|
||||
return RecoEngineResult(
|
||||
items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})],
|
||||
meta=RecoMeta(scene="push", served_k=1),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async)
|
||||
|
||||
out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1)
|
||||
assert out["meta"]["served_k"] == 1
|
||||
|
||||
129
server/tests/test_observability.py
Normal file
129
server/tests/test_observability.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.observability.builder import RecoMetaBuilder
|
||||
from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
def _u(*, need: dict | None = None, context: dict | None = None, emotion_score=None, conf_u: float = 0.9) -> UserProfileV1_2:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=now,
|
||||
profile_confidence=conf_u,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True),
|
||||
stage=UserStageOneHot(unknown=1),
|
||||
emotion_score=emotion_score,
|
||||
context=context or {},
|
||||
need=need or {},
|
||||
)
|
||||
|
||||
|
||||
def test_compute_missing_fields() -> None:
|
||||
u1 = _u(need={}, context={}, emotion_score=None)
|
||||
m1 = compute_missing_fields(u1)
|
||||
assert m1.need is True
|
||||
assert m1.context is True
|
||||
assert m1.emotion is True
|
||||
|
||||
u2 = _u(need={"x": 1}, context={"y": 1}, emotion_score=0.6)
|
||||
m2 = compute_missing_fields(u2)
|
||||
assert m2.need is False
|
||||
assert m2.context is False
|
||||
assert m2.emotion is False
|
||||
|
||||
|
||||
def test_compute_empty_reason_branches() -> None:
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=1,
|
||||
candidate_pool_size_raw=0,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=0,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "pool_empty"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "hard_filter_all"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=5,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "freqcap_all"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=5,
|
||||
candidate_pool_size_after_freqcap=3,
|
||||
)
|
||||
== "unknown"
|
||||
)
|
||||
|
||||
|
||||
def test_builder_outputs_stable_fields_and_monotonic_counts() -> None:
|
||||
u = _u(need={"emotional_support": 1}, context={}, emotion_score=None, conf_u=0.2)
|
||||
|
||||
# 故意设置“非单调”的输入,验证 builder 的防御修正
|
||||
meta = (
|
||||
RecoMetaBuilder(scene="feed", user_profile=u, k=30)
|
||||
.set_candidate_pool_size_raw(10)
|
||||
.set_after_hard_filter(12) # 非法:大于 raw
|
||||
.set_after_dedup(20) # 非法:大于 after_hard
|
||||
.set_after_freqcap(15) # 非法:大于 after_dedup(修正后会与 after_dedup 对齐)
|
||||
.set_served_k(99) # 非法:大于 after_freqcap
|
||||
.set_fallback_level_final(1, reason="freqcap_all")
|
||||
.build()
|
||||
)
|
||||
|
||||
d = meta.model_dump()
|
||||
for k in [
|
||||
"scene",
|
||||
"candidate_pool_size_raw",
|
||||
"candidate_pool_size_after_hard_filter",
|
||||
"candidate_pool_size_after_dedup",
|
||||
"candidate_pool_size_after_freqcap",
|
||||
"fallback_level_final",
|
||||
"served_k",
|
||||
"empty_reason",
|
||||
"conf_U",
|
||||
"missing_fields",
|
||||
]:
|
||||
assert k in d
|
||||
|
||||
assert meta.candidate_pool_size_raw == 10
|
||||
assert meta.candidate_pool_size_after_hard_filter == 10
|
||||
assert meta.candidate_pool_size_after_dedup == 10
|
||||
assert meta.candidate_pool_size_after_freqcap == 10
|
||||
assert meta.served_k == 10
|
||||
assert meta.conf_U == pytest.approx(0.2)
|
||||
assert meta.missing_fields.context is True
|
||||
assert meta.missing_fields.emotion is True
|
||||
|
||||
173
server/tests/test_reco_engine.py
Normal file
173
server/tests/test_reco_engine.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.reco_engine.orchestrator import recommend
|
||||
from app.features.personalized_reco.reco_engine.types import RecoConstraints
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
class _FakeRepo(ContentRepository):
|
||||
def __init__(self, candidates_by_level: dict[int, list[ContentProfileDTO]]):
|
||||
self._candidates_by_level = candidates_by_level
|
||||
|
||||
async def fetch_candidates( # type: ignore[override]
|
||||
self,
|
||||
*,
|
||||
scene: str,
|
||||
user_profile: object,
|
||||
fallback_level: int,
|
||||
limit: int,
|
||||
locale: str,
|
||||
exclude_content_ids: list[int] | None = None,
|
||||
) -> list[ContentProfileDTO]:
|
||||
# 单测:简化实现,只按 level 返回,忽略 limit/locale/exclude
|
||||
return list(self._candidates_by_level.get(int(fallback_level), []))[: int(limit)]
|
||||
|
||||
async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: # type: ignore[override]
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime(2026, 2, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _user_profile(*, stage: str = "unknown", emotion_score: float | None = 0.5) -> UserProfileV1_2:
|
||||
if stage == "expecting":
|
||||
st = UserStageOneHot(expecting=1, parenting=0, unknown=0) # type: ignore[arg-type]
|
||||
elif stage == "parenting":
|
||||
st = UserStageOneHot(expecting=0, parenting=1, unknown=0) # type: ignore[arg-type]
|
||||
else:
|
||||
st = UserStageOneHot(expecting=0, parenting=0, unknown=1) # type: ignore[arg-type]
|
||||
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=_now(),
|
||||
profile_confidence=1.0,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=emotion_score is not None, context=False, need=False),
|
||||
stage=st,
|
||||
emotion_score=emotion_score,
|
||||
context={},
|
||||
need={},
|
||||
)
|
||||
|
||||
|
||||
def _content(
|
||||
*,
|
||||
content_id: int,
|
||||
text: str = "hello",
|
||||
stage: str = "general",
|
||||
personalization_power: float = 0.0,
|
||||
risk_flags: list[str] | None = None,
|
||||
author_id: str | None = None,
|
||||
template_id: str | None = None,
|
||||
need_suitability: dict[str, float] | None = None,
|
||||
) -> ContentProfileDTO:
|
||||
return ContentProfileDTO(
|
||||
content_id=int(content_id),
|
||||
text=text,
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=None,
|
||||
context_suitability={},
|
||||
need_suitability=need_suitability or {},
|
||||
personalization_power=float(personalization_power),
|
||||
risk_flags=risk_flags or [],
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
review_confidence=0.7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_k_zero_returns_empty() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1)]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=0,
|
||||
now=_now(),
|
||||
locale=None,
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.served_k == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_pool_empty_sets_empty_reason() -> None:
|
||||
repo = _FakeRepo({0: []})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "pool_empty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_hard_filter_all_sets_empty_reason() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1, risk_flags=["block_health_medical"])]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="push",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "hard_filter_all"
|
||||
assert res.meta.risk_filtered_count_by_flag.get("block_health_medical", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_freqcap_all_sets_empty_reason() -> None:
|
||||
# Push 场景:作者冷却命中,导致 after_freqcap=0
|
||||
repo = _FakeRepo({0: [_content(content_id=1, author_id="a1")]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="push",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
constraints=RecoConstraints(recent_author_ids=["a1"]),
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "freqcap_all"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_returns_item_and_explanations_enabled() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1, text="t1", personalization_power=0.0)]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert len(res.items) == 1
|
||||
assert res.items[0].content_id == 1
|
||||
assert res.items[0].text == "t1"
|
||||
assert res.items[0].explanations is not None
|
||||
|
||||
|
||||
130
server/tests/test_rerank_freqcap.py
Normal file
130
server/tests/test_rerank_freqcap.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap
|
||||
from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate
|
||||
|
||||
|
||||
def _c(
|
||||
*,
|
||||
content_id: int,
|
||||
score: float,
|
||||
author_id: str | None = None,
|
||||
template_id: str | None = None,
|
||||
stage: str = "general",
|
||||
need_key: str = "emotional_support",
|
||||
context_key: str = "family",
|
||||
) -> ScoredCandidate:
|
||||
cp = ContentProfileDTO(
|
||||
content_id=content_id,
|
||||
text="t",
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=None,
|
||||
context_suitability={context_key: 1.0},
|
||||
need_suitability={need_key: 1.0},
|
||||
personalization_power=0.0,
|
||||
risk_flags=[],
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
return ScoredCandidate(
|
||||
content_id=content_id,
|
||||
final_score=score,
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
content_profile=cp,
|
||||
)
|
||||
|
||||
|
||||
def test_dedup_normalizes_str_int_ids() -> None:
|
||||
cands = [_c(content_id=1, score=0.9), _c(content_id=2, score=0.8), _c(content_id=3, score=0.7)]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=["1", "bad"],
|
||||
touched_or_viewed_ids=[2],
|
||||
k=10,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [3]
|
||||
assert r.meta.candidate_pool_size_after_dedup == 1
|
||||
assert r.meta.freqcap_filtered_counts["sentence"] == 2
|
||||
|
||||
|
||||
def test_freqcap_missing_recent_author_template_is_recorded() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.9, author_id="a1", template_id="t1"),
|
||||
_c(content_id=2, score=0.8, author_id="a2", template_id="t2"),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=10,
|
||||
recent_author_ids=None,
|
||||
recent_template_ids=None,
|
||||
)
|
||||
assert r.meta.missing_history_fields == ["author", "template"]
|
||||
assert "author" not in r.meta.freqcap_filtered_counts # 未提供则不执行该维度
|
||||
assert "template" not in r.meta.freqcap_filtered_counts
|
||||
|
||||
|
||||
def test_freqcap_filters_by_recent_author_when_provided() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.9, author_id="a1", template_id="t1"),
|
||||
_c(content_id=2, score=0.8, author_id="a2", template_id="t2"),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="widget",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=10,
|
||||
recent_author_ids=["a1"],
|
||||
recent_template_ids=None,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [2]
|
||||
assert r.meta.missing_history_fields == ["template"]
|
||||
assert r.meta.freqcap_filtered_counts["author"] == 1
|
||||
|
||||
|
||||
def test_feed_mmr_picks_diverse_second_item() -> None:
|
||||
# 构造:Top1 是 a1;c2 分数略高但同作者;c3 分数略低但不同作者/阶段
|
||||
c1 = _c(content_id=1, score=1.0, author_id="a1", template_id="t1", stage="general")
|
||||
c2 = _c(content_id=2, score=0.99, author_id="a1", template_id="t2", stage="general")
|
||||
c3 = _c(content_id=3, score=0.95, author_id="a2", template_id="t3", stage="expecting")
|
||||
|
||||
r = rerank_and_freqcap(
|
||||
scene="feed",
|
||||
scored_candidates=[c1, c2, c3],
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=2,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids[0] == 1
|
||||
assert got_ids[1] == 3
|
||||
|
||||
|
||||
def test_push_topk_sorted_by_score_after_filters() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.1),
|
||||
_c(content_id=2, score=0.9),
|
||||
_c(content_id=3, score=0.8),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=2,
|
||||
recent_author_ids=[],
|
||||
recent_template_ids=[],
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [2, 3]
|
||||
|
||||
133
server/tests/test_scoring.py
Normal file
133
server/tests/test_scoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.scoring.defaults import get_default_config
|
||||
from app.features.personalized_reco.scoring.score import score_content
|
||||
from app.features.personalized_reco.scoring.types import ExternalTerms, ScoreConfig
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
def _u(
|
||||
*,
|
||||
stage: str = "unknown",
|
||||
emotion_score: float | None = None,
|
||||
need: dict[str, int] | None = None,
|
||||
context: dict[str, int] | None = None,
|
||||
profile_confidence: float = 1.0,
|
||||
) -> UserProfileV1_2:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if stage == "expecting":
|
||||
s = UserStageOneHot(expecting=1, parenting=0, unknown=0)
|
||||
elif stage == "parenting":
|
||||
s = UserStageOneHot(expecting=0, parenting=1, unknown=0)
|
||||
else:
|
||||
s = UserStageOneHot(unknown=1)
|
||||
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=now,
|
||||
profile_confidence=profile_confidence,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True),
|
||||
stage=s,
|
||||
emotion_score=emotion_score,
|
||||
context=context or {},
|
||||
need=need or {},
|
||||
)
|
||||
|
||||
|
||||
def _c(
|
||||
*,
|
||||
stage: str = "general",
|
||||
emotion_score: float | None = None,
|
||||
need_key: str = "emotional_support",
|
||||
context_key: str = "family",
|
||||
need_value: float = 1.0,
|
||||
context_value: float = 1.0,
|
||||
personalization_power: float = 1.0,
|
||||
review_confidence: float = 0.7,
|
||||
) -> ContentProfileDTO:
|
||||
return ContentProfileDTO(
|
||||
content_id=1,
|
||||
text="hello",
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=emotion_score,
|
||||
context_suitability={context_key: context_value},
|
||||
need_suitability={need_key: need_value},
|
||||
personalization_power=personalization_power,
|
||||
risk_flags=[],
|
||||
review_confidence=review_confidence,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_fields_defaults_are_applied() -> None:
|
||||
u = _u(emotion_score=None, need={}, context={})
|
||||
c = _c(stage="general", emotion_score=None)
|
||||
r = score_content(scene="feed", user_profile=u, content_profile=c)
|
||||
|
||||
assert r.breakdown.S_need == pytest.approx(0.5)
|
||||
assert r.breakdown.S_context == pytest.approx(0.5)
|
||||
assert r.breakdown.S_emotion == pytest.approx(0.8)
|
||||
assert set(r.breakdown.missing_fields) == {"need", "context", "emotion"}
|
||||
|
||||
|
||||
def test_uncertainty_penalty_enabled_for_push_by_default_and_can_be_disabled() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}, profile_confidence=0.2)
|
||||
c = _c(stage="general", emotion_score=0.6, personalization_power=1.0, review_confidence=0.2)
|
||||
|
||||
r_on = score_content(scene="push", user_profile=u, content_profile=c)
|
||||
assert r_on.breakdown.P_uncertainty > 0
|
||||
|
||||
cfg_off = ScoreConfig.model_validate(get_default_config("push").model_dump() | {"enable_uncertainty_penalty": False})
|
||||
r_off = score_content(scene="push", user_profile=u, content_profile=c, config=cfg_off)
|
||||
assert r_off.breakdown.P_uncertainty == pytest.approx(0.0)
|
||||
assert r_off.final_score > r_on.final_score
|
||||
|
||||
|
||||
def test_widget_emotion_soft_penalty_is_applied_outside_range() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1})
|
||||
|
||||
cfg = get_default_config("widget")
|
||||
assert cfg.widget_emotion_soft_range == (0.4, 0.8)
|
||||
assert cfg.widget_emotion_penalty_gamma == pytest.approx(0.25)
|
||||
|
||||
c_in = _c(stage="general", emotion_score=0.6, personalization_power=0.0)
|
||||
r_in = score_content(scene="widget", user_profile=u, content_profile=c_in, config=cfg)
|
||||
assert r_in.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.0)
|
||||
|
||||
c_out = _c(stage="general", emotion_score=0.0, personalization_power=0.0)
|
||||
r_out = score_content(scene="widget", user_profile=u, content_profile=c_out, config=cfg)
|
||||
assert r_out.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.25)
|
||||
assert r_out.final_score < r_in.final_score
|
||||
|
||||
|
||||
def test_pass_false_forces_final_score_zero_but_breakdown_is_present() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1})
|
||||
c = _c(stage="general", emotion_score=0.6, personalization_power=1.0)
|
||||
r = score_content(scene="feed", user_profile=u, content_profile=c, pass_filters=False, external_terms=ExternalTerms())
|
||||
|
||||
assert r.final_score == pytest.approx(0.0)
|
||||
assert r.breakdown.passed is False
|
||||
# breakdown 字段集合稳定(至少包含关键分解项)
|
||||
d = r.breakdown.model_dump(by_alias=True)
|
||||
for k in [
|
||||
"scene",
|
||||
"pass",
|
||||
"S_need",
|
||||
"S_context",
|
||||
"S_stage",
|
||||
"S_emotion",
|
||||
"S_core",
|
||||
"S_personal",
|
||||
"S_fresh",
|
||||
"P_fatigue",
|
||||
"P_repeat",
|
||||
"P_risk",
|
||||
"P_uncertainty",
|
||||
"P_widget_emotion_out_of_range",
|
||||
"missing_fields",
|
||||
]:
|
||||
assert k in d
|
||||
|
||||
151
spec_kit/Client User Identity/spec.md
Normal file
151
spec_kit/Client User Identity/spec.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Client User Identity|客户端用户标识建立(用于 PUSH Token 绑定)|Spec
|
||||
|
||||
> 阶段:高层规范(spec)
|
||||
>
|
||||
> 目标:在**无账号体系或账号可选**的前提下,为客户端生成一个稳定的“客户端用户标识”(下称 `client_user_id`),用于与 APNs/FCM 的 Push Token 建立绑定关系,便于后端精准下发 PUSH,并支持 Token 变更/多设备/环境隔离等场景。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机(摘要)
|
||||
|
||||
Push Token(APNs device token / FCM registration token)会发生变化(重装、系统升级、重新授权、token rotate 等),且同一用户可能多设备。为了能稳定地“找到这个客户端实例/用户侧主体”并维护 Token 映射,需要一个**与 Token 解耦**、可持久化、不可推断的标识。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- **建立 `client_user_id`**:客户端可生成并持久化一个稳定标识,作为后端 Push Token 绑定的主键之一。
|
||||
- **Token 绑定可更新**:支持 Token 变更时“同一 `client_user_id` 重新上报即可更新”。
|
||||
- **多设备兼容**:同一个账号(若未来引入)可关联多个 `client_user_id`;一个 `client_user_id` 可存在多个 Token(例如同设备多渠道/多应用包形态)时需可扩展。
|
||||
- **环境隔离**:dev/prod、iOS/Android、bundle id / package name 维度隔离,避免串绑。
|
||||
- **隐私友好**:不使用可追踪的硬件标识(IMEI/IDFA/Android ID 等),不引入额外合规风险。
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标(Non-goals)
|
||||
|
||||
- 不在本阶段引入完整账号体系、登录态、用户合并策略(如“同一人多设备合并为一个 user_id”)。
|
||||
- 不在本阶段强制接入设备证明(App Attest/Play Integrity);仅在安全章节提出可选增强方向。
|
||||
- 不定义具体数据库表结构与迁移脚本(属于 plan 阶段细化)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 术语与对象(Definitions)
|
||||
|
||||
- **`client_user_id`**:客户端生成并持久化的随机标识,代表“一个客户端安装实例(或一段时间内的用户侧主体)”,用于 Push 绑定。
|
||||
- **`push_token`**:系统/厂商下发的推送 token(iOS/APNs,Android/FCM),可能变化。
|
||||
- **`account_id`(可选)**:若未来存在登录账号,则用于把多个 `client_user_id` 归属到同一账号。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键决策:使用 UUID 做 `client_user_id` 是否合适?
|
||||
|
||||
结论:**合适**,推荐用**随机 UUID(UUID v4 为默认)**,并把它当作后端与客户端都不解析的**不透明字符串**。
|
||||
|
||||
### 5.1 为什么 UUID 合适
|
||||
|
||||
- **唯一性足够**:v4 基于随机数,碰撞概率极低,满足全局唯一需求。
|
||||
- **不可推断**:相较自增 ID,不易被枚举;相较设备硬件标识,更隐私友好。
|
||||
- **跨端易实现**:iOS/Android/JS 都可稳定生成与序列化(字符串)。
|
||||
|
||||
### 5.2 需要明确的边界与注意事项
|
||||
|
||||
- **UUID 不等于“真实用户”**:它更像“安装实例 ID”。用户重装/清数据后可能变化;这对 Push 绑定通常可接受(新装产生新 token 与新 id)。
|
||||
- **不要用设备硬件/系统可追踪 ID 替代**:避免隐私与合规风险,也避免系统限制导致的不稳定。
|
||||
- **安全边界**:如果后端完全信任客户端上报的 `client_user_id`,存在“伪造绑定”风险;应结合登录态或签名/证明(见第 9 节)降低滥用。
|
||||
|
||||
### 5.3 UUID 版本建议
|
||||
|
||||
- **默认**:UUID v4(实现最简单、兼容最好)。
|
||||
- **可选增强**:UUID v7(有时间有序性,利于日志/索引与写入局部性),但需要确保两端实现一致与依赖可控。
|
||||
|
||||
---
|
||||
|
||||
## 6. 客户端行为规范(Client Contract)
|
||||
|
||||
### 6.1 生成与持久化
|
||||
|
||||
- 首次启动(或首次需要注册 Push 时):
|
||||
- 若本地不存在 `client_user_id`:生成一个新的 UUID(字符串),写入持久化存储。
|
||||
- 若已存在:直接复用。
|
||||
- 存储建议(不做强约束,但必须“尽量稳定”):
|
||||
- iOS:Keychain
|
||||
- Android:Keystore 保护的加密存储/SharedPreferences(或等价方案)
|
||||
- React Native/Expo:使用安全存储能力(例如 SecureStore/Keychain wrapper)
|
||||
|
||||
### 6.2 Push Token 获取与上报时机
|
||||
|
||||
- 在以下任一时机触发“注册/更新”:
|
||||
- 用户同意 Push 权限后获得 token
|
||||
- App 冷启动获取到 token(含 token 变更)
|
||||
- 账号登录/登出(若存在账号)
|
||||
- 环境切换(dev/prod)或应用更新(可选)
|
||||
|
||||
---
|
||||
|
||||
## 7. 后端接口契约(API Contract,摘要)
|
||||
|
||||
> 具体路由/鉴权方式在 plan 阶段落地;此处先定义字段语义与幂等行为。
|
||||
|
||||
### 7.1 注册/更新绑定
|
||||
|
||||
- `POST /v1/push/register`
|
||||
- 请求体(最小集):
|
||||
- `client_user_id`: string(UUID 字符串)
|
||||
- `platform`: `"ios" | "android"`
|
||||
- `push_token`: string
|
||||
- `app_id`: string(bundle id / package name,用于隔离)
|
||||
- `env`: `"dev" | "prod"`
|
||||
- (可选)`account_id`: string
|
||||
- (可选)`device_meta`: `{ model, os_version, app_version, locale, timezone }`
|
||||
- 行为要求(幂等):
|
||||
- 以 `push_token + env + app_id` 维度做唯一性约束,避免重复记录。
|
||||
- 若同一 `client_user_id` 上报了新 token:应更新/新增映射,旧 token 进入失效或保留历史(由实现决定,但必须可控)。
|
||||
|
||||
### 7.2 解绑(可选但建议)
|
||||
|
||||
- `POST /v1/push/unregister`
|
||||
- 请求体:
|
||||
- `client_user_id`
|
||||
- `platform`
|
||||
- `push_token`(或让后端按 `client_user_id` 批量解绑,二选一)
|
||||
- `app_id`
|
||||
- `env`
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据模型(逻辑约束)
|
||||
|
||||
最小需要表达的关系:
|
||||
|
||||
- 一个 `client_user_id` 可对应 0..N 个 `push_token`(考虑多端、多渠道、token rotate)。
|
||||
- 一个 `push_token` 在同一 `env + app_id` 下应只对应一个“当前归属”(避免重复推送)。
|
||||
- 若存在 `account_id`:
|
||||
- 一个 `account_id` 可关联 0..N 个 `client_user_id`(多设备)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全与滥用防护(高层约束)
|
||||
|
||||
- **最小要求**:接口需具备基本鉴权与频率限制(例如基于设备指纹/匿名 session/应用侧签名的任一组合),避免被脚本批量绑定垃圾 token。
|
||||
- **若存在登录态**:推荐绑定写入需要登录态(或在登录后把 `client_user_id` 归属到 `account_id`),降低“抢绑”风险。
|
||||
- **可选增强(后续)**:接入 iOS App Attest / Android Play Integrity,或对注册请求做一次性挑战签名。
|
||||
|
||||
---
|
||||
|
||||
## 10. 边界场景与处理原则
|
||||
|
||||
- **用户拒绝 Push 权限**:允许只有 `client_user_id`,不产生 token 绑定;后端不应报错。
|
||||
- **token 变化**:客户端重新调用 `register`,后端必须幂等更新,避免重复推送。
|
||||
- **重装/清数据**:`client_user_id` 变化可接受;若未来有 `account_id`,可在登录后重新建立关联。
|
||||
- **多环境**:dev/prod token 不可混用;必须以 `env + app_id` 隔离。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准(Acceptance Criteria)
|
||||
|
||||
- 客户端能稳定生成并持久化 `client_user_id`(重复启动不变)。
|
||||
- 在 token 获取/变更后,调用注册接口可在后端建立(或更新)绑定关系,且接口幂等。
|
||||
- 同一 `push_token` 在同一 `env + app_id` 下不会产生多条“当前有效”绑定,避免重复推送。
|
||||
- 在用户拒绝 Push 权限、无 token 的情况下,不影响 App 正常使用与后续再次授权后的绑定。
|
||||
|
||||
275
spec_kit/Personalized Reco/modules/content-repository/plan.md
Normal file
275
spec_kit/Personalized Reco/modules/content-repository/plan.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Content Repository(候选查询与数据访问层)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/content-repository/spec.md`
|
||||
>
|
||||
> 依赖对齐:
|
||||
>
|
||||
> - DB 设计:`spec_kit/Personalized Reco/modules/db-design/plan.md`
|
||||
> - 回退梯度与场景口径:`spec_kit/Personalized Reco/spec.md`(Fallback Ladder + 场景默认参数)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 为推荐引擎提供**与 ORM/SQL 解耦**的数据访问接口:候选召回与按 ID 批量获取。
|
||||
- 将 DB 内部存储形态(JSON、关联表、NULL 语义等)统一“规范化”为上层稳定的 `ContentProfile` 结构。
|
||||
- 在候选不足时支持按 `fallback_level (L0~L3)` 进行**可控降级**(降个性化/回退通用池/安全池),并且不产生 N+1 查询。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/content-repository/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(后续 tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/content_repository/`(或等价目录)
|
||||
- 需要包含:
|
||||
- 抽象接口 `ContentRepository`(Protocol 或 ABC)
|
||||
- SQLAlchemy 实现 `SqlAlchemyContentRepository`
|
||||
- `ContentProfile`(DTO/数据结构)与规范化工具函数
|
||||
- 单元测试与最小集成测试(后续 tasks 阶段落地):
|
||||
- risk_flags 映射与去重
|
||||
- suitability 缺失兜底
|
||||
- personalization_power 映射
|
||||
- 查询不出现按 `content_id` 循环查 flags(避免 N+1)
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键技术决策(V1)
|
||||
|
||||
### 2.1 存储形态(来自 DB Design 的已对齐结论)
|
||||
|
||||
- `content_id`:MySQL 自增主键(`int`)。
|
||||
- `context_suitability_json / need_suitability_json`:JSON 存储。
|
||||
- `risk_flags`:关联表 `content_risk_flags(content_id, flag)`(同一 content 下 `uniq_content_flag` 去重)。
|
||||
- `emotion_score`:`NULL` 表示 general。
|
||||
- `review_confidence`:DB 可为 `NULL`;读取层输出时按 `0.7` 兜底(对齐规则口径)。
|
||||
- `personalization_power`:DB 推荐存 `0/5/10`;读取层输出稳定为 `0.0/0.5/1.0`。
|
||||
|
||||
### 2.2 职责边界(避免耦合)
|
||||
|
||||
- `Content Repository` **不负责** Hard Filter/Soft Scoring/Rerank/Freqcap(这些由引擎编排与打分子模块完成)。
|
||||
- `Content Repository` **负责**:
|
||||
- 按 `fallback_level` 对候选池做“降级约束”(例如限制 `personalization_power`、回退通用池/安全池)
|
||||
- 输出稳定结构(JSON 解析、默认值兜底、旧 flag 映射)
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口与数据结构(V1)
|
||||
|
||||
### 3.1 接口定义(与 spec 对齐)
|
||||
|
||||
- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]`
|
||||
- `fetch_contents_by_ids(content_ids: List[int], locale) -> List[ContentProfile]`
|
||||
|
||||
### 3.2 `ContentProfile`(输出契约的推荐形态)
|
||||
|
||||
稳定字段(必须输出):
|
||||
|
||||
- `content_id: int`
|
||||
- `text: str`
|
||||
- `stage: "general" | "expecting" | "parenting" | "unknown"`
|
||||
- `emotion_score: float | None`(`None` 表示 general)
|
||||
- `context_suitability: Dict[str, float]`
|
||||
- `need_suitability: Dict[str, float]`
|
||||
- `personalization_power: float`(`0/0.5/1`)
|
||||
- `risk_flags: List[str]`
|
||||
|
||||
可选字段(尽量输出):
|
||||
|
||||
- `author_id: str | None`
|
||||
- `template_id: str | None`
|
||||
- `review_confidence: float`(缺失/NULL 按 `0.7` 输出)
|
||||
|
||||
---
|
||||
|
||||
## 4. 读取层规范化(Normalization)
|
||||
|
||||
### 4.1 text 选文案与多语言策略(不允许回退)
|
||||
|
||||
数据来源(当前 DB/ORM 约定):
|
||||
|
||||
- `contents.text_en`:英文
|
||||
- `contents.text_tc`:繁体中文
|
||||
|
||||
规则(**不允许语言回退**):
|
||||
|
||||
- `locale=en*`:仅允许返回存在 `text_en` 的内容;输出 `text = text_en`
|
||||
- `locale=tc/zh-TW/zh-HK`:仅允许返回存在 `text_tc` 的内容;输出 `text = text_tc`
|
||||
|
||||
若内容缺少目标语言文本(例如 `locale=en*` 但 `text_en` 为空):该内容视为不可用,必须在候选/按 ID 获取时过滤掉。
|
||||
|
||||
### 4.1 suitability JSON 解析与缺失兜底
|
||||
|
||||
固定 key 集合(对齐 DB Plan 的最小入库契约):
|
||||
|
||||
- `context_suitability`:`family/work/relationship/friends/health`
|
||||
- `need_suitability`:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
|
||||
|
||||
规则:
|
||||
|
||||
- 若 DB 字段缺失/为 `NULL`/解析失败:**补齐为全 0.5**(以上所有 key 均为 `0.5`)。
|
||||
- 若 DB JSON 存在但缺少部分 key:对缺少 key 补 `0.5`,其余按原值。
|
||||
- 值域约束:期望值为 `0/0.5/1`;若出现其他值(例如字符串、越界浮点),按 `0.5` 兜底并记录告警日志(V1 可先打 debug,后续接入可观测模块)。
|
||||
|
||||
### 4.2 `review_confidence` 兜底
|
||||
|
||||
- DB `review_confidence` 为 `NULL` 或缺失:输出 `0.7`。
|
||||
|
||||
### 4.3 `personalization_power` 映射
|
||||
|
||||
若 DB 存 `0/5/10`:
|
||||
|
||||
- `0 -> 0.0`
|
||||
- `5 -> 0.5`
|
||||
- `10 -> 1.0`
|
||||
|
||||
若读到其他值:按 `0.0` 兜底并记录告警日志。
|
||||
|
||||
### 4.4 risk_flags 旧→新映射与输出约束
|
||||
|
||||
映射表(对齐 spec):
|
||||
|
||||
- `block_stage_unknown` → `unsafe_for_stage_unknown`
|
||||
- `block_stage_parenting` → `unsafe_for_stage_parenting`
|
||||
- `block_emotion_low` → `unsafe_for_emotion_low`
|
||||
- `block_health_sensitive` → `block_health_medical`(V1 保守硬拦截)
|
||||
|
||||
输出约束:
|
||||
|
||||
- 输出 `risk_flags` 必须去重。
|
||||
- 输出不得包含旧命名。
|
||||
- 输出建议稳定排序(便于测试与可观测):按字典序排序或按严重等级排序(V1 可先字典序)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 查询策略(V1)
|
||||
|
||||
> 原则:DB 层先做“粗过滤”,应用层再做“精过滤/打分”。避免在 V1 过早依赖 JSON 路径查询索引。
|
||||
|
||||
### 5.1 `fetch_contents_by_ids`(按 ID 批量获取)
|
||||
|
||||
目标:
|
||||
|
||||
- 输入任意 `content_id` 列表,返回无重复的 `ContentProfile` 列表。
|
||||
- 避免 N+1:不得按 `content_id` 循环查 `content_risk_flags`。
|
||||
- 返回顺序:必须与输入 `content_ids` 一致(对“缺记录/缺语言文本”的 id 采取跳过策略,见下)。
|
||||
|
||||
缺记录/缺语言文本的处理(V1 约定):
|
||||
|
||||
- 若某个 `content_id` 在 DB 中不存在,或按 `locale` 规则无法产出 `text`:该 id 在返回列表中**跳过**(不返回占位对象)。
|
||||
|
||||
推荐实现形态(两段式,避免 JOIN 导致重复行):
|
||||
|
||||
1. **批量拉主体与画像**(`contents` JOIN `content_profiles`),限制 `content_id IN (...)`。
|
||||
2. **批量拉 risk_flags**:`SELECT content_id, flag FROM content_risk_flags WHERE content_id IN (...)`,在应用层按 `content_id` 聚合为集合,再做旧→新映射与去重。
|
||||
|
||||
备注:
|
||||
|
||||
- 由于 `content_risk_flags` 是 1:N,直接三表 JOIN 容易导致行膨胀;两段式更便于组装与去重。
|
||||
|
||||
### 5.2 `fetch_candidates`(候选召回,支持 L0~L3)
|
||||
|
||||
输入:
|
||||
|
||||
- `scene: feed | push | widget`
|
||||
- `user_profile`(允许字段缺失)
|
||||
- `fallback_level: 0|1|2|3`
|
||||
- `limit`
|
||||
- `exclude_content_ids`(可选)
|
||||
|
||||
#### 5.2.1 fallback_level 约束(对齐大规范 Fallback Ladder)
|
||||
|
||||
从 `spec_kit/Personalized Reco/spec.md` 对齐:
|
||||
|
||||
- **L0**:正常召回配比(不在读取层实现复杂配比,读取层只保证候选池足够大且不过度放宽)
|
||||
- **L1**:放宽匹配 + 降个性化:限制 `personalization_power ≤ 0.5`
|
||||
- **L2**:回退通用池 + 进一步降个性化:限制 `personalization_power = 0`,且优先 `stage=general`
|
||||
- **L3**:兜底安全池:限制 `is_safe_pool = true`(安全池字段来自 DB Design)
|
||||
|
||||
缺失字段的最小处理(对齐大规范 Candidate Generation 口径):
|
||||
|
||||
- 若 `user_profile` 缺失明显(need/context/emotion 任一缺失):读取层按**至少 L1** 的约束执行(即使入参 fallback_level=0)。
|
||||
|
||||
#### 5.2.2 stage 粗过滤策略(V1)
|
||||
|
||||
读取层可做的最小粗过滤(不引入复杂业务判断):
|
||||
|
||||
- **L2/L3**:只取 `stage=general`(L3 额外 `is_safe_pool=true`)。
|
||||
- **L0/L1**:
|
||||
- 优先取 `stage=用户匹配阶段` + `stage=general`
|
||||
- 若无法从 `user_profile` 明确阶段,则仅取 `stage=general`(避免误推)
|
||||
|
||||
> 说明:更细粒度的阶段/跨维度规则(例如 unknown+parenting_pressure 的禁推)由 Hard Filter 子模块实现;读取层仅做粗过滤以减少扫描与传输。
|
||||
|
||||
#### 5.2.3 查询形态(避免 JOIN 行膨胀 + 保证 limit)
|
||||
|
||||
推荐采用“两段式候选召回”:
|
||||
|
||||
1. **先只查候选 ID 列表**(`contents` JOIN `content_profiles`),应用粗过滤(stage / personalization_power / is_safe_pool / exclude_content_ids),并增加 **locale 文本存在性过滤**(不允许语言回退),再用 `LIMIT limit * multiplier` 拉一批候选 ID(`multiplier` 例如 3~5,避免后续去重/过滤后不足)。
|
||||
2. **再用 `fetch_contents_by_ids` 批量补全字段**(主体+画像+risk_flags),最终在应用层去重并截断到 `limit`。
|
||||
|
||||
排序(V1):
|
||||
|
||||
- 若没有更明确的排序字段:使用 `updated_at DESC` 或随机抽样(需谨慎,MySQL `ORDER BY RAND()` 在大表会慢)。
|
||||
- 推荐:V1 先用 `content_profiles.updated_at DESC` 或 `contents.created_at DESC`,后续由打分模块决定最终排序。
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能与可观测(V1)
|
||||
|
||||
### 6.1 性能约束
|
||||
|
||||
- 单次调用不得出现按 `content_id` 循环查库(避免 N+1)。
|
||||
- `fetch_candidates` 必须在 DB 层支持 `limit`,并尽量通过粗过滤减少扫描。
|
||||
|
||||
### 6.2 建议打点/日志(为 observability 子模块预留)
|
||||
|
||||
在 Repository 层建议输出 debug 级日志(或埋点字段,供上层汇总):
|
||||
|
||||
- `scene`
|
||||
- `fallback_level`(入参)与 `effective_fallback_level`(考虑缺失字段自动至少 L1 后的实际约束级别)
|
||||
- `limit`、`exclude_content_ids_count`
|
||||
- `candidate_ids_size_raw`(第 1 段查到的候选 ID 数)
|
||||
- `candidate_size_returned`(最终返回数量)
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试计划(V1)
|
||||
|
||||
### 7.1 单元测试(纯函数)
|
||||
|
||||
- risk_flags 映射:
|
||||
- 输入包含旧 flag,输出只包含新命名
|
||||
- 去重与稳定排序
|
||||
- suitability 兜底:
|
||||
- DB 字段缺失/NULL/解析失败 → 全 0.5
|
||||
- 部分 key 缺失 → 补齐 0.5
|
||||
- personalization_power 映射:
|
||||
- 0/5/10 → 0.0/0.5/1.0
|
||||
- 异常值 → 0.0 兜底
|
||||
- review_confidence:
|
||||
- NULL/缺失 → 0.7
|
||||
|
||||
### 7.2 最小集成测试(含数据库)
|
||||
|
||||
- `fetch_contents_by_ids`:
|
||||
- 输入多个 id 返回无重复
|
||||
- flags 聚合正确(同一 content 多条 flag 行能聚合成 list)
|
||||
- 查询次数断言(避免 N+1):
|
||||
- `fetch_contents_by_ids`:固定 2 次查询(主体+画像一次,flags 一次)
|
||||
- `fetch_candidates`:固定 3 次查询(候选 id 一次 + `fetch_contents_by_ids` 两次),或实现允许的常数级次数
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与后续演进
|
||||
|
||||
### 8.1 已知风险
|
||||
|
||||
- V1 不做 JSON 路径索引:候选量变大后,粗过滤不足可能导致候选池拉取过多、应用层过滤成本上升。
|
||||
- `ORDER BY RAND()` 的性能风险:候选大表下不可用,需要替代策略(时间窗口抽样/预生成候选池)。
|
||||
|
||||
### 8.2 V1.1 优化方向(与 DB Plan 对齐)
|
||||
|
||||
- 为常用 need/context key 增加生成列/函数索引(从 JSON_EXTRACT 提取到 TINYINT)以加速召回。
|
||||
- 为强规则风险(如 `block_health_medical`)增加派生布尔列或缓存表,减少 JOIN 成本。
|
||||
|
||||
105
spec_kit/Personalized Reco/modules/content-repository/spec.md
Normal file
105
spec_kit/Personalized Reco/modules/content-repository/spec.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 子模块:Content Repository(候选查询与数据访问层)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
提供推荐算法可注入的、与 ORM/SQL 解耦的数据访问接口:
|
||||
|
||||
- 按场景与用户画像拉取候选内容画像(Cᵢ)。
|
||||
- 按 `content_id` 批量获取内容画像(去重/重排/补字段)。
|
||||
- 对 risk_flags、suitability JSON 等“存储形态”做统一解析与兼容(对上提供稳定结构)。
|
||||
|
||||
> 规则口径:risk_flags 命名与语义严格按 `句子文案打分规则`;若历史数据存在旧 flag,需在读取层做一次映射(避免语义漂移)。
|
||||
>
|
||||
> 存储形态对齐 `modules/db-design/plan.md`:读取层需要将 `contents` + `content_profiles` + `content_risk_flags` 组装为上层稳定的 `ContentProfile` 结构。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`: 客户端问卷画像(V1.2;字段允许缺失)
|
||||
- `locale`:客户端语言(由请求携带并透传至推荐模块;**当前仅支持 EN/TC**,例如 `en` / `en-US` / `tc` / `zh-TW` / `zh-HK`)
|
||||
- `fallback_level`: `0|1|2|3`
|
||||
- `limit`: 候选条数上限(由引擎配置)
|
||||
- (可选)排除集合:`exclude_content_ids`(用于 DB 层先排一部分,减少传输;类型为 `List[int]`,与 MySQL 自增 `content_id` 对齐)
|
||||
- 数据库会话/连接(实现层使用 `AsyncSession` 注入)
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
- `List[ContentProfile]`(稳定字段契约):
|
||||
- `content_id`:`int`(MySQL 自增主键)
|
||||
- `text`:按 `locale` 输出的文案文本(**不允许语言回退**)
|
||||
- `locale=en*`:必须从 `contents.text_en` 产出;若该 content 无 `text_en`,则该 content 不可返回(在候选/按 ID 获取时过滤)
|
||||
- `locale=tc/zh-TW/zh-HK`:必须从 `contents.text_tc` 产出;若无 `text_tc`,则该 content 不可返回
|
||||
- `stage`:`general | expecting | parenting | unknown`
|
||||
- `emotion_score`:`float | None`(约定:`None` 表示 general)
|
||||
- `context_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`)
|
||||
- `need_suitability`:`Dict[str, float]`(见 4.1 的 key 集合;值为 `0/0.5/1`)
|
||||
- `personalization_power`:`float`(对上稳定口径为 `0/0.5/1`;若 DB 存 `0/5/10`,读取层需映射)
|
||||
- `risk_flags`:`List[str]`(已做旧→新映射、去重;命名只允许 `unsafe_for_* / block_* / soft_*`)
|
||||
- 可选:`author_id`、`template_id`、`review_confidence`
|
||||
|
||||
### 2.3 数据存储形态(对齐 DB 设计)
|
||||
|
||||
> 对齐:`spec_kit/Personalized Reco/modules/db-design/plan.md`
|
||||
|
||||
- `contents`:提供 `content_id`、`text`、(可选)`author_id`、`template_id`
|
||||
- `content_profiles`:提供 `stage`、`emotion_score`、`context_suitability_json`、`need_suitability_json`、`personalization_power`(推荐存 `0/5/10`)、(可选)`review_confidence`
|
||||
- `content_risk_flags`:通过关联表提供风险标记集合(同一 content 下按 `uniq_content_flag(content_id, flag)` 去重)
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口(建议)
|
||||
|
||||
推荐模块对该子模块只依赖抽象接口(Python Protocol/ABC 均可):
|
||||
|
||||
- `fetch_candidates(scene, user_profile, fallback_level, limit, exclude_content_ids=None) -> List[ContentProfile]`
|
||||
- `fetch_contents_by_ids(content_ids: List[int]) -> List[ContentProfile]`
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键规则与实现约束
|
||||
|
||||
### 4.1 字段缺失与默认值
|
||||
|
||||
- 若 `review_confidence` 缺失:输出时默认按 `0.7`(对齐 `句子文案打分规则` V1.2 约定)。
|
||||
- `context_suitability/need_suitability` 若缺失:**读取层必须补齐为“全 0.5 的通用可推”结构**(稳定输出,避免上层分支判断)。
|
||||
- `context_suitability` 必须包含 5 个 key:`family/work/relationship/friends/health`
|
||||
- `need_suitability` 必须包含 5 个 key:`emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance`
|
||||
- 补齐时上述 key 的默认值均为 `0.5`
|
||||
- `personalization_power`:若 DB 采用 `0/5/10` 存储,读取层必须映射为 `0.0/0.5/1.0` 对上输出。
|
||||
|
||||
### 4.2 risk_flags 兼容映射(若存在历史旧数据)
|
||||
|
||||
对齐 `句子文案打分规则` 的旧→新映射:
|
||||
|
||||
- `block_stage_unknown` → `unsafe_for_stage_unknown`
|
||||
- `block_stage_parenting` → `unsafe_for_stage_parenting`
|
||||
- `block_emotion_low` → `unsafe_for_emotion_low`
|
||||
- `block_health_sensitive` → `block_health_medical`(读取层默认采用更保守的硬拦截映射;除非未来引入可判定的细分字段再放宽为 `soft_health_sensitive`)
|
||||
|
||||
输出约束:
|
||||
|
||||
- 输出的 flags 必须已去重,且不得包含任何旧命名。
|
||||
|
||||
### 4.3 性能约束
|
||||
|
||||
- 不得产生 N+1 查询:候选与字段必须一次或少量批量查询获取。
|
||||
- `fetch_candidates` 必须支持 limit,并在 DB 层尽量过滤(减少应用层扫描)。
|
||||
- `fetch_contents_by_ids` / `fetch_candidates` 推荐查询形态:
|
||||
- `contents` JOIN `content_profiles`,再 LEFT JOIN `content_risk_flags`(或先批量取 profiles,再批量取 flags 并在应用层聚合),避免按 `content_id` 循环查 flags。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准(可验证)
|
||||
|
||||
- `fetch_contents_by_ids`:
|
||||
- 输入任意 `content_id` 列表,返回包含完整字段的 `ContentProfile` 列表(无重复、可缺省字段按约定兜底)。
|
||||
- `fetch_candidates`:
|
||||
- 在不同 `scene` 与 `fallback_level` 下能返回候选(即便画像缺失也不报错)。
|
||||
- risk_flags 映射正确:输出的 flag 名称集合只包含新命名(`unsafe_for_* / block_* / soft_*`)。
|
||||
- 性能:
|
||||
- 单次调用不出现按 content_id 循环查库的行为(可通过日志/测试断言查询次数)。
|
||||
|
||||
198
spec_kit/Personalized Reco/modules/content-repository/tasks.md
Normal file
198
spec_kit/Personalized Reco/modules/content-repository/tasks.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Content Repository(候选查询与数据访问层)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/content-repository/plan.md`
|
||||
>
|
||||
> 本清单已对齐确认点:
|
||||
>
|
||||
> - `text` 按客户端 **locale** 输出(请求携带)
|
||||
> - **不允许语言回退**(缺少目标语言文本的内容直接过滤,不返回)
|
||||
> - 需要做 **MySQL(dev)DB 集成测试**
|
||||
> - 代码与其他推荐子模块放同一目录:`server/app/features/personalized_reco/`
|
||||
> - `fetch_contents_by_ids` 返回顺序必须与输入 `content_ids` **一致**
|
||||
>
|
||||
> 执行说明:
|
||||
>
|
||||
> - 本次已在 dev MySQL 环境跑通 `pytest`,且测试不做破坏性操作:
|
||||
> - 不清表(不执行 DELETE/TRUNCATE)
|
||||
> - 每个用例使用事务并在结束时 rollback
|
||||
> - 默认不执行 Alembic 迁移(如需自动迁移需显式设置 `ALLOW_SCHEMA_MIGRATION=1`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 更新 `modules/content-repository/spec.md`,加入 locale 相关契约
|
||||
- **变更点**:
|
||||
- 在输入中新增 `locale`(例如 `en`/`en-US`/`tc`/`zh-TW`/`zh-HK`),声明由客户端请求携带并透传至 repository
|
||||
- 在输出字段 `text` 补充语言选择规则:
|
||||
- `locale=en*`:必须从 `text_en` 产出;若缺失则该内容不可返回(过滤)
|
||||
- `locale=zh-TW|zh-HK`:必须从 `text_tc` 产出;若缺失则该内容不可返回(过滤)
|
||||
- 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略
|
||||
- 明确:`fetch_contents_by_ids` **返回顺序与入参一致**
|
||||
- **验收**:`spec.md` 中输入/输出与接口签名不再缺少 locale,且 `text` 的来源与“不允许语言回退”规则清晰。
|
||||
|
||||
- [x] 1.2 更新 `modules/content-repository/plan.md`,补齐 locale 选文与“繁转简”技术方案
|
||||
- **变更点**:
|
||||
- 在“读取层规范化(Normalization)”新增 `text` 规范化章节:语言选择 + 简中转换
|
||||
- 说明:当前仅支持 EN/TC(不做繁转简);若未来新增 `zh-CN` 再单独设计转换策略
|
||||
- 明确策略:**不允许语言回退**;缺少目标语言文本的内容视为不可用,必须在候选/按 ID 获取时过滤掉
|
||||
- **验收**:`plan.md` 有明确依赖与落地策略(含依赖包/转换时机/兜底策略),不留二义性。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/content_repository/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(DTO:`ContentProfile`、locale 类型等)
|
||||
- `interface.py`(`ContentRepository` Protocol/ABC)
|
||||
- `normalization.py`(解析与兜底:suitability、risk_flags、power、text)
|
||||
- `sqlalchemy_repo.py`(SQLAlchemy 实现)
|
||||
- **验收**:可被 `app.features.personalized_reco.content_repository.*` 正常 import。
|
||||
|
||||
- [ ] 2.2 依赖补齐(如采用 OpenCC)
|
||||
- **说明**:当前仅支持 EN/TC,此任务可跳过;若未来新增 `zh-CN` 并需要繁转简,再引入 OpenCC。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据结构与接口(面向引擎注入)
|
||||
|
||||
- [x] 3.1 定义 `ContentProfile` DTO(稳定字段契约)
|
||||
- **字段**:对齐 `modules/content-repository/spec.md`,并补齐 `review_confidence` 输出兜底为 `0.7`
|
||||
- **注意**:`text` 为最终对外输出文本(已按 locale 选择/转换)
|
||||
- **验收**:DTO 字段齐全;类型清晰;不暴露 ORM 模型。
|
||||
|
||||
- [x] 3.2 定义 `ContentRepository` 接口(含 locale)
|
||||
- **建议签名**(示例,最终以 spec 为准):
|
||||
- `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids=None) -> List[ContentProfile]`
|
||||
- `fetch_contents_by_ids(content_ids, locale) -> List[ContentProfile]`
|
||||
- **验收**:推荐引擎可以仅依赖该接口,不依赖 SQLAlchemy/FastAPI Depends。
|
||||
|
||||
---
|
||||
|
||||
## 4. 规范化工具函数(可单测)
|
||||
|
||||
- [x] 4.1 suitability 解析与兜底
|
||||
- **规则**:
|
||||
- 缺失/NULL/解析失败 → 全 0.5(固定 key 集合)
|
||||
- 部分 key 缺失 → 对缺失 key 补 0.5
|
||||
- 非法值 → 兜底 0.5
|
||||
- **验收**:单元测试覆盖缺失/部分缺失/非法值。
|
||||
|
||||
- [x] 4.2 risk_flags 映射、去重与排序
|
||||
- **规则**:旧→新映射对齐 spec;去重;稳定排序(例如字典序)
|
||||
- **验收**:单元测试断言输出不含旧命名且顺序稳定。
|
||||
|
||||
- [x] 4.3 personalization_power 映射
|
||||
- **规则**:`0/5/10 -> 0.0/0.5/1.0`;非法值 -> 0.0
|
||||
- **验收**:单元测试覆盖正常/异常值。
|
||||
|
||||
- [x] 4.4 text 选择与简中转换
|
||||
- **输入**:`text_en`、`text_tc`、`locale`
|
||||
- **规则**:按 1.1/1.2 写死的策略执行(当前仅支持 EN/TC,不做繁转简)
|
||||
- **验收**:单元测试覆盖:
|
||||
- `en` 取英文
|
||||
- `zh-TW` 取繁中
|
||||
- 缺失目标语言文本时的行为:返回“不可用”(例如返回空字符串 + 上层过滤,或直接返回 `None` 由调用方过滤;实现中必须一致)
|
||||
|
||||
---
|
||||
|
||||
## 5. SQLAlchemy 实现(无 N+1、顺序可控)
|
||||
|
||||
> 说明:当前 DB 模型为:
|
||||
>
|
||||
> - `contents`:`text_en` / `text_tc` / `author_id` / `template_id`
|
||||
> - `content_profiles`:JSON、power、stage、is_safe_pool、review_confidence
|
||||
> - `content_risk_flags`:关联表(1:N)
|
||||
|
||||
- [x] 5.1 实现 `fetch_contents_by_ids(content_ids, locale)`
|
||||
- **实现要点**:
|
||||
- 输入去重,但输出必须按原始输入顺序重排(并忽略不存在的 id 或明确行为:不存在则跳过)
|
||||
- 两段式查询避免行膨胀:
|
||||
1) `contents` JOIN `content_profiles` 批量取主体与画像
|
||||
2) `content_risk_flags` 批量取 flags,再按 `content_id` 聚合
|
||||
- 组装 DTO 时执行 normalization(含 text locale 规则)
|
||||
- **验收**:
|
||||
- 返回顺序与输入一致
|
||||
- 缺失目标语言文本的 content_id 不返回(跳过,不做语言回退)
|
||||
- 不产生按 id 循环查 flags 的查询(查询次数为常数级)
|
||||
|
||||
- [x] 5.2 实现 `fetch_candidates(scene, user_profile, fallback_level, limit, locale, exclude_content_ids)`
|
||||
- **实现要点**:
|
||||
- 计算 `effective_fallback_level`:
|
||||
- 若画像 need/context/emotion 任一缺失,则 `effective_fallback_level = max(fallback_level, 1)`
|
||||
- DB 粗过滤对齐 plan:
|
||||
- L1:`personalization_power <= 5`
|
||||
- L2:`personalization_power = 0` 且 `stage = general`
|
||||
- L3:`is_safe_pool = true` 且 `stage = general` 且 `personalization_power = 0`(如需更严格可在此明确)
|
||||
- 排序(V1):按 `content_profiles.updated_at DESC` 或 `contents.updated_at DESC`(择一写死并记录)
|
||||
- 两段式候选:
|
||||
1) 先查候选 id(`LIMIT limit * multiplier`)
|
||||
2) 调用 `fetch_contents_by_ids` 补全字段
|
||||
- locale 文本存在性过滤:
|
||||
- `locale=en*`:`contents.text_en IS NOT NULL`
|
||||
- `locale=zh-*`:`contents.text_tc IS NOT NULL`
|
||||
- 输出顺序:
|
||||
- 返回顺序按候选 id 列表顺序(用于后续引擎打分/重排);最终截断至 `limit`
|
||||
- **验收**:
|
||||
- 在不同 `effective_fallback_level` 下能返回候选
|
||||
- 不返回缺少目标语言文本的内容(不做语言回退)
|
||||
- 查询次数为常数级(不随 `limit` 线性增长)
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 集成测试(dev MySQL)
|
||||
|
||||
- [x] 6.1 建立测试目录与 pytest 配置
|
||||
- **目标**:在 `server/` 内新增 `tests/`(或 `app/**/__tests__/`,但建议统一为 `server/tests/`)
|
||||
- **内容**:
|
||||
- `server/tests/conftest.py`:提供 AsyncEngine/AsyncSession、清库策略、query count 统计工具
|
||||
- 测试运行约定:通过 `DATABASE_URL` 指向 dev 测试库(建议单独库名,例如 `mindfulness_dev_test`)
|
||||
- **验收**:`pytest` 可在 `server/` 下运行并发现测试。
|
||||
|
||||
- [x] 6.2 测试库 schema 初始化(用 Alembic)
|
||||
- **策略**(二选一写死):
|
||||
- A:测试启动时 `alembic upgrade head`(确保 schema 最新)
|
||||
- B:在 CI/本地提前准备库,仅在测试中清表
|
||||
- **验收**:测试运行前 schema 可用,且不会污染开发主库数据(推荐使用独立 test 库)。
|
||||
|
||||
- [x] 6.3 集成测试用例:`fetch_contents_by_ids`
|
||||
- **准备数据**:插入最小内容 2~3 条(覆盖 text_en/text_tc 缺失组合)、profiles、flags(含旧 flag)
|
||||
- **断言**:
|
||||
- 返回顺序与输入一致
|
||||
- `en` 不返回 `text_en` 缺失的内容(不回退 `text_tc`)
|
||||
- risk_flags 映射后不含旧命名
|
||||
- `review_confidence` NULL → 0.7
|
||||
- **验收**:测试稳定通过。
|
||||
|
||||
- [x] 6.4 集成测试用例:`fetch_candidates`
|
||||
- **准备数据**:覆盖 `personalization_power` 0/5/10、`is_safe_pool` true/false、不同 stage
|
||||
- **断言**:
|
||||
- L1/L2/L3 粗过滤生效
|
||||
- `exclude_content_ids` 生效
|
||||
- 查询次数为常数级(用 before_cursor_execute 计数)
|
||||
- **验收**:测试稳定通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终自检清单(合入前)
|
||||
|
||||
- [x] 7.1 文档一致性检查
|
||||
- `spec.md` / `plan.md` / 实现接口签名三者一致(尤其是 `locale` 与 `text` 输出规则)
|
||||
|
||||
- [x] 7.2 性能检查(最小)
|
||||
- `fetch_contents_by_ids` / `fetch_candidates` 查询次数断言通过(无 N+1)
|
||||
|
||||
- [x] 7.3 回归检查
|
||||
- 不影响现有 `user_profile_scoring` 模块与迁移脚本(仅新增模块与测试)
|
||||
|
||||
217
spec_kit/Personalized Reco/modules/db-design/plan.md
Normal file
217
spec_kit/Personalized Reco/modules/db-design/plan.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# DB Design & Migrations(数据库设计与迁移)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/db-design/spec.md`
|
||||
>
|
||||
> 前置确认(已对齐):
|
||||
>
|
||||
> - `content_id`:MySQL **自增主键**;文案微调时 **content_id 不变**(更新同一条记录)。
|
||||
> - `need_suitability/context_suitability`:**JSON** 存储。
|
||||
> - `risk_flags`:选择更强扩展性的方案(本计划采用 **关联表**,利于索引与过滤)。
|
||||
> - 安全池(L3):**方式 A**(`is_safe_pool`)。
|
||||
> - 迁移:使用 **Alembic**,目录放 `server/alembic/`;dev/pro 两套库均可运行同一套迁移。
|
||||
> - 字符集:统一 **utf8mb4**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 在“库为空”的前提下,落地推荐系统最小可用的数据模型。
|
||||
- 保证后续推荐查询可实现:按画像条件召回、按 ID 批量查、按风险标记过滤、支持安全池兜底。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `server/alembic/`:Alembic 初始化目录、`alembic.ini`(或等价配置)、迁移脚本。
|
||||
- SQLAlchemy ORM 模型(建议放 `server/app/db/models/`)。
|
||||
- 初始迁移:创建 `contents`、`content_profiles`、`content_risk_flags`(以及必要索引)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术决策(V1)
|
||||
|
||||
### 2.1 表设计原则
|
||||
|
||||
- **分离主体与画像**:`contents` 存文本与来源字段;`content_profiles` 存画像字段(便于未来画像重算/回填)。
|
||||
- **JSON 存 suitability**:`context_suitability`、`need_suitability` 用 JSON,保持结构与规则文档一致。
|
||||
- **risk_flags 关联表**:用 `content_risk_flags(content_id, flag)`,便于:
|
||||
- 快速 Hard Filter(`block_health_medical` 等)
|
||||
- 索引与统计(按 flag 计数)
|
||||
- 兼容旧 flag 映射(在写入/读取层)
|
||||
- **emotion_score 的 general 表示**:用 `NULL` 表示 general(与规则文档“可为 general”语义等价)。
|
||||
- **personalization_power**:存为 `TINYINT`(0/5/10)或 `DECIMAL(2,1)`(0/0.5/1)。本计划推荐 `TINYINT`(更易索引/更省空间),应用层做映射:
|
||||
- 0 → 0.0
|
||||
- 5 → 0.5
|
||||
- 10 → 1.0
|
||||
|
||||
### 2.2 字符集与排序规则
|
||||
|
||||
- 数据库与表:`utf8mb4`
|
||||
- collation:建议 `utf8mb4_0900_ai_ci`(MySQL 8 默认更常见;若环境不同以实际为准,但必须 utf8mb4)
|
||||
|
||||
---
|
||||
|
||||
## 3. 表结构(V1 方案)
|
||||
|
||||
> 以下为“建议 schema”。实际字段名可调整,但语义必须严格对齐 `句子文案打分规则`。
|
||||
|
||||
### 3.1 `contents`(文案主体)
|
||||
|
||||
- `content_id` BIGINT UNSIGNED PK AUTO_INCREMENT
|
||||
- `text` TEXT NOT NULL
|
||||
- `author_id` VARCHAR(64) NULL
|
||||
- `template_id` VARCHAR(64) NULL
|
||||
- `created_at` DATETIME NOT NULL
|
||||
- `updated_at` DATETIME NOT NULL
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_contents_author_id(author_id)`
|
||||
- `idx_contents_template_id(template_id)`
|
||||
|
||||
### 3.2 `content_profiles`(内容画像)
|
||||
|
||||
- `content_id` BIGINT UNSIGNED PK(FK → contents.content_id,ON DELETE CASCADE)
|
||||
- `stage` ENUM('general','expecting','parenting','unknown') NOT NULL DEFAULT 'general'
|
||||
- `emotion_score` DECIMAL(3,2) NULL
|
||||
- 约定:NULL 表示 general
|
||||
- `context_suitability_json` JSON NOT NULL
|
||||
- `need_suitability_json` JSON NOT NULL
|
||||
- `personalization_power` TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
- 约定:只允许 0/5/10
|
||||
- `review_confidence` DECIMAL(3,2) NULL
|
||||
- 约定:NULL 由推荐模块按 0.7 兜底(对齐规则文档)
|
||||
- `is_safe_pool` BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `updated_at` DATETIME NOT NULL
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_profiles_stage(stage)`
|
||||
- `idx_profiles_personalization_power(personalization_power)`
|
||||
- `idx_profiles_is_safe_pool(is_safe_pool)`
|
||||
|
||||
> 说明:suitability 放 JSON 后,V1 可以先不做 JSON 路径索引;当候选量上来后再加“生成列/函数索引”做加速(见 6.2)。
|
||||
|
||||
### 3.3 `content_risk_flags`(风险标记,关联表)
|
||||
|
||||
- `id` BIGINT UNSIGNED PK AUTO_INCREMENT
|
||||
- `content_id` BIGINT UNSIGNED NOT NULL(FK → contents.content_id,ON DELETE CASCADE)
|
||||
- `flag` VARCHAR(64) NOT NULL
|
||||
- `created_at` DATETIME NOT NULL
|
||||
|
||||
约束与索引:
|
||||
|
||||
- UNIQUE:`uniq_content_flag(content_id, flag)`(同一 content 不重复插同 flag)
|
||||
- 索引:`idx_flag(flag)`(用于 Hard Filter 与统计)
|
||||
- 索引:`idx_content_id(content_id)`(用于按内容批量取 flags)
|
||||
|
||||
命名约束(应用层强制,DB 可选):
|
||||
|
||||
- flag 必须以 `unsafe_for_` / `block_` / `soft_` 开头(严格对齐规则文档)
|
||||
|
||||
---
|
||||
|
||||
## 4. Alembic 迁移落地步骤
|
||||
|
||||
### 4.1 依赖与目录
|
||||
|
||||
- 后端依赖:`alembic`(加入 `server/requirements.txt`,版本随项目统一管理)
|
||||
- 目录:`server/alembic/`(包含 `env.py`、`versions/`)
|
||||
- 连接串:复用现有 `DATABASE_URL`(`mysql+aiomysql://...`)
|
||||
|
||||
### 4.2 初始化与生成迁移(一次性)
|
||||
|
||||
- `alembic init alembic`(在 `server/` 下)
|
||||
- 配置 `env.py`:
|
||||
- 从 `app/core/config.py` 读取 `DATABASE_URL`
|
||||
- 引入 ORM Base 与 models,启用 autogenerate
|
||||
- 创建初始迁移:
|
||||
- `alembic revision --autogenerate -m "init content tables"`
|
||||
- `alembic upgrade head`
|
||||
|
||||
### 4.3 dev/pro 一致性
|
||||
|
||||
- 迁移脚本保持同一套;通过不同环境的 `DATABASE_URL` 指向 `mindfulness_dev` 或 `mindfulness`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 入库流程(写入契约)
|
||||
|
||||
### 5.1 一条文案最小入库数据
|
||||
|
||||
必须字段(V1 最小可用):
|
||||
|
||||
- `text`
|
||||
- `content_profiles.stage`
|
||||
- `content_profiles.emotion_score`(可为 NULL 表示 general)
|
||||
- `content_profiles.context_suitability_json`(必须包含 5 个 key:family/work/relationship/friends/health,值为 0/0.5/1)
|
||||
- `content_profiles.need_suitability_json`(必须包含 5 个 key:emotional_support/parenting_pressure/self_worth/anxiety_relief/rest_balance,值为 0/0.5/1)
|
||||
- `content_profiles.personalization_power`(0/5/10)
|
||||
- `content_risk_flags`(可为空集合,但若存在必须按命名规范)
|
||||
|
||||
强烈建议字段:
|
||||
|
||||
- `author_id`、`template_id`
|
||||
- `review_confidence`
|
||||
- `is_safe_pool`(若要参与 L3 安全池)
|
||||
|
||||
### 5.2 写入策略
|
||||
|
||||
- 创建文案时:
|
||||
- 先写 `contents` 得到 `content_id`(自增)
|
||||
- 再写 `content_profiles`(同 content_id)
|
||||
- 再批量写 `content_risk_flags`
|
||||
- 文案微调时(content_id 不变):
|
||||
- 更新 `contents.text` 与 `updated_at`
|
||||
- 同步更新 `content_profiles`(若画像变更)
|
||||
- risk_flags 做“全量覆盖”或“差量更新”(plan 实现阶段定)
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询与性能规划
|
||||
|
||||
### 6.1 V1 查询策略(先可用)
|
||||
|
||||
- 候选召回:
|
||||
- 先按 `stage`、`personalization_power`、`is_safe_pool` 等可索引字段进行粗过滤
|
||||
- 再在应用层结合 suitability JSON 与 risk_flags 做精过滤/打分
|
||||
- Hard Filter:
|
||||
- 通过 `content_risk_flags` join 或子查询排除指定 flags(如 `block_health_medical`)
|
||||
- 批量查:
|
||||
- `content_id IN (...)` join `content_profiles` + left join `content_risk_flags`
|
||||
|
||||
### 6.2 V1.1 性能增强(候选量上来后再做)
|
||||
|
||||
当候选池变大、应用层过滤成本上升时,优先做两类增强:
|
||||
|
||||
- **生成列/函数索引**:为常用召回维度(例如 need/context 的某些 key)创建 generated columns(从 JSON_EXTRACT 取值并映射到 TINYINT),再加索引。
|
||||
- **风险 flag 位图/派生列**:对 `block_health_medical` 等强规则增加派生布尔列(或维护冗余表),降低 join 成本。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试与验收(DB 子模块)
|
||||
|
||||
### 7.1 迁移验收
|
||||
|
||||
- 在全新库执行 `alembic upgrade head` 成功。
|
||||
- 执行 `downgrade`(若实现)可回滚(至少在开发环境可用)。
|
||||
|
||||
### 7.2 数据契约验收
|
||||
|
||||
插入一条最小文案记录后,能够查询并组装出推荐模块所需的 `ContentProfile` 字段集合:
|
||||
|
||||
- `content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags`
|
||||
|
||||
### 7.3 规则口径验收(写入侧)
|
||||
|
||||
- 写入 `risk_flags` 时,若出现旧 flag(如 `block_stage_unknown`):
|
||||
- 写入层需在入库前映射为新命名(或拒绝写入并提示)
|
||||
- 推荐侧读取层不得再出现旧 flag 名称
|
||||
|
||||
---
|
||||
|
||||
## 8. 与其他子模块的接口约定
|
||||
|
||||
- `Content Repository` 只依赖本模块提供的表与字段语义,不依赖具体迁移实现细节。
|
||||
- 推荐引擎/打分模块对 `review_confidence` 的缺省值假设(0.7)在 DB 缺失时依然成立。
|
||||
|
||||
91
spec_kit/Personalized Reco/modules/db-design/spec.md
Normal file
91
spec_kit/Personalized Reco/modules/db-design/spec.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 子模块:DB Design & Migrations(数据库设计与迁移)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
在当前“数据库尚未设计且为空”的前提下,为个性化推荐提供最小可用的数据存储与索引能力:
|
||||
|
||||
- 存储文案及其内容画像(Content Profile,Cᵢ)。
|
||||
- 能按画像条件进行候选召回(need/context/stage/general、personalization_power、risk_flags 等)。
|
||||
- 能按 `content_id` 批量查询(补全候选、去重/重排时取字段)。
|
||||
- 为后续标注/审核/置信度补齐留出扩展空间。
|
||||
|
||||
> 规则口径:字段语义必须严格对齐 `设计说明文档/句子文案打分規則.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- 内容侧提供的文案与画像数据(可由运营导入、AI Reviewer 产出、人审修正等方式写入)。
|
||||
- 推荐模块对数据访问的需求(召回过滤字段、排序字段、频控字段)。
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
- 一套 MySQL 表结构(或视图)满足 `ContentProfile` 字段契约:
|
||||
- `content_id`(稳定主键)
|
||||
- `text`
|
||||
- `stage`(`general/expecting/parenting/unknown`)
|
||||
- `emotion_score`(0~1 或 `general` 的等价表示)
|
||||
- `context_suitability`(每个 context 的 {0,0.5,1})
|
||||
- `need_suitability`(每个 need 的 {0,0.5,1})
|
||||
- `personalization_power`(0/0.5/1)
|
||||
- `risk_flags`(命名以 `unsafe_for_* / block_* / soft_*` 为准)
|
||||
- 可选:`author_id`、`template_id`、`review_confidence`
|
||||
- Alembic 迁移脚本:`alembic revision --autogenerate` / `alembic upgrade head` 可创建上述表。
|
||||
- 推荐查询需要的索引(至少支持按场景召回与按 ID 批量查)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建议表结构(V1,允许后续调整)
|
||||
|
||||
> 说明:本子模块不强制具体表名;但需保证字段语义与索引可用。以下给出一个推荐落地方案,便于后续 plan 直接实现。
|
||||
|
||||
### 3.1 `contents`(文案主体)
|
||||
|
||||
- `content_id`(PK)
|
||||
- `text`
|
||||
- `author_id`(可空)
|
||||
- `template_id`(可空)
|
||||
- `created_at` / `updated_at`
|
||||
|
||||
### 3.2 `content_profiles`(内容画像)
|
||||
|
||||
- `content_id`(PK/FK → contents)
|
||||
- `stage`(枚举:general/expecting/parenting/unknown)
|
||||
- `emotion_score`(可为 NULL 表示 general,或用额外字段 `emotion_is_general` 表示)
|
||||
- `context_suitability_json`(JSON:每个 context -> 0/0.5/1)
|
||||
- `need_suitability_json`(JSON:每个 need -> 0/0.5/1)
|
||||
- `personalization_power`(DECIMAL(2,1) 或 TINYINT 映射到 0/0.5/1)
|
||||
- `risk_flags_json`(JSON 数组:字符串集合)
|
||||
- `review_confidence`(DECIMAL(3,2),缺省可按 0.7 处理,由推荐模块兜底)
|
||||
- `updated_at`
|
||||
|
||||
### 3.3 “通用安全池”支持(L3 兜底)
|
||||
|
||||
至少提供一种方式能拉到安全池内容:
|
||||
|
||||
- 方式 A:`content_profiles` 增加 `is_safe_pool`(boolean)
|
||||
- 方式 B:单独 `safe_pool_contents`(content_id 列表)
|
||||
|
||||
---
|
||||
|
||||
## 4. 索引与查询能力(V1 必需)
|
||||
|
||||
- **按 ID 批量查**:`content_id in (...)`
|
||||
- **按 stage / personalization_power 过滤**:支持候选召回与回退梯度
|
||||
- **按 risk_flags 过滤**:
|
||||
- 推荐做法:将 `risk_flags_json` 冗余为可索引的派生列/位图/多表行(plan 阶段定实现)
|
||||
- V1 最小可用:允许先在应用层过滤(但需控制候选量,避免全表扫)
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准(可验证)
|
||||
|
||||
- **迁移可运行**:全新 MySQL 库上执行 `alembic upgrade head` 可成功创建表结构。
|
||||
- **字段契约可满足**:能从 DB 读出 `ContentProfile` 需要的字段(至少 content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags)。
|
||||
- **基本查询可用**:
|
||||
- 能按 `content_id` 批量拉取内容画像
|
||||
- 能按 `stage/general` 与 `personalization_power` 条件召回候选(用于 L0~L3)
|
||||
- **安全池可用**:能稳定拉取 L3 兜底候选集(不依赖用户画像)。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user