fix:并
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user