Compare commits
24 Commits
502a6ac500
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd0ac32de | |||
|
|
d98ec76dc0 | ||
| 3c35e14c3d | |||
|
|
c0deea9318 | ||
| 915e995ab7 | |||
|
|
0e42e6f2a9 | ||
| 39e4bcab6c | |||
|
|
69a1046ff4 | ||
| ceaf459d97 | |||
|
|
d9a5dbafd6 | ||
|
|
86e4853709 | ||
|
|
2adf2475fa | ||
| 9dbba04408 | |||
|
|
64b8352ad3 | ||
|
|
4b739dd194 | ||
|
|
240cdda68f | ||
|
|
ce48e54c03 | ||
|
|
d045237952 | ||
|
|
228fd7fd84 | ||
| 3587a24115 | |||
|
|
6dc4e2b943 | ||
|
|
936094211b | ||
|
|
be38d817d5 | ||
|
|
58d17fc39f |
@@ -1,4 +1,5 @@
|
|||||||
请开始完成编码
|
请开始完成编码
|
||||||
客户端请按照标准的RN架构目录写代码
|
客户端请按照标准的RN架构目录写代码
|
||||||
后端请按照标准的python FastAPI 架构目录写代码
|
后端请按照标准的python FastAPI 架构目录写代码
|
||||||
现在多语言仅支持 EN / TC
|
现在多语言仅支持 EN / TC
|
||||||
|
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
- 输入/输出定义
|
- 输入/输出定义
|
||||||
- 验收标准(可验证)
|
- 验收标准(可验证)
|
||||||
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
|
3. 拆分后输出一个 `modules/` 目录结构列表,并为每个模块生成对应 spec 内容。
|
||||||
4. 保留大 spec.md 的高层背景/总览到 overview 部分。
|
4. 保留大 spec.md 的高层背景/总览到 overview 部分,并标明各个模块的实现顺序。
|
||||||
5. 子模块之间按逻辑关系关联。
|
5. 子模块之间按逻辑关系关联。
|
||||||
6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。
|
6. 不生成 plan.md 或 tasks.md,仅拆出子模块 spec。
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
根据对应的plan.md 生成task.md
|
根据对应的plan.md 生成task.md
|
||||||
任务清单详细可执行
|
任务清单详细可执行
|
||||||
执行完要标记
|
执行完要标记
|
||||||
|
整个task.md执行完毕后需要在对应的overview.md标记
|
||||||
|
|||||||
1
.cursor/commands/myspec.test.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
使用测试工具完成集成测试,并给我一份简单的测试报告
|
||||||
@@ -28,4 +28,5 @@ modules/ 可嵌套 modules/,每层都独立规范。
|
|||||||
输出时根据这个结构生成内容时,请保持文件职责清晰。
|
输出时根据这个结构生成内容时,请保持文件职责清晰。
|
||||||
简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md
|
简短记录项目的该层每个spec的内容 ,每次编码完成后更新overview.md
|
||||||
可以通过nvm 切换node版本
|
可以通过nvm 切换node版本
|
||||||
|
在对数据库操作中,禁止执行破坏性操作,如果必须请让我同意,并回复:允许操作数据库
|
||||||
|
|
||||||
|
|||||||
5
.gitea/workflows/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
docker exec -it gitea-runner bash
|
||||||
|
# 然后在容器里安装 Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
node -v
|
||||||
116
.gitea/workflows/server-build.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
name: Build and Push Server Docker Image
|
||||||
|
|
||||||
|
# 手动触发 workflow:从哪个分支运行,就打包哪个分支的代码
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 1️⃣ Checkout 仓库代码
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# 需要能 push tag(请在仓库 Secrets 配置 RUNNER_TOKEN)
|
||||||
|
token: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
persist-credentials: true
|
||||||
|
|
||||||
|
# 2️⃣ 自动递增 tag 并推送回 Gitea 仓库(默认按 vX.Y.Z 的 patch +1)
|
||||||
|
- name: Auto bump tag and push to repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 配置提交信息(用于创建注释 tag)
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@local"
|
||||||
|
|
||||||
|
# 确保本地有最新 tags
|
||||||
|
git fetch --tags --force
|
||||||
|
|
||||||
|
# 取最新的 semver tag(vX.Y.Z),按版本号排序
|
||||||
|
LATEST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)"
|
||||||
|
echo "LATEST_TAG=${LATEST_TAG}"
|
||||||
|
|
||||||
|
if [[ -z "${LATEST_TAG}" ]]; then
|
||||||
|
NEXT_TAG="v1.0.0"
|
||||||
|
else
|
||||||
|
if [[ "${LATEST_TAG}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||||
|
MAJOR="${BASH_REMATCH[1]}"
|
||||||
|
MINOR="${BASH_REMATCH[2]}"
|
||||||
|
PATCH="${BASH_REMATCH[3]}"
|
||||||
|
NEXT_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
else
|
||||||
|
# 如果最新 tag 不符合 vX.Y.Z,回退到 v1.0.0,避免误解析
|
||||||
|
NEXT_TAG="v1.0.0"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "NEXT_TAG=${NEXT_TAG}"
|
||||||
|
|
||||||
|
# 如果 tag 已存在则直接复用(避免重复运行失败)
|
||||||
|
if git rev-parse -q --verify "refs/tags/${NEXT_TAG}" >/dev/null; then
|
||||||
|
echo "Tag ${NEXT_TAG} 已存在,跳过创建。"
|
||||||
|
else
|
||||||
|
git tag -a "${NEXT_TAG}" -m "Release ${NEXT_TAG}"
|
||||||
|
git push origin "${NEXT_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 输出给后续步骤使用
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
# 兼容部分 Gitea Runner 环境变量命名
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3️⃣ 设置镜像仓库与镜像名称(自建 Registry / Docker Hub 都可)
|
||||||
|
- name: Set image variables
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# 自建镜像仓库地址(例如:docker.damer.fun 或 registry.example.com)
|
||||||
|
# 如要推送 Docker Hub:可把 REGISTRY 留空并把 IMAGE_REPO 设置为 yourname/xxx
|
||||||
|
REGISTRY="${DOCKER_REGISTRY:-docker.damer.fun}"
|
||||||
|
# 镜像仓库路径(不含 registry)
|
||||||
|
IMAGE_REPO="${DOCKER_IMAGE_REPO:-damer/mindfulness-server}"
|
||||||
|
|
||||||
|
if [[ -n "$REGISTRY" ]]; then
|
||||||
|
IMAGE_NAME="$REGISTRY/$IMAGE_REPO"
|
||||||
|
else
|
||||||
|
IMAGE_NAME="$IMAGE_REPO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "REGISTRY=$REGISTRY" >> "$GITHUB_ENV"
|
||||||
|
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITHUB_ENV"
|
||||||
|
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "REGISTRY=$REGISTRY" >> "$GITEA_ENV"
|
||||||
|
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITEA_ENV"
|
||||||
|
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4️⃣ 登录镜像仓库(自建 Registry)
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.damer.fun
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
# 5️⃣ 构建 Docker 镜像(使用 server/ 作为构建上下文)
|
||||||
|
- name: Build Docker Image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker build -f server/Dockerfile -t "$IMAGE_NAME:$IMAGE_TAG" server
|
||||||
|
|
||||||
|
# 6️⃣ 推送 Docker 镜像到镜像仓库
|
||||||
|
- name: Push Docker Image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker push "$IMAGE_NAME:$IMAGE_TAG"
|
||||||
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# Python(运行产物)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
# Node / JS
|
# Node / JS
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.*
|
npm-debug.*
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Hey Mama",
|
"name": "client",
|
||||||
"slug": "hey-mama",
|
"slug": "client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "heymama",
|
"scheme": "client",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.heymama.app"
|
"bundleIdentifier": "com.damer.mindfulness"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
|
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigation, useFocusEffect } from 'expo-router';
|
import { useNavigation, useFocusEffect } from 'expo-router';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -14,12 +14,20 @@ import Animated, {
|
|||||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||||
import {
|
import {
|
||||||
addFavorite,
|
addFavorite,
|
||||||
|
getRecoFeedCache,
|
||||||
|
getRecoFeedHistory,
|
||||||
getThemeMode,
|
getThemeMode,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
|
getUserProfileScoring,
|
||||||
|
recordRecoFeedServed,
|
||||||
|
recordRecoFeedTouched,
|
||||||
|
setRecoFeedCache,
|
||||||
setReaction,
|
setReaction,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
|
type RecoFeedCacheItem,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
import ThemeModal from '@/components/home/ThemeModal';
|
import ThemeModal from '@/components/home/ThemeModal';
|
||||||
@@ -31,6 +39,40 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
|||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// 预定义风景图列表
|
||||||
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 预定义颜色列表
|
||||||
|
const THEME_COLORS = [
|
||||||
|
'#F7D9BF',
|
||||||
|
'#CBF2D8',
|
||||||
|
'#F5CDDE',
|
||||||
|
'#F2ECCB',
|
||||||
|
'#E2CBF2',
|
||||||
|
'#CBD9F2',
|
||||||
|
];
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -41,8 +83,11 @@ export default function HomeScreen() {
|
|||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [likeFilled, setLikeFilled] = 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
|
// 动画相关 Shared Values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
@@ -66,12 +111,75 @@ export default function HomeScreen() {
|
|||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
// 首次进入:先读缓存,再拉后端 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;
|
||||||
|
return THEME_COLORS[colorIndex];
|
||||||
|
}
|
||||||
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
|
}, [themeMode, index]);
|
||||||
|
|
||||||
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
|
const natureImageIndex = useMemo(() => {
|
||||||
|
return Math.floor(index / 10) % NATURE_IMAGES.length;
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerStyle: { backgroundColor },
|
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||||
|
headerTransparent: themeMode === 'scenery',
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
@@ -89,7 +197,7 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [backgroundColor, navigation, t]);
|
}, [backgroundColor, themeMode, navigation, t]);
|
||||||
|
|
||||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: translateY.value }],
|
transform: [{ translateY: translateY.value }],
|
||||||
@@ -105,6 +213,11 @@ export default function HomeScreen() {
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
||||||
|
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||||
|
if (typeof currentContentId === 'number') {
|
||||||
|
void recordRecoFeedTouched(currentContentId);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 当前文案向上移动并消失
|
// 1. 当前文案向上移动并消失
|
||||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
@@ -125,7 +238,7 @@ export default function HomeScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [busy, index, translateY, opacity]);
|
}, [busy, currentContentId, index, translateY, opacity]);
|
||||||
|
|
||||||
const lastTapRef = useRef<number>(0);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -176,10 +289,11 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
// 2. 保存到收藏夹,包含当前背景信息
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
await addFavorite({
|
await addFavorite({
|
||||||
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
id: item.id,
|
id: item.id,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
background: backgroundColor, // 目前存储的是颜色值
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 爱心缩放动画
|
// 3. 爱心缩放动画
|
||||||
@@ -202,8 +316,15 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||||
<Animated.View style={[styles.card, textAnimatedStyle]}>
|
{themeMode === 'scenery' && (
|
||||||
<Text style={styles.text}>{item.text}</Text>
|
<ImageBackground
|
||||||
|
source={currentNatureImage}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||||
|
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
@@ -216,9 +337,13 @@ export default function HomeScreen() {
|
|||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={35} height={36} />
|
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||||
) : (
|
) : (
|
||||||
<LikeIcon width={35} height={36} />
|
<LikeIcon
|
||||||
|
width={35}
|
||||||
|
height={36}
|
||||||
|
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -277,9 +402,15 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
paddingHorizontal: 30,
|
position: 'absolute',
|
||||||
alignItems: 'center',
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
zIndex: 5, // 降低层级,防止遮挡底部按钮
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
@@ -288,6 +419,16 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
sceneryCard: {
|
||||||
|
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
},
|
||||||
|
sceneryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: SCREEN_HEIGHT * 0.16,
|
bottom: SCREEN_HEIGHT * 0.16,
|
||||||
@@ -295,6 +436,7 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
zIndex: 20, // 提升层级,确保在最顶层可点击
|
||||||
},
|
},
|
||||||
reactionButton: {
|
reactionButton: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
|||||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
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 = [
|
const STEPS = [
|
||||||
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
||||||
@@ -71,6 +80,40 @@ export default function OnboardingScreen() {
|
|||||||
const { status } = await Notifications.requestPermissionsAsync();
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
const pushEnabled = status === 'granted';
|
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({
|
await setUserProfile({
|
||||||
name,
|
name,
|
||||||
intents: Object.values(selections).flat()
|
intents: Object.values(selections).flat()
|
||||||
@@ -97,20 +140,30 @@ export default function OnboardingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSkip = () => {
|
const onSkip = async () => {
|
||||||
|
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||||
|
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||||
|
await setUserProfileScoring(scoringProfile);
|
||||||
|
|
||||||
|
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||||
|
await setOnboardingCompleted(true);
|
||||||
router.replace('/(app)/home');
|
router.replace('/(app)/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||||
const handleToggleSelection = (id: string) => {
|
const handleToggleSelection = (id: string) => {
|
||||||
setSelections(prev => {
|
setSelections(prev => {
|
||||||
const currentIds = prev[currentStep.id] || [];
|
const currentIds = prev[currentStep.id] || [];
|
||||||
const nextIds = currentIds.includes(id)
|
const nextIds = currentIds.includes(id) ? [] : [id];
|
||||||
? currentIds.filter(i => i !== id)
|
|
||||||
: [...currentIds, id];
|
|
||||||
return { ...prev, [currentStep.id]: nextIds };
|
return { ...prev, [currentStep.id]: nextIds };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSkipStep = () => {
|
||||||
|
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingLayout
|
<OnboardingLayout
|
||||||
title={currentStep.title}
|
title={currentStep.title}
|
||||||
@@ -134,6 +187,7 @@ export default function OnboardingScreen() {
|
|||||||
selectedIds={selections[currentStep.id] || []}
|
selectedIds={selections[currentStep.id] || []}
|
||||||
onToggle={handleToggleSelection}
|
onToggle={handleToggleSelection}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
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 状态跳转
|
* 启动分发:根据 consent 和 onboarding 状态跳转
|
||||||
@@ -14,10 +13,6 @@ export default function Index() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
// 【完全重置】:清除本地存储的所有数据(收藏、设置、引导状态等)
|
|
||||||
await AsyncStorage.clear();
|
|
||||||
console.log('AsyncStorage has been cleared.');
|
|
||||||
|
|
||||||
// 1. 检查是否同意协议
|
// 1. 检查是否同意协议
|
||||||
const consentAccepted = await getConsentAccepted();
|
const consentAccepted = await getConsentAccepted();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -27,9 +22,17 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Onboarding
|
// 2. 检查 Onboarding 是否已完成
|
||||||
const completed = await getOnboardingCompleted();
|
const completed = await getOnboardingCompleted();
|
||||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
// 如果已经完成过流程,直接进 Home
|
||||||
|
router.replace('/(app)/home');
|
||||||
|
} else {
|
||||||
|
// 如果是首次进入(或未完成流程),进入 Onboarding
|
||||||
|
router.replace('/(onboarding)/onboarding');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -46,4 +49,3 @@ export default function Index() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
client/assets/theme/nature/1.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/10.png
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
client/assets/theme/nature/11.png
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
client/assets/theme/nature/12.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
client/assets/theme/nature/13.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
client/assets/theme/nature/14.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
client/assets/theme/nature/15.png
Normal file
|
After Width: | Height: | Size: 693 KiB |
BIN
client/assets/theme/nature/17.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
client/assets/theme/nature/18.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
client/assets/theme/nature/19.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
client/assets/theme/nature/2.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
client/assets/theme/nature/20.png
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
client/assets/theme/nature/22.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
client/assets/theme/nature/3.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
client/assets/theme/nature/4.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
client/assets/theme/nature/5.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
client/assets/theme/nature/6.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
client/assets/theme/nature/7.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/8.png
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
client/assets/theme/nature/9.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
@@ -50,6 +50,29 @@ type Props = {
|
|||||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||||||
type NavDirection = 'forward' | 'back';
|
type NavDirection = 'forward' | 'back';
|
||||||
|
|
||||||
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -260,11 +283,11 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
setFavorites(list);
|
setFavorites(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(id: string) {
|
async function handleRemove(favId: string) {
|
||||||
// 1. 调用存储层移除收藏
|
// 1. 调用存储层移除收藏
|
||||||
await removeFavorite(id);
|
await removeFavorite(favId);
|
||||||
// 2. 更新本地状态
|
// 2. 更新本地状态
|
||||||
setFavorites(prev => prev.filter(item => item.id !== id));
|
setFavorites(prev => prev.filter(item => item.favId !== favId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,7 +297,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={favorites}
|
data={favorites}
|
||||||
keyExtractor={(it) => it.id}
|
keyExtractor={(it) => it.favId}
|
||||||
contentContainerStyle={styles.favList}
|
contentContainerStyle={styles.favList}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@@ -290,11 +313,30 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
<View style={styles.favRight}>
|
<View style={styles.favRight}>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.favThumb,
|
styles.favThumb,
|
||||||
{ backgroundColor: item.background } // 动态同步 Home 页的背景
|
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||||||
]}>
|
]}>
|
||||||
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text>
|
{item.themeMode === 'scenery' ? (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
<Image
|
||||||
|
source={NATURE_IMAGES[parseInt(item.background)]}
|
||||||
|
style={{
|
||||||
|
width: width * 0.6,
|
||||||
|
height: 800, // 假设原图较高,设置一个较大的高度
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0, // 关键:将图片底部对齐容器底部
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text style={[
|
||||||
|
styles.favThumbText,
|
||||||
|
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
|
||||||
|
]} numberOfLines={4}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => handleRemove(item.id)}
|
onPress={() => handleRemove(item.favId)}
|
||||||
style={styles.favRemoveBtn}
|
style={styles.favRemoveBtn}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
>
|
>
|
||||||
@@ -765,6 +807,7 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(119, 47, 0, 0.05)',
|
borderColor: 'rgba(119, 47, 0, 0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
favThumbText: {
|
favThumbText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ interface SelectionStepProps {
|
|||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onNext: () => 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;
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,13 +49,17 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext }: Select
|
|||||||
|
|
||||||
{/* 底部按钮:距离底部 12% 高度 */}
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<TouchableOpacity
|
<View style={styles.footerRow}>
|
||||||
onPress={onNext}
|
{onSkip && (
|
||||||
disabled={!hasSelection}
|
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||||
activeOpacity={0.8}
|
<SerifText style={styles.skipText}>跳过</SerifText>
|
||||||
>
|
</TouchableOpacity>
|
||||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -99,5 +104,20 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: 'center',
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,5 +59,13 @@ target 'client' do
|
|||||||
:mac_catalyst_enabled => false,
|
:mac_catalyst_enabled => false,
|
||||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 生成并随归档产物携带 dSYM(用于崩溃符号化与 Upload Symbols Failed 修复)
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |build_config|
|
||||||
|
build_config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
|
||||||
|
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXConstants (18.0.13):
|
- EXConstants (18.0.13):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXJSONUtils (0.15.0)
|
|
||||||
- EXManifests (1.0.10):
|
|
||||||
- ExpoModulesCore
|
|
||||||
- EXNotifications (0.32.16):
|
- EXNotifications (0.32.16):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (54.0.32):
|
- Expo (54.0.32):
|
||||||
@@ -33,177 +30,6 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- expo-dev-client (6.0.20):
|
|
||||||
- EXManifests
|
|
||||||
- expo-dev-launcher
|
|
||||||
- expo-dev-menu
|
|
||||||
- expo-dev-menu-interface
|
|
||||||
- EXUpdatesInterface
|
|
||||||
- expo-dev-launcher (6.0.20):
|
|
||||||
- EXManifests
|
|
||||||
- expo-dev-launcher/Main (= 6.0.20)
|
|
||||||
- expo-dev-menu
|
|
||||||
- expo-dev-menu-interface
|
|
||||||
- ExpoModulesCore
|
|
||||||
- EXUpdatesInterface
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-jsinspector
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTAppDelegate
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactAppDependencyProvider
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- expo-dev-launcher/Main (6.0.20):
|
|
||||||
- EXManifests
|
|
||||||
- expo-dev-launcher/Unsafe
|
|
||||||
- expo-dev-menu
|
|
||||||
- expo-dev-menu-interface
|
|
||||||
- ExpoModulesCore
|
|
||||||
- EXUpdatesInterface
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-jsinspector
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTAppDelegate
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactAppDependencyProvider
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- expo-dev-launcher/Unsafe (6.0.20):
|
|
||||||
- EXManifests
|
|
||||||
- expo-dev-menu
|
|
||||||
- expo-dev-menu-interface
|
|
||||||
- ExpoModulesCore
|
|
||||||
- EXUpdatesInterface
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-jsinspector
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTAppDelegate
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactAppDependencyProvider
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- expo-dev-menu (7.0.18):
|
|
||||||
- expo-dev-menu/Main (= 7.0.18)
|
|
||||||
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- expo-dev-menu-interface (2.0.0)
|
|
||||||
- expo-dev-menu/Main (7.0.18):
|
|
||||||
- EXManifests
|
|
||||||
- expo-dev-menu-interface
|
|
||||||
- ExpoModulesCore
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-jsinspector
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- ExpoAsset (12.0.12):
|
- ExpoAsset (12.0.12):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFileSystem (19.0.21):
|
- ExpoFileSystem (19.0.21):
|
||||||
@@ -248,8 +74,6 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (15.0.10):
|
- ExpoWebBrowser (15.0.10):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXUpdatesInterface (2.0.0):
|
|
||||||
- ExpoModulesCore
|
|
||||||
- FBLazyVector (0.81.5)
|
- FBLazyVector (0.81.5)
|
||||||
- hermes-engine (0.81.5):
|
- hermes-engine (0.81.5):
|
||||||
- hermes-engine/Pre-built (= 0.81.5)
|
- hermes-engine/Pre-built (= 0.81.5)
|
||||||
@@ -1974,28 +1798,6 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNGestureHandler (2.30.0):
|
|
||||||
- hermes-engine
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React-Core
|
|
||||||
- React-Core-prebuilt
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-jsi
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-renderercss
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- ReactNativeDependencies
|
|
||||||
- Yoga
|
|
||||||
- RNReanimated (4.1.6):
|
- RNReanimated (4.1.6):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -2238,26 +2040,19 @@ PODS:
|
|||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
||||||
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
|
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
|
||||||
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
|
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)"
|
||||||
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)"
|
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)"
|
||||||
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios`)"
|
|
||||||
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo`)"
|
|
||||||
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
|
|
||||||
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
|
|
||||||
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
|
|
||||||
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
|
||||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios`)"
|
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)"
|
||||||
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
|
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
|
||||||
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios`)"
|
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)"
|
||||||
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
|
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
|
||||||
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
|
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
|
||||||
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
|
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
|
||||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
||||||
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
|
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
|
||||||
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)"
|
|
||||||
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
|
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
|
||||||
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
|
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
|
||||||
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
|
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
|
||||||
@@ -2294,7 +2089,7 @@ DEPENDENCIES:
|
|||||||
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)"
|
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)"
|
||||||
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||||
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
|
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
|
||||||
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context`)"
|
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)"
|
||||||
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)"
|
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)"
|
||||||
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)"
|
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)"
|
||||||
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)"
|
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)"
|
||||||
@@ -2326,12 +2121,11 @@ DEPENDENCIES:
|
|||||||
- ReactCodegen (from `build/generated/ios`)
|
- ReactCodegen (from `build/generated/ios`)
|
||||||
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||||
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
|
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
|
||||||
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage`)"
|
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)"
|
||||||
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler`)"
|
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)"
|
||||||
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated`)"
|
|
||||||
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
|
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
|
||||||
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
|
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
|
||||||
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets`)"
|
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)"
|
||||||
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)"
|
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)"
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
@@ -2339,22 +2133,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
||||||
EXConstants:
|
EXConstants:
|
||||||
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
|
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
|
||||||
EXJSONUtils:
|
|
||||||
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
|
|
||||||
EXManifests:
|
|
||||||
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
|
|
||||||
EXNotifications:
|
EXNotifications:
|
||||||
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+r_758952db70529f49bda448def1c13c49/node_modules/expo-notifications/ios"
|
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios"
|
||||||
Expo:
|
Expo:
|
||||||
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-nati_18ad48ba284ee86e6eb1cb0f939697b0/node_modules/expo"
|
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo"
|
||||||
expo-dev-client:
|
|
||||||
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
|
|
||||||
expo-dev-launcher:
|
|
||||||
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
|
|
||||||
expo-dev-menu:
|
|
||||||
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
|
|
||||||
expo-dev-menu-interface:
|
|
||||||
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
|
|
||||||
ExpoAsset:
|
ExpoAsset:
|
||||||
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
||||||
ExpoFileSystem:
|
ExpoFileSystem:
|
||||||
@@ -2362,11 +2144,11 @@ EXTERNAL SOURCES:
|
|||||||
ExpoFont:
|
ExpoFont:
|
||||||
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
|
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
|
||||||
ExpoHead:
|
ExpoHead:
|
||||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.1_bd9aa16746ed7110429f931eb008e6d2/node_modules/expo-router/ios"
|
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios"
|
||||||
ExpoKeepAwake:
|
ExpoKeepAwake:
|
||||||
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
||||||
ExpoLinearGradient:
|
ExpoLinearGradient:
|
||||||
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+_53aef72480df9baa4504f4743d9c64bb/node_modules/expo-linear-gradient/ios"
|
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios"
|
||||||
ExpoLinking:
|
ExpoLinking:
|
||||||
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios"
|
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios"
|
||||||
ExpoLocalization:
|
ExpoLocalization:
|
||||||
@@ -2377,8 +2159,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
||||||
ExpoWebBrowser:
|
ExpoWebBrowser:
|
||||||
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
|
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
|
||||||
EXUpdatesInterface:
|
|
||||||
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
|
|
||||||
FBLazyVector:
|
FBLazyVector:
|
||||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
|
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
|
||||||
hermes-engine:
|
hermes-engine:
|
||||||
@@ -2451,7 +2231,7 @@ EXTERNAL SOURCES:
|
|||||||
React-microtasksnativemodule:
|
React-microtasksnativemodule:
|
||||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+reac_14122a3aa345cfabcc022a0f638ef16d/node_modules/react-native-safe-area-context"
|
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context"
|
||||||
React-NativeModulesApple:
|
React-NativeModulesApple:
|
||||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||||
React-oscompat:
|
React-oscompat:
|
||||||
@@ -2515,43 +2295,34 @@ EXTERNAL SOURCES:
|
|||||||
ReactNativeDependencies:
|
ReactNativeDependencies:
|
||||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||||
RNCAsyncStorage:
|
RNCAsyncStorage:
|
||||||
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6__ce3c4972004f3d6791573ec5b64bee38/node_modules/@react-native-async-storage/async-storage"
|
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage"
|
||||||
RNGestureHandler:
|
|
||||||
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react_39cf7da47c9c8531caaa923ee740e293/node_modules/react-native-gesture-handler"
|
|
||||||
RNReanimated:
|
RNReanimated:
|
||||||
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+cor_c7c888bd389fb93c9cfe2d3c1c8b0777/node_modules/react-native-reanimated"
|
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
|
||||||
RNScreens:
|
RNScreens:
|
||||||
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens"
|
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens"
|
||||||
RNSVG:
|
RNSVG:
|
||||||
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg"
|
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg"
|
||||||
RNWorklets:
|
RNWorklets:
|
||||||
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@_40f69326ce21d3f9f6d74d3965fd9adf/node_modules/react-native-worklets"
|
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets"
|
||||||
Yoga:
|
Yoga:
|
||||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||||
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
|
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||||
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
|
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
|
||||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
|
||||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
|
||||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
|
||||||
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
|
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||||
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
|
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
|
||||||
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
|
|
||||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
|
||||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
|
||||||
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
|
|
||||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
|
||||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
|
||||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
@@ -2559,74 +2330,73 @@ SPEC CHECKSUMS:
|
|||||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||||
ReactCodegen: fffa79906f5866f6a5ab5d98480b375191190271
|
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f
|
||||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||||
RNReanimated: 9c6a550b41de91cf374e60afd79db93a362f1126
|
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||||
RNWorklets: 1b50cb7595142f95e70518196ba247ad7f46a52e
|
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
|
|
||||||
PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3
|
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
||||||
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||||
|
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
||||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
||||||
|
EB630B2F2F30BE2700DC761A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
|
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -156,6 +159,7 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
EB630B2F2F30BE2700DC761A /* Assets.xcassets */,
|
||||||
13B07FAE1A68108700A75B9A /* client */,
|
13B07FAE1A68108700A75B9A /* client */,
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||||
@@ -305,6 +309,7 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -312,6 +317,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -374,8 +380,6 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
|
||||||
);
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@@ -389,8 +393,6 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
@@ -479,9 +481,11 @@
|
|||||||
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
|
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -493,14 +497,14 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = client;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -509,7 +513,7 @@
|
|||||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -519,23 +523,27 @@
|
|||||||
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
|
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
|
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||||
INFOPLIST_FILE = client/Info.plist;
|
INFOPLIST_FILE = client/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = client;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -543,7 +551,7 @@
|
|||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -680,8 +688,9 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -699,7 +708,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -713,7 +722,7 @@
|
|||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -732,8 +741,9 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -750,7 +760,7 @@
|
|||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -763,7 +773,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,81 +1,81 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>client</string>
|
<string>client</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>client</string>
|
<string>client</string>
|
||||||
<string>com.anonymous.client</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string></string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Automatic</string>
|
<string>Automatic</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>production</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,54 +1,28 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// V2:纯色背景 + 随机文案小组件(Small/Medium/Large + 点击跳转 Home)
|
// V1:写死文案的小组件(Small/Medium/Large + 点击跳转 Home)
|
||||||
|
|
||||||
struct EmotionProvider: TimelineProvider {
|
struct EmotionProvider: TimelineProvider {
|
||||||
private let quotes = [
|
|
||||||
"你已经很努力了,今天也值得被温柔对待。",
|
|
||||||
"轻轻呼吸,感受当下的每一刻。",
|
|
||||||
"所有的压力,都会在深呼吸中慢慢消散。",
|
|
||||||
"给生活一点留白,给自己一点温柔。",
|
|
||||||
"不要走得太快,等一等落下的灵魂。",
|
|
||||||
"世界虽嘈杂,但你可以拥有一颗宁静的心。",
|
|
||||||
"每一个瞬间,都是生命最好的安排。",
|
|
||||||
"抱抱自己,辛苦了,亲爱的。",
|
|
||||||
"慢一点也没关系,只要你在前行。",
|
|
||||||
"今天,你对自己微笑了吗?",
|
|
||||||
"愿你历经山河,仍觉得人间值得。",
|
|
||||||
"心简单,世界就简单;心平顺,生活就平顺。",
|
|
||||||
"即使生活偶尔晦暗,你也要成为自己的光。",
|
|
||||||
"别让琐事挤走生活的快乐,别让压力消磨奋斗的激情。"
|
|
||||||
]
|
|
||||||
|
|
||||||
func placeholder(in context: Context) -> EmotionEntry {
|
func placeholder(in context: Context) -> EmotionEntry {
|
||||||
EmotionEntry(date: Date(), text: quotes[0])
|
EmotionEntry(date: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
||||||
let entry = EmotionEntry(date: Date(), text: quotes.randomElement() ?? quotes[0])
|
completion(EmotionEntry(date: Date()))
|
||||||
completion(entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||||
var entries: [EmotionEntry] = []
|
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
||||||
let currentDate = Date()
|
let entry = EmotionEntry(date: Date())
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
|
||||||
// 生成未来 24 小时的 6 个条目,每 4 小时更换一次随机文案
|
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
||||||
for hourOffset in 0..<6 {
|
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset * 4, to: currentDate)!
|
|
||||||
let entry = EmotionEntry(date: entryDate, text: quotes.randomElement() ?? quotes[0])
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeline = Timeline(entries: entries, policy: .atEnd)
|
|
||||||
completion(timeline)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmotionEntry: TimelineEntry {
|
struct EmotionEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let text: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmotionWidgetView: View {
|
struct EmotionWidgetView: View {
|
||||||
@@ -56,37 +30,160 @@ struct EmotionWidgetView: View {
|
|||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
private let title = "正念"
|
private let title = "正念"
|
||||||
|
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
||||||
private let deepLink = URL(string: "client:///(app)/home")
|
private let deepLink = URL(string: "client:///(app)/home")
|
||||||
|
|
||||||
// 背景色 #F7D9BF
|
|
||||||
private let backgroundColor = Color(red: 247/255, green: 217/255, blue: 191/255)
|
|
||||||
// 文本颜色(深咖色,适合搭配浅橘色背景)
|
|
||||||
private let textColor = Color(red: 74/255, green: 52/255, blue: 40/255)
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 0) {
|
switch family {
|
||||||
Spacer(minLength: 0)
|
case .systemSmall:
|
||||||
|
smallView()
|
||||||
Text(entry.text)
|
case .systemMedium:
|
||||||
.font(.system(size: family == .systemSmall ? 17 : 20, weight: .medium))
|
mediumView()
|
||||||
.foregroundColor(textColor)
|
case .systemLarge:
|
||||||
.lineSpacing(6)
|
largeView()
|
||||||
.multilineTextAlignment(.center)
|
default:
|
||||||
.minimumScaleFactor(0.7)
|
smallView()
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
// 统一的“卡片背景”风格(iOS 15 兼容)
|
||||||
if family != .systemSmall {
|
private func cardBackground(colors: [Color]) -> some View {
|
||||||
Text("Hey Mama")
|
ZStack {
|
||||||
.font(.system(size: 10, weight: .semibold))
|
LinearGradient(
|
||||||
.foregroundColor(textColor.opacity(0.3))
|
colors: colors,
|
||||||
.padding(.bottom, 4)
|
startPoint: .topLeading,
|
||||||
}
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
// 轻微光斑,增加层次
|
||||||
|
RadialGradient(
|
||||||
|
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
||||||
|
center: .topTrailing,
|
||||||
|
startRadius: 10,
|
||||||
|
endRadius: 180
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.cornerRadius(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.9))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.white.opacity(0.14))
|
||||||
|
.cornerRadius(999)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||||
|
])
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
chip(title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(2)
|
||||||
|
.lineLimit(4)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text("点我回到 App")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.65))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.widgetURL(deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mediumView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.09, green: 0.11, blue: 0.17),
|
||||||
|
])
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
chip(title)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.lineLimit(5)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text("轻轻呼吸,回到当下")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧装饰区:让版面更饱满
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
|
Text(entry.date, style: .time)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.8))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("今日")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.widgetURL(deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func largeView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||||
|
])
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
chip(title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(entry.date, style: .time)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.78))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(4)
|
||||||
|
.lineLimit(8)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("点我回到 Home")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.7))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("🌿")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.opacity(0.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
}
|
}
|
||||||
.padding(family == .systemSmall ? 16 : 24)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // 强制撑开容器
|
|
||||||
.background(backgroundColor) // 将背景色直接应用到容器上
|
|
||||||
.widgetURL(deepLink)
|
.widgetURL(deepLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,13 +194,7 @@ struct EmotionWidget: Widget {
|
|||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||||
if #available(iOS 17.0, *) {
|
EmotionWidgetView(entry: entry)
|
||||||
EmotionWidgetView(entry: entry)
|
|
||||||
.containerBackground(Color(red: 247/255, green: 217/255, blue: 191/255), for: .widget)
|
|
||||||
} else {
|
|
||||||
EmotionWidgetView(entry: entry)
|
|
||||||
.background(Color(red: 247/255, green: 217/255, blue: 191/255))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.configurationDisplayName("情绪小组件")
|
.configurationDisplayName("情绪小组件")
|
||||||
.description("一段温柔提醒,陪你回到当下。")
|
.description("一段温柔提醒,陪你回到当下。")
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"react-test-renderer": "19.1.0",
|
"react-test-renderer": "19.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
1220
client/pnpm-lock.yaml
generated
@@ -20,14 +20,31 @@ function getOptionalEnv(name: string, fallback: string): string {
|
|||||||
return process.env[name] ?? fallback;
|
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
|
* - auto:优先设备语言(支持列表内时),否则回退 en
|
||||||
* - zh-CN/en/es/pt/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
* - en/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');
|
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
@@ -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
@@ -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
@@ -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
@@ -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 { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
import en from './locales/en.json';
|
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';
|
import zhTW from './locales/zh-TW.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言码约定:
|
* 语言码约定:
|
||||||
* - 简体中文:zh-CN
|
|
||||||
* - 繁体中文:zh-TW
|
* - 繁体中文:zh-TW
|
||||||
* - 英语:en
|
* - 英语: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[] = [
|
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
||||||
'zh-CN',
|
|
||||||
'zh-TW',
|
'zh-TW',
|
||||||
'en',
|
'en',
|
||||||
'es',
|
|
||||||
'pt',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
|
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'en';
|
||||||
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
||||||
|
|
||||||
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||||
@@ -37,19 +28,13 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
|
|||||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||||
const tag = languageTag.toLowerCase();
|
const tag = languageTag.toLowerCase();
|
||||||
|
|
||||||
// 中文:优先区分繁简
|
// 中文:当前仅支持繁体中文(zh-TW)
|
||||||
if (tag.startsWith('zh')) {
|
if (tag.startsWith('zh')) {
|
||||||
// 常见繁体标记:zh-TW / zh-HK / zh-Hant
|
return 'zh-TW';
|
||||||
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
|
|
||||||
return 'zh-TW';
|
|
||||||
}
|
|
||||||
return 'zh-CN';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他语言:按前缀匹配
|
// 其他语言:按前缀匹配(当前仅支持英文)
|
||||||
if (tag.startsWith('en')) return 'en';
|
if (tag.startsWith('en')) return 'en';
|
||||||
if (tag.startsWith('es')) return 'es';
|
|
||||||
if (tag.startsWith('pt')) return 'pt';
|
|
||||||
|
|
||||||
return DEFAULT_FALLBACK_LANGUAGE;
|
return DEFAULT_FALLBACK_LANGUAGE;
|
||||||
}
|
}
|
||||||
@@ -87,7 +72,7 @@ export async function clearLanguagePreference(): Promise<void> {
|
|||||||
* 语言选择优先级:
|
* 语言选择优先级:
|
||||||
* 1) 用户设置(若存在)
|
* 1) 用户设置(若存在)
|
||||||
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
||||||
* 3) 默认回退(zh-CN)
|
* 3) 默认回退(en)
|
||||||
*/
|
*/
|
||||||
export async function initI18n(): Promise<void> {
|
export async function initI18n(): Promise<void> {
|
||||||
if (i18n.isInitialized) return;
|
if (i18n.isInitialized) return;
|
||||||
@@ -98,11 +83,8 @@ export async function initI18n(): Promise<void> {
|
|||||||
|
|
||||||
await i18n.use(initReactI18next).init({
|
await i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
|
||||||
'zh-TW': { translation: zhTW },
|
'zh-TW': { translation: zhTW },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
|
||||||
pt: { translation: pt },
|
|
||||||
},
|
},
|
||||||
lng: initialLang,
|
lng: initialLang,
|
||||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||||
|
|||||||
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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||||
@@ -9,6 +10,9 @@ const KEY_CONTENT_REACTIONS = 'content.reactions';
|
|||||||
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
const KEY_FAVORITES_ITEMS = 'favorites.items';
|
||||||
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
const KEY_CONSENT_ACCEPTED = 'consent.accepted';
|
||||||
const KEY_USER_PROFILE = 'user.profile';
|
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_UI_THEME_MODE = 'ui.theme.mode';
|
||||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||||
|
|
||||||
@@ -20,11 +24,42 @@ export type UserProfile = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
intents?: string[];
|
intents?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户画像(问卷打分输出)
|
||||||
|
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||||
|
*/
|
||||||
|
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||||
export type DailyReminderSettings = {
|
export type DailyReminderSettings = {
|
||||||
timesPerDay: number;
|
timesPerDay: number;
|
||||||
pushEnabled: boolean;
|
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> {
|
async function getJson<T>(key: string, fallback: T): Promise<T> {
|
||||||
const raw = await AsyncStorage.getItem(key);
|
const raw = await AsyncStorage.getItem(key);
|
||||||
@@ -70,6 +105,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FavoriteItem = {
|
export type FavoriteItem = {
|
||||||
|
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -82,15 +118,15 @@ export async function getFavorites(): Promise<FavoriteItem[]> {
|
|||||||
|
|
||||||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
if (list.some(i => i.id === item.id)) return;
|
// 允许重复点赞,不再根据 id 去重
|
||||||
const newList = [item, ...list];
|
const newList = [item, ...list];
|
||||||
console.log('Adding to favorites, new list size:', newList.length);
|
console.log('Adding to favorites, new list size:', newList.length);
|
||||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFavorite(contentId: string): Promise<void> {
|
export async function removeFavorite(favId: string): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
const next = list.filter(item => item.id !== contentId);
|
const next = list.filter(item => item.favId !== favId);
|
||||||
await setJson(KEY_FAVORITES_ITEMS, next);
|
await setJson(KEY_FAVORITES_ITEMS, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +158,103 @@ export async function setUserProfile(profile: UserProfile): Promise<void> {
|
|||||||
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
|
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> {
|
export async function getDailyReminderSettings(): Promise<DailyReminderSettings> {
|
||||||
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
const s = await getJson<DailyReminderSettings>(KEY_DAILY_REMINDER_SETTINGS, {
|
||||||
timesPerDay: 3,
|
timesPerDay: 3,
|
||||||
|
|||||||
20
server/.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# 本地/测试数据
|
||||||
|
.test.db
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# 测试与开发脚本(按需移除)
|
||||||
|
tests/
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Git 元数据
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
BIN
server/.test.db
Normal file
27
server/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 运行时基础环境
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 系统依赖(按需扩展;多数依赖为纯 Python)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 先复制依赖清单以利用 Docker layer cache
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN python -m pip install -U pip \
|
||||||
|
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
# 复制后端代码与迁移配置
|
||||||
|
COPY app /app/app
|
||||||
|
COPY alembic /app/alembic
|
||||||
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 生产镜像默认不开启 reload
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
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())
|
||||||
|
|
||||||
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(),
|
||||||
|
)
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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
|
from app.api.v1.user_profile_scoring import router as user_profile_router
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# 业务路由
|
# 业务路由
|
||||||
app.include_router(user_profile_router)
|
app.include_router(user_profile_router)
|
||||||
|
app.include_router(reco_router)
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict:
|
async def healthz() -> dict:
|
||||||
|
|||||||