7 Commits
Author SHA1 Message Date
jkooe 3dcda9fde4 fix(ci): 修正 gradle 调用与素材地址,失败信息直指根因
Build APK / build (push) Failing after 38s
Probe(run 343)实测结论:
- job 跑在 Docker 容器内,容器里没有 java,也没有 /opt/jdk17、/opt/android-sdk、/opt/gradle
- 127.0.0.1 不可达(容器内 127 指向自身);172.17.0.1(Docker 网关)17.6 MB/s;公网 370 KB/s

改动:
- 素材地址顺序改为 172.17.0.1 → 127.0.0.1 → server_url(按容器/host 两种模式的实际可达性排)
- 构建改用探测到的系统 gradle:本项目没有 gradlew(build.sh 用的是 /opt/gradle/gradle-8.5)
- 找不到 SDK 时的报错补上「容器看不到宿主机 /opt」这一层,并给出改 host 模式的具体命令
2026-11-09 18:36:41 +08:00
jkooe 33393e97fd fix(ci): 素材获取优先同机回环与本地缓存,加超时保护
首次构建卡在 git fetch --depth 1 full-assets:650MB 素材走公网地址实测 72KB/s,
需要 2.5 小时。取消 run 后 runner 仍 busy(git 进程未被杀)。

- 素材地址按 127.0.0.1 → 172.17.0.1(Docker 网关)→ server_url 顺序探测,选第一个可用的
- 优先读取宿主机本地素材缓存 /opt/petfloat-assets(可用 PET_ASSET_CACHE 覆盖),
  构建机本来就该有完整仓库,不必每次重下 650MB
- git fetch 加 900s 超时;超时给出预置缓存的明确命令,而不是静默卡死
- 删除临时探测 workflow
2026-11-09 17:37:20 +08:00
jkooe 903decd838 chore(ci): 临时探测 job —— 定位素材获取瓶颈(用完删除) 2026-11-09 17:34:14 +08:00
jkooe 665baa0831 docs: 补充「发布与更新」章节,归档 act_runner 安装脚本
- README 新增第 12 节:发版流程(打 tag)、必需 secrets、签名硬约束、
  应用内更新原理、runner 要求(host 模式复用现成 JDK/SDK)
- scripts/install-runner.sh:act_runner 安装与注册(host 模式),
  参数化 GITEA_URL / REG_TOKEN / LABELS,装成 systemd 服务
2026-11-09 17:32:01 +08:00
jkooe 43275ae7a0 feat(ci): Gitea Actions 构建完整版 APK 并发布 Release;App 内更新检查
Build APK / build (push) Canceled after 7m12s
- .gitea/workflows/build.yml:打 tag 触发构建。代码取当前 ref,素材目录单独从 full-assets 分支叠加
  (main 保持 ~8MB 不追踪 assets_hq/assets_small/assets_net,缺它们构建出来的包宠物是空白的)。
  用 secret 注入的固定 keystore 签 release 包,构建后发布 APK + version.json 到 Gitea Release。
- app/build.gradle.kts:签名改为从环境变量读取(KEYSTORE_PATH / KEYSTORE_PASSWORD / KEY_ALIAS /
  KEY_PASSWORD),本地无 keystore 时回退 debug 签名;versionCode 支持 CI 注入,保证单调递增
  (更新机制要求新包 versionCode 严格大于已装版本,否则系统拒绝覆盖安装)。
- UpdateChecker.kt:读 Gitea Release API 按 versionCode 判版本 → 下载并校验 SHA256 → 经自带
  ApkProvider(content://)拉起系统安装器。项目零第三方依赖,用不了 androidx FileProvider,
  故自带一个只读 ContentProvider(只暴露 cacheDir/update 下的 .apk,路径取 basename 防穿越)。
- MainActivity 设置页新增「关于 / 检查更新」入口,下载前先检查「安装未知来源应用」授权。
- AndroidManifest 新增 REQUEST_INSTALL_PACKAGES 与 ApkProvider 声明。
- .gitignore 增加 *.jks / *.keystore / keystore.properties,防止签名文件误入库。
2026-11-09 17:16:41 +08:00
jkooe 2853b1bb1d fix(memory): 记忆整理重构为四动作冲突消解,并修复两处数据安全缺陷
旧实现 consolidate() 用「相似→内容取更长」,实测会把「主人喜欢咖啡」与「主人不喝咖啡了」合并后保留旧内容(新事实被吞)。根因不是相似度阈值,是缺少「矛盾」与「冗余」的语义分类。

- 新增 ReconcileEngine:ADD/SUPERSEDE/UPDATE/NOOP 四动作,纯 JVM 可测
- 新增 MemoryPersistence:三态读(Ok/Absent/Corrupt)、损坏即只读、主键与备份同 Editor 原子提交、独立文件备份(atomic move)
- 新增 DeterministicExtractor / MemoryModel:结构化五元组抽取,抽取失败一律降级为「不可比」
- MemoryStore:consolidate 换核、add/applyOps 走 reconcile、审计 200->1024、MAX_CARDS 300->600
- FloatingPetService:写入被拒时不推进 seed_memory_v,否则 8 条种子卡会静默覆盖用户记忆
- MainActivity:整理移出主线程、列表分片渲染、失败可见(Log.e + FAILED 审计)、损坏态显示「读取失败」
- 新增 60 条单测(44 引擎 + 16 持久化)

本提交同时修复:合并正文被 token 重组改坏(数据损失)、supersededBy 写成字符串 "null" 导致遗忘分支从未生效、R4 合并逐次累加导致 600 卡场景 JSON 膨胀至 35MB 并整理失败、存储解析失败被当作空导致记忆静默重置为 8 条种子卡。
2026-11-09 17:16:41 +08:00
mtfyun d97c9bf847 docs: 补充 main / full-assets 分支约定 2026-11-08 08:37:00 +00:00
16 changed files with 5543 additions and 187 deletions

No files matched your search

+234
View File
@@ -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"
+5
View File
@@ -12,6 +12,11 @@ local.properties
.DS_Store
captures/
# ==== 签名与密钥(绝不入库)====
*.jks
*.keystore
keystore.properties
# ==== 日志与临时 ====
*.log
/tmp/
+70 -1
View File
@@ -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
View File
@@ -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")
}
+12
View File
@@ -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
)
}
+366 -138
View File
@@ -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
+104
View File
@@ -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。"