Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AddiGuard.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
D737EBE9B04451C4221E790A /* IngredientAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7D6B8338E7EF549D224BA9 /* IngredientAnalyzer.swift */; };
D7C7D95FDB65EE201699D629 /* RecognitionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C8A7B39A0B2C3A5BC721CA /* RecognitionServiceTests.swift */; };
DA65C06D64964B4B1B393D85 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 318ABEA401C73CCB7E66128A /* PrivacyInfo.xcprivacy */; };
DB0315228A8B223A2F39BD3D /* LabelPipelineHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */; };
DC745D61DCA0DFB712627D68 /* CuratedSpriteAdditivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C63F3F6662117CFB78AA5D /* CuratedSpriteAdditivesTests.swift */; };
E1A58E33DABF1B21DFDB43D4 /* CameraScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECF8C13D900BDF9935559E00 /* CameraScanner.swift */; };
E1F1993D3731777CDECDAAD7 /* OpenRouterIngredientAnalysisProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854651E9176E019610153559 /* OpenRouterIngredientAnalysisProvider.swift */; };
Expand Down Expand Up @@ -108,6 +109,7 @@
88E567CDD8409A1E03A4B3EA /* AddiGuardTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AddiGuardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8917A567CF885CE79177E324 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
892B30AF09C77CB5B823FF2B /* ReferenceSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceSourceTests.swift; sourceTree = "<group>"; };
89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelPipelineHarnessTests.swift; sourceTree = "<group>"; };
8A7D6B8338E7EF549D224BA9 /* IngredientAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngredientAnalyzer.swift; sourceTree = "<group>"; };
9457EF4B050581874E15B0BD /* PrivacyPolicyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPolicyView.swift; sourceTree = "<group>"; };
9AAFFCBADA2244FE50C819A7 /* Additive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Additive.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -210,6 +212,7 @@
64F83313A0AAD3270BA836AF /* IngredientAnalysisServiceTests.swift */,
6AD56770E84737E8823CB7ED /* IngredientAnalyzerTests.swift */,
F007426E6A5809F4EC89D8F8 /* IngredientTextParserTests.swift */,
89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */,
DCDB426A7AE2AF1B38C2B236 /* OCRServiceTests.swift */,
E5821238597D3191FE995CF8 /* OpenRouterConfigurationTests.swift */,
AC6D660EE10BD2358D95538A /* OpenRouterIngredientAnalysisProviderTests.swift */,
Expand Down Expand Up @@ -482,6 +485,7 @@
A340C30C960F64D49DA243EE /* IngredientAnalysisServiceTests.swift in Sources */,
C4CA188B704BD25BFB08B8FE /* IngredientAnalyzerTests.swift in Sources */,
090D42B2635BC155111973EE /* IngredientTextParserTests.swift in Sources */,
DB0315228A8B223A2F39BD3D /* LabelPipelineHarnessTests.swift in Sources */,
867133B9FE9E7BCE4301DE77 /* OCRServiceTests.swift in Sources */,
34E34F3328FBBF6378A6000B /* OpenRouterConfigurationTests.swift in Sources */,
059363A4F1C54849C46E9259 /* OpenRouterIngredientAnalysisProviderTests.swift in Sources */,
Expand Down
6 changes: 3 additions & 3 deletions AddiGuard.xcodeproj/xcshareddata/xcschemes/AddiGuard.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "OPENROUTER_API_KEY"
value = "$(OPENROUTER_API_KEY)"
key = "DEEPSEEK_API_KEY"
value = "$(DEEPSEEK_API_KEY)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
Expand All @@ -105,7 +105,7 @@
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "OPENROUTER_API_KEY"
key = "DEEPSEEK_API_KEY"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
Expand Down
2 changes: 1 addition & 1 deletion Config/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
OPENROUTER_API_KEY =
DEEPSEEK_API_KEY =
#include? "Secrets.xcconfig"
2 changes: 1 addition & 1 deletion Config/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Production credentials must be supplied by a backend proxy, never the app bundle.
OPENROUTER_API_KEY =
DEEPSEEK_API_KEY =
2 changes: 1 addition & 1 deletion Config/Secrets.xcconfig.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Copy this file to Secrets.xcconfig for local Debug builds only.
OPENROUTER_API_KEY = your-local-development-key
DEEPSEEK_API_KEY = your-local-development-key
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# AddiGuard for iOS

AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入食品配料表,始终通过设备上的 Apple Vision 提取文字,再选择本地知识库或 OpenRouter 上的 GLM-5.3 Flash 对配料进行分析。
AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入食品配料表,始终通过设备上的 Apple Vision 提取文字,再选择本地知识库或 DeepSeek 的 DeepSeek Flash 对配料进行分析。

## MVP 功能

- 相机取景、拍照识别与相册导入
- 锁定屏幕小组件,可一键打开食品配料识别页
- 两种明确可选的分析方式:本地知识库分析,以及 OpenRouter GLM-5.3 Flash 在线逐项辅助说明
- 两种明确可选的分析方式:本地知识库分析,以及 DeepSeek Flash 在线逐项辅助说明
- 始终在设备端执行的中英文双路 OCR(Vision):原图与增强图并行识别,并使用本地添加剂名称、别名和 E/INS 编码词表辅助小字识别;图片不会上传
- 完整配料拆分:分别标出已匹配添加剂、常见普通配料、可能匹配和未匹配文字,不再静默丢弃未知项
- GB 2760-2024 附录 F(附录 A 索引)的 287 个名称组、本地中英文常用名与经校验的 E/INS 编码匹配
Expand All @@ -18,16 +18,16 @@ AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入

## 在线 API 接入状态

- 图片始终只在设备上由 Apple Vision 提取文字。应用先在本地拆分配料,再按所选方式分析;OpenRouter 不参与 OCR,也不会收到图片。
- 图片始终只在设备上由 Apple Vision 提取文字。应用先在本地拆分配料,再按所选方式分析;DeepSeek 不参与 OCR,也不会收到图片。
- 本地解析器生成有界、按原顺序排列的配料数组。在线模式只发送每项的不透明序号和配料名称,不发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;若没有拆出配料,会直接停止在线请求,不会改为上传全文。
- 本地分析是默认模式。启用在线辅助分析必须基于当前数据边界重新确认,不会隐藏上传或静默切换;确认后该偏好持续到用户切回本地模式。
- 本地 Debug 构建通过 OpenRouter Chat Completions 调用 `z-ai/glm-5.3-flash`(Z.ai GLM-5.3 Flash)。请求采用 HTTPS、Bearer 认证、低强度推理和 `data_collection=deny`;模型只为本地清单中的每一种配料生成辅助说明,不能新增、删除、改名、排序或改写本地风险结论。
- 模型 slug 会随上游下线而失效:本项目最初使用的 `stealth/ox-alpha` 隐身测试期结束后已被 OpenRouter 移除,改名为 `z-ai/glm-5.3-flash`。此时接口返回 HTTP 404,应用按 `modelUnavailable` 提示改用本地分析,不会崩溃也不会静默上传。升级模型前可先用 `curl` 调用 `https://openrouter.ai/api/v1/models` 或直接发一次最小请求确认 slug 仍然有效
- 本地 Debug 构建直接调用 DeepSeek 官方 Chat Completions(`https://api.deepseek.com/chat/completions`)上的 `deepseek-flash`。请求采用 HTTPS、Bearer 认证、`temperature=0` 与 `response_format=json_object`;模型只为本地清单中的每一种配料生成辅助说明,不能新增、删除、改名、排序或改写本地风险结论。传输层仅放行 `api.deepseek.com` 与 `/chat/completions`,其他主机一律判定为 `invalidEndpoint`
- 模型名会随上游下线而失效:本项目先后用过 OpenRouter 上的 `stealth/ox-alpha` `z-ai/glm-5.3-flash`,现已整体迁移到 DeepSeek 官方接口。模型不可用时接口返回 HTTP 404,应用按 `modelUnavailable` 提示改用本地分析,不会崩溃也不会静默上传。升级模型前先用 `curl -s https://api.deepseek.com/models -H "Authorization: Bearer $KEY"` 确认名称仍然有效;该接口当前只返回 `deepseek-flash` 与 `deepseek-v4-pro`
- 本地凭证只放在被 Git 忽略的 `Config/Secrets.xcconfig`,不会写入源码、测试、README、Info.plist 或 App 包。`Config/Debug.xcconfig` 会可选加载它,Debug scheme 在启动时传入并保存至版本化的本机 Keychain 槽位;若启动变量存在但为空或未展开,应用会直接判定 API 未配置,不会静默回退到旧凭证。`Config/Release.xcconfig` 始终为空,Release 代码也不会读取开发凭证,因此 Archive/TestFlight/App Store 构建不会携带它。
- 新环境可复制 `Config/Secrets.xcconfig.example` 为 `Config/Secrets.xcconfig` 并填写仅用于开发、带额度限制的密钥,然后运行 `xcodegen generate` 并从 Xcode 执行一次 Run。没有本地凭证或 Keychain 记录时,在线选项显示“待配置”且不会发起网络请求。
- Debug 日志只记录凭证来源、不可逆短指纹、请求返回的 generation ID、模型、结束原因、token 数与成本,不记录 API key、Authorization、图片、完整 OCR 或配料名称。排查免费模型时应以 HTTP 200、`finish_reason=stop`、token 数和 generation 记录为准;费用为 0 本身不代表请求失败。
- 客户端在运行时使用的任何密钥仍可能从受控设备或进程中提取。正式发布应改为调用自有后端代理;若改为用户自带密钥,应提供应用内录入、Keychain 保存与删除能力。
- `data_collection=deny` 只用于排除明确声明收集数据的上游,不构成零留存保证。OpenRouter、模型提供方 Z.ai,以及 OpenRouter 实际路由到的第三方推理服务商,仍可能依服务条款长期处理、保留并使用提示、回复及请求元数据,包括服务或模型改进许可;提供方、处理地区和政策也可能变化。发布前需重新核对服务政策、重新取得适当同意,并更新 App Store 隐私披露。
- 迁移到 DeepSeek 后不再发送任何声称限制上游留存的参数(OpenRouter 专有的 `provider.data_collection=deny` 与 `reasoning` 已一并移除,它们在 DeepSeek 上是无效字段,继续发送等于给出一个并不存在的隐私承诺)。DeepSeek 仍可能依其服务条款长期处理、保留并使用提示、回复及请求元数据,包括服务或模型改进许可;处理地区与政策也可能变化。因上游提供方已更换,`OnlineAnalysisConsent.currentVersion` 升到 2,旧的在线同意全部作废并回落到本地分析。发布前需重新核对服务政策、重新取得适当同意,并更新 App Store 隐私披露。

## 运行

Expand All @@ -39,12 +39,13 @@ AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入
## 测试

- 常规测试:`xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`。全部用例都不联网。
- 联网冒烟测试:`Tests/OpenRouterLiveSmokeTests.swift` 会用生产请求体真实调用一次 OpenRouter,用来发现模型 slug 被上游下线这类离线测试查不出的问题。默认跳过,CI 不会执行,也不会消耗额度。
- 联网冒烟测试:`Tests/OpenRouterLiveSmokeTests.swift` 会用生产请求体真实调用一次 DeepSeek,用来发现模型被上游下线这类离线测试查不出的问题。默认跳过,CI 不会执行,也不会消耗额度。
- 整图流水线诊断:`Tests/LabelPipelineHarnessTests.swift` 把一张真实照片依次跑过 Vision OCR、本地解析器和在线接口,并打印每一阶段的产物,用 `TEST_RUNNER_ADDIGUARD_LABEL_IMAGE` 指定图片路径。
- 该用例在模拟器进程内运行,不继承终端环境变量,因此必须使用 xcodebuild 的 `TEST_RUNNER_` 前缀(转发时会自动去掉前缀);不加前缀只会静默跳过:

```
TEST_RUNNER_ADDIGUARD_LIVE_API_TESTS=1 \
TEST_RUNNER_OPENROUTER_API_KEY="$(awk -F= '/OPENROUTER_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \
TEST_RUNNER_DEEPSEEK_API_KEY="$(awk -F= '/DEEPSEEK_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \
xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:AddiGuardTests/OpenRouterLiveSmokeTests test
Expand Down
2 changes: 1 addition & 1 deletion Sources/Design/RecognitionModePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ struct AnalysisModePicker: View {
}
Button("取消", role: .cancel) {}
} message: {
Text("图片始终由设备上的 Apple Vision 提取文字,不会上传。在线模式只把本地拆出的配料名称发送给 OpenRouter 的 \(OpenRouterConfiguration.displayName),供其逐项生成辅助说明;不会发送完整 OCR、用户画像或历史。模型提供方 Z.ai 与 OpenRouter 路由到的第三方推理服务商,可能按服务条款长期保留并使用这些文本与回复,包括服务或模型改进许可。AI 说明不会改写本地风险结论。")
Text("图片始终由设备上的 Apple Vision 提取文字,不会上传。在线模式只把本地拆出的配料名称发送给 DeepSeek 官方接口的 \(OpenRouterConfiguration.displayName),供其逐项生成辅助说明;不会发送完整 OCR、用户画像或历史。模型提供方 DeepSeek 可能按其服务条款保留并使用这些文本与回复,包括服务或模型改进许可。AI 说明不会改写本地风险结论。")
}
}

Expand Down
6 changes: 3 additions & 3 deletions Sources/Features/Profile/PrivacyPolicyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct PrivacyPolicyView: View {

policySection(
title: "如何处理",
text: "图片始终只由设备上的 Apple Vision 提取文字,不会上传。你可以选择本地分析或在线辅助分析:本地模式不发送任何内容;在线模式需要重新确认,只把本地解析器拆出的配料名称和不透明序号发送给 OpenRouter 的 \(OpenRouterConfiguration.displayName),由其逐项生成辅助说明。不会发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;如果本地没有拆出配料,应用不会改为上传完整 OCR。"
text: "图片始终只由设备上的 Apple Vision 提取文字,不会上传。你可以选择本地分析或在线辅助分析:本地模式不发送任何内容;在线模式需要重新确认,只把本地解析器拆出的配料名称和不透明序号发送给 DeepSeek 的 \(OpenRouterConfiguration.displayName),由其逐项生成辅助说明。不会发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;如果本地没有拆出配料,应用不会改为上传完整 OCR。"
)

policySection(
Expand All @@ -25,7 +25,7 @@ struct PrivacyPolicyView: View {

policySection(
title: "在线服务说明",
text: "在线请求会设置 data_collection=deny,以排除明确声明收集数据的上游提供方,但这不等于零留存保证。OpenRouter、模型提供方 Z.ai,以及 OpenRouter 实际路由到的第三方推理服务商,仍可能依其服务条款长期处理、保留并使用配料文本、回复及请求元数据,包括服务或模型改进许可;模型页面的当前说明与未来政策也可能变化。启用前请基于这一边界重新同意,发布前也必须再次核对 OpenRouter、上游服务和 App Store 的最新隐私要求。"
text: "在线请求直接发往 DeepSeek 官方接口 api.deepseek.com,不经第三方聚合路由,也不再附带任何声称限制上游留存的参数——本应用不提供零留存保证。DeepSeek 仍可能依其服务条款长期处理、保留并使用配料文本、回复及请求元数据,包括服务或模型改进许可;其隐私政策与数据保留期限也可能变化。请求会离开中国大陆以外用户所在地区并在 DeepSeek 的服务器处理。启用前请基于这一边界重新同意,发布前也必须再次核对 DeepSeek 与 App Store 的最新隐私要求。"
)

policySection(
Expand All @@ -39,7 +39,7 @@ struct PrivacyPolicyView: View {
)

Section {
Text("版本:2026-08-23")
Text("版本:2026-09-14")
.foregroundStyle(.secondary)
} footer: {
Text("App Store 上架前仍需由发布方提供公开可访问的隐私政策与支持网址,并确保其内容与本说明一致。")
Expand Down
11 changes: 6 additions & 5 deletions Sources/Services/IngredientAnalysisService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import SwiftUI

enum OnlineAnalysisConsent {
/// Increment whenever the provider, transmitted fields, or retention disclosure changes.
static let currentVersion = 1
/// v2: upstream moved from OpenRouter/Z.ai to DeepSeek, so earlier consent is void.
static let currentVersion = 2
}

struct IngredientAnalysisHTTPTransport: @unchecked Sendable {
Expand Down Expand Up @@ -81,7 +82,7 @@ struct RemoteIngredientAnalyzer: @unchecked Sendable {
let provider = IngredientAnalysisAPIProvider.openRouter(configuration: configuration)
return RemoteIngredientAnalyzer { tokens in
let request = try provider.makeRequest(tokens)
try validateOpenRouterRequest(request)
try validateAnalysisRequest(request)

let data: Data
let response: HTTPURLResponse
Expand Down Expand Up @@ -218,12 +219,12 @@ extension EnvironmentValues {
}
}

private func validateOpenRouterRequest(_ request: URLRequest) throws {
private func validateAnalysisRequest(_ request: URLRequest) throws {
guard let url = request.url,
url.scheme?.lowercased() == "https",
url.host?.lowercased() == "openrouter.ai",
url.host?.lowercased() == "api.deepseek.com",
url.port == nil || url.port == 443,
url.path == "/api/v1/chat/completions"
url.path == "/chat/completions"
else {
throw RecognitionServiceError.invalidEndpoint
}
Expand Down
Loading
Loading