“冰箱里有鸡蛋和土豆,今天能做什么?”这是菜谱应用中非常实用的一类入口。它不要求用户先知道菜名,而是从已有食材反推候选菜谱。看起来只要把食材关键词和菜谱文本做一次匹配即可,真正落地时却会遇到数据覆盖、别名归一、主辅料权重、缺失食材解释、排序稳定性和空结果等问题。

本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。FridgeRecipePage.ets 已经提供 21 个常用食材、默认选中的鸡蛋与土豆、自定义食材输入,以及最多 12 条候选菜谱;项目总菜谱数是 60,但页面内的 recipeIngredientIndex 只覆盖 recipeId 1 到 20。换句话说,剩余 40 道菜没有结构化食材索引,只能偶然通过菜名、分类、描述或标签中的文字被命中。

冰箱推荐不是搜索框换了一个外观,而是把“我有什么、菜谱需要什么、还缺什么、为什么排在前面”计算成一份可解释结果。

HEALTHY-RECIPE-ONE7-FRIDGE-MATCH-20260730

HarmonyOS ArkTS 冰箱食材推荐封面

本文将完成以下内容:

  • 还原当前食材选择、文本匹配和候选排序链路。
  • 解释 60 道菜与 20 条食材索引之间的数据覆盖缺口。
  • 设计食材归一、别名映射和去重规则。
  • 把“命中几项”升级为覆盖率、主料命中和缺失清单。
  • 修复 duration.localeCompare() 带来的字符串排序错误。
  • 给出纯本地、可复核、无需联网的推荐模型。
  • 覆盖空选择、无结果、重复输入和多设备布局测试。

一、真实页面目前做了什么

页面只维护两个可变字段:

@State selectedIngredients: string[] = ['鸡蛋', '土豆']
@State customIngredient: string = ''

常用食材按蛋奶、肉类、水产、蔬菜和主食组织,共 21 项:

private commonIngredients: FridgeIngredient[] = [
  { name: '鸡蛋', group: '蛋奶' },
  { name: '豆腐', group: '蛋奶' },
  { name: '牛奶', group: '蛋奶' },
  { name: '鸡胸肉', group: '肉类' },
  // ...共 21 项
  { name: '面条', group: '主食' }
]

点击标签时使用不可变数组更新,能触发 ArkUI 状态刷新:

toggleIngredient(name: string): void {
  const index = this.selectedIngredients.indexOf(name)
  if (index >= 0) {
    const next = [...this.selectedIngredients]
    next.splice(index, 1)
    this.selectedIngredients = next
  } else {
    this.selectedIngredients = [...this.selectedIngredients, name]
  }
}

这个交互基础是正确的。问题主要出在候选数据和评分规则,而不是标签本身。

二、当前推荐算法的完整链路

页面把菜名、菜系、描述、标签和食材索引拼成一个字符串:

getMatchCount(recipe: RecipeModel): number {
  const searchableText = `${recipe.name} ${recipe.category} ${recipe.description} `
    + `${recipe.tags.join(' ')} ${this.getIngredientText(recipe.id)}`
  let count = 0
  for (const ingredient of this.selectedIngredients) {
    if (searchableText.indexOf(ingredient) >= 0) {
      count++
    }
  }
  return count
}

随后按命中数量降序、时长字符串升序排列:

source.sort((a: RecipeModel, b: RecipeModel) => {
  const scoreDiff = this.getMatchCount(b) - this.getMatchCount(a)
  if (scoreDiff !== 0) {
    return scoreDiff
  }
  return a.duration.localeCompare(b.duration)
})

有选择时保留命中数大于 0 的前 12 条;没有选择时直接返回排序后的前 8 条。这条链路足够演示“点食材后列表会变化”,却不能解释一条菜为什么排在另一条前面。

三、先量化真实数据缺口

源码中的数量关系如下:

数据数量覆盖情况
RECIPE_LIST60 道菜完整列表
常用食材21 项只用于输入
recipeIngredientIndex20 条只覆盖 recipeId 1–20
候选上限12 条有食材时
空选择上限8 条无食材时

后 40 道菜的 getIngredientText() 都返回空字符串:

getIngredientText(recipeId: number): string {
  for (const item of this.recipeIngredientIndex) {
    if (item.recipeId === recipeId) {
      return item.ingredients.join('、')
    }
  }
  return ''
}

例如“油焖大虾”是 recipeId 26。用户选择“大虾”时,这道菜没有结构化索引;只有当名称、描述或标签恰好出现“大虾”时才可能命中。推荐是否成功取决于文案措辞,而不是菜谱真实所需食材。

数据覆盖率只有:

20 / 60 = 33.3%

这应当作为上线前的硬检查,而不是由 UI 静默掩盖。

从食材输入到可解释候选的推荐流程

四、推荐模型要先从字符串变成结构化数据

建议把食材需求放到独立模型中:

export type IngredientRole = 'primary' | 'secondary' | 'seasoning'

export interface RecipeIngredientRequirement {
  ingredientId: string
  displayName: string
  role: IngredientRole
  required: boolean
  amount?: string
  aliases?: string[]
}

export interface RecipeIngredientProfile {
  recipeId: number
  ingredients: RecipeIngredientRequirement[]
}

主料、辅料和调味料不能完全等权。用户有鸡胸肉,对宫保鸡丁的意义显然高于用户有大葱;盐、油、生抽等基础调味料也不应让每道菜都获得大量分数。

一个实际条目可以写成:

const GONG_BAO_PROFILE: RecipeIngredientProfile = {
  recipeId: 1,
  ingredients: [
    { ingredientId: 'chicken_breast', displayName: '鸡胸肉', role: 'primary', required: true },
    { ingredientId: 'peanut', displayName: '花生米', role: 'secondary', required: true },
    { ingredientId: 'dried_chili', displayName: '干辣椒', role: 'secondary', required: false },
    { ingredientId: 'scallion', displayName: '大葱', role: 'seasoning', required: false },
    { ingredientId: 'ginger', displayName: '生姜', role: 'seasoning', required: false }
  ]
}

这样算法不再从一整段自然语言中猜食材。

五、食材输入必须归一化

用户可能输入“西红柿”“番茄”“小番茄”,也可能输入带空格的“ 鸡蛋 ”。当前代码只执行 trim(),重复判断仍然是精确字符串比较:

if (this.selectedIngredients.indexOf(name) < 0) {
  this.selectedIngredients = [...this.selectedIngredients, name]
}

可以建立本地别名字典:

const INGREDIENT_ALIASES: Record<string, string> = {
  '西红柿': '番茄',
  '小番茄': '番茄',
  '圣女果': '番茄',
  '马铃薯': '土豆',
  '洋芋': '土豆',
  '鸡肉丁': '鸡胸肉',
  '虾仁': '大虾'
}

function normalizeIngredientName(input: string): string {
  const compact = input.trim().replace(/\s+/g, '')
  return INGREDIENT_ALIASES[compact] ?? compact
}

归一后再判断重复:

addCustomIngredient(): void {
  const normalized = normalizeIngredientName(this.customIngredient)
  if (!normalized) {
    promptAction.showToast({ message: '先输入一种食材' })
    return
  }
  if (!this.selectedIngredients.includes(normalized)) {
    this.selectedIngredients = [...this.selectedIngredients, normalized]
  }
  this.customIngredient = ''
}

别名表必须可审查,不能把不等价食材强行合并。例如“鱼头”不能直接等同于“草鱼”,“鸡腿”也不应默认等同于“鸡胸肉”。

六、把用户输入转成集合

评分时反复调用 indexOf 会让语义散落。可以在计算开始时生成规范集合:

function buildPantrySet(selected: string[]): Set<string> {
  const pantry = new Set<string>()
  selected.forEach((item) => {
    const normalized = normalizeIngredientName(item)
    if (normalized) pantry.add(normalized)
  })
  return pantry
}

Set 同时解决去重和 O(1) 查找。对 21 个常用食材而言性能不是瓶颈,但统一数据形态能减少规则分叉。

七、定义一份可解释的 MatchResult

不要让 UI 只拿到一个数字。推荐函数应返回命中、缺失和解释:

export interface IngredientMatch {
  ingredientId: string
  name: string
  role: IngredientRole
}

export interface RecipeMatchResult {
  recipe: RecipeModel
  score: number
  coverage: number
  matched: IngredientMatch[]
  missingRequired: IngredientMatch[]
  missingOptional: IngredientMatch[]
  reason: string
  durationMinutes: number
}

这样卡片可以展示:

已具备 3/4 项核心食材
命中:土豆、茄子、青椒
还缺:大蒜
预计 15 分钟

这比“3项匹配”更能帮助用户做决定。

八、给主料、辅料和调味料不同权重

可以使用简单透明的本地规则:

const ROLE_WEIGHT: Record<IngredientRole, number> = {
  primary: 5,
  secondary: 2,
  seasoning: 0.5
}

function scoreIngredient(
  item: RecipeIngredientRequirement,
  pantry: Set<string>
): number {
  return pantry.has(item.displayName) ? ROLE_WEIGHT[item.role] : 0
}

随后增加完整度和缺失主料惩罚:

function calculateScore(
  profile: RecipeIngredientProfile,
  pantry: Set<string>
): number {
  let score = 0
  let missingPrimary = 0
  for (const item of profile.ingredients) {
    if (pantry.has(item.displayName)) {
      score += ROLE_WEIGHT[item.role]
    } else if (item.required && item.role === 'primary') {
      missingPrimary++
    }
  }
  return score - missingPrimary * 4
}

该规则不是“黑盒”,每一分都能解释。对于纯本地菜谱应用,这种透明度往往比复杂模型更重要。

九、覆盖率应该只计算相关食材

如果把盐、油、葱姜蒜都算入分母,覆盖率会被调味料稀释。可以只对 required 项计算:

function calculateCoverage(
  profile: RecipeIngredientProfile,
  pantry: Set<string>
): number {
  const required = profile.ingredients.filter((item) => item.required)
  if (required.length === 0) return 0
  const matched = required.filter((item) => pantry.has(item.displayName)).length
  return matched / required.length
}

UI 展示时转成百分比,但排序保留原始浮点值。推荐条件可以定义为:

  • 至少命中一个主料;或
  • required 覆盖率达到 50%;或
  • 没有主料的轻食菜谱命中两个辅料。

规则应写进测试,不要藏在多个页面判断里。

十、修复时长字符串排序

当前代码使用:

a.duration.localeCompare(b.duration)

duration 的真实值覆盖 15 至 180 分钟,并包含 120分钟、180分钟 等数字分钟格式。字符串按字符顺序比较时,120分钟 会排在 20分钟 前,因为字符 '1' 小于 '2'。这与“更快完成”完全不同。

最小修复是解析数字:

function parseDurationMinutes(duration: string): number {
  const hourMatch = duration.match(/(\d+(?:\.\d+)?)\s*小时/)
  const minuteMatch = duration.match(/(\d+)\s*分钟/)
  const hours = hourMatch ? Number(hourMatch[1]) * 60 : 0
  const minutes = minuteMatch ? Number(minuteMatch[1]) : 0
  const total = hours + minutes
  return Number.isFinite(total) && total > 0 ? total : Number.MAX_SAFE_INTEGER
}

当前数据都是分钟格式,也可以在 RecipeModel 中直接保存 durationMinutes: number,展示层再格式化。结构化数值优于每次排序时解析字符串。

十一、稳定排序需要完整优先级

推荐列表建议按以下顺序比较:

  1. 缺失必需主料数量升序。
  2. required 覆盖率降序。
  3. 评分降序。
  4. 烹饪分钟数升序。
  5. recipeId 升序,作为稳定兜底。
function compareMatches(a: RecipeMatchResult, b: RecipeMatchResult): number {
  const missing = a.missingRequired.length - b.missingRequired.length
  if (missing !== 0) return missing
  const coverage = b.coverage - a.coverage
  if (coverage !== 0) return coverage
  const score = b.score - a.score
  if (score !== 0) return score
  const duration = a.durationMinutes - b.durationMinutes
  if (duration !== 0) return duration
  return a.recipe.id - b.recipe.id
}

最后的 recipeId 保证相同条件下每次打开顺序一致,便于用户形成预期,也便于截图测试。

十二、不要让“未选择食材”偷偷使用推荐排序

当前空选择时仍对全部菜谱排序,只是所有匹配分都为 0,然后按错误的时长字符串取前 8 条。页面文案写“先展示常用快手菜”,算法却没有“常用”数据,也没有正确的数值时长。

可以选择一种明确策略:

  • 展示标签包含“快手菜”的菜谱,再按数值时长排序;
  • 展示用户历史中常做的菜;
  • 展示固定编辑精选;
  • 只显示空态,引导至少选择一种食材。

如果当前产品没有历史统计,建议采用第一种:

function getQuickRecipes(recipes: RecipeModel[]): RecipeModel[] {
  return recipes
    .filter((recipe) => recipe.tags.includes('快手菜') || parseDurationMinutes(recipe.duration) <= 20)
    .sort((a, b) => parseDurationMinutes(a.duration) - parseDurationMinutes(b.duration))
    .slice(0, 8)
}

这与“常用快手菜”的用户文案一致,也能被验证。

十三、60 道菜的食材索引必须闭合

推荐质量的第一门槛不是算法,而是数据完整。可以在开发阶段加入检查:

function validateIngredientProfiles(
  recipes: RecipeModel[],
  profiles: RecipeIngredientProfile[]
): string[] {
  const issues: string[] = []
  const profileIds = new Set(profiles.map((item) => item.recipeId))
  recipes.forEach((recipe) => {
    if (!profileIds.has(recipe.id)) {
      issues.push(`recipeId=${recipe.id} ${recipe.name} 缺少食材索引`)
    }
  })
  profiles.forEach((profile) => {
    if (!recipes.some((recipe) => recipe.id === profile.recipeId)) {
      issues.push(`recipeId=${profile.recipeId} 没有对应菜谱`)
    }
    if (!profile.ingredients.some((item) => item.role === 'primary')) {
      issues.push(`recipeId=${profile.recipeId} 缺少主料`)
    }
  })
  return issues
}

基线应该是 60 个菜谱、60 个 profile、0 个孤立 ID。索引未补齐前,页面可以给旧的 20 道菜显示推荐结果,对其余菜谱明确标记“食材数据待完善”,而不是假装算法覆盖完整。

十四、把计算移出 ArkUI build

当前 ForEach(this.getMatchedRecipes()) 在 UI 构建过程中执行排序,而卡片内部又多次调用 getMatchCount(recipe)。状态刷新时,同一批字符串会被重复拼接和扫描。

更清晰的做法是在选择变化时重算:

@State matchResults: RecipeMatchResult[] = []

private refreshMatches(): void {
  const pantry = buildPantrySet(this.selectedIngredients)
  this.matchResults = this.recommendationService
    .recommend(pantry)
    .slice(0, 12)
}

toggleIngredient(name: string): void {
  // 更新 selectedIngredients
  this.refreshMatches()
}

build() 只消费 matchResults。推荐逻辑可以脱离 HarmonyOS UI 单独测试,页面也不会在每个卡片中重复计算。

十五、输入和结果状态要完整

页面至少需要以下状态:

type RecommendationState =
  'idle' | 'calculating' | 'content' | 'empty' | 'error'

虽然当前算法完全本地,计算几乎瞬时完成,但 empty 仍非常重要。用户输入“榴莲”后如果没有任何菜谱,不能只留下空白区域:

if (this.state === 'empty') {
  Column({ space: 10 }) {
    Text('暂时没有匹配菜谱')
    Text('可以减少一种食材,或换成番茄、鸡蛋等常用食材')
    Button('清空重选').onClick(() => this.clearSelected())
  }
}

错误态用于数据索引加载失败或结构校验失败;不要用“暂无结果”掩盖程序错误。

十六、卡片要解释推荐,不只显示分数

当前卡片显示菜名、N项匹配、完整食材字符串、时长、难度和热量。结构化结果后,建议把最有决策价值的信息放在前两行:

地三鲜                      75% 已具备
已有:土豆、茄子、青椒
还缺:大蒜                  15 分钟 · 简单

可使用以下字段生成文案:

function buildReason(result: RecipeMatchResult): string {
  const matched = result.matched.map((item) => item.name).slice(0, 3).join('、')
  if (result.missingRequired.length === 0) {
    return `核心食材已齐:${matched}`
  }
  const missing = result.missingRequired.map((item) => item.name).slice(0, 2).join('、')
  return `已有 ${matched},还缺 ${missing}`
}

不要使用“智能推荐”“最适合”等无法验证的绝对表述。页面只应陈述本地规则算出的事实。

食材推荐的数据、评分、服务与 ArkUI 分层

十七、服务层如何组织

建议拆成三个关注点:

17.1 IngredientNormalizer

负责空格清理、别名映射和去重,不知道任何页面信息。

17.2 RecipeIngredientRepository

按 recipeId 提供食材 profile,并在启动或测试阶段执行 60/60 完整性检查。

17.3 FridgeRecommendationService

接收 pantry 集合,输出 RecipeMatchResult[]。它只做匹配、评分、解释和排序,不负责导航详情页。

接口可以保持很小:

export interface FridgeRecommendationService {
  recommend(selectedIngredients: string[]): RecipeMatchResult[]
  getQuickRecipes(limit: number): RecipeMatchResult[]
}

页面继续负责 router.pushUrl(),服务层不依赖 ArkUI 路由。

十八、隐私与离线边界

冰箱食材是用户生活信息,但当前功能完全可以本地完成:

  • 不上传食材列表。
  • 不需要账号和云端画像。
  • 不请求相机、相册或定位权限。
  • 不把输入词发送到第三方推荐接口。
  • 若未来保存历史,需要在隐私政策中说明本地存储、清除方式和保存范围。

当前项目已经声明 INTERNET 权限,但这个页面本身不需要网络。文章和应用说明不应把本地规则描述成云端智能服务;若未来接入联网能力,要重新评估权限、隐私、失败降级和费用。

十九、多设备布局

手机上适合“输入区—已选食材—结果列表”的纵向结构。平板与 2in1 可以采用左右分栏:左侧固定食材选择,右侧滚动候选,避免用户每次改一项都上下往返。

设备宽度建议布局关键约束
小于 600vp单列输入框与加入按钮不挤压,标签可换行
600–960vp左 2/5、右 3/5两侧独立滚动,结果卡片限宽
大于 960vp左侧固定 360–420vp支持鼠标、键盘焦点和窗口缩放

已选食材文案当前最多两行并省略,选择较多时用户无法确认全部内容。可以改成可折叠标签区或横向滚动 Chip 列表,而不是把所有名称拼成一个 Text。

二十、测试矩阵

编号输入预期
F01默认鸡蛋、土豆返回包含对应主辅料的候选,并显示命中原因
F02西红柿归一为番茄,不产生两个重复标签
F03鸡蛋 去除空格并与已有鸡蛋去重
F04只选大葱调味料不能让大量菜谱获得高分
F05选鸡胸肉宫保鸡丁主料命中,排名高于只命中葱姜的菜
F06选土豆、茄子、青椒地三鲜展示高覆盖率和缺少大蒜
F07输入榴莲显示空态与重选入口,不留白
F08清空选择展示真正的快手菜,不使用字符串时长排序
F09比较 120 与 20 分钟20 分钟候选排序更靠前
F10recipeId 21–60每道菜都有结构化食材 profile
F11重复点击标签状态稳定切换,不出现重复食材
F12选择 15 项以上已选区域可查看全部项,布局不溢出
F13相同分数按覆盖率、时长、recipeId 稳定排序
F14profile 缺主料完整性检查报告明确错误
F15打开结果recipeId 正确传给详情页

二十一、可复核的验证步骤

21.1 数据覆盖

  1. 读取 RECIPE_LIST 的全部 60 个 ID。
  2. 读取全部 RecipeIngredientProfile。
  3. 比较 ID 集合,确认无缺失、无重复、无孤立 profile。
  4. 确认每道菜至少有一个 primary 食材。
  5. 记录基线:60 道菜、60 个 profile、覆盖率 100%。

21.2 排序验证

构造三条相同评分、不同覆盖率和时长的数据,运行 compareMatches()。验证缺主料更少者优先,覆盖率更高者优先,最后才比较数值时长;重复执行顺序必须一致。

21.3 解释一致性

对每条结果检查:matched 与用户 pantry 有交集,missingRequired 不在 pantry 中,coverage 等于命中 required 数除以 required 总数,reason 只引用实际字段。

21.4 UI 验证

在手机竖屏、横屏、平板和 2in1 窗口下选择 0、2、10 和 21 项食材。检查输入框、Chip、已选区域、空态、结果卡和底部安全区。深色模式下检查标签选中态、次要文字和缺失食材提示的对比度。

二十二、常见问题与定位

22.1 明明有大虾,却不推荐油焖大虾

原因: 食材索引只覆盖 recipeId 1–20,油焖大虾的 recipeId 是 26。

处理: 为 21–60 补齐 profile,并把 60/60 覆盖作为构建检查。

22.2 120 分钟的菜排在 20 分钟前

原因: 使用 localeCompare() 比较展示字符串。

处理: 在模型中保存数值分钟,或先用 parseDurationMinutes() 转换。

22.3 只选葱姜却出现很多“高匹配”

原因: 主料、辅料与调味料每命中一项都加 1 分。

处理: 按 role 加权,并对缺失主料设置惩罚或过滤条件。

22.4 番茄和西红柿被当成两个食材

原因: 当前去重只比较原始字符串。

处理: 先归一再去重,所有 Chip 和自定义输入共用同一函数。

22.5 没有结果时页面空白

原因: 结果数组为空但没有 empty 状态。

处理: 显示原因、调整建议和清空入口;数据异常应进入 error 而不是 empty。

22.6 相同条件下顺序偶尔变化

原因: 比较器没有最终稳定键,或 UI 构建中重复计算。

处理: 末级比较 recipeId,并在状态变化时一次性生成结果。

二十三、落地顺序

  1. 先把 duration 改为数值排序,消除明确错误。
  2. 建立 RecipeIngredientProfile,补齐 60 道菜的食材数据。
  3. 加入归一与别名字典,统一常用标签和自定义输入。
  4. 输出 RecipeMatchResult,让 UI 展示命中与缺失。
  5. 将算法移到服务层并补齐 F01–F15 测试。
  6. 最后调整平板分栏和已选食材交互。

每一步都能独立验证,不需要一次性重写整个页面。

二十四、源码事实与建议实现的边界

本文既复盘当前页面,也给出推荐模型的演进方案。为避免把方案误写成已经上线的能力,下面按源码快照逐项区分。当前事实可从 FridgeRecipePage.ets、MockData.ets 与 RecipeModel.ets 直接复核。

检查项当前源码事实本文建议,不代表已经落地
菜谱总量RECIPE_LIST 有 60 条菜谱建立 60 个结构化 RecipeIngredientProfile
常用食材页面定义 21 个 Chip,默认选中鸡蛋和土豆为别名、分类和展示顺序建立可测试配置
食材索引recipeIngredientIndex 只有 20 条,仅覆盖 recipeId 1–20补齐 21–60,并把 60/60 覆盖作为硬检查
自定义输入只执行 trim(),再按原始字符串精确去重先做别名归一和空白压缩,再用集合去重
匹配规则在菜名、分类、描述、标签和索引文本中执行 indexOf(),每命中一个选择项加 1使用主料、辅料、调味料角色和透明权重
排序规则先按命中数降序,再用 duration.localeCompare() 比较展示字符串使用数值分钟、覆盖率、缺失主料和 recipeId 稳定排序
结果数量有选择时过滤零命中并取前 12;空选择时取排序后的前 8空选择展示明确的快手菜、历史菜或编辑精选
推荐解释卡片显示“N项匹配”和索引食材字符串,不计算缺失清单输出 RecipeMatchResult,展示命中、缺失、覆盖率和理由
计算位置build() 中调用 getMatchedRecipes(),卡片渲染时再次计算命中数选择变化时由服务层一次性生成结果,UI 只渲染
网络与隐私FridgeRecipePage 没有网络调用,也不请求相机、相册或定位;工程整体声明了 INTERNET 权限若未来接入联网能力,重新核对权限、隐私、失败降级和费用说明
构建与设备验证本文读取并统计了本地源码,没有声称执行安装包构建或真机测试在 phone、tablet、2in1 上验证长 Chip、空态、分栏和键鼠焦点

因此,“可解释评分”“60/60 食材 profile”“别名归一”和“服务层缓存”都属于建议实现。它们只有在代码落地、硬检查通过并完成设备验证后,才能写成当前应用能力。

二十五、总结

当前冰箱食材推荐页已经具备完整的交互外壳:21 个常用食材、可输入自定义项、选中态、清空操作和最多 12 条候选卡片。限制它实用性的,是 60 道菜只有 20 条食材索引、文本包含匹配无法区分主辅料、时长采用字符串排序,以及结果只显示命中数量而不说明缺什么。

一个可靠的本地推荐器不需要神秘模型,但需要四项工程基础:

  • 60 道菜与 60 份结构化食材 profile 完整闭合。
  • 输入经过别名归一和去重。
  • 评分区分主料、辅料、调味料并使用数值时长。
  • 每条候选都能说明已具备、仍缺少和排序理由。

当推荐结果能被用户理解、被测试复算、被开发者定位时,“冰箱有什么”才真正从关键词筛选升级为一个可信的日常决策工具。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐