This commit is contained in:
1
2026-02-02 17:33:20 +08:00
113 changed files with 11785 additions and 280 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
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');

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildUserProfileFromQuestionnaire } from '../index';
import { mapOnboardingSelectionsToQuestionnaireAnswers } from '../onboardingMapping';
describe('Onboarding → UserProfileScoring 集成', () => {
it('完整作答Onboarding 选择能正确映射并生成画像', () => {
const selections = {
status: ['pregnant'],
emotion: ['calm'],
influence: ['work'],
support: ['balance'],
};
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
expect(answers).toEqual({
mom_stage: 'expecting',
emotion: 'calm',
context: 'work',
need: 'rest_balance',
});
const p = buildUserProfileFromQuestionnaire(answers, {
generatedAt: '2026-01-30T00:00:00Z',
now: '2026-01-30T00:00:00Z',
});
expect(p.stage).toEqual({ expecting: 1, parenting: 0, unknown: 0 });
expect(p.emotion_score).toBe(0.8);
expect(p.context).toEqual({ work: 1 });
expect(p.need).toEqual({ rest_balance: 1 });
expect(p.profile_answered).toEqual({ stage: true, emotion: true, context: true, need: true });
});
it('全部跳过仍能生成最小可计算画像unknown=1', () => {
const answers = mapOnboardingSelectionsToQuestionnaireAnswers({});
expect(answers).toEqual({ mom_stage: null, emotion: null, context: null, need: null });
const p = buildUserProfileFromQuestionnaire(answers, {
generatedAt: '2026-01-30T00:00:00Z',
now: '2026-01-30T00:00:00Z',
});
expect(p.stage).toEqual({ unknown: 1 });
expect(p.emotion_score).toBeNull();
expect(p.context).toEqual({});
expect(p.need).toEqual({});
expect(p.profile_answered).toEqual({ stage: false, emotion: false, context: false, need: false });
});
});

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import type { QuestionnaireAnswersV1_2 } from './types';
/**
* Onboarding UI 的选项 ID → 标准问卷枚举(可跳过)
*
* 说明:
* - UI 侧每题目前是单选,但数据结构是 string[];这里取第 1 个作为答案
* - 不存在错误处理:未知/非法值统一按“跳过”处理(返回 null
*/
export type OnboardingSelections = Record<string, string[] | undefined>;
export function mapOnboardingSelectionsToQuestionnaireAnswers(
selections: OnboardingSelections
): QuestionnaireAnswersV1_2 {
return {
mom_stage: mapMomStage(selections.status?.[0]),
emotion: mapEmotion(selections.emotion?.[0]),
context: mapContext(selections.influence?.[0]),
need: mapNeed(selections.support?.[0]),
};
}
function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_stage'] {
// 跳过null显式跳过
if (!raw) return null;
// UI id → 标准枚举
if (raw === 'pregnant') return 'expecting';
if (raw === 'has_kids') return 'parenting';
if (raw === 'no_fill') return 'unknown';
// 其他非法值:按跳过处理
return null;
}
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
if (!raw) return null;
// UI 当前选项happy/calm/stressed/low
if (raw === 'happy') return 'joyful';
if (raw === 'calm') return 'calm';
if (raw === 'stressed') return 'overwhelmed';
if (raw === 'low') return 'low';
return null;
}
function mapContext(raw: string | undefined): QuestionnaireAnswersV1_2['context'] {
if (!raw) return null;
// UI id 已与标准枚举一致family/work/relationship/friends/health
if (raw === 'family' || raw === 'work' || raw === 'relationship' || raw === 'friends' || raw === 'health') return raw;
return null;
}
function mapNeed(raw: string | undefined): QuestionnaireAnswersV1_2['need'] {
if (!raw) return null;
// UI id → 标准枚举
if (raw === 'emotional') return 'emotional_support';
if (raw === 'parenting') return 'parenting_pressure';
if (raw === 'self_worth') return 'self_worth';
if (raw === 'anxiety') return 'anxiety_relief';
if (raw === 'balance') return 'rest_balance';
return null;
}

View File

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

View File

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

View File

@@ -4,30 +4,21 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import 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,

View File

@@ -0,0 +1,63 @@
import i18n from 'i18next';
import { API_BASE_URL } from '@/src/constants/env';
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
export type RecommendedItem = {
content_id: number;
text: string;
final_score: number;
fallback_level_final: number;
explanations?: Record<string, unknown> | null;
};
export type RecoMeta = Record<string, unknown>;
export type RecoEngineResult = {
items: RecommendedItem[];
meta: RecoMeta;
};
export type RecoRequest = {
k?: number;
user_profile: UserProfileV1_2;
already_recommended_ids?: Array<string | number>;
touched_or_viewed_ids?: Array<string | number>;
now?: string; // ISO8601可选
};
function withTimeout(ms: number): AbortController {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller;
}
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
const controller = withTimeout(12_000);
const url = `${API_BASE_URL}/v1/reco/feed`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': i18n.language || 'en',
},
body: JSON.stringify({
k: req.k,
user_profile: req.user_profile,
already_recommended_ids: req.already_recommended_ids ?? [],
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
now: req.now,
}),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
}
return (await res.json()) as RecoEngineResult;
}

View File

@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import 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,