新功能:个性化推荐算法

This commit is contained in:
吕新雨
2026-02-02 16:47:37 +08:00
parent 936094211b
commit 6dc4e2b943
119 changed files with 7427 additions and 357 deletions

View File

@@ -20,9 +20,26 @@ 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);
/**
* 默认语言策略:

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

@@ -5,6 +5,8 @@ export type {
UserProfileV1_2_Extended,
} from './types';
export type { OnboardingSelections } from './onboardingMapping';
export {
buildUserProfileFromQuestionnaire,
computeProfileAnswered,
@@ -13,3 +15,5 @@ export {
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,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);
@@ -122,6 +157,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,