chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import type { RecipeItem } from '../../packages/cook/src/types/index'
|
||||
import { recommendRecipes } from '../../packages/cook/src/utils/recommend'
|
||||
|
||||
/** Rows as returned by the cook-feedback export. */
|
||||
export interface FeedbackRow {
|
||||
recipeId: number
|
||||
recipeName: string
|
||||
kind: string
|
||||
note: string
|
||||
reporter: string
|
||||
signedIn: boolean
|
||||
day: string
|
||||
}
|
||||
|
||||
export interface SignalRow {
|
||||
type: 'empty' | 'open'
|
||||
stuff: string[]
|
||||
tool: string
|
||||
mode: string
|
||||
recipeId: number
|
||||
count: number
|
||||
rankSum: number
|
||||
}
|
||||
|
||||
/** A selection users made and the recipe they opened, for evaluating ranking. */
|
||||
export interface EvalCase {
|
||||
stuff: string[]
|
||||
tool?: string
|
||||
mode?: string
|
||||
/** Recipe ids users opened for this selection, with how often. */
|
||||
opened?: { id: number, count: number }[]
|
||||
/** How many times this selection was left with no results. */
|
||||
empty?: number
|
||||
}
|
||||
|
||||
const SIGNED_IN_WEIGHT = 2
|
||||
|
||||
export function summarizeReports(rows: FeedbackRow[]) {
|
||||
const byRecipe = new Map<number, { recipeId: number, recipeName: string, score: number, kinds: Record<string, number>, notes: string[], reporters: Set<string> }>()
|
||||
for (const row of rows) {
|
||||
const item = byRecipe.get(row.recipeId) ?? { recipeId: row.recipeId, recipeName: row.recipeName, score: 0, kinds: {}, notes: [], reporters: new Set<string>() }
|
||||
if (!item.reporters.has(`${row.reporter}|${row.kind}`)) {
|
||||
item.reporters.add(`${row.reporter}|${row.kind}`)
|
||||
item.score += row.signedIn ? SIGNED_IN_WEIGHT : 1
|
||||
item.kinds[row.kind] = (item.kinds[row.kind] ?? 0) + 1
|
||||
}
|
||||
if (row.note)
|
||||
item.notes.push(row.note)
|
||||
byRecipe.set(row.recipeId, item)
|
||||
}
|
||||
return [...byRecipe.values()]
|
||||
.map(({ reporters, ...rest }) => ({ ...rest, reporters: new Set([...reporters].map(r => r.split('|')[0])).size }))
|
||||
.sort((a, b) => b.score - a.score || a.recipeId - b.recipeId)
|
||||
}
|
||||
|
||||
function selectionKey(row: { stuff: string[], tool?: string, mode?: string }) {
|
||||
return [[...row.stuff].sort().join('、'), row.tool ?? '', row.mode ?? 'loose'].join('|')
|
||||
}
|
||||
|
||||
/** Merge signals into one eval case per selection. */
|
||||
export function buildEvalCases(signals: SignalRow[]): EvalCase[] {
|
||||
const cases = new Map<string, EvalCase>()
|
||||
for (const s of signals) {
|
||||
const key = selectionKey(s)
|
||||
const item = cases.get(key) ?? { stuff: [...s.stuff].sort(), ...(s.tool ? { tool: s.tool } : {}), ...(s.mode && s.mode !== 'loose' ? { mode: s.mode } : {}) }
|
||||
if (s.type === 'empty')
|
||||
item.empty = (item.empty ?? 0) + s.count
|
||||
else
|
||||
item.opened = [...(item.opened ?? []), { id: s.recipeId, count: s.count }].sort((a, b) => b.count - a.count || a.id - b.id)
|
||||
cases.set(key, item)
|
||||
}
|
||||
return [...cases.values()].sort((a, b) => selectionKey(a).localeCompare(selectionKey(b)))
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
cases: number
|
||||
/** Opened recipes: share found in the top 10, and mean reciprocal rank (weighted by count). */
|
||||
opened: { total: number, hitAt10: number, mrr: number, missing: number }
|
||||
/** Selections that had no results: how many still have none. */
|
||||
empty: { total: number, stillEmpty: number }
|
||||
worst: { stuff: string[], id: number, rank: number | null, count: number }[]
|
||||
}
|
||||
|
||||
/** Run the current recommendation logic over the eval cases. */
|
||||
export function evaluate(recipes: RecipeItem[], cases: EvalCase[], pantry: string[] = []): EvalResult {
|
||||
let total = 0
|
||||
let hits = 0
|
||||
let rr = 0
|
||||
let missing = 0
|
||||
let emptyTotal = 0
|
||||
let stillEmpty = 0
|
||||
const worst: EvalResult['worst'] = []
|
||||
|
||||
for (const c of cases) {
|
||||
const matches = recommendRecipes(recipes, { stuff: c.stuff, pantry, tool: c.tool, mode: c.mode as never })
|
||||
if (c.empty) {
|
||||
emptyTotal += c.empty
|
||||
if (!matches.length)
|
||||
stillEmpty += c.empty
|
||||
}
|
||||
for (const { id, count } of c.opened ?? []) {
|
||||
const index = matches.findIndex(m => m.recipe.id === id)
|
||||
total += count
|
||||
if (index < 0) {
|
||||
missing += count
|
||||
worst.push({ stuff: c.stuff, id, rank: null, count })
|
||||
continue
|
||||
}
|
||||
if (index < 10)
|
||||
hits += count
|
||||
else
|
||||
worst.push({ stuff: c.stuff, id, rank: index + 1, count })
|
||||
rr += count / (index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = (n: number) => total ? Math.round(n / total * 1000) / 1000 : 0
|
||||
return {
|
||||
cases: cases.length,
|
||||
opened: { total, hitAt10: ratio(hits), mrr: ratio(rr), missing },
|
||||
empty: { total: emptyTotal, stillEmpty },
|
||||
worst: worst.sort((a, b) => b.count - a.count).slice(0, 20),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RecipeItem } from '../../packages/cook/src/types'
|
||||
import type { RecommendationOptions } from '../../packages/cook/src/utils/recommend'
|
||||
import { createRecipeRecommender, uniqueStuff } from '../../packages/cook/src/utils/recommend'
|
||||
|
||||
/**
|
||||
* Web 和 iOS 共用的推荐用例:输入是筛选条件,输出是当前 TS 实现的结果。
|
||||
* iOS 的 CookCore 读同一份文件,结果必须完全一致(顺序也算)。
|
||||
*/
|
||||
export const recommendInputs: RecommendationOptions[] = [
|
||||
{ stuff: ['番茄', '鸡蛋'], pantry: ['油', '盐'] },
|
||||
{ stuff: ['西红柿', '鸡蛋'], pantry: ['油', '盐'] },
|
||||
{ stuff: ['土豆'], pantry: ['油', '盐'] },
|
||||
{ stuff: ['腊肠', '米'], pantry: ['油', '盐'], tool: '电饭煲' },
|
||||
{ stuff: ['鸡肉', '洋葱', '菌菇'], pantry: ['油', '盐', '生抽', '蒜'] },
|
||||
{ stuff: ['牛肉', '番茄', '米'], pantry: [] },
|
||||
{ stuff: ['番茄', '鸡蛋'], pantry: ['油', '盐'], mode: 'strict' },
|
||||
{ stuff: ['番茄', '鸡蛋', '米'], pantry: ['油', '盐'], mode: 'survival' },
|
||||
{ stuff: ['鸡蛋'], pantry: ['油', '盐'], keyword: '蛋' },
|
||||
{ stuff: ['猪肉', '白菜', '豆腐'], pantry: ['油', '盐'], avoid: ['猪肉'] },
|
||||
{ stuff: ['猪肉', '白菜', '豆腐', '菌菇'], pantry: ['油', '盐'], avoid: ['豆腐'] },
|
||||
{ stuff: ['鸡肉', '土豆'], pantry: ['油', '盐'], methodGroups: ['炖煮', '烤'] },
|
||||
{ stuff: ['牛肉', '豆腐', '莴笋'], pantry: ['油', '盐'], cuisines: ['川菜'] },
|
||||
{ stuff: ['鸡肉', '土豆'], pantry: ['油', '盐'], noSpicy: true },
|
||||
{ stuff: ['鱼'], pantry: ['油', '盐', '葱', '姜'], difficulty: ['简单'] },
|
||||
{ stuff: ['鸡蛋', '面食'], pantry: ['油', '盐'], tool: '一口大锅', mode: 'strict' },
|
||||
{ stuff: ['火龙果'] },
|
||||
{ stuff: [] },
|
||||
]
|
||||
|
||||
/** 别名和分隔符处理 */
|
||||
export const normalizeInputs: string[][] = [
|
||||
['西红柿', '鸡蛋'],
|
||||
['土豆丝、马铃薯', ' 洋芋 '],
|
||||
['鸡翅,鸡腿;排骨'],
|
||||
['虾仁\n鲈鱼', ''],
|
||||
]
|
||||
|
||||
export function buildFixtures(recipes: RecipeItem[]) {
|
||||
const recommend = createRecipeRecommender(recipes)
|
||||
return {
|
||||
description: '由 pnpm fixtures 生成,勿手改。Web(test/fixtures.test.ts)和 iOS(CookCore)都要和它一致。',
|
||||
recipeCount: recipes.length,
|
||||
normalize: normalizeInputs.map(input => ({ input, output: uniqueStuff(input) })),
|
||||
recommend: recommendInputs.map(input => ({
|
||||
input,
|
||||
output: recommend(input).map(match => ({
|
||||
id: match.recipe.id,
|
||||
matched: match.matchedStuff,
|
||||
pantry: match.pantryStuff,
|
||||
missing: match.missingStuff,
|
||||
optional: match.optionalStuff,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user