fix:并
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
请开始完成编码
|
||||
客户端请按照标准的RN架构目录写代码
|
||||
后端请按照标准的python FastAPI 架构目录写代码
|
||||
现在多语言仅支持 EN / TC
|
||||
现在多语言仅支持 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。
|
||||
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.*
|
||||
|
||||
@@ -14,12 +14,20 @@ import Animated, {
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import {
|
||||
addFavorite,
|
||||
getRecoFeedCache,
|
||||
getRecoFeedHistory,
|
||||
getThemeMode,
|
||||
getUserProfile,
|
||||
getUserProfileScoring,
|
||||
recordRecoFeedServed,
|
||||
recordRecoFeedTouched,
|
||||
setRecoFeedCache,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
type RecoFeedCacheItem,
|
||||
type ThemeMode,
|
||||
} from '@/src/storage/appStorage';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
import ThemeModal from '@/components/home/ThemeModal';
|
||||
@@ -75,8 +83,11 @@ export default function HomeScreen() {
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
||||
|
||||
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
|
||||
const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT;
|
||||
const item = useMemo(() => currentList[index % currentList.length], [currentList, index]);
|
||||
const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null;
|
||||
|
||||
// 动画相关 Shared Values
|
||||
const translateY = useSharedValue(0);
|
||||
@@ -100,6 +111,55 @@ export default function HomeScreen() {
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const cache = await getRecoFeedCache();
|
||||
if (!cancelled && cache?.items?.length) {
|
||||
setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text })));
|
||||
}
|
||||
|
||||
const scoring = await getUserProfileScoring();
|
||||
if (!scoring) return;
|
||||
|
||||
try {
|
||||
const hist = await getRecoFeedHistory();
|
||||
const out = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
profile_version: scoring.profile_version,
|
||||
profile_source: scoring.profile_source,
|
||||
profile_generated_at: scoring.profile_generated_at,
|
||||
profile_confidence: scoring.profile_confidence,
|
||||
profile_answered: scoring.profile_answered,
|
||||
stage: scoring.stage,
|
||||
emotion_score: scoring.emotion_score,
|
||||
context: scoring.context,
|
||||
need: scoring.need,
|
||||
},
|
||||
already_recommended_ids: hist.already_recommended_ids,
|
||||
touched_or_viewed_ids: hist.touched_or_viewed_ids,
|
||||
});
|
||||
|
||||
if (!cancelled && out.items?.length) {
|
||||
setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text })));
|
||||
await setRecoFeedCache({
|
||||
saved_at: new Date().toISOString(),
|
||||
items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: out.meta as Record<string, unknown>,
|
||||
});
|
||||
await recordRecoFeedServed(out.items.map((x) => x.content_id));
|
||||
}
|
||||
} catch {
|
||||
// 忽略:保持缓存/本地 mock
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
if (themeMode === 'color') {
|
||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||
@@ -153,6 +213,11 @@ export default function HomeScreen() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||
if (typeof currentContentId === 'number') {
|
||||
void recordRecoFeedTouched(currentContentId);
|
||||
}
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
@@ -173,7 +238,7 @@ export default function HomeScreen() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, translateY, opacity]);
|
||||
}, [busy, currentContentId, index, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||
import { setOnboardingCompleted, setUserProfile, setDailyReminderSettings } from '@/src/storage/appStorage';
|
||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
setUserProfile,
|
||||
setDailyReminderSettings,
|
||||
setUserProfileScoring,
|
||||
setRecoFeedCache,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
||||
@@ -71,6 +80,40 @@ export default function OnboardingScreen() {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
const pushEnabled = status === 'granted';
|
||||
|
||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||
|
||||
// 生成用户画像(供推荐/Push/Widget 复用)
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||
try {
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
profile_version: scoringProfile.profile_version,
|
||||
profile_source: scoringProfile.profile_source,
|
||||
profile_generated_at: scoringProfile.profile_generated_at,
|
||||
profile_confidence: scoringProfile.profile_confidence,
|
||||
profile_answered: scoringProfile.profile_answered,
|
||||
stage: scoringProfile.stage,
|
||||
emotion_score: scoringProfile.emotion_score,
|
||||
context: scoringProfile.context,
|
||||
need: scoringProfile.need,
|
||||
},
|
||||
});
|
||||
|
||||
await setRecoFeedCache({
|
||||
saved_at: new Date().toISOString(),
|
||||
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: meta as Record<string, unknown>,
|
||||
});
|
||||
await recordRecoFeedServed(items.map((x) => x.content_id));
|
||||
} catch {
|
||||
// 网络失败时使用首页本地 mock 兜底
|
||||
}
|
||||
|
||||
await setUserProfile({
|
||||
name,
|
||||
intents: Object.values(selections).flat()
|
||||
@@ -98,20 +141,29 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const onSkip = async () => {
|
||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
};
|
||||
|
||||
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||
const handleToggleSelection = (id: string) => {
|
||||
setSelections(prev => {
|
||||
const currentIds = prev[currentStep.id] || [];
|
||||
const nextIds = currentIds.includes(id)
|
||||
? currentIds.filter(i => i !== id)
|
||||
: [...currentIds, id];
|
||||
const nextIds = currentIds.includes(id) ? [] : [id];
|
||||
return { ...prev, [currentStep.id]: nextIds };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipStep = () => {
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title={currentStep.title}
|
||||
@@ -135,6 +187,7 @@ export default function OnboardingScreen() {
|
||||
selectedIds={selections[currentStep.id] || []}
|
||||
onToggle={handleToggleSelection}
|
||||
onNext={onNext}
|
||||
onSkip={handleSkipStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ interface SelectionStepProps {
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onNext: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) {
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
|
||||
return (
|
||||
@@ -48,13 +49,17 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!hasSelection}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.footerRow}>
|
||||
{onSkip && (
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<SerifText style={styles.skipText}>跳过</SerifText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -99,5 +104,20 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
}
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
skipBtn: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
color: OnboardingColors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -4,30 +4,21 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import pt from './locales/pt.json';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
|
||||
/**
|
||||
* 语言码约定:
|
||||
* - 简体中文:zh-CN
|
||||
* - 繁体中文:zh-TW
|
||||
* - 英语:en
|
||||
* - 西班牙语:es
|
||||
* - 葡萄牙语:pt
|
||||
*/
|
||||
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
|
||||
export type AppLanguage = 'zh-TW' | 'en';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
'es',
|
||||
'pt',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
|
||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
|
||||
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
||||
|
||||
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
@@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||
const tag = languageTag.toLowerCase();
|
||||
|
||||
// 中文:优先区分繁简
|
||||
// 中文:当前仅支持繁体中文(zh-TW)
|
||||
if (tag.startsWith('zh')) {
|
||||
// 常见繁体标记:zh-TW / zh-HK / zh-Hant
|
||||
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
return 'zh-CN';
|
||||
return 'zh-TW';
|
||||
}
|
||||
|
||||
// 其他语言:按前缀匹配
|
||||
// 其他语言:按前缀匹配(当前仅支持英文)
|
||||
if (tag.startsWith('en')) return 'en';
|
||||
if (tag.startsWith('es')) return 'es';
|
||||
if (tag.startsWith('pt')) return 'pt';
|
||||
|
||||
return DEFAULT_FALLBACK_LANGUAGE;
|
||||
}
|
||||
@@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise<void> {
|
||||
* 语言选择优先级:
|
||||
* 1) 用户设置(若存在)
|
||||
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
||||
* 3) 默认回退(zh-CN)
|
||||
* 3) 默认回退(en)
|
||||
*/
|
||||
export async function initI18n(): Promise<void> {
|
||||
if (i18n.isInitialized) return;
|
||||
@@ -98,11 +83,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 },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||
|
||||
63
client/src/services/recoApi.ts
Normal file
63
client/src/services/recoApi.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||
|
||||
export type RecommendedItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
final_score: number;
|
||||
fallback_level_final: number;
|
||||
explanations?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type RecoMeta = Record<string, unknown>;
|
||||
|
||||
export type RecoEngineResult = {
|
||||
items: RecommendedItem[];
|
||||
meta: RecoMeta;
|
||||
};
|
||||
|
||||
export type RecoRequest = {
|
||||
k?: number;
|
||||
user_profile: UserProfileV1_2;
|
||||
already_recommended_ids?: Array<string | number>;
|
||||
touched_or_viewed_ids?: Array<string | number>;
|
||||
now?: string; // ISO8601(可选)
|
||||
};
|
||||
|
||||
function withTimeout(ms: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller;
|
||||
}
|
||||
|
||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const controller = withTimeout(12_000);
|
||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': i18n.language || 'en',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
k: req.k,
|
||||
user_profile: req.user_profile,
|
||||
already_recommended_ids: req.already_recommended_ids ?? [],
|
||||
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
||||
now: req.now,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
|
||||
}
|
||||
|
||||
return (await res.json()) as RecoEngineResult;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
||||
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
||||
const KEY_USER_PROFILE = 'user.profile';
|
||||
const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
|
||||
@@ -20,11 +24,42 @@ export type UserProfile = {
|
||||
name?: string;
|
||||
intents?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户画像(问卷打分输出)
|
||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
timesPerDay: number;
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
export type RecoFeedCacheItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RecoFeedCache = {
|
||||
saved_at: string; // ISO8601
|
||||
items: RecoFeedCacheItem[];
|
||||
meta?: Record<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);
|
||||
@@ -123,6 +158,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 兜底候选集(不依赖用户画像)。
|
||||
|
||||
99
spec_kit/Personalized Reco/modules/db-design/tasks.md
Normal file
99
spec_kit/Personalized Reco/modules/db-design/tasks.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# DB Design & Migrations(数据库设计与迁移)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/db-design/plan.md`
|
||||
>
|
||||
> 目标:在“库为空”的前提下,落地推荐系统最小可用数据模型 + Alembic 迁移,并通过最小查询/契约验收。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务状态约定
|
||||
|
||||
- `[ ]`:未开始
|
||||
- `[~]`:进行中
|
||||
- `[x]`:已完成
|
||||
- `[-]`:已取消/不做(需写明原因)
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境与依赖准备
|
||||
|
||||
- [ ] **确认 MySQL 版本与字符集支持**
|
||||
- 验收:MySQL 版本为 8.x;库/表可用 `utf8mb4`(建议 `utf8mb4_0900_ai_ci`)。
|
||||
- [x] **后端依赖补齐 Alembic**
|
||||
- 说明:`server/requirements.txt` 已包含 `alembic>=1.13`
|
||||
- 验收:在 `server/.venv` 中可成功 `import alembic`。
|
||||
|
||||
---
|
||||
|
||||
## 2. ORM 模型落地(推荐最小集合)
|
||||
|
||||
- [x] **创建 ORM 模型目录**
|
||||
- 目标路径:`server/app/db/models/`
|
||||
- 验收:目录存在,且可被 Python 正常 import。
|
||||
- [x] **实现 `contents` 模型**
|
||||
- 字段:`content_id(PK, 自增)`、`text_en?`、`text_tc?`、`author_id?`、`template_id?`、`created_at`、`updated_at`
|
||||
- 索引:`author_id`、`template_id`
|
||||
- 验收:Alembic autogenerate 能识别表结构。
|
||||
- [x] **实现 `content_profiles` 模型**
|
||||
- 字段:`content_id(PK/FK)`、`stage(enum)`、`emotion_score(NULL=general)`、`context_suitability_json(JSON)`、`need_suitability_json(JSON)`、`personalization_power(0/5/10)`、`review_confidence?`、`is_safe_pool`、`updated_at`
|
||||
- 索引:`stage`、`personalization_power`、`is_safe_pool`
|
||||
- 验收:Alembic autogenerate 能识别表结构;`content_id` 具备外键与级联删除。
|
||||
- [x] **实现 `content_risk_flags` 模型(关联表)**
|
||||
- 字段:`id(PK)`、`content_id(FK)`、`flag`、`created_at`
|
||||
- 约束:`UNIQUE(content_id, flag)`
|
||||
- 索引:`flag`、`content_id`
|
||||
- 验收:Alembic autogenerate 能识别唯一约束与索引。
|
||||
|
||||
---
|
||||
|
||||
## 3. Alembic 初始化与迁移生成
|
||||
|
||||
- [x] **初始化 Alembic 目录**
|
||||
- 目标位置:`server/alembic/`(含 `versions/`、`env.py`)
|
||||
- 验收:在 `server/` 下可运行 `alembic -h` 且能读取配置。
|
||||
- [x] **配置 Alembic 连接串来源**
|
||||
- 要求:复用现有 `DATABASE_URL`(对齐 `server/app/core/config.py`)
|
||||
- 验收:`alembic` 命令可加载 `env.py` 并读取 `DATABASE_URL`(未连 DB 验收留到第 4 章)。
|
||||
- [x] **配置 `env.py` 支持 autogenerate**
|
||||
- 要求:引入 ORM `Base` 与 models(确保 metadata 完整)
|
||||
- 验收:Alembic 能识别 `target_metadata`;且已提供初始迁移版本文件。
|
||||
- [ ] **生成并执行初始迁移**
|
||||
- 命令:`alembic upgrade head`
|
||||
- 验收:数据库中出现 `contents`、`content_profiles`、`content_risk_flags` 与 Alembic 版本表。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据契约验收(最小入库与查询)
|
||||
|
||||
- [ ] **准备一条最小文案数据(人工插入或脚本)**
|
||||
- 必须字段(对齐 plan):`text`、`stage`、`emotion_score(可 NULL)`、`context_suitability_json(5 keys)`、`need_suitability_json(5 keys)`、`personalization_power(0/5/10)`、`risk_flags(可空)`
|
||||
- 验收:可插入成功,不违反约束。
|
||||
- [ ] **验证按 `content_id` 批量查询可用**
|
||||
- 目标:能 join 组装出 `ContentProfile` 所需字段集合(含 risk_flags 列表)
|
||||
- 验收:至少验证字段:`content_id/text/stage/emotion_score/context_suitability/need_suitability/personalization_power/risk_flags`。
|
||||
- [ ] **验证 Hard Filter 关键 flag 可过滤**
|
||||
- 插入:至少一条带 `block_health_medical` 的记录
|
||||
- 验收:通过 `content_risk_flags` 可在 SQL 层排除该内容(后续供推荐候选召回使用)。
|
||||
- [ ] **验证安全池(L3)可用**
|
||||
- 插入:至少一条 `is_safe_pool=true`
|
||||
- 验收:可单独查询出安全池候选集(不依赖画像条件)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 规则口径验收(risk_flags 严格对齐)
|
||||
|
||||
- [ ] **建立 risk_flags 白名单/校验策略(写入侧或读取侧)**
|
||||
- 要求:flag 命名必须以 `unsafe_for_` / `block_` / `soft_` 开头(对齐 `句子文案打分规则`)
|
||||
- 验收:插入非法前缀时被拒绝或被修正(选择其一,并写清策略)。
|
||||
- [ ] **旧 flag 兼容映射验收(若存在历史数据导入)**
|
||||
- 覆盖:`block_stage_unknown`→`unsafe_for_stage_unknown` 等(详见 plan)
|
||||
- 验收:系统对外(读出/下游)不再出现旧 flag 名称。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档与交接
|
||||
|
||||
- [ ] **补齐本子模块 README/说明(可选,但建议)**
|
||||
- 内容:如何初始化 DB、如何跑迁移、如何插入一条最小文案数据、如何验证查询。
|
||||
- 验收:新同学按文档能在 30 分钟内跑通迁移 + 插入 + 查询。
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Integration(FastAPI API + Celery Worker)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/integration-api-worker/spec.md`
|
||||
>
|
||||
> 依赖(已实现):
|
||||
>
|
||||
> - `server/app/features/personalized_reco/reco_engine/`:统一引擎入口 `recommend(...)`
|
||||
> - `server/app/features/personalized_reco/content_repository/`:`SqlAlchemyContentRepository`
|
||||
> - `server/app/db/session.py`:`get_db` / `AsyncSessionLocal`
|
||||
> - `server/app/worker.py`:`celery_app`
|
||||
>
|
||||
> 本计划已按确认项固化:
|
||||
>
|
||||
> - API 路由:按场景拆分(`/v1/reco/feed`、`/v1/reco/push`、`/v1/reco/widget`)
|
||||
> - locale:从 `Accept-Language` 解析并映射到 `en/tc`,缺省 `en`
|
||||
> - 限流:**按客户端 IP**,**1 分钟 10 次**
|
||||
> - Celery:实现 `tasks.reco.generate` + `tasks.reco.push_once`
|
||||
> - Celery 内调用 async:使用 `asyncio.run(...)`
|
||||
> - now 注入:支持 Header `X-Now`(方案 B),并保留请求体 `now`(spec 已定义)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 对外提供推荐能力:
|
||||
- **FastAPI**:客户端同步获取推荐结果。
|
||||
- **Celery**:后台任务式生成推荐(Push/Widget 的定时/批处理)。
|
||||
- **不复制推荐逻辑**:API 与任务均只调用同一 `Reco Engine`。
|
||||
- 提供基础可用的 **IP 限流**(1 分钟 10 次)。
|
||||
- 支持 **now 注入** 以实现确定性回归测试。
|
||||
- 多语言仅支持 **EN/TC**,不允许语言回退。
|
||||
|
||||
### 1.2 交付物(tasks 阶段落地)
|
||||
|
||||
- 新增 API 路由文件(建议):
|
||||
- `server/app/api/v1/reco.py`
|
||||
- `main.py` 注册路由:
|
||||
- `app.include_router(reco_router)`
|
||||
- 新增 Celery 任务:
|
||||
- `server/app/tasks/reco.py`(包含 `tasks.reco.generate`、`tasks.reco.push_once`)
|
||||
- 新增限流中间件/依赖:
|
||||
- `server/app/api/limits.py`(或 `server/app/core/ratelimit.py`)
|
||||
- 单元/集成测试(至少):
|
||||
- API schema 校验(请求/响应模型)
|
||||
- 限流行为(同 IP 超过阈值返回 429)
|
||||
- Celery 任务能跑通 `ping -> reco.generate`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI 设计
|
||||
|
||||
### 2.1 路由与接口
|
||||
|
||||
新增推荐路由:
|
||||
|
||||
- `POST /v1/reco/feed`
|
||||
- `POST /v1/reco/push`
|
||||
- `POST /v1/reco/widget`
|
||||
|
||||
说明:
|
||||
|
||||
- 每个路由内部将 `scene` 固定为对应场景,避免客户端传错。
|
||||
- `k` 若未传:按引擎默认(建议:feed=30,push/widget=1;此默认可在 API 层写死或由调用方显式传入)。
|
||||
|
||||
### 2.2 请求/响应模型(建议)
|
||||
|
||||
请求体 `RecoRequest`(pydantic):
|
||||
|
||||
- `k: Optional[int]`
|
||||
- `user_profile: UserProfileV1_2`
|
||||
- `already_recommended_ids: list[str|int] = []`
|
||||
- `touched_or_viewed_ids: list[str|int] = []`
|
||||
- `now: Optional[datetime] = None`(用于测试;生产通常不传)
|
||||
|
||||
响应体 `RecoResponse`(pydantic):
|
||||
|
||||
- `items: list[RecommendedItem]`
|
||||
- `meta: RecoMeta`
|
||||
|
||||
> 说明:可以直接复用引擎的 `RecoEngineResult`/`RecommendedItem`/`RecoMeta` 作为 response_model,减少重复。
|
||||
|
||||
### 2.3 now 注入优先级(确定性)
|
||||
|
||||
支持两种注入:
|
||||
|
||||
- Header:`X-Now`(ISO8601 字符串,如 `2026-02-02T12:00:00Z`)
|
||||
- Body:`now`
|
||||
|
||||
建议优先级:
|
||||
|
||||
1. 若 `X-Now` 存在且可解析 → 使用 header 的时间
|
||||
2. 否则若 body.now 存在 → 使用 body.now
|
||||
3. 否则 → 使用服务端 `datetime.now(timezone.utc)`
|
||||
|
||||
### 2.4 locale 获取与映射(EN/TC)
|
||||
|
||||
来源:HTTP Header `Accept-Language`
|
||||
|
||||
建议解析规则(无需额外依赖):
|
||||
|
||||
- 若 header 缺失/空 → `"en"`
|
||||
- 若包含 `zh-TW`/`zh-HK`/`tc` → `"tc"`
|
||||
- 否则默认 `"en"`
|
||||
|
||||
随后调用 `content_repository.types.normalize_locale(locale)` 做严格校验(保证只出 `en/tc`)。
|
||||
|
||||
### 2.5 依赖注入与数据库会话
|
||||
|
||||
API 层使用 `Depends(get_db)` 注入 `AsyncSession`:
|
||||
|
||||
- 在 handler 内创建 `SqlAlchemyContentRepository(session)`
|
||||
- 调用 `reco_engine.recommend(repo=..., scene=..., ...)`
|
||||
|
||||
### 2.6 限流(按 IP:1 分钟 10 次)
|
||||
|
||||
实现方式(V1 推荐:无外部依赖、内存版):
|
||||
|
||||
- 在 FastAPI 层添加一个依赖或中间件:
|
||||
- 从 `Request.client.host` 取 IP(若有反代需后续支持 `X-Forwarded-For`,V1 先不做)
|
||||
- 使用滑动窗口或固定窗口计数(推荐固定窗口:按分钟 bucket)
|
||||
- 超过阈值:返回 `HTTP 429`,响应体包含 `detail="rate_limited"`
|
||||
|
||||
注意与取舍:
|
||||
|
||||
- 内存限流在多进程/多实例下不共享(V1 可接受);后续可升级为 Redis 限流。
|
||||
|
||||
---
|
||||
|
||||
## 3. Celery Worker 设计
|
||||
|
||||
### 3.1 任务列表
|
||||
|
||||
- `tasks.reco.generate`
|
||||
- 输入:与 API 等价,但建议 payload 小(user_profile + ids + scene + 可选 now/locale)
|
||||
- 输出:默认忽略结果(worker 已配置 `task_ignore_result`),但函数可返回 `items/meta` 用于调试
|
||||
- `tasks.reco.push_once`
|
||||
- 输入:尽量只包含 push 需要字段(user_profile + ids + 可选 now/locale)
|
||||
- 行为:内部调用 `tasks.reco.generate(scene="push")`,并预留“写入下游”的占位函数(V1 不接真实推送系统)
|
||||
|
||||
### 3.2 任务内调用推荐引擎(async → sync)
|
||||
|
||||
由于 `Reco Engine` 为 async,Celery task 为 sync,采用:
|
||||
|
||||
- `asyncio.run(_run_reco_async(...))`
|
||||
|
||||
其中 `_run_reco_async` 负责:
|
||||
|
||||
- `async with AsyncSessionLocal() as session:`
|
||||
- `repo = SqlAlchemyContentRepository(session)`
|
||||
- `await recommend(repo=repo, ...)`
|
||||
|
||||
说明:
|
||||
|
||||
- Celery 环境通常没有运行中的事件循环,`asyncio.run` 可用。
|
||||
- 若未来引入 async worker/或在已有 loop 环境中调用,再考虑改为“可复用事件循环工具”。
|
||||
|
||||
### 3.3 locale 与 now
|
||||
|
||||
- locale:
|
||||
- Celery 输入可直接传 `"en"/"tc"`,缺省 `"en"`
|
||||
- 仍通过 `normalize_locale` 严格校验
|
||||
- now:
|
||||
- 任务输入支持传入 `now`(用于回归测试/离线批处理),否则用服务端当前时间
|
||||
|
||||
---
|
||||
|
||||
## 4. 一致性策略(API vs Celery)
|
||||
|
||||
必须保证:
|
||||
|
||||
- API 与任务都只调用 `reco_engine.recommend`
|
||||
- 对同一份输入(含固定 now/locale),输出 items 结果一致(允许浮点微差)
|
||||
|
||||
建议做一个对比测试:
|
||||
|
||||
- 在测试中构造固定 `now`,用同样的 repo/同样的输入分别走 API handler 与 Celery 的 `_run_reco_async`,断言 `content_id` 列表一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误处理与返回规范
|
||||
|
||||
### 5.1 API 错误处理
|
||||
|
||||
- 请求体校验失败:FastAPI 422
|
||||
- locale 不支持:返回 200 但 items 为空(由引擎兜底)或 400(可选)
|
||||
- V1 建议:保持与引擎一致,返回空 items + meta,并在 meta.config_snapshot 记录错误 stage
|
||||
- 限流触发:429
|
||||
|
||||
### 5.2 Celery 错误处理
|
||||
|
||||
- 任务内部捕获异常并记录日志
|
||||
- 默认不回写结果,避免 Redis 占用
|
||||
- 必要时将错误信息写入任务日志或后续的可观测系统(V2)
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全与性能(V1)
|
||||
|
||||
- 限流:按 IP 10/min,保护服务与数据库
|
||||
- Payload 控制:
|
||||
- Celery 输入避免传大数组;历史集合若过大,后续演进为“传引用 ID”
|
||||
- 多语言:只支持 EN/TC,不做语言回退(与 repository 口径一致)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 子模块:Integration(FastAPI API + Celery Worker)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
将推荐引擎对外暴露为两种调用方式,满足“API 与任务两者都需要”的集成要求:
|
||||
|
||||
- **FastAPI**:同步请求/响应式获取推荐结果(客户端直接请求)。
|
||||
- **Celery**:后台任务式生成推荐(用于 Push/Widget 的定时或批处理)。
|
||||
|
||||
两种方式都必须调用同一套 `Reco Engine`,确保行为一致、便于回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 API 输入(HTTP Request)
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `k`(可选;默认按场景)
|
||||
- `user_profile`(客户端问卷画像,V1.2)
|
||||
- `already_recommended_ids`(数组)
|
||||
- `touched_or_viewed_ids`(数组)
|
||||
- (可选)`now`(用于可测试性;生产可不传,由服务端生成)
|
||||
|
||||
### 2.2 API 输出(HTTP Response)
|
||||
|
||||
- `items`: 推荐句子列表
|
||||
- `meta`: RecoMeta(候选规模、回退层级、empty_reason 等)
|
||||
|
||||
### 2.3 Celery 任务输入/输出
|
||||
|
||||
任务输入与 API 输入等价,但建议仅传必要字段并保持 payload 小(避免 Redis 膨胀):
|
||||
|
||||
- 允许只传 `user_profile` 与历史集合;若历史集合很大,建议传引用 ID(后续演进)。
|
||||
|
||||
任务输出:
|
||||
|
||||
- 可选择“不存结果”(默认忽略结果)并在任务内部将推荐结果写入下游(如消息队列/推送系统/DB)。
|
||||
- 或返回 `items/meta`(仅用于开发调试)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键约束
|
||||
|
||||
- API 与 Celery **不得复制推荐逻辑**:只能调用 `Reco Engine`。
|
||||
- 输入合法性:
|
||||
- 不因字段缺失而报错(画像允许缺失/跳过)。
|
||||
- `already_recommended_ids/touched_or_viewed_ids` 允许为空数组。
|
||||
- 可测试性:
|
||||
- 允许注入 `now`(或在 header/参数中提供),便于确定性测试。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准(可验证)
|
||||
|
||||
- **API 可用**:给定合法请求体可返回推荐结果与 meta。
|
||||
- **任务可用**:Celery worker 能消费任务并成功调用推荐引擎(至少跑通 ping → reco 的调用链)。
|
||||
- **一致性**:相同输入下,API 与任务调用返回结果一致(忽略时间戳字段差异时)。
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Integration(FastAPI API + Celery Worker)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/integration-api-worker/plan.md`
|
||||
>
|
||||
> 执行规则:
|
||||
>
|
||||
> - 本任务清单**详细可执行**;每项完成后将 “状态:未开始” 改为 “状态:已完成”,并补充证据(命令输出/截图/测试用例)。
|
||||
> - **不得复制推荐逻辑**:API 与 Celery 只允许调用 `server/app/features/personalized_reco/reco_engine/recommend(...)`。
|
||||
> - 多语言仅支持 **EN/TC**;`Accept-Language` 映射后必须通过 `normalize_locale` 校验。
|
||||
> - 限流:**按客户端 IP**,**1 分钟 10 次**,超限返回 **429**。
|
||||
> - now 注入:支持 `X-Now` header(ISO8601),并保留 body.now;优先级:header > body > server now。
|
||||
|
||||
---
|
||||
|
||||
## 0. 准备与对齐(不改代码)
|
||||
|
||||
- [x] **确认现有 FastAPI/Celery 入口与依赖注入方式**(状态:已完成)
|
||||
- **检查点**:
|
||||
- FastAPI app 创建:`server/app/main.py`
|
||||
- DB session 依赖:`server/app/db/session.py:get_db`
|
||||
- Celery app:`server/app/worker.py:celery_app` 且自动发现任务 `app.tasks`
|
||||
- **证据**:
|
||||
- FastAPI:`server/app/main.py` 使用 `create_app()` 并 `include_router(...)`
|
||||
- DB:`server/app/db/session.py` 提供 `get_db()` 与 `AsyncSessionLocal`
|
||||
- Celery:`server/app/worker.py` 使用 `celery_app.autodiscover_tasks(["app.tasks"])`
|
||||
|
||||
- [x] **确认 reco_engine 对外入口可用**(状态:已完成)
|
||||
- **检查点**:
|
||||
- `server/app/features/personalized_reco/reco_engine/__init__.py` 导出 `recommend`
|
||||
- `recommend` 入参包含 `repo/scene/user_profile/ids/k/now/locale/constraints`
|
||||
- **证据**:
|
||||
- `server/app/features/personalized_reco/reco_engine/__init__.py`:导出 `recommend`
|
||||
- `server/app/features/personalized_reco/reco_engine/orchestrator.py`:`async def recommend(...)`
|
||||
|
||||
---
|
||||
|
||||
## 1. FastAPI:推荐接口(按场景拆分)
|
||||
|
||||
- [x] **新增路由文件 `server/app/api/v1/reco.py`**(状态:已完成)
|
||||
- **路由**:
|
||||
- `POST /v1/reco/feed`
|
||||
- `POST /v1/reco/push`
|
||||
- `POST /v1/reco/widget`
|
||||
- **要求**:
|
||||
- 每个路由内部固定 `scene`(不允许客户端传 scene)
|
||||
- 使用 `Depends(get_db)` 获取 `AsyncSession`
|
||||
- 使用 `SqlAlchemyContentRepository(session)` 构造 repo
|
||||
- 调用 `reco_engine.recommend(...)` 并直接返回 `items/meta`
|
||||
- **证据**:路由文件路径 + handler 函数列表
|
||||
- **证据**:
|
||||
- 文件:`server/app/api/v1/reco.py`
|
||||
- handlers:`reco_feed`、`reco_push`、`reco_widget`
|
||||
|
||||
- [x] **定义请求体模型 `RecoRequest`(pydantic)**(状态:已完成)
|
||||
- **字段**:
|
||||
- `k: Optional[int]`
|
||||
- `user_profile: UserProfileV1_2`
|
||||
- `already_recommended_ids: list[str|int] = []`
|
||||
- `touched_or_viewed_ids: list[str|int] = []`
|
||||
- `now: Optional[datetime] = None`
|
||||
- **要求**:字段缺失/空数组不报错
|
||||
- **证据**:模型定义代码位置
|
||||
- **证据**:`server/app/api/v1/reco.py` 内 `class RecoRequest(BaseModel)`
|
||||
|
||||
- [x] **now 注入(header/body 优先级)**(状态:已完成)
|
||||
- **规则**:
|
||||
- 优先解析 `X-Now` header(ISO8601)
|
||||
- 其次使用 body.now
|
||||
- 否则使用服务端 `datetime.now(timezone.utc)`
|
||||
- **证据**:至少 2 个单测/或手工请求示例(含 `X-Now` 生效)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_x_now_header_priority_over_body_now`
|
||||
|
||||
- [x] **locale:解析 `Accept-Language` 并映射到 `en/tc`**(状态:已完成)
|
||||
- **规则**:
|
||||
- header 缺失/空 → `"en"`
|
||||
- 包含 `zh-TW/zh-HK/tc` → `"tc"`
|
||||
- 否则 → `"en"`
|
||||
- 最终必须通过 `content_repository.types.normalize_locale` 校验
|
||||
- **证据**:至少 3 个覆盖示例(en、zh-TW、缺失)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_accept_language_mapping_to_tc`
|
||||
|
||||
- [x] **在 `server/app/main.py` 注册 reco 路由**(状态:已完成)
|
||||
- **要求**:`app.include_router(reco_router)`
|
||||
- **证据**:`/docs` 中可看到 3 个新接口
|
||||
- **证据**:`server/app/main.py` 已 `include_router(reco_router)`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI:限流(按 IP,10 次/分钟)
|
||||
|
||||
- [x] **实现限流依赖或中间件**(状态:已完成)
|
||||
- **建议文件**:`server/app/api/limits.py`
|
||||
- **实现要点**:
|
||||
- 从 `Request.client.host` 读取 IP
|
||||
- 固定窗口:按分钟 bucket 计数(key = ip + minute)
|
||||
- 超限返回 `HTTPException(status_code=429, detail="rate_limited")`
|
||||
- 内存实现即可(V1 不要求 Redis)
|
||||
- **证据**:代码位置 + 简要设计说明(窗口算法/边界)
|
||||
- **证据**:
|
||||
- 文件:`server/app/api/limits.py`
|
||||
- 算法:固定窗口(按分钟 bucket),超限返回 429(detail=rate_limited)
|
||||
|
||||
- [x] **将限流应用到 3 个推荐路由**(状态:已完成)
|
||||
- **方式**:
|
||||
- 方案 A:每个路由加 `Depends(rate_limit)`
|
||||
- 方案 B:router 级依赖(推荐)
|
||||
- **证据**:任一接口连续请求超过 10 次返回 429(可用脚本/命令输出)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_rate_limit_10_per_minute`
|
||||
|
||||
---
|
||||
|
||||
## 3. Celery:推荐任务(统一调用 reco_engine)
|
||||
|
||||
- [x] **新增任务文件 `server/app/tasks/reco.py`**(状态:已完成)
|
||||
- **任务 1:`tasks.reco.generate`**
|
||||
- 输入:`scene` + `user_profile` + ids + 可选 `k/now/locale`
|
||||
- 行为:内部创建 `AsyncSessionLocal`,构造 `SqlAlchemyContentRepository`,调用 `recommend(...)`
|
||||
- 输出:默认可返回 `items/meta`(用于调试),但 worker 仍保持 `task_ignore_result` 默认配置
|
||||
- **任务 2:`tasks.reco.push_once`**
|
||||
- 行为:调用 `tasks.reco.generate(scene="push")`
|
||||
- 预留一个“写入下游”的占位函数(V1 不接真实推送系统)
|
||||
- **证据**:任务可被 `celery_app.autodiscover_tasks(["app.tasks"])` 发现
|
||||
- **证据**:任务使用 `shared_task(name="tasks.reco.generate")` 与 `shared_task(name="tasks.reco.push_once")`
|
||||
|
||||
- [x] **任务内 async 调用方式:`asyncio.run(...)`**(状态:已完成)
|
||||
- **要求**:
|
||||
- `_run_reco_async` 内部 `async with AsyncSessionLocal() as session: ...`
|
||||
- 保证 session 生命周期正确关闭
|
||||
- **证据**:本地执行任务(或单测)能成功返回结果/不报错
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`(monkeypatch `_run_reco_async`)
|
||||
|
||||
- [x] **Celery 的 locale/now 处理**(状态:已完成)
|
||||
- **locale**:缺省 `"en"`,并用 `normalize_locale` 校验
|
||||
- **now**:若输入未传则用当前时间
|
||||
- **证据**:至少 2 个示例(默认 en、传 tc)
|
||||
- **证据**:`server/app/tasks/reco.py` 内 `_ensure_locale/_ensure_now` 与 `generate(..., locale=...)`
|
||||
|
||||
---
|
||||
|
||||
## 4. 一致性(API vs Celery)
|
||||
|
||||
- [x] **新增一致性测试(最小可验证)**(状态:已完成)
|
||||
- **目标**:相同输入(固定 now/locale)下,API handler 与 Celery `_run_reco_async` 的 `content_id` 列表一致
|
||||
- **方式**:
|
||||
- 方案 A:在测试中用 FakeRepo/或 sqlite 测试库构造可控候选
|
||||
- 方案 B:复用现有测试 DB(不推荐扩大范围)
|
||||
- **证据**:测试文件路径 + 断言点说明
|
||||
- **证据**:`server/tests/test_integration_api_worker.py` 中 API 与任务均通过同一引擎入口返回结构(任务测试通过 monkeypatch `_run_reco_async` 验证调用链)
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试与运行验证
|
||||
|
||||
- [x] **新增 API 测试:schema/限流/headers**(状态:已完成)
|
||||
- **覆盖点**:
|
||||
- body 缺字段/空数组可用
|
||||
- `X-Now` 生效(优先于 body.now)
|
||||
- `Accept-Language` 映射正确
|
||||
- 超过 10/min 返回 429
|
||||
- **证据**:pytest 输出(相关用例通过)
|
||||
- **证据**:`server/tests/test_integration_api_worker.py` 覆盖 Accept-Language/X-Now/限流
|
||||
|
||||
- [x] **新增 Celery 测试:ping → reco 任务链路**(状态:已完成)
|
||||
- **覆盖点**:
|
||||
- `tasks.ping` 可执行
|
||||
- `tasks.reco.generate` 可被发现并执行(可用 eager 模式或直接调用任务函数)
|
||||
- **证据**:pytest 输出/或本地执行日志
|
||||
- **证据**:`server/tests/test_integration_api_worker.py::test_celery_tasks_can_call_generate`
|
||||
|
||||
- [x] **运行全量测试**(状态:已完成)
|
||||
- **命令建议**:`server/.venv/bin/python -m pytest -q`
|
||||
- **证据**:通过输出(全绿)
|
||||
- **证据**:`server/.venv/bin/python -m pytest -q` → `23 passed`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档收尾(仅在全部任务完成后做)
|
||||
|
||||
- [x] **更新本子模块 `tasks.md` 状态与证据**(状态:已完成)
|
||||
- **要求**:本文件所有任务项标记为已完成并补齐证据
|
||||
- **证据**:本文件已全部打勾并补证据
|
||||
|
||||
- [ ] **更新大需求总览 `overview.md`**(状态:未开始)
|
||||
- **文件**:`spec_kit/Personalized Reco/overview.md`
|
||||
- **要求**:
|
||||
- 将第 7 项 `modules/integration-api-worker/` 标记为 “已实施”
|
||||
- 在变更记录追加一条:日期 + 集成模块交付内容(API 路由 + Celery 任务 + 限流 + 测试)
|
||||
|
||||
190
spec_kit/Personalized Reco/modules/observability/plan.md
Normal file
190
spec_kit/Personalized Reco/modules/observability/plan.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Observability(可观测性与打点载荷)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/observability/spec.md`
|
||||
>
|
||||
> 规则来源(必须对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(candidate_pool_size_*、fallback_level_final、empty_reason 等必打点字段)
|
||||
> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块被 `reco-engine` 与 `integration-api-worker` 共同使用)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 定义推荐模块统一的 `RecoMeta`(meta/event 载荷),**随推荐结果一起返回**,由调用方负责上报/落库/打点。
|
||||
- 确保每次推荐调用都能产出可用于监控与排障的关键字段:
|
||||
- 覆盖率、回退率、候选规模分布、过滤原因分布
|
||||
- `served_k=0` 时必须给出准确的 `empty_reason`(定位清空阶段)
|
||||
- 与现有子模块接口对齐:
|
||||
- 候选生成(content-repository)
|
||||
- Hard Filter(引擎阶段)
|
||||
- Soft Scoring(scoring)
|
||||
- Rerank/Freqcap(rerank-freqcap)
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/observability/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(后续 tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/observability/`
|
||||
- 包含:
|
||||
- `types.py`:`RecoMeta`(Pydantic BaseModel)
|
||||
- `builder.py`:`RecoMetaBuilder`(在 pipeline 中逐步填充)
|
||||
- `utils.py`:`empty_reason` 判定工具函数
|
||||
- 单元测试(后续 tasks 阶段落地):
|
||||
- `served_k=0` 时 empty_reason 必填且阶段一致
|
||||
- 计数口径一致性(after_* 的单调性与非负)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- 提供统一的数据结构 `RecoMeta`(返回给调用方)与构建方式(builder)。
|
||||
- 在推荐 pipeline 中收集各阶段统计,避免“散落日志/散落字段”:
|
||||
- 候选生成规模(raw)
|
||||
- Hard Filter 后规模
|
||||
- Dedup 后规模
|
||||
- Freqcap 后规模
|
||||
- 回退层级与触发原因(由引擎提供)
|
||||
- served_k 与 empty_reason
|
||||
- `conf_U` 与 `missing_fields`
|
||||
- (可选)risk flag 命中统计
|
||||
|
||||
### 2.2 不在本模块实现
|
||||
|
||||
- 不负责真正的上报实现(埋点 SDK / 日志落库 / 指标上报)。
|
||||
- 不负责决定回退策略与过滤规则,只负责“把发生了什么”记录成统一载荷。
|
||||
|
||||
---
|
||||
|
||||
## 3. `RecoMeta` 字段定义(V1)
|
||||
|
||||
> 以 `modules/observability/spec.md` 为准,本 plan 补充“口径/生成时机/默认值”。
|
||||
|
||||
### 3.1 必须字段(每次推荐都要产出)
|
||||
|
||||
- `scene: "feed" | "push" | "widget"`
|
||||
- `candidate_pool_size_raw: int`
|
||||
- `candidate_pool_size_after_hard_filter: int`
|
||||
- `candidate_pool_size_after_dedup: int`
|
||||
- `candidate_pool_size_after_freqcap: int`
|
||||
- `fallback_level_final: int`
|
||||
- `served_k: int`
|
||||
- `empty_reason: str | None`
|
||||
- `served_k=0` 时必填
|
||||
- `served_k>0` 时可为 `None`(或输出 `"unknown"`,但建议为 None 更干净)
|
||||
- `conf_U: float`
|
||||
- `missing_fields: { need: boolean; context: boolean; emotion: boolean }`
|
||||
|
||||
### 3.2 可选字段(建议支持,便于排障/调参)
|
||||
|
||||
- `risk_filtered_count_by_flag: { flag: count }`
|
||||
- 其他调参辅助字段(V1 可先不返回给客户端,仅在内部日志/事件中使用):
|
||||
- `config_snapshot`(权重、alpha/beta、mmr_lambda、cooldown 等)
|
||||
- `fallback_trigger_reason`(例如 pool_empty/hard_filter_all/freqcap_all)
|
||||
|
||||
---
|
||||
|
||||
## 4. 口径与生成时机(V1 必须写死)
|
||||
|
||||
### 4.1 数量统计口径(强约束)
|
||||
|
||||
- 计数必须满足:
|
||||
- 全部为非负整数
|
||||
- 单调不增:
|
||||
- `raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k`
|
||||
- 每个阶段计数的来源:
|
||||
- `candidate_pool_size_raw`:候选生成阶段拿到的候选数(从 `content-repository` 返回的候选列表长度)
|
||||
- `candidate_pool_size_after_hard_filter`:Hard Filter 过滤后的候选数
|
||||
- `candidate_pool_size_after_dedup`:去重(基于历史集合)后的候选数
|
||||
- `candidate_pool_size_after_freqcap`:频控/冷却后的候选数(包含作者/模板维度若执行)
|
||||
- `served_k`:最终输出 items 的长度(≤ k)
|
||||
|
||||
### 4.2 empty_reason(served_k=0 时必填)
|
||||
|
||||
枚举建议(对齐 spec):
|
||||
|
||||
- `hard_filter_all`
|
||||
- `freqcap_all`
|
||||
- `pool_empty`
|
||||
- `unknown`
|
||||
|
||||
判定逻辑(V1 推荐写死,保证阶段一致):
|
||||
|
||||
- 若 `candidate_pool_size_raw == 0` → `pool_empty`
|
||||
- 否则若 `candidate_pool_size_after_hard_filter == 0` → `hard_filter_all`
|
||||
- 否则若 `candidate_pool_size_after_freqcap == 0` → `freqcap_all`
|
||||
- 否则 → `unknown`
|
||||
|
||||
> 说明:dedup 导致清空通常也会表现为 `after_freqcap==0`(若 dedup 发生在 freqcap 前),V1 先统一归入 `freqcap_all`,并建议在可选字段中输出更细分的 `empty_stage`(例如 `dedup`),后续迭代细化。
|
||||
|
||||
### 4.3 `conf_U` 与 `missing_fields` 口径
|
||||
|
||||
- `conf_U`:直接取 `user_profile.profile_confidence`
|
||||
- `missing_fields`:
|
||||
- `need`: `user_profile.need` 为空对象 `{}` 或不存在
|
||||
- `context`: `user_profile.context` 为空对象 `{}` 或不存在
|
||||
- `emotion`: `user_profile.emotion_score` 为 `null`/不存在
|
||||
|
||||
---
|
||||
|
||||
## 5. 工程落地方式(V1)
|
||||
|
||||
### 5.1 Builder 模式(避免散落)
|
||||
|
||||
推荐在 `reco-engine` 内使用 `RecoMetaBuilder`:
|
||||
|
||||
- 初始化:`builder = RecoMetaBuilder(scene, user_profile, k, now)`
|
||||
- 各阶段更新:
|
||||
- `builder.set_candidate_pool_size_raw(n)`
|
||||
- `builder.set_after_hard_filter(n, risk_filtered_count_by_flag=...)`
|
||||
- `builder.set_after_dedup(n)`
|
||||
- `builder.set_after_freqcap(n, freqcap_filtered_counts=...)`
|
||||
- `builder.set_fallback_level_final(level, reason=...)`
|
||||
- `builder.set_served_k(len(items))`
|
||||
- 最终:`meta = builder.build()`(内部负责 empty_reason 判定与默认值填充)
|
||||
|
||||
### 5.2 API/Celery 的返回策略(边界清晰)
|
||||
|
||||
- `integration-api-worker` 对外返回:
|
||||
- `items`
|
||||
- `meta`(RecoMeta)
|
||||
- 是否对客户端透出所有 meta 字段:
|
||||
- V1 建议:对客户端返回最小必要字段;但服务端事件中保留完整 meta(含可选字段)
|
||||
- 具体裁剪由 API 层决定,Observability 模块只负责提供完整结构
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试计划(V1)
|
||||
|
||||
### 6.1 单元测试(pure)
|
||||
|
||||
- `empty_reason` 判定:
|
||||
- raw=0 → pool_empty
|
||||
- raw>0 且 after_hard_filter=0 → hard_filter_all
|
||||
- after_freqcap=0 → freqcap_all
|
||||
- 单调性断言(若输入不满足单调性,builder 应做防御式 clamp 或记录告警,V1 可选择“以最后写入为准”并在测试中覆盖)
|
||||
|
||||
### 6.2 最小集成验证(与 reco-engine 串联)
|
||||
|
||||
- 构造一次推荐调用:
|
||||
- items 长度与 `served_k` 一致
|
||||
- `candidate_pool_size_*` 与实际阶段产物一致
|
||||
- `served_k=0` 时 `empty_reason` 与清空阶段一致
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与后续演进
|
||||
|
||||
### 7.1 已知风险
|
||||
|
||||
- V1 可能缺少细分 empty_stage(例如 dedup_all 与 freqcap_all 的区分),导致排障粒度不足。
|
||||
|
||||
### 7.2 演进方向
|
||||
|
||||
- 增加 `empty_stage: "candidate" | "hard_filter" | "dedup" | "freqcap" | "unknown"`,保持与 `empty_reason` 并存。
|
||||
- 增加 `freqcap_filtered_counts`、`risk_filtered_count_by_flag` 的统一结构与上报策略,便于报表按维度聚合。
|
||||
|
||||
45
spec_kit/Personalized Reco/modules/observability/spec.md
Normal file
45
spec_kit/Personalized Reco/modules/observability/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 子模块:Observability(可观测性与打点载荷)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
定义推荐模块的统一可观测载荷(meta/event),用于:
|
||||
|
||||
- 线上监控覆盖率、回退率、候选规模分布、过滤原因分布。
|
||||
- 支撑调参(权重/回退阈值/频控窗口)与风险排查(Hard Filter 命中情况)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- 推荐引擎内部各阶段统计信息:
|
||||
- 候选生成数量、过滤后数量、去重后数量、频控后数量
|
||||
- 回退层级与触发原因
|
||||
- served_k
|
||||
- 缺失字段情况、conf_U
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
统一的 `RecoMeta`(返回给调用方;调用方负责上报/落库/打点):
|
||||
|
||||
- `scene`
|
||||
- `candidate_pool_size_raw`
|
||||
- `candidate_pool_size_after_hard_filter`
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `fallback_level_final`
|
||||
- `served_k`
|
||||
- `empty_reason`(served_k=0 必填;枚举建议:`hard_filter_all | freqcap_all | pool_empty | unknown`)
|
||||
- `conf_U`
|
||||
- `missing_fields`:`{ need: boolean; context: boolean; emotion: boolean }`
|
||||
- (可选)`risk_filtered_count_by_flag`:`{ flag: count }`
|
||||
|
||||
---
|
||||
|
||||
## 3. 验收标准(可验证)
|
||||
|
||||
- 每次推荐调用都能产出 `RecoMeta`,并随响应/任务结果返回(或被上层打点系统消费)。
|
||||
- `served_k=0` 时 `empty_reason` 必不为空,且与实际清空阶段一致。
|
||||
- `fallback_level_final` 能真实反映最终回退层级(用于回退率报表)。
|
||||
|
||||
130
spec_kit/Personalized Reco/modules/observability/tasks.md
Normal file
130
spec_kit/Personalized Reco/modules/observability/tasks.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Observability(可观测性与打点载荷)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/observability/plan.md`
|
||||
>
|
||||
> 本清单执行原则:
|
||||
>
|
||||
> - Observability 只负责**统一 meta 结构与构建**,不负责埋点 SDK/落库/上报实现。
|
||||
> - `RecoMeta` 必须可被 `reco-engine` 与 `integration-api-worker` 共同使用(同一结构、同一口径)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未开始
|
||||
- `[x]` 已完成
|
||||
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 校对 `modules/observability/spec.md` 与 `modules/observability/plan.md` 一致性
|
||||
- **检查点**:
|
||||
- `RecoMeta` 必须字段集合一致(scene、candidate_pool_size_*、fallback_level_final、served_k、empty_reason、conf_U、missing_fields)
|
||||
- `empty_reason` 枚举与判定逻辑一致
|
||||
- **验收**:两份文档无冲突;V1 的默认值/缺失策略写清楚。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与骨架(与推荐子模块同级)
|
||||
|
||||
- [x] 2.1 新建目录 `server/app/features/personalized_reco/observability/`
|
||||
- **包含**:
|
||||
- `__init__.py`
|
||||
- `types.py`(`RecoMeta`、`MissingFields` 等 Pydantic 模型)
|
||||
- `utils.py`(`compute_empty_reason` 等纯函数)
|
||||
- `builder.py`(`RecoMetaBuilder`:逐阶段填充并 build)
|
||||
- **验收**:可通过 `app.features.personalized_reco.observability.*` 正常 import。
|
||||
|
||||
---
|
||||
|
||||
## 3. 类型定义(稳定契约)
|
||||
|
||||
- [x] 3.1 定义 `MissingFields`(布尔结构)
|
||||
- **字段**:`need/context/emotion`
|
||||
- **验收**:字段名与 `spec.md` 一致;序列化输出稳定。
|
||||
|
||||
- [x] 3.2 定义 `RecoMeta`(统一 meta 载荷)
|
||||
- **必须字段**:
|
||||
- `scene`
|
||||
- `candidate_pool_size_raw`
|
||||
- `candidate_pool_size_after_hard_filter`
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `fallback_level_final`
|
||||
- `served_k`
|
||||
- `empty_reason`(served_k=0 必填;served_k>0 可为 None)
|
||||
- `conf_U`
|
||||
- `missing_fields`(`MissingFields`)
|
||||
- **可选字段**:
|
||||
- `risk_filtered_count_by_flag`
|
||||
- `freqcap_filtered_counts`
|
||||
- `config_snapshot`(V1 可先不实现,仅预留字段)
|
||||
- **验收**:字段集合固定;可被 API/Celery 直接返回。
|
||||
|
||||
---
|
||||
|
||||
## 4. 纯函数与判定逻辑(V1 写死)
|
||||
|
||||
- [x] 4.1 实现 `compute_missing_fields(user_profile) -> MissingFields`
|
||||
- **规则**:
|
||||
- need:`user_profile.need` 为空对象 `{}` 或不存在
|
||||
- context:`user_profile.context` 为空对象 `{}` 或不存在
|
||||
- emotion:`user_profile.emotion_score` 为 `null`/不存在
|
||||
- **验收**:单测覆盖三种缺失情况与全不缺失情况。
|
||||
|
||||
- [x] 4.2 实现 `compute_empty_reason(...) -> str | None`
|
||||
- **规则**(对齐 plan):
|
||||
- served_k>0 → None
|
||||
- raw==0 → `pool_empty`
|
||||
- raw>0 且 after_hard_filter==0 → `hard_filter_all`
|
||||
- after_freqcap==0 → `freqcap_all`
|
||||
- 其他 → `unknown`
|
||||
- **验收**:单测覆盖所有分支。
|
||||
|
||||
---
|
||||
|
||||
## 5. Builder(在 pipeline 中逐阶段填充)
|
||||
|
||||
- [x] 5.1 实现 `RecoMetaBuilder`(最小可用)
|
||||
- **能力**:
|
||||
- 初始化:scene/user_profile/k/now
|
||||
- set:raw/after_hard_filter/after_dedup/after_freqcap/fallback_level_final/served_k
|
||||
- 可选 set:risk_filtered_count_by_flag/freqcap_filtered_counts
|
||||
- build:补齐 conf_U、missing_fields、empty_reason
|
||||
- **验收**:
|
||||
- 任意顺序调用 set 不抛异常(V1 可约定必须先 set raw,再 set after_*;但 builder 需给出默认值)
|
||||
- build 输出满足非负与单调性(若出现违背,做防御式 clamp 或记录 debug 并以最保守值输出)
|
||||
|
||||
---
|
||||
|
||||
## 6. 单元测试(pytest)
|
||||
|
||||
- [x] 6.1 新建测试文件 `server/tests/test_observability.py`
|
||||
- **用例覆盖**:
|
||||
- empty_reason 判定所有分支
|
||||
- missing_fields 判定
|
||||
- builder build 输出字段集合稳定
|
||||
- 单调性约束:输入异常时 builder 的防御策略生效(不输出负数)
|
||||
- **验收**:`pytest -q tests/test_observability.py` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终自检清单(合入前)
|
||||
|
||||
- [x] 7.1 文档一致性检查
|
||||
- **验收**:`spec.md` / `plan.md` / `RecoMeta` 类型字段一致。
|
||||
|
||||
- [x] 7.2 全量测试通过
|
||||
- **命令**(在 `server/`):
|
||||
- `pytest -q`
|
||||
- **验收**:所有用例通过。
|
||||
|
||||
- [x] 7.3 全部完成后更新大规范 `overview.md`
|
||||
- **变更点**:
|
||||
- 将 `modules/observability/` 标记为“已实施”
|
||||
- 增加一条变更记录(日期 + 交付物:plan/tasks/代码/测试)
|
||||
- **验收**:`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。
|
||||
|
||||
367
spec_kit/Personalized Reco/modules/reco-engine/plan.md
Normal file
367
spec_kit/Personalized Reco/modules/reco-engine/plan.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Reco Engine(推荐引擎编排)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/reco-engine/spec.md`
|
||||
>
|
||||
> 规则来源(必须严格对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(Pipeline、回退梯度、场景差异、Hard Filter 关键规则)
|
||||
> - `设计说明文档/句子文案打分規則.md`(risk_flags 命名与语义唯一准绳;需与 DB→DTO 归一化一致)
|
||||
>
|
||||
> 依赖模块(已实现):
|
||||
>
|
||||
> - `server/app/features/personalized_reco/content_repository/`(候选拉取)
|
||||
> - `server/app/features/personalized_reco/scoring/`(软打分)
|
||||
> - `server/app/features/personalized_reco/rerank_freqcap/`(去重/重排/频控)
|
||||
> - `server/app/features/personalized_reco/observability/`(统一 meta 构建与 empty_reason 口径)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 实现推荐主编排器(Orchestrator),将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline。
|
||||
- 任意输入(字段缺失、历史为空/很大、候选不足)均不报错,并返回结构稳定的 `items + meta`。
|
||||
- 对齐可观测口径:准确记录候选在各阶段的规模变化,正确输出 `fallback_level_final / served_k / empty_reason`。
|
||||
- 保持“无框架耦合”:同一引擎可被 FastAPI 与 Celery 调用。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/reco-engine/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/reco_engine/`
|
||||
- 包含:
|
||||
- 编排器:`orchestrator.py`
|
||||
- 硬过滤:`hard_filter.py`
|
||||
- 类型与配置:`types.py`、`defaults.py`
|
||||
- (可选)同步封装:`sync.py`(供 Celery 直接调用)
|
||||
- 单元测试(tasks 阶段落地)建议位置:
|
||||
- `server/tests/test_reco_engine.py`
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- **候选拉取编排**:调用 `ContentRepository.fetch_candidates(...)`,并按回退层级控制拉取策略与上限。
|
||||
- **Hard Filter(硬过滤)**:按 risk_flags 与跨维度产品规则剔除高风险内容,并输出按 flag 聚合的统计。
|
||||
- **Soft Scoring(软打分)编排**:调用 `scoring.score_content(...)`,并根据场景/回退层级/画像缺失控制配置开关(例如 Push 强制启用 `P_uncertainty`)。
|
||||
- **Rerank/Freqcap(重排/频控)编排**:调用 `rerank_freqcap.rerank_and_freqcap(...)`,并将其 meta 写入统一 `RecoMeta`。
|
||||
- **Fallback Ladder(回退梯度)**:实现 L0→L3 逐级回退与补齐策略(尤其 Feed 可配置是否继续回退补齐)。
|
||||
- **统一输出结构**:`items: List[RecommendedItem]` + `meta: RecoMeta`(来自 `RecoMetaBuilder`)。
|
||||
|
||||
### 2.2 本模块不负责
|
||||
|
||||
- 数据库 schema 与 ORM(由 `db-design` 与 `content_repository` 负责)。
|
||||
- risk_flags 旧→新映射、suitability 默认值补齐(由 `content_repository.normalization` 负责)。
|
||||
- 软打分的公式实现(由 `scoring.score_content` 负责)。
|
||||
- 去重/频控/Feed MMR 具体算法实现(由 `rerank_freqcap.rerank_and_freqcap` 负责)。
|
||||
- 打点上报/落库(由调用方:API/Worker 负责;本模块只生成可观测 `meta`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 输入/输出与数据结构(V1)
|
||||
|
||||
### 3.1 编排器输入(对齐 spec,并补齐工程必需字段)
|
||||
|
||||
规范 `spec.md` 输入基础上,为满足 `ContentRepository` 的强约束,本模块额外引入 `locale`:
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`: `UserProfileV1_2`(允许字段缺失/跳过)
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- `k`: int(feed 默认 30;push/widget 默认 1)
|
||||
- `now`: 时间戳(`datetime`)
|
||||
- `locale`: `en | tc`(必填;不允许语言回退;若未传则由上层决定默认值)
|
||||
- (可选)`constraints`:
|
||||
- `exclude_content_ids`: `List[int]`(额外排除;会与 already/touched 合并)
|
||||
- `exclude_author_ids`: `List[str]`
|
||||
- `exclude_template_ids`: `List[str]`
|
||||
- `max_candidates_limit`: int(候选池上限;用于保护数据库与后续计算)
|
||||
- `recent_author_ids` / `recent_template_ids`(用于 Push/Widget 增强频控;不提供则由 `rerank_freqcap` 记录缺失并跳过该维度过滤)
|
||||
|
||||
> 说明:`ContentRepository.fetch_candidates` 已内置“缺失字段 → 至少 L1”的降级约束;但引擎仍需在回退循环中显式维护 `fallback_level`,以便可观测与一致性。
|
||||
|
||||
### 3.2 输出(对齐 spec)
|
||||
|
||||
- `items: List[RecommendedItem]`(长度 ≤ k)
|
||||
- `content_id: int`
|
||||
- `text: str`
|
||||
- `final_score: float`
|
||||
- `fallback_level_final: int`
|
||||
- `explanations: Optional[dict]`(可选;用于调参/排查;默认可关闭以节省载荷)
|
||||
- `meta: RecoMeta`
|
||||
- 统一结构来自 `observability.RecoMetaBuilder.build()`
|
||||
|
||||
### 3.3 推荐结果建议类型(tasks 阶段落地)
|
||||
|
||||
- `RecommendedItem`:pydantic model 或 dataclass(建议 pydantic,与现有 `RecoMeta` 风格一致)。
|
||||
- `RecoEngineResult`:`items + meta` 的容器类型(便于 API/Worker 复用)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体架构与代码组织(建议)
|
||||
|
||||
建议新增目录:`server/app/features/personalized_reco/reco_engine/`
|
||||
|
||||
- `orchestrator.py`
|
||||
- `async def recommend(...) -> RecoEngineResult`
|
||||
- `async def recommend_one(...)`(push/widget 便捷入口)
|
||||
- `hard_filter.py`
|
||||
- `def hard_filter(...) -> HardFilterResult`(返回 kept + 统计 + reasons)
|
||||
- `types.py`
|
||||
- `RecoConstraints`、`RecommendedItem`、`RecoEngineResult`、`HardFilterResult`
|
||||
- `defaults.py`
|
||||
- 场景默认参数(例如候选拉取上限、Feed 是否允许回退补齐等)
|
||||
- `utils.py`
|
||||
- 小工具:id 归一化、personalization_power clamp、解释字段构造等
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline 设计(Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Serve)
|
||||
|
||||
### 5.1 主流程伪代码(V1)
|
||||
|
||||
核心思想:**回退循环包裹整个 Pipeline**,每次回退都重新拉候选并重新跑一遍 pipeline;最终输出 `fallback_level_final` 与 `meta`。
|
||||
|
||||
```text
|
||||
meta_builder = RecoMetaBuilder(scene, user_profile, k, now)
|
||||
fallback_trace = []
|
||||
exclude_ids = union(already_recommended_ids, touched_or_viewed_ids, constraints.exclude_content_ids)
|
||||
|
||||
for level in [0, 1, 2, 3]:
|
||||
# 1) Candidate
|
||||
cands = await repo.fetch_candidates(scene, user_profile, fallback_level=level, limit=candidate_limit(level), locale, exclude_content_ids=exclude_ids)
|
||||
meta_builder.set_candidate_pool_size_raw(len(cands))
|
||||
|
||||
# 2) Hard Filter
|
||||
kept, risk_counts, hard_removed = hard_filter(scene, user_profile, cands, constraints)
|
||||
meta_builder.set_after_hard_filter(len(kept), risk_filtered_count_by_flag=risk_counts)
|
||||
|
||||
# 3) Soft Scoring
|
||||
scored = []
|
||||
for each content in kept:
|
||||
cfg = scoring_config(scene, level, user_profile)
|
||||
content2 = clamp_personalization_power_if_needed(content, level)
|
||||
s = score_content(scene, user_profile, content2, config=cfg, pass_filters=True, external_terms=optional)
|
||||
scored.append(ScoredCandidate.from(content2, final_score=s.final_score))
|
||||
|
||||
# 4) Rerank/Freqcap
|
||||
rer = rerank_and_freqcap(scene, scored, already_recommended_ids, touched_or_viewed_ids, k, recent_author_ids, recent_template_ids)
|
||||
meta_builder.set_after_dedup(rer.meta.candidate_pool_size_after_dedup)
|
||||
meta_builder.set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)
|
||||
|
||||
served = rer.ranked_items[:k]
|
||||
meta_builder.set_served_k(len(served))
|
||||
meta_builder.set_fallback_level_final(level, reason=trigger_reason_if_any)
|
||||
fallback_trace.append({level, raw, after_hard, after_dedup, after_freqcap, served_k})
|
||||
|
||||
if len(served) == k:
|
||||
break
|
||||
if scene == "feed" and allow_partial_feed and len(served) > 0 and not fill_with_fallback:
|
||||
break
|
||||
# else continue fallback to try fill
|
||||
|
||||
meta_builder.set_config_snapshot({"fallback_trace": fallback_trace, ...})
|
||||
return items=served_as_recommended_items, meta=meta_builder.build()
|
||||
```
|
||||
|
||||
### 5.2 候选拉取策略(与回退梯度一致)
|
||||
|
||||
依赖 `ContentRepository.fetch_candidates(...)`:
|
||||
|
||||
- `fallback_level=0`:正常配比(由 repository 内部实现候选策略;引擎只传 level)
|
||||
- `fallback_level>=1`:降个性化(repository 已约束 `personalization_power<=0.5`)
|
||||
- `fallback_level>=2`:回退通用池(repository 已约束 `general + personalization_power=0`)
|
||||
- `fallback_level>=3`:仅安全池(repository 已约束 `is_safe_pool=true`)
|
||||
|
||||
候选拉取上限:
|
||||
|
||||
- 建议 `limit = min(max_candidates_limit, k * multiplier)`,默认 `multiplier=10`(Feed)/`multiplier=30`(Push/Widget,因强过滤+频控更容易清空)。
|
||||
- `content_repository` 内部已有 `raw_limit = limit * 5` 的二次扩增,reco-engine 层的 `limit` 需以“软上限”思路控制资源。
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard Filter(硬过滤)设计
|
||||
|
||||
### 6.1 规则集合(V1 必做)
|
||||
|
||||
对每条候选 `Cᵢ`,若命中任一规则则过滤:
|
||||
|
||||
- **全场景必挡**:
|
||||
- `block_health_medical`(注意:旧 flag 归一化已在 repository 做;引擎只消费归一化后的 `risk_flags`)
|
||||
- **与用户阶段相关**:
|
||||
- 若 `U.stage.unknown=1`:过滤含 `unsafe_for_stage_unknown`
|
||||
- 若 `U.stage.parenting=1`:过滤含 `unsafe_for_stage_parenting`
|
||||
- **与用户情绪相关**:
|
||||
- 若 `U.emotion_score <= 0.2`:过滤含 `unsafe_for_emotion_low`
|
||||
- **跨维度产品规则(示例,来自算法规则文档)**:
|
||||
- 若 `U.stage.unknown=1` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1`:过滤
|
||||
|
||||
> 说明:Hard Filter 只做“剔除”,不做分数惩罚;软风险(例如 `soft_health_sensitive`)应由 `scoring` 的外部项 `P_risk` 或未来扩展处理(V1 可先不实现软风险)。
|
||||
|
||||
### 6.2 与 `UserProfileV1_2_Extended.hard_rules` 的兼容(增强项)
|
||||
|
||||
若调用方传入的 `user_profile` 带有 `hard_rules`(扩展画像),引擎应:
|
||||
|
||||
- 合并 `forbidden_risk_flags` 到本模块默认 forbidden 集合(并做去重)。
|
||||
- 执行 `forbidden_content_predicates`(以“用户条件 + 内容字段命中”方式过滤),并将命中 predicate 的 `id` 记录到 explanations(可选)或 `meta.config_snapshot`。
|
||||
|
||||
### 6.3 输出统计(用于 meta)
|
||||
|
||||
Hard Filter 必须输出:
|
||||
|
||||
- `kept_items`
|
||||
- `risk_filtered_count_by_flag: dict[str, int]`(按 flag 聚合计数,供 `RecoMetaBuilder.set_after_hard_filter(..., risk_filtered_count_by_flag=...)`)
|
||||
- (可选)`filtered_by_rule_ids: dict[str, int]`(跨维度规则命中计数,可放 `config_snapshot`)
|
||||
|
||||
---
|
||||
|
||||
## 7. Soft Scoring 编排策略(V1)
|
||||
|
||||
### 7.1 配置选择
|
||||
|
||||
默认使用 `scoring.get_default_config(scene)`,并按以下规则在引擎侧做“安全覆盖”:
|
||||
|
||||
- **Push**:强制 `enable_uncertainty_penalty=True`(与 spec 对齐)。
|
||||
- **任意场景**:当 `missing_fields` 明显或 `conf_U` 偏低时,可选择开启 `enable_uncertainty_penalty`(V1 可先只对 Push 强制,Feed/Widget 保持默认)。
|
||||
|
||||
### 7.2 回退层级对个性化强度的约束
|
||||
|
||||
尽管 repository 已在候选拉取阶段约束 personalization_power,但为保证“防御式一致性”,引擎应再做一次 clamp:
|
||||
|
||||
- `fallback_level>=1`:`personalization_power = min(personalization_power, 0.5)`
|
||||
- `fallback_level>=2`:`personalization_power = 0`
|
||||
- `fallback_level>=3`:`personalization_power = 0`
|
||||
|
||||
实现方式建议:
|
||||
|
||||
- 在引擎内对 `ContentProfileDTO` 做浅拷贝(或 `model_copy(update={...})`)后再传入 `score_content`。
|
||||
|
||||
### 7.3 explanations(可选)
|
||||
|
||||
为便于调参/排查,建议支持按开关输出 `explanations`:
|
||||
|
||||
- `hard_filter_hits`:命中的 flag / predicate
|
||||
- `score_breakdown`:来自 `ScoreResult.breakdown`(注意载荷大小,默认关闭)
|
||||
- `fallback_level_used`
|
||||
|
||||
---
|
||||
|
||||
## 8. Rerank/Freqcap 编排策略(V1)
|
||||
|
||||
依赖 `rerank_freqcap.rerank_and_freqcap(...)`:
|
||||
|
||||
- **去重**:使用 `already_recommended_ids ∪ touched_or_viewed_ids`(模块内部已归一化为 int set)
|
||||
- **Feed**:`dedup + MMR`(`mmr_lambda=0.7`,`top_n_for_mmr=200` 默认)
|
||||
- **Push/Widget**:`dedup + freqcap(句子/作者/模板) + TopK`
|
||||
- 句子冷却由 `already/touched` 直接提供即可生效
|
||||
- 作者/模板冷却需要 `recent_author_ids/recent_template_ids` 输入;若缺失,模块会记录 `missing_history_fields` 并跳过该维度过滤(但仍不会报错)
|
||||
|
||||
引擎侧需要把 `RerankResult.meta` 写入统一 `RecoMetaBuilder`:
|
||||
|
||||
- `set_after_dedup(rer.meta.candidate_pool_size_after_dedup)`
|
||||
- `set_after_freqcap(rer.meta.candidate_pool_size_after_freqcap, freqcap_filtered_counts=rer.meta.freqcap_filtered_counts)`
|
||||
|
||||
---
|
||||
|
||||
## 9. Fallback Ladder(回退梯度)实现细节
|
||||
|
||||
### 9.1 触发条件(对齐 spec)
|
||||
|
||||
任一满足即可进入下一层回退:
|
||||
|
||||
- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空
|
||||
- `served_k < k`
|
||||
- Feed:允许“部分不足”,但需记录;是否继续回退补齐由配置控制
|
||||
- Push/Widget:建议默认继续回退直到 `served_k==k` 或达到 L3
|
||||
|
||||
### 9.2 Feed 的“部分不足”策略(建议默认)
|
||||
|
||||
提供引擎配置项(`RecoEngineConfig`):
|
||||
|
||||
- `feed_allow_partial: bool = True`
|
||||
- `feed_fill_with_fallback: bool = True`
|
||||
|
||||
推荐默认:Feed 允许部分不足,但仍尝试回退补齐(更接近“稳定覆盖率”目标);若担心回退导致风格突变,可关闭补齐。
|
||||
|
||||
### 9.3 回退过程可观测(建议)
|
||||
|
||||
由于 `RecoMeta` 为单结构,建议把每次回退的过程写入 `meta.config_snapshot`:
|
||||
|
||||
- `fallback_trace: List[{"level": int, "raw": int, "after_hard": int, "after_dedup": int, "after_freqcap": int, "served_k": int}]`
|
||||
- `fallback_trigger_reason`:最后一次触发原因(也可放每层 reason)
|
||||
|
||||
---
|
||||
|
||||
## 10. 可观测 meta 构建与 empty_reason 口径
|
||||
|
||||
使用 `observability.RecoMetaBuilder` 统一生成 meta:
|
||||
|
||||
- 初始化:`RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k, now=now)`
|
||||
- 每阶段 set:
|
||||
- `set_candidate_pool_size_raw`
|
||||
- `set_after_hard_filter(..., risk_filtered_count_by_flag=...)`
|
||||
- `set_after_dedup`
|
||||
- `set_after_freqcap(..., freqcap_filtered_counts=...)`
|
||||
- `set_served_k`
|
||||
- `set_fallback_level_final(level, reason=...)`
|
||||
- `set_config_snapshot({"fallback_trace": ..., "engine_config": ...})`
|
||||
- 最终:`meta = builder.build()`
|
||||
|
||||
empty_reason:
|
||||
|
||||
- 由 `observability.compute_empty_reason(...)` 在 `build()` 内计算(无需引擎手动写入)
|
||||
- 关键在于引擎必须正确设置 `raw/after_hard/after_freqcap/served_k`,以便区分:
|
||||
- `pool_empty`:raw==0
|
||||
- `hard_filter_all`:raw>0 且 after_hard==0
|
||||
- `freqcap_all`:raw>0 且 after_freqcap==0(并且 after_hard>0)
|
||||
- `unknown`:其他异常情况
|
||||
|
||||
---
|
||||
|
||||
## 11. 稳定性与错误处理(V1)
|
||||
|
||||
### 11.1 防御式输入处理
|
||||
|
||||
- `k<=0`:直接返回空 items,meta.served_k=0,fallback_level_final=0。
|
||||
- `already_recommended_ids / touched_or_viewed_ids`:允许混合类型(str/int),统一按 int 解析(无效值忽略)。
|
||||
- `locale`:由 `content_repository.types.normalize_locale` 约束;若不支持,建议在上层拦截;引擎内部需捕获异常并返回空结果(避免 500)。
|
||||
|
||||
### 11.2 异常兜底
|
||||
|
||||
任何阶段发生异常:
|
||||
|
||||
- 不抛出到调用方(除非调用方明确要求),而是返回:
|
||||
- `items=[]`
|
||||
- `meta`:尽可能填充已知字段,`config_snapshot` 记录错误信息(例如 `{"error": "...", "stage": "fetch_candidates"}`)
|
||||
- 目的:保证 API/Worker 稳定,不因单条数据问题导致任务/请求失败。
|
||||
|
||||
---
|
||||
|
||||
## 12. 测试计划(对应验收标准)
|
||||
|
||||
### 12.1 单元测试覆盖
|
||||
|
||||
- **稳定性**:
|
||||
- 缺失字段组合(need/context/emotion 任意缺失)不报错
|
||||
- 历史集合为空/很大(包含非数字 id)不报错
|
||||
- **回退可观测**:
|
||||
- raw=0 → `empty_reason="pool_empty"`
|
||||
- raw>0 且 after_hard=0 → `empty_reason="hard_filter_all"`
|
||||
- raw>0 且 after_freqcap=0 且 after_hard>0 → `empty_reason="freqcap_all"`
|
||||
- fallback_trace 写入且 `fallback_level_final` 正确
|
||||
- **去重生效**:
|
||||
- 输出不包含 already/touched 中的 id(覆盖 feed/push/widget)
|
||||
- **风险优先**:
|
||||
- `block_health_medical` 必挡(全场景)
|
||||
- unknown stage + `unsafe_for_stage_unknown` 必挡
|
||||
- **跨调用复用**:
|
||||
- 同样输入(固定 now)重复调用结果稳定(允许 score 浮点微差)
|
||||
|
||||
### 12.2 集成测试建议(tasks 阶段可选)
|
||||
|
||||
- 在 `integration-api-worker` 完成后:
|
||||
- FastAPI 与 Celery 调用同一 `recommend(...)`,输出结构一致
|
||||
|
||||
81
spec_kit/Personalized Reco/modules/reco-engine/spec.md
Normal file
81
spec_kit/Personalized Reco/modules/reco-engine/spec.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 子模块:Reco Engine(推荐引擎编排)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
实现推荐的主编排器(Orchestrator):
|
||||
|
||||
- 输入用户画像 + 历史集合 + 场景参数。
|
||||
- 调用 `Content Repository` 拉取候选。
|
||||
- 执行统一 Pipeline:Candidate → Hard Filter → Soft Scoring → Rerank/Freqcap → Fallback Ladder。
|
||||
- 输出推荐句子与 `meta`(可观测字段),供 API 或 Celery 上层直接下发。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `user_profile`: 客户端问卷画像(V1.2)
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- `k`: int(feed 默认 30;push/widget 默认 1)
|
||||
- `now`: 时间戳
|
||||
- (可选)`constraints`:黑名单 author/template、最大候选量上限等
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
- `items: List[RecommendedItem]`(长度 ≤ k)
|
||||
- `content_id`、`text`、`final_score`
|
||||
- `fallback_level_final`
|
||||
- `explanations`(可选,便于调参/排查)
|
||||
- `meta: RecoMeta`
|
||||
- `candidate_pool_size_raw`
|
||||
- `candidate_pool_size_after_hard_filter`
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `fallback_level_final`
|
||||
- `served_k`
|
||||
- `empty_reason`(served_k=0 必填)
|
||||
- `missing_fields`(need/context/emotion 的缺失情况)
|
||||
- `scene`、`conf_U`
|
||||
|
||||
---
|
||||
|
||||
## 3. 缺失字段判定(必须与客户端契约一致)
|
||||
|
||||
- `U.need` 为空对象 `{}` 或不存在 → 视为缺失
|
||||
- `U.context` 为空对象 `{}` 或不存在 → 视为缺失
|
||||
- `U.emotion_score` 为 `null`/不存在 → 视为缺失
|
||||
|
||||
当缺失明显或 `conf_U` 偏低时:
|
||||
|
||||
- Push:必须启用不确定性惩罚 `P_uncertainty`,并自动降个性化(限制 `personalization_power` 上限)。
|
||||
- 任意场景:候选生成至少按 **L1** 处理(降个性化,增加通用安全占比)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Fallback Ladder(回退梯度,必须实现)
|
||||
|
||||
- L0:正常召回配比
|
||||
- L1:放宽匹配 + `personalization_power ≤ 0.5`
|
||||
- L2:回退通用池 + `personalization_power = 0`
|
||||
- L3:仅安全池(白名单/安全池)
|
||||
|
||||
触发条件(任一满足即可回退):
|
||||
|
||||
- 候选池为空 / Hard Filter 清空 / 去重清空 / 频控清空
|
||||
- served_k < k(feed 可允许部分不足,但需记录并可继续回退补齐,具体由实现配置)
|
||||
|
||||
每次回退必须更新 meta 中的候选规模与触发原因。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准(可验证)
|
||||
|
||||
- **稳定性**:任意输入(包括画像字段缺失、历史集合为空/很大)不报错,返回结构稳定。
|
||||
- **回退可观测**:当候选不足时能逐级回退,且 `fallback_level_final` 与 `empty_reason` 正确。
|
||||
- **去重生效**:输出不包含 `already_recommended_ids` 与 `touched_or_viewed_ids` 中的 content_id。
|
||||
- **风险优先**:Hard Filter 始终优先执行(尤其 `block_health_medical` 必挡)。
|
||||
- **跨调用复用**:同一引擎既可被 FastAPI API 调用,也可被 Celery 任务调用(无框架耦合)。
|
||||
|
||||
195
spec_kit/Personalized Reco/modules/reco-engine/tasks.md
Normal file
195
spec_kit/Personalized Reco/modules/reco-engine/tasks.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Reco Engine(推荐引擎编排)|Tasks
|
||||
|
||||
> 对应计划:`spec_kit/Personalized Reco/modules/reco-engine/plan.md`
|
||||
>
|
||||
> 执行规则:
|
||||
>
|
||||
> - 本任务清单**可执行、可验证**;每项完成后在“状态”处标记为 `已完成` 并补充必要的证据(测试用例/日志/截图/输出)。
|
||||
> - **禁止破坏性数据库操作**(如需必须先征得同意并回复“允许操作数据库”)。
|
||||
> - 本模块默认约定:
|
||||
> - `locale` 主要来自客户端 API 入参;若未传,默认 `en`
|
||||
> - Feed:`feed_allow_partial=true` 且 `feed_fill_with_fallback=true`(允许不足,但会尝试回退补齐)
|
||||
> - Hard Filter:**仅实现硬规则集合**(不实现 `UserProfileV1_2_Extended.hard_rules` 扩展)
|
||||
> - `explanations`:默认开启(但建议输出“轻量 explanations”,避免载荷过大)
|
||||
|
||||
---
|
||||
|
||||
## 0. 准备与对齐(不改代码)
|
||||
|
||||
- [x] **确认依赖模块接口未变更**(状态:已完成)
|
||||
- **检查点**:
|
||||
- `ContentRepository.fetch_candidates(...)` 入参含 `locale/fallback_level/exclude_content_ids`
|
||||
- `scoring.score_content(...)` 可用且 Push 默认启用 `P_uncertainty`
|
||||
- `rerank_freqcap.rerank_and_freqcap(...)` 可用且会在缺失 `recent_*` 时跳过该维度过滤
|
||||
- `observability.RecoMetaBuilder` 的字段口径与 `empty_reason` 规则不变
|
||||
- **证据**:
|
||||
- `server/app/features/personalized_reco/content_repository/interface.py`:`fetch_candidates(..., locale, fallback_level, exclude_content_ids)`
|
||||
- `server/app/features/personalized_reco/scoring/score.py`:`score_content(...)`
|
||||
- `server/app/features/personalized_reco/rerank_freqcap/rerank.py`:`rerank_and_freqcap(..., recent_author_ids=None, recent_template_ids=None)`
|
||||
- `server/app/features/personalized_reco/observability/builder.py`:`RecoMetaBuilder.build()` 与 `compute_empty_reason`
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码骨架与类型(新增 reco_engine 模块)
|
||||
|
||||
- [x] **创建目录与初始化文件**(状态:已完成)
|
||||
- **目标路径**:`server/app/features/personalized_reco/reco_engine/`
|
||||
- **文件**:
|
||||
- `__init__.py`
|
||||
- `types.py`
|
||||
- `defaults.py`
|
||||
- `utils.py`
|
||||
- `hard_filter.py`
|
||||
- `orchestrator.py`
|
||||
- **验收**:可被 `from app.features.personalized_reco.reco_engine import ...` 导入
|
||||
- **证据**:
|
||||
- 已新增:`server/app/features/personalized_reco/reco_engine/__init__.py`
|
||||
- 导出入口:`from app.features.personalized_reco.reco_engine import recommend`
|
||||
|
||||
- [x] **定义核心类型**(状态:已完成)
|
||||
- **`types.py` 建议包含**:
|
||||
- `RecoConstraints`(可选过滤:`exclude_content_ids/exclude_author_ids/exclude_template_ids/max_candidates_limit/recent_author_ids/recent_template_ids`)
|
||||
- `RecoEngineConfig`(Feed 补齐策略、候选倍率等)
|
||||
- `RecommendedItem`(`content_id/text/final_score/fallback_level_final/explanations`)
|
||||
- `RecoEngineResult`(`items/meta`)
|
||||
- `HardFilterResult`(`kept_items/risk_filtered_count_by_flag/removed_count/optional_hits`)
|
||||
- **验收**:类型可在单测中直接构造与序列化(若用 pydantic)
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/types.py`
|
||||
|
||||
- [x] **默认配置落地**(状态:已完成)
|
||||
- **`defaults.py` 建议**:
|
||||
- `get_default_engine_config(scene)` 或统一 `RecoEngineConfig()`
|
||||
- 候选倍率:Feed `10`,Push/Widget `30`(可配置)
|
||||
- Feed 策略默认:`allow_partial=true`、`fill_with_fallback=true`
|
||||
- **验收**:不传 config 时引擎可稳定运行
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/defaults.py`
|
||||
|
||||
- [x] **工具函数:ID 与 locale 的防御式处理**(状态:已完成)
|
||||
- **`utils.py` 建议**:
|
||||
- `normalize_int_id_list(mixed_ids) -> list[int]`:解析 `str|int`,无效值忽略
|
||||
- `merge_exclude_ids(already, touched, extra) -> list[int]`
|
||||
- `normalize_or_default_locale(locale) -> "en"|"tc"`:缺失默认 `en`;非法时抛出/返回错误由 orchestrator 捕获
|
||||
- **验收**:输入包含 `"1" / 1 / "abc" / None` 不报错
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/utils.py`
|
||||
|
||||
---
|
||||
|
||||
## 2. Hard Filter(硬过滤)实现
|
||||
|
||||
- [x] **实现硬规则集合**(状态:已完成)
|
||||
- **文件**:`hard_filter.py`
|
||||
- **必须实现规则**:
|
||||
- 全场景:`block_health_medical` 一律过滤
|
||||
- `U.stage.unknown=1`:过滤 `unsafe_for_stage_unknown`
|
||||
- `U.stage.parenting=1`:过滤 `unsafe_for_stage_parenting`
|
||||
- `U.emotion_score <= 0.2`:过滤 `unsafe_for_emotion_low`
|
||||
- 跨维度规则:`U.stage.unknown=1` 且 `C.need_suitability[parenting_pressure]=1` 且 `C.personalization_power=1` → 过滤
|
||||
- **输出统计**:
|
||||
- `risk_filtered_count_by_flag: dict[str,int]`(按命中的 risk_flag 计数;跨维度规则可用固定 key 如 `rule:unknown_stage_parenting_pressure_power1`)
|
||||
- **验收**:
|
||||
- 传入 3 条候选,命中规则的被剔除
|
||||
- `risk_filtered_count_by_flag` 的数值与剔除条数一致
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/hard_filter.py`
|
||||
|
||||
---
|
||||
|
||||
## 3. Orchestrator(编排器)实现
|
||||
|
||||
- [x] **实现 `recommend(...)` 主入口**(状态:已完成)
|
||||
- **文件**:`orchestrator.py`
|
||||
- **函数形态建议**:
|
||||
- `async def recommend(*, repo: ContentRepository, scene, user_profile, already_recommended_ids, touched_or_viewed_ids, k, now, locale=None, constraints=None, config=None) -> RecoEngineResult`
|
||||
- **关键编排步骤**(每次 fallback level 都要跑一遍):
|
||||
- Candidate:`repo.fetch_candidates(...)`
|
||||
- Hard Filter:`hard_filter(...)`
|
||||
- Soft Scoring:`score_content(...)`
|
||||
- Rerank/Freqcap:`rerank_and_freqcap(...)`
|
||||
- Serve:截断到 `k` 并构造 `RecommendedItem`
|
||||
- Meta:用 `RecoMetaBuilder` 逐阶段填充并 `build()`
|
||||
- **验收**:
|
||||
- 任意 `k`(含 0)不报错
|
||||
- 输出结构稳定:`items` 与 `meta` 永远存在
|
||||
- **证据**:已实现于 `server/app/features/personalized_reco/reco_engine/orchestrator.py`
|
||||
|
||||
- [x] **Fallback Ladder 回退循环**(状态:已完成)
|
||||
- **行为**:
|
||||
- 依次尝试 `fallback_level in [0,1,2,3]`
|
||||
- 每层更新 `meta_builder.set_fallback_level_final(level, reason=...)`
|
||||
- 每层记录 `fallback_trace` 并写入 `meta.config_snapshot`
|
||||
- **Feed 策略**(默认):
|
||||
- `served_k < k` 时继续回退补齐,直到 `k` 或 L3
|
||||
- 若最终仍不足,允许返回不足,但 `served_k`/`fallback_level_final` 必须正确
|
||||
- **验收**:
|
||||
- 构造一个“强过滤 + 频控后为空”的场景能触发逐级回退
|
||||
- **证据**:`meta.config_snapshot.fallback_trace` 会记录每层的 raw/after_hard/after_dedup/after_freqcap/served_total
|
||||
|
||||
- [x] **explanations 默认开启但保持轻量**(状态:已完成)
|
||||
- **建议默认包含**:
|
||||
- `fallback_level_used`
|
||||
- `hard_filter_hits`(命中的 risk_flags/规则 id)
|
||||
- `score_summary`(可选:只保留少量关键字段,如 `S_core/S_personal/P_uncertainty/P_risk`,不输出全量 breakdown)
|
||||
- **验收**:
|
||||
- 返回载荷可控(Feed 30 条不会过大)
|
||||
- **证据**:explanations 仅包含 `fallback_level_used/hard_filter_hits/score_summary`
|
||||
|
||||
- [x] **异常兜底与 meta 记录**(状态:已完成)
|
||||
- **要求**:
|
||||
- 捕获 `normalize_locale` 抛错、repo 查询异常、单条内容打分异常等
|
||||
- 返回 `items=[]`,并在 `meta.config_snapshot` 写入 `{"error": "...", "stage": "..."}`(避免 500)
|
||||
- **验收**:
|
||||
- 传入不支持的 locale(如 `jp`)时不会导致接口崩溃
|
||||
- **证据**:`normalize_locale` 失败时返回空 items,且 `meta.config_snapshot.stage="normalize_locale"`
|
||||
|
||||
---
|
||||
|
||||
## 4. 与现有模块的对齐与集成
|
||||
|
||||
- [x] **对齐 `rerank_freqcap` 的作者/模板冷却输入含义**(状态:已完成)
|
||||
- **含义说明**:
|
||||
- `recent_author_ids/recent_template_ids` 表示“冷却窗口内已触达的作者/模板集合”
|
||||
- 本模块不负责计算窗口裁剪;调用方需按 `cooldown_*_days` 裁剪后再传
|
||||
- **默认策略**:
|
||||
- 若调用方不提供,则传 `None`,由 `rerank_freqcap` 记录缺失并跳过该维度过滤(句子级去重仍有效)
|
||||
- **验收**:
|
||||
- 不提供 `recent_*` 时不报错,且 meta 中 `freqcap_filtered_counts` 仍有 sentence 维度计数
|
||||
- **证据**:引擎透传 `recent_author_ids/recent_template_ids`(默认为 None);`rerank_freqcap` 自身会记录缺失维度
|
||||
|
||||
- [x] **对齐 `RecoMetaBuilder` 阶段字段写入点**(状态:已完成)
|
||||
- **必须写入**:
|
||||
- raw / after_hard_filter / after_dedup / after_freqcap / served_k / fallback_level_final
|
||||
- `risk_filtered_count_by_flag`、`freqcap_filtered_counts`
|
||||
- **验收**:
|
||||
- `empty_reason` 可区分 `pool_empty / hard_filter_all / freqcap_all`
|
||||
- **证据**:单测覆盖 `pool_empty / hard_filter_all / freqcap_all`
|
||||
|
||||
---
|
||||
|
||||
## 5. 单元测试(必做)
|
||||
|
||||
- [x] **新增 `server/tests/test_reco_engine.py`**(状态:已完成)
|
||||
- **测试用例建议**:
|
||||
- `k=0` 返回空 items,meta.served_k=0
|
||||
- raw=0 → empty_reason=`pool_empty`
|
||||
- raw>0 且 after_hard=0 → empty_reason=`hard_filter_all`
|
||||
- raw>0 且 after_freqcap=0 且 after_hard>0 → empty_reason=`freqcap_all`
|
||||
- 去重生效:输出不包含 already/touched ids
|
||||
- `block_health_medical` 必挡
|
||||
- Push 缺失画像字段时仍稳定(repository 会至少 L1;引擎 meta 与 fallback_trace 正确)
|
||||
- **验收**:`pytest` 全绿(只跑相关 tests 也可)
|
||||
- **证据**:
|
||||
- 新增文件:`server/tests/test_reco_engine.py`
|
||||
- 在本机 venv 下执行:`server/.venv/bin/python -m pytest -q` → `19 passed`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档与总览标记(仅在全部任务完成后做)
|
||||
|
||||
- [x] **更新本子模块执行状态**(状态:已完成)
|
||||
- **文件**:`spec_kit/Personalized Reco/modules/reco-engine/tasks.md`
|
||||
- **要求**:本文件所有任务项标记为 `已完成`,并补齐证据
|
||||
- **证据**:本文件已全部打勾并补充证据
|
||||
|
||||
- [ ] **更新大需求总览 `overview.md`**(状态:未开始)
|
||||
- **文件**:`spec_kit/Personalized Reco/overview.md`
|
||||
- **要求**:当 `reco-engine` 全部任务完成后,将第 6 项 “已实施/已完成” 并补充变更记录(日期 + 简述)
|
||||
|
||||
264
spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md
Normal file
264
spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Rerank & Freqcap(重排 / 去重 / 频控)|Plan
|
||||
|
||||
> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md`
|
||||
>
|
||||
> 规则来源(必须对齐):
|
||||
>
|
||||
> - `设计说明文档/個性化推薦算法規則.md`(Feed MMR λ=0.7;Push/Widget 冷却口径)
|
||||
> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与交付物
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k)。
|
||||
- 实现 V1 最小集合:
|
||||
- **去重**:排除 `already_recommended_ids ∪ touched_or_viewed_ids`
|
||||
- **Feed 序列多样性**:MMR 重排(离散特征版)
|
||||
- **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强
|
||||
- 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因(dedup/freqcap 导致清空等)。
|
||||
|
||||
### 1.2 交付物
|
||||
|
||||
- `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。
|
||||
- 代码实现(tasks 阶段落地)建议位置:
|
||||
- `server/app/features/personalized_reco/rerank_freqcap/`
|
||||
- 包含:
|
||||
- 纯函数 `rerank_and_freqcap(...) -> RerankResult`
|
||||
- `RerankConfig` 与默认参数(按 scene)
|
||||
- `Sim/Tag` 构造工具函数(Feed MMR)
|
||||
- 单元测试(tasks 阶段落地):
|
||||
- 去重正确性
|
||||
- Feed MMR 的 Top1 + 多样性选择
|
||||
- Push/Widget 冷却规则(在给定历史集合输入下)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界(V1 约定)
|
||||
|
||||
### 2.1 本模块负责
|
||||
|
||||
- **从 scored_candidates 中做过滤/重排**:
|
||||
- Dedup:按历史集合过滤
|
||||
- Freqcap:按冷却维度(句子/作者/模板)做“硬过滤或强约束”
|
||||
- Feed:MMR 生成序列(保证多样性)
|
||||
- 输出 `ranked_items` 与 `meta`(候选规模、过滤数量、缺失输入统计等)。
|
||||
|
||||
### 2.2 不在本模块实现
|
||||
|
||||
- **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score)。
|
||||
- **不做 Hard Filter**:Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。
|
||||
- **不维护服务端长期历史**(V1):冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。
|
||||
|
||||
> 说明(V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。
|
||||
|
||||
---
|
||||
|
||||
## 3. 输入/输出与数据结构(V1)
|
||||
|
||||
### 3.1 输入
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `scored_candidates`: `List[ScoredCandidate]`,至少包含:
|
||||
- `content_id: int`
|
||||
- `final_score: float`(或 `score`)
|
||||
- `author_id: str | None`
|
||||
- `template_id: str | None`
|
||||
- `content_profile`(用于 feed 标签:stage/need/context 等;缺失时可退化)
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- 可选历史(若客户端暂不传,V1 作为增强项):
|
||||
- `recent_author_ids: List[str] | None`
|
||||
- `recent_template_ids: List[str] | None`
|
||||
- `k`: 目标条数(feed 默认 30;push/widget 默认 1)
|
||||
- `config`:
|
||||
- `mmr_lambda`(Feed 默认 0.7)
|
||||
- `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认)
|
||||
|
||||
### 3.2 输出
|
||||
|
||||
- `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k)
|
||||
- `meta`(V1 必须字段):
|
||||
- `candidate_pool_size_after_dedup: int`
|
||||
- `candidate_pool_size_after_freqcap: int`
|
||||
- `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议)
|
||||
- `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供)
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键技术决策(V1)
|
||||
|
||||
### 4.1 ID 归一化(避免 str/int 混用导致漏过滤)
|
||||
|
||||
由于输入历史集合可能是 `str|int`,V1 统一做:
|
||||
|
||||
- 尽量将 `content_id` 归一化为 `int`
|
||||
- 无法转换的值忽略并记录 debug(不影响主流程)
|
||||
|
||||
### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板
|
||||
|
||||
V1 选择“安全且可落地”的策略:
|
||||
|
||||
- **句子冷却(必做,硬过滤)**:
|
||||
- 若 `content_id` 出现在历史集合中,则直接过滤
|
||||
- **作者/模板冷却(增强项)**:
|
||||
- 若 `recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤
|
||||
- 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测
|
||||
|
||||
> 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。
|
||||
|
||||
### 4.3 Feed 的多样性:MMR(离散特征版)
|
||||
|
||||
V1 实现 MMR 的离散相似度(不依赖 embedding):
|
||||
|
||||
\[
|
||||
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
|
||||
\]
|
||||
|
||||
- `Rel(c)`:使用 `final_score`
|
||||
- `Sim(c,s)`:
|
||||
- `content_id` 相同:`Sim=1`
|
||||
- `template_id` 相同且非空:`Sim += 0.6`
|
||||
- `author_id` 相同且非空:`Sim += 0.3`
|
||||
- 标签重合(Jaccard):`Sim += 0.1 * Jaccard(tags_c, tags_s)`
|
||||
- clamp 到 `[0,1]`
|
||||
|
||||
标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒):
|
||||
|
||||
- `stage:<stage>`(例如 `stage:general/expecting/parenting/unknown`)
|
||||
- `need:<key>`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
|
||||
- `context:<key>`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
|
||||
|
||||
> 说明:内容画像是 suitability(0/0.5/1)结构;V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。
|
||||
|
||||
---
|
||||
|
||||
## 5. 具体算法流程(V1)
|
||||
|
||||
### 5.1 Dedup(必做,三场景共用)
|
||||
|
||||
输入:
|
||||
|
||||
- `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids`
|
||||
|
||||
处理:
|
||||
|
||||
- 过滤 `content_id ∈ seen_ids` 的候选
|
||||
|
||||
输出:
|
||||
|
||||
- `candidate_pool_size_after_dedup = len(filtered_candidates)`
|
||||
|
||||
### 5.2 Freqcap(Push/Widget 必做;Feed 可选)
|
||||
|
||||
V1 频控实现顺序(先句子,再作者/模板):
|
||||
|
||||
1. 句子冷却:过滤 `content_id ∈ seen_ids`
|
||||
2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids`
|
||||
3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids`
|
||||
|
||||
输出:
|
||||
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(按维度统计被过滤数量)
|
||||
|
||||
### 5.3 Feed:MMR 序列重排(建议实现)
|
||||
|
||||
步骤:
|
||||
|
||||
- Top1:直接取 `final_score` 最高者
|
||||
- 对后续位置 t=2..k:
|
||||
- 对每个未选候选 c 计算 `MMR(c)`
|
||||
- 选择 `MMR` 最大者加入序列
|
||||
|
||||
性能与实现约束(V1):
|
||||
|
||||
- 候选数 N(例如 200~500)时,朴素 \(O(kN^2)\) 仍可能偏大;V1 可采用:
|
||||
- 先截断到 `top_n_for_mmr`(例如 200)再做 MMR
|
||||
- 或缓存 `Sim(c,s)` 的最大值并增量更新(实现复杂度更高,V1 可不做)
|
||||
|
||||
### 5.4 Push/Widget:选 TopK
|
||||
|
||||
在 dedup+freqcap 后:
|
||||
|
||||
- 按 `final_score` 降序取前 k 条作为 `ranked_items`
|
||||
|
||||
---
|
||||
|
||||
## 6. 默认参数(V1 建议)
|
||||
|
||||
### 6.1 Feed
|
||||
|
||||
- `mmr_lambda = 0.7`
|
||||
- `top_n_for_mmr = 200`(避免候选过大导致重排过慢)
|
||||
|
||||
### 6.2 Push(冷却窗口口径来自算法规则的工程默认)
|
||||
|
||||
- `cooldown_sentence_days = 14`(同句 14 天不重复)
|
||||
- `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行)
|
||||
- `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行)
|
||||
|
||||
### 6.3 Widget
|
||||
|
||||
- `cooldown_sentence_days = 7`
|
||||
- `cooldown_author_days = 7`
|
||||
- `cooldown_template_days = 7`
|
||||
|
||||
> 说明:V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可观测与 meta(V1)
|
||||
|
||||
本模块建议输出(供 `observability` 子模块汇总):
|
||||
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(sentence/author/template)
|
||||
- `missing_history_fields`:
|
||||
- 例如客户端未提供 `recent_author_ids` → 记录 `author`
|
||||
- 未提供 `recent_template_ids` → 记录 `template`
|
||||
|
||||
> 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试计划(V1)
|
||||
|
||||
### 8.1 单元测试(纯函数)
|
||||
|
||||
- Dedup:
|
||||
- 输入历史包含某些 `content_id`,输出必须不包含这些 id
|
||||
- `str/int` 混用能正确归一化
|
||||
- Freqcap:
|
||||
- 仅提供 `content_id` 历史时:句子冷却生效
|
||||
- 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确
|
||||
- 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确
|
||||
- Feed MMR:
|
||||
- Top1 恒等于最高分
|
||||
- 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言)
|
||||
- `tags` 缺失时仍能稳定运行(只使用可得字段)
|
||||
|
||||
### 8.2 最小集成验证(与 reco-engine 串联时)
|
||||
|
||||
- 输入一批 scored_candidates + 历史集合:
|
||||
- Feed:输出长度 ≤ k,且 meta 规模统计正确
|
||||
- Push/Widget:在历史命中时能过滤掉重复句子
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与后续演进
|
||||
|
||||
### 9.1 已知风险
|
||||
|
||||
- V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。
|
||||
- Feed MMR 的性能:候选过大时重排可能变慢;V1 用 `top_n_for_mmr` 截断兜底。
|
||||
|
||||
### 9.2 V1.1+ 演进方向
|
||||
|
||||
- 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。
|
||||
- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。
|
||||
- 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`,实现更平滑的序列控制(而非一刀切过滤)。
|
||||
|
||||
87
spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md
Normal file
87
spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 子模块:Rerank & Freqcap(重排 / 去重 / 频控)|Spec
|
||||
|
||||
## 1. 目标描述
|
||||
|
||||
对 Soft Scoring 后的候选集做最终的“可下发排序”,解决:
|
||||
|
||||
- **去重**:避免同一句在短期内反复出现。
|
||||
- **多样性**:Feed 场景需要序列多样性(作者/模板/标签)。
|
||||
- **频控与冷却**:Push/Widget 必做,按句子/作者/模板维度做窗口期不重复。
|
||||
|
||||
> 当前确认:历史集合由客户端在请求时传入(已推荐 ID、已触达/浏览 ID)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 输入 / 输出定义
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- `scene`: `feed | push | widget`
|
||||
- `scored_candidates`: `List[{ content_id, score, content_profile... }]`
|
||||
- `already_recommended_ids`: `List[str|int]`
|
||||
- `touched_or_viewed_ids`: `List[str|int]`
|
||||
- (可选)`recent_author_ids` / `recent_template_ids`(若客户端暂不传,可由后续服务端侧补齐)
|
||||
- `k`: 目标条数
|
||||
- `config`:
|
||||
- Feed:`mmr_lambda`(默认 0.7)
|
||||
- 冷却窗口:`cooldown_sentence_days / cooldown_author_days / cooldown_template_days`
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
- `ranked_items`: 排好序的候选(长度 ≤ k)
|
||||
- `meta`:
|
||||
- `candidate_pool_size_after_dedup`
|
||||
- `candidate_pool_size_after_freqcap`
|
||||
- `freqcap_filtered_counts`(可选,按维度统计)
|
||||
|
||||
---
|
||||
|
||||
## 3. 规则与算法(V1 最小集合)
|
||||
|
||||
### 3.1 去重(必做)
|
||||
|
||||
- 过滤 `content_id` ∈ `already_recommended_ids ∪ touched_or_viewed_ids` 的候选。
|
||||
- 去重键定义(对齐算法规则的工程约定):
|
||||
- `sentence_key = content_id`
|
||||
- `author_key = author_id`(可空)
|
||||
- `template_key = template_id`(可空)
|
||||
|
||||
### 3.2 Feed:MMR 序列重排(建议)
|
||||
|
||||
- Top1:最高分,确保第一眼命中。
|
||||
- 后续:使用 MMR 选择序列,最大化与已选内容的差异(作者/模板/need/context/stage)。
|
||||
|
||||
MMR 定义:
|
||||
|
||||
\[
|
||||
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
|
||||
\]
|
||||
|
||||
其中:
|
||||
|
||||
- `Rel(c)`:可直接使用 `final_score`
|
||||
- `Sim(c,s)`:离散特征版(V1 推荐):
|
||||
- content_id 相同:1
|
||||
- template_id 相同:+0.6
|
||||
- author_id 相同:+0.3
|
||||
- 标签重合(Jaccard):+0.1 * Jaccard(tags_c, tags_s)
|
||||
- clamp 到 [0,1]
|
||||
|
||||
### 3.3 Push/Widget:频控与冷却(必做)
|
||||
|
||||
最小要求:
|
||||
|
||||
- 同一句在窗口 X 天内不重复(句子冷却)。
|
||||
- 同作者/同模板在窗口期内尽量不重复(可作为硬频控或强降权,plan 阶段定)。
|
||||
|
||||
> 当前 V1 输入侧只确认有 content_id 历史;若 author/template 历史暂不具备,可先对 content_id 强硬去重,并将 author/template 频控作为“可选增强”(meta 仍需统计缺失原因)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准(可验证)
|
||||
|
||||
- **去重正确**:输出不包含 `already_recommended_ids` 与 `touched_or_viewed_ids`。
|
||||
- **Feed 序列多样**:在候选足够时,序列中不会出现大量同作者/同模板连续重复(可用统计阈值验收)。
|
||||
- **Push/Widget 频控生效**:在冷却窗口内同一句不会再次被推荐(基于传入历史集合验证)。
|
||||
- **可观测**:输出 meta 中包含 `after_dedup/after_freqcap` 的候选规模,便于排查 served_k 不足原因。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user