Skill: liuzx886/calorie-lookup (version 0.3.0)

--- name: calorie-lookup version: 0.3.0 description: Spoonacular 营养查询与热量估算（主数据源，USDA 后备）。支持搜寻优化、烹饪系数、交叉验证及图像识别。需要 API key 环境变量 SPOONACULAR_API_KEY 或 USDA_FDC_API_KEY。 description_en: Spoonacular nutrition lookup & meal calorie estimation (primary, USDA fallback). Supports search optimization, cooking modifiers, cross-validation, and image recognition. Requires env var SPOONACULAR_API_KEY or USDA_FDC_API_KEY. metadata: {"clawdbot":{"emoji":"🍽️","requires":{"env":["SPOONACULAR_API_KEY","USDA_FDC_API_KEY"],"python":["requests"]}}} --- # Calorie Lookup (Spoonacular + USDA) ## 使用 / Usage - 入口函数 / Entry functions：`scripts/core.py` 中的 `lookup_meal(text, meal_type)` / `lookup_food(name, qty, unit)` - 结果为 JSON / Returns JSON：items + totals + questions（最多 2 个追问 / up to 2 follow-up questions） ## 配置 / Configuration - 环境变量 / Env var：`SPOONACULAR_API_KEY`（主数据源）/ `USDA_FDC_API_KEY`（后备）/ `SPOONACULAR_API_KEY` (primary) or `USDA_FDC_API_KEY` (fallback) - 缓存 / Cache：`CALORIE_SKILL_CACHE_DB`（默认本地 sqlite / defaults to local sqlite） ## 结构说明 / Module Structure - `scripts/core.py`：主逻辑（Spoonacular 主路由 + USDA 后备）/ Main logic (Spoonacular primary routing + USDA fallback) - `scripts/spoonacular.py`：Spoonacular API 封装 / Spoonacular API wrapper - `scripts/translate.py`：中英食物名字典（加速缓存，非主翻译路径）/ CN→EN dictionary (acceleration cache, not the primary translation path) - `scripts/usda_fdc.py`：USDA API 封装 + 错误处理 / USDA API wrapper + error handling - `scripts/parser.py`：文本解析 / Text parsing - `scripts/units.py`：默认换算表（常改）/ Unit conversion & portion defaults - `scripts/cache.py`：SQLite 缓存 / SQLite cache - `scripts/cooking.py`：烹饪热量修正系数（USDA 数据源）/ Cooking calorie modifiers (USDA source) ## 行为约束 / Behavior Constraints - 缺单位或无法换算：返回 `questions` 提示补充 / Missing unit or unconvertible: returns `questions` prompting user - 401/403/429/5xx：返回明确错误信息（适合直接在 Discord 显示）/ Returns clear error messages (suitable for Discord display) ## 工作流（含 Sub-agent 翻译 + 分解）/ Workflow (with Sub-agent Translation + Decomposition) - sub-agent 描述位于本 skill 的 `agents/` 目录 / Sub-agent contracts are in `agents/` - `agents/calorie-lookup-image-recognizer.md`：图像识别 Sub-agent 合约 / Image Recognizer Sub-agent contract - **非英语输入** → 触发 Decomposer Sub-agent（LLM 翻译 + 分解）/ **Non-English input** → triggers Decomposer Sub-agent (LLM translation + decomposition) - 复合菜/套餐/模糊描述 → 触发 Decomposer Sub-agent / Composite dishes / set meals / ambiguous descriptions → triggers Decomposer - 纯英语简单食材 → 直接调用 `lookup_meal` / Plain English simple ingredients → calls `lookup_meal` directly - 见 / See `WORKFLOW.md` 与 / and `HOOKS.md` - 分解后逐条调用 `lookup_food`（英文 name），再汇总 totals / After decomposition, calls `lookup_food` per item (English name), then aggregates totals - `scripts/translate.py` 字典仅作为 Python 层的加速缓存，不替代 LLM 翻译 / The dictionary is only an acceleration cache, not a replacement for LLM translation - 仍缺关键量化 → 追问最多 2 条 / Still missing key quantity data → up to 2 follow-up questions ## 路径规范 / Path Convention - 本 skill 内所有引用均使用相对路径 / All references within this skill use relative paths（例如 / e.g. `scripts/core.py`、`agents/`、`references/usda_fdc.md`）

path: AGENTS.md
content: # AGENTS Guide for `calorie-lookup` / `calorie-lookup` Agent 指南
Practical instructions for coding agents working in this repository.
本文档为在此仓库中工作的编码 Agent 提供实用指引。

Everything below is based on files currently present in this repo.
以下内容基于本仓库中当前存在的文件。

## 1) Project Snapshot / 项目概览
- Language / 语言: Python
- Dependency / 依赖 in `requirements.txt`: `requests>=2.31.0`
- Domain / 领域: USDA FoodData Central nutrition lookup + meal calorie estimation / USDA FoodData Central 营养查询与热量估算
- Main code folder / 主代码目录: `scripts/`
- Main docs / 主要文档: `SKILL.md`, `WORKFLOW.md`, `HOOKS.md`, `references/usda_fdc.md`

## 2) Key Files and Entry Points / 关键文件与入口
- `scripts/core.py`: orchestration (`lookup_food`, `lookup_meal`) + Spoonacular routing / 主编排逻辑 + Spoonacular 路由
- `scripts/spoonacular.py`: Spoonacular API calls + wrapper / Spoonacular API 调用 + 币窗
- `scripts/usda_fdc.py`: USDA API calls + `USDAError` / USDA API 调用 + 错误类
- `scripts/usda_fdc.py`: USDA API calls + `USDAError` / USDA API 调用 + 错误类
- `scripts/parser.py`: text splitting and amount parsing (`ParsedItem`) / 文本分割与份量解析
- `scripts/units.py`: unit normalization + portion-based gram conversion / 单位标准化 + 基于份量的克数换算
- `scripts/translate.py`: Chinese→English food name dictionary (acceleration cache, not primary translation path) / 中英食物名字典（加速缓存，非主翻译路径）
- `scripts/cache.py`: sqlite cache read/write with TTL / SQLite 缓存读写（含 TTL）
- `scripts/config.py`: runtime config and env var reads / 运行时配置与环境变量读取
- `agents/calorie-lookup-decomposer.md`: sub-agent contract for decomposition + translation / Sub-agent 合约（分解 + 翻译）
- `scripts/cooking.py`: calorie modifiers for 8 cooking methods (steamed, grilled, fried, etc.) / 8 种烹饪方式的热量修正系数
- `agents/calorie-lookup-image-recognizer.md`: multimodal sub-agent contract for food photo recognition / 图像识别 Sub-agent 合约

Programmatic smoke entry / 编程式冒烟测试入口:
```bash
python -c "from scripts.core import lookup_meal; print(lookup_meal('鸡胸肉200g+米饭1碗'))"
```

## 3) Environment Setup / 环境搭建
- Recommended Python / 推荐 Python: 3.10+
- Use a virtual environment before running commands / 运行命令前请使用虚拟环境
- Install deps / 安装依赖: `python -m pip install -r requirements.txt`
- Spoonacular key / Spoonacular API 密钥: `SPOONACULAR_API_KEY` (optional / 可选)
- USDA key / USDA API 密钥: `USDA_FDC_API_KEY` (optional / 可选)
- Cache DB env var / 缓存数据库环境变量: `CALORIE_SKILL_CACHE_DB` (optional / 可选)
- Cross-validation toggle / 交叉验证开关: `CROSS_VALIDATE_DEFAULT` (optional / 可选, default `False`)

Example / 示例:
```bash
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
export SPOONACULAR_API_KEY="<your-spoonacular-key>"
export USDA_FDC_API_KEY="<your-usda-key>"
export CALORIE_SKILL_CACHE_DB="./calorie_skill_cache.sqlite3"
```

## 4) Build, Lint, and Test Commands / 构建、检查与测试命令
There is no formal build system or committed test suite/config in this repo.
本仓库暂无正式构建系统或已提交的测试套件/配置。

Use the commands below as the default execution workflow.
请使用以下命令作为默认执行工作流。

### Install / 安装
- `python -m pip install -r requirements.txt`

### Build / sanity / 构建/健全性检查
- `python -m compileall scripts`

### Lint / format (optional / 可选)
- `python -m ruff check scripts`
- `python -m black --check scripts`

If `ruff`/`black` are not installed, either install them in the venv, or skip lint and report that lint tooling is not configured in-repo.
若未安装 `ruff`/`black`，可在虚拟环境中安装，或跳过检查并报告仓库内未配置 lint 工具。

### Tests (preferred style when tests are added) / 测试（添加测试时的推荐风格）
- All tests / 全部测试: `python -m pytest`
- Verbose / 详细输出: `python -m pytest -v`
- Keyword subset / 按关键字: `python -m pytest -k "lookup and not cache"`

Single test execution (important) / 单个测试执行（重要）:
- One file / 单文件: `python -m pytest tests/test_core.py`
- One function / 单函数: `python -m pytest tests/test_core.py::test_lookup_food_success`
- One class method / 单方法: `python -m pytest tests/test_core.py::TestCore::test_lookup_meal`

Unittest fallback / Unittest 后备:
- All tests / 全部测试: `python -m unittest discover -s tests -p "test_*.py"`
- One module / 单模块: `python -m unittest tests.test_core`
- One class method / 单方法: `python -m unittest tests.test_core.TestCore.test_lookup_meal`

### Runtime smoke checks (no framework required) / 运行时冒烟检查（无需框架）
- Parser / 解析器: `python -c "from scripts.parser import parse_meal_text; print(parse_meal_text('鸡胸肉200g+米饭1碗'))"`
- Units / 单位换算: `python -c "from scripts.units import to_grams; print(to_grams('米饭',1,'碗'))"`

## 5) Code Style Conventions / 代码风格约定
These reflect existing patterns in `scripts/` and should be preserved.
以下反映 `scripts/` 中的现有模式，应予以保留。

### Imports / 导入
- Keep imports at module top / 保持导入在模块顶部
- Order as: standard library -> third-party -> local modules / 顺序：标准库 → 第三方 → 本地模块
- Prefer explicit imports, never wildcard imports / 使用显式导入，禁止通配符导入
- Preserve local import style used in repo / 保持仓库中使用的本地导入风格（for example / 例如 `from parser import ...` in `scripts/core.py`）

### Formatting / 格式
- Follow PEP 8 and 4-space indentation / 遵循 PEP 8，4 空格缩进
- Keep functions focused and small / 函数保持专注和简短
- Avoid unnecessary comments for obvious code / 避免对显而易见的代码添加不必要注释
- Keep lines Black-compatible where practical / 尽量保持行与 Black 兼容

### Types / 类型
- Add type hints for public functions and key helpers / 为公开函数和关键辅助函数添加类型注解
- Use `Dict[str, Any]` at flexible JSON boundaries only / 仅在灵活的 JSON 边界使用 `Dict[str, Any]`
- Prefer dataclasses for structured values (`ParsedItem`, `Portion`) / 结构化值优先使用 dataclass
- Use `Optional[T]` when `None` is a valid path / 当 `None` 是合法值时使用 `Optional[T]`

### Naming / 命名
- `snake_case`: functions, variables / 函数、变量
- `UPPER_SNAKE_CASE`: constants / 常量（`QUERY_TTL`, `ITEM_TTL`, `NUTRIENT_IDS`）
- `PascalCase`: classes/dataclasses / 类/数据类
- Keep nutrition keys explicit / 营养键保持显式: `kcal`, `protein_g`, `carb_g`, `fat_g`, `fiber_g`

### Error handling / 错误处理
- Use `USDAError` for API-layer failures in `scripts/usda_fdc.py` / API 层失败使用 `USDAError`
- Map known HTTP statuses to actionable messages (401/403/429/5xx) / 将已知 HTTP 状态码映射为可操作的消息
- In orchestration (`lookup_food`, `lookup_meal`), prefer structured payloads over raw tracebacks / 编排层优先返回结构化负载而非原始堆栈
- Keep boundary error shape stable (`ok`, `error`, `status`) / 保持边界错误结构稳定

### Data contracts / 数据契约
- Do not break response shape from `lookup_food` / `lookup_meal` / 不要破坏响应结构
- Keep keys stable / 保持键名稳定: `items`, `totals`, `questions`, `ok`, `error`, `status`
- Keep follow-up questions concise and capped (current cap is 2) / 追问保持简洁，上限 2 条
- Preserve question object shape (`field`, `ask`) / 保持问题对象结构

### Parsing and units / 解析与单位
- Parser must remain tolerant of mixed Chinese/English separators / 解析器必须兼容中英混合分隔符
- Missing amount/unit should trigger follow-up questions, not silent guesses / 缺少数量/单位应触发追问，而非静默猜测
- Unit conversion failures should return clear reasons / 单位换算失败应返回明确原因
- Assumption-based conversions should be reflected in `notes` / 基于假设的换算应在 `notes` 中说明

### Caching and API behavior / 缓存与 API 行为
- Respect configured request timeout (`HTTP_TIMEOUT_SEC`) / 遵守配置的请求超时
- Keep cache keys deterministic and normalized / 缓存键保持确定性和标准化
- Cache serializable payloads only / 仅缓存可序列化的负载
- Keep item-level and meal-level TTL semantics distinct / 保持食材级和餐食级 TTL 语义分离

### Language and i18n / 语言与国际化
- User-facing prompts/errors are primarily Chinese in current code / 面向用户的提示/错误目前以中文为主
- Keep tone and language consistent with surrounding UX text / 保持语调和语言与周围 UX 文本一致
- Preserve Chinese serialization behavior (`ensure_ascii=False` in cache writes) / 保留中文序列化行为

## 6) Agent Workflow Rules (from repo docs) / Agent 工作流规则（源自仓库文档）
From `WORKFLOW.md`, `HOOKS.md`, and `agents/README.md` / 来自 `WORKFLOW.md`、`HOOKS.md` 和 `agents/README.md`：
- **Non-English input → always trigger Decomposer sub-agent / 非英语输入 → 始终触发 Decomposer sub-agent** (LLM translation is the primary path / LLM 翻译是主路径)
- Decomposer also handles: composite dishes, set meals, ambiguous inputs / Decomposer 还处理：复合菜、套餐、模糊输入
- Decomposer contract / 合约: `items[{name(English), name_zh, qty, unit}]` plus `notes[]`
- Decomposer must output English `name` for USDA API; `name_zh` preserves original / `name` 必须为英文（USDA API 要求）；`name_zh` 保留原文
- After decomposition, run `lookup_food` per item (with English name) and aggregate totals / 分解后逐条调用 `lookup_food`（英文名）并汇总
- Pure English simple input → can skip decomposer, call `lookup_meal` directly / 纯英语简单输入 → 可跳过 decomposer，直接调用 `lookup_meal`
- `scripts/translate.py` dictionary is an **acceleration cache** only, not the primary translator / 字典仅为**加速缓存**，非主翻译器
- If key quantity data is missing, ask follow-up questions (max 2) / 缺少关键量化数据时追问（最多 2 条）
- Prefer ingredient-level food names that USDA can match directly / 优先使用 USDA 可直接匹配的食材级名称

## 7) Cursor / Copilot Rules Status / 编辑器规则状态
- `.cursorrules`: not found / 未找到
- `.cursor/rules/`: not found / 未找到
- `.github/copilot-instructions.md`: not found / 未找到

There are currently no additional editor-specific rule files in this repo.
本仓库目前没有额外的编辑器专用规则文件。

## 8) Known Repository Caveat / 已知仓库注意事项
- **Spoonacular is the primary data source** / **Spoonacular 是主数据源**—USDA is automatic fallback / USDA 是自动后备
- `scripts/config.py` env vars: `SPOONACULAR_API_KEY` (optional / 可选), `USDA_FDC_API_KEY` (optional / 可选) — at least one should be set / 至少配置其中一个
- If changing runtime config, align code and docs together in one change / 修改运行时配置时，代码和文档须在同一变更中对齐
- Both Spoonacular and USDA APIs support English queries only / Spoonacular 和 USDA API 都仅支持英文查询
- **LLM translation (via Decomposer sub-agent) is the primary translation path** for non-English input / **LLM 翻译（通过 Decomposer sub-agent）是非英语输入的主翻译路径**
- `scripts/translate.py` provides a built-in ~200-entry Chinese→English dictionary as acceleration cache / 提供内置 ~200 条中英字典作为加速缓存
- The dictionary cannot cover all foods — non-English input should always go through the Decomposer / 字典无法覆盖所有食材，非英语输入应始终走 Decomposer
- The decomposer contract requires `name` (English) + `name_zh` (original language) in output items / 合约要求输出项包含 `name`（英文）+ `name_zh`（原文）
- Spoonacular wraps USDA data with better search algorithms—data overlap is expected / Spoonacular 用更好的搜索算法包装 USDA 数据，数据重叠是预期的
### v0.3 Features / v0.3 新特性
- **Search Optimization** / **搜索优化**: Added `search_hint` field to Decomposer output and implemented search fallback (Spoonacular → USDA).
- **Cooking Modifiers** / **烹饪修正**: Integrated `cooking.py` with 8 USDA-cited cooking methods (e.g., fried, roasted).
- **Cross-Validation** / **交叉验证**: Optional multi-source validation (Spoonacular vs USDA) with 30% warning threshold.
- **Multimodal Recognition** / **多模态识别**: New Image Recognizer sub-agent using Gemini 3 Pro (primary) / GPT-5.2 (fallback).

## 9) Change Management Checklist / 变更管理清单
- Keep edits minimal and scoped to the request / 编辑保持最小化，限定在请求范围内
- Do not rename public response keys without updating all call sites / 不要在未更新所有调用处的情况下重命名公开响应键
- Avoid new dependencies unless necessary / 除非必要，避免新增依赖
- If adding tests, prefer `pytest` and include at least one single-test runnable example / 添加测试时优先使用 `pytest`，并包含至少一个可单独运行的测试示例
- Never commit secrets or API keys / 永远不要提交密钥或 API key
- Update this file when workflow/tooling/contracts change / 工作流/工具链/合约变更时更新本文件


path: HOOKS.md
content: # Sub-agent / Hooks

## sub-agent: calorie-lookup-decomposer

- role: 将用户食物输入解析为可查询食材，并翻译为英文 / Parses user food input into queryable ingredients and translates to English
- input: 原始用户文本 / Raw user text
- output: items[{name(英文/English), name_zh, qty, unit}], notes[]

## hook: on_user_food_input

主 Agent 收到食物输入时的判断逻辑：
Decision logic when main agent receives food input:

```
if 输入包含非英语文字 / input contains non-English text:
    触发 decomposer sub-agent（LLM 翻译 + 分解）
    trigger decomposer sub-agent (LLM translation + decomposition)
    用 decomposer 输出的英文 name 调用 lookup_food
    call lookup_food with English name from decomposer output
elif 输入是复合菜/套餐/模糊描述 / input is composite dish / set meal / ambiguous:
    触发 decomposer sub-agent（分解）
    trigger decomposer sub-agent (decomposition)
    用 decomposer 输出调用 lookup_food
    call lookup_food with decomposer output
else:
    直接调用 lookup_meal（快速路径）
    call lookup_meal directly (fast path)
```

### 触发条件汇总（满足任一即触发）/ Trigger Conditions (any one triggers)
1. 输入包含非英语文字（中/日/韩等）/ Input contains non-English text (Chinese / Japanese / Korean, etc.)
2. 复合菜品 / Composite dishes（宫保鸡丁、红烧肉、麻婆豆腐…）
3. 套餐/外卖描述 / Set meals / takeout descriptions
4. 模糊描述无法直接映射到 USDA 食材名 / Ambiguous descriptions that cannot be directly mapped to USDA ingredient names

### 关键原则 / Key Principles
- **LLM 翻译是主路径 / LLM translation is the primary path**，字典只是加速缓存 / dictionary is only an acceleration cache
- 非英语输入不应依赖硬编码字典作为唯一翻译手段 / Non-English input should not rely on the hardcoded dictionary as the sole translation mechanism


path: README.md
content: # calorie-lookup v0.3.0

| Spoonacular 与USDA FoodData Central 两重数据源的营养查询与热量估算 Agent Skill。

将自然语言食物输入（支持中文）转化为结构化营养数据：热量、蛋白质、碳水、脂肪、膳食纤维。

Spoonacular (primary) & USDA FoodData Central (fallback) nutrition lookup & meal calorie estimation agent skill.

Converts natural language food input (Chinese supported) into structured nutrition data: calories, protein, carbs, fat, and fiber.
- **Multimodal Recognition** / **多模态识别**: Supports food photo recognition via Image Recognizer sub-agent (Gemini 3 Pro / GPT-5.2).
- **Search Optimization** / **搜索优化**: Improved accuracy with `search_hint` and multi-source fallback logic.
- **Data Quality** / **数据质量**: Added calorie modifiers for 8 cooking methods and optional source cross-validation.

---

## 快速开始 / Quick Start

```bash
# 1. 安装依赖 / Install dependencies
python -m pip install -r requirements.txt

# 2. 填入 API Key / Fill in your API keys
#    Spoonacular 免费签上 / Free sign-up: https://spoonacular.com/food-api
#    或 USDA FDC API Key / or USDA FDC API key: https://fdc.nal.usda.gov/api-key-signup.html
export SPOONACULAR_API_KEY="<your-spoonacular-key>"
# 或第二个 API Key / or use second API Key (fallback)
export USDA_FDC_API_KEY="<your-usda-key>"

# 3. 试运行 / Try it
PYTHONPATH=scripts python -c "
from core import lookup_meal
print(lookup_meal('chicken breast 200g + rice 1 bowl'))
"
```

---

## 项目结构 / Project Structure

```
calorie-lookup/
├── SKILL.md                  # Skill 元数据 / Skill metadata
├── WORKFLOW.md               # 工作流说明 / Workflow description
├── HOOKS.md                  # Sub-agent 触发逻辑 / Sub-agent trigger logic
├── AGENTS.md                 # 开发者/Agent 指南 / Developer/Agent guide
├── agents/
│   ├── README.md             # Sub-agent 概览 / Sub-agent overview
│   ├── calorie-lookup-decomposer.md  # Decomposer 合约 / Decomposer contract
│   └── calorie-lookup-image-recognizer.md # Image Recognizer 合约 / Image Recognizer contract
├── scripts/
│   ├── __init__.py           # 模块导出 / Module exports
│   ├── core.py               # 主逻辑 / Main logic: lookup_meal / lookup_food
│   ├── config_example.py     # 配置模板 / Config template (tracked by git)
│   ├── spoonacular.py        # Spoonacular API 封装 / Spoonacular API wrapper
│   ├── cooking.py            # 烹饪系数 / Cooking modifiers (USDA data)
│   ├── parser.py             # 自然语言文本解析 / NL text parsing
│   ├── units.py              # 单位换算 / Unit conversion & portion defaults
│   ├── translate.py          # 中英食物名字典 / CN→EN dictionary (acceleration cache)
│   ├── usda_fdc.py           # USDA API 封装 / USDA API wrapper + error handling
│   └── cache.py              # SQLite 缓存 / SQLite cache
├── references/
│   └── usda_fdc.md           # USDA API 快速参考 / USDA API quick reference
├── pyproject.toml            # 项目元数据 / Project metadata
├── requirements.txt          # Python 依赖 / Python dependencies
└── LICENSE                   # MIT
```

---

## 架构 / Architecture

### 两条处理路径 / Two Processing Paths

**路径 A — 快速路径 / Path A — Fast Path**
纯英语简单食材 → `lookup_meal(text)` 直接处理—优先查询 Spoonacular。
Plain English simple ingredients → `lookup_meal(text)` directly—prioritizes Spoonacular.

**路径 B — 主路径 / Path B — Primary Path**
非英语/覆合菜/套餐 → Decomposer Sub-agent（LLM 翻译 + 分解）→ 英文食材名 → Spoonacular 查询(或 USDA 后备) → 汇总。
Non-English / composite dishes / set meals → Decomposer Sub-agent (LLM translation + decomposition) → English ingredient names → Spoonacular query (or USDA fallback) → aggregate totals.

### 译业 / Translation Strategy

- **主数据源 / Primary data source**：Spoonacular API
- **后备 / Fallback**：USDA FoodData Central API
- **主语言路径 / Primary language path**：LLM 翻译（通过 Decomposer Sub-agent）
- **加速缓存 / Acceleration cache**：`scripts/translate.py` 内置 ~224 条中英字典
- Spoonacular 和 USDA API 都仅支持英文查询 / Both APIs only accept English queries

### 数据流 / Data Flow

```
用户输入 / User Input
  → [Agent 判断路径 / Path Decision]
  → [解析/翻译 / Parse & Translate]
  → Spoonacular API 查询（主）/ Spoonacular API Query (primary)
  → USDA API 查询（后备）/ USDA API Query (fallback on error)
  → 营养计算 / Nutrition Calculation
  → 结构化输出 / Structured Output
  → SQLite 缓存 / Cache
```

---

## API

### `lookup_meal(text, meal_type="unknown")`

解析多食材文本，逐条查询并汇总。
Parses multi-ingredient text, queries each item, and aggregates totals.

```python
result = lookup_meal("chicken breast 200g + rice 1 bowl")
# {
#   "type": "meal_nutrition",
#   "items": [...],
#   "totals": {"kcal": ..., "protein_g": ..., "carb_g": ..., "fat_g": ..., "fiber_g": ...},
#   "questions": [...]
# }
```

### `lookup_food(name, qty, unit)`

查询单个食材的营养信息。
Queries nutrition info for a single ingredient.

```python
result = lookup_food("chicken breast", 200, "g")
# {"ok": True, "kcal": 330.0, "protein_g": 62.0, ...}
```

---

## 环境变量 / Environment Variables

| 变量 / Variable | 必需 / Required | 说明 / Description |
|------|------|------|
| `SPOONACULAR_API_KEY` | 否 / No | Spoonacular API Key（为空時自动使用 USDA / Auto-fallback to USDA if empty）|
| `USDA_FDC_API_KEY` | 否 / No | USDA FoodData Central API Key |
| `CALORIE_SKILL_CACHE_DB` | 否 / No | SQLite 缓存路径 / Cache DB path（默认 / default: `calorie_skill_cache.sqlite3`）|
|| `CROSS_VALIDATE_DEFAULT` | 否 / No | 开启交叉验证 / Enable cross-validation (default: `False`) |

---

## License

MIT


path: WORKFLOW.md
content: # Calorie Lookup Workflow / 热量查询工作流

## 判断是否需要 Sub-agent（主 Agent 职责）/ Determining Sub-agent Need (Main Agent's Responsibility)

主 Agent 收到用户的食物输入后，按以下规则判断处理路径：
When the main agent receives food input from the user, it determines the processing path by the following rules:

### 路径 A：直接调用 `lookup_meal`（快速路径）/ Path A: Direct `lookup_meal` Call (Fast Path)
- 输入为**纯英语**且是**简单食材列表** / Input is **plain English** and a **simple ingredient list**（如 / e.g. `chicken breast 200g + rice 1 bowl`）
- 可选：输入为中文但所有食材都在 `scripts/translate.py` 字典中能命中（加速缓存）/ Optional: Chinese input where all items hit the dictionary (acceleration cache)
- 字典命中仅作为**加速**，不保证覆盖所有食材 / Dictionary hits serve as **acceleration** only, not guaranteed to cover all foods
- **注：Spoonacular 是主数据源** / **Note: Spoonacular is the primary data source**—对于简单食材需优先使用。任何错误时自动回滚到 USDA / For simple ingredients, prioritize Spoonacular. Falls back to USDA on any error.

### 路径 B：触发 Decomposer Sub-agent（LLM 翻译 + 分解，主路径）/ Path B: Trigger Decomposer Sub-agent (LLM Translation + Decomposition, Primary Path)
**触发条件（满足任一即触发）/ Trigger conditions (any one triggers)：**
1. 输入包含**非英语文字**（中文、日文、韩文等）/ Input contains **non-English text** (Chinese, Japanese, Korean, etc.)
2. 输入是**复合菜** / Input is a **composite dish**（如 / e.g. 宫保鸡丁、鱼香肉丝、咖喱牛肉饭）
3. 输入是**套餐/外卖描述** / Input is a **set meal / takeout description**（如 / e.g. 麦当劳巨无霸套餐、一份黄焖鸡）
4. 输入**描述模糊**，无法直接映射到 USDA 可查询的食材名 / Input is **ambiguous**, cannot be directly mapped to USDA-searchable ingredient names

**Decomposer 职责 / Decomposer Responsibilities：**
- 将复合菜拆分为可独立查询的食材列表 / Split composite dishes into independently queryable ingredients
- **将所有非英语食材名翻译为英文** / **Translate all non-English ingredient names to English**（Spoonacular 和 USDA API 都仅支持英文查询 / Spoonacular and USDA API both only support English queries）
- Spoonacular 优先查询，任何错误自动回滚到 USDA / Spoonacular is tried first; any error falls back to USDA
- 估算缺失的份量，并在 notes 中说明假设 / Estimate missing quantities and note assumptions in `notes`
- 输出格式 / Output format：`{items: [{name, name_zh, qty, unit}], notes: []}`
### 路径 C：触发 Image Recognizer Sub-agent（图像识别 + 分解）/ Path C: Trigger Image Recognizer Sub-agent (Image Recognition + Decomposition)
**触发条件 / Trigger conditions：**
- 用户发送食物照片（含或不含文字描述）/ User sends a food photo (with or without text description)

**Image Recognizer 职责 / Image Recognizer Responsibilities：**
- 识别照片中的食物种类并结合用户提供的文字进行分析 / Identify food types in the photo and analyze them alongside user-provided text
- **输出兼容 Decomposer 的 JSON 格式** / **Output JSON compatible with Decomposer**：包含英文食材名、原始中文名、估算份量及单位 / Contains English ingredient names, original Chinese names, estimated quantities, and units
- 优先识别为可直接查询的单一食材，必要时进行分解 / Prioritize identifying individually queryable ingredients, decomposing where necessary
- 使用 Gemini 3 Pro (主) / GPT-5.2 (后备) 进行多模态识别 / Uses Gemini 3 Pro (primary) / GPT-5.2 (fallback) for multimodal recognition
- 输出格式 / Output format：`{items: [{name, name_zh, qty, unit, search_hint}], notes: []}`


## 处理步骤 / Processing Steps

### 路径 A（纯英语/字典命中）/ Path A (English / Dictionary Hit)
1. 主 Agent 调用 `lookup_meal(text, meal_type)` / Main agent calls `lookup_meal(text, meal_type)`
2. `core.py` 内部解析 → Spoonacular 查询（或 USDA 后备）→ 汇总 / `core.py` internally parses → Spoonacular query (or USDA fallback) → aggregation
3. 返回结果 / Return results

### 路径 B（Sub-agent 主路径）/ Path B (Sub-agent Primary Path)
1. 主 Agent 将原始用户文本发送给 Decomposer Sub-agent / Main agent sends raw user text to Decomposer Sub-agent
2. Decomposer 返回 / Decomposer returns `{items: [{name(英文/English), name_zh, qty, unit}], notes: []}`
3. 主 Agent 对每个 item 调用 `lookup_food(name, qty, unit)`（name 已是英文）/ Main agent calls `lookup_food(name, qty, unit)` per item (name is already English)
4. 汇总 totals / Aggregate totals
5. 若仍缺关键量化信息，最多追问 2 条 / If key quantity data is still missing, ask up to 2 follow-up questions
### 路径 C（Image Recognizer 识别路径）/ Path C (Image Recognizer Path)
1. 主 Agent 将照片（及可选文字）发送给 `calorie-lookup-image-recognizer` Sub-agent / Main agent sends photo (and optional text) to `calorie-lookup-image-recognizer` Sub-agent
2. Image Recognizer 使用多模态 LLM 识别并返回 / Image Recognizer uses multimodal LLM to identify and returns `{items: [{name, name_zh, qty, unit, search_hint}], notes: []}`
3. 主 Agent 对每个 item 调用 `lookup_food(name, qty, unit, search_hint=search_hint)` / Main agent calls `lookup_food(name, qty, unit, search_hint=search_hint)` per item
4. 汇总 totals / Aggregate totals
5. 若识别不确定或关键信息缺失，向用户追问 / If identification is uncertain or key info is missing, ask the user


## 关键原则 / Key Principles
- **LLM 翻译是主路径 / LLM translation is the primary path**：字典只是加速缓存，不可能覆盖所有食材 / The dictionary is only an acceleration cache; it cannot cover all foods
- **非英语输入默认走 Sub-agent / Non-English input defaults to Sub-agent**：不要假设字典能处理 / Do not assume the dictionary can handle it
- **Decomposer 的 `name` 字段必须是英文 / Decomposer's `name` field must be English**：这是 USDA API 的硬性要求 / This is a hard requirement of the USDA API
- **追问上限 2 条 / Follow-up question limit: 2**：避免用户体验过差 / To avoid degrading user experience
- **图像输入必须走 Image Recognizer / Image-based input must go through Image Recognizer**：利用多模态 LLM 进行识别，不依赖外部第三方图像识别 API / Utilizes multimodal LLM for identification without relying on external third-party image recognition APIs
**Spoonacular 是数据查询的主来源** / **Spoonacular is the primary data source**—USDA 仅作为自动后备 / USDA is only automatic fallback
- **搜索优化 / Search Optimization**：支持 `search_hint` 提高复合菜搜索准确度，并具备搜索回退策略 / Supports `search_hint` for better composite dish matching with fallback strategy.
- **数据质量 / Data Quality**：支持 8 种烹饪方式的热量修正（`cooking.py`）及数据源交叉验证（可选） / Supports 8 cooking method calorie modifiers and optional data cross-validation.


path: agents/README.md
content: # Sub-agents

## calorie-lookup-decomposer
- 触发 / Trigger：非英语输入 / 复合菜 / 套餐 / 模糊描述 — Non-English input / composite dishes / set meals / ambiguous descriptions
- 目标 / Goal：翻译为英文 + 拆分为可查询食材条目 — Translate to English + decompose into queryable ingredient items
- 输出 / Output：items[{name(英文/English), name_zh, qty, unit}], notes[]

### 使用说明 / Usage
- **非英语输入默认触发 / Triggered by default for non-English input**：主 Agent 检测到非英语字符时必须调用此 sub-agent / Main agent must invoke this sub-agent when non-English characters are detected
- Decomposer 输出的 `name` 字段为英文，可直接传给 `lookup_food(name, qty, unit)` / Decomposer's `name` field is English, can be passed directly to `lookup_food(name, qty, unit)`
- `name_zh` 保留原始非英语名称，用于用户展示 / `name_zh` preserves original non-English name for user display
- LLM 翻译是主路径；`scripts/translate.py` 字典仅作为 Python 层的加速缓存 / LLM translation is the primary path; `scripts/translate.py` dictionary serves only as a Python-layer acceleration cache


path: agents/calorie-lookup-decomposer.md
content: # calorie-lookup-decomposer

## 角色 / Role
将用户的食物输入解析为 USDA API 可查询的食材列表。
Parses user food input into a list of ingredients queryable by the USDA API.

核心职责：**翻译**（非英语 → 英语）+ **分解**（复合菜 → 单一食材）。
Core responsibilities: **Translation** (non-English → English) + **Decomposition** (composite dishes → individual ingredients).

## 触发条件 / Trigger Conditions
满足任一即触发 / Triggers on any of the following：
1. 输入包含非英语文字（中文、日文、韩文等）/ Input contains non-English text (Chinese, Japanese, Korean, etc.)
2. 复合菜品 / Composite dishes（如 / e.g. 宫保鸡丁、鱼香肉丝、咖喱牛肉饭）
3. 套餐/外卖描述 / Set meals / takeout descriptions（如 / e.g. 麦当劳巨无霸套餐、一份黄焖鸡）
4. 描述模糊，无法直接映射到 USDA 可查询的食材名 / Ambiguous descriptions that cannot be directly mapped to USDA-searchable ingredient names

## 输入 / Input
- 原始用户文本（任何语言）/ Raw user text (any language)

## 输出（JSON）/ Output (JSON)
```json
{
  "items": [
    {"name": "chicken breast", "name_zh": "鸡胸肉", "qty": 200, "unit": "g"},
    {"name": "cooked white rice", "name_zh": "米饭", "qty": 1, "unit": "碗"}
  ],
  "notes": ["按常见做法估算，若不确定请追问克数 / Estimated based on common serving; ask for grams if unsure"]
}
```

### 字段说明 / Field Description
| 字段 / Field | 类型 / Type | 说明 / Description |
|--------|------|------|
| `name` | string | **必须英文 / Must be English**。USDA API 可搜索的食材描述 / USDA API searchable ingredient description（如 / e.g. `chicken breast`, `cooked white rice`）|
| `name_zh` | string | 原始非英语名称，用于用户展示 / Original non-English name for user display。英语输入时可与 `name` 相同 / May equal `name` for English input |
| `qty` | number | 数量 / Quantity。缺失时可合理估算并在 notes 说明 / May be estimated if missing (note in `notes`) |
| `unit` | string | 保留用户原始单位 / Preserves user's original unit（g, ml, 碗, 个等），后续由 `units.py` 换算 / converted later by `units.py` |
| `search_hint` | string \| null | API-友好的搜索词，用于复合菜 / API-friendly search term for composite dishes（可选 / optional）。当 `name` 是可直接查询的食材时设为 `null` / Set to `null` when `name` is directly queryable |

## 规则 / Rules

### 翻译规则 / Translation Rules
- **所有非英语食材名必须翻译为英文 / All non-English ingredient names must be translated to English**（USDA API 仅支持英文查询 / USDA API only supports English queries）
- 英文 `name` 使用 USDA 常见食材描述 / English `name` should use common USDA ingredient descriptions（如 / e.g. `chicken breast` 而不是 / not `ji xiong rou`）
- 优先用最接近原材料的英文名，而不是菜名的直译 / Prefer ingredient-level English names over literal dish name translations（如 / e.g. 红烧肉 → 拆为 / decompose to pork belly + soy sauce，而不是 / not "red braised meat"）

### 分解规则 / Decomposition Rules
- 优先拆成"可直接在 USDA 查询的单一食材" / Prioritize decomposing into individually USDA-queryable ingredients
- 复合菜必须拆解为主要食材组分 / Composite dishes must be broken down into main ingredient components（例 / e.g. 宫保鸡丁 → 鸡胸肉 + 花生 + 青椒 + 调味料 / chicken breast + peanuts + green pepper + seasoning）
- 不要输出复合菜名作为 `name` / Do not output composite dish names as `name`（USDA 搜不到 / USDA cannot find them）

### 份量规则 / Portion Rules
- 份量不确定时可给出合理默认，但**必须**在 `notes` 说明假设 / When portion is uncertain, provide reasonable defaults but **must** note assumptions in `notes`
- 如果完全无法判断，输出空 `items` 并在 `notes` 提示追问 / If completely unable to determine, output empty `items` and prompt in `notes`
- `unit` 保留用户原始单位，后续由 `units.py` 换算 / `unit` preserves original user unit; conversion handled later by `units.py`

### Search Hint 规则 / Search Hint Rules
- 当 `name` 是复合菜或 USDA 无法直接搜索的描述时，提供 `search_hint` / When `name` is a composite dish or USDA-unsearchable description, provide `search_hint`
- `search_hint` 应该是**最简单、最通用的食材名** / `search_hint` should be the **simplest, most generic ingredient term** (例如 / e.g. `chicken katsu` → `search_hint: "breaded fried chicken"`) 
- 当 `name` 已是可直接在 USDA 查询的食材时，设 `search_hint: null` / When `name` is already directly USDA-queryable, set `search_hint: null` (例如 / e.g. `chicken breast` → `search_hint: null`)
- 若 API 使用 `search_hint` 作为备选搜索词时，应能获得更好的匹配结果 / If API uses `search_hint` as fallback query, should achieve better match results

## 示例 / Examples

### 简单翻译 / Simple Translation
- 输入 / Input：`鸡胸肉200g`
- 输出 / Output：`{"items": [{"name": "chicken breast", "name_zh": "鸡胸肉", "qty": 200, "unit": "g", "search_hint": null}], "notes": []}`

### 复合菜分解 / Composite Dish Decomposition
- 输入 / Input：`宫保鸡丁一份`
- 输出 / Output：
```json
{
  "items": [
    {"name": "chicken breast", "name_zh": "鸡胸肉", "qty": 200, "unit": "g", "search_hint": null},
    {"name": "peanut", "name_zh": "花生", "qty": 30, "unit": "g", "search_hint": null},
    {"name": "green bell pepper", "name_zh": "青椒", "qty": 50, "unit": "g", "search_hint": null},
    {"name": "vegetable oil", "name_zh": "食用油", "qty": 15, "unit": "ml", "search_hint": null}
  ],
  "notes": ["宫保鸡丁按常见一份估算，实际用量因餐厅而异 / Kung Pao Chicken estimated as one standard serving; actual amounts vary by restaurant"]
}
```


path: agents/calorie-lookup-image-recognizer.md
content: # calorie-lookup-image-recognizer

## 角色 / Role
从用户上传的食物照片识别食品，估算份量，输出与 Decomposer 兼容的格式。
Identifies food items from user-uploaded food photos, estimates portions, outputs in Decomposer-compatible format.

核心能力：**多模态识别**（结合图像和文字）+ **份量估算**（从视觉线索）。
Core capabilities: **Multimodal recognition** (combines image and text) + **Portion estimation** (from visual cues).

## 触发条件 / Trigger Conditions
满足任一即触发 / Triggers on any of the following：
1. 用户上传食物照片 / User uploads a food photo
2. 用户说"我吃了这个"并附带图片 / User says "I ate this" with an image
3. 用户同时上传照片和文字描述 / User uploads both photo and text description

## 模型配置 / Model Configuration

| 角色 / Role | 模型 ID / Model ID | 上下文 / Context | 功能 / Capability |
|---------|-------------|---------|---------|
| Primary / 主模型 | [REDACTED_SECRET] | 1024k | Multimodal (text+image) / 多模态 |
| Fallback / 备用 | `openai/gpt-5.2` | 391k | Multimodal (text+image) / 多模态 |

**选择逻辑 / Selection Logic**:
- 优先使用 Gemini 3 Pro，若不可用则切换到 GPT-5.2
- Prefer Gemini 3 Pro; fallback to GPT-5.2 if unavailable

## 输入 / Input
- 食物照片 / Food photo（JPEG, PNG, WebP 等）
- 可选的文字描述 / Optional text description（如份量、食物名称、用户备注等）

## 输出（JSON）/ Output (JSON)
```json
{
  "items": [
    {"name": "breaded fried chicken", "name_zh": "炸鸡排", "qty": 150, "unit": "g", "search_hint": "chicken schnitzel"},
    {"name": "cooked white rice", "name_zh": "米饭", "qty": 150, "unit": "g", "search_hint": null}
  ],
  "notes": ["份量根据图片估算，若不准确请告知实际克数 / Portions estimated from photo; please specify actual grams if inaccurate"]
}
```

### 字段说明 / Field Description
| 字段 / Field | 类型 / Type | 说明 / Description |
|---------|------|------|
| `name` | string | **必须英文 / Must be English**。USDA API 可搜索的食材描述 / USDA API searchable ingredient description（如 / e.g. `breaded fried chicken`, `cooked white rice`）|
| `name_zh` | string | 原始中文名称或用户识别的名称 / Original Chinese name or recognized name from photo。用于用户展示 / for user display |
| `qty` | number | 数量 / Quantity。从图片估算（参考盘子大小、食物比例等）/ Estimated from visual cues (plate size, food proportions, etc.) |
| `unit` | string | 保留用户原始单位或估算单位 / Preserves user's original unit or estimated unit（g, ml, 个等）。后续由 `units.py` 换算 / converted later by `units.py` |
| `search_hint` | string \| null | API-友好的搜索词，用于复合菜或难以直接查询的食物 / API-friendly search term for composite dishes or hard-to-search items。当 `name` 已是可直接查询的食材时设为 `null` / Set to `null` when `name` is directly queryable |
| `notes` | array | 备注列表，包含份量假设、识别不确定性等 / List of notes including portion assumptions, recognition uncertainty, etc. |

## 规则 / Rules

### 图像识别规则 / Image Recognition Rules
- **结合图像和文字 / Combine image and text**：若用户同时提供照片和文字描述，必须同时使用两者来识别食品。文字可能澄清照片中的内容。/ If user provides both photo and text, use BOTH to identify food. Text may clarify what's in the photo.
- **估算份量 / Portion estimation**：从视觉线索估算份量（盘子大小、食物堆积高度、相对大小等）。**始终在 `notes` 中说明估算假设** / Estimate portions from visual cues (plate size, food height, relative size, etc.). **Always note assumptions in `notes`**.
- **英文名称 / English names**：`name` 必须是英文，用于后续 USDA API 查询 / `name` must be English for USDA API search compatibility
- **search_hint 规则 / Search hint rules**：当识别的食物是复合菜或需要特殊搜索词时提供 `search_hint`（如 `chicken katsu` → `search_hint: "chicken schnitzel"`）；简单食材设为 `null` / Provide `search_hint` when identified food is a composite or needs special search term; set to `null` for simple ingredients
- **不确定时询问 / Ask when uncertain**：若照片中的食物无法可靠识别，输出空 `items` 并在 `notes` 中提示用户提供更多信息 / If food cannot be reliably identified, output empty `items` and ask for clarification in `notes`

### 分解复合菜 / Decompose Composite Dishes
- **拆解原则 / Decomposition principle**：同 Decomposer 规则 — 优先拆成"可直接在 USDA 查询的单一食材" / Follow Decomposer rules — prioritize decomposing into individually USDA-queryable ingredients
- **示例 / Example**：照片中显示咖喱鸡炸饭，应拆解为：鸡肉、米饭、洋葱、诅咒粉、食用油等单一食材 / Photo shows chicken katsu curry rice → decompose to: breaded fried chicken + cooked white rice + curry sauce (as simplified ingredient)
- **不要输出复合菜名 / No composite dish names**：输出的 `name` 必须是 USDA 可搜索的单一食材或简化食材 / Output `name` must be USDA-searchable ingredients or simplified terms, not composite dish names

### 多模态融合规则 / Multimodal Fusion Rules
- **优先级 / Priority**：图像识别是主要信息来源 / Image recognition is the primary source
- **文字补充 / Text enhancement**：文字描述用于：确认食物名称、标注份量（如"大约 300g"）、澄清照片中的模糊部分 / Text is used to: confirm food name, specify portion (e.g., "about 300g"), clarify ambiguous parts in photo
- **冲突处理 / Conflict resolution**：若图像和文字冲突（如图显示米饭，文字说"没有米"），在 `notes` 中说明 / If image and text conflict, note the discrepancy in `notes`

## 示例 / Examples

### 炸鸡配米饭的照片 / Photo of fried chicken with rice
- 输入 / Input：用户上传咖喱炸鸡翅腿配米饭的照片 / User uploads photo of breaded fried chicken with white rice
- 输出 / Output：
```json
{
  "items": [
    {"name": "breaded fried chicken", "name_zh": "炸鸡排", "qty": 200, "unit": "g", "search_hint": null},
    {"name": "cooked white rice", "name_zh": "米饭", "qty": 150, "unit": "g", "search_hint": null},
    {"name": "curry sauce", "name_zh": "咖喱酱", "qty": 30, "unit": "ml", "search_hint": "curry"}
  ],
  "notes": ["份量根据盘子大小和食物堆积高度估算，若不准确请告知实际克数 / Portions estimated based on plate size and food height; please specify actual grams if inaccurate"]
}
```

### 沙拉照片加文字描述 / Photo of salad with text description
- 输入 / Input：用户上传蔬菜沙拉照片，文字说"大约 300g，加了一些坚果" / User uploads salad photo with text "about 300g, with some nuts"
- 输出 / Output：
```json
{
  "items": [
    {"name": "mixed salad greens", "name_zh": "混合生菜", "qty": 250, "unit": "g", "search_hint": null},
    {"name": "walnut", "name_zh": "核桃", "qty": 30, "unit": "g", "search_hint": null},
    {"name": "olive oil dressing", "name_zh": "橄榄油酱", "qty": 20, "unit": "ml", "search_hint": "olive oil"}
  ],
  "notes": ["用户标注约 300g，可根据盘子大小验证；坚果种类从图片识别 / User indicated ~300g; nuts type identified from photo"]
}
```

### 不清晰的照片 / Unclear photo
- 输入 / Input：用户上传模糊的食物照片，无法确定食物类型 / User uploads blurry photo, unclear what food is
- 输出 / Output：
```json
{
  "items": [],
  "notes": ["照片不够清晰，无法确定食物类型。请上传更清晰的照片或用文字描述食物内容 / Photo is unclear; cannot determine food type. Please upload a clearer photo or describe the food in text."]
}
```

## 集成注记 / Integration Notes
- 此 sub-agent 输出与 Decomposer 完全兼容，后续主 agent 调用 `lookup_food(name, qty, unit, search_hint=search_hint)` 按常规流程处理 / Output is fully Decomposer-compatible; main agent calls `lookup_food()` with results per standard pipeline
- 多模态 LLM（Gemini 3 Pro / GPT-5.2）同时支持图像解析和文字理解，能准确识别中文食品和进行份量估算 / Multimodal LLM supports both image parsing and text understanding for accurate food recognition and portion estimation
- 当识别不确定时，主 agent 从 `notes` 中获取澄清请求，反馈给用户 / When uncertain, main agent retrieves clarification request from `notes` and responds to user


path: pyproject.toml
content: [project]
name = "calorie-lookup"
version = "0.3.0"
description = "Spoonacular nutrition lookup with search optimization, cooking modifiers, and multimodal image recognition — agent skill"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = [
    "requests>=2.31.0",
]

[project.urls]
Repository = "https://github.com/zengxuanliu2005/calorie-lookup"


path: references/usda_fdc.md
content: # USDA FoodData Central (FDC) 快速参考 / Quick Reference

- Base URL: https://api.nal.usda.gov/fdc/v1
- 认证 / Authentication：API Key（query 参数 / query parameter `api_key`）

## 常用端点 / Common Endpoints
- POST /foods/search
- GET /food/{fdcId}

## 常见错误 / Common Errors
- 401 Unauthorized: API key 无效或缺失 / Invalid or missing API key
- 403 Forbidden: 权限不足 / Insufficient permissions
- 429 Too Many Requests: 触发限速（稍后再试）/ Rate limited (retry later)
- 5xx: 服务端错误 / Server error

## 建议 / Recommendations
- 搜索结果有限，优先 Foundation / SR Legacy / Survey (FNDDS) / Search results are limited; prefer Foundation / SR Legacy / Survey (FNDDS) data types
- 复合菜品命中率较低，建议拆成食材 / Composite dishes have low match rates; decompose into individual ingredients


path: requirements.txt
content: requests>=2.31.0


path: scripts/__init__.py
content: from core import lookup_meal, lookup_food  # noqa


path: scripts/cache.py
content: import json
import sqlite3
import time
from typing import Optional, Any, Dict
from config import CACHE_DB_PATH

def _conn():
    c = sqlite3.connect(CACHE_DB_PATH)
    c.execute("""
    CREATE TABLE IF NOT EXISTS cache (
      key TEXT PRIMARY KEY,
      value TEXT NOT NULL,
      expires_at INTEGER NOT NULL
    )
    """)
    return c

def get(key: str) -> Optional[Dict[str, Any]]:
    now = int(time.time())
    with _conn() as c:
        row = c.execute("SELECT value, expires_at FROM cache WHERE key=?", (key,)).fetchone()
        if not row:
            return None
        value, expires_at = row
        if expires_at < now:
            c.execute("DELETE FROM cache WHERE key=?", (key,))
            return None
        return json.loads(value)

def set(key: str, value: Dict[str, Any], ttl_sec: int):
    expires_at = int(time.time()) + ttl_sec
    with _conn() as c:
        c.execute(
            "INSERT OR REPLACE INTO cache(key,value,expires_at) VALUES(?,?,?)",
            (key, json.dumps(value, ensure_ascii=False), expires_at)
        )


path: scripts/config_example.py
content: import os

# Copy this file to config.py and fill in your USDA FDC API key.
# 将此文件复制为 config.py 并填入你的 USDA FDC API Key。
# Free signup / 免费申请: https://fdc.nal.usda.gov/api-key-signup.html

USDA_API_KEY = os.getenv("USDA_FDC_API_KEY", "").strip()
FDC_BASE = "https://api.nal.usda.gov/fdc/v1"

CACHE_DB_PATH = os.getenv("CALORIE_SKILL_CACHE_DB", "calorie_skill_cache.sqlite3")
HTTP_TIMEOUT_SEC = 15
SEARCH_PAGE_SIZE = 8
PREFERRED_DATA_TYPES = [
    "Foundation",
    "SR Legacy",
    "Survey (FNDDS)",
    "Branded",
]

# Get your free API key at https://spoonacular.com/food-api/console
SPOONACULAR_API_KEY = os.getenv("SPOONACULAR_API_KEY", "")
SPOONACULAR_BASE = "https://api.spoonacular.com"
SPOONACULAR_SEARCH_LIMIT = 1  # Minimize point consumption: 1+1=2pts per search
CROSS_VALIDATE_DEFAULT = False


path: scripts/cooking.py
content: """Cooking method modifier table for nutrition adjustments.

Based on USDA Table of Nutrient Retention Factors for Home Cooking
(https://www.ars.usda.gov/ARSUserFiles/80400525/Data/retn/retn06.pdf)

Modifiers adjust nutrition data based on cooking method (e.g., frying adds oil
and calories, grilling removes fat through dripping). Each method multiplier
reflects typical losses or gains of specific nutrients.

Nutrient keys: kcal, protein_g, carb_g, fat_g, fiber_g (from core.py)
"""

from typing import Dict


COOKING_MODIFIERS: Dict[str, Dict[str, float]] = {
    "raw": {
        "kcal": 1.0,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 1.0,
        "fiber_g": 1.0,
    },
    "steamed": {
        "kcal": 1.0,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 1.0,
        "fiber_g": 1.0,
    },
    "boiled": {
        "kcal": 0.9,
        "protein_g": 0.95,
        "carb_g": 0.85,
        "fat_g": 0.9,
        "fiber_g": 0.85,
    },
    "grilled": {
        "kcal": 0.95,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 0.85,
        "fiber_g": 1.0,
    },
    "baked": {
        "kcal": 0.95,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 0.9,
        "fiber_g": 1.0,
    },
    "roasted": {
        "kcal": 0.95,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 0.9,
        "fiber_g": 1.0,
    },
    "fried": {
        "kcal": 1.3,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 1.5,
        "fiber_g": 1.0,
    },
    "deep_fried": {
        "kcal": 1.3,
        "protein_g": 1.0,
        "carb_g": 1.0,
        "fat_g": 1.5,
        "fiber_g": 1.0,
    },
}


def _round(x: float) -> float:
    """Round to 1 decimal place, matching core.py pattern."""
    return round(float(x), 1)


def apply_cooking_modifier(
    nutrients: Dict[str, float], cooking_method: str
) -> Dict[str, float]:
    """Apply cooking method modifier to nutrient values.

    Args:
        nutrients: Dict with keys: kcal, protein_g, carb_g, fat_g, fiber_g
        cooking_method: Cooking method name (case-insensitive, whitespace-stripped)

    Returns:
        New dict with modified nutrient values, rounded to 1 decimal.
        If cooking_method is unknown, returns unchanged nutrients dict.

    Example:
        >>> base = {'kcal': 100, 'protein_g': 20, 'carb_g': 10, 'fat_g': 5, 'fiber_g': 2}
        >>> fried = apply_cooking_modifier(base, 'fried')
        >>> fried['kcal']
        130.0
        >>> fried['fat_g']
        7.5
        >>> base['kcal']  # original unchanged
        100
    """
    # Normalize method lookup: case-insensitive, strip whitespace
    method = cooking_method.lower().strip()

    # Fail open: unknown method returns unchanged nutrients
    if method not in COOKING_MODIFIERS:
        return dict(nutrients)

    # Get modifier dict for this method
    modifiers = COOKING_MODIFIERS[method]

    # Apply modifier to each nutrient, return new dict
    result = {}
    for key, value in nutrients.items():
        multiplier = modifiers.get(key, 1.0)
        result[key] = _round(value * multiplier)

    return result


path: scripts/core.py
content: from typing import Dict, Any, List, Optional, Tuple
from parser import parse_meal_text  # pyright: ignore[reportImplicitRelativeImport]
from units import to_grams  # pyright: ignore[reportImplicitRelativeImport]
from cache import get as cache_get, set as cache_set  # pyright: ignore[reportImplicitRelativeImport]
from usda_fdc import (  # pyright: ignore[reportImplicitRelativeImport]
    search_food,
    pick_best_candidate,
    get_food_detail,
    extract_per_100g_nutrients,
    USDAError,
)
from spoonacular import (  # pyright: ignore[reportImplicitRelativeImport]
    search_ingredient,
    get_ingredient_info,
    extract_nutrients,
    SpoonacularError,
    pick_best_ingredient,
)
from translate import translate_food_name, is_chinese  # pyright: ignore[reportImplicitRelativeImport]
from config import SPOONACULAR_API_KEY  # pyright: ignore[reportImplicitRelativeImport]

try:
    from cooking import apply_cooking_modifier  # pyright: ignore[reportImplicitRelativeImport]
except ImportError:
    apply_cooking_modifier = None

QUERY_TTL = 24 * 3600
ITEM_TTL = 7 * 24 * 3600
SPOONACULAR_ITEM_TTL = 3600  # 1 hour (Spoonacular ToS)
USDA_ITEM_TTL = 7 * 24 * 3600  # 7 days

CROSS_VALIDATE_THRESHOLD = 0.30  # Flag when nutrients differ by >30%


def _round(x: float) -> float:
    return round(float(x), 1)


def _as_float(v: Any) -> float:
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def _cross_validate(
    spoon_nutrients: Dict[str, float], usda_nutrients: Dict[str, float], name: str
) -> List[str]:
    """Compare Spoonacular and USDA nutrients, return warnings if difference >30%."""
    warnings = []
    nutrient_labels = {
        "kcal": "热量",
        "protein_g": "蛋白质",
        "carb_g": "碳水化合物",
        "fat_g": "脂肪",
    }

    for key in ["kcal", "protein_g", "carb_g", "fat_g"]:
        spoon_val = spoon_nutrients.get(key, 0)
        usda_val = usda_nutrients.get(key, 0)
        if spoon_val == 0 or usda_val == 0:
            continue

        diff_ratio = abs(spoon_val - usda_val) / max(spoon_val, usda_val)
        if diff_ratio > CROSS_VALIDATE_THRESHOLD:
            label = nutrient_labels.get(key, key)
            unit = "kcal" if key == "kcal" else "g"
            warnings.append(
                f"Spoonacular 和 USDA 的{label}差异较大："
                f"Spoonacular {spoon_val}{unit} vs USDA {usda_val}{unit}，"
                f"建议确认食物是否一致"
            )

    return warnings






def _error(message: str, status: int = 500) -> Dict[str, Any]:
    return {"ok": False, "error": message, "status": status}


def _search_with_fallback(
    search_name: str, grams: float, search_hint: Optional[str] = None
) -> Tuple[Optional[Tuple[str, Dict[str, Any], str]], List[str]]:
    spoon_errors: List[str] = []
    search_terms = [search_name]
    if search_hint and search_hint.strip():
        hint = search_hint.strip()
        if hint.lower() != search_name.lower():
            search_terms.append(hint)

    if SPOONACULAR_API_KEY.strip():
        for term in search_terms:
            try:
                spoon_cands = search_ingredient(term)
                if not spoon_cands:
                    continue
                spoon_best = pick_best_ingredient(spoon_cands, term)
                if not spoon_best:
                    continue
                ingredient_id = int(spoon_best["id"])
                spoon_detail = get_ingredient_info(ingredient_id, grams, "g")
                spoon_nutrients = extract_nutrients(spoon_detail)
                return (
                    "spoonacular",
                    {
                        "best": spoon_best,
                        "ingredient_id": ingredient_id,
                        "nutrients": spoon_nutrients,
                    },
                    term,
                ), spoon_errors
            except SpoonacularError as e:
                spoon_errors.append(f"Spoonacular 查询失败（{term}）：{e}")
            except Exception as e:
                spoon_errors.append(f"Spoonacular 查询异常（{term}）：{e}")

    for term in search_terms:
        cands = search_food(term)
        best, conf = pick_best_candidate(cands, term)
        if not best:
            continue
        fdc_id = int(best["fdcId"])
        detail = get_food_detail(fdc_id)
        per100 = extract_per_100g_nutrients(detail)
        return (
            "usda_fdc",
            {"best": best, "fdc_id": fdc_id, "per100": per100, "confidence": conf},
            term,
        ), spoon_errors

    return None, spoon_errors


def lookup_food(
    name: str,
    qty: float,
    unit: str,
    search_hint: Optional[str] = None,
    cooking_method: Optional[str] = None,
    cross_validate: bool = False,
) -> Dict[str, Any]:
    grams, note = to_grams(name, qty, unit)
    if grams is None:
        return {
            "ok": False,
            "reason": note,
            "question": {
                "field": "amount_in_grams",
                "ask": f"「{name}」的份量我没法换算：你大概吃了多少克(g)或毫升(ml)？（例如 150g）",
            },
        }

    # Dictionary-based translation (acceleration cache for known Chinese names).
    # For unknown names, the main agent should use the Decomposer sub-agent (LLM).
    search_name = name
    translate_note = None
    if is_chinese(name):
        search_name, translate_note = translate_food_name(name)

    normalized_search_name = search_name.lower().strip()
    normalized_hint = search_hint.lower().strip() if search_hint else None
    rounded_grams = int(round(grams))
    cache_terms = [normalized_search_name]
    if normalized_hint and normalized_hint != normalized_search_name:
        cache_terms.append(normalized_hint)

    cache_keys = [
        f"item::{provider}::{term}::{rounded_grams}g"
        for provider in ["spoonacular", "usda"]
        for term in cache_terms
    ]
    for cache_key in cache_keys:
        cached = cache_get(cache_key)
        if cached:
            response_item = dict(cached)
            if cooking_method and apply_cooking_modifier:
                nutrient_base = {
                    "kcal": _as_float(response_item["kcal"]),
                    "protein_g": _as_float(response_item["protein_g"]),
                    "carb_g": _as_float(response_item["carb_g"]),
                    "fat_g": _as_float(response_item["fat_g"]),
                    "fiber_g": _as_float(response_item["fiber_g"]),
                }
                modified = apply_cooking_modifier(nutrient_base, cooking_method)
                response_item.update({k: _round(v) for k, v in modified.items()})
            return {"ok": True, **response_item}

    try:
        search_result, spoon_errors = _search_with_fallback(
            search_name, grams, search_hint
        )
        if not search_result:
            return {
                "ok": False,
                "reason": "not_found",
                "question": {
                    "field": "food_clarify",
                    "ask": f"我在USDA里没搜到「{name}」。你能换个更像“食材”的说法吗？（例如：鸡胸肉/熟米饭/全脂牛奶）",
                },
            }

        provider, data, actual_term_used = search_result
        notes = [n for n in [note, translate_note] if n]
        notes.extend(spoon_errors)

        if actual_term_used.lower().strip() != search_name.lower().strip():
            notes.append(
                f"原始搜索词 '{search_name}' 未找到，使用 '{actual_term_used}' 查询成功"
            )

        if provider == "spoonacular":
            spoon_best = data["best"]
            spoon_nutrients = data["nutrients"]
            ingredient_id = data["ingredient_id"]

            spoon_name = (spoon_best.get("name") or actual_term_used).strip()
            query_name = actual_term_used.lower().strip()
            spoon_name_lc = spoon_name.lower()
            if spoon_name_lc == query_name:
                spoon_conf = 0.95
            elif query_name and query_name in spoon_name_lc:
                spoon_conf = 0.80
            else:
                spoon_conf = 0.65

            if spoon_name_lc != query_name and len(notes) < 2:
                notes.append(
                    f"搜索到的食物是 '{spoon_name}'，和输入的 '{actual_term_used}' 不完全匹配，确认是否正确？"
                )

            item = {
                "name_raw": f"{name} {qty}{unit}",
                "name_std": spoon_name or name,
                "qty": qty,
                "unit": unit,
                "grams": _round(grams),
                "kcal": _round(spoon_nutrients["kcal"]),
                "protein_g": _round(spoon_nutrients["protein_g"]),
                "carb_g": _round(spoon_nutrients["carb_g"]),
                "fat_g": _round(spoon_nutrients["fat_g"]),
                "fiber_g": _round(spoon_nutrients["fiber_g"]),
                "source": "spoonacular",
                "fdcId": None,
                "spoonacularId": ingredient_id,
                "confidence": _round(spoon_conf),
                "notes": notes,
            }
            cache_key = f"item::spoonacular::{actual_term_used.lower().strip()}::{rounded_grams}g"
            cache_set(cache_key, item, SPOONACULAR_ITEM_TTL)
        else:
            best = data["best"]
            fdc_id = data["fdc_id"]
            per100 = data["per100"]
            conf = data["confidence"]
            factor = grams / 100.0
            item = {
                "name_raw": f"{name} {qty}{unit}",
                "name_std": best.get("description") or name,
                "qty": qty,
                "unit": unit,
                "grams": _round(grams),
                "kcal": _round(per100["kcal"] * factor),
                "protein_g": _round(per100["protein_g"] * factor),
                "carb_g": _round(per100["carb_g"] * factor),
                "fat_g": _round(per100["fat_g"] * factor),
                "fiber_g": _round(per100["fiber_g"] * factor),
                "source": "usda_fdc",
                "fdcId": fdc_id,
                "confidence": _round(conf),
                "notes": notes,
            }
            cache_key = (
                f"item::usda::{actual_term_used.lower().strip()}::{rounded_grams}g"
            )
            cache_set(cache_key, item, USDA_ITEM_TTL)

        # Cross-validation: query OTHER source for comparison if enabled
        if cross_validate and SPOONACULAR_API_KEY.strip():
            try:
                if provider == "spoonacular":
                    # Primary was Spoonacular, query USDA for comparison
                    from usda_fdc import search_food, pick_best_candidate, get_food_detail, extract_per_100g_nutrients  # pyright: ignore
                    usda_cands = search_food(actual_term_used)
                    usda_best, _ = pick_best_candidate(usda_cands, actual_term_used)
                    if usda_best:
                        usda_fdc_id = int(usda_best["fdcId"])
                        usda_detail = get_food_detail(usda_fdc_id)
                        usda_per100 = extract_per_100g_nutrients(usda_detail)
                        usda_factor = grams / 100.0
                        usda_nutrients = {
                            "kcal": usda_per100["kcal"] * usda_factor,
                            "protein_g": usda_per100["protein_g"] * usda_factor,
                            "carb_g": usda_per100["carb_g"] * usda_factor,
                            "fat_g": usda_per100["fat_g"] * usda_factor,
                        }
                        spoon_nutrients_cv = {
                            "kcal": _as_float(item["kcal"]),
                            "protein_g": _as_float(item["protein_g"]),
                            "carb_g": _as_float(item["carb_g"]),
                            "fat_g": _as_float(item["fat_g"]),
                        }
                        cross_warnings = _cross_validate(spoon_nutrients_cv, usda_nutrients, name)
                        if isinstance(item["notes"], list):
                            item["notes"].extend(cross_warnings)
                else:
                    # Primary was USDA, query Spoonacular for comparison
                    from spoonacular import search_ingredient, pick_best_ingredient, get_ingredient_info, extract_nutrients  # pyright: ignore
                    spoon_cands = search_ingredient(actual_term_used)
                    if spoon_cands:
                        spoon_best = pick_best_ingredient(spoon_cands, actual_term_used)
                        if spoon_best:
                            spoon_id = int(spoon_best["id"])
                            spoon_detail = get_ingredient_info(spoon_id, grams, "g")
                            spoon_nutrients_full = extract_nutrients(spoon_detail)
                            spoon_nutrients_cv = {
                                "kcal": spoon_nutrients_full["kcal"],
                                "protein_g": spoon_nutrients_full["protein_g"],
                                "carb_g": spoon_nutrients_full["carb_g"],
                                "fat_g": spoon_nutrients_full["fat_g"],
                            }
                            usda_nutrients = {
                                "kcal": _as_float(item["kcal"]),
                                "protein_g": _as_float(item["protein_g"]),
                                "carb_g": _as_float(item["carb_g"]),
                                "fat_g": _as_float(item["fat_g"]),
                            }
                            cross_warnings = _cross_validate(spoon_nutrients_cv, usda_nutrients, name)
                            if isinstance(item["notes"], list):
                                item["notes"].extend(cross_warnings)
            except Exception:
                pass  # Cross-validation is optional, don't fail if it errors

        response_item = dict(item)
        if cooking_method and apply_cooking_modifier:
            nutrient_base = {
                "kcal": _as_float(response_item["kcal"]),
                "protein_g": _as_float(response_item["protein_g"]),
                "carb_g": _as_float(response_item["carb_g"]),
                "fat_g": _as_float(response_item["fat_g"]),
                "fiber_g": _as_float(response_item["fiber_g"]),
            }
            modified = apply_cooking_modifier(nutrient_base, cooking_method)
            response_item.update({k: _round(v) for k, v in modified.items()})

        return {"ok": True, **response_item}
    except USDAError as e:
        return _error(str(e), status=e.status or 500)
    except Exception as e:
        return _error(f"USDA 查询失败：{e}")


def lookup_meal(text: str, meal_type: str = "unknown") -> Dict[str, Any]:
    qkey = f"meal::{meal_type}::{text.strip().lower()}"
    cached = cache_get(qkey)
    if cached:
        return cached

    parsed = parse_meal_text(text)
    items: List[Dict[str, Any]] = []
    questions: List[Dict[str, Any]] = []

    for p in parsed:
        if len(p.name) <= 1:
            continue

        if p.qty is None or p.unit is None:
            if len(questions) < 2:
                questions.append(
                    {
                        "field": "missing_amount",
                        "ask": f"「{p.name}」你吃了多少？给我一个数字+单位（如 200g / 250ml / 1碗 / 2个）",
                    }
                )
            continue

        r = lookup_food(p.name, p.qty, p.unit)
        if not r.get("ok"):
            if "question" in r and len(questions) < 2:
                questions.append(r["question"])
            elif "error" in r and len(questions) < 2:
                questions.append({"field": "api_error", "ask": r["error"]})
            continue

        items.append(r)

    totals = {
        "kcal": _round(sum(i["kcal"] for i in items)),
        "protein_g": _round(sum(i["protein_g"] for i in items)),
        "carb_g": _round(sum(i["carb_g"] for i in items)),
        "fat_g": _round(sum(i["fat_g"] for i in items)),
        "fiber_g": _round(sum(i["fiber_g"] for i in items)),
    }

    out = {
        "type": "meal_nutrition",
        "meal_type": meal_type,
        "items": items,
        "totals": totals,
        "questions": questions,
    }

    if items:
        cache_set(qkey, out, QUERY_TTL)
    return out


path: scripts/parser.py
content: from dataclasses import dataclass
from typing import List, Optional
import re
from units import AMOUNT_RE, normalize_unit

SPLIT_RE = re.compile(r"[+\n,，;；]|(?:\s+and\s+)|(?:\s*\+\s*)")

@dataclass
class ParsedItem:
    name: str
    qty: Optional[float] = None
    unit: Optional[str] = None

def split_items(text: str) -> List[str]:
    parts = [p.strip() for p in SPLIT_RE.split(text) if p and p.strip()]
    return parts

def parse_one(part: str) -> ParsedItem:
    m = AMOUNT_RE.search(part)
    if not m:
        return ParsedItem(name=part.strip())

    qty = float(m.group("qty"))
    unit = normalize_unit(m.group("unit"))
    name = (part[:m.start()] + part[m.end():]).strip()
    if not name:
        name = part[:m.start()].strip() or part[m.end():].strip() or part.strip()
    return ParsedItem(name=name, qty=qty, unit=unit)

def parse_meal_text(text: str) -> List[ParsedItem]:
    return [parse_one(p) for p in split_items(text)]


path: scripts/spoonacular.py
content: import requests
from typing import Dict, Any, List, Optional
from config import (
    SPOONACULAR_API_KEY,
    SPOONACULAR_BASE,
    HTTP_TIMEOUT_SEC,
    SPOONACULAR_SEARCH_LIMIT,
)


class SpoonacularError(RuntimeError):
    def __init__(self, message: str, status: Optional[int] = None):
        super().__init__(message)
        self.status = status


def _require_key():
    if not SPOONACULAR_API_KEY:
        raise SpoonacularError("Missing SPOONACULAR_API_KEY env var", status=401)


def _handle_response(r: requests.Response):
    if r.status_code == 401:
        raise SpoonacularError("Spoonacular API key 无效或缺失（401）", status=401)
    if r.status_code == 402:
        raise SpoonacularError("Spoonacular API 配额超限（402）", status=402)
    if r.status_code == 403:
        raise SpoonacularError("Spoonacular API 禁止访问（403）", status=403)
    if r.status_code == 429:
        raise SpoonacularError("Spoonacular API 请求频率超限（429）", status=429)
    if r.status_code >= 500:
        raise SpoonacularError(
            f"Spoonacular 服务器错误（{r.status_code}）", status=r.status_code
        )
    r.raise_for_status()


def search_ingredient(name: str) -> List[Dict[str, Any]]:
    _require_key()
    url = f"{SPOONACULAR_BASE}/food/ingredients/search"
    params = {
        "query": name,
        "number": SPOONACULAR_SEARCH_LIMIT,
        "apiKey": SPOONACULAR_API_KEY,
    }
    r = requests.get(url, params=params, timeout=HTTP_TIMEOUT_SEC)
    _handle_response(r)
    data = r.json()

    # Handle defensive response format: list, dict with results, or empty
    if isinstance(data, list):
        return data
    elif isinstance(data, dict) and "results" in data:
        return data["results"]
    else:
        return []


def pick_best_ingredient(
    candidates: List[Dict[str, Any]], query: str
) -> Optional[Dict[str, Any]]:
    if not candidates:
        return None

    q = query.lower().strip()

    # Exact name match
    for c in candidates:
        name = (c.get("name") or "").lower()
        if name == q:
            return c

    # Substring match
    for c in candidates:
        name = (c.get("name") or "").lower()
        if q in name:
            return c

    # First result fallback
    return candidates[0]


def get_ingredient_info(ingredient_id: int, amount: float, unit: str) -> Dict[str, Any]:
    _require_key()
    url = f"{SPOONACULAR_BASE}/food/ingredients/{ingredient_id}/information"
    params = {
        "amount": amount,
        "unit": unit,
        "apiKey": SPOONACULAR_API_KEY,
    }
    r = requests.get(url, params=params, timeout=HTTP_TIMEOUT_SEC)
    _handle_response(r)
    return r.json()


def extract_nutrients(info: Dict[str, Any]) -> Dict[str, float]:
    out = {
        "kcal": 0.0,
        "protein_g": 0.0,
        "carb_g": 0.0,
        "fat_g": 0.0,
        "fiber_g": 0.0,
    }

    nutrients = info.get("nutrition", {}).get("nutrients", []) or []

    name_map = {
        "Calories": "kcal",
        "Protein": "protein_g",
        "Carbohydrates": "carb_g",
        "Fat": "fat_g",
        "Fiber": "fiber_g",
    }

    for n in nutrients:
        nutrient_name = n.get("name")
        amount = n.get("amount")
        if nutrient_name in name_map and amount is not None:
            out[name_map[nutrient_name]] = float(amount)

    return out


path: scripts/translate.py
content: """Chinese → English food name translation acceleration cache.

This module provides a built-in dictionary (~200 entries) as an
**acceleration layer** for the translation workflow. It is NOT the
primary translation mechanism.

Primary translation path:
  Non-English input → Decomposer Sub-agent (LLM) → English food names

This dictionary serves as:
  - Fast path for `lookup_food` when called directly with known Chinese names
  - Fallback inside Python code when the LLM sub-agent path is not available
  - Acceleration cache to skip LLM calls for common foods

If no dictionary match is found, `translate_food_name` returns the original
name unchanged. The caller (main agent) should route through the Decomposer
sub-agent for proper LLM-based translation.
"""

import re
from typing import Optional, Tuple

# --- Detection -----------------------------------------------------------

_CJK_RE = re.compile(r"[\u4e00-\u9fff]")


def is_chinese(name: str) -> bool:
    """Return True if name contains any CJK characters."""
    return bool(_CJK_RE.search(name))


# --- Dictionary ----------------------------------------------------------
# Keys: Chinese food name (or substring).
# Values: English name that USDA FDC can match.
# Ordered roughly by category for maintainability.

FOOD_DICT: dict[str, str] = {
    # ── 肉类 Meat ──
    "鸡胸肉": "chicken breast",
    "鸡腿": "chicken thigh",
    "鸡翅": "chicken wing",
    "鸡肉": "chicken meat",
    "鸡": "chicken",
    "鸭肉": "duck meat",
    "鸭": "duck",
    "猪肉": "pork",
    "猪排": "pork chop",
    "猪里脊": "pork tenderloin",
    "五花肉": "pork belly",
    "猪蹄": "pork feet",
    "牛肉": "beef",
    "牛排": "beef steak",
    "牛腩": "beef brisket",
    "牛腱": "beef shank",
    "羊肉": "lamb",
    "羊排": "lamb chop",
    "培根": "bacon",
    "火腿": "ham",
    "香肠": "sausage",
    "腊肉": "cured pork belly",
    # ── 海鲜 Seafood ──
    "三文鱼": "salmon",
    "鲑鱼": "salmon",
    "金枪鱼": "tuna",
    "鳕鱼": "cod",
    "鲈鱼": "sea bass",
    "带鱼": "hairtail fish",
    "鱼": "fish",
    "虾": "shrimp",
    "虾仁": "shrimp",
    "大虾": "shrimp",
    "龙虾": "lobster",
    "螃蟹": "crab",
    "蟹": "crab",
    "鱿鱼": "squid",
    "墨鱼": "cuttlefish",
    "扇贝": "scallop",
    "蛤蜊": "clam",
    "牡蛎": "oyster",
    "生蚝": "oyster",
    # ── 蛋奶 Eggs & Dairy ──
    "鸡蛋": "egg",
    "蛋": "egg",
    "鸭蛋": "duck egg",
    "鹌鹑蛋": "quail egg",
    "牛奶": "whole milk",
    "脱脂牛奶": "skim milk",
    "全脂牛奶": "whole milk",
    "酸奶": "yogurt",
    "奶酪": "cheese",
    "芝士": "cheese",
    "黄油": "butter",
    "奶油": "cream",
    # ── 主食 Staples ──
    "米饭": "cooked white rice",
    "白米饭": "cooked white rice",
    "糙米饭": "cooked brown rice",
    "糙米": "brown rice",
    "粥": "rice porridge",
    "白粥": "rice porridge",
    "面条": "noodles cooked",
    "面": "noodles cooked",
    "意面": "spaghetti cooked",
    "意大利面": "spaghetti cooked",
    "挂面": "noodles cooked",
    "馒头": "steamed bun",
    "花卷": "steamed roll",
    "包子": "steamed stuffed bun",
    "饺子": "dumpling",
    "馄饨": "wonton",
    "面包": "bread",
    "吐司": "toast bread",
    "全麦面包": "whole wheat bread",
    "燕麦": "oatmeal",
    "燕麦片": "oatmeal",
    "麦片": "cereal",
    "玉米": "corn",
    "红薯": "sweet potato",
    "地瓜": "sweet potato",
    "紫薯": "purple sweet potato",
    "土豆": "potato",
    "马铃薯": "potato",
    "芋头": "taro",
    "山药": "yam",
    "年糕": "rice cake",
    "粽子": "rice dumpling",
    "烧饼": "sesame flatbread",
    "油条": "fried dough stick",
    # ── 蔬菜 Vegetables ──
    "西兰花": "broccoli",
    "花菜": "cauliflower",
    "菜花": "cauliflower",
    "白菜": "chinese cabbage",
    "大白菜": "chinese cabbage",
    "小白菜": "bok choy",
    "青菜": "chinese greens",
    "菠菜": "spinach",
    "生菜": "lettuce",
    "芹菜": "celery",
    "韭菜": "chinese chives",
    "葱": "green onion",
    "洋葱": "onion",
    "蒜": "garlic",
    "大蒜": "garlic",
    "姜": "ginger",
    "辣椒": "chili pepper",
    "青椒": "green bell pepper",
    "红椒": "red bell pepper",
    "甜椒": "bell pepper",
    "茄子": "eggplant",
    "西红柿": "tomato",
    "番茄": "tomato",
    "黄瓜": "cucumber",
    "胡萝卜": "carrot",
    "白萝卜": "daikon radish",
    "萝卜": "radish",
    "南瓜": "pumpkin",
    "冬瓜": "winter melon",
    "苦瓜": "bitter melon",
    "丝瓜": "loofah",
    "西葫芦": "zucchini",
    "豆芽": "bean sprouts",
    "豆角": "green beans",
    "四季豆": "green beans",
    "毛豆": "edamame",
    "豌豆": "green peas",
    "荷兰豆": "snow peas",
    "秋葵": "okra",
    "芦笋": "asparagus",
    "蘑菇": "mushroom",
    "香菇": "shiitake mushroom",
    "金针菇": "enoki mushroom",
    "木耳": "wood ear mushroom",
    "海带": "kelp",
    "紫菜": "seaweed",
    "莲藕": "lotus root",
    "竹笋": "bamboo shoot",
    # ── 水果 Fruits ──
    "苹果": "apple",
    "香蕉": "banana",
    "橙子": "orange",
    "橘子": "tangerine",
    "柚子": "pomelo",
    "柠檬": "lemon",
    "葡萄": "grape",
    "草莓": "strawberry",
    "蓝莓": "blueberry",
    "西瓜": "watermelon",
    "哈密瓜": "cantaloupe",
    "芒果": "mango",
    "菠萝": "pineapple",
    "凤梨": "pineapple",
    "猕猴桃": "kiwi",
    "奇异果": "kiwi",
    "桃子": "peach",
    "梨": "pear",
    "樱桃": "cherry",
    "荔枝": "lychee",
    "龙眼": "longan",
    "石榴": "pomegranate",
    "火龙果": "dragon fruit",
    "百香果": "passion fruit",
    "椰子": "coconut",
    "榴莲": "durian",
    "木瓜": "papaya",
    "杏": "apricot",
    "枣": "jujube",
    "红枣": "jujube",
    "柿子": "persimmon",
    "李子": "plum",
    "无花果": "fig",
    "牛油果": "avocado",
    # ── 豆制品 Soy & Legumes ──
    "豆腐": "tofu",
    "嫩豆腐": "silken tofu",
    "老豆腐": "firm tofu",
    "豆浆": "soy milk",
    "豆干": "dried tofu",
    "腐竹": "tofu skin",
    "黄豆": "soybean",
    "黑豆": "black bean",
    "红豆": "adzuki bean",
    "绿豆": "mung bean",
    "花生": "peanut",
    # ── 坚果 Nuts & Seeds ──
    "核桃": "walnut",
    "杏仁": "almond",
    "腰果": "cashew",
    "开心果": "pistachio",
    "瓜子": "sunflower seeds",
    "芝麻": "sesame seeds",
    "松子": "pine nut",
    "栗子": "chestnut",
    "板栗": "chestnut",
    # ── 饮品 Beverages ──
    "咖啡": "coffee",
    "绿茶": "green tea",
    "红茶": "black tea",
    "茶": "tea",
    "豆奶": "soy milk",
    "椰奶": "coconut milk",
    "果汁": "fruit juice",
    "橙汁": "orange juice",
    "苹果汁": "apple juice",
    "可乐": "cola",
    "啤酒": "beer",
    "红酒": "red wine",
    "白酒": "baijiu",
    # ── 调味/油脂 Oils & Condiments ──
    "橄榄油": "olive oil",
    "花生油": "peanut oil",
    "菜籽油": "canola oil",
    "植物油": "vegetable oil",
    "酱油": "soy sauce",
    "醋": "vinegar",
    "蜂蜜": "honey",
    "白糖": "white sugar",
    "红糖": "brown sugar",
    "冰糖": "rock sugar",
    # ── 零食/加工 Snacks & Processed ──
    "薯片": "potato chips",
    "饼干": "crackers",
    "巧克力": "chocolate",
    "冰淇淋": "ice cream",
    "蛋糕": "cake",
    "月饼": "mooncake",
    "方便面": "instant noodles",
    "泡面": "instant noodles",
    "午餐肉": "luncheon meat",
    "豆腐乳": "fermented tofu",
    "皮蛋": "preserved egg",
    "咸鸭蛋": "salted duck egg",
}

# Sorted by key length descending so longer (more specific) matches win.
_SORTED_KEYS = sorted(FOOD_DICT.keys(), key=len, reverse=True)


# --- Translation ---------------------------------------------------------


def translate_food_name(name: str) -> Tuple[str, Optional[str]]:
    """Translate a Chinese food name to English for USDA queries.

    Returns (english_name, note) where:
    - If a dictionary match is found: (english_name, "翻译：X → Y")
    - If no match and name is Chinese: (original_name, None)
      Caller should fall back to LLM sub-agent or ask user.
    - If name is already English: (original_name, None)
    """
    if not is_chinese(name):
        return name, None

    stripped = name.strip()

    # Exact match first
    if stripped in FOOD_DICT:
        en = FOOD_DICT[stripped]
        return en, f"翻译：{stripped} → {en}"

    # Substring match (longest match wins)
    for zh_key in _SORTED_KEYS:
        if zh_key in stripped:
            en = FOOD_DICT[zh_key]
            return en, f"翻译：{stripped}（匹配 {zh_key}）→ {en}"

    # No match — return original; caller decides fallback
    return stripped, None


path: scripts/units.py
content: from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Portion:
    grams: float
    note: str

DEFAULT_PORTIONS = {
    "米饭": {
        "碗": Portion(200, "默认：熟米饭 1碗≈200g"),
        "小碗": Portion(150, "默认：熟米饭 小碗≈150g"),
        "大碗": Portion(260, "默认：熟米饭 大碗≈260g"),
    },
    "面条": {
        "碗": Portion(220, "默认：熟面条 1碗≈220g"),
    },
    "燕麦": {
        "勺": Portion(10, "默认：燕麦 1汤勺≈10g"),
    },
    "鸡蛋": {
        "个": Portion(50, "默认：鸡蛋 1个可食部分≈50g"),
    },
    "鸡胸肉": {
        "掌": Portion(120, "默认：鸡胸肉 1掌≈120g"),
    },
    "牛奶": {
        "杯": Portion(250, "默认：牛奶 1杯≈250ml≈250g"),
    },
    "香蕉": {
        "根": Portion(120, "默认：香蕉 1根可食部分≈120g"),
    },
}

UNIT_ALIASES = {
    "克": "g", "g": "g",
    "千克": "kg", "kg": "kg",
    "毫升": "ml", "ml": "ml",
    "升": "l", "l": "l",
    "碗": "碗", "小碗": "小碗", "大碗": "大碗",
    "杯": "杯",
    "勺": "勺", "汤勺": "勺",
    "个": "个", "颗": "个",
    "根": "根",
    "掌": "掌",
}

def normalize_unit(u: str) -> str:
    u = u.strip().lower()
    return UNIT_ALIASES.get(u, u)

def to_grams(name: str, qty: float, unit: str):
    unit = normalize_unit(unit)

    if unit == "g":
        return qty, "单位已是g"
    if unit == "kg":
        return qty * 1000.0, "kg→g"
    if unit == "ml":
        return qty * 1.0, "ml≈g（近似）"
    if unit == "l":
        return qty * 1000.0, "l→ml≈g（近似）"

    for key, portion_map in DEFAULT_PORTIONS.items():
        if key in name:
            p = portion_map.get(unit)
            if p:
                return qty * p.grams, p.note

    return None, f"无法将单位'{unit}'自动换算为g"

AMOUNT_RE = re.compile(r"(?P<qty>\d+(\.\d+)?)\s*(?P<unit>[a-zA-Z\u4e00-\u9fff]+)")


path: scripts/usda_fdc.py
content: import requests
from typing import Dict, Any, List, Optional, Tuple
from config import USDA_API_KEY, FDC_BASE, HTTP_TIMEOUT_SEC, SEARCH_PAGE_SIZE, PREFERRED_DATA_TYPES

NUTRIENT_IDS = {
    "kcal": 1008,
    "protein_g": 1003,
    "fat_g": 1004,
    "carb_g": 1005,
    "fiber_g": 1079,
}

class USDAError(RuntimeError):
    def __init__(self, message: str, status: Optional[int] = None):
        super().__init__(message)
        self.status = status

def _require_key():
    if not USDA_API_KEY:
        raise USDAError("Missing USDA_FDC_API_KEY env var", status=401)

def _handle_response(r: requests.Response):
    if r.status_code == 401:
        raise USDAError("USDA API key 无效或缺失（401）", status=401)
    if r.status_code == 403:
        raise USDAError("USDA API 权限不足（403）", status=403)
    if r.status_code == 429:
        raise USDAError("USDA API 触发限速（429），请稍后再试", status=429)
    if r.status_code >= 500:
        raise USDAError(f"USDA API 服务器错误（{r.status_code}）", status=r.status_code)
    r.raise_for_status()

def search_food(query: str) -> List[Dict[str, Any]]:
    _require_key()
    url = f"{FDC_BASE}/foods/search"
    payload = {
        "query": query,
        "pageSize": SEARCH_PAGE_SIZE,
        "dataType": PREFERRED_DATA_TYPES,
    }
    r = requests.post(url, params={"api_key": USDA_API_KEY}, json=payload, timeout=HTTP_TIMEOUT_SEC)
    _handle_response(r)
    data = r.json()
    return data.get("foods", []) or []

def get_food_detail(fdc_id: int) -> Dict[str, Any]:
    _require_key()
    url = f"{FDC_BASE}/food/{fdc_id}"
    r = requests.get(url, params={"api_key": USDA_API_KEY}, timeout=HTTP_TIMEOUT_SEC)
    _handle_response(r)
    return r.json()

def extract_per_100g_nutrients(food_detail: Dict[str, Any]) -> Dict[str, float]:
    out = {k: 0.0 for k in NUTRIENT_IDS.keys()}
    nutrients = food_detail.get("foodNutrients", []) or []
    for n in nutrients:
        nid = n.get("nutrient", {}).get("id") or n.get("nutrientId")
        amount = n.get("amount")
        if nid is None or amount is None:
            continue
        for key, target_id in NUTRIENT_IDS.items():
            if nid == target_id:
                out[key] = float(amount)
    return out

def pick_best_candidate(cands: List[Dict[str, Any]], query: str) -> Tuple[Optional[Dict[str, Any]], float]:
    if not cands:
        return None, 0.0

    def dt_rank(dt: str) -> int:
        try:
            return PREFERRED_DATA_TYPES.index(dt)
        except ValueError:
            return len(PREFERRED_DATA_TYPES)

    q = query.lower().strip()
    scored = []
    for c in cands:
        desc = (c.get("description") or "").lower()
        dt = c.get("dataType") or ""
        score = 0.0
        score += max(0, 10 - 2 * dt_rank(dt))
        if q and q in desc:
            score += 6
        if desc.startswith(q):
            score += 2
        if c.get("fdcId"):
            score += 1
        scored.append((score, c))

    scored.sort(key=lambda x: x[0], reverse=True)
    best = scored[0][1]
    top = scored[0][0]
    second = scored[1][0] if len(scored) > 1 else top - 4
    gap = max(0.0, top - second)
    conf = min(1.0, 0.4 + 0.05 * top + 0.06 * gap)
    return best, conf


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)