| name | diet-logging-claw |
| description | 饮食记录、饮食打卡、记录饮食、记一下吃了什么、吃了XX。当用户用文字描述饮食内容时使用,解析食物/数量/单位,查询数据库匹配热量后自动打卡。 |
| metadata | {"pha":{"emoji":"🍱","category":"health-coaching-cli","tags":["cli","diet","food","logging","calories"],"requires":{"tools":"[Truncated]","skills":"[Truncated]"}}} |
饮食录入技能(CLI 版)
处理用户的自然语言饮食录入,提取食物信息,通过 CLI 查询数据库匹配卡路里,自动打卡写入,并给出简短点评。
执行原则(优先于一切步骤)
调用工具,不问用户。 缺失信息通过工具和推断解决,不通过提问解决:
| 缺失信息 | 解决方式 | 禁止做法 |
|---|
| 餐别 | 用户有说 → 直接取值;用户没说 → 按当前时间推断 | ❌ 问用户"这是哪顿饭" |
| 数量/单位 | 第二步规则推断 | ❌ 问用户"吃了多少" |
必须问用户的场景:
query_and_format_food 返回多个结果且用户输入的食物名不能精确锁定唯一选项(第五步A)
第零步:输入校验(优先执行,任何其他步骤之前)
对原始文本按优先级逐一判断,命中即停止,直接输出对应话术:
校验规则(优先级从高到低)
| 类型 | 判定逻辑 | 话术 |
|---|
| INFORMATION | 文本中找不到任何有效食物名词("一个"、"这个"、"记录一下"等占位符不算食物) | 话术·信息缺失 |
| TIME | 明确出现非当日日期词(昨天/前天/明天/具体旧日期);注意:早上/中午/晚上/刚刚默认当天,不触发 | 话术·时间异常 |
| UNIT | 单位违反物理常识(如"一公里米饭"、"50瓦特牛奶");热量单位(大卡/千焦)视为正常 | 话术·单位异常 |
| FIX | 以"不对"/"不是"/"改成"/"换成"/"其实是"/"记错了"等修正词开头,但当前消息中没有可被修改的食物记录(孤立修改指令) | 话术·孤立修改 |
| NA | 以上均未命中,进入第一步 | — |
注意:含有 INFORMATION 时,无论是否有时间词,强制输出话术·信息缺失,不继续判断 TIME。
话术库
话术·信息缺失(INFORMATION):
"你想记录什么食物呢?告诉我名称就好,比如'早上吃了一个包子'。"
话术·时间异常(TIME):
"我目前只支持记录今天的饮食哦。如果你想记今天吃的,告诉我就好~"
话术·单位异常(UNIT):
"这个单位好像有点奇怪,你是说[食物]吃了多少[合理单位]吗?比如一碗、一个、200克这样?"
话术·孤立修改(FIX):
"你想修改哪条记录呢?告诉我原来记了什么、要改成什么就行,比如'刚才记的包子,其实是三明治'。"
话术A(非食物生理状态):
"听起来你描述的是身体状态,不是饮食记录——我没法把这个记到你的饮食日志里。如果身体不舒服欢迎告诉我详情;如果想记今天吃了什么,告诉我食物名称就行。"
话术B(荒诞不可食用):
"哈哈,[用户说的内容] 确实不在我的食物数据库里……你今天实际上吃了什么?告诉我,我帮你记上。"
话术C(饮食咨询转接):
"这个问题更像是营养咨询——我来帮你查一下 [食物] 的热量信息:[直接给出简要信息]。如果你今天吃了它,告诉我数量,我可以帮你录入。"
第一步:意图分类与槽位提取
1.1 意图分类(第零步通过后执行)
| 意图类型 | 判断标准 | 处理方式 |
|---|
| ✅ 饮食录入 | 包含真实可食用食物 + 进食动词(吃/喝/尝/吞/含) | 进入槽位提取(第1.2节) |
| 🚫 非食物生理状态 | 描述身体状态/症状,非饮食行为("来了姨妈"、"拉肚子"、"发烧") | 话术A |
| 🚫 荒诞不可食用 | 明显不是食物("吃了拖拉机"、"喝了汽油") | 话术B |
| 🔀 饮食咨询 | 问某食物热量或营养,但没有明确录入意图 | 话术C |
| ✅ 已含热量 | 文字中含有明确卡路里数字("吃了一个200卡的包子") | 录入流程,打卡时 kilocalorie 优先使用用户给出的值 |
1.2 槽位提取规则(饮食录入意图时执行)
从原始文本中提取以下6个槽位,若无法从原文确定,严格填 NA,禁止推测:
| 槽位 | 说明 | NA 条件 |
|---|
meal | 餐别(6种之一) | 原文无时间/餐别信息 |
food | 食物名称,不含数量和单位 | — |
quantity | 数字或小数(中文数字转阿拉伯数字) | 原文无数量 |
unit | 单位(个/碗/克/ml…) | 可根据食物类型推测合理值 |
energy | 热量数值 | 原文无具体热量数字 |
energy_unit | 热量单位(如"大卡"/"千焦") | 无热量时同为 NA |
1.2.1 餐别提取规则
只从用户原文提取,未提及餐别/时间词 → meal = NA,禁止凭食物种类或系统时间猜测:
| 关键词 | 餐别 |
|---|
| 早上/早餐/早饭 | 早餐 |
| 上午茶/上午(非早餐语境) | 上午加餐 |
| 中午/午餐/午饭 | 午餐 |
| 下午/下午茶 | 下午加餐 |
| 晚上/晚餐/晚饭 | 晚餐 |
| 宵夜/夜宵/睡前/半夜 | 晚上加餐 |
meal=NA 时:按当前时间推断:
- 05:00–10:00 → 早餐
- 10:00–11:30 → 上午加餐
- 11:30–14:00 → 午餐
- 14:00–17:00 → 下午加餐
- 17:00–20:30 → 晚餐
- 20:30–05:00 → 晚上加餐
禁止以任何理由(食物内容、用餐习惯等)修改按时间算出的餐别
多餐别处理:用户输入涉及 N 个餐别 → 提取 N 组槽位,每组对应该餐别的所有食物。
1.2.2 食物名称提取规则
food 中不能出现数量和单位("一个包子" → food=包子, quantity=1, unit=个)
- 品牌/馅料/甜度一并提取:蒙牛牛奶 → food=蒙牛牛奶;七分糖奶茶 → food=七分糖奶茶
- 数量形容词不带入 food:"一把豆芽" → food=豆芽
- 消除歧义:若有疑似语音识别错误或重复字,结合语境修正(如"米米米糕" → 米糕)
- 只有单位后无食物时,food=NA
1.2.3 数量提取规则
- 中文数字 → 阿拉伯数字:"两" → 2,"半" → 0.5,"一" → 1
- 多食物只有一个数量时,分配给原文中离数字最近的食物,其余=NA
- 冗余前置数字处理:当数字表达出现冗余前缀且无法构成明确量级时,只取后面实际数值
- "一五百克" → 500,"三五十毫升" → 50
- 多数量优先精确单位:一份黄焖鸡饭400克 → quantity=400,unit=克(舍弃"一份")
- 范围表述取较大值:3-4个 → quantity=4
1.2.4 单位提取规则
- 每个食物对应其单位,多食物分别列举
- 口语单位也可提取:"几口" → unit=口,quantity=3(估算);"一点" → unit=点,quantity=1
- 原文无单位时,可根据食物类型给出合理推测值(如苹果→个,牛奶→杯)
1.2.5 热量提取规则
- 只对原文有具体数值才提取("大概200卡" → energy=200)
- 单位直接提取原文文字,无需换算
- 安全红线:energy > 50000 时视为异常,触发话术B(进食过多)
- 无具体数值的热量描述不提取(如"高热量食物")
1.3 修改逻辑(原文同时含有 food_extract 和 food_fix 时)
判定有修改意图的标志词:不对/不是/其实是/记错了/改成/换成/改一下/修改
提取步骤:
- 先提取原始记录槽位(
food_extract)
- 再提取修改目标(
food_fix)
修改规则
规则A:food_fix 数量 >= 2 → 完全覆盖,以修改后食物为准,删除所有 food_extract
规则B:单个 food_fix 且明确
- B1:food_fix 属于 food_extract(同类修改) → 就近修改 quantity/unit
- B2:food_fix 不属于 food_extract(跨类替换) → 强制删除最近的 food_extract,新增 food_fix
规则C:无 food_fix,只有 quantity/unit 修改 → 就近替换
规则D:其他模糊情况 → 根据上下文判断
第二步:数据补全(录入专家推理)
槽位提取完成后,对数量/单位/热量缺失的食物补全合理值:
- 缺少数量:根据食物类型补全("一个苹果" → 默认1个;"一碗米饭" → 默认200g)
- 缺少热量:从食物库匹配结果中取(
units 字段已预计算),无法从库获取时走第五步A流程
- 用户指定热量时:以用户热量值为准,反向推算重量(如"200卡苹果" → 200/0.52=385g)
- 逻辑自洽检查:确保"重量 x 热量密度 = 总热量",矛盾时以用户显式声明为优先
第三步:查询食物数据库
意图确认为饮食录入后,并行对所有食物调用(多个食物同时搜索,不要一个一个来):
query_and_format_food --food-name "<food槽位值>"
返回结构:
not_found: true → 该食物不在数据库,无法打卡(进入第五步A,提示用户换个名称重新搜索)
not_found: false → 有结果,每项结构:
{
idx: number, // 1-based 序号
foodId: string,
name: string,
kcal_per_100g: number,
protein_per_100g: number,
fat_per_100g: number,
carbs_per_100g: number,
units: [{ unit, weight_g, kcal, protein, fat, carbs }] // 每单位预计算值
}
有了此结构,可直接用选中项的 units[i] 乘以 quantity 构建 Output3 的全部字段。
第四步:匹配确认
收到 query_and_format_food 结果后,用语义理解判断匹配质量。
强匹配(直接进入第五步B自动打卡)
用户说的食物和返回结果本质上是同一个东西,大多数人不会区分。靠常识判断,不是规则。
举例:
- "古老肉" → 菠萝古老肉 → 强匹配
- "米饭" → 白米饭 → 强匹配
- "鸡蛋" → 水煮鸡蛋 → 强匹配
弱匹配(进入第五步A,让用户选)
用户说的食物和返回结果存在实质差异,不同选项之间营养/口味/食材不同。
举例:
- "包子" → [猪肉包子, 素菜包子, 豆沙包] → 弱匹配
- "粥" → [小米粥, 大米粥] → 弱匹配
无匹配
not_found: true → 提示用户换个名称重新搜索
第五步A:用户选择(弱匹配/无匹配时,最多 3 轮)
多食物场景:所有食物并行搜索完毕后,强匹配的先放一边,把所有弱匹配/无匹配的一次性展示给用户。
展示格式:
当 not_found: true:
没找到「[food]」,无法打卡。换个名称试试,或者告诉我更具体的叫法?
当有选项但弱匹配:
没找到完全匹配「[food]」的记录,以下是最接近的选项,请选择序号或重新描述:
1. 猪肉包子 — 1个 ≈80g / 200kcal
2. 素菜包 — 1个 ≈70g / 140kcal
3. 豆沙包 — 1个 ≈65g / 190kcal
也可以直接告诉我你吃的食物名称,我重新查询。
用户回复解析规则(按优先级)
- 序号/位置锚定 — 正序/倒序均支持,越界则提示重新选择
- 热量锚定 — "456卡的那个" → 精确匹配
- 名称语义匹配 — 正向匹配 + 反向排斥(食材/口味/做法互斥时禁止匹配)
- 无关兜底 — 闲聊或无关 → 重新提示选择
重试机制(最多 3 轮)
| 用户输入 | 处理方式 | 计入重试 |
|---|
| 有效序号(1-N) | 确认,进入第五步B | 否 |
| 描述了另一种食物 | 重新调用 query_and_format_food | +1 |
| 无效输入/序号越界 | 提示重新输入 | 否 |
| 已用完 3 次 | 话术D | — |
话术D:
"找了好几次都没能精确匹配,你方便告诉我这个食物大概多少卡吗?或者说一下大概重量,我来帮你估算。"
第五步B:构建 Output3 并自动打卡(多食物并行)
Output3 构建(从 query_and_format_food 选中项计算)
selectedUnit = query_and_format_food 结果中与 unit槽位 匹配的单位项(默认取 units[0])
intakeWeight = selectedUnit.weight_g x quantity
kilocalorie = selectedUnit.kcal x quantity
protein = selectedUnit.protein x quantity
fat = selectedUnit.fat x quantity
carbohydrate = selectedUnit.carbs x quantity (注意字段名转换: carbs → carbohydrate)
若用户已声明热量,以用户值覆盖 kilocalorie,其余营养素仍按比例计算。
调用 log_food_entry(强匹配或用户选择确认后静默执行)
log_food_entry \
--food-id "<output3.foodId>" \
--food-name "<output3.foodName>" \
--meal-type "<meal槽位值>" \
--count <output3.quantity> \
--unit "<output3.unit>" \
--intake-weight <output3.intakeWeight> \
--kilocalorie <output3.kilocalorie> \
--protein <output3.protein> \
--fat <output3.fat> \
--carbohydrate <output3.carbohydrate>
打卡成功(httpStatus=200)→ 进入第五步C。
打卡失败 → 告知用户:"记录写入失败,请稍后重试,或手动记录一下。"
第五步C:查询当日营养(打卡成功后)
get_nutrition --date today
此时服务端已持久化本次打卡,返回的 totalCalories 等字段已含本次摄入。将结果传入第六步生成点评。
第六步:输出结果
静默原则
整个流程中,除了以下两种情况,禁止输出任何文字:
- 需要用户选择/确认时(弱匹配选项)
- 最终结果输出
搜索中、打卡中、查询营养中——一个字都不许说。
最终输出格式
数据来源
| 信息类型 | 来源 | 字段 |
|---|
| 本次食物名/数量/单位 | Output3 | foodName, quantity, unit |
| 本次热量/蛋白质/脂肪/碳水 | Output3 | kilocalorie, protein, fat, carbohydrate |
| 今日累计总热量 | get_nutrition | totalCalories |
| 今日累计蛋白质 | get_nutrition | protein(可选,有则用) |
| 今日累计脂肪 | get_nutrition | fat(可选,有则用) |
| 今日累计碳水 | get_nutrition | carbs(可选,有则用) |
禁止从 get_nutrition.meals 中读取食物名——该字段结构为 {time, calories},不含食物名称。
输出结构(3 部分)
第1部分(本次记录,必选):
[meal] [foodName] [quantity][unit] ≈ [kilocalorie]kcal,含蛋白质 [protein]g、脂肪 [fat]g、碳水 [carbohydrate]g
第2部分(今日累计,必选):
今日累计 [totalCalories]kcal,蛋白质 [protein]g / 脂肪 [fat]g / 碳水 [carbs]g
- get_nutrition 返回 null 时省略此部分
- protein/fat/carbs 字段缺失时只显示热量
第3部分(点评,必选):结合餐别参考范围和今日累计给出一句具体点评
- 餐别参考范围:早餐 300-500kcal / 上午加餐 <150kcal / 午餐 500-800kcal / 下午加餐 <200kcal / 晚餐 400-700kcal / 晚上加餐 <150kcal
- 只说一句,不要啰嗦
示例
早餐 猪肉包子 2个 ≈ 352kcal,含蛋白质 13.6g、脂肪 9.6g、碳水 51.2g。今日累计 352kcal,蛋白质 13.6g / 脂肪 9.6g / 碳水 51.2g。早餐热量合理,可以再加杯牛奶补钙。
- 餐别必须显示(用户指定的或按时间推断的)
- 禁止说"已记录"、"帮你记好了"等过程性描述