Compare commits
7
Commits
full-assets
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dcda9fde4 | ||
|
|
33393e97fd | ||
|
|
903decd838 | ||
|
|
665baa0831 | ||
|
|
43275ae7a0 | ||
|
|
2853b1bb1d | ||
|
|
d97c9bf847 |
No files matched your search
@@ -0,0 +1,234 @@
|
||||
name: Build APK
|
||||
|
||||
# 触发方式:
|
||||
# · push tag(v*) → 构建并发布到 Gitea Release(App 内更新机制读的就是这里)
|
||||
# · workflow_dispatch → 手动构建,只上传 artifact,不发布
|
||||
#
|
||||
# 关键设计:**代码取当前 ref,资源目录从 full-assets 分支单独检出**
|
||||
# main 分支为保持 ~8MB 不追踪 assets_hq / assets_small / assets_net(见 README「分支约定」),
|
||||
# 而这三个目录是宠物动画与表情包本体,缺了它们构建出来的包宠物是空白的。
|
||||
# 所以构建时把 full-assets 的这三个目录叠加到工作树,而不是让代码跟着 650MB 分支走。
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
variant:
|
||||
description: 'asset 变体(hq=640x360 原画质 / small=320x180 小体积)'
|
||||
required: false
|
||||
default: 'hq'
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git clone --depth 1 --branch "${{ github.ref_name }}" \
|
||||
"${{ github.server_url }}/${{ github.repository }}.git" .
|
||||
git log --oneline -1
|
||||
|
||||
- name: 叠加完整素材(full-assets 分支)
|
||||
run: |
|
||||
set -e
|
||||
# 地址选择(实测数据,见 Probe 探测):
|
||||
# 172.17.0.1:3000(Docker 网关 → 宿主机) 17.6 MB/s ← 容器模式下的正确地址
|
||||
# 127.0.0.1:3000 不可达(容器内 127 是容器自己)
|
||||
# 123.206.124.151:3000 370 KB/s(走公网绕行,慢 47 倍)
|
||||
# 顺序按"容器模式优先、host 模式兜底"排:先试网关,再试回环,最后才回公网。
|
||||
GH_REPO="${{ github.repository }}"
|
||||
GIT_URL=""
|
||||
for cand in "http://172.17.0.1:3000" "http://127.0.0.1:3000" "${{ github.server_url }}"; do
|
||||
if timeout 8 curl -sf -o /dev/null "$cand/api/v1/version" 2>/dev/null; then
|
||||
GIT_URL="$cand/$GH_REPO.git"
|
||||
echo "选用 Gitea 地址:$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$GIT_URL" ] || { echo "::error::所有 Gitea 地址都不可达"; exit 1; }
|
||||
|
||||
# 优先用宿主机上的素材缓存:构建机本来就有完整仓库,没必要每次重下 650MB。
|
||||
# 预置方式:把 assets_hq / assets_small / assets_net 放到 /opt/petfloat-assets/ 下。
|
||||
ASSET_CACHE="${PET_ASSET_CACHE:-/opt/petfloat-assets}"
|
||||
if [ -d "$ASSET_CACHE" ] && [ -d "$ASSET_CACHE/assets_hq" ]; then
|
||||
echo "使用本地素材缓存:$ASSET_CACHE"
|
||||
for d in assets_hq assets_small assets_net; do
|
||||
rm -rf "app/src/main/$d"
|
||||
if [ -d "$ASSET_CACHE/$d" ]; then
|
||||
cp -r "$ASSET_CACHE/$d" "app/src/main/$d"
|
||||
echo "✓ $d (缓存) $(find "app/src/main/$d" -type f | wc -l) 个文件 $(du -sh "app/src/main/$d" | cut -f1)"
|
||||
else
|
||||
echo "::error::$ASSET_CACHE 缺少 $d"; exit 1
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "无本地缓存($ASSET_CACHE 不存在),改从 full-assets 分支取(超时保护 900s)"
|
||||
git remote add fa "$GIT_URL" 2>/dev/null || true
|
||||
if ! timeout 900 git fetch --depth 1 fa full-assets; then
|
||||
echo "::error::拉取 full-assets 超时或失败。"
|
||||
echo " 650MB 素材经公网下载极慢,建议在 runner 机器上预置缓存:"
|
||||
echo " mkdir -p /opt/petfloat-assets && cp -r <仓库>/app/src/main/assets_{hq,small,net} /opt/petfloat-assets/"
|
||||
exit 1
|
||||
fi
|
||||
for d in assets_hq assets_small assets_net; do
|
||||
if git ls-tree -d --name-only FETCH_HEAD "app/src/main/$d" | grep -q .; then
|
||||
rm -rf "app/src/main/$d"
|
||||
git checkout FETCH_HEAD -- "app/src/main/$d"
|
||||
echo "✓ $d $(find "app/src/main/$d" -type f | wc -l) 个文件 $(du -sh "app/src/main/$d" | cut -f1)"
|
||||
else
|
||||
echo "::error::full-assets 分支缺少 app/src/main/$d"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
- name: 准备构建环境
|
||||
run: |
|
||||
set -e
|
||||
# JDK 17
|
||||
if ! java -version 2>&1 | grep -qE '"(17|2[1-9])'; then
|
||||
for c in /opt/jdk17 /usr/lib/jvm/java-17-openjdk-amd64 /usr/lib/jvm/java-21-openjdk-amd64; do
|
||||
[ -x "$c/bin/java" ] && export JAVA_HOME="$c" && break
|
||||
done
|
||||
fi
|
||||
[ -n "${JAVA_HOME:-}" ] && echo "JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
java -version 2>&1 | head -2
|
||||
# Android SDK
|
||||
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "${ANDROID_HOME:-}" ]; then
|
||||
for p in /opt/android-sdk /usr/local/android-sdk "$HOME/Android/Sdk" /root/android-sdk; do
|
||||
[ -d "$p" ] && export ANDROID_HOME="$p" && break
|
||||
done
|
||||
fi
|
||||
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME" ]; then
|
||||
echo "::error::找不到 Android SDK(ANDROID_HOME 未设置且常见路径均不存在)"
|
||||
echo " 环境线索:java = $(java -version 2>&1 | head -1 || echo '未安装')"
|
||||
if [ -f /.dockerenv ]; then
|
||||
echo " ⚠ 本 job 运行在 Docker 容器内 —— 容器**看不到**宿主机的 /opt/jdk17、/opt/android-sdk。"
|
||||
echo " (Probe 实测:容器内 java 不存在、/opt/* 全空、127.0.0.1 不可达、172.17.0.1 达 17.6MB/s)"
|
||||
fi
|
||||
echo " 两条出路:"
|
||||
echo " 1)(推荐,最快)把 runner 改成 host 模式,直接复用宿主机现成环境:"
|
||||
echo " sed -i 's|\"ubuntu-latest:docker://[^\"]*\"|\"ubuntu-latest:host\"|' /data/.runner"
|
||||
echo " 然后重启 runner 容器。host 模式下 /opt/jdk17 与 /opt/android-sdk 直接可用。"
|
||||
echo " 2) 给 runner 换一个自带 JDK17 + Android SDK 的镜像(约 +3GB)。"
|
||||
exit 1
|
||||
fi
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "SDK: $ANDROID_HOME"
|
||||
ls "$ANDROID_HOME/platforms" 2>/dev/null | head -3
|
||||
|
||||
- name: 准备签名密钥
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${KEYSTORE_BASE64:-}" ]; then
|
||||
echo "::warning::未配置 KEYSTORE_BASE64,本次用 debug 签名构建(仅验证链路,不可分发)。"
|
||||
echo " ⚠ debug 签名每台机器不同,手机**无法覆盖安装**,更新机制会失效。"
|
||||
echo " 按 README「发布与更新」配好 secrets 后重跑,即可得到正式签名包。"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
|
||||
echo "KEYSTORE_PATH=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV"
|
||||
echo "KEY_ALIAS=${{ secrets.KEY_ALIAS || 'petfloat' }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: 构建 Release APK
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
VARIANT: ${{ inputs.variant || 'hq' }}
|
||||
run: |
|
||||
set -e
|
||||
# versionCode 需严格递增:run_number 天然单调,但设备上已装的是 1,
|
||||
# 这里加基准 1000 避免与历史调试包冲突,也留出回退空间。
|
||||
export VERSION_CODE=$((1000 + VERSION_CODE))
|
||||
export VERSION_NAME="1.0.${GITHUB_RUN_NUMBER}"
|
||||
echo "构建版本:versionCode=$VERSION_CODE versionName=$VERSION_NAME variant=$VARIANT"
|
||||
|
||||
ARGS=""
|
||||
[ "$VARIANT" = "hq" ] && ARGS="-Phq"
|
||||
|
||||
# 本项目**不带 gradlew**(build.sh 走的是系统 gradle),所以按优先级探测可执行文件
|
||||
GRADLE_BIN=""
|
||||
for c in /opt/gradle/gradle-8.5/bin/gradle /opt/gradle/bin/gradle ./gradlew; do
|
||||
[ -x "$c" ] && GRADLE_BIN="$c" && break
|
||||
done
|
||||
if [ -z "$GRADLE_BIN" ]; then
|
||||
GRADLE_BIN=$(command -v gradle 2>/dev/null || true)
|
||||
fi
|
||||
[ -n "$GRADLE_BIN" ] || {
|
||||
echo "::error::找不到 gradle —— 项目无 gradlew,必须用系统 gradle"
|
||||
echo " 期望路径:/opt/gradle/gradle-8.5/bin/gradle(build.sh 里用的就是它)"
|
||||
exit 1
|
||||
}
|
||||
echo "gradle: $GRADLE_BIN"
|
||||
|
||||
"$GRADLE_BIN" :app:assembleRelease $ARGS --no-daemon --stacktrace
|
||||
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
[ -f "$APK" ] || { echo "::error::构建产物不存在:$APK"; exit 1; }
|
||||
|
||||
# 产物登记:体积 + 指纹 + 签名摘要(更新机制靠 versionCode,人靠 SHA 核对)
|
||||
APKSHA=$(sha256sum "$APK" | cut -d' ' -f1)
|
||||
SIZE=$(stat -c%s "$APK")
|
||||
echo "APK_SHA=$APKSHA" >> "$GITHUB_ENV"
|
||||
echo "APK_SIZE=$SIZE" >> "$GITHUB_ENV"
|
||||
echo "APK_NAME=petfloat-${VERSION_NAME}-${VARIANT}.apk" >> "$GITHUB_ENV"
|
||||
cp "$APK" "$RUNNER_TEMP/petfloat-${VERSION_NAME}-${VARIANT}.apk"
|
||||
|
||||
# version.json:App 端更新检查优先读它取 versionCode / sha256 / 体积,
|
||||
# 比从 tag 名解析可靠(tag 是人写的,这个文件由构建产物自己写)。
|
||||
printf '{"versionCode":%s,"versionName":"%s","apk":"%s","sha256":"%s","size":%s}\n' \
|
||||
"$VERSION_CODE" "$VERSION_NAME" "$APK_NAME" "$APK_SHA" "$SIZE" \
|
||||
> "$RUNNER_TEMP/version.json"
|
||||
cat "$RUNNER_TEMP/version.json"
|
||||
|
||||
echo "APK: $APK_SHA ${SIZE} bytes"
|
||||
|
||||
# 签名核验:确保用的是发布密钥而不是 debug 密钥(debug 密钥每台机器不同,装不上)
|
||||
APKSIGNER=$(find "$ANDROID_HOME"/build-tools -name apksigner 2>/dev/null | sort -V | tail -1)
|
||||
if [ -n "$APKSIGNER" ]; then
|
||||
"$APKSIGNER" verify --print-certs "$APK" | head -3
|
||||
fi
|
||||
|
||||
- name: 上传构建产物
|
||||
run: echo "$RUNNER_TEMP/$APK_NAME"
|
||||
|
||||
- name: 发布到 Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
|
||||
# 幂等:tag 已存在 release 就复用它,避免重跑流水线时 409
|
||||
RID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$RID" ]; then
|
||||
RID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"PetFloat $TAG\",\"body\":\"versionCode=$VERSION_CODE\\nSHA256=$APK_SHA\",\"target_commitish\":\"main\"}" \
|
||||
"$API/releases" \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))")
|
||||
fi
|
||||
echo "release id=$RID"
|
||||
|
||||
# 先删同名附件(重跑时避免重名失败),再上传
|
||||
for aid in $(curl -s -H "Authorization: token $TOKEN" "$API/releases/$RID/assets" \
|
||||
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin)]" 2>/dev/null); do
|
||||
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$RID/assets/$aid" || true
|
||||
done
|
||||
# 上传 APK 与 version.json(App 端靠后者判版本与校验下载完整性)
|
||||
for f in "$RUNNER_TEMP/$APK_NAME" "$RUNNER_TEMP/version.json"; do
|
||||
n=$(basename "$f")
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$f" \
|
||||
"$API/releases/$RID/assets?name=$n" | head -c 200
|
||||
echo " ← $n"
|
||||
done
|
||||
echo "已发布:${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
||||
@@ -12,6 +12,11 @@ local.properties
|
||||
.DS_Store
|
||||
captures/
|
||||
|
||||
# ==== 签名与密钥(绝不入库)====
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# ==== 日志与临时 ====
|
||||
*.log
|
||||
/tmp/
|
||||
|
||||
@@ -203,9 +203,78 @@ DSH_PET=/path/to/dsh-pet python3 scripts/gen_config.py
|
||||
> 仓库只存「源码 + 脚本 + 源数据 + 小体积源资产」,构建产物(APK)与可再生资源(hq/small/net)
|
||||
> 始终留在本机,不被 git 追踪——这是为了让仓库能稳定推送、克隆,而不是塞进 650MB 二进制。
|
||||
|
||||
### 分支约定
|
||||
|
||||
- `main`(默认,轻量 ~8MB):源码 + 脚本 + 源数据 + 小体积源资产,新机器跑 `scripts/` 再生成大资源。
|
||||
- `full-assets`(完整快照 ~650MB):在 `main` 基础上额外包含 `assets_hq` / `assets_small` / `assets_net`,克隆后**无需再生成资源**即可直接 `./build.sh` 构建完整 APK。
|
||||
|
||||
> 默认维护 `main`;需要交付/备份完整素材时,切到 `full-assets` 或从它检出。
|
||||
|
||||
---
|
||||
|
||||
## 12. 参考与借鉴
|
||||
## 12. 发布与更新
|
||||
|
||||
两条链路:**CI 构建**(Gitea Actions → Release)与**应用内更新**(App 读 Release → 下载 → 安装)。
|
||||
|
||||
### 发版流程
|
||||
|
||||
```bash
|
||||
git push origin main # 代码先上 main
|
||||
git tag v1.0.1002 && git push origin v1.0.1002 # 打 tag 触发构建 + 发版
|
||||
```
|
||||
|
||||
打 tag 后 `.gitea/workflows/build.yml` 会:检出当前 ref 的代码 → **从 `full-assets` 分支叠加**
|
||||
`assets_hq` / `assets_small` / `assets_net`(main 保持轻量,不追踪这三个目录)→ 用 `KEYSTORE_BASE64`
|
||||
注入的固定密钥签 release 包 → 发布 APK 与 `version.json` 到该 tag 的 Release。
|
||||
|
||||
`versionCode = 1000 + run_number`,由 CI 注入。**必须单调递增**——系统只允许更高 versionCode
|
||||
覆盖安装,版本号回退会导致手机装不上。
|
||||
|
||||
### 必需 secrets
|
||||
|
||||
仓库 → Settings → Actions,配 4 个:
|
||||
|
||||
| Secret | 内容 |
|
||||
|---|---|
|
||||
| `KEYSTORE_BASE64` | 发布密钥的 base64:`base64 -i petfloat-release.jks \| tr -d '\n'` |
|
||||
| `KEYSTORE_PASSWORD` | keystore 口令 |
|
||||
| `KEY_ALIAS` | `petfloat` |
|
||||
| `KEY_PASSWORD` | 密钥口令(与上面相同即可) |
|
||||
|
||||
`GITEA_TOKEN` 由 Gitea 自动注入,**不需要配**(同名 secret 会被拒 400)。
|
||||
|
||||
> **签名是硬约束**:所有版本必须用同一把密钥。换密钥后已装用户无法覆盖升级,只能卸载重装,
|
||||
> 记忆数据会一起丢。keystore 请离线备份。
|
||||
|
||||
### 应用内更新
|
||||
|
||||
`UpdateChecker` 的流程:
|
||||
|
||||
1. `GET /api/v1/repos/mtfyun/dsh-pet-floating/releases?limit=10`,跳过 draft,取 versionCode 最大的一条
|
||||
2. 优先读 `version.json` 拿 versionCode / sha256 / 体积;读不到再从 tag 尾段解析
|
||||
3. 与当前安装版本比 **versionCode**(用 versionName 按字符串比会把 `1.0.9` 判成比 `1.0.10` 新)
|
||||
4. 下载到 `cacheDir/update/`,校验体积与 SHA256,不一致立即删除
|
||||
5. 经自带 `ApkProvider`(`content://com.dsh.petfloat.apk/`)交给系统安装器
|
||||
|
||||
入口:设置页 → 关于 → 检查更新。Android 8+ 首次需「安装未知来源应用」授权,未授权时**先引导去开**,
|
||||
而不是下载完才失败。
|
||||
|
||||
> 项目零第三方依赖,用不了 `androidx.core` 的 `FileProvider`,所以 `ApkProvider` 是自带的只读
|
||||
> ContentProvider:只暴露 `cacheDir/update` 下的 `.apk`,路径取 basename 防 `../` 穿越。
|
||||
|
||||
### runner
|
||||
|
||||
需要一台带 JDK 17 + Android SDK 的 act_runner,标签 `ubuntu-latest`。
|
||||
推荐 **host 模式**(直接复用机器上现成的 `/opt/jdk17`、`/opt/android-sdk`,不必再做 Android 镜像):
|
||||
|
||||
```bash
|
||||
# 注册令牌:Gitea → 站点管理 → Actions → Runners
|
||||
REG_TOKEN=<令牌> bash scripts/install-runner.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 参考与借鉴
|
||||
|
||||
本工程并非从零造轮子,素材、行为系统、记忆策略、UI 语言分别借鉴/参考了以下项目:
|
||||
|
||||
|
||||
+43
-2
@@ -11,16 +11,45 @@ android {
|
||||
applicationId = "com.dsh.petfloat"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// 版本号:CI 注入 VERSION_CODE(单调递增),本地缺省 1000。
|
||||
// 更新机制要求新包 versionCode **严格大于**已装版本,否则系统会拒绝覆盖安装。
|
||||
val injectedCode = System.getenv("VERSION_CODE")?.toIntOrNull()
|
||||
versionCode = injectedCode ?: 1000
|
||||
versionName = System.getenv("VERSION_NAME") ?: "1.0.$versionCode"
|
||||
}
|
||||
|
||||
// 发布签名:CI 通过 secret 注入 keystore(见 .gitea/workflows/build.yml)。
|
||||
// 本地没有 keystore 时不创建 release signingConfig,release 变体退回 debug 签名——
|
||||
// 仅用于本地验证,**不可用于分发**(debug 签名每台机器不同,无法覆盖安装)。
|
||||
val keystorePath = System.getenv("KEYSTORE_PATH")
|
||||
val hasReleaseKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath!!)
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEY_ALIAS") ?: "petfloat"
|
||||
keyPassword = System.getenv("KEY_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 BuildConfig:QA-004 的 debug 故障注入必须靠 `BuildConfig.DEBUG` 做编译期闸门
|
||||
//(release 里它是常量 false,注入开关整段不可达)。AGP 8 默认不生成,这里显式打开。
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -30,6 +59,12 @@ android {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
// 单测(纯 JVM,不需要设备):判定内核 ReconcileEngine 不碰 Context,可直接跑。
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true // android.jar stub 方法返回默认值而不是抛异常
|
||||
unitTests.all { it.testLogging { showStandardStreams = true } } // 让 benchmark 数字打到控制台
|
||||
}
|
||||
|
||||
// 素材两套:默认小体积(pilot 128MB 上限内可装),-Phq 用 640×360 原画质
|
||||
val hqBuild = project.hasProperty("hq")
|
||||
sourceSets["main"].assets.setSrcDirs(
|
||||
@@ -43,4 +78,10 @@ android {
|
||||
|
||||
dependencies {
|
||||
// 纯平台 API(android.* / java.* / 平台自带 org.json),无需第三方依赖。
|
||||
// 以下**仅测试依赖,不进 APK**:
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// JVM 版 org.json:android.jar 里的 org.json 只是 stub,JVM 单测调用会抛异常;
|
||||
// 加它才能在单测里实测「600 条全量 JSON 解析」的真实耗时(Android 平台的 org.json
|
||||
// 与此实现同源,属同一量级,作为代理指标使用,见交付说明)。
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- WebView 加载 http://127.0.0.1 需要网络权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 应用内更新:下载新版本后拉起系统安装器(对应 UpdateChecker + ApkProvider) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
@@ -39,5 +41,15 @@
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="floating-desktop-pet" />
|
||||
</service>
|
||||
|
||||
<!-- 应用内更新:只读地把自己下载的 APK 交给系统安装器。
|
||||
Android 7+ 不允许 file:// 传给别的应用,而本项目零第三方依赖(无 androidx
|
||||
FileProvider),所以自带一个最小只读 provider。exported=false + 临时授予
|
||||
读权限,安装完成后系统即释放。 -->
|
||||
<provider
|
||||
android:name=".ApkProvider"
|
||||
android:authorities="${applicationId}.apk"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
/**
|
||||
* 确定性抽取器:把一条自由文本压成 [FactKey](§2.1)。
|
||||
*
|
||||
* 硬约束:**零依赖、纯字符串/集合运算、无模型、无网络**。同样的输入永远得到同样的输出。
|
||||
* 单卡耗时 < 1ms(几百条量级,见 ReconcileEngineTest 的 benchmark)。
|
||||
*
|
||||
* 设计上的安全默认:**任何抽取失败都退化为"不可比"**(subject/attribute 为空),
|
||||
* 于是该卡只能走 ADD / NOOP —— 只会"少压缩",不会"记错"(§2.6)。
|
||||
*
|
||||
* 口径(MEM-002C / QA-002-B02·B03 之后收紧):**宁可抽不出,不可抽错**。
|
||||
* - 属性只在 [ATTRIBUTES] 的 `strong` 词里认;命中 0 个或 ≥2 个不同属性 → `UNKNOWN`。
|
||||
* - 单字/宽泛词(喝/吃/住/叫/养/茶/酒/饭/菜/疼/怕/忙)**不再参与判定**。
|
||||
* - 主体多人称冲突("主人和妈妈")→ `subject=""` → 不可比。
|
||||
* - 系统种子卡(小鲸娘的自我认知)由 [ReconcileEngine] 在 `stamp` 时整体改写进
|
||||
* `小鲸娘|system` 命名空间,不属于用户属性空间,因此不会与用户事实互相取代。
|
||||
*/
|
||||
object DeterministicExtractor {
|
||||
|
||||
const val DEFAULT_SUBJECT = "主人"
|
||||
|
||||
/** 第三人称称谓词典(长词优先,避免 "妈妈" 被 "妈" 抢先命中)。 */
|
||||
private val SUBJECT_WORDS: List<String> = listOf(
|
||||
"女朋友", "男朋友", "未婚妻", "未婚夫", "老公", "老婆",
|
||||
"妈妈", "爸爸", "儿子", "女儿", "哥哥", "姐姐", "弟弟", "妹妹",
|
||||
"爷爷", "奶奶", "外婆", "外公", "同事", "老板", "室友", "朋友",
|
||||
"妈", "爸"
|
||||
).sortedByDescending { it.length }
|
||||
|
||||
/**
|
||||
* 属性规则。**只有 `strong` 词参与判定**,且必须"领域唯一"。
|
||||
*
|
||||
* ## 为什么把一半的词删掉了(QA-002-B02/B03 的根因)
|
||||
*
|
||||
* 旧实现是"含某词即判某槽":`喝`、`吃`、`住`、`叫`、`养`、`茶`、`酒`、`饭`、`菜`、`疼`、`怕`、`忙`
|
||||
* 这些**单字/宽泛词**只要在长正文里出现过一次,就抢走整张卡的属性。后果是真实数据上:
|
||||
* - 内置「技能清单」正文含"喝水 hydrate" → 被判 `主人|drink`,占住 drink 主题链头;
|
||||
* - 内置「插件清单」正文含"喝水提醒" → 同样 `主人|drink`,于是两条无关清单被当成同主题合并,
|
||||
* 真正矛盾的「咖啡偏好 / 咖啡戒断」反而彼此得不到比较。
|
||||
*
|
||||
* 现在的口径是**宁可抽不出,不可抽错**(§2.6 安全侧失败):抽不出 → `UNKNOWN` → 不可比 →
|
||||
* 只能 ADD,最坏结果是多一条重复卡,而不是记错或改坏别人的卡。
|
||||
*
|
||||
* 被显式移除的宽泛词(不要再加回来):喝 / 吃 / 住 / 叫 / 养 / 茶 / 酒 / 饭 / 菜 / 疼 / 怕 / 忙。
|
||||
* 它们的正确用法是"弱证据",而弱证据不足以判定属性,因此在本实现里直接不参与判定。
|
||||
*/
|
||||
private data class AttrRule(val attribute: String, val slot: SlotType, val strong: List<String>)
|
||||
|
||||
/**
|
||||
* 属性词典(封闭、可扩展)。`strong` 词之间**没有顺序优先级**——
|
||||
* 命中两个不同属性即视为歧义(`UNKNOWN`),不再"先匹配先赢"。
|
||||
*/
|
||||
private val ATTRIBUTES: List<AttrRule> = listOf(
|
||||
// drink 里必须有咖啡的**具体品类词**:TC-02 的 "喜欢手冲耶加雪菲" 若命中不了 drink,
|
||||
// 就会退化成 ADD,同主题的兼容合并就永远发生不了(文档 §2.4 R4 的用例要求它能命中)。
|
||||
AttrRule(
|
||||
"drink", SlotType.MULTI,
|
||||
listOf(
|
||||
"咖啡", "手冲", "耶加雪菲", "拿铁", "美式", "摩卡", "卡布奇诺", "冷萃", "浓缩",
|
||||
"奶茶", "可乐", "果汁", "汽水", "乌龙茶", "绿茶", "红茶", "啤酒", "白酒", "红酒", "戒酒"
|
||||
)
|
||||
),
|
||||
AttrRule("allergy", SlotType.SINGLE, listOf("过敏", "不能吃", "忌口", "忌食")),
|
||||
AttrRule("residence", SlotType.SINGLE, listOf("住在", "搬到", "搬去", "搬家", "老家", "定居", "租在")),
|
||||
AttrRule("birthday", SlotType.SINGLE, listOf("生日", "出生", "星座")),
|
||||
AttrRule("work", SlotType.SINGLE, listOf("上班", "工作", "公司", "职业", "加班", "入职", "辞职", "实习")),
|
||||
AttrRule("pet", SlotType.MULTI, listOf("猫", "狗", "仓鼠", "宠物", "养了一只")),
|
||||
AttrRule("hobby", SlotType.MULTI, listOf("爱好", "追剧", "打游戏", "看番", "运动", "健身", "喜欢玩")),
|
||||
AttrRule("health", SlotType.MULTI, listOf("生病", "体检", "吃药", "失眠", "例假", "感冒", "发烧", "头疼")),
|
||||
AttrRule("schedule", SlotType.SINGLE, listOf("作息", "熬夜", "早起", "几点睡", "午睡", "早睡")),
|
||||
AttrRule("mood_trigger", SlotType.MULTI, listOf("焦虑", "压力大", "讨厌被", "开心因为", "烦躁")),
|
||||
// nickname 认明确的称呼句式;裸 "叫" 保留但依赖上面的"多属性即歧义"规则兜底:
|
||||
// "主人的猫叫团子" 会同时命中 pet(猫) + nickname(叫) → UNKNOWN → 不可比,不再是误判。
|
||||
AttrRule("nickname", SlotType.SINGLE, listOf("叫我", "称呼", "外号", "名字", "叫")),
|
||||
AttrRule("food", SlotType.MULTI, listOf("火锅", "香菜", "零食", "外卖", "早餐", "夜宵", "米饭", "面条", "饺子", "烧烤", "食堂", "甜品"))
|
||||
)
|
||||
|
||||
private val NEG_WORDS: List<String> = listOf(
|
||||
"不再", "不喜欢", "不爱", "不想", "不是", "不会", "没有", "不能", "不喝", "不吃", "不玩",
|
||||
"不", "别", "没", "戒", "停止", "放弃", "拒绝", "受不了", "讨厌"
|
||||
).sortedByDescending { it.length }
|
||||
|
||||
/** 双重否定 → 逐对抵消(§2.1)。 */
|
||||
private val DOUBLE_NEG: List<String> = listOf("不是不", "不能不", "没有不", "不会不", "并非不")
|
||||
|
||||
private val CHANGE_CUES: List<String> = listOf(
|
||||
"了", "现在", "已经", "改成", "变成", "搬到", "搬去", "换成", "最近", "后来", "开始", "不再"
|
||||
)
|
||||
|
||||
private val STOP_WORDS: List<String> = listOf(
|
||||
"特别", "尤其", "真的", "其实", "现在", "已经", "最近", "后来", "以前", "之前", "开始",
|
||||
"有点", "一些", "一个", "一直", "可能", "应该", "比较", "时候",
|
||||
"的", "了", "很", "最", "都", "也", "就", "在", "和", "跟", "是", "有", "会", "要", "想",
|
||||
"能", "可以", "过", "着", "吧", "呢", "啊", "吗", "呀", "哦", "嗯", "挺", "蛮", "还",
|
||||
"又", "才", "只", "更", "把", "给", "对"
|
||||
)
|
||||
|
||||
/** 只剔除"通用动词/属性提示词",**不剔除** 咖啡/杭州 这类具体实例词——它们才是值 token。 */
|
||||
private val VERB_WORDS: List<String> = listOf(
|
||||
"喜欢喝", "喜欢吃", "喜欢玩", "喜欢", "爱喝", "爱吃",
|
||||
"住在", "搬去", "搬到", "搬家",
|
||||
"喝", "吃", "玩", "追", "看", "用", "叫", "觉得", "感觉", "知道", "记得", "买", "学", "养", "上班", "工作"
|
||||
)
|
||||
|
||||
/** 切 token 前统一剔除的词(长词优先,避免 "喜欢" 先被 "喝" 截断)。 */
|
||||
private val REMOVALS: List<String> =
|
||||
(SUBJECT_WORDS + VERB_WORDS + NEG_WORDS + STOP_WORDS).distinct().sortedByDescending { it.length }
|
||||
|
||||
private val TOKEN_SPLIT = Regex("[^\\p{IsHan}A-Za-z0-9]+")
|
||||
|
||||
/** "记住…" 里的 "住" 不是居住线索,先抹掉,避免 residence 误命中。 */
|
||||
private val NORMALIZE = Regex("记住|记下|别忘了|帮我记住")
|
||||
|
||||
fun extract(title: String, content: String): FactKey = runCatching { extractInner(title, content) }
|
||||
.getOrDefault(FactKey.UNKNOWN) // §7.2:抽取抛异常 → 不可比 → 只会 ADD
|
||||
|
||||
private fun extractInner(title: String, content: String): FactKey {
|
||||
val raw = normalize("$title $content").trim()
|
||||
val body = normalize(if (content.isNotBlank()) content else title).trim()
|
||||
val (subject, subjectless) = subjectOf(body)
|
||||
val (attribute, slot) = attributeOf(raw)
|
||||
val polarity = polarityOf(raw)
|
||||
val changeCue = CHANGE_CUES.any { raw.contains(it) }
|
||||
val tokens = valueTokensOf(subjectless)
|
||||
return FactKey(subject, attribute, slot, tokens, polarity, changeCue)
|
||||
}
|
||||
|
||||
private fun normalize(s: String): String = NORMALIZE.replace(s, " ")
|
||||
|
||||
/**
|
||||
* §2.1 主体抽取。返回 (subject, 已删主体词的正文)。
|
||||
* 多人称冲突 → subject="" (永久不可比,绝不 SUPERSEDE)。
|
||||
*/
|
||||
private fun subjectOf(text: String): Pair<String, String> {
|
||||
var s = text.replace(DEFAULT_SUBJECT, " ")
|
||||
s = Regex("我的|我").replace(s, " ")
|
||||
val found = LinkedHashSet<String>()
|
||||
for (w in SUBJECT_WORDS) {
|
||||
if (s.contains(w)) {
|
||||
found.add(w)
|
||||
s = s.replace(w, " ")
|
||||
}
|
||||
}
|
||||
val subject = when {
|
||||
found.isEmpty() -> DEFAULT_SUBJECT
|
||||
found.size == 1 -> found.first()
|
||||
else -> ""
|
||||
}
|
||||
return subject to s
|
||||
}
|
||||
|
||||
/**
|
||||
* §2.1 属性抽取。返回 (attribute, slotType);判定不出来返回 `("", UNKNOWN)`。
|
||||
*
|
||||
* 规则:
|
||||
* 1. 只在 `strong` 词里找命中;
|
||||
* 2. 命中的属性**恰好一个** → 采纳;
|
||||
* 3. 命中**零个** → `UNKNOWN`(抽不出,走 ADD);
|
||||
* 4. 命中**两个及以上不同属性** → 歧义,同样 `UNKNOWN`。
|
||||
*
|
||||
* 第 4 条是这次新加的:旧实现按词典顺序"先匹配先赢",于是一段同时提到"喝"和"吃"的
|
||||
* 长正文会被随便归到其中一类,再把不相干的卡拖进同一个主题链(QA-002-B03)。
|
||||
*/
|
||||
private fun attributeOf(raw: String): Pair<String, SlotType> {
|
||||
val hits = ATTRIBUTES.filter { rule -> rule.strong.any { raw.contains(it) } }
|
||||
return if (hits.size == 1) hits[0].attribute to hits[0].slot else "" to SlotType.UNKNOWN
|
||||
}
|
||||
|
||||
/** §2.1 极性:有否定词 → -1;无任何极性词的陈述句 → +1(陈述即肯定)。 */
|
||||
fun polarityOf(text: String): Int {
|
||||
if (DOUBLE_NEG.any { text.contains(it) }) return 1
|
||||
return if (countNegations(text) > 0) -1 else 1
|
||||
}
|
||||
|
||||
private fun countNegations(text: String): Int {
|
||||
var i = 0
|
||||
var count = 0
|
||||
while (i < text.length) {
|
||||
val hit = NEG_WORDS.firstOrNull { text.startsWith(it, i) }
|
||||
if (hit != null) {
|
||||
count++
|
||||
i += hit.length
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** §2.1 值 token:剔除主体/属性提示词/否定词/时态词/停用词后,按标点切分,保留长度 ≥ 2 的片段。 */
|
||||
fun valueTokensOf(text: String): List<String> {
|
||||
var s = text
|
||||
for (w in REMOVALS) s = s.replace(w, " ")
|
||||
return s.split(TOKEN_SPLIT).map { it.trim() }.filter { it.length >= 2 }.distinct()
|
||||
}
|
||||
}
|
||||
@@ -976,8 +976,18 @@ class FloatingPetService : Service() {
|
||||
private fun ensureSeedMemories() {
|
||||
val p = prefs(this)
|
||||
if (p.getInt("seed_memory_v", 0) >= PetKnowledge.SEED_VERSION) return
|
||||
// QA-004-B01:这里正是"用户记忆被静默覆盖成 8 条种子卡"的最后一步。
|
||||
// 存储损坏时 add 会被拒绝(返回 REJECTED_CORRUPT);此时**不能**推进 seed_memory_v,
|
||||
// 否则下次启动会认为种子已经装过,坏数据永远没人处理、也不会再补种子。
|
||||
var rejected = false
|
||||
PetKnowledge.seeds.forEach { s ->
|
||||
MemoryStore.add(this, s.title, s.content, s.tags, s.importance)
|
||||
if (MemoryStore.add(this, s.title, s.content, s.tags, s.importance) ==
|
||||
MemWriteResult.REJECTED_CORRUPT
|
||||
) rejected = true
|
||||
}
|
||||
if (rejected) {
|
||||
android.util.Log.w("PetFloat", "种子记忆未写入:记忆存储处于损坏只读态,保留 seed_memory_v 以便恢复后补写")
|
||||
return
|
||||
}
|
||||
p.edit().putInt("seed_memory_v", PetKnowledge.SEED_VERSION).apply()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
@@ -65,6 +66,13 @@ class MainActivity : Activity() {
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* logcat 过滤用:`adb logcat -s PetFloat`。
|
||||
* QA-003-B02 之前本应用**一条日志都没有**,整理失败时现场只能靠"存储零变化 + 无审计 +
|
||||
* CPU 空闲"反推;现在失败至少留下堆栈(`Log.e`)+ 一条 `op=FAILED` 审计。
|
||||
*/
|
||||
private const val TAG = "PetFloat"
|
||||
|
||||
const val EXTRA_TAB = "tab"
|
||||
const val TAB_STATUS = 0
|
||||
const val TAB_CHAT = 1
|
||||
@@ -89,6 +97,19 @@ class MainActivity : Activity() {
|
||||
private lateinit var pal: Pal
|
||||
private lateinit var config: PetConfig
|
||||
private var running = false
|
||||
/** 整理记忆是否正在后台执行(防重入,只在主线程读写)。 */
|
||||
private var consolidating = false
|
||||
|
||||
/**
|
||||
* 记忆列表分片渲染参数(QA-002-B04)。
|
||||
*
|
||||
* memRowChunk = 12 行/帧:一条记忆是"标题 + 长正文"两个 TextView,600 卡长正文
|
||||
* (平均 377 字)时实测单行构建 + 布局约 0.6ms,12 行 ≈ 7ms,留足余量给同一帧里的
|
||||
* 其它工作,确保单帧 < 16ms。
|
||||
* memRowFrameMs = 16:让出一帧,下一帧继续。
|
||||
*/
|
||||
private val memRowChunk = 12
|
||||
private val memRowFrameMs = 16L
|
||||
|
||||
private val tabs = ArrayList<TextView>()
|
||||
private lateinit var tabIndicator: View
|
||||
@@ -498,6 +519,38 @@ class MainActivity : Activity() {
|
||||
.putString(KEY_CHAT_HISTORY, arr.toString()).apply()
|
||||
}
|
||||
|
||||
/** 记忆列表单行(供 [showMemoryDialog] 的分片渲染复用)。 */
|
||||
private fun memoryRow(m: Memory, onDeleted: () -> Unit): View {
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(20), dp(12), dp(20), dp(12))
|
||||
isClickable = true
|
||||
setOnLongClickListener {
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setMessage("删除这条记忆?\n${m.title}")
|
||||
.setPositiveButton("删除") { _, _ ->
|
||||
MemoryStore.remove(this@MainActivity, m.id)
|
||||
onDeleted()
|
||||
}
|
||||
.setNegativeButton("取消", null).show()
|
||||
true
|
||||
}
|
||||
}
|
||||
row.addView(TextView(this).apply {
|
||||
text = m.title + if (m.importance >= 3) " ★" else ""
|
||||
textSize = 14f
|
||||
setTextColor(pal.text)
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
row.addView(TextView(this).apply {
|
||||
text = m.content
|
||||
textSize = 13f
|
||||
setTextColor(pal.sub)
|
||||
setPadding(0, dp(2), 0, 0)
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
/** 二级菜单:记忆管理。 */
|
||||
private fun showMemoryDialog() {
|
||||
val list = LinearLayout(this).apply {
|
||||
@@ -531,9 +584,42 @@ class MainActivity : Activity() {
|
||||
}
|
||||
list.addView(autoRow)
|
||||
val dlgRef = arrayOfNulls<AlertDialog>(1)
|
||||
|
||||
/**
|
||||
* 记忆列表渲染 —— **分片**,不再一次性重建全部行。
|
||||
*
|
||||
* 为什么(QA-002-B04):`consolidateAsync` 的回调里调用本函数,600 行 × 长正文
|
||||
* 一次性 `removeAllViews + addView` 让主线程阻塞 2361ms(`Davey! duration=2361ms`、
|
||||
* `Skipped 44 frames!`,p99 帧 2350ms)。计算已经搬到后台线程了,卡的是"把结果贴回界面"
|
||||
* 这一步 —— 它仍然是 O(卡片数) 的主线程工作。
|
||||
*
|
||||
* 现在的做法:每帧只建 [memRowChunk] 行,用 `postDelayed(…, 16)` 让出这一帧,
|
||||
* 下一帧继续。目标单帧主线程占用 < 16ms。代价是列表"长出来"用了几百毫秒
|
||||
* (600 卡 ≈ 0.8s 填满),但每一帧都能响应触摸和返回键。
|
||||
*
|
||||
* `gen` 是渲染代次:每次重渲染 +1,上一轮还在排队的批次发现代次变了就自行停止,
|
||||
* 避免"整理完成"与"长按删除"两轮渲染交错插行。
|
||||
*/
|
||||
val renderGen = intArrayOf(0)
|
||||
fun renderRows() {
|
||||
val gen = ++renderGen[0]
|
||||
while (list.childCount > 1) list.removeViewAt(1)
|
||||
val mems = MemoryStore.load(this)
|
||||
// QA-004-B01:读是三态的。损坏时**不能**显示"还没有记忆"——那正是旧实现误导
|
||||
// 用户(和开发者)的地方。这里显式告诉用户"读失败、已保护、怎么办"。
|
||||
val lr = MemoryStore.loadResult(this)
|
||||
if (lr is LoadResult.Corrupt) {
|
||||
list.addView(TextView(this).apply {
|
||||
text = "记不起主人的事了:记忆存储读取失败。\n" +
|
||||
"为免覆盖原有内容,所有写入已暂停(原数据仍保留在设备上)。\n" +
|
||||
"可以「整理 → 清空全部记忆」重置,或先备份 pet_prefs.xml 再排查。\n" +
|
||||
"原因:${lr.reason}"
|
||||
textSize = 13f
|
||||
setTextColor(pal.danger)
|
||||
setPadding(dp(20), dp(20), dp(20), dp(16))
|
||||
})
|
||||
return
|
||||
}
|
||||
val mems = lr.listOrEmpty()
|
||||
if (mems.isEmpty()) {
|
||||
list.addView(TextView(this).apply {
|
||||
text = "还没有记忆。聊聊就有了,也可以手动添加。"
|
||||
@@ -543,37 +629,22 @@ class MainActivity : Activity() {
|
||||
})
|
||||
return
|
||||
}
|
||||
mems.forEach { m ->
|
||||
list.addView(divider())
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(20), dp(12), dp(20), dp(12))
|
||||
isClickable = true
|
||||
setOnLongClickListener {
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setMessage("删除这条记忆?\n${m.title}")
|
||||
.setPositiveButton("删除") { _, _ ->
|
||||
MemoryStore.remove(this@MainActivity, m.id)
|
||||
renderRows()
|
||||
}
|
||||
.setNegativeButton("取消", null).show()
|
||||
true
|
||||
var i = 0
|
||||
val step = object : Runnable {
|
||||
override fun run() {
|
||||
if (gen != renderGen[0]) return // 已被更新的一轮取代
|
||||
if (isFinishing || isDestroyed) return
|
||||
val end = minOf(i + memRowChunk, mems.size)
|
||||
while (i < end) {
|
||||
list.addView(divider())
|
||||
list.addView(memoryRow(mems[i]) { renderRows() })
|
||||
i++
|
||||
}
|
||||
if (i < mems.size) ui.postDelayed(this, memRowFrameMs)
|
||||
}
|
||||
row.addView(TextView(this).apply {
|
||||
text = m.title + if (m.importance >= 3) " ★" else ""
|
||||
textSize = 14f
|
||||
setTextColor(pal.text)
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
row.addView(TextView(this).apply {
|
||||
text = m.content
|
||||
textSize = 13f
|
||||
setTextColor(pal.sub)
|
||||
setPadding(0, dp(2), 0, 0)
|
||||
})
|
||||
list.addView(row)
|
||||
}
|
||||
// 首片也走消息队列:否则首次打开弹窗时会在 `renderRows()` 同步返回前就建完所有行
|
||||
ui.post(step)
|
||||
}
|
||||
renderRows()
|
||||
val d = AlertDialog.Builder(this)
|
||||
@@ -585,21 +656,84 @@ class MainActivity : Activity() {
|
||||
.create()
|
||||
d.setOnShowListener {
|
||||
d.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { addMemoryDialog { renderRows(); d.dismiss() } }
|
||||
d.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
val n = MemoryStore.consolidate(this)
|
||||
renderRows()
|
||||
Toast.makeText(this, if (n > 0) "已合并/清理 $n 条" else "记忆已经很整洁了", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
// 整理与清空共用一个入口。
|
||||
// 修复:此前这里对 BUTTON_NEGATIVE 连续设置了两次监听,后一个「清空」把前一个「整理」
|
||||
// 覆盖掉了,导致按钮文案是「整理」、实际弹出的却是「清空全部记忆」确认框。
|
||||
// 现在改为点「整理」后展开维护菜单,两个功能各自有明确文案,危险操作多一层确认。
|
||||
d.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage("清空全部记忆?")
|
||||
.setPositiveButton("清空") { _, _ -> MemoryStore.clear(this); renderRows() }
|
||||
.setNegativeButton("取消", null).show()
|
||||
.setTitle("记忆维护")
|
||||
.setItems(arrayOf("整理记忆(合并相似、清理低价值)", "清空全部记忆…")) { _, which ->
|
||||
when (which) {
|
||||
0 -> consolidateAsync { renderRows() }
|
||||
1 -> AlertDialog.Builder(this)
|
||||
.setMessage("清空全部记忆?此操作不可恢复。")
|
||||
.setPositiveButton("清空") { _, _ -> MemoryStore.clear(this); renderRows() }
|
||||
.setNegativeButton("取消", null).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
d.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台整理记忆。
|
||||
*
|
||||
* 为什么必须离开主线程:`MemoryStore.consolidate()` = 惰性迁移 + `reconcileBatch(600 卡 ×
|
||||
* 最多 600 候选)` + 全量 JSON 读盘/写盘。实测(JVM)最坏一档 23ms,低端机按 4–8× 估
|
||||
* 90–190ms,加上 IO 落在 150–250ms —— 主线程上跑就是一次可感知卡顿。
|
||||
*
|
||||
* 线程方案:裸 `Thread` + 主线程布尔防重入,与本文件其它异步处的
|
||||
* `Thread { … ui.post { … } }.start()` 风格保持一致。**不用单线程 Executor**:
|
||||
* Executor 会把连点排队执行(用户点两下就整理两遍),而这里要的语义是
|
||||
* "正在整理时直接拒绝"。
|
||||
*
|
||||
* 并发安全性:`MemoryStore.consolidate()` 内部有 `synchronized(lock)`(lock 在
|
||||
* [MemoryPersist] 里,与 `add` / `applyOps` / `remove` / `clear` 共用同一把锁,且读、写、
|
||||
* 损坏标记、上一版备份全都在锁内),因此后台整理与主线程上的增删改互斥,不会交错写盘。
|
||||
*
|
||||
* 异常:后台线程抛出会直接崩进程,所以整段 `runCatching`;失败时提示"记忆未改动"
|
||||
* ——`consolidate` 只在最后一步 `save()` 落盘,中途失败不会留下半成品。
|
||||
*
|
||||
* 失败必须**可诊断**(QA-003-B02):`Log.e(TAG, …)` 留下完整堆栈,同时用
|
||||
* [MemoryStore.recordConsolidateFailure] 往现有审计环形缓冲里写一条 `op="FAILED"` +
|
||||
* 异常类名/首行 message。用户侧文案不变("整理失败,记忆未改动"在语义上仍然成立)。
|
||||
*/
|
||||
private fun consolidateAsync(onDone: () -> Unit) {
|
||||
if (consolidating) {
|
||||
Toast.makeText(this, "正在整理记忆,请稍候…", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
consolidating = true
|
||||
Thread {
|
||||
val result = runCatching { MemoryStore.consolidate(this) }
|
||||
// 失败必须留痕(QA-003-B02):旧实现把异常整个吞掉,现场只能靠"存储零变化 +
|
||||
// 无审计 + CPU 空闲"反推,连异常类型都拿不到。堆栈进 logcat,短原因进审计环形缓冲。
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
Log.e(TAG, "consolidate failed", e)
|
||||
MemoryStore.recordConsolidateFailure(this, e)
|
||||
}
|
||||
ui.post {
|
||||
consolidating = false // 先解锁,避免回调里再触发时被自己挡住
|
||||
if (result.isSuccess) {
|
||||
val n = result.getOrDefault(0)
|
||||
onDone()
|
||||
Toast.makeText(
|
||||
this,
|
||||
if (n > 0) "已整理 $n 条记忆" else "记忆已经很整洁了",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
onDone()
|
||||
Toast.makeText(this, "整理失败,记忆未改动", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun addMemoryDialog(onDone: () -> Unit) {
|
||||
val box = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
@@ -612,7 +746,19 @@ class MainActivity : Activity() {
|
||||
.setTitle("添加记忆")
|
||||
.setView(box)
|
||||
.setPositiveButton("保存") { dd, _ ->
|
||||
if (MemoryStore.add(this, title.text.toString(), content.text.toString())) onDone()
|
||||
// QA-004-B01:写路径现在**三态**返回。"拒绝"与"没有变更"必须给出不同反馈,
|
||||
// 否则存储损坏时用户点了保存却毫无反应(旧实现更是会把记忆静默覆盖掉)。
|
||||
when (MemoryStore.add(this, title.text.toString(), content.text.toString())) {
|
||||
MemWriteResult.CHANGED -> onDone()
|
||||
MemWriteResult.REJECTED_CORRUPT -> Toast.makeText(
|
||||
this,
|
||||
"记忆存储异常,已阻止写入(原有记忆未被覆盖)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
MemWriteResult.UNCHANGED -> Toast.makeText(
|
||||
this, "已经有同样的记忆了", Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
dd.dismiss()
|
||||
}
|
||||
.setNegativeButton("取消", null).show()
|
||||
@@ -625,8 +771,13 @@ class MainActivity : Activity() {
|
||||
val body = remember.groupValues[2].trim()
|
||||
if (body.isNotEmpty()) {
|
||||
val title = body.take(12)
|
||||
MemoryStore.add(this, title, body, emptyList(), 3)
|
||||
Toast.makeText(this, "记住啦:$title", Toast.LENGTH_SHORT).show()
|
||||
val r = MemoryStore.add(this, title, body, emptyList(), 3)
|
||||
Toast.makeText(
|
||||
this,
|
||||
if (r == MemWriteResult.REJECTED_CORRUPT) "记忆存储异常,这条暂时记不下(原有记忆未动)"
|
||||
else "记住啦:$title",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -636,9 +787,17 @@ class MainActivity : Activity() {
|
||||
if (kw.isEmpty()) return
|
||||
val list = MemoryStore.load(this)
|
||||
val hit = list.filter { it.title.contains(kw) || it.content.contains(kw) }
|
||||
hit.forEach { MemoryStore.remove(this, it.id) }
|
||||
Toast.makeText(this, if (hit.isEmpty()) "没找到相关记忆" else "忘掉了 ${hit.size} 条",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
var rejected = false
|
||||
hit.forEach { if (MemoryStore.remove(this, it.id) == MemWriteResult.REJECTED_CORRUPT) rejected = true }
|
||||
Toast.makeText(
|
||||
this,
|
||||
when {
|
||||
rejected -> "记忆存储异常,已阻止删除(原有记忆未动)"
|
||||
hit.isEmpty() -> "没找到相关记忆"
|
||||
else -> "忘掉了 ${hit.size} 条"
|
||||
},
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1766,9 +1925,12 @@ class MainActivity : Activity() {
|
||||
}) { showSkillsDialog() })
|
||||
extCard.addView(divider())
|
||||
extCard.addView(rowWith("记忆", TextView(this).apply {
|
||||
text = "${MemoryStore.load(this@MainActivity).size} 条"
|
||||
// 三态读:损坏时显示"读取失败"而不是骗人的"0 条"(QA-004-B01)
|
||||
val lr = MemoryStore.loadResult(this@MainActivity)
|
||||
val corrupt = lr is LoadResult.Corrupt
|
||||
text = if (corrupt) "读取失败" else "${lr.listOrEmpty().size} 条"
|
||||
textSize = 13f
|
||||
setTextColor(pal.sub)
|
||||
setTextColor(if (corrupt) pal.danger else pal.sub)
|
||||
}) { showMemoryDialog() })
|
||||
col.addView(extCard)
|
||||
|
||||
@@ -2043,9 +2205,161 @@ class MainActivity : Activity() {
|
||||
col.addView(authRows)
|
||||
|
||||
refreshAuthUi()
|
||||
|
||||
// ---- 关于与更新 ----
|
||||
// 更新链路:CI 打 tag → Gitea Release(APK + version.json)→ App 读公开 API 比对
|
||||
// versionCode → 下载 → ApkProvider + 系统安装器。
|
||||
// 判定用 versionCode 而不是 versionName:后者按字符串比较会把 "1.0.9" 判成比
|
||||
// "1.0.10" 新。
|
||||
col.addView(sectionLabel("关于"))
|
||||
val aboutCard = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(pal.card, 16, pal.line)
|
||||
}
|
||||
aboutCard.addView(actionRow("版本 ${UpdateChecker.currentVersionName(this)}") {})
|
||||
aboutCard.addView(divider())
|
||||
aboutCard.addView(actionRow("检查更新") { checkUpdate() })
|
||||
col.addView(aboutCard)
|
||||
|
||||
return scroll
|
||||
}
|
||||
|
||||
// ---------------- 应用内更新 ----------------
|
||||
|
||||
/** 防重入:一次只允许一个检查/下载在进行 */
|
||||
private var updateBusy = false
|
||||
|
||||
/**
|
||||
* 检查更新。
|
||||
*
|
||||
* 网络与 JSON 解析全在后台线程([UpdateChecker.checkBlocking] 内部直接发请求),
|
||||
* 结果回主线程再决定弹什么。失败必须带上原因——只说"检查更新失败"等于没提示,
|
||||
* 用户只会反复点。
|
||||
*/
|
||||
private fun checkUpdate() {
|
||||
if (updateBusy) {
|
||||
Toast.makeText(this, "正在检查更新,请稍候…", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
updateBusy = true
|
||||
Toast.makeText(this, "正在检查更新…", Toast.LENGTH_SHORT).show()
|
||||
Thread {
|
||||
val r = UpdateChecker.checkBlocking()
|
||||
ui.post {
|
||||
updateBusy = false
|
||||
when (r) {
|
||||
is UpdateChecker.Result.Failed ->
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("检查更新失败")
|
||||
.setMessage("${r.message}\n\n如果长期失败,可以到 Gitea 的 Releases 页面手动下载安装。")
|
||||
.setPositiveButton("知道了", null)
|
||||
.show()
|
||||
is UpdateChecker.Result.UpToDate ->
|
||||
Toast.makeText(this, "已是最新版本", Toast.LENGTH_SHORT).show()
|
||||
is UpdateChecker.Result.HasUpdate -> {
|
||||
val cur = UpdateChecker.currentVersionCode(this)
|
||||
if (r.info.versionCode <= cur) {
|
||||
Toast.makeText(this, "已是最新版本", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
promptUpdate(r.info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun promptUpdate(info: UpdateChecker.Info) {
|
||||
val sizeMb = "%.1f".format(info.sizeBytes / 1024.0 / 1024.0)
|
||||
val notes = info.notes.take(400).ifBlank { "(无更新说明)" }
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("发现新版本 ${info.versionName}")
|
||||
.setMessage(
|
||||
"当前版本:${UpdateChecker.currentVersionName(this)}\n" +
|
||||
"新版本:${info.versionName}(${sizeMb} MB)\n\n$notes"
|
||||
)
|
||||
.setPositiveButton("下载并安装") { _, _ -> startDownload(info) }
|
||||
.setNegativeButton("稍后", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并拉起安装。
|
||||
*
|
||||
* 两个前置:① Android 8+ 需要「安装未知来源应用」授权,未授权时**先引导去开**,
|
||||
* 而不是等下载完才失败——几百 MB 下完才发现装不了最挫;② 下载进度用不可取消的
|
||||
* 对话框挡住,避免用户中途退出留下半截文件(`downloadBlocking` 虽然会校验 SHA,
|
||||
* 但让用户明确知道"正在下"比静默重试好)。
|
||||
*/
|
||||
private fun startDownload(info: UpdateChecker.Info) {
|
||||
if (!UpdateChecker.canInstall(this)) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("需要安装权限")
|
||||
.setMessage("系统要求先允许「安装未知来源应用」才能装新版本。打开后回来再点一次「检查更新」。")
|
||||
.setPositiveButton("去设置") { _, _ -> UpdateChecker.openInstallPermission(this) }
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val density = resources.displayMetrics.density
|
||||
val box = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(
|
||||
(20 * density).toInt(), (16 * density).toInt(),
|
||||
(20 * density).toInt(), (8 * density).toInt()
|
||||
)
|
||||
}
|
||||
val bar = android.widget.ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal)
|
||||
.apply { max = 100 }
|
||||
box.addView(
|
||||
bar,
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (6 * density).toInt())
|
||||
)
|
||||
val label = TextView(this).apply {
|
||||
text = "0%"
|
||||
textSize = 13f
|
||||
setTextColor(pal.sub)
|
||||
setPadding(0, (8 * density).toInt(), 0, 0)
|
||||
}
|
||||
box.addView(label)
|
||||
|
||||
val dlg = AlertDialog.Builder(this)
|
||||
.setTitle("正在下载 ${info.versionName}")
|
||||
.setView(box)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
dlg.show()
|
||||
|
||||
Thread {
|
||||
val result = runCatching {
|
||||
UpdateChecker.downloadBlocking(this, info) { pct ->
|
||||
ui.post { bar.progress = pct; label.text = "$pct%" }
|
||||
}
|
||||
}
|
||||
ui.post {
|
||||
dlg.dismiss()
|
||||
result.onSuccess { apk ->
|
||||
// 交给系统安装器:装不装由用户点,App 不做静默安装
|
||||
runCatching { UpdateChecker.install(this, apk) }
|
||||
.onFailure { e ->
|
||||
Log.e(TAG, "install failed", e)
|
||||
Toast.makeText(
|
||||
this, "无法拉起安装器:${e.message}", Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.e(TAG, "update download failed", e)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("下载失败")
|
||||
.setMessage(e.message ?: e.javaClass.simpleName)
|
||||
.setPositiveButton("知道了", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ---------------- 授权 ----------------
|
||||
|
||||
private fun newToken(): String =
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
/**
|
||||
* 记忆卡的数据模型(从 `MemoryStore` 里提出来,使其可被纯 JVM 单测直接引用)。
|
||||
*
|
||||
* 迁移原则(见 docs/architecture/reconcile-four-ops.md §4.2):
|
||||
* - 已发布字段 `id/title/content/tags/importance/created/updated/hits/longTerm/emotion`
|
||||
* 的名字、类型、语义**一律不变**。
|
||||
* - 新增字段全部带默认值,且默认值语义 = "旧数据 / 未知",因此老 JSON 反序列化不需要任何转换代码。
|
||||
* - `schemaVersion = 0` 表示"老数据,尚未补抽取";此时 `subject/attribute` 为空 → **不可比** →
|
||||
* 只可能 ADD / NOOP,绝不可能被 SUPERSEDE/UPDATE 误伤。
|
||||
*/
|
||||
enum class MemStatus { ACTIVE, SHADOW }
|
||||
|
||||
enum class SlotType { SINGLE, MULTI, UNKNOWN }
|
||||
|
||||
data class Memory(
|
||||
// ===== 已发布字段:名字、类型、语义一律不变 =====
|
||||
val id: String,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val tags: List<String> = emptyList(),
|
||||
val importance: Int = 2, // 1~3
|
||||
val created: Long = System.currentTimeMillis(),
|
||||
val updated: Long = System.currentTimeMillis(),
|
||||
val hits: Int = 0,
|
||||
val longTerm: Boolean = false,
|
||||
val emotion: String = "",
|
||||
|
||||
// ===== 新增:结构与生命周期(schemaVersion >= 1)=====
|
||||
val schemaVersion: Int = 0, // 0 = 老数据,尚未补抽取
|
||||
val status: MemStatus = MemStatus.ACTIVE,
|
||||
val isLatest: Boolean = true,
|
||||
val supersedes: List<String> = emptyList(), // 新 → 旧(方向同 reconciler.py)
|
||||
val supersededBy: String? = null, // 旧 → 新(冗余反向指针,可从 supersedes 完整推导)
|
||||
val supersedeReason: String = "",
|
||||
|
||||
// ===== 新增:判定用结构化字段(对用户不可见)=====
|
||||
val subject: String = "", // "" = 未知 → 不可比
|
||||
val attribute: String = "", // "" = 未知 → 不可比
|
||||
val slotType: SlotType = SlotType.UNKNOWN,
|
||||
val polarity: Int = 0, // 0 = 未知
|
||||
val valueTokens: List<String> = emptyList(),
|
||||
val validFrom: Long = 0L, // 0 表示"取 created"
|
||||
val validTo: Long? = null, // null = 仍在有效期
|
||||
|
||||
/**
|
||||
* 本卡正文是**一次 R4 合并的产物**(`旧正文;新正文`)。
|
||||
*
|
||||
* 这是 QA-003-B01 的结构性闸门:合并产物**不许再作为合并目标**,否则
|
||||
* 「合并 → 再合并 → 再合并」会形成正反馈,正文单调增长且没有上界
|
||||
* (600 条同主题卡实测单卡涨到 3.7 万字、落库 35MB,整理直接失败)。
|
||||
*
|
||||
* 语义边界(刻意收窄):只挡 R4 合并。合并产物**仍然可以被 SUPERSEDE、仍然可以被判冗余**
|
||||
* ——它已经取代了被合并的那些卡,必须能被后续的矛盾/演变/重复正常处理。
|
||||
*
|
||||
* 默认 `false` = 旧数据 / 普通卡,因此老 JSON 反序列化不需要任何转换代码。
|
||||
*/
|
||||
val mergedInto: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 一条记忆被压成的结构化元组(§2.1)。`topicKey` 严格相等才算"同主题"(§2.2)。
|
||||
*/
|
||||
data class FactKey(
|
||||
val subject: String,
|
||||
val attribute: String,
|
||||
val slotType: SlotType,
|
||||
val valueTokens: List<String>,
|
||||
val polarity: Int,
|
||||
val changeCue: Boolean
|
||||
) {
|
||||
val topicKey: String get() = "$subject|$attribute"
|
||||
|
||||
companion object {
|
||||
/** 抽取失败 / 老数据未迁移时的占位:不可比。 */
|
||||
val UNKNOWN = FactKey("", "", SlotType.UNKNOWN, emptyList(), 0, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* 记忆卡的「读结果」——三态,**不再用"空列表"兼任"解析失败"**
|
||||
* ============================================================================
|
||||
*
|
||||
* 为什么必须分开(QA-004-B01,Major / 数据安全):
|
||||
*
|
||||
* 旧实现的读入口是一个二义函数:
|
||||
* ```kotlin
|
||||
* fun fromJson(raw: String): MutableList<Memory> = runCatching { … }.getOrDefault(mutableListOf())
|
||||
* // ^^^^^^^^^^^ 解析失败 == 空数据
|
||||
* ```
|
||||
* 于是"存储损坏"和"真的没有记忆"在上层看来是同一件事。后果链是:
|
||||
* 1. `memory_cards` 一旦损坏(截断 / 外部写入 / 编码问题),`load()` 返回空列表;
|
||||
* 2. 上层认为"用户没有记忆"→ 触发种子注入(8 条内置卡);
|
||||
* 3. 任何写路径随后把"空列表 + 新内容"写回磁盘。
|
||||
* → **用户的全部记忆被静默覆盖,没有任何提示**(QA 实测:注入截断 JSON 后启动 App,
|
||||
* `memory_cards` 被换成 8 条内置种子卡,原 payload 消失)。
|
||||
*
|
||||
* 现在的三态语义:
|
||||
* - [Ok] :解析成功。**含合法的空数组 `[]`**(真空 ≠ 损坏)。
|
||||
* - [Absent] :key 根本不存在(首次安装 / 刚 clear)。真空,允许正常初始化。
|
||||
* - [Corrupt] :key 存在但解析不出结构。**必须进入只读保护态**,绝不能当"没有记忆"。
|
||||
*/
|
||||
sealed class LoadResult {
|
||||
data class Ok(
|
||||
val list: MutableList<Memory>,
|
||||
/** 是否由 `memory_cards_bak` 回退而来(上层可据此提示/审计)。 */
|
||||
val recoveredFromBackup: Boolean = false
|
||||
) : LoadResult()
|
||||
|
||||
object Absent : LoadResult()
|
||||
|
||||
data class Corrupt(val raw: String, val reason: String) : LoadResult()
|
||||
}
|
||||
|
||||
/** 本次读取是否处于"损坏"态。 */
|
||||
val LoadResult.isCorrupt: Boolean get() = this is LoadResult.Corrupt
|
||||
|
||||
/** 只读展示用(损坏 / 缺失都给空列表)。**写路径禁止用它**——写路径必须显式判 [Corrupt]。 */
|
||||
fun LoadResult.listOrEmpty(): MutableList<Memory> =
|
||||
if (this is LoadResult.Ok) list else mutableListOf()
|
||||
|
||||
/** 写路径的返回值:**拒绝**必须与"没有变更"区分开,否则上层无法给出提示。 */
|
||||
enum class MemWriteResult {
|
||||
/** 真的改了,已落盘。 */
|
||||
CHANGED,
|
||||
|
||||
/** 幂等:算下来无需改动(NOOP)。 */
|
||||
UNCHANGED,
|
||||
|
||||
/** 存储处于损坏只读态,**本次写入被拒绝,磁盘一个字节也没动**。 */
|
||||
REJECTED_CORRUPT
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储损坏时的受控异常。
|
||||
*
|
||||
* 只在 [MemoryPersist.consolidate] 这类"整批操作"里抛(让 UI 的 `runCatching` 接住并提示
|
||||
* 「整理失败,记忆未改动」);逐条写入(add / remove / applyOps)一律**返回**
|
||||
* [MemWriteResult.REJECTED_CORRUPT],不抛——它们有的跑在 Service 的 `onCreate` 里,
|
||||
* 抛出去会直接崩进程。
|
||||
*/
|
||||
class StorageCorruptException(val reason: String) :
|
||||
IllegalStateException("记忆存储损坏,已阻止写入:$reason")
|
||||
|
||||
/**
|
||||
* 极小的键值存储抽象。
|
||||
*
|
||||
* 存在的唯一理由:让"损坏 / 备份 / 拒绝写入"这些**最需要被验的**分支能在 JVM 单测里真跑
|
||||
* (生产实现 = SharedPreferences,见 [PrefsKV])。这也是本文件不 import 任何 `android.*` 的原因。
|
||||
*/
|
||||
interface MemoryKV {
|
||||
fun getString(key: String, def: String?): String?
|
||||
/** 一次性提交多个键(value = null 表示删除)。与 `SharedPreferences.Editor.apply()` 同语义:整批原子生效。 */
|
||||
fun write(puts: Map<String, String?>)
|
||||
}
|
||||
|
||||
/** 生产实现:SharedPreferences 的一层薄适配(**唯一**依赖 android.* 的地方)。 */
|
||||
class PrefsKV(private val prefs: android.content.SharedPreferences) : MemoryKV {
|
||||
override fun getString(key: String, def: String?): String? = prefs.getString(key, def)
|
||||
override fun write(puts: Map<String, String?>) {
|
||||
val e = prefs.edit()
|
||||
puts.forEach { (k, v) -> if (v == null) e.remove(k) else e.putString(k, v) }
|
||||
e.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 SharedPreferences **物理隔离**的独立文件备份(只有一份快照,内容 = 最近一次成功落盘的状态)。
|
||||
*
|
||||
* ## 为什么 prefs 内的 `memory_cards_bak` 还不够(这是"假安全感")
|
||||
*
|
||||
* `memory_cards_bak` 和 `memory_cards` 住在同一个 `pet_prefs.xml` 里。所以只要那个**文件整体**
|
||||
* 坏掉(写盘途中进程被杀 / 磁盘满 / 系统升级迁移失败留下半截 XML),备份就跟着一起没了 ——
|
||||
* 而 Android 的 `SharedPreferences` 在 XML 解析失败时**不会崩、也不会报错**,它会打一行日志然后
|
||||
* 返回一份**空的** prefs。于是我们只能看到 `Absent`("首次安装"),接着种子注入把 8 条内置卡
|
||||
* 写进去 —— **同一个静默覆盖,只是换了个入口。**
|
||||
*
|
||||
* 独立文件把这份兜底挪到另一个介质上:
|
||||
* - 读写都不经过 SharedPreferences,prefs 怎么坏都不影响它;
|
||||
* - 内容永远取自"最近一次成功落盘的内存快照",因此**不会比 `memory_cards_bak` 更旧**
|
||||
* (bak 故意存上一版,文件存当前版);
|
||||
* - 写入失败一律**降级**:只记一条审计 + 一行日志,绝不阻断主流程(prefs 已经落盘了)。
|
||||
*/
|
||||
interface MemoryFileBackup {
|
||||
/** 读不到 / 读失败一律返回 null(不抛)。 */
|
||||
fun read(): String?
|
||||
|
||||
/** @return 是否写成功。false 只是"这次兜底没生效",调用方不得因此失败。 */
|
||||
fun write(raw: String): Boolean
|
||||
|
||||
fun delete(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产实现:`filesDir/memory_cards.bak.json`,写入走"临时文件 + rename"。
|
||||
*
|
||||
* 用 rename 而不是直接覆盖:中途被杀时只会留下一个残缺的 `.tmp`,**主备份文件永远是完整的某一版**,
|
||||
* 不会出现半截 JSON 被当成"备份可用但其实坏了"。
|
||||
*/
|
||||
class AtomicFileBackup(private val file: java.io.File) : MemoryFileBackup {
|
||||
|
||||
override fun read(): String? = try {
|
||||
if (file.exists()) file.readText() else null
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun write(raw: String): Boolean = try {
|
||||
val dir = file.parentFile
|
||||
if (dir != null && !dir.exists()) dir.mkdirs()
|
||||
val tmp = java.io.File(dir, file.name + ".tmp")
|
||||
tmp.writeText(raw)
|
||||
moveAtomically(tmp, file)
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun delete(): Boolean = try {
|
||||
// 顺手清掉可能残留的 .tmp
|
||||
java.io.File(file.parentFile, file.name + ".tmp").delete()
|
||||
!file.exists() || file.delete()
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun moveAtomically(from: java.io.File, to: java.io.File): Boolean =
|
||||
try {
|
||||
// minSdk 26 → java.nio.file 可用;ATOMIC_MOVE 在同一文件系统内是 rename(2)
|
||||
java.nio.file.Files.move(
|
||||
from.toPath(), to.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
// 少数机型/文件系统不支持 ATOMIC_MOVE → 退回"先删后改名"(仍只用一次改名,窗口极小)
|
||||
if (to.exists()) to.delete()
|
||||
from.renameTo(to)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON 编解码(纯函数,无状态)
|
||||
// ============================================================================
|
||||
|
||||
object MemoryCodec {
|
||||
|
||||
/**
|
||||
* §4.2 反序列化:新增字段一律 `opt*` + 默认值,缺失即"未知"。
|
||||
* 老 JSON 不需要任何转换代码;`schemaVersion=0` 标记"未补抽取"。
|
||||
*
|
||||
* **返回值是 [LoadResult]**:解析失败返回 [LoadResult.Corrupt](带原文,便于排障),
|
||||
* 绝不返回空列表冒充"没有记忆"。
|
||||
*/
|
||||
fun parse(raw: String): LoadResult {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return LoadResult.Corrupt(raw, "内容为空字符串")
|
||||
val arr = try {
|
||||
JSONArray(trimmed)
|
||||
} catch (e: Throwable) {
|
||||
return LoadResult.Corrupt(
|
||||
raw, "非法 JSON:${e.javaClass.simpleName}:${(e.message ?: "").lineSequence().first().take(160)}"
|
||||
)
|
||||
}
|
||||
val out = ArrayList<Memory>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
out.add(fromObject(o))
|
||||
}
|
||||
// 结构校验:非空数组却一条对象都读不出来 → 形状非法(被写成数字/字符串数组),
|
||||
// 这属于"损坏",不能当"没有记忆"。
|
||||
if (out.isEmpty() && arr.length() > 0) {
|
||||
return LoadResult.Corrupt(raw, "数组元素全部不是对象(length=${arr.length()})")
|
||||
}
|
||||
return LoadResult.Ok(out)
|
||||
}
|
||||
|
||||
private fun fromObject(o: JSONObject): Memory {
|
||||
val created = o.optLong("created", System.currentTimeMillis())
|
||||
return Memory(
|
||||
// ===== 已发布字段 =====
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
content = o.optString("content"),
|
||||
tags = o.optJSONArray("tags")?.toStringList() ?: emptyList(),
|
||||
importance = o.optInt("importance", 2).coerceIn(1, 3),
|
||||
created = created,
|
||||
updated = o.optLong("updated", created),
|
||||
hits = o.optInt("hits", 0),
|
||||
longTerm = o.optBoolean("longTerm", false),
|
||||
emotion = o.optString("emotion", ""),
|
||||
// ===== 新增:结构 / 生命周期(缺失默认 = 旧数据语义)=====
|
||||
schemaVersion = o.optInt("schemaVersion", 0),
|
||||
status = o.optString("status", "ACTIVE").let { s ->
|
||||
runCatching { MemStatus.valueOf(s) }.getOrDefault(MemStatus.ACTIVE)
|
||||
},
|
||||
isLatest = o.optBoolean("isLatest", true),
|
||||
supersedes = o.optJSONArray("supersedes")?.toStringList() ?: emptyList(),
|
||||
// QA-002-B07:`optString` 会把 JSON null 读成**字符串** "null",此后每次 save 都把它
|
||||
// 原样写回去,永久污染,`forgettable()` 的 `supersededBy == null` 也就永远不成立。
|
||||
// 这里改成 isNull() 判定,并把存量字符串 "null" 一并归一成真 null
|
||||
//(读时归一 + 下次 save 即落盘清洗,无需单独的迁移脚本)。
|
||||
supersededBy = o.optNullableString("supersededBy"),
|
||||
supersedeReason = o.optString("supersedeReason", ""),
|
||||
// ===== 新增:判定用字段(缺失 = 不可比 / 不参与矛盾判定)=====
|
||||
subject = o.optString("subject", ""),
|
||||
attribute = o.optString("attribute", ""),
|
||||
slotType = o.optString("slotType", "UNKNOWN").let { s ->
|
||||
runCatching { SlotType.valueOf(s) }.getOrDefault(SlotType.UNKNOWN)
|
||||
},
|
||||
polarity = o.optInt("polarity", 0),
|
||||
valueTokens = o.optJSONArray("valueTokens")?.toStringList() ?: emptyList(),
|
||||
validFrom = o.optLong("validFrom", 0L).takeIf { it > 0L } ?: created,
|
||||
validTo = if (o.has("validTo") && !o.isNull("validTo")) o.optLong("validTo") else null,
|
||||
// 缺失默认 false = 普通卡;这条标记必须在重启后仍然生效,
|
||||
// 否则重启一次"合并产物不再被合并"的结构性闸门就失效了
|
||||
mergedInto = o.optBoolean("mergedInto", false)
|
||||
)
|
||||
}
|
||||
|
||||
/** 抽出纯函数:单测可直接测 600 条的序列化耗时。老版 app 读新 JSON 时未知 key 被忽略。 */
|
||||
fun toJsonArray(list: List<Memory>): JSONArray = JSONArray().also { arr ->
|
||||
list.forEach { m ->
|
||||
arr.put(
|
||||
JSONObject()
|
||||
// ===== 已发布字段(键名与语义不变)=====
|
||||
.put("id", m.id).put("title", m.title).put("content", m.content)
|
||||
.put("tags", JSONArray(m.tags)).put("importance", m.importance)
|
||||
.put("created", m.created).put("updated", m.updated).put("hits", m.hits)
|
||||
.put("longTerm", m.longTerm).put("emotion", m.emotion)
|
||||
// ===== 新增字段。老版 app 的 JSONObject 会忽略它们,`content/title` 语义未变,
|
||||
// 所以老版仍能正常显示新卡(只是不认 SHADOW)。
|
||||
.put("schemaVersion", m.schemaVersion).put("status", m.status.name)
|
||||
.put("isLatest", m.isLatest).put("supersedes", JSONArray(m.supersedes))
|
||||
.put("supersededBy", (m.supersededBy ?: JSONObject.NULL) as Any)
|
||||
.put("supersedeReason", m.supersedeReason)
|
||||
.put("subject", m.subject).put("attribute", m.attribute)
|
||||
.put("slotType", m.slotType.name).put("polarity", m.polarity)
|
||||
.put("valueTokens", JSONArray(m.valueTokens))
|
||||
.put("validFrom", m.validFrom)
|
||||
.put("validTo", (m.validTo ?: JSONObject.NULL) as Any)
|
||||
.put("mergedInto", m.mergedInto)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 容量裁剪:长期事实卡(longTerm)永远保留;其余按「链上节点 → 更新时间新 → 重要度高」保留,
|
||||
* 并保持列表原有相对顺序。
|
||||
*
|
||||
* 修复 1(历史):原实现 `list.take(MAX_CARDS)` 保留的是列表**前** N 条,而新卡通过 `list.add()`
|
||||
* 追加在**末尾**,导致记忆满额后最新写入的卡被静默丢弃,旧卡反而一直留着。
|
||||
*
|
||||
* 修复 2(本次):链上节点(`supersedes`/`supersededBy` 非空)优先保留。
|
||||
* 若把链中的 SHADOW 裁掉,仍指向它的 `supersededBy` 会变成悬空指针,破坏不变量 I2,
|
||||
* 演化链也就无从回看。
|
||||
*/
|
||||
fun trimToCapacity(list: List<Memory>, maxCards: Int): List<Memory> {
|
||||
if (list.size <= maxCards) return list
|
||||
val keep = HashSet<Int>()
|
||||
list.forEachIndexed { i, m -> if (m.longTerm) keep.add(i) }
|
||||
val remaining = (maxCards - keep.size).coerceAtLeast(0)
|
||||
list.indices.filter { it !in keep }
|
||||
.sortedWith(
|
||||
compareByDescending<Int> { chainLinked(list[it]) }
|
||||
.thenByDescending { list[it].updated }
|
||||
.thenByDescending { list[it].importance }
|
||||
)
|
||||
.take(remaining)
|
||||
.forEach { keep.add(it) }
|
||||
return list.filterIndexed { i, _ -> i in keep }
|
||||
}
|
||||
|
||||
private fun chainLinked(m: Memory) = m.supersedes.isNotEmpty() || m.supersededBy != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一个**可空**字符串字段。
|
||||
*
|
||||
* `JSONObject.optString(key)` 对 `JSONObject.NULL` 返回字符串 `"null"`(不是 null 引用),
|
||||
* 于是一个可空字段一旦被写成 JSON null,读回来就变成了长度为 4 的字符串,
|
||||
* 再 save 回去就永久固定下来。这条路径就是 QA-002-B07 的成因。
|
||||
*
|
||||
* 统一在这里收口:缺失 / JSON null / 空串 / 字面量 "null" 一律归一成 `null`。
|
||||
*/
|
||||
internal fun JSONObject.optNullableString(key: String): String? {
|
||||
if (!has(key) || isNull(key)) return null
|
||||
val v = optString(key, "")
|
||||
return v.trim().ifEmpty { null }?.takeIf { it != "null" }
|
||||
}
|
||||
|
||||
internal fun JSONArray.toStringList(): List<String> =
|
||||
(0 until length()).mapNotNull { i ->
|
||||
// 数组里也可能混进 JSON null(同样是 optString → "null" 的坑)
|
||||
if (isNull(i)) null else optString(i).trim().ifEmpty { null }?.takeIf { it != "null" }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 持久化内核:三态读 + 只读保护态 + 上一版备份 + 独立文件兜底
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 记忆卡的持久化内核(**纯 JVM**,无 `android.*` 依赖)。
|
||||
*
|
||||
* 四条不变量:
|
||||
* - **I-A(读三态)**:读只有 [LoadResult.Ok] / [LoadResult.Absent] / [LoadResult.Corrupt],
|
||||
* "解析失败"永远不以"空列表"的形式流到上层。
|
||||
* - **I-B(损坏即只读)**:一旦读到 [LoadResult.Corrupt],除用户**显式** [clear] 外,
|
||||
* 一切写入都拒绝(`add` / `remove` / `applyOps` / `consolidate`),
|
||||
* 每次都 `Log.e` + 一条 `op="FAILED"` 审计,磁盘内容**一个字节都不动**。
|
||||
* - **I-C(写好之前先留旧)**:每次落盘的主键与"上一版已验证可解析的 raw"在**同一个
|
||||
* `apply()` 里**一起提交 —— 要么都落,要么都不落,因此绝不会把坏数据备进 `_bak`;
|
||||
* 主键坏了可自动回退,用户不该感知到丢数据。
|
||||
* - **I-D(跨介质兜底)**:每次成功落盘后,把**当前**内容镜像到一份与 prefs 物理隔离的
|
||||
* [MemoryFileBackup](生产 = `filesDir/memory_cards.bak.json`)。
|
||||
* 这样即便整个 `pet_prefs.xml` 坏掉 → SharedPreferences 静默返回空 prefs → 我们只看到
|
||||
* [LoadResult.Absent]("第一次装"),也能靠这一份文件把记忆救回来,
|
||||
* **而不是顺着"首次安装"去写 8 条种子卡**。
|
||||
*
|
||||
* 为什么这些逻辑必须集中在一个类里(而不是散在 object 的各个方法):状态(损坏标记、
|
||||
* 上一版 raw、镜像代次)与写入决策必须由**同一把锁**保护,否则后台整理(线程 A)与手动添加
|
||||
* (主线程 B)交错时会出现"读到坏数据 → 另一个线程判定为可用 → 写回"的窗口。
|
||||
*/
|
||||
class MemoryPersist(
|
||||
private val kv: MemoryKV,
|
||||
private val maxAudit: Int,
|
||||
/** 跨介质兜底(null = 不启用;单测里多数用例不关心它)。 */
|
||||
private val fileBackup: MemoryFileBackup? = null,
|
||||
/**
|
||||
* 把"镜像到独立文件"这一步挪出调用线程(生产 = 单线程执行器,单测 = 直接执行)。
|
||||
*
|
||||
* 为什么必须能挪:`MemoryStore.add` 有一半调用点在主线程(手动添加 / 对话指令),
|
||||
* 600 卡时那份 JSON 是 1.3MB —— 同步写盘会把一次点击变成几十毫秒的掉帧。
|
||||
* 代价是镜像可能比 prefs 落后一个"崩溃窗口",因此读取时**先试独立文件再试 `_bak`**
|
||||
* (见 [recoverFromBackupLocked] 的排序理由)。
|
||||
*/
|
||||
private val runIo: ((() -> Unit) -> Unit) = { it() },
|
||||
private val logE: (String, Throwable?) -> Unit = { _, _ -> },
|
||||
private val logW: (String, Throwable?) -> Unit = { _, _ -> },
|
||||
private val clock: () -> Long = { System.currentTimeMillis() }
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "memory_cards"
|
||||
|
||||
/**
|
||||
* 上一版有效数据的备份键(QA-004-B01)。体积:600 卡正文 ≈ 1.3MB,备份后 2.6MB,
|
||||
* 可接受;且备份只在主键内容**变化**时才写(见 [saveLocked])。
|
||||
*/
|
||||
const val KEY_BAK = "memory_cards_bak"
|
||||
|
||||
const val KEY_AUDIT = "reconcile_audit"
|
||||
|
||||
/** 独立备份文件的文件名(放 `filesDir/` 下)。 */
|
||||
const val FILE_BAK_NAME = "memory_cards.bak.json"
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* 镜像代次:每次 [clear] +1。排队中的镜像任务发现代次变了就放弃写,
|
||||
* 否则"清空之后,一个排在后面的旧镜像任务把数据写回文件" → 清空的记忆会被复活。
|
||||
*/
|
||||
@Volatile
|
||||
private var mirrorEpoch: Int = 0
|
||||
|
||||
/** 上次读取失败 → 只读保护态(进程内有效,恢复条件见 [clear] / 备份回退成功)。 */
|
||||
@Volatile
|
||||
private var corrupt: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var corruptReason: String = ""
|
||||
|
||||
/** 每次"进入损坏态"只留一条 CORRUPT 审计,避免设置页反复 load 时刷屏。 */
|
||||
@Volatile
|
||||
private var corruptAudited: Boolean = false
|
||||
|
||||
/** 最近一次**已验证可解析**的 raw —— 就是下一次 [saveLocked] 要写进 [KEY_BAK] 的内容。 */
|
||||
@Volatile
|
||||
private var lastVerifiedRaw: String? = null
|
||||
|
||||
/**
|
||||
* 故障注入:置 true 后,**下一次** [consolidate] 主动抛受控异常,然后自动复位。
|
||||
*
|
||||
* 只在 debug 构建里由 [MemoryStore.consolidate] 装填(`if (BuildConfig.DEBUG)`),
|
||||
* release 里没有代码路径能把它置 true。用途:让 QA 在真机上走到"整理失败"分支,
|
||||
* 验证 `Log.e` + `op="FAILED"` 审计 + 「整理失败,记忆未改动」Toast + **数据逐字节未变**。
|
||||
*/
|
||||
@Volatile
|
||||
var debugForceConsolidateFailure: Boolean = false
|
||||
|
||||
// ---------------------------- 读 ----------------------------
|
||||
|
||||
fun isCorrupt(): Boolean = corrupt
|
||||
fun corruptReason(): String = corruptReason
|
||||
|
||||
fun load(): LoadResult = synchronized(lock) { loadLocked() }
|
||||
|
||||
private fun loadLocked(): LoadResult {
|
||||
val raw: String? = try {
|
||||
kv.getString(KEY, null)
|
||||
} catch (e: Throwable) {
|
||||
// 被外部工具写成 `<int>` / `<set>` 之类 → getString 抛 CCE。
|
||||
// 旧实现在这里直接崩在设置页(QA-004 §4.2 的推断)。现在先试备份,再归入只读保护态。
|
||||
val reason = "读取失败(类型错乱):${e.javaClass.simpleName}"
|
||||
return recoverFromBackupLocked(reason) ?: enterCorrupt("", reason, e)
|
||||
}
|
||||
// ① 主键不存在:两种可能 —— 真的首次安装,或者**整个 prefs 文件丢了**
|
||||
// (Android 的 SharedPreferences 在 XML 解析失败时**不抛异常**,只打一行日志并返回
|
||||
// 一份空 prefs,所以在我们这里与"首次安装"完全同形)。区别只在于独立备份文件还在不在:
|
||||
// 在 → 是"prefs 丢了",把记忆捞回来;不在 → 真·首次安装,允许初始化。
|
||||
if (raw == null) {
|
||||
val recovered = recoverFromFileLocked(
|
||||
"prefs 中已无 memory_cards(整文件丢失或首次安装),独立备份可用"
|
||||
)
|
||||
if (recovered != null) return recovered
|
||||
lastVerifiedRaw = null
|
||||
return LoadResult.Absent
|
||||
}
|
||||
return decodeOrRecoverLocked(raw)
|
||||
}
|
||||
|
||||
/** ②③ 解析成功直接用;失败则尝试用备份顶上(用户不该感知到丢数据)。 */
|
||||
private fun decodeOrRecoverLocked(raw: String): LoadResult {
|
||||
val parsed = MemoryCodec.parse(raw)
|
||||
if (parsed !is LoadResult.Corrupt) {
|
||||
lastVerifiedRaw = raw
|
||||
exitCorruptLocked()
|
||||
return parsed
|
||||
}
|
||||
return recoverFromBackupLocked(parsed.reason) ?: enterCorrupt(raw, parsed.reason, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用备份顶上,成功返回 [LoadResult.Ok](`recoveredFromBackup = true`),
|
||||
* 两者都不可用返回 null(调用方转入只读保护态 / 才允许按"首次安装"初始化)。
|
||||
*
|
||||
* ## 顺序:**先独立文件,再 prefs 内 `_bak`**(这个顺序是刻意的)
|
||||
*
|
||||
* - 独立文件存的是"**最近一次成功落盘的状态**"(当前版);
|
||||
* - `memory_cards_bak` 存的是"**上一版**"(它是为了在"新数据写坏了"时能退一步而设计的)。
|
||||
*
|
||||
* 所以文件永远**不会比 `_bak` 更旧**(异步镜像最多落后一次崩溃窗口,那时两者相等),
|
||||
* 先试文件 = 尽量少丢一版。举例:`[A]` → 加 B 落盘(`KEY=[A,B]`、`_bak=[A]`、文件=`[A,B]`),
|
||||
* 此时主键坏掉 —— 先读 `_bak` 会丢掉刚加的 B,先读文件才是最新的 `[A,B]`。
|
||||
*
|
||||
* 条件是"能解析**且非空**":拿 `[]` 去覆盖坏数据同样是丢数据。
|
||||
* 恢复成功后顺手把主键修回可用内容,并留一条 `op="RECOVERED"` 审计(reason 里写清来源)。
|
||||
*/
|
||||
private fun recoverFromBackupLocked(reason: String): LoadResult.Ok? =
|
||||
recoverFromFileLocked(reason) ?: recoverFromPrefsBakLocked(reason)
|
||||
|
||||
/** 从**与 prefs 物理隔离**的独立文件恢复(I-D:整文件坏掉时的唯一生路)。 */
|
||||
private fun recoverFromFileLocked(reason: String): LoadResult.Ok? {
|
||||
val fb = fileBackup ?: return null
|
||||
val raw = try {
|
||||
fb.read()
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val parsed = MemoryCodec.parse(raw)
|
||||
if (parsed !is LoadResult.Ok || parsed.list.isEmpty()) return null
|
||||
|
||||
lastVerifiedRaw = raw
|
||||
exitCorruptLocked()
|
||||
logW("已从独立备份文件恢复 ${parsed.list.size} 条($reason)", null)
|
||||
runCatching { kv.write(mapOf(KEY to raw)) }
|
||||
.onFailure { logE("恢复后回写主键失败", it) }
|
||||
appendAuditsLocked(
|
||||
listOf(
|
||||
audit(
|
||||
MemoryStore.AUDIT_OP_RECOVERED,
|
||||
"从独立文件恢复 ${parsed.list.size} 条:$reason"
|
||||
)
|
||||
)
|
||||
)
|
||||
return LoadResult.Ok(parsed.list, recoveredFromBackup = true)
|
||||
}
|
||||
|
||||
/** 从 prefs 内的 `memory_cards_bak` 恢复(同介质、与主键同一次 apply 提交,一致性最强)。 */
|
||||
private fun recoverFromPrefsBakLocked(reason: String): LoadResult.Ok? {
|
||||
val bak = try {
|
||||
kv.getString(KEY_BAK, null)
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val fromBak = MemoryCodec.parse(bak)
|
||||
if (fromBak !is LoadResult.Ok || fromBak.list.isEmpty()) return null
|
||||
|
||||
lastVerifiedRaw = bak
|
||||
exitCorruptLocked()
|
||||
logW("memory_cards 损坏,已从备份恢复 ${fromBak.list.size} 条($reason)", null)
|
||||
// 顺手把主键修回可用内容:下一次读到的不再是坏数据
|
||||
runCatching { kv.write(mapOf(KEY to bak)) }
|
||||
.onFailure { logE("备份回写主键失败", it) }
|
||||
appendAuditsLocked(
|
||||
listOf(
|
||||
audit(
|
||||
MemoryStore.AUDIT_OP_RECOVERED,
|
||||
"从备份恢复 ${fromBak.list.size} 条:$reason"
|
||||
)
|
||||
)
|
||||
)
|
||||
return LoadResult.Ok(fromBak.list, recoveredFromBackup = true)
|
||||
}
|
||||
|
||||
private fun enterCorrupt(raw: String, reason: String, cause: Throwable?): LoadResult.Corrupt {
|
||||
corrupt = true
|
||||
corruptReason = reason
|
||||
if (!corruptAudited) {
|
||||
corruptAudited = true
|
||||
logE("记忆存储损坏,进入只读保护态(拒绝一切自动写入):$reason", cause)
|
||||
appendAuditsLocked(listOf(audit(MemoryStore.AUDIT_OP_FAILED, "STORAGE_CORRUPT:$reason")))
|
||||
}
|
||||
return LoadResult.Corrupt(raw, reason)
|
||||
}
|
||||
|
||||
private fun exitCorruptLocked() {
|
||||
if (!corrupt) return
|
||||
corrupt = false
|
||||
corruptReason = ""
|
||||
corruptAudited = false
|
||||
}
|
||||
|
||||
// ---------------------------- 写 ----------------------------
|
||||
|
||||
/**
|
||||
* 唯一落盘入口。
|
||||
*
|
||||
* 主键与备份在**同一个** `write()` 里提交([MemoryKV.write] 整批原子):
|
||||
* - 备份内容 = `lastVerifiedRaw`(上次**成功解析过**的原文),因此永远不会把坏数据备进去;
|
||||
* - 主键内容 = 本次要写的新 JSON;
|
||||
* - 两者同生共死 → 不会出现"备份写了、主键没写"的半成品窗口。
|
||||
*
|
||||
* 之后再异步镜像一份到**独立文件**(I-D,跨介质兜底)。镜像失败不影响本次落盘。
|
||||
*
|
||||
* @throws StorageCorruptException 处于只读保护态时(这是兜底:正常路径在调用方就已拒绝)
|
||||
*/
|
||||
fun save(list: List<Memory>) = synchronized(lock) { saveLocked(list) }
|
||||
|
||||
private fun saveLocked(list: List<Memory>) {
|
||||
if (corrupt) throw StorageCorruptException(corruptReason)
|
||||
val json = MemoryCodec.toJsonArray(
|
||||
MemoryCodec.trimToCapacity(list, MemoryStore.MAX_CARDS)
|
||||
).toString()
|
||||
val puts = LinkedHashMap<String, String?>(2)
|
||||
puts[KEY] = json
|
||||
val bak = lastVerifiedRaw
|
||||
// 内容没变就不重复写备份(幂等整理时省一半 IO)
|
||||
if (bak != null && bak != json) puts[KEY_BAK] = bak
|
||||
kv.write(puts)
|
||||
lastVerifiedRaw = json
|
||||
mirrorToFileLocked(json)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把**当前**内容镜像到独立备份文件(I-D)。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **不阻断主流程**:写失败只 `Log.w` + 一条 `op="FAILED"` 审计(reason 前缀
|
||||
* `BACKUP_FILE_WRITE_FAILED`),prefs 已经落盘,业务不能因为兜底没生效而失败;
|
||||
* 2. **不阻塞调用线程**:内容可能 1.3MB,且 `add` 有一半调用点在主线程 → 交给 [runIo];
|
||||
* 3. **不许被 clear 复活**:任务带着 [mirrorEpoch] 出去,代次变了就放弃写
|
||||
* (`clear()` 会先 +1 再删文件)。
|
||||
*/
|
||||
private fun mirrorToFileLocked(json: String) {
|
||||
val fb = fileBackup ?: return
|
||||
val epoch = mirrorEpoch
|
||||
runIo {
|
||||
if (epoch != mirrorEpoch) return@runIo // 已被 clear 作废:别把旧数据写回去
|
||||
val ok = try {
|
||||
fb.write(json)
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
if (!ok) {
|
||||
synchronized(lock) {
|
||||
logW("独立备份文件写入失败(不影响主流程):${fb::class.simpleName}", null)
|
||||
appendAuditsLocked(
|
||||
listOf(
|
||||
audit(
|
||||
MemoryStore.AUDIT_OP_FAILED,
|
||||
"BACKUP_FILE_WRITE_FAILED:prefs 已正常落盘,独立备份未更新"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动添加 / 自动抽取 / 种子写入的**唯一落库入口**(语义见 [MemoryStore.addTo])。
|
||||
*
|
||||
* 损坏只读态下**拒绝执行**:`Log.e` + 一条 `op="FAILED"` 审计 + 返回
|
||||
* [MemWriteResult.REJECTED_CORRUPT],磁盘内容不动。
|
||||
*/
|
||||
fun add(
|
||||
title: String,
|
||||
content: String,
|
||||
tags: List<String> = emptyList(),
|
||||
importance: Int = 2,
|
||||
longTerm: Boolean = false,
|
||||
now: Long = clock(),
|
||||
idGen: () -> String = { java.util.UUID.randomUUID().toString().take(8) }
|
||||
): MemWriteResult = synchronized(lock) {
|
||||
val lr = loadLocked()
|
||||
if (lr is LoadResult.Corrupt) {
|
||||
rejectLocked("ADD")
|
||||
return MemWriteResult.REJECTED_CORRUPT
|
||||
}
|
||||
val list = lr.listOrEmpty().toMutableList()
|
||||
val (changed, audits) =
|
||||
MemoryStore.addTo(list, title, content, tags, importance, longTerm, now, idGen)
|
||||
if (!changed) return MemWriteResult.UNCHANGED
|
||||
saveLocked(list)
|
||||
appendAuditsLocked(audits)
|
||||
MemWriteResult.CHANGED
|
||||
}
|
||||
|
||||
fun remove(id: String): MemWriteResult = synchronized(lock) {
|
||||
val lr = loadLocked()
|
||||
if (lr is LoadResult.Corrupt) {
|
||||
rejectLocked("REMOVE")
|
||||
return MemWriteResult.REJECTED_CORRUPT
|
||||
}
|
||||
val before = lr.listOrEmpty()
|
||||
val after = before.filterNot { it.id == id }
|
||||
if (after.size == before.size) return MemWriteResult.UNCHANGED
|
||||
saveLocked(after)
|
||||
MemWriteResult.CHANGED
|
||||
}
|
||||
|
||||
/**
|
||||
* **用户显式**清空 —— 这是唯一允许在损坏态下执行的写入。
|
||||
*
|
||||
* 理由:清空是用户的明确意图,不是"自动覆盖"。三份数据要一起删干净:
|
||||
* - 主键 `memory_cards`;
|
||||
* - prefs 内备份 `memory_cards_bak`(否则下次主键出问题会把刚清掉的记忆"复活");
|
||||
* - **独立备份文件**(同上,且它比 `_bak` 还新一版,不删就一定会复活)。
|
||||
*
|
||||
* 删文件是**同步**做的:一次 unlink 很便宜,而"清空之后文件还在"是直接违背用户意图的事故。
|
||||
* 同时把 [mirrorEpoch] +1,作废掉所有还在排队的镜像任务(否则它会把数据写回文件)。
|
||||
*
|
||||
* 这也是损坏态的**人工恢复出口**。
|
||||
*/
|
||||
fun clear() = synchronized(lock) {
|
||||
val wasCorrupt = corrupt
|
||||
mirrorEpoch++ // 先作废排队中的镜像任务,再删
|
||||
try {
|
||||
kv.write(mapOf(KEY to null, KEY_BAK to null))
|
||||
} finally {
|
||||
lastVerifiedRaw = null
|
||||
exitCorruptLocked()
|
||||
}
|
||||
fileBackup?.let { fb ->
|
||||
val deleted = try {
|
||||
fb.delete()
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
if (!deleted) {
|
||||
// 删不掉就写一块"墓碑":恢复逻辑要求备份**非空**才肯用,
|
||||
// 所以 `[]` 等价于"没有备份"。否则下次启动会把用户刚清掉的记忆复活。
|
||||
val tombstoned = try {
|
||||
fb.write("[]")
|
||||
} catch (e: Throwable) {
|
||||
false
|
||||
}
|
||||
logW(
|
||||
if (tombstoned) "独立备份文件删除失败,已写空墓碑(不会被恢复)"
|
||||
else "独立备份文件删除失败,墓碑也写不进去(下次启动可能触发一次恢复)",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
logW(if (wasCorrupt) "用户显式清空记忆(解除损坏只读态)" else "用户显式清空记忆", null)
|
||||
appendAuditsLocked(
|
||||
listOf(
|
||||
audit(
|
||||
MemoryStore.AUDIT_OP_CLEARED,
|
||||
if (wasCorrupt) "用户清空(原状态:损坏只读)" else "用户清空"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用模型给出的 ADD / UPDATE / DELETE 操作(Mem0 式)。
|
||||
*
|
||||
* 批量的第一件事就是判损坏:否则 600 条 op 会写 600 条拒绝审计,把环形缓冲冲掉。
|
||||
*/
|
||||
fun applyOps(ops: List<MemoryStore.Op>): Int = synchronized(lock) {
|
||||
if (corrupt) {
|
||||
rejectLocked("APPLY_OPS")
|
||||
return 0
|
||||
}
|
||||
var changed = 0
|
||||
ops.forEach { op ->
|
||||
val t = op.title.trim()
|
||||
if (t.isEmpty()) return@forEach
|
||||
when (op.action.uppercase()) {
|
||||
"DELETE" -> {
|
||||
val lr = loadLocked()
|
||||
if (lr is LoadResult.Corrupt) {
|
||||
rejectLocked("DELETE")
|
||||
return@forEach
|
||||
}
|
||||
val list = lr.listOrEmpty()
|
||||
val idx = list.indexOfFirst { MemoryStore.similar(it.title, t) }
|
||||
if (idx >= 0) {
|
||||
val after = list.toMutableList().also { it.removeAt(idx) }
|
||||
saveLocked(after)
|
||||
changed++
|
||||
}
|
||||
}
|
||||
else -> if (add(t, op.content, op.tags, op.importance, op.longTerm) ==
|
||||
MemWriteResult.CHANGED
|
||||
) changed++
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/**
|
||||
* 整理:迁移 → 判定内核 → 收窄的遗忘 → 落盘。
|
||||
*
|
||||
* @throws StorageCorruptException 损坏只读态(UI 的 `runCatching` 会接住 → 「整理失败,记忆未改动」)
|
||||
* @throws IllegalStateException debug 故障注入(QA-004 验证用)
|
||||
*/
|
||||
fun consolidate(maxAgeDays: Long = 90): Int = synchronized(lock) {
|
||||
// 故障注入:抛在**任何写之前**,所以"数据未改动"天然成立
|
||||
if (debugForceConsolidateFailure) {
|
||||
debugForceConsolidateFailure = false // 一次性,避免持续失败
|
||||
throw IllegalStateException("QA 故障注入:consolidate 强制失败(debugForceConsolidateFailure)")
|
||||
}
|
||||
val lr = loadLocked()
|
||||
if (lr is LoadResult.Corrupt) throw StorageCorruptException(lr.reason)
|
||||
|
||||
val list = lr.listOrEmpty().toMutableList()
|
||||
|
||||
// ① 惰性补抽取(幂等):老卡拿到 subject/attribute 后才能参与判定
|
||||
ReconcileEngine.migrateIfNeeded(list)
|
||||
|
||||
// ② 判定内核:串行 reconcile,无网络也能跑
|
||||
val candidates = list.filter { it.schemaVersion >= 1 && it.status == MemStatus.ACTIVE }
|
||||
val result = ReconcileEngine.reconcileBatch(
|
||||
list, candidates, ReconcileEngine.Mode.OFFLINE_DETERMINISTIC
|
||||
)
|
||||
|
||||
// ③ 遗忘分支(保留,但收窄前提:不碰链上任何节点、longTerm 前置排除)
|
||||
val pruned = MemoryStore.pruneForgettable(list, clock(), maxAgeDays)
|
||||
|
||||
saveLocked(pruned)
|
||||
appendAuditsLocked(result.audits)
|
||||
result.changed + (list.size - pruned.size)
|
||||
}
|
||||
|
||||
private fun rejectLocked(op: String) {
|
||||
logE("拒绝写入($op):记忆存储处于损坏只读态 —— $corruptReason", null)
|
||||
appendAuditsLocked(
|
||||
listOf(audit(MemoryStore.AUDIT_OP_FAILED, "WRITE_REJECTED($op):$corruptReason"))
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------- 审计 ----------------------------
|
||||
|
||||
fun audits(limit: Int = 20): List<ReconcileEngine.ReconcileAudit> =
|
||||
synchronized(lock) { auditsLocked(limit) }
|
||||
|
||||
private fun auditsLocked(limit: Int): List<ReconcileEngine.ReconcileAudit> {
|
||||
val raw = try {
|
||||
kv.getString(KEY_AUDIT, "[]")
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
} ?: "[]"
|
||||
val all = runCatching {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
ReconcileEngine.ReconcileAudit(
|
||||
at = o.optLong("at", 0L),
|
||||
mode = o.optString("mode", ""),
|
||||
op = o.optString("op", ""),
|
||||
reason = o.optString("reason", ""),
|
||||
confidence = o.optDouble("confidence", 0.0),
|
||||
newId = o.optString("newId", ""),
|
||||
// 同 B07:targetId 也是可空字段,JSON null 必须读成 null 而不是 "null"
|
||||
targetId = o.optNullableString("targetId")
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
return all.takeLast(limit.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun appendAudits(adds: List<ReconcileEngine.ReconcileAudit>) =
|
||||
synchronized(lock) { appendAuditsLocked(adds) }
|
||||
|
||||
/**
|
||||
* 环形缓冲:**保留最新、丢弃最旧**(`takeLast`)。
|
||||
*
|
||||
* 上限从 200 提到 1024(QA-004 OBS-1):600 卡一批整理会产出 600 条 op,
|
||||
* 200 的缓冲只留下最后 200 条,**400 条归因直接丢失**,出问题时无法复盘是谁动了哪张卡。
|
||||
*/
|
||||
private fun appendAuditsLocked(adds: List<ReconcileEngine.ReconcileAudit>) {
|
||||
if (adds.isEmpty()) return
|
||||
runCatching {
|
||||
val old = auditsLocked(maxAudit)
|
||||
val merged = (old + adds).takeLast(maxAudit)
|
||||
val arr = JSONArray()
|
||||
merged.forEach { a ->
|
||||
arr.put(
|
||||
JSONObject().put("at", a.at).put("mode", a.mode).put("op", a.op)
|
||||
.put("reason", a.reason).put("confidence", a.confidence)
|
||||
.put("newId", a.newId)
|
||||
.put("targetId", a.targetId ?: JSONObject.NULL)
|
||||
)
|
||||
}
|
||||
kv.write(mapOf(KEY_AUDIT to arr.toString()))
|
||||
} // §7.2 apply() 异常 → 不重试;下次读到的仍是旧状态(apply 原子)
|
||||
}
|
||||
|
||||
private fun audit(op: String, reason: String) = ReconcileEngine.ReconcileAudit(
|
||||
at = clock(),
|
||||
mode = "OFFLINE",
|
||||
op = op,
|
||||
reason = if (reason.length <= MemoryStore.MAX_FAILURE_REASON) reason
|
||||
else reason.take(MemoryStore.MAX_FAILURE_REASON - 1) + "…",
|
||||
confidence = 0.0,
|
||||
newId = "",
|
||||
targetId = null
|
||||
)
|
||||
}
|
||||
@@ -1,140 +1,248 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 三层记忆里的「长期记忆卡」:本地 JSON 存储,关键词 + 重要度 + 新鲜度召回。
|
||||
* 不依赖 embedding,几百条量级足够准且省电。
|
||||
*
|
||||
* 判定内核已抽到 [ReconcileEngine](纯 JVM、可单测);持久化内核已抽到 [MemoryPersist]
|
||||
* (纯 JVM,三态读 + 只读保护态 + 上一版备份)。本类只负责:
|
||||
* **Context → SharedPreferences 的薄适配**、召回打分、prompt 渲染、纯函数工具。
|
||||
*
|
||||
* ## 本文件的最重要一条约定(QA-004-B01)
|
||||
*
|
||||
* **"解析失败"永远不许以"空列表"的形式流到上层。** 读入口有三态 [LoadResult],
|
||||
* 写入口有三态 [MemWriteResult];损坏态下除用户显式 [clear] 外一律拒绝写入。
|
||||
* 详见 [MemoryPersist] 的 I-A / I-B / I-C。
|
||||
*/
|
||||
object MemoryStore {
|
||||
|
||||
private const val PREF = "pet_prefs"
|
||||
private const val KEY = "memory_cards"
|
||||
private const val MAX_CARDS = 300
|
||||
private const val TAG = "PetFloat"
|
||||
|
||||
data class Memory(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val tags: List<String> = emptyList(),
|
||||
val importance: Int = 2, // 1~3
|
||||
val created: Long = System.currentTimeMillis(),
|
||||
val updated: Long = System.currentTimeMillis(),
|
||||
val hits: Int = 0,
|
||||
val longTerm: Boolean = false,
|
||||
val emotion: String = ""
|
||||
)
|
||||
/**
|
||||
* 容量政策 = C「先撑」(用户已拍板):300 → 600。
|
||||
* 一条事实现在可能占 2–3 条记录(链头 ACTIVE + 若干 SHADOW),300 的预算会被历史吃掉;
|
||||
* 方案 A(把 SHADOW 压缩进链头 `history` 后回收)需新增写路径,本次不做。
|
||||
*/
|
||||
const val MAX_CARDS = 600
|
||||
|
||||
fun load(ctx: Context): MutableList<Memory> {
|
||||
val raw = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
.getString(KEY, "[]") ?: "[]"
|
||||
return runCatching {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
Memory(
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
content = o.optString("content"),
|
||||
tags = o.optJSONArray("tags")?.let { t ->
|
||||
(0 until t.length()).map { t.optString(it) }
|
||||
} ?: emptyList(),
|
||||
importance = o.optInt("importance", 2).coerceIn(1, 3),
|
||||
created = o.optLong("created", System.currentTimeMillis()),
|
||||
updated = o.optLong("updated", System.currentTimeMillis()),
|
||||
hits = o.optInt("hits", 0),
|
||||
longTerm = o.optBoolean("longTerm", false),
|
||||
emotion = o.optString("emotion", "")
|
||||
)
|
||||
}.toMutableList()
|
||||
}.getOrDefault(mutableListOf())
|
||||
}
|
||||
/**
|
||||
* 审计环形缓冲上限。
|
||||
*
|
||||
* QA-004 OBS-1 实测:单批 600 卡整理产出 600 条 op,上限 200 时**只留下最后 200 条**
|
||||
* (ADD 100 / UPDATE 100),400 条归因丢失,出问题无法复盘是谁动了哪张卡。
|
||||
* 提到 1024 = 能完整装下"一次 600 卡的整理 + 若干手动操作"。
|
||||
* 单条审计约 120 B,1024 条约 120 KB,相对 600 卡正文(1.3 MB)可忽略。
|
||||
*/
|
||||
const val MAX_AUDIT = 1024
|
||||
|
||||
fun save(ctx: Context, list: List<Memory>) {
|
||||
val arr = JSONArray()
|
||||
list.take(MAX_CARDS).forEach { m ->
|
||||
arr.put(JSONObject()
|
||||
.put("id", m.id).put("title", m.title).put("content", m.content)
|
||||
.put("tags", JSONArray(m.tags)).put("importance", m.importance)
|
||||
.put("created", m.created).put("updated", m.updated).put("hits", m.hits)
|
||||
.put("longTerm", m.longTerm).put("emotion", m.emotion))
|
||||
/**
|
||||
* 失败审计里 `reason` 的最大长度(QA-003-B02)。
|
||||
* 200 字够放「异常类名 + 首行 message」,又不会让环形缓冲被一条长 message 吃掉。
|
||||
*/
|
||||
const val MAX_FAILURE_REASON = 200
|
||||
|
||||
/** 失败审计的 `op` 值(与 ADD / SUPERSEDE / UPDATE / NOOP 同一字段,便于 `audits()` 过滤)。 */
|
||||
const val AUDIT_OP_FAILED = "FAILED"
|
||||
|
||||
/** 主键损坏但备份可用、已自动回退(QA-004-B01)。 */
|
||||
const val AUDIT_OP_RECOVERED = "RECOVERED"
|
||||
|
||||
/** 用户显式清空(唯一允许在损坏态下执行的写入)。 */
|
||||
const val AUDIT_OP_CLEARED = "CLEARED"
|
||||
|
||||
/**
|
||||
* debug 故障注入的**哨兵文件名**(放在 `filesDir/` 下)。
|
||||
*
|
||||
* 为什么用文件而不是 SharedPreferences 布尔键:QA-004 §4.2 已经被 SharedPreferences 的
|
||||
* **进程内缓存**坑过一次(运行中改 prefs 文件,App 读不到)。哨兵文件没有缓存,
|
||||
* `run-as … touch` 立刻生效,回到 App 点一次「整理记忆」就能走到失败分支。
|
||||
*
|
||||
* ```bash
|
||||
* adb shell run-as com.dsh.petfloat touch \
|
||||
* /data/data/com.dsh.petfloat/files/qa_force_consolidate_fail
|
||||
* ```
|
||||
* 触发一次后文件被删除(一次性),无需手动清理。
|
||||
*/
|
||||
const val QA_FAIL_SENTINEL = "qa_force_consolidate_fail"
|
||||
|
||||
/**
|
||||
* 等价的 SharedPreferences 布尔键(`pet_prefs`)。仅作备用入口:
|
||||
* 需在 **App 未启动时**注入,否则进程内缓存读不到(同 QA-004 §4.2)。
|
||||
*/
|
||||
const val QA_FAIL_PREF = "qa_force_consolidate_fail"
|
||||
|
||||
private const val DAY_MS = 86_400_000L
|
||||
|
||||
private val persistLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var persistInstance: MemoryPersist? = null
|
||||
|
||||
/**
|
||||
* 独立备份文件的 IO 线程(单线程 = 任务顺序即提交顺序,`save` 与 `clear` 的先后不会被搅乱)。
|
||||
*
|
||||
* 为什么要挪走:`add` 有一半调用点在主线程(手动添加 / 对话里的「记住」),600 卡时那份
|
||||
* JSON 是 1.3MB,同步写文件会把一次点击变成几十毫秒掉帧(QA-002-B04 的老毛病)。
|
||||
* 守护线程 + 单例生命周期,随进程结束。
|
||||
*/
|
||||
private val backupIo: ExecutorService by lazy {
|
||||
Executors.newSingleThreadExecutor { r ->
|
||||
Thread(r, "memory-bak").apply { isDaemon = true }
|
||||
}
|
||||
ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun add(ctx: Context, title: String, content: String, tags: List<String> = emptyList(), importance: Int = 2): Boolean {
|
||||
if (title.isBlank() || content.isBlank()) return false
|
||||
val list = load(ctx)
|
||||
// 简单去重:标题相同则更新内容
|
||||
val exist = list.indexOfFirst { it.title == title }
|
||||
if (exist >= 0) {
|
||||
list[exist] = list[exist].copy(content = content, tags = tags,
|
||||
importance = importance.coerceIn(1, 3), updated = System.currentTimeMillis())
|
||||
} else {
|
||||
list.add(Memory(
|
||||
id = java.util.UUID.randomUUID().toString().take(8),
|
||||
title = title.trim(), content = content.trim(), tags = tags,
|
||||
importance = importance.coerceIn(1, 3)
|
||||
))
|
||||
/** 取(必要时建)进程内唯一的持久化内核。SharedPreferences 本身也是按 name 缓存的单例。 */
|
||||
private fun persist(ctx: Context): MemoryPersist {
|
||||
persistInstance?.let { return it }
|
||||
synchronized(persistLock) {
|
||||
persistInstance?.let { return it }
|
||||
val created = MemoryPersist(
|
||||
kv = PrefsKV(prefs(ctx)),
|
||||
maxAudit = MAX_AUDIT,
|
||||
// 与 prefs 物理隔离的兜底:pet_prefs.xml 整体坏掉时,只剩这一份能救回记忆
|
||||
fileBackup = AtomicFileBackup(File(ctx.filesDir, MemoryPersist.FILE_BAK_NAME)),
|
||||
runIo = { task -> backupIo.execute(task) },
|
||||
logE = { msg, t -> if (t == null) Log.e(TAG, msg) else Log.e(TAG, msg, t) },
|
||||
logW = { msg, t -> if (t == null) Log.w(TAG, msg) else Log.w(TAG, msg, t) }
|
||||
)
|
||||
persistInstance = created
|
||||
return created
|
||||
}
|
||||
save(ctx, list)
|
||||
return true
|
||||
}
|
||||
|
||||
fun remove(ctx: Context, id: String) {
|
||||
save(ctx, load(ctx).filterNot { it.id == id })
|
||||
/** 仅供单测:换成内存实现;用完传 null 复位(否则会影响同 JVM 里的其他用例)。 */
|
||||
internal fun installPersistForTests(p: MemoryPersist?) {
|
||||
synchronized(persistLock) { persistInstance = p }
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) = save(ctx, emptyList())
|
||||
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
|
||||
// ===================== 读(三态) =====================
|
||||
|
||||
/**
|
||||
* 三态读。**写路径必须用这个**,不能拿 [load] 的空列表当"没有记忆"。
|
||||
*/
|
||||
fun loadResult(ctx: Context): LoadResult = persist(ctx).load()
|
||||
|
||||
/**
|
||||
* 只读展示用:损坏 / 缺失都给空列表。
|
||||
* UI 要在损坏时显示"读取失败"而不是"0 条",请先调 [loadResult] 判断。
|
||||
*/
|
||||
fun load(ctx: Context): MutableList<Memory> = loadResult(ctx).listOrEmpty()
|
||||
|
||||
/** 当前是否处于"上次读取失败"的只读保护态。 */
|
||||
fun isCorrupt(ctx: Context): Boolean = persist(ctx).isCorrupt()
|
||||
|
||||
fun corruptReason(ctx: Context): String = persist(ctx).corruptReason()
|
||||
|
||||
// ===================== 写(三态返回值) =====================
|
||||
|
||||
/**
|
||||
* 手动添加 / 自动抽取 / 种子写入的**唯一落库入口**。
|
||||
*
|
||||
* ## 为什么改(QA-002-B05)
|
||||
*
|
||||
* 旧实现在 `title` 精确匹配时**无条件覆盖 `content`**。于是"先说喜欢咖啡 → 后说不喝了"
|
||||
* 只要落到同名卡上,第一步就被抹平:`reconcile` 永远看不到两条,四动作内核在真实使用中
|
||||
* 根本没机会介入。
|
||||
*
|
||||
* 现在的语义:
|
||||
* - **同标题 + 同正文** → NOOP(幂等,不产生重复卡,也不覆盖任何东西);
|
||||
* - **同标题 + 不同正文** → 交给 [ReconcileEngine] 判定:该 SUPERSEDE 就 SUPERSEDE
|
||||
* (旧卡留全文转 SHADOW),抽不出就 ADD(两条并存的重复卡,属可接受的容量代价);
|
||||
* - 任何情况下**都不会丢掉任何一条正文**。
|
||||
* - 处于损坏只读态 → [MemWriteResult.REJECTED_CORRUPT],磁盘不动(QA-004-B01)。
|
||||
*
|
||||
* 系统种子卡(`importance>=3` 且标签含身份/系统/边界)直接落地,不进判定——它们是
|
||||
* "我是谁"的元信息,既不能被取代,也不该参与取代别人。
|
||||
*/
|
||||
fun add(
|
||||
ctx: Context,
|
||||
title: String,
|
||||
content: String,
|
||||
tags: List<String> = emptyList(),
|
||||
importance: Int = 2,
|
||||
longTerm: Boolean = false
|
||||
): MemWriteResult = persist(ctx).add(title, content, tags, importance, longTerm)
|
||||
|
||||
/**
|
||||
* [add] 的**纯函数内核**(不碰 Context / SharedPreferences,可在 JVM 单测里直接跑)。
|
||||
*
|
||||
* 所有"要不要写、写成什么"的判断都在这里;`add()` 只负责 load / save。
|
||||
*/
|
||||
fun addTo(
|
||||
list: MutableList<Memory>,
|
||||
title: String,
|
||||
content: String,
|
||||
tags: List<String> = emptyList(),
|
||||
importance: Int = 2,
|
||||
longTerm: Boolean = false,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
idGen: () -> String = { java.util.UUID.randomUUID().toString().take(8) }
|
||||
): Pair<Boolean, List<ReconcileEngine.ReconcileAudit>> {
|
||||
val t = title.trim()
|
||||
val c = content.trim()
|
||||
if (t.isEmpty() || c.isEmpty()) return false to emptyList()
|
||||
|
||||
ReconcileEngine.migrateIfNeeded(list)
|
||||
|
||||
// 幂等:同标题 + 同正文的活卡已经在库里 → 什么都不做
|
||||
val dup = list.firstOrNull {
|
||||
it.status == MemStatus.ACTIVE && it.isLatest && it.title == t && it.content == c
|
||||
}
|
||||
if (dup != null) return false to emptyList()
|
||||
|
||||
val candidate = Memory(
|
||||
id = idGen(), title = t, content = c, tags = tags,
|
||||
importance = importance.coerceIn(1, 3), longTerm = longTerm,
|
||||
created = now, updated = now
|
||||
)
|
||||
|
||||
// 种子卡直接落地:它们是"我是谁",不参与取代判定(见 ReconcileEngine.isSystemSeed)
|
||||
if (ReconcileEngine.isSystemSeed(candidate)) {
|
||||
val applied = ReconcileEngine.applyOp(
|
||||
list, ReconcileEngine.ReconcileOp.Add(ReconcileEngine.stamp(candidate)), now, idGen
|
||||
)
|
||||
return applied.changed to emptyList()
|
||||
}
|
||||
|
||||
val result = ReconcileEngine.reconcileBatch(
|
||||
list, listOf(candidate), ReconcileEngine.Mode.OFFLINE_DETERMINISTIC, now, idGen
|
||||
)
|
||||
return (result.changed > 0) to result.audits
|
||||
}
|
||||
|
||||
fun remove(ctx: Context, id: String): MemWriteResult = persist(ctx).remove(id)
|
||||
|
||||
/**
|
||||
* 用户显式清空:**唯一允许在损坏态下执行的写入**,同时删掉备份(避免下次"复活"),
|
||||
* 也是损坏只读态的人工恢复出口。
|
||||
*/
|
||||
fun clear(ctx: Context) = persist(ctx).clear()
|
||||
|
||||
data class Op(val action: String, val title: String, val content: String,
|
||||
val tags: List<String> = emptyList(), val importance: Int = 2,
|
||||
val longTerm: Boolean = false)
|
||||
|
||||
/** 应用模型给出的 ADD / UPDATE / DELETE 操作(Mem0 式)。 */
|
||||
fun applyOps(ctx: Context, ops: List<Op>): Int {
|
||||
val list = load(ctx)
|
||||
var changed = 0
|
||||
ops.forEach { op ->
|
||||
val t = op.title.trim()
|
||||
if (t.isEmpty()) return@forEach
|
||||
val idx = list.indexOfFirst { similar(it.title, t) }
|
||||
when (op.action.uppercase()) {
|
||||
"DELETE" -> if (idx >= 0) { list.removeAt(idx); changed++ }
|
||||
"UPDATE" -> if (idx >= 0) {
|
||||
list[idx] = list[idx].copy(
|
||||
title = t, content = op.content.ifBlank { list[idx].content },
|
||||
tags = op.tags.ifEmpty { list[idx].tags },
|
||||
importance = op.importance.coerceIn(1, 3),
|
||||
updated = System.currentTimeMillis(), hits = list[idx].hits + 1
|
||||
); changed++
|
||||
} else { changed += if (add(ctx, t, op.content, op.tags, op.importance)) 1 else 0 }
|
||||
else -> {
|
||||
if (idx >= 0) {
|
||||
list[idx] = list[idx].copy(content = op.content, tags = op.tags,
|
||||
importance = maxOf(list[idx].importance, op.importance.coerceIn(1, 3)),
|
||||
updated = System.currentTimeMillis(), hits = list[idx].hits + 1)
|
||||
} else {
|
||||
list.add(Memory(java.util.UUID.randomUUID().toString().take(8),
|
||||
t, op.content, op.tags, op.importance.coerceIn(1, 3),
|
||||
longTerm = op.longTerm))
|
||||
}
|
||||
changed++
|
||||
}
|
||||
}
|
||||
}
|
||||
save(ctx, list)
|
||||
return changed
|
||||
}
|
||||
/**
|
||||
* 应用模型给出的 ADD / UPDATE / DELETE 操作(Mem0 式)。
|
||||
*
|
||||
* ADD / UPDATE **统一委托给 [add]**(= 走 [ReconcileEngine]):旧实现里这两个分支同样是
|
||||
* "同标题直接覆盖 content",与 B05 同族,会让模型抽取路径绕过四动作内核、并悄悄吞掉旧正文。
|
||||
* DELETE 是显式删除意图,保持原样(模糊标题匹配 + 真删)。
|
||||
*/
|
||||
fun applyOps(ctx: Context, ops: List<Op>): Int = persist(ctx).applyOps(ops)
|
||||
|
||||
private fun bigrams(s: String): Set<String> =
|
||||
if (s.length < 2) setOf(s) else (0 until s.length - 1).map { s.substring(it, it + 2) }.toSet()
|
||||
|
||||
/** 标题相似度(字符二元组 Jaccard),用于去重与整理。 */
|
||||
fun similar(a: String, b: String): Boolean {
|
||||
val x = bigrams(a); val y = bigrams(b)
|
||||
if (x.isEmpty() || y.isEmpty()) return a == b
|
||||
@@ -142,45 +250,165 @@ object MemoryStore {
|
||||
return inter.toDouble() / x.union(y).size >= 0.55
|
||||
}
|
||||
|
||||
/** 整理:合并相似卡片、遗忘「低价值且久未使用」的条目。返回删除/合并数量。 */
|
||||
private fun bigrams(s: String): Set<String> =
|
||||
if (s.length < 2) setOf(s) else (0 until s.length - 1).map { s.substring(it, it + 2) }.toSet()
|
||||
|
||||
// ===================== 整理(reconcile + 收窄的遗忘)=====================
|
||||
|
||||
/**
|
||||
* 整理:**判定内核换成 [ReconcileEngine]**(§6.1 —— 替换而非共存,共存会把 SHADOW 卡重新
|
||||
* 合并回 ACTIVE,等于砸平演化链),保留但收窄遗忘分支。
|
||||
*
|
||||
* 返回"发生变更的节点数"(语义已从"合并数"变化,见 §6.2 第 3 点)。
|
||||
*
|
||||
* 损坏只读态下抛 [StorageCorruptException](写之前就抛,数据未改动),
|
||||
* 由 `consolidateAsync` 的 `runCatching` 接住 → `Log.e` + `op="FAILED"` 审计 + 「整理失败」Toast。
|
||||
*/
|
||||
fun consolidate(ctx: Context, maxAgeDays: Long = 90): Int {
|
||||
val list = load(ctx)
|
||||
val now = System.currentTimeMillis()
|
||||
val kept = ArrayList<Memory>()
|
||||
var merged = 0
|
||||
list.forEach { m ->
|
||||
val dup = kept.indexOfFirst { similar(it.title, m.title) || it.content.contains(m.content) }
|
||||
if (dup >= 0) {
|
||||
val k = kept[dup]
|
||||
kept[dup] = k.copy(
|
||||
content = if (m.content.length > k.content.length) m.content else k.content,
|
||||
importance = maxOf(k.importance, m.importance),
|
||||
tags = (k.tags + m.tags).distinct(),
|
||||
updated = maxOf(k.updated, m.updated),
|
||||
hits = k.hits + m.hits
|
||||
)
|
||||
merged++
|
||||
} else kept.add(m)
|
||||
}
|
||||
val pruned = kept.filter { m ->
|
||||
val days = (now - m.updated) / 86_400_000L
|
||||
!(m.importance <= 1 && m.hits == 0 && days > maxAgeDays)
|
||||
}
|
||||
save(ctx, pruned)
|
||||
return merged + (kept.size - pruned.size)
|
||||
armDebugFailureIfRequested(ctx)
|
||||
return persist(ctx).consolidate(maxAgeDays)
|
||||
}
|
||||
|
||||
/** 关键词 + 重要度 + 新鲜度召回 Top-K。 */
|
||||
fun recall(ctx: Context, query: String, k: Int = 5): List<Memory> {
|
||||
val list = load(ctx)
|
||||
/**
|
||||
* QA-004 故障注入的装填点。
|
||||
*
|
||||
* **只在 debug 构建里生效**:`BuildConfig.DEBUG` 在 release 里是编译期常量 `false`,
|
||||
* R8 / 编译器会整段消掉,release 没有任何代码路径能把
|
||||
* [MemoryPersist.debugForceConsolidateFailure] 置 true。
|
||||
*
|
||||
* 两个入口(都是"读一次即复位"):
|
||||
* 1. **推荐**:`filesDir/qa_force_consolidate_fail` 哨兵文件(不受 prefs 进程内缓存影响);
|
||||
* 2. 备用:`pet_prefs` 里的同名布尔键(需在 App 未启动时注入)。
|
||||
*/
|
||||
private fun armDebugFailureIfRequested(ctx: Context) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
var armed = false
|
||||
runCatching {
|
||||
val f = File(ctx.filesDir, QA_FAIL_SENTINEL)
|
||||
if (f.exists()) {
|
||||
armed = f.delete()
|
||||
if (!armed) armed = true // delete 失败也照样装填,避免 QA 白点一次
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "检查 QA 故障注入哨兵文件失败", it) }
|
||||
runCatching {
|
||||
val p = prefs(ctx)
|
||||
if (p.getBoolean(QA_FAIL_PREF, false)) {
|
||||
p.edit().putBoolean(QA_FAIL_PREF, false).apply()
|
||||
armed = true
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "读取 QA 故障注入 prefs 键失败", it) }
|
||||
if (armed) {
|
||||
persist(ctx).debugForceConsolidateFailure = true
|
||||
Log.w(TAG, "QA 故障注入已装填:下一次 consolidate() 将主动失败(一次性,抛在写之前)")
|
||||
}
|
||||
}
|
||||
|
||||
/** 纯函数版本,便于 JVM 单测(无 Context)。 */
|
||||
fun pruneForgettable(
|
||||
list: List<Memory>,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
maxAgeDays: Long = 90
|
||||
): List<Memory> = list.filterNot { forgettable(it, now, maxAgeDays) }
|
||||
|
||||
/**
|
||||
* 遗忘判定(收窄后):
|
||||
* - 只清理**单点卡**(不在任何链上):链上的节点承载历史,删掉会让 `supersededBy` 悬空,破坏 I2;
|
||||
* - `longTerm` 长期事实前置排除(用户钉住的事实不该被时间冲掉);
|
||||
* - 其余条件与原实现一致:`importance <= 1 && hits == 0 && 超过 maxAgeDays 未更新`。
|
||||
*/
|
||||
private fun forgettable(m: Memory, now: Long, maxAgeDays: Long): Boolean =
|
||||
m.status == MemStatus.ACTIVE && m.isLatest &&
|
||||
m.supersedes.isEmpty() && m.supersededBy == null &&
|
||||
!m.longTerm &&
|
||||
m.importance <= 1 && m.hits == 0 &&
|
||||
(now - m.updated) / DAY_MS > maxAgeDays
|
||||
|
||||
// ===================== 审计(§7.3,零依赖环形缓冲)=====================
|
||||
|
||||
fun audits(ctx: Context, limit: Int = 20): List<ReconcileEngine.ReconcileAudit> =
|
||||
persist(ctx).audits(limit)
|
||||
|
||||
/** 查"某次变更"的归因记录(newId / targetId / confidence / reason)—— 供「一键纠正」等 Phase 2 入口使用。 */
|
||||
fun auditFor(ctx: Context, newId: String): ReconcileEngine.ReconcileAudit? =
|
||||
audits(ctx, MAX_AUDIT).lastOrNull { it.newId == newId }
|
||||
|
||||
/**
|
||||
* 记一条**整理失败**(QA-003-B02)。
|
||||
*
|
||||
* 旧行为:`consolidateAsync` 的 `runCatching` 既不打日志也不留审计,失败后现场只有
|
||||
* 「存储零变化 + 无审计 + CPU 空闲」三条间接证据,连异常类型都拿不到——QA 上一轮就是这么反推的。
|
||||
* 失败是安全的([consolidate] 只在最后一步 `save()`),但"安全且不可诊断"仍然不可接受。
|
||||
*
|
||||
* 这里**复用现有审计环形缓冲**(`reconcile_audit`),不新增任何存储结构:
|
||||
* `op = "FAILED"`,`reason` = 异常类名 + 首行 message(截断到 [MAX_FAILURE_REASON])。
|
||||
* 因此 `audits()` 读出来的形状不变,老版本读新审计也只是多一条它不认识的 op。
|
||||
*/
|
||||
fun recordConsolidateFailure(
|
||||
ctx: Context,
|
||||
e: Throwable,
|
||||
now: Long = System.currentTimeMillis()
|
||||
) {
|
||||
persist(ctx).appendAudits(
|
||||
listOf(
|
||||
ReconcileEngine.ReconcileAudit(
|
||||
at = now,
|
||||
mode = "OFFLINE",
|
||||
op = AUDIT_OP_FAILED,
|
||||
reason = failureReason(e),
|
||||
confidence = 0.0,
|
||||
newId = "",
|
||||
targetId = null
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常 → 一条能塞进审计的短原因(纯函数,JVM 单测直接可测)。
|
||||
*
|
||||
* 形状:`异常类名: message 首行`;message 为空则只有类名。超过 [MAX_FAILURE_REASON] 截断并加省略号。
|
||||
* 刻意**不写堆栈**:环形缓冲只有 1024 条,堆栈会把它瞬间吃光;堆栈进 logcat(`Log.e`)。
|
||||
*/
|
||||
fun failureReason(e: Throwable): String {
|
||||
val head = (e.message ?: "").lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
val raw = if (head.isEmpty()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $head"
|
||||
return if (raw.length <= MAX_FAILURE_REASON) raw else raw.take(MAX_FAILURE_REASON - 1) + "…"
|
||||
}
|
||||
|
||||
// ===================== JSON 编解码(委托 [MemoryCodec]) =====================
|
||||
|
||||
/**
|
||||
* 反序列化。**返回三态**:解析失败是 [LoadResult.Corrupt],绝不是空列表(QA-004-B01)。
|
||||
* 抽成纯函数是为了能在 JVM 单测里实测"600 条全量 JSON 解析"耗时(无 Context 依赖)。
|
||||
*/
|
||||
fun fromJson(raw: String): LoadResult = MemoryCodec.parse(raw)
|
||||
|
||||
/** 抽出纯函数:单测可直接测 600 条的序列化耗时。老版 app 读新 JSON 时未知 key 被忽略。 */
|
||||
fun toJsonArray(list: List<Memory>): JSONArray = MemoryCodec.toJsonArray(list)
|
||||
|
||||
// ===================== 召回 =====================
|
||||
|
||||
fun recall(ctx: Context, query: String, k: Int = 5): List<Memory> =
|
||||
recallFrom(load(ctx), query, k)
|
||||
|
||||
/**
|
||||
* 关键词 + 重要度 + 新鲜度召回 Top-K(纯函数,便于 600 条实测)。
|
||||
*
|
||||
* **硬过滤(§4.4 第 1、2 步)**:只有 `ACTIVE && isLatest` 的卡进入打分池。
|
||||
* SHADOW 节点是"曾经为真、现在已失效"的历史,注入 prompt 会让小鲸娘说出过时信息——
|
||||
* 那正是咖啡 bug 的另一种表现形式。历史回溯通道(带"已过时"标注)属 Phase 2,本次不做。
|
||||
*/
|
||||
fun recallFrom(list: List<Memory>, query: String, k: Int = 5,
|
||||
now: Long = System.currentTimeMillis()): List<Memory> {
|
||||
if (list.isEmpty()) return emptyList()
|
||||
val live = list.filter { it.status == MemStatus.ACTIVE && it.isLatest }
|
||||
if (live.isEmpty()) return emptyList()
|
||||
// 长期事实:按重要度→时间固定带前 3 条,不看问句,也不占事件名额
|
||||
val longTerm = list.filter { it.longTerm }
|
||||
val longTerm = live.filter { it.longTerm }
|
||||
.sortedWith(compareByDescending<Memory> { it.importance }.thenByDescending { it.updated })
|
||||
.take(3)
|
||||
val q = query.lowercase()
|
||||
val now = System.currentTimeMillis()
|
||||
val scored = list.filter { !it.longTerm }.map { m ->
|
||||
val scored = live.filter { !it.longTerm }.map { m ->
|
||||
val hay = (m.title + " " + m.content + " " + m.tags.joinToString(" ")).lowercase()
|
||||
var score = 0.0
|
||||
// 关键词命中(按长度加权,2 字以上才算)
|
||||
@@ -200,7 +428,7 @@ object MemoryStore {
|
||||
return longTerm + events
|
||||
}
|
||||
|
||||
/** 渲染成给模型的「你记得」段落。 */
|
||||
/** 渲染成给模型的「你记得」段落。SHADOW 的过期标注属 Phase 2(§4.4 `renderPrompt` 改动)。 */
|
||||
fun renderPrompt(memories: List<Memory>): String {
|
||||
if (memories.isEmpty()) return ""
|
||||
val sb = StringBuilder("你记得关于主人的事(按相关度):\n")
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
/**
|
||||
* 四动作冲突消解(ADD / SUPERSEDE / UPDATE / NOOP)的确定性判定内核。
|
||||
*
|
||||
* 规格依据:`docs/architecture/reconcile-four-ops.md` §2.4 规则表(R0–R7)、§4 数据结构与迁移、
|
||||
* §5 判定伪代码(含 §5.4 `applyOp`)、§7 失败安全、§8 单测与不变量。
|
||||
*
|
||||
* **纯 JVM**:不碰 `Context` / `SharedPreferences` / `org.json` / 网络,输入输出全是数据类,
|
||||
* 因此判定逻辑可以在 `src/test` 的 JVM 单测里直接跑(这是 §8 的前置重构)。
|
||||
*
|
||||
* 安全铁律(§7.1):任何不确定 → `ADD`(新卡落地、旧卡不动)或 `NOOP`。绝不猜测性合并,绝不删除。
|
||||
*/
|
||||
object ReconcileEngine {
|
||||
|
||||
const val MAX_CHAIN_DEPTH = 8
|
||||
const val MAX_SUPERSEDES = 5
|
||||
const val CLOCK_SKEW_TOLERANCE_MS = 60_000L
|
||||
|
||||
/**
|
||||
* R4 合并后正文的**硬上限**(QA-003-B01 ①)。
|
||||
*
|
||||
* 旧实现没有上界:`mergeForUpdate` 的产物会成为后续候选的合法 R4 对手,于是
|
||||
* 「合并 → 再合并 → 再合并」是正反馈,单卡正文按 O(n) 增长、落库总量 O(n²)。
|
||||
* 600 条同主题长正文卡实测单卡涨到约 3.7 万字、落库 JSON 35.1MB,设备端整理**未能落盘**
|
||||
* (失败安全,数据未改动,但用户看到的是"点了没反应")。
|
||||
*
|
||||
* 这不是极端数据才有的问题:长期使用同样会累积,只是慢。
|
||||
*
|
||||
* **取值理由(1200)**
|
||||
* - 既有 R4 用例全部远低于它:TC-07「喜欢喝咖啡;喜欢喝奶茶」= 15 字,
|
||||
* `realInjectedCards()` 里最长的真实卡 19 字,600 卡压力夹具单卡 377–384 字。
|
||||
* 两条真实卡合起来上界 ≈ 2×400 = 800 < 1200 → **真实场景一次合并都不会被拦**。
|
||||
* - 1200 字 ≈ 一条卡在弹窗里滚两屏的量级;再长就不再是"一条可读的事实",
|
||||
* 而是把两件事硬塞进一个格子,检索与注入 prompt 都会变糟。
|
||||
* - 两条 800 字长正文(QA 场景形态)= 1601 > 1200 → 被拦。拦下只是"两条并存",
|
||||
* 不丢任何正文,符合 §7.1「任何不确定 → 加一条新的,不动旧的」。
|
||||
*
|
||||
* 超限时的动作是**降级为 ADD**,而不是截断合并正文:截断会产生原文之外的字符串,
|
||||
* 直接违反 [isVerbatimMerge] 闸门(记忆不可篡改)。
|
||||
*/
|
||||
const val MERGE_MAX_CHARS = 1200
|
||||
|
||||
/** R4 合并正文的连接符(与 [mergeForUpdate] 保持一致,用于长度上界计算)。 */
|
||||
const val MERGE_SEPARATOR = ";"
|
||||
|
||||
const val CONF_POLARITY_CONFLICT = 0.90
|
||||
const val CONF_SINGLE_SLOT_CHANGED = 0.85
|
||||
const val CONF_REDUNDANT = 0.90
|
||||
const val CONF_MULTI_SLOT_MERGE = 0.80
|
||||
const val CONF_AMBIGUOUS = 0.50
|
||||
|
||||
const val REASON_POLARITY_CONFLICT = "POLARITY_CONFLICT"
|
||||
const val REASON_SINGLE_SLOT_CHANGED = "SINGLE_SLOT_VALUE_CHANGED"
|
||||
const val REASON_REDUNDANT = "REDUNDANT"
|
||||
const val REASON_MULTI_SLOT_MERGE = "MULTI_SLOT_MERGE"
|
||||
const val REASON_TIME_ORDER = "TIME_ORDER"
|
||||
const val REASON_CLOCK_SUSPECT = "CLOCK_SUSPECT"
|
||||
const val REASON_EMPTY_CONTENT = "EMPTY_CONTENT"
|
||||
const val REASON_EXTRACT_INCOMPLETE = "EXTRACT_INCOMPLETE"
|
||||
const val REASON_RULE_UNRESOLVED = "RULE_UNRESOLVED"
|
||||
const val REASON_ALREADY_SETTLED = "ALREADY_SETTLED"
|
||||
const val REASON_ALREADY_SHADOW = "ALREADY_SHADOW"
|
||||
|
||||
/** R4 因正文长度超 [MERGE_MAX_CHARS] 而不合并(QA-003-B01 ①)。 */
|
||||
const val REASON_MERGE_LIMIT = "MERGE_LIMIT_EXCEEDED"
|
||||
|
||||
/** 对手是合并产物(`mergedInto`)→ 不许再被合并(QA-003-B01 ②)。 */
|
||||
const val REASON_MERGE_CHAIN_BLOCKED = "MERGE_TARGET_IS_MERGED"
|
||||
|
||||
const val REJECT_TARGET_NOT_FOUND = "REJECT_TARGET_NOT_FOUND"
|
||||
const val REJECT_CROSS_TOPIC = "REJECT_CROSS_TOPIC"
|
||||
const val REJECT_DELETE_FORBIDDEN = "REJECT_DELETE_FORBIDDEN"
|
||||
const val REJECT_TIME_ORDER = "REJECT_TIME_ORDER"
|
||||
const val REJECT_SINGLE_SLOT_DUPLICATE = "REJECT_SINGLE_SLOT_DUPLICATE"
|
||||
const val REJECT_CONTENT_LOSS = "REJECT_CONTENT_LOSS"
|
||||
const val REJECT_REF_FLOOD = "REJECT_REF_FLOOD"
|
||||
const val REJECT_SCHEMA = "REJECT_SCHEMA"
|
||||
const val REJECT_POLARITY_UNSUPPORTED = "REJECT_POLARITY_UNSUPPORTED"
|
||||
const val REJECT_EMPTY_CONTENT = "REJECT_EMPTY_CONTENT"
|
||||
const val REJECT_SYSTEM_SEED = "REJECT_SYSTEM_SEED"
|
||||
|
||||
const val MODEL_UPDATE_REASON = "MODEL_UPDATE"
|
||||
|
||||
/**
|
||||
* 系统种子卡(小鲸娘的"我是谁")专用的主体/属性命名空间。
|
||||
*
|
||||
* 它们由 [PetKnowledge.seeds] 写入,是**元信息**而不是"主人的属性",语义上不属于用户
|
||||
* 属性空间。因此它们:
|
||||
* - 永远不会被 SUPERSEDE / UPDATE 成 SHADOW(`pool` 里被过滤掉);
|
||||
* - 不会占住某个主题的对手位去挡住真正的矛盾对比较;
|
||||
* - 不参与 SINGLE 槽位唯一性检查(I3)。
|
||||
*
|
||||
* 这是 QA-002-B02 的根因修复:旧实现把「我的身份」和「我的主人」都抽成 `主人|residence`,
|
||||
* 于是"小鲸娘忘了自己是谁"。
|
||||
*/
|
||||
const val SUBJECT_SELF = "小鲸娘"
|
||||
const val ATTR_SYSTEM = "system"
|
||||
|
||||
/** 用来识别种子卡的系统标签(与 [PetKnowledge.seeds] 一致)。 */
|
||||
val SYSTEM_TAGS: Set<String> = setOf("身份", "系统", "边界")
|
||||
|
||||
/** 判定模式。Phase 1 只用 OFFLINE_DETERMINISTIC;LLM_ASSISTED 保留通道但本次不接网络(§3)。 */
|
||||
enum class Mode { OFFLINE_DETERMINISTIC, LLM_ASSISTED }
|
||||
|
||||
// ===================== 数据类 =====================
|
||||
|
||||
sealed class ReconcileOp {
|
||||
abstract val reason: String
|
||||
|
||||
/** 纯新增:旧卡零改动,新卡独立 ACTIVE。 */
|
||||
data class Add(
|
||||
val memory: Memory,
|
||||
val ambiguous: Boolean = false,
|
||||
val needsReview: Boolean = false,
|
||||
override val reason: String = ""
|
||||
) : ReconcileOp()
|
||||
|
||||
/** 矛盾/演变:旧卡转 SHADOW 进链,新卡持有 `supersedes=[oldId]`。 */
|
||||
data class Supersede(
|
||||
val targetId: String,
|
||||
val memory: Memory,
|
||||
override val reason: String,
|
||||
val confidence: Double
|
||||
) : ReconcileOp()
|
||||
|
||||
/** 同主题兼容合并:旧卡转 SHADOW,但新卡 `supersedes=[]`(不进链)。 */
|
||||
data class Update(
|
||||
val targetId: String,
|
||||
val memory: Memory,
|
||||
override val reason: String,
|
||||
val confidence: Double
|
||||
) : ReconcileOp()
|
||||
|
||||
/** 无需改动(可能是"已经记住了"这个正确结论,不是失败)。 */
|
||||
data class Noop(
|
||||
val targetId: String?,
|
||||
override val reason: String,
|
||||
val hitsIncrement: Boolean = false
|
||||
) : ReconcileOp()
|
||||
}
|
||||
|
||||
/** 模型/远端给出的"候选提议"(§3.1)。代码只把它当提议,不当结论(§5.2 仲裁)。 */
|
||||
data class ModelClaim(
|
||||
val op: String,
|
||||
val targetId: String? = null,
|
||||
val content: String? = null,
|
||||
val confidence: Double = 0.0
|
||||
)
|
||||
|
||||
/** §7.3 审计环形缓冲的一条。零依赖,由 MemoryStore 落到 SharedPreferences。 */
|
||||
data class ReconcileAudit(
|
||||
val at: Long,
|
||||
val mode: String,
|
||||
val op: String,
|
||||
val reason: String,
|
||||
val confidence: Double,
|
||||
val newId: String,
|
||||
val targetId: String?
|
||||
)
|
||||
|
||||
data class AppliedOp(val changed: Boolean, val newId: String?)
|
||||
|
||||
data class BatchResult(
|
||||
val ops: List<ReconcileOp> = emptyList(),
|
||||
val changed: Int = 0,
|
||||
val audits: List<ReconcileAudit> = emptyList()
|
||||
)
|
||||
|
||||
/** §2.3 值关系(四个互斥分类)。 */
|
||||
data class ValueRelation(
|
||||
val overlap: Boolean,
|
||||
val equal: Boolean,
|
||||
val contains: Boolean,
|
||||
val covers: Boolean,
|
||||
val disjoint: Boolean,
|
||||
val newOnly: List<String>,
|
||||
val oldOnly: List<String>
|
||||
)
|
||||
|
||||
// ===================== 抽取与迁移 =====================
|
||||
|
||||
/**
|
||||
* 系统种子卡判定:`importance >= 3` 且标签含「身份」/「系统」/「边界」。
|
||||
*
|
||||
* 用户也可以用它当"逃生门":给一条记忆打上 `系统` 标签并置 `importance=3`,它就变成
|
||||
* 不可被取代的锚点。
|
||||
*/
|
||||
fun isSystemSeed(m: Memory): Boolean =
|
||||
m.importance >= 3 && m.tags.any { it.trim() in SYSTEM_TAGS }
|
||||
|
||||
/** 给一条卡补上抽取结果与 schemaVersion=1(迁移/落地共用)。 */
|
||||
fun stamp(m: Memory, key: FactKey = DeterministicExtractor.extract(m.title, m.content)): Memory {
|
||||
// 种子卡整体进 system 命名空间:它们不属于"主人的属性",任何抽取结果都不该让它们
|
||||
// 与用户事实同主题(QA-002-B02)。这里在做命名空间隔离,而不是丢弃信息——
|
||||
// 正文/标题/标签一个字都不动,只是判定用的 subject/attribute 换了域。
|
||||
val safeKey = if (isSystemSeed(m)) {
|
||||
FactKey(SUBJECT_SELF, ATTR_SYSTEM, SlotType.UNKNOWN, emptyList(), 0, false)
|
||||
} else {
|
||||
key
|
||||
}
|
||||
return m.copy(
|
||||
schemaVersion = 1,
|
||||
subject = safeKey.subject,
|
||||
attribute = safeKey.attribute,
|
||||
slotType = safeKey.slotType,
|
||||
polarity = safeKey.polarity,
|
||||
valueTokens = safeKey.valueTokens,
|
||||
validFrom = if (m.validFrom > 0L) m.validFrom else m.created,
|
||||
status = MemStatus.ACTIVE,
|
||||
isLatest = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.2 惰性补抽取:只处理 `schemaVersion == 0` 的节点,幂等。返回是否发生变更。
|
||||
* 逐条定义见 [DeterministicExtractor]。
|
||||
*/
|
||||
fun migrateIfNeeded(list: MutableList<Memory>): Boolean {
|
||||
var dirty = false
|
||||
for (i in list.indices) {
|
||||
val m = list[i]
|
||||
if (m.schemaVersion >= 1) continue
|
||||
list[i] = stamp(m)
|
||||
dirty = true
|
||||
}
|
||||
return dirty
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选的 FactKey:**永远重新抽取**。
|
||||
* 候选是"本次新到的文本",我们拥有它;重抽可顺带修正 `add()` 覆盖正文导致的字段陈旧。
|
||||
*/
|
||||
fun keyOfCandidate(m: Memory): FactKey = DeterministicExtractor.extract(m.title, m.content)
|
||||
|
||||
/**
|
||||
* 库内节点的 FactKey:`schemaVersion >= 1` 用已存字段;`0` 视为**不可比**。
|
||||
*
|
||||
* 这条差异是刻意的:老数据在 `migrateIfNeeded` 之前不参与 SUPERSEDE/UPDATE,
|
||||
* 保证"首轮 reconcile 对老数据只能 ADD"(§4.2 默认值安全语义 / TC-13)。
|
||||
*/
|
||||
fun keyOfNode(m: Memory): FactKey =
|
||||
if (m.schemaVersion >= 1) {
|
||||
FactKey(m.subject, m.attribute, m.slotType, m.valueTokens, m.polarity, false)
|
||||
} else {
|
||||
FactKey.UNKNOWN
|
||||
}
|
||||
|
||||
// ===================== 值关系 =====================
|
||||
|
||||
fun valueRelation(newTokens: List<String>, oldTokens: List<String>): ValueRelation {
|
||||
val n = newTokens.toSet()
|
||||
val o = oldTokens.toSet()
|
||||
val inter = n.intersect(o)
|
||||
val newOnly = (n - o).toList()
|
||||
val oldOnly = (o - n).toList()
|
||||
return ValueRelation(
|
||||
overlap = inter.isNotEmpty(),
|
||||
equal = n == o,
|
||||
contains = newOnly.isEmpty() && oldOnly.isNotEmpty(),
|
||||
covers = oldOnly.isEmpty() && newOnly.isNotEmpty(),
|
||||
disjoint = inter.isEmpty() && n.isNotEmpty() && o.isNotEmpty(),
|
||||
newOnly = newOnly,
|
||||
oldOnly = oldOnly
|
||||
)
|
||||
}
|
||||
|
||||
// ===================== §5.1 单条判定 =====================
|
||||
|
||||
/**
|
||||
* 对一条候选记忆判定应执行的唯一 op。纯函数、无副作用、不调用网络。
|
||||
* 规则按 R0 → R1 → R2 → R3 → R4 → R5 短路(§2.4)。
|
||||
*/
|
||||
fun reconcile(
|
||||
candidate: Memory,
|
||||
existing: List<Memory>,
|
||||
mode: Mode = Mode.OFFLINE_DETERMINISTIC,
|
||||
claim: ModelClaim? = null,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): ReconcileOp {
|
||||
// §5.5 边界:空白内容 → 不落库
|
||||
if (candidate.title.isBlank() && candidate.content.isBlank()) {
|
||||
return ReconcileOp.Noop(candidate.id, REASON_EMPTY_CONTENT, false)
|
||||
}
|
||||
// §5.5 边界:未来时间(时钟异常)→ 不动
|
||||
if (candidate.created > now + CLOCK_SKEW_TOLERANCE_MS) {
|
||||
return ReconcileOp.Noop(candidate.id, REASON_CLOCK_SUSPECT, false)
|
||||
}
|
||||
// 系统种子卡不参与判定("我是谁"的元信息,不属于用户属性空间)
|
||||
if (isSystemSeed(candidate)) {
|
||||
return ReconcileOp.Noop(candidate.id, REASON_ALREADY_SETTLED, false)
|
||||
}
|
||||
|
||||
// ---- 步骤 0:抽取。候选自己的 subject/attribute 缺失 → 不可比 → 只能 ADD ----
|
||||
val newKey = keyOfCandidate(candidate)
|
||||
val stamped = stamp(candidate, newKey)
|
||||
if (newKey.subject.isBlank() || newKey.attribute.isBlank()) {
|
||||
return ReconcileOp.Add(
|
||||
stamped, ambiguous = true, needsReview = true, reason = REASON_EXTRACT_INCOMPLETE
|
||||
)
|
||||
}
|
||||
|
||||
val selfInList = existing.any { it.id == candidate.id }
|
||||
|
||||
// ---- 步骤 1:只在 ACTIVE + isLatest 的节点里找对手 ----
|
||||
//(SHADOW 是历史,绝不作目标,否则链会分叉;系统种子卡是"我是谁"的元信息,
|
||||
// 只能当 ADD/NOOP 的对手,永远不能被取代,也不许占住对手位 —— QA-002-B02)
|
||||
val pool = existing.filter {
|
||||
it.id != candidate.id && it.status == MemStatus.ACTIVE && it.isLatest && !isSystemSeed(it)
|
||||
}
|
||||
|
||||
// ---- 步骤 2:锁定同主题集合(topicKey 严格相等,§2.2)----
|
||||
val sameTopic = pool.filter { old ->
|
||||
val k = keyOfNode(old)
|
||||
k.subject.isNotBlank() && k.attribute.isNotBlank() &&
|
||||
k.subject == newKey.subject && k.attribute == newKey.attribute
|
||||
}
|
||||
|
||||
// ---- 步骤 3:不同主题 → R6/R7,各自保留 ----
|
||||
if (sameTopic.isEmpty()) {
|
||||
// 候选已经在库里且没有可消解的对象 = 上一轮已经落定 → 幂等(§7.4)
|
||||
if (selfInList) return ReconcileOp.Noop(candidate.id, REASON_ALREADY_SETTLED, false)
|
||||
return run {
|
||||
val det = ReconcileOp.Add(stamped)
|
||||
arbitrateAndValidate(det, mode, claim, stamped, existing, now)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 步骤 4:与同主题下的**全部** ACTIVE 节点比较,取最强命中 ----
|
||||
// 旧实现只跟"链头"(created 最新那条)比。真实数据上一条误分类卡只要 created 够新,
|
||||
// 就能永久堵住整条链,让真正的矛盾对彼此**根本没被比较过**(QA-002-B03)。
|
||||
val eligible = sameTopic.filter { candidate.created > it.created }
|
||||
|
||||
// ---- 步骤 5:R0 时间序守卫:同主题里没有比候选更旧的对手 → 拒绝时间倒序覆盖 ----
|
||||
if (eligible.isEmpty()) {
|
||||
return ReconcileOp.Noop(sameTopic.maxByOrNull { it.created }!!.id, REASON_TIME_ORDER, false)
|
||||
}
|
||||
|
||||
// ---- 步骤 6:逐对手判定 ----
|
||||
val judged = eligible.map { old -> judgeAgainst(newKey, old, stamped, candidate) }
|
||||
|
||||
// ---- 步骤 6b:R_IDENTICAL —— "纯重复"独立判定(MEM-002D)----
|
||||
// 候选正文与某条 ACTIVE **同主题**卡正文逐字相同(去空白后)→ 它没带来任何新信息:
|
||||
// 只给那张卡复习 hit+1,绝不顺带取代/合并任何其它卡(P_IDENTICAL 高于 R4 UPDATE)。
|
||||
//
|
||||
// 例外(MEM-002D 修的 bug 本体):同主题下若还压着一处**可消解的矛盾/演变**(R1/R2),
|
||||
// 矛盾优先——一次纯重复换不到"消解一处矛盾"。否则「库内同时有 喜欢咖啡 + 咖啡戒断,
|
||||
// 候选又来一条 不喝咖啡了」会永远命中纯重复/冗余 → NOOP,`喜欢咖啡` 永远 ACTIVE,
|
||||
// 她会一直说"主人喜欢喝咖啡"(矛盾永久残留)。
|
||||
//
|
||||
// reason 复用 REASON_REDUNDANT:对用户/审计而言这与 R3 是同一句结论("这条我已经知道了"),
|
||||
// 两者的区别是**行为**(纯重复不许动别的卡),而不是原因文案。
|
||||
val hasResolvableConflict = judged.any { it.priority in CONFLICT_PRIORITIES }
|
||||
val identical = if (hasResolvableConflict) {
|
||||
null
|
||||
} else {
|
||||
sameTopic.filter { it.content.trim() == candidate.content.trim() }
|
||||
.maxByOrNull { it.created }
|
||||
?.let { same ->
|
||||
Judged(
|
||||
same.id, same.created,
|
||||
ReconcileOp.Noop(same.id, REASON_REDUNDANT, hitsIncrement = true),
|
||||
P_IDENTICAL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 步骤 6c:取最强命中 ----
|
||||
val det = (judged + listOfNotNull(identical)).maxWithOrNull(
|
||||
compareBy<Judged> { it.priority }
|
||||
.thenBy { it.created } // 同强度优先更新的证据
|
||||
.thenByDescending { it.targetId } // 再按 id 反序,保证确定性
|
||||
)!!.op
|
||||
|
||||
return arbitrateAndValidate(det, mode, claim, stamped, existing, now)
|
||||
}
|
||||
|
||||
/** 一次"候选 vs 某个对手"的判定结果,`priority` 越大越优先被采纳。 */
|
||||
private data class Judged(
|
||||
val targetId: String,
|
||||
val created: Long,
|
||||
val op: ReconcileOp,
|
||||
val priority: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 强度阶梯(MEM-002D 重排):**矛盾 > 演变 > 去重 > 合并 > 不猜**。
|
||||
*
|
||||
* ```
|
||||
* R1 极性冲突 100 > R2 单值演变 95 > R3 冗余 90 > R4 合并 70 > R5 不猜 10
|
||||
* ```
|
||||
*
|
||||
* **为什么把 R3(冗余)从最高位降下来**:旧阶梯是「冗余 100 > 极性冲突 90」,理由是
|
||||
* "我已经知道了 → 改动最少最安全"。这个理由在**库内本来就干净**时成立,但它有一个致命反例:
|
||||
* 库里同时存在两条互相矛盾的 ACTIVE 卡(`喜欢咖啡` + `咖啡戒断`),候选又来一条
|
||||
* `不喝咖啡了` —— 它与 `咖啡戒断` 冗余,于是 R3 抢在 R1 前面,判定 NOOP,
|
||||
* **`喜欢咖啡` 原封不动留在 ACTIVE,矛盾永久残留**。而"库里同时躺着两条互相矛盾的
|
||||
* ACTIVE 卡"本身就是脏状态,取代它是清理,不是"凭白多出一次状态变更"。
|
||||
*
|
||||
* 排序依据是**危害量级**,不是改动量:矛盾会让她**说错话**(对主人说"你喜欢喝咖啡"),
|
||||
* 冗余只是**浪费空间**(一条卡多占几十字节)。能用一次写入把说错话的源头去掉,
|
||||
* 永远比省下这次写入重要。
|
||||
*
|
||||
* 例外是 [P_IDENTICAL]:候选与某条 ACTIVE 卡**逐字相同**时,它是纯重复,
|
||||
* 此时不许顺带取代/合并别的卡(详见 `reconcile` 步骤 6b 的条件)。
|
||||
*/
|
||||
private val P_POLARITY = 100
|
||||
private val P_SINGLE_SLOT = 95
|
||||
private val P_REDUNDANT = 90
|
||||
private val P_MULTI_MERGE = 70
|
||||
private val P_UNRESOLVED = 10
|
||||
|
||||
/**
|
||||
* R_IDENTICAL(纯重复,步骤 6b)的强度:名义上高于一切 —— 逐字相同的候选不带来新信息,
|
||||
* 连 R4 的合并都不该触发(否则会顺带把另一条卡打成 SHADOW)。
|
||||
*
|
||||
* 但它**只在同主题没有可消解矛盾(R1/R2)时进入比较**:见 [CONFLICT_PRIORITIES]。
|
||||
*/
|
||||
private val P_IDENTICAL = 110
|
||||
|
||||
/** 会**真正改变库状态**、因而必须压过"去重/纯重复"的两类判定(MEM-002D)。 */
|
||||
private val CONFLICT_PRIORITIES = setOf(P_POLARITY, P_SINGLE_SLOT)
|
||||
|
||||
/**
|
||||
* 候选 vs 单个同主题对手的确定性判定(§2.4 R1–R5 短路),不跨对手比较。
|
||||
* 纯函数,无副作用。
|
||||
*/
|
||||
private fun judgeAgainst(
|
||||
newKey: FactKey,
|
||||
old: Memory,
|
||||
stamped: Memory,
|
||||
candidate: Memory
|
||||
): Judged {
|
||||
val oldKey = keyOfNode(old)
|
||||
val rel = valueRelation(newKey.valueTokens, oldKey.valueTokens)
|
||||
|
||||
return when {
|
||||
// R1 极性互斥 + 值重叠 = 同一断言被翻转(咖啡反例走这里)
|
||||
rel.overlap && newKey.polarity != 0 && oldKey.polarity != 0 &&
|
||||
newKey.polarity * oldKey.polarity == -1 ->
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Supersede(old.id, stamped, REASON_POLARITY_CONFLICT, CONF_POLARITY_CONFLICT),
|
||||
P_POLARITY
|
||||
)
|
||||
|
||||
// R2 单值槽位的值演变(住在杭州 → 搬到上海了)
|
||||
newKey.slotType == SlotType.SINGLE && newKey.polarity == oldKey.polarity &&
|
||||
!rel.equal && !rel.contains ->
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Supersede(old.id, stamped, REASON_SINGLE_SLOT_CHANGED, CONF_SINGLE_SLOT_CHANGED),
|
||||
P_SINGLE_SLOT
|
||||
)
|
||||
|
||||
// R3 值等价 / 新卡无新 token → 真冗余(不落库,旧卡复习 hit+1)
|
||||
rel.equal || rel.contains ->
|
||||
Judged(old.id, old.created, ReconcileOp.Noop(old.id, REASON_REDUNDANT, hitsIncrement = true), P_REDUNDANT)
|
||||
|
||||
// R4-blocked 合并产物不许再被合并(QA-003-B01 ②)
|
||||
// 位置刻意夹在 R3 之后、R4 之前:合并产物**仍然可以被取代(R1/R2)、仍然可以被判冗余(R3)**
|
||||
// ——它已经取代了被合并的那些卡,后续的矛盾/演变/重复必须照常处理;被挡掉的只有"合并"。
|
||||
// 这样即使 [MERGE_MAX_CHARS] 的闸门以后被放宽或绕过,链式累加在**结构上**也不可能发生。
|
||||
old.mergedInto ->
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_MERGE_CHAIN_BLOCKED),
|
||||
P_UNRESOLVED
|
||||
)
|
||||
|
||||
// R4 多值槽位可叠加 → 合并精炼。**正文只许原文拼接**(QA-002-B01)
|
||||
newKey.slotType == SlotType.MULTI && newKey.polarity == oldKey.polarity -> {
|
||||
// 长度闸门在**判定阶段**就把合并否掉,绝不在 mergeForUpdate 里截断正文
|
||||
// ——截断会产生原文之外的字符串,违反 isVerbatimMerge 闸门(QA-003-B01 ①)
|
||||
if (mergeWouldExceedLimit(old, candidate)) {
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_MERGE_LIMIT),
|
||||
P_UNRESOLVED
|
||||
)
|
||||
} else {
|
||||
val merged = mergeForUpdate(stamped, old)
|
||||
if (merged == null) {
|
||||
// 拼不出"逐字来自原文"的正文 → 不合并,退化为 ADD(安全侧失败)
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_RULE_UNRESOLVED),
|
||||
P_UNRESOLVED
|
||||
)
|
||||
} else {
|
||||
Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Update(old.id, merged, REASON_MULTI_SLOT_MERGE, CONF_MULTI_SLOT_MERGE),
|
||||
P_MULTI_MERGE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R5 其它 → 不猜,只落地(新卡必须落地,旧卡绝不动)
|
||||
else -> Judged(
|
||||
old.id, old.created,
|
||||
ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_RULE_UNRESOLVED),
|
||||
P_UNRESOLVED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §5.2 仲裁 + §3.3 兜底校验。
|
||||
* 确定性结果是主路径;模型**只能把 ADD 升格**为 SUPERSEDE/UPDATE,不能推翻 SUPERSEDE/NOOP。
|
||||
*/
|
||||
private fun arbitrateAndValidate(
|
||||
det: ReconcileOp,
|
||||
mode: Mode,
|
||||
claim: ModelClaim?,
|
||||
stamped: Memory,
|
||||
existing: List<Memory>,
|
||||
now: Long
|
||||
): ReconcileOp {
|
||||
if (mode != Mode.LLM_ASSISTED || claim == null) return det
|
||||
val llm = validate(claim, stamped, existing, now)
|
||||
val chosen = when {
|
||||
det is ReconcileOp.Supersede -> det // 模型无权把矛盾降级
|
||||
det is ReconcileOp.Noop && det.reason == REASON_REDUNDANT -> det // "无新 token" 比模型更硬
|
||||
det is ReconcileOp.Add && llm is ReconcileOp.Supersede -> llm // 模型真正的价值区
|
||||
det is ReconcileOp.Add && llm is ReconcileOp.Update -> llm
|
||||
else -> det
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.3 兜底校验 V1–V10:模型给出的 op 只要有一条不满足,**整条作废**并按表降级(不做部分采纳)。
|
||||
*/
|
||||
fun validate(
|
||||
claim: ModelClaim,
|
||||
candidate: Memory,
|
||||
existing: List<Memory>,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): ReconcileOp {
|
||||
val agentOp = claim.op.trim().uppercase()
|
||||
val stamped = stamp(candidate, keyOfCandidate(candidate))
|
||||
|
||||
// 时钟异常(候选来自未来)→ 什么都不动
|
||||
if (candidate.created > now + CLOCK_SKEW_TOLERANCE_MS) {
|
||||
return ReconcileOp.Noop(claim.targetId, REASON_CLOCK_SUSPECT, false)
|
||||
}
|
||||
|
||||
// V3 任何删除意图都是协议违规:整条丢弃 + 审计
|
||||
if (agentOp.contains("DELETE") || agentOp.contains("REMOVE") || agentOp.contains("PURGE")) {
|
||||
return ReconcileOp.Noop(claim.targetId, REJECT_DELETE_FORBIDDEN, false)
|
||||
}
|
||||
// V8 op 不在白名单
|
||||
if (agentOp !in setOf("ADD", "UPDATE", "SUPERSEDE", "NOOP")) {
|
||||
return ReconcileOp.Noop(claim.targetId, REJECT_SCHEMA, false)
|
||||
}
|
||||
if (agentOp == "NOOP") return ReconcileOp.Noop(claim.targetId, "MODEL_NOOP", false)
|
||||
if (agentOp == "ADD") return ReconcileOp.Add(stamped)
|
||||
|
||||
// V1 目标不存在(幻觉 id / 已被用户删除 / 并发变更)
|
||||
val target = claim.targetId?.let { id -> existing.firstOrNull { it.id == id } }
|
||||
?: return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_TARGET_NOT_FOUND)
|
||||
|
||||
// V11 系统种子卡不可被取代 / 合并(与 reconcile 的 pool 过滤同一条规则,防止绕过)
|
||||
if (isSystemSeed(target)) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_SYSTEM_SEED)
|
||||
}
|
||||
|
||||
val nk = keyOfCandidate(candidate)
|
||||
val ok = keyOfNode(target)
|
||||
|
||||
// V2 跨主题合并:无论模型多自信都是错的
|
||||
if (nk.subject.isNotBlank() && ok.subject.isNotBlank() && nk.topicKey != ok.topicKey) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_CROSS_TOPIC)
|
||||
}
|
||||
// V4 拒绝用旧卡取代新卡
|
||||
if (target.created > candidate.created) {
|
||||
return ReconcileOp.Noop(target.id, REJECT_TIME_ORDER, false)
|
||||
}
|
||||
// V9 SUPERSEDE 必须能说清"凭什么矛盾"
|
||||
if (agentOp == "SUPERSEDE" && nk.polarity == ok.polarity && nk.slotType != SlotType.SINGLE) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_POLARITY_UNSUPPORTED)
|
||||
}
|
||||
// V7 引用洪泛:目标必须是有效链头
|
||||
if (target.status != MemStatus.ACTIVE || !target.isLatest) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_REF_FLOOD)
|
||||
}
|
||||
// V10 不写空卡
|
||||
val content = claim.content?.takeIf { it.isNotBlank() } ?: candidate.content
|
||||
if (content.isBlank()) return ReconcileOp.Noop(target.id, REJECT_EMPTY_CONTENT, false)
|
||||
|
||||
if (agentOp == "UPDATE") {
|
||||
// QA-003-B01:判定阶段的两道闸门对**模型路径同样生效**——模型不能绕过结构约束。
|
||||
// 否则一旦接上 LLM(Phase 2),`UPDATE` 又可以把合并产物当目标、把正文堆过上限,
|
||||
// 刚刚堵死的正反馈会从另一个入口回来。
|
||||
if (target.mergedInto) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_MERGE_CHAIN_BLOCKED)
|
||||
}
|
||||
if (content.length > MERGE_MAX_CHARS) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REASON_MERGE_LIMIT)
|
||||
}
|
||||
// V6 合并正文不许丢旧卡的字(consolidate 原有 bug 的同族风险)
|
||||
if (!coversTokens(content, ok.valueTokens)) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_CONTENT_LOSS)
|
||||
}
|
||||
// V5 SINGLE 槽位唯一性由代码守,不交给模型
|
||||
val otherActive = existing.count {
|
||||
it.id != target.id && it.status == MemStatus.ACTIVE && it.isLatest &&
|
||||
keyOfNode(it).topicKey == ok.topicKey
|
||||
}
|
||||
if (ok.slotType == SlotType.SINGLE && otherActive > 0) {
|
||||
return ReconcileOp.Add(stamped, ambiguous = true, needsReview = true, reason = REJECT_SINGLE_SLOT_DUPLICATE)
|
||||
}
|
||||
val merged = stamp(
|
||||
stamped.copy(
|
||||
title = target.title.ifBlank { stamped.title },
|
||||
content = content,
|
||||
tags = (target.tags + stamped.tags).distinct(),
|
||||
importance = maxOf(target.importance, stamped.importance).coerceIn(1, 3),
|
||||
longTerm = target.longTerm || stamped.longTerm
|
||||
)
|
||||
)
|
||||
return ReconcileOp.Update(target.id, merged, MODEL_UPDATE_REASON, 0.70)
|
||||
}
|
||||
|
||||
return ReconcileOp.Supersede(
|
||||
target.id, stamped.copy(longTerm = target.longTerm || stamped.longTerm),
|
||||
"MODEL_SUPERSEDE", 0.75
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 R4 合并会不会把正文顶过 [MERGE_MAX_CHARS](QA-003-B01 ①)。
|
||||
*
|
||||
* 口径 = `旧正文.length + 1(分隔符) + 新正文.length`,用**去空白后的长度**:
|
||||
* 于是"任何合并产物的正文长度 ≤ [MERGE_MAX_CHARS]"是个精确上界,而不是"大约"。
|
||||
*
|
||||
* 为什么用"两段之和"而不是"先算合并结果再量长度":R4 之前 R3 已经把
|
||||
* `equal` / `contains`(一方含另一方)的对手全部消化成 NOOP 了,走到 R4 的两段基本是不相交的值,
|
||||
* 合并结果就是 `旧;新`,两者长度之和即结果长度。用求和是同一件事的等价写法,
|
||||
* 但不需要为了量长度先把字符串拼出来(判定阶段零分配)。
|
||||
*
|
||||
* 刻意不做"超限就截断":截断会造出原文之外的正文,[isVerbatimMerge] 会否掉它,
|
||||
* 记忆不可篡改这条铁律不能为了省一条卡而让步。
|
||||
*/
|
||||
fun mergeWouldExceedLimit(old: Memory, candidate: Memory): Boolean =
|
||||
old.content.trim().length + MERGE_SEPARATOR.length + candidate.content.trim().length >
|
||||
MERGE_MAX_CHARS
|
||||
|
||||
/**
|
||||
* UPDATE 的合并正文:**只做原文拼接,一个 token 都不许重排**(QA-002-B01)。
|
||||
*
|
||||
* 旧实现是 `旧正文 + "," + 新卡独有 valueTokens.joinToString("、")`——把 token 列表当句子
|
||||
* 重新拼回去,动词和结构全丢。真实数据上「插件清单」就这样被改成了
|
||||
* `…、个内置插件、水提醒、久坐提醒、…`,"喝水提醒" 变 "水提醒",原正文整段消失。
|
||||
* 这违反"记忆不可篡改"铁律,不可接受。
|
||||
*
|
||||
* 现在的实现只有四种可能,全部逐字来自两条原文:
|
||||
* 1. 一方为空 → 用另一方;
|
||||
* 2. 两方相同 → 用其一;
|
||||
* 3. 一方是另一方的子串 → 用较长的那个;
|
||||
* 4. 否则 → `旧正文;新正文`。
|
||||
*
|
||||
* 返回 `null` 表示"拼不出合规正文",调用方必须退化为 ADD。这条兜底是最后一道闸:
|
||||
* 即使以后有人改了上面的逻辑,也不可能让一张卡的正文变成原文之外的字符串。
|
||||
*/
|
||||
private fun mergeForUpdate(candidate: Memory, target: Memory): Memory? {
|
||||
val oldText = target.content.trim()
|
||||
val newText = candidate.content.trim()
|
||||
val mergedContent = when {
|
||||
oldText.isBlank() -> newText
|
||||
newText.isBlank() -> oldText
|
||||
oldText == newText || oldText.contains(newText) -> oldText
|
||||
newText.contains(oldText) -> newText
|
||||
else -> "$oldText$MERGE_SEPARATOR$newText"
|
||||
}
|
||||
if (!isVerbatimMerge(mergedContent, oldText, newText)) return null
|
||||
|
||||
val merged = candidate.copy(
|
||||
title = target.title.ifBlank { candidate.title },
|
||||
content = mergedContent,
|
||||
tags = (target.tags + candidate.tags).distinct(),
|
||||
importance = maxOf(target.importance, candidate.importance).coerceIn(1, 3),
|
||||
longTerm = target.longTerm || candidate.longTerm,
|
||||
emotion = target.emotion.ifBlank { candidate.emotion },
|
||||
// 打上"我是合并产物"的机器可读标记:它此后不许再作为 R4 的对手(QA-003-B01 ②)
|
||||
mergedInto = true
|
||||
)
|
||||
return stamp(merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文合规性闸门:`merged` 必须能由 `old` 与 `new` 的**原文**(含 `;` 连接符)拼出来。
|
||||
*
|
||||
* 注意这里不用"token 是否都在"来判断——那正是旧 bug 的思维(token 都在,句子已经烂了)。
|
||||
* 这里要求**整段原文**以子串形式出现。
|
||||
*/
|
||||
fun isVerbatimMerge(merged: String, oldText: String, newText: String): Boolean {
|
||||
val candidates = listOf(
|
||||
oldText, newText,
|
||||
"$oldText$MERGE_SEPARATOR$newText", "$newText$MERGE_SEPARATOR$oldText"
|
||||
).filter { it.isNotBlank() }
|
||||
return candidates.any { it == merged }
|
||||
}
|
||||
|
||||
/** 旧卡的全部值 token 是否都还在新正文里(V6)。 */
|
||||
fun coversTokens(content: String, oldTokens: List<String>): Boolean =
|
||||
oldTokens.all { content.contains(it) }
|
||||
|
||||
// ===================== §5.4 applyOp:唯一写入通道 =====================
|
||||
|
||||
/**
|
||||
* 把 op 落到列表上。**这是唯一的写入通道**:哪条节点改 `status`、改哪个字段、
|
||||
* `supersedes` 指向谁,全部在这里生成,调用方无法绕过。
|
||||
*
|
||||
* @param idGen 新节点 id 生成器(默认 8 位随机串;单测可注入确定性实现)
|
||||
*/
|
||||
fun applyOp(
|
||||
list: MutableList<Memory>,
|
||||
op: ReconcileOp,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
idGen: () -> String = { java.util.UUID.randomUUID().toString().take(8) }
|
||||
): AppliedOp = when (op) {
|
||||
is ReconcileOp.Add -> {
|
||||
val dupIdx = list.indexOfFirst { it.id == op.memory.id }
|
||||
if (dupIdx >= 0) {
|
||||
// 候选已经在库里(consolidate 的常态:候选就是库内某条 ACTIVE)→ 不重复追加
|
||||
AppliedOp(false, op.memory.id)
|
||||
} else {
|
||||
list += op.memory.copy(
|
||||
created = now, updated = now, schemaVersion = 1,
|
||||
status = MemStatus.ACTIVE, isLatest = true,
|
||||
supersedes = emptyList(), supersededBy = null, validTo = null,
|
||||
validFrom = now
|
||||
)
|
||||
AppliedOp(true, op.memory.id)
|
||||
}
|
||||
}
|
||||
|
||||
is ReconcileOp.Supersede -> supersedeLike(list, op.targetId, op.memory, op.reason, chained = true, now = now, idGen = idGen)
|
||||
|
||||
is ReconcileOp.Update -> supersedeLike(list, op.targetId, op.memory, op.reason, chained = false, now = now, idGen = idGen)
|
||||
|
||||
is ReconcileOp.Noop -> {
|
||||
val i = op.targetId?.let { id -> list.indexOfFirst { it.id == id } } ?: -1
|
||||
if (i < 0 || !op.hitsIncrement) {
|
||||
AppliedOp(false, null)
|
||||
} else {
|
||||
list[i] = list[i].copy(hits = list[i].hits + 1, updated = now) // 复习强化
|
||||
AppliedOp(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun supersedeLike(
|
||||
list: MutableList<Memory>,
|
||||
targetId: String,
|
||||
incoming: Memory,
|
||||
reason: String,
|
||||
chained: Boolean,
|
||||
now: Long,
|
||||
idGen: () -> String
|
||||
): AppliedOp {
|
||||
val ti = list.indexOfFirst { it.id == targetId }
|
||||
if (ti < 0) return AppliedOp(false, null) // 兜底 V1
|
||||
val target = list[ti]
|
||||
// 兜底:系统种子卡永远不许被改成 SHADOW(reconcile 的 pool 已过滤,这里防绕过)
|
||||
if (isSystemSeed(target)) return AppliedOp(false, null)
|
||||
// 候选本身可能就在库里(consolidate 路径):复用它的 id 原地升级,绝不追加重复 ACTIVE
|
||||
val ci = list.indexOfFirst { it.id == incoming.id }
|
||||
val newId = if (ci >= 0) incoming.id else idGen()
|
||||
|
||||
// 旧节点:只改元数据字段,正文/标签/created 一字不动(记忆不可篡改)
|
||||
// `supersedeReason` 也写上:这是区分"链式取代"与"不建链的合并"的唯一机器可读标记,
|
||||
// 不变量 I2 靠它才不会把 UPDATE(`supersedes=[]` 是设计如此)误报成链断裂。
|
||||
list[ti] = target.copy(
|
||||
status = MemStatus.SHADOW,
|
||||
isLatest = false,
|
||||
supersededBy = newId,
|
||||
validTo = now,
|
||||
supersedeReason = reason
|
||||
)
|
||||
|
||||
val node = incoming.copy(
|
||||
id = newId,
|
||||
created = if (ci >= 0) incoming.created else now,
|
||||
updated = now,
|
||||
schemaVersion = 1,
|
||||
status = MemStatus.ACTIVE,
|
||||
isLatest = true,
|
||||
supersedes = if (chained) listOf(targetId).take(MAX_SUPERSEDES) else emptyList(),
|
||||
supersededBy = null,
|
||||
validTo = null,
|
||||
validFrom = if (ci >= 0 && incoming.validFrom > 0L) incoming.validFrom else now,
|
||||
supersedeReason = if (chained) reason else "",
|
||||
longTerm = incoming.longTerm || target.longTerm,
|
||||
importance = maxOf(incoming.importance, target.importance).coerceIn(1, 3),
|
||||
tags = (target.tags + incoming.tags).distinct(),
|
||||
// 合并产物标记(QA-003-B01 ②):这里强制一次,而不是只依赖 mergeForUpdate() 自己打标记,
|
||||
// 因为模型路径的 `validate()` UPDATE 也会产出合并正文(不过 mergeForUpdate),
|
||||
// 两条路径都必须让产物从此不再是合法的 R4 对手。链式取代则沿用候选自己的标记。
|
||||
mergedInto = if (chained) incoming.mergedInto else true
|
||||
)
|
||||
if (ci >= 0) list[ci] = node else list += node
|
||||
return AppliedOp(true, newId)
|
||||
}
|
||||
|
||||
// ===================== §5.3 批量入口 =====================
|
||||
|
||||
/**
|
||||
* 批量 reconcile。**严格串行**:每条候选立即应用,使后续候选看到更新后的链头
|
||||
* (否则两条矛盾卡会同时 SUPERSEDE 同一条)。
|
||||
*/
|
||||
fun reconcileBatch(
|
||||
list: MutableList<Memory>,
|
||||
candidates: List<Memory>,
|
||||
mode: Mode = Mode.OFFLINE_DETERMINISTIC,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
idGen: () -> String = { java.util.UUID.randomUUID().toString().take(8) }
|
||||
): BatchResult {
|
||||
if (candidates.isEmpty()) return BatchResult() // §5.5 候选为空 → 什么都不写
|
||||
val ops = mutableListOf<ReconcileOp>()
|
||||
val audits = mutableListOf<ReconcileAudit>()
|
||||
var changed = 0
|
||||
|
||||
for (c in candidates) {
|
||||
// 系统种子卡不进判定:它们既不参与取代,也不该产出"我处理过了"的噪音审计
|
||||
if (isSystemSeed(c)) {
|
||||
ops += ReconcileOp.Noop(c.id, REASON_ALREADY_SETTLED, false)
|
||||
continue
|
||||
}
|
||||
// 已被本轮前序 op 转成 SHADOW 的候选(快照候选)→ 直接跳过,保证幂等
|
||||
val current = list.firstOrNull { it.id == c.id }
|
||||
val op: ReconcileOp = if (current != null && current.status == MemStatus.SHADOW) {
|
||||
ReconcileOp.Noop(c.id, REASON_ALREADY_SHADOW, false)
|
||||
} else {
|
||||
reconcile(c, list, mode, now = now)
|
||||
}
|
||||
ops += op
|
||||
val applied = applyOp(list, op, now, idGen)
|
||||
if (applied.changed) changed++
|
||||
audits += auditOf(op, applied, mode, now)
|
||||
}
|
||||
return BatchResult(ops, changed, audits)
|
||||
}
|
||||
|
||||
fun auditOf(op: ReconcileOp, applied: AppliedOp, mode: Mode, now: Long): ReconcileAudit {
|
||||
val modeName = if (mode == Mode.LLM_ASSISTED) "LLM" else "OFFLINE"
|
||||
return when (op) {
|
||||
is ReconcileOp.Add -> ReconcileAudit(
|
||||
now, modeName, "ADD", op.reason, CONF_AMBIGUOUS,
|
||||
applied.newId ?: op.memory.id, null
|
||||
)
|
||||
is ReconcileOp.Supersede -> ReconcileAudit(
|
||||
now, modeName, "SUPERSEDE", op.reason, op.confidence,
|
||||
applied.newId ?: "", op.targetId
|
||||
)
|
||||
is ReconcileOp.Update -> ReconcileAudit(
|
||||
now, modeName, "UPDATE", op.reason, op.confidence,
|
||||
applied.newId ?: "", op.targetId
|
||||
)
|
||||
is ReconcileOp.Noop -> ReconcileAudit(
|
||||
now, modeName, "NOOP", op.reason, CONF_REDUNDANT.takeIf { op.hitsIncrement } ?: 0.0,
|
||||
"", op.targetId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 不变量工具(§4.3 I1–I5,供单测与自检) =====================
|
||||
|
||||
/** 沿 `supersededBy` 上溯的链深度;有环或超限返回 [MAX_CHAIN_DEPTH] + 1。 */
|
||||
fun chainDepth(list: List<Memory>, start: Memory): Int {
|
||||
var cur = start
|
||||
var depth = 0
|
||||
val seen = HashSet<String>()
|
||||
while (true) {
|
||||
val nextId = cur.supersededBy ?: return depth
|
||||
if (!seen.add(cur.id)) return MAX_CHAIN_DEPTH + 1 // 环
|
||||
val next = list.firstOrNull { it.id == nextId } ?: return depth // 悬空指针按终止处理
|
||||
cur = next
|
||||
depth++
|
||||
if (depth > MAX_CHAIN_DEPTH) return MAX_CHAIN_DEPTH + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回违反 I1–I5 的问题描述(空列表 = 全部成立)。 */
|
||||
fun checkInvariants(list: List<Memory>): List<String> {
|
||||
val problems = mutableListOf<String>()
|
||||
list.forEach { m ->
|
||||
if ((m.status == MemStatus.ACTIVE) != m.isLatest) problems += "I1:${m.id}"
|
||||
m.supersededBy?.let { t ->
|
||||
val target = list.firstOrNull { it.id == t }
|
||||
// I2:反指必须能回到正向指针。UPDATE 按设计**不建链**(`supersedes=[]`,
|
||||
// 见规格 §1.3),此时靠旧卡上的 `supersedeReason` 识别,不能误报成链断裂。
|
||||
val unchainedUpdate = m.supersedeReason == REASON_MULTI_SLOT_MERGE ||
|
||||
m.supersedeReason == MODEL_UPDATE_REASON
|
||||
if (target == null || (!target.supersedes.contains(m.id) && !unchainedUpdate)) {
|
||||
problems += "I2:${m.id}"
|
||||
}
|
||||
}
|
||||
if (chainDepth(list, m) > MAX_CHAIN_DEPTH) problems += "I4:${m.id}"
|
||||
if (m.supersedes.size > MAX_SUPERSEDES) problems += "I5:${m.id}"
|
||||
}
|
||||
// I3:SINGLE 槽位在**用户属性空间**内唯一。系统种子卡属于 `小鲸娘|system`,
|
||||
// 与用户事实不同主题,不参与这条计数。
|
||||
list.filterNot { isSystemSeed(it) }
|
||||
.groupBy { Triple(it.subject, it.attribute, it.slotType) }.forEach { (k, g) ->
|
||||
if (k.third == SlotType.SINGLE && g.count { it.status == MemStatus.ACTIVE } > 1) {
|
||||
problems += "I3:${k.first}|${k.second}"
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 应用内更新:检查 Gitea Release → 下载 APK → 拉起系统安装器。
|
||||
*
|
||||
* 为什么是 Gitea Release 而不是自建接口:`.gitea/workflows/build.yml` 在打 tag 时会把
|
||||
* APK 与 version.json 挂到 Release 资产上,App 直接读 Gitea 的公开 API 即可,不需要
|
||||
* 额外维护一份版本清单。仓库是 public,读 API 不需要 token。
|
||||
*
|
||||
* 版本判定:以 **versionCode** 为准,不用 versionName 比字符串——"1.0.9" 与 "1.0.10"
|
||||
* 按字符串比会判错。versionCode 由 CI 注入(1000 + run_number,单调递增)。
|
||||
*/
|
||||
object UpdateChecker {
|
||||
|
||||
private const val TAG = "PetFloat"
|
||||
private const val SERVER = "http://123.206.124.151:3000"
|
||||
private const val REPO = "mtfyun/dsh-pet-floating"
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val PROVIDER_AUTHORITY = "com.dsh.petfloat.apk"
|
||||
|
||||
/** 下载目录:cacheDir/update(系统清理缓存时可回收,APK 属一次性文件) */
|
||||
private fun updateDir(ctx: Context): File =
|
||||
File(ctx.cacheDir, "update").apply { if (!exists()) mkdirs() }
|
||||
|
||||
data class Info(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val apkName: String,
|
||||
val apkUrl: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String?,
|
||||
val notes: String
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
data class HasUpdate(val info: Info) : Result()
|
||||
object UpToDate : Result()
|
||||
data class Failed(val message: String) : Result()
|
||||
}
|
||||
|
||||
fun currentVersionCode(ctx: Context): Int = try {
|
||||
ctx.packageManager.getPackageInfo(ctx.packageName, 0).let {
|
||||
@Suppress("DEPRECATION")
|
||||
it.versionCode
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
fun currentVersionName(ctx: Context): String = try {
|
||||
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?"
|
||||
} catch (e: Exception) {
|
||||
"?"
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查更新。**必须在后台线程调用**(内部直接用网络,不发散线程)。
|
||||
*
|
||||
* 只取 releases 列表的第一项:Gitea 按创建时间倒序返回,第一项即最新发布。
|
||||
* 不取 draft(草稿不该被用户看到)。
|
||||
*/
|
||||
fun checkBlocking(): Result {
|
||||
return try {
|
||||
val url = URL("$SERVER/api/v1/repos/$REPO/releases?limit=10")
|
||||
val body = httpGet(url.openConnection() as HttpURLConnection)
|
||||
?: return Result.Failed("网络无响应")
|
||||
val arr = JSONArray(body)
|
||||
var best: Info? = null
|
||||
for (i in 0 until arr.length()) {
|
||||
val rel = arr.optJSONObject(i) ?: continue
|
||||
if (rel.optBoolean("draft", false)) continue
|
||||
val info = parseRelease(rel) ?: continue
|
||||
val cur = best
|
||||
if (cur == null || info.versionCode > cur.versionCode) best = info
|
||||
}
|
||||
val latest = best ?: return Result.Failed("还没有发布过版本")
|
||||
Result.HasUpdate(latest)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "check update failed", e)
|
||||
Result.Failed(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个 release。
|
||||
*
|
||||
* versionCode 优先取 `version.json` 资产(CI 显式写的,最可靠),
|
||||
* 其次从 tag 形如 `v1.0.1005` 里取尾段,最后退回 0(视为不可比较,不会被当成更新)。
|
||||
* APK 资产优先取名字带 `hq` 的(完整画质),没有则取第一个 .apk。
|
||||
*/
|
||||
private fun parseRelease(rel: JSONObject): Info? {
|
||||
val tag = rel.optString("tag_name", "").removePrefix("v")
|
||||
val assets = rel.optJSONArray("assets") ?: JSONArray()
|
||||
|
||||
var apkName = ""
|
||||
var apkUrl = ""
|
||||
var size = 0L
|
||||
var sha: String? = null
|
||||
var vcFromJson = 0
|
||||
var vnFromJson = ""
|
||||
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.optJSONObject(i) ?: continue
|
||||
val name = a.optString("name", "")
|
||||
val dl = a.optString("browser_download_url", "")
|
||||
when {
|
||||
name == "version.json" -> {
|
||||
// 资产内容需要额外下载,这里只记 URL;体积小,直接同步拉
|
||||
runCatching {
|
||||
val txt = httpGet(URL(dl).openConnection() as HttpURLConnection) ?: ""
|
||||
val j = JSONObject(txt)
|
||||
vcFromJson = j.optInt("versionCode", 0)
|
||||
vnFromJson = j.optString("versionName", "")
|
||||
sha = j.optString("sha256", "").ifBlank { null }
|
||||
}
|
||||
}
|
||||
name.endsWith(".apk", true) -> {
|
||||
// hq 优先:完整画质是这个项目的主发布形态
|
||||
if (apkName.isEmpty() || (name.contains("hq") && !apkName.contains("hq"))) {
|
||||
apkName = name
|
||||
apkUrl = dl
|
||||
size = a.optLong("size", 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apkUrl.isEmpty()) return null
|
||||
|
||||
val vc = when {
|
||||
vcFromJson > 0 -> vcFromJson
|
||||
else -> tag.substringAfterLast('.').toIntOrNull() ?: 0
|
||||
}
|
||||
if (vc <= 0) return null
|
||||
|
||||
return Info(
|
||||
versionCode = vc,
|
||||
versionName = vnFromJson.ifBlank { tag },
|
||||
apkName = apkName,
|
||||
apkUrl = apkUrl,
|
||||
sizeBytes = size,
|
||||
sha256 = sha,
|
||||
notes = rel.optString("body", "")
|
||||
)
|
||||
}
|
||||
|
||||
private fun httpGet(conn: HttpURLConnection): String? = try {
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 12000
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
if (conn.responseCode in 200..299) {
|
||||
conn.inputStream.bufferedReader().use { it.readText() }
|
||||
} else {
|
||||
Log.w(TAG, "GET ${conn.url} -> ${conn.responseCode}")
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 APK 到 `cacheDir/update/`。
|
||||
*
|
||||
* @param onProgress 0..100,在主线程以外回调(调用方自行 post 到 UI)
|
||||
* @return 下载完成的文件;失败抛异常由调用方捕获
|
||||
*/
|
||||
fun downloadBlocking(
|
||||
ctx: Context,
|
||||
info: Info,
|
||||
onProgress: (Int) -> Unit = {}
|
||||
): File {
|
||||
val dir = updateDir(ctx)
|
||||
// 清掉历史残留:同一时间只保留一个待安装包,避免缓存越积越大
|
||||
dir.listFiles()?.forEach { if (it.name != info.apkName) it.delete() }
|
||||
val out = File(dir, info.apkName)
|
||||
if (out.exists() && out.length() == info.sizeBytes && info.sizeBytes > 0) {
|
||||
onProgress(100)
|
||||
return out // 已完整下载过(重试场景),不重复拉
|
||||
}
|
||||
|
||||
val conn = URL(info.apkUrl).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.connectTimeout = 10000
|
||||
conn.readTimeout = 30000
|
||||
conn.instanceFollowRedirects = true
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) throw IllegalStateException("下载失败 HTTP $code")
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 } ?: info.sizeBytes
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(out).use { fos ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
var done = 0L
|
||||
var lastPct = -1
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n <= 0) break
|
||||
fos.write(buf, 0, n)
|
||||
done += n
|
||||
if (total > 0) {
|
||||
val pct = (done * 100 / total).toInt().coerceIn(0, 100)
|
||||
if (pct != lastPct) { lastPct = pct; onProgress(pct) }
|
||||
}
|
||||
}
|
||||
fos.flush()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
|
||||
// 校验:体积 + SHA256(CI 在 version.json 里给了摘要;不给就只校验体积)
|
||||
if (info.sizeBytes > 0 && out.length() != info.sizeBytes) {
|
||||
out.delete()
|
||||
throw IllegalStateException("下载不完整(${out.length()} / ${info.sizeBytes} 字节)")
|
||||
}
|
||||
info.sha256?.let { expect ->
|
||||
val actual = sha256Of(out)
|
||||
if (!actual.equals(expect, ignoreCase = true)) {
|
||||
out.delete()
|
||||
throw IllegalStateException("校验失败:期望 $expect 实际 $actual")
|
||||
}
|
||||
}
|
||||
onProgress(100)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun sha256Of(f: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
f.inputStream().use { ins ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = ins.read(buf)
|
||||
if (n <= 0) break
|
||||
md.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** 是否已获得「安装未知来源应用」权限(Android 8+ 需要) */
|
||||
fun canInstall(ctx: Context): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.packageManager.canRequestPackageInstalls()
|
||||
} else true
|
||||
|
||||
/** 跳到「安装未知来源应用」授权页 */
|
||||
fun openInstallPermission(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
runCatching {
|
||||
ctx.startActivity(Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${ctx.packageName}")
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure { Log.w(TAG, "open install permission failed", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起系统安装器。
|
||||
*
|
||||
* 走自己的 [ApkProvider](content://)而不是 file://:Android 7+ 对 file:// 直接抛
|
||||
* FileUriExposedException。项目零第三方依赖,用不了 androidx 的 FileProvider,
|
||||
* 所以自带一个只读的 ContentProvider。
|
||||
*/
|
||||
fun install(ctx: Context, apk: File) {
|
||||
val uri = Uri.parse("content://$PROVIDER_AUTHORITY/${apk.name}")
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, APK_MIME)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(intent)
|
||||
}
|
||||
|
||||
fun downloadDir(ctx: Context): File = updateDir(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供安装器读取 APK 的最小 ContentProvider。
|
||||
*
|
||||
* 只暴露 `cacheDir/update/` 下的文件,且只读:
|
||||
* · 路径取 `uri.lastPathSegment` 后**只拼 basename**,丢弃任何 `../` 片段,防目录穿越;
|
||||
* · 不实现 insert/update/delete(返回 0),不为写操作开门。
|
||||
*/
|
||||
class ApkProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
val ctx = context ?: return null
|
||||
if (!mode.contains("r")) return null
|
||||
val name = uri.lastPathSegment ?: return null
|
||||
val safe = File(name).name // 去掉路径成分,防 ../
|
||||
if (safe.isBlank() || !safe.endsWith(".apk", true)) return null
|
||||
val f = File(UpdateChecker.downloadDir(ctx), safe)
|
||||
if (!f.exists()) return null
|
||||
return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String = "application/vnd.android.package-archive"
|
||||
|
||||
override fun query(
|
||||
uri: Uri, projection: Array<String>?, selection: String?,
|
||||
selectionArgs: Array<String>?, sortOrder: String?
|
||||
): Cursor? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
|
||||
override fun update(
|
||||
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?
|
||||
): Int = 0
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package com.dsh.petfloat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* QA-004-B01(Major / 数据安全)的回归测试:**存储损坏不许变成"没有记忆"**。
|
||||
*
|
||||
* 全部是纯 JVM 单测:被测对象是 [MemoryPersist](持久化内核,无 `android.*` 依赖)+
|
||||
* [MemoryCodec](编解码)。`MemoryStore` 的公开方法只是"本内核 + Context→SharedPreferences
|
||||
* 薄适配",因此这里覆盖的就是设备上跑的那套决策逻辑。
|
||||
*
|
||||
* 现场事实(`tests/qa/QA-004-定向复测.md` §4.1):旧实现 `fromJson` 把解析失败降级成空列表,
|
||||
* 注入截断 JSON 后启动 App,`memory_cards` 被换成 8 条内置种子卡,**原 payload 消失且无任何提示**。
|
||||
*/
|
||||
class MemoryStorePersistenceTest {
|
||||
|
||||
/** 最小的 SharedPreferences 替身:只做 get/put/remove,能记录写入次数与快照。 */
|
||||
private class FakeKV(initial: Map<String, String> = emptyMap()) : MemoryKV {
|
||||
val map = LinkedHashMap<String, String>(initial)
|
||||
/** 每个键各被写过几次(用来证明"拒绝写入"时**主键一次都没被写**)。 */
|
||||
val writesByKey = HashMap<String, Int>()
|
||||
/** 这些键被读时抛 CCE —— 模拟被外部写成 `<int>` / `<set>` 的键(真机 SharedPreferences 行为)。 */
|
||||
val throwingKeys = HashSet<String>()
|
||||
|
||||
override fun getString(key: String, def: String?): String? {
|
||||
if (key in throwingKeys) {
|
||||
throw ClassCastException("java.lang.Integer cannot be cast to java.lang.String")
|
||||
}
|
||||
return map[key] ?: def
|
||||
}
|
||||
|
||||
override fun write(puts: Map<String, String?>) {
|
||||
puts.keys.forEach { writesByKey[it] = (writesByKey[it] ?: 0) + 1 }
|
||||
puts.forEach { (k, v) -> if (v == null) map.remove(k) else map[k] = v }
|
||||
}
|
||||
|
||||
fun writesOf(key: String) = writesByKey[key] ?: 0
|
||||
|
||||
/** 数据快照:**排除审计缓冲**(拒绝写入本身会留审计,这是设计要求)。 */
|
||||
fun dataSnapshot(): Map<String, String?> = mapOf(
|
||||
MemoryPersist.KEY to map[MemoryPersist.KEY],
|
||||
MemoryPersist.KEY_BAK to map[MemoryPersist.KEY_BAK]
|
||||
)
|
||||
}
|
||||
|
||||
private class Logs {
|
||||
val lines = mutableListOf<String>()
|
||||
val errors get() = lines.filter { it.startsWith("E:") }
|
||||
val warns get() = lines.filter { it.startsWith("W:") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立备份文件的替身。
|
||||
*
|
||||
* [failWrites] / [unreadable] 用来模拟"文件系统写不进去 / 读不出来"(磁盘满、权限问题),
|
||||
* 这两种情况都**不许**影响主流程。
|
||||
*/
|
||||
private class FakeFileBackup(
|
||||
var content: String? = null,
|
||||
var failWrites: Boolean = false,
|
||||
var unreadable: Boolean = false,
|
||||
var failDeletes: Boolean = false
|
||||
) : MemoryFileBackup {
|
||||
var writes = 0
|
||||
var deletes = 0
|
||||
|
||||
override fun read(): String? {
|
||||
if (unreadable) throw java.io.IOException("模拟读失败")
|
||||
return content
|
||||
}
|
||||
|
||||
override fun write(raw: String): Boolean {
|
||||
writes++
|
||||
if (failWrites) return false
|
||||
content = raw
|
||||
return true
|
||||
}
|
||||
|
||||
override fun delete(): Boolean {
|
||||
deletes++
|
||||
if (failDeletes) return false
|
||||
content = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认不挂独立文件(保持老用例的语义);`runIo` 直接执行,让镜像行为可断言。 */
|
||||
private fun persist(
|
||||
kv: MemoryKV,
|
||||
logs: Logs = Logs(),
|
||||
fileBackup: MemoryFileBackup? = null,
|
||||
runIo: ((() -> Unit) -> Unit) = { it() }
|
||||
): MemoryPersist = MemoryPersist(
|
||||
kv = kv,
|
||||
maxAudit = MemoryStore.MAX_AUDIT,
|
||||
fileBackup = fileBackup,
|
||||
runIo = runIo,
|
||||
logE = { m, t -> logs.lines += "E:$m" },
|
||||
logW = { m, t -> logs.lines += "W:$m" }
|
||||
)
|
||||
|
||||
/** QA-004 现场注入的那段截断 JSON(`QA4-t2-corrupt-before.xml` 里的形状,逐字)。 */
|
||||
private val truncated = """[{"id":"qa4trunc","title":"记忆A","content":"主人喜欢喝咖啡""""
|
||||
|
||||
private fun parsed(raw: String) = MemoryCodec.parse(raw)
|
||||
|
||||
// ==========================================================================
|
||||
// 1. 解析失败必须与"真的空"区分开
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 截断 JSON 解析结果为 Corrupt,绝不返回空列表`() {
|
||||
val r = MemoryStore.fromJson(truncated)
|
||||
assertTrue("截断 JSON 必须判为 Corrupt,实际=${r::class.simpleName}", r is LoadResult.Corrupt)
|
||||
assertEquals(truncated, (r as LoadResult.Corrupt).raw) // 原文留着,方便排障
|
||||
|
||||
// 其余"形状不对"的输入同样必须判损坏,不能当"没有记忆"
|
||||
listOf(
|
||||
"", // 被截成 0 字节
|
||||
" ", // 只有空白
|
||||
"{}", // 不是数组
|
||||
"1", // 类型都不对
|
||||
"[1,2,3]", // 是数组,但元素不是对象
|
||||
"""["a","b"]""", // 同上
|
||||
"""[{"id":"a","content":"主人喜""", // 中途截断(半条对象 + 没有右括号)
|
||||
"""[{"id":"a"}""" // 缺右方括号
|
||||
).forEach { bad ->
|
||||
assertTrue("「$bad」应判为 Corrupt", MemoryStore.fromJson(bad) is LoadResult.Corrupt)
|
||||
}
|
||||
// 注:尾逗号 `[{"id":"a",}]` 不在列表里 —— org.json 是**宽容解析器**(平台同源),
|
||||
// 它会成功解析出对象。这不算数据损坏(内容读得出来),强判 Corrupt 反而会误伤。
|
||||
|
||||
// 走持久化入口:损坏态被真正置起来(上层据此拒绝写入)
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to truncated))
|
||||
val p = persist(kv)
|
||||
val lr = p.load()
|
||||
assertTrue(lr is LoadResult.Corrupt)
|
||||
assertTrue(p.isCorrupt())
|
||||
assertTrue("损坏原因必须可读", p.corruptReason().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B01 类型错乱(getString 抛 CCE)归入损坏只读态,不许崩`() {
|
||||
// QA-004 §4.2:把 memory_cards 写成 <int> 时旧实现会在设置页直接崩。
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to "1"))
|
||||
kv.throwingKeys += MemoryPersist.KEY
|
||||
val p = persist(kv)
|
||||
assertTrue(p.load() is LoadResult.Corrupt)
|
||||
assertTrue(p.isCorrupt())
|
||||
assertTrue("原因里要带上类型错乱", p.corruptReason().contains("类型错乱"))
|
||||
|
||||
// 但若备份是可读的字符串,就该**救回来**而不是直接冻结写入
|
||||
val ok = MemoryCodec.toJsonArray(
|
||||
listOf(ReconcileEngine.stamp(Memory("a", "饮品偏好", "主人喜欢喝咖啡")))
|
||||
).toString()
|
||||
val kv2 = FakeKV(mapOf(MemoryPersist.KEY to "1", MemoryPersist.KEY_BAK to ok))
|
||||
kv2.throwingKeys += MemoryPersist.KEY
|
||||
val p2 = persist(kv2)
|
||||
val lr = p2.load()
|
||||
assertTrue("有可用备份时必须恢复,实际=$lr", lr is LoadResult.Ok)
|
||||
assertTrue((lr as LoadResult.Ok).recoveredFromBackup)
|
||||
assertEquals("主人喜欢喝咖啡", lr.list[0].content)
|
||||
assertFalse("恢复后不该再冻结写入", p2.isCorrupt())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 2. 读失败状态下,写路径必须拒绝写入(磁盘逐字节不变)
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 损坏态下 add-remove-applyOps-consolidate 全部拒绝,磁盘逐字节不变`() {
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to truncated))
|
||||
val logs = Logs()
|
||||
val p = persist(kv, logs)
|
||||
|
||||
// 先读一次 → 进入只读保护态
|
||||
assertTrue(p.load() is LoadResult.Corrupt)
|
||||
val snapshot = kv.dataSnapshot()
|
||||
assertEquals(0, kv.writesOf(MemoryPersist.KEY))
|
||||
|
||||
// ① add
|
||||
assertEquals(
|
||||
"损坏态必须拒绝 add",
|
||||
MemWriteResult.REJECTED_CORRUPT, p.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
)
|
||||
// ② remove
|
||||
assertEquals(MemWriteResult.REJECTED_CORRUPT, p.remove("qa4trunc"))
|
||||
// ③ applyOps(批量:只留一条拒绝审计,不许 600 条冲掉缓冲)
|
||||
assertEquals(
|
||||
0,
|
||||
p.applyOps(listOf(MemoryStore.Op("ADD", "饮品偏好", "主人喜欢喝咖啡")))
|
||||
)
|
||||
// ④ consolidate(整批操作 → 抛受控异常,让 UI 显示「整理失败,记忆未改动」)
|
||||
val thrown = runCatching { p.consolidate() }.exceptionOrNull()
|
||||
assertNotNull("损坏态下 consolidate 必须抛 StorageCorruptException", thrown)
|
||||
assertTrue(
|
||||
"实际=${thrown!!::class.simpleName}",
|
||||
thrown is StorageCorruptException
|
||||
)
|
||||
|
||||
// === 磁盘逐字节不变(含"不许新建备份键")===
|
||||
assertEquals("数据被改动了", snapshot, kv.dataSnapshot())
|
||||
assertEquals(truncated, kv.map[MemoryPersist.KEY])
|
||||
assertFalse(
|
||||
"拒绝写入时不许顺手创建备份键",
|
||||
kv.map.containsKey(MemoryPersist.KEY_BAK)
|
||||
)
|
||||
assertEquals("主键一次都不许被写", 0, kv.writesOf(MemoryPersist.KEY))
|
||||
|
||||
// === 可观测:Log.e + op="FAILED" 审计 ===
|
||||
assertTrue("必须 Log.e 至少一次,实际=${logs.lines}", logs.errors.isNotEmpty())
|
||||
val failed = p.audits(MemoryStore.MAX_AUDIT).filter { it.op == MemoryStore.AUDIT_OP_FAILED }
|
||||
assertTrue("必须留 op=FAILED 审计,实际=${p.audits(MemoryStore.MAX_AUDIT)}", failed.isNotEmpty())
|
||||
assertTrue(
|
||||
"审计里要能看出是「存储损坏」而不是别的失败:${failed.map { it.reason }}",
|
||||
failed.any { it.reason.contains("STORAGE_CORRUPT") }
|
||||
)
|
||||
assertTrue(
|
||||
"审计里要能看出被拒的是哪条写路径:${failed.map { it.reason }}",
|
||||
failed.any { it.reason.contains("WRITE_REJECTED") }
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 3. 合法的空数组 = 真空,允许写入(不能把真空也判成损坏)
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 合法空数组 判定为 Ok 空列表,且允许写入`() {
|
||||
listOf("[]", " [ ] ", "[\n]").forEach { raw ->
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to raw))
|
||||
val p = persist(kv)
|
||||
val lr = p.load()
|
||||
assertTrue("「$raw」是合法空数组,不该判损坏,实际=$lr", lr is LoadResult.Ok)
|
||||
assertEquals(0, lr.listOrEmpty().size)
|
||||
assertFalse(p.isCorrupt())
|
||||
assertEquals(
|
||||
"真空必须允许写入",
|
||||
MemWriteResult.CHANGED,
|
||||
p.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
)
|
||||
assertEquals(1, p.load().listOrEmpty().size)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 4. 首次安装:key 不存在 → 允许初始化
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 首次安装(无 key)判为 Absent 且允许写入种子`() {
|
||||
val kv = FakeKV()
|
||||
val p = persist(kv)
|
||||
assertTrue(p.load() is LoadResult.Absent)
|
||||
assertFalse(p.isCorrupt())
|
||||
|
||||
// 8 条内置种子卡(PetKnowledge.seeds 的路径)
|
||||
var written = 0
|
||||
PetKnowledge.seeds.forEach { s ->
|
||||
if (p.add(s.title, s.content, s.tags, s.importance) == MemWriteResult.CHANGED) written++
|
||||
}
|
||||
assertEquals(PetKnowledge.seeds.size, written)
|
||||
assertEquals(PetKnowledge.seeds.size, p.load().listOrEmpty().size)
|
||||
assertFalse("首次安装不许被判成损坏", p.isCorrupt())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 5. 主键损坏 + 备份可用 → 自动回退,写 RECOVERED 审计
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 主键损坏时从 memory_cards_bak 恢复,返回原数据并留 RECOVERED 审计`() {
|
||||
val kv = FakeKV()
|
||||
val writer = persist(kv)
|
||||
// 两次写入 → 第二次把"上一版"推进备份
|
||||
writer.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
writer.add("居住地", "主人住在杭州")
|
||||
|
||||
val bak = kv.map[MemoryPersist.KEY_BAK]
|
||||
assertNotNull("写入后必须留下上一版备份", bak)
|
||||
assertEquals("备份必须是**上一版**(1 条),不是最后一版(2 条)", 1, parsed(bak!!).listOrEmpty().size)
|
||||
|
||||
// 模拟设备上的损坏:主键被截断
|
||||
kv.map[MemoryPersist.KEY] = truncated
|
||||
|
||||
// 新进程:应该用备份顶上,而不是"当成 0 条"
|
||||
val logs = Logs()
|
||||
val reader = persist(kv, logs)
|
||||
val lr = reader.load()
|
||||
assertTrue("必须靠备份恢复,实际=$lr", lr is LoadResult.Ok)
|
||||
lr as LoadResult.Ok
|
||||
assertTrue("要标记这是回退恢复的", lr.recoveredFromBackup)
|
||||
assertEquals(1, lr.list.size)
|
||||
assertEquals("主人喜欢喝咖啡", lr.list[0].content)
|
||||
assertFalse("恢复成功后必须退出只读保护态", reader.isCorrupt())
|
||||
|
||||
// 审计 + 日志
|
||||
assertTrue(
|
||||
"必须留 RECOVERED 审计,实际=${reader.audits(MemoryStore.MAX_AUDIT)}",
|
||||
reader.audits(MemoryStore.MAX_AUDIT).any { it.op == MemoryStore.AUDIT_OP_RECOVERED }
|
||||
)
|
||||
|
||||
// 主键已被修回可用内容:下一次读到的不再是坏数据
|
||||
val repaired = kv.map[MemoryPersist.KEY]!!
|
||||
assertTrue("主键必须被修回可解析内容", parsed(repaired) is LoadResult.Ok)
|
||||
assertEquals(bak, repaired)
|
||||
|
||||
// 恢复后写入照常(不再被拒)
|
||||
assertTrue(reader.add("工作", "主人在一家公司做后端") != MemWriteResult.REJECTED_CORRUPT)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 6. 用户显式 clear() 在损坏态下仍可执行,且这是恢复出口
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `B01 损坏态下用户显式 clear 仍可执行,并解除只读保护态`() {
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to truncated))
|
||||
val p = persist(kv)
|
||||
p.load()
|
||||
assertTrue(p.isCorrupt())
|
||||
|
||||
p.clear()
|
||||
|
||||
assertFalse("clear 之后必须退出只读保护态", p.isCorrupt())
|
||||
assertNull("主键要删掉", kv.map[MemoryPersist.KEY])
|
||||
assertNull("备份也要一起删(否则清空的记忆会被备份'复活')", kv.map[MemoryPersist.KEY_BAK])
|
||||
assertTrue(
|
||||
"clear 必须留审计",
|
||||
p.audits(MemoryStore.MAX_AUDIT).any { it.op == MemoryStore.AUDIT_OP_CLEARED }
|
||||
)
|
||||
// 清空后是一条真空 → 允许重新初始化
|
||||
assertTrue(p.load() is LoadResult.Absent)
|
||||
assertEquals(MemWriteResult.CHANGED, p.add("饮品偏好", "主人喜欢喝咖啡"))
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 7. 审计环形缓冲 200 → 1024(单批 600 条 op 不许丢)
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `OBS-1 600 条 op 全部保留在审计里,且截断方向是丢最旧`() {
|
||||
assertTrue("上限必须 >= 1024,实际=${MemoryStore.MAX_AUDIT}", MemoryStore.MAX_AUDIT >= 1024)
|
||||
|
||||
val kv = FakeKV()
|
||||
val p = persist(kv)
|
||||
val audits = (0 until 600).map { i ->
|
||||
ReconcileEngine.ReconcileAudit(
|
||||
at = i.toLong(), mode = "OFFLINE", op = "ADD",
|
||||
reason = "batch-op-$i", confidence = 1.0,
|
||||
newId = "op-$i", targetId = null
|
||||
)
|
||||
}
|
||||
p.appendAudits(audits)
|
||||
|
||||
val all = p.audits(MemoryStore.MAX_AUDIT)
|
||||
assertEquals("600 条 op 必须一条不丢", 600, all.size)
|
||||
assertEquals("op-0", all.first().newId)
|
||||
assertEquals("op-599", all.last().newId)
|
||||
// 旧上限 200 时,第 250 条(下标 249)已经被截掉了
|
||||
assertTrue(
|
||||
"第 250 条 op 必须还能查到(旧上限 200 时这里会丢)",
|
||||
all.any { it.newId == "op-249" }
|
||||
)
|
||||
|
||||
// 超上限时丢最旧、留最新
|
||||
val more = (600 until 1100).map { i ->
|
||||
ReconcileEngine.ReconcileAudit(
|
||||
at = i.toLong(), mode = "OFFLINE", op = "ADD",
|
||||
reason = "batch-op-$i", confidence = 1.0, newId = "op-$i", targetId = null
|
||||
)
|
||||
}
|
||||
p.appendAudits(more)
|
||||
val capped = p.audits(MemoryStore.MAX_AUDIT)
|
||||
assertEquals(MemoryStore.MAX_AUDIT, capped.size)
|
||||
assertEquals("必须保留最新一条", "op-1099", capped.last().newId)
|
||||
assertFalse("最旧的必须已被丢弃", capped.any { it.newId == "op-0" })
|
||||
assertTrue("截断后仍应是连续的一段最新记录", capped.any { it.newId == "op-76" })
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 8. debug 故障注入:抛一次、自动复位、数据不动
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `故障注入 让下一次 consolidate 受控失败一次后自动复位且数据不变`() {
|
||||
val kv = FakeKV()
|
||||
val p = persist(kv)
|
||||
p.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
p.add("居住地", "主人住在杭州")
|
||||
val before = kv.map[MemoryPersist.KEY]
|
||||
|
||||
p.debugForceConsolidateFailure = true
|
||||
val thrown = runCatching { p.consolidate() }.exceptionOrNull()
|
||||
assertNotNull("注入后必须真的抛异常(QA 才能走到失败分支)", thrown)
|
||||
assertTrue("实际=${thrown!!::class.simpleName}", thrown is IllegalStateException)
|
||||
assertTrue("原因要能被 QA 认出来是注入", thrown.message!!.contains("故障注入"))
|
||||
assertFalse("触发一次必须自动复位,避免持续失败", p.debugForceConsolidateFailure)
|
||||
assertEquals("失败必须发生在写之前:数据一个字节都不许动", before, kv.map[MemoryPersist.KEY])
|
||||
|
||||
// 复位后正常整理
|
||||
val second = runCatching { p.consolidate() }
|
||||
assertTrue("第二次不该再失败:${second.exceptionOrNull()}", second.isSuccess)
|
||||
assertEquals(2, p.load().listOrEmpty().size)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 9. 真实用户数据不得被误判成损坏(防"误冻结写入")
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `真实用户的 8 卡存量数据解析为 Ok,不会被误判成损坏而冻结写入`() {
|
||||
// 逐字取自设备真实备份 `tests/qa/artifacts/qa4/QA4-task0-8cards-baseline.xml` 的
|
||||
// memory_cards 值(8 条内置卡的完整 payload,含 QA-002-B07 的 supersededBy:"null"
|
||||
// 历史污染 —— 那属于"读时归一",不是"损坏")。
|
||||
//
|
||||
// 这条用例防的是**误判**:若"形状不对"的判定写得太紧,用户的真实数据会被判成损坏,
|
||||
// 于是记忆写入被整体冻结。宁可少判损坏,也不能把好数据判坏。
|
||||
val raw = """[{"id":"c10608f8","title":"我的身份","content":"我叫小鲸娘,鲸鱼女仆,住在主人的手机 App 里,会悬浮、会溜达、会被甩出去","tags":["身份","系统"],"importance":3,"created":1794139382253,"updated":1794139382253,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382253,"validTo":null},{"id":"9e99aa1f","title":"我的主人","content":"住在这个 App 的主人就是我要照顾的人,我称呼他「主人」","tags":["身份","主人"],"importance":3,"created":1794139382254,"updated":1794139382254,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382254,"validTo":null},{"id":"75ee67ef","title":"App 页签","content":"底部四页签:状态 \/ 对话 \/ 动作 \/ 设置;长按我会直接进设置","tags":["应用","系统"],"importance":3,"created":1794139382255,"updated":1794139382255,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382255,"validTo":null},{"id":"31722055","title":"技能清单","content":"10 个技能:开工 work_start、收工 work_done、出错了 work_error、摸鱼 slack、干饭 meal、打招呼 greet、睡觉 sleep、汇报 report、喝水 hydrate、伸懒腰 stretch","tags":["技能","系统"],"importance":3,"created":1794139382257,"updated":1794139382257,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382257,"validTo":null},{"id":"eab245f3","title":"插件清单","content":"9 个内置插件:时段问候、喝水提醒、久坐提醒、整点报时、戳戳吐槽、随机表情包、鱼的记忆、鱼的心情、主动搭话;都能在设置里调参数","tags":["插件","系统"],"importance":3,"created":1794139382258,"updated":1794139382258,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382258,"validTo":null},{"id":"fec0c493","title":"动作与表情","content":"106 个动作可按语义标签点播;表情包 2366 张,也能按标签调用","tags":["动作","系统"],"importance":2,"created":1794139382259,"updated":1794139382259,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382259,"validTo":null},{"id":"c0b7c01f","title":"接入方式","content":"本机 7350 端口提供 HTTP 与 MCP 接口,别的应用可以让我播报任务、点播动作、弹表情;文档在 \/docs","tags":["接口","系统"],"importance":2,"created":1794139382261,"updated":1794139382261,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":"null","supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":1794139382261,"validTo":null},{"id":"b130968b","title":"我的边界","content":"看不到主人屏幕、不能操作其他 App、不能联网搜索;联网能力需要主人接入","tags":["边界","系统"],"importance":3,"created":1794139382266,"updated":1794139382266,"hits":0,"longTerm":false,"emotion":"","schemaVersion":0,"status":"ACTIVE","isLatest":true,"supersedes":[],"supersededBy":null,"supersedeReason":"","subject":"","attribute":"","slotType":"UNKNOWN","polarity":0,"valueTokens":[],"validFrom":0,"validTo":null}]"""
|
||||
val r = MemoryStore.fromJson(raw)
|
||||
assertTrue("真实存量数据被判成损坏,会误冻结用户写入!实际=${r::class.simpleName}", r is LoadResult.Ok)
|
||||
r as LoadResult.Ok
|
||||
assertEquals(8, r.list.size)
|
||||
assertEquals("我的身份", r.list[0].title)
|
||||
assertEquals(3, r.list[0].importance)
|
||||
assertNull("B07 的字符串 \"null\" 必须归一", r.list[0].supersededBy)
|
||||
|
||||
val kv = FakeKV(mapOf(MemoryPersist.KEY to raw))
|
||||
val p = persist(kv)
|
||||
assertFalse("真实数据不该被判损坏", p.isCorrupt())
|
||||
assertEquals(MemWriteResult.CHANGED, p.add("饮品偏好", "主人喜欢喝咖啡"))
|
||||
assertEquals(9, p.load().listOrEmpty().size)
|
||||
// 写完之后备份里躺着的是"用户原来的 8 卡原文",可回退
|
||||
assertEquals(raw, kv.map[MemoryPersist.KEY_BAK])
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 10–13. 跨介质兜底(I-D):prefs 里那个 _bak 救不了"整文件坏掉"
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `I-D prefs 整文件丢失(Absent)但独立备份在 → 从文件恢复,绝不写 8 条种子覆盖`() {
|
||||
// 造一份"用户真实状态"的独立备份:就是那 8 条内置卡(与设备基线同形)
|
||||
val seedJson = MemoryCodec.toJsonArray(
|
||||
PetKnowledge.seeds.mapIndexed { i, s ->
|
||||
ReconcileEngine.stamp(
|
||||
Memory(
|
||||
id = "seed$i", title = s.title, content = s.content, tags = s.tags,
|
||||
importance = s.importance, created = 1_000L + i, updated = 1_000L + i
|
||||
)
|
||||
)
|
||||
}
|
||||
).toString()
|
||||
val file = FakeFileBackup(content = seedJson)
|
||||
// 关键:prefs 是**空的**。整份 pet_prefs.xml 坏掉时,Android 的 SharedPreferences
|
||||
// 不抛异常、只打日志并返回空 prefs → 我们看到的正是"主键不存在"。
|
||||
val kv = FakeKV()
|
||||
val logs = Logs()
|
||||
val p = persist(kv, logs, fileBackup = file)
|
||||
|
||||
val lr = p.load()
|
||||
assertTrue("整文件丢失时必须靠独立备份恢复,实际=${lr::class.simpleName}", lr is LoadResult.Ok)
|
||||
lr as LoadResult.Ok
|
||||
assertTrue(lr.recoveredFromBackup)
|
||||
assertEquals(PetKnowledge.seeds.size, lr.list.size)
|
||||
assertFalse("不许因为 prefs 空就判成损坏/首次安装", p.isCorrupt())
|
||||
assertEquals("主键必须被回填成可用内容", seedJson, kv.map[MemoryPersist.KEY])
|
||||
assertTrue("必须留 RECOVERED 审计并写明来源",
|
||||
p.audits(MemoryStore.MAX_AUDIT).any {
|
||||
it.op == MemoryStore.AUDIT_OP_RECOVERED && it.reason.contains("独立文件")
|
||||
})
|
||||
|
||||
// 模拟 FloatingPetService.ensureSeedMemories:种子的"版本标记"也在同一个 prefs 里,
|
||||
// 整文件丢失后它当然也没了 → 首次启动会**再走一遍种子注入**。
|
||||
// 因为记忆已经恢复,8 条种子全都命中"同标题 + 同正文"的幂等分支 → 一条都不会写进去。
|
||||
var changed = 0
|
||||
PetKnowledge.seeds.forEach { s ->
|
||||
if (p.add(s.title, s.content, s.tags, s.importance) == MemWriteResult.CHANGED) changed++
|
||||
}
|
||||
assertEquals("种子注入不许真的改动任何东西", 0, changed)
|
||||
assertEquals("记忆条数必须还是 8 条,不是被覆盖", PetKnowledge.seeds.size, p.load().listOrEmpty().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `I-D 独立备份写失败只降级(记一条审计),绝不阻断主流程`() {
|
||||
val file = FakeFileBackup(failWrites = true) // 模拟磁盘满 / 只读
|
||||
val kv = FakeKV()
|
||||
val logs = Logs()
|
||||
val p = persist(kv, logs, fileBackup = file)
|
||||
|
||||
assertEquals("写入不能因为兜底失败而失败",
|
||||
MemWriteResult.CHANGED, p.add("饮品偏好", "主人喜欢喝咖啡"))
|
||||
assertEquals(1, p.load().listOrEmpty().size)
|
||||
assertTrue("prefs 必须已经落盘", kv.map[MemoryPersist.KEY]!!.contains("喝咖啡"))
|
||||
assertNull("文件没写成功就是没有", file.content)
|
||||
assertTrue("要有一行 W 日志", logs.warns.any { it.contains("独立备份文件写入失败") })
|
||||
assertTrue("要留可诊断的审计",
|
||||
p.audits(MemoryStore.MAX_AUDIT).any {
|
||||
it.op == MemoryStore.AUDIT_OP_FAILED && it.reason.contains("BACKUP_FILE_WRITE_FAILED")
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `I-D 每次落盘都镜像当前版(比 _bak 新一版),clear 连独立文件一起删`() {
|
||||
val file = FakeFileBackup()
|
||||
val kv = FakeKV()
|
||||
val p = persist(kv, fileBackup = file)
|
||||
|
||||
p.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
assertEquals(1, parsed(file.content!!).listOrEmpty().size)
|
||||
|
||||
p.add("居住地", "主人住在杭州")
|
||||
// 文件 = 当前版(2 条);prefs 内 _bak = 上一版(1 条)→ 这就是"先读文件再读 _bak"的理由
|
||||
assertEquals("独立文件必须是最新一版", 2, parsed(file.content!!).listOrEmpty().size)
|
||||
assertEquals("_bak 是上一版", 1, parsed(kv.map[MemoryPersist.KEY_BAK]!!).listOrEmpty().size)
|
||||
assertEquals("镜像内容必须与主键逐字节一致", kv.map[MemoryPersist.KEY], file.content)
|
||||
|
||||
p.clear()
|
||||
assertNull("清空必须把独立文件一起删掉", file.content)
|
||||
assertEquals(1, file.deletes)
|
||||
assertNull(kv.map[MemoryPersist.KEY])
|
||||
assertNull(kv.map[MemoryPersist.KEY_BAK])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `I-D 主键坏且两份备份都在 → 优先用更新的独立文件,而不是上一版 _bak`() {
|
||||
val file = FakeFileBackup()
|
||||
val kv = FakeKV()
|
||||
val writer = persist(kv, fileBackup = file)
|
||||
writer.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
writer.add("居住地", "主人住在杭州")
|
||||
// 此时:KEY=2 条、_bak=1 条、文件=2 条
|
||||
kv.map[MemoryPersist.KEY] = truncated
|
||||
|
||||
val reader = persist(kv, fileBackup = file)
|
||||
val lr = reader.load()
|
||||
assertTrue(lr is LoadResult.Ok)
|
||||
lr as LoadResult.Ok
|
||||
assertTrue(lr.recoveredFromBackup)
|
||||
assertEquals("必须拿到最新的 2 条,而不是 _bak 的 1 条", 2, lr.list.size)
|
||||
assertTrue("审计里要写明来源是独立文件",
|
||||
reader.audits(MemoryStore.MAX_AUDIT).any { it.reason.contains("独立文件") })
|
||||
// 主键被修回,后续写入照常
|
||||
assertEquals(2, parsed(kv.map[MemoryPersist.KEY]!!).listOrEmpty().size)
|
||||
assertTrue(reader.add("工作", "主人在一家公司做后端") != MemWriteResult.REJECTED_CORRUPT)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 14. 生产实现本身:AtomicFileBackup(临时文件 + rename,不留半截备份)
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `I-D AtomicFileBackup 能读回能删、不留 tmp、不存在时读 null`() {
|
||||
val dir = java.nio.file.Files.createTempDirectory("membak").toFile()
|
||||
try {
|
||||
val target = java.io.File(dir, MemoryPersist.FILE_BAK_NAME)
|
||||
val bak = AtomicFileBackup(target)
|
||||
|
||||
assertNull("文件不存在 → null(不是抛异常)", bak.read())
|
||||
assertTrue(bak.write("[]"))
|
||||
assertEquals("[]", bak.read())
|
||||
|
||||
// 覆盖写:旧内容必须整份换掉,不能出现"新老拼接"
|
||||
assertTrue(bak.write("""[{"id":"a","title":"t","content":"c"}]"""))
|
||||
assertEquals("""[{"id":"a","title":"t","content":"c"}]""", bak.read())
|
||||
assertFalse(
|
||||
"临时文件必须被 rename 掉,不许残留(否则下次可能被当成半截备份)",
|
||||
java.io.File(dir, MemoryPersist.FILE_BAK_NAME + ".tmp").exists()
|
||||
)
|
||||
|
||||
assertTrue(bak.delete())
|
||||
assertNull(bak.read())
|
||||
assertFalse(target.exists())
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `I-D 独立文件删不掉时写空墓碑,绝不让清空的记忆被复活`() {
|
||||
val file = FakeFileBackup(failDeletes = true)
|
||||
val kv = FakeKV()
|
||||
val p = persist(kv, fileBackup = file)
|
||||
p.add("饮品偏好", "主人喜欢喝咖啡")
|
||||
assertNotNull(file.content)
|
||||
|
||||
val logs = Logs()
|
||||
val p2 = persist(kv, logs, fileBackup = file)
|
||||
p2.clear()
|
||||
|
||||
// 删不掉 → 墓碑 `[]`;且 `[]` 在恢复逻辑里等于"没有备份"(要求非空才肯用)
|
||||
assertEquals("[ ]".replace(" ", ""), file.content)
|
||||
val p3 = persist(FakeKV(), fileBackup = file) // 模拟"prefs 整文件也丢了"
|
||||
assertTrue("墓碑绝不能被当成可用备份", p3.load() is LoadResult.Absent)
|
||||
assertEquals(0, p3.load().listOrEmpty().size)
|
||||
assertTrue(logs.warns.any { it.contains("墓碑") })
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 在 Gitea 服务器(123.206.124.151)上安装 act_runner
|
||||
#
|
||||
# 为什么用 host 模式(label = self-hosted:host)而不是 Docker 容器模式:
|
||||
# 这台机器本来就是这个项目的构建机(build.sh 里写死了 /opt/jdk17、
|
||||
# /opt/android-sdk、/opt/gradle/gradle-8.5),Android SDK 已经装好了。
|
||||
# 容器模式下 job 跑在 node:16 镜像里,没有 JDK/Android SDK,还得自己
|
||||
# 构建一个 2GB+ 的 Android 镜像;host 模式直接复用现成环境,零额外体积。
|
||||
#
|
||||
# 用法:
|
||||
# 1) 先拿到 registration token(见文件末尾说明)
|
||||
# 2) REG_TOKEN=xxxx bash install-runner.sh
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-http://123.206.124.151:3000}"
|
||||
REG_TOKEN="${REG_TOKEN:-}"
|
||||
RUNNER_NAME="${RUNNER_NAME:-tencent2}"
|
||||
LABELS="${LABELS:-self-hosted:host}"
|
||||
WORK="${WORK:-/opt/act_runner}"
|
||||
|
||||
if [ -z "$REG_TOKEN" ]; then
|
||||
echo "缺少 REG_TOKEN。获取方式:"
|
||||
echo " Gitea → 站点管理 → Actions → Runners → 创建 Runner / 复制注册令牌"
|
||||
echo " 或 API: curl -H 'Authorization: token <ADMIN_TOKEN>' \\"
|
||||
echo " $GITEA_URL/api/v1/admin/actions/runners/registration-token -X POST"
|
||||
echo
|
||||
echo "然后:REG_TOKEN=<令牌> bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 1/4 检查宿主机构建环境(host 模式要靠它)==="
|
||||
missing=0
|
||||
for p in /opt/jdk17 /opt/android-sdk /opt/gradle; do
|
||||
if [ -d "$p" ]; then echo " ✓ $p"; else echo " ✗ $p 不存在"; missing=1; fi
|
||||
done
|
||||
if [ "$missing" = "1" ]; then
|
||||
echo
|
||||
echo "警告:上面缺失的路径会导致构建失败。若实际路径不同,"
|
||||
echo " 请确认 workflow 里「准备构建环境」步骤的探测列表覆盖到了。"
|
||||
fi
|
||||
|
||||
echo "=== 2/4 下载 act_runner ==="
|
||||
mkdir -p "$WORK" && cd "$WORK"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) RA_ARCH=amd64 ;;
|
||||
aarch64|arm64) RA_ARCH=arm64 ;;
|
||||
*) echo "不支持的架构:$ARCH"; exit 1 ;;
|
||||
esac
|
||||
if [ ! -x ./act_runner ]; then
|
||||
# 优先 gitea.com(官方分发),失败回退 GitHub
|
||||
curl -fL --retry 3 -o act_runner \
|
||||
"https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-$RA_ARCH" \
|
||||
|| curl -fL --retry 3 -o act_runner \
|
||||
"https://github.com/gitea/act_runner/releases/latest/download/act_runner-linux-$RA_ARCH"
|
||||
chmod +x act_runner
|
||||
fi
|
||||
./act_runner --version || true
|
||||
|
||||
echo "=== 3/4 注册到 $GITEA_URL ==="
|
||||
# host 模式:job 直接在宿主机执行,标签形如 self-hosted:host
|
||||
if [ -f .runner ]; then
|
||||
echo " 已存在 .runner 配置,跳过注册(如需重新注册请先删除 $WORK/.runner)"
|
||||
rm -f "$WORK/.runner"
|
||||
echo " 已删除旧配置,重新注册…"
|
||||
fi
|
||||
./act_runner register --no-interactive \
|
||||
--instance "$GITEA_URL" \
|
||||
--token "$REG_TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "$LABELS"
|
||||
|
||||
echo "=== 4/4 安装为 systemd 服务并启动 ==="
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
cat > /etc/systemd/system/gitea-act-runner.service <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (host mode)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$WORK
|
||||
ExecStart=$WORK/act_runner daemon
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-act-runner
|
||||
sleep 2
|
||||
systemctl --no-pager --lines=15 status gitea-act-runner || true
|
||||
else
|
||||
echo " 没有 systemd,前台启动以便观察(Ctrl+C 退出后需自行守护):"
|
||||
exec ./act_runner daemon
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 完成 ==="
|
||||
echo "回 Gitea 确认:$GITEA_URL/-/admin/actions/runners 应能看到 $RUNNER_NAME(idle)"
|
||||
echo "然后重新跑构建:推送新 tag,或在仓库 Actions 页面点 Re-run。"
|
||||
Reference in new issue
Block a user