skill-calorie 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/SKILL.md +54 -26
  2. package/dist/analysis/anomaly/common.d.ts.map +1 -1
  3. package/dist/analysis/anomaly/common.js +2 -1
  4. package/dist/analysis/anomaly/common.js.map +1 -1
  5. package/dist/analysis/cross.d.ts.map +1 -1
  6. package/dist/analysis/cross.js +3 -2
  7. package/dist/analysis/cross.js.map +1 -1
  8. package/dist/analysis/deficit.d.ts.map +1 -1
  9. package/dist/analysis/deficit.js +4 -0
  10. package/dist/analysis/deficit.js.map +1 -1
  11. package/dist/analysis/diet.js +2 -2
  12. package/dist/analysis/diet.js.map +1 -1
  13. package/dist/analysis/exercise.js +5 -5
  14. package/dist/analysis/exercise.js.map +1 -1
  15. package/dist/analysis/review.js +2 -2
  16. package/dist/analysis/review.js.map +1 -1
  17. package/dist/analysis/series.d.ts +7 -1
  18. package/dist/analysis/series.d.ts.map +1 -1
  19. package/dist/analysis/series.js +22 -6
  20. package/dist/analysis/series.js.map +1 -1
  21. package/dist/analysis/utils.d.ts +7 -0
  22. package/dist/analysis/utils.d.ts.map +1 -1
  23. package/dist/analysis/utils.js +7 -0
  24. package/dist/analysis/utils.js.map +1 -1
  25. package/dist/analysis/weightCompare3.js +2 -2
  26. package/dist/analysis/weightCompare3.js.map +1 -1
  27. package/dist/cli/cmd_read.d.ts +21 -3
  28. package/dist/cli/cmd_read.d.ts.map +1 -1
  29. package/dist/cli/cmd_read.js +460 -54
  30. package/dist/cli/cmd_read.js.map +1 -1
  31. package/dist/cli/keys.d.ts +89 -1
  32. package/dist/cli/keys.d.ts.map +1 -1
  33. package/dist/cli/keys.js +30 -0
  34. package/dist/cli/keys.js.map +1 -1
  35. package/dist/cli/write.d.ts +12 -9
  36. package/dist/cli/write.d.ts.map +1 -1
  37. package/dist/cli/write.js +133 -48
  38. package/dist/cli/write.js.map +1 -1
  39. package/dist/output.d.ts +55 -1
  40. package/dist/output.d.ts.map +1 -1
  41. package/dist/output.js +203 -5
  42. package/dist/output.js.map +1 -1
  43. package/dist/render/dietDocs.d.ts +45 -0
  44. package/dist/render/dietDocs.d.ts.map +1 -0
  45. package/dist/render/dietDocs.js +561 -0
  46. package/dist/render/dietDocs.js.map +1 -0
  47. package/dist/render/envelope.d.ts +32 -6
  48. package/dist/render/envelope.d.ts.map +1 -1
  49. package/dist/render/envelope.js +71 -0
  50. package/dist/render/envelope.js.map +1 -1
  51. package/dist/render/exercisePort.d.ts +161 -0
  52. package/dist/render/exercisePort.d.ts.map +1 -0
  53. package/dist/render/exercisePort.js +395 -0
  54. package/dist/render/exercisePort.js.map +1 -0
  55. package/dist/render/goal.d.ts.map +1 -1
  56. package/dist/render/goal.js +13 -2
  57. package/dist/render/goal.js.map +1 -1
  58. package/dist/render/goalPlate.d.ts +1 -1
  59. package/dist/render/goalPlate.d.ts.map +1 -1
  60. package/dist/render/goalPlate.js +5 -1
  61. package/dist/render/goalPlate.js.map +1 -1
  62. package/dist/render/helpCenter.d.ts +104 -0
  63. package/dist/render/helpCenter.d.ts.map +1 -0
  64. package/dist/render/helpCenter.js +386 -0
  65. package/dist/render/helpCenter.js.map +1 -0
  66. package/dist/render/html.d.ts.map +1 -1
  67. package/dist/render/html.js +2 -1
  68. package/dist/render/html.js.map +1 -1
  69. package/dist/render/index.d.ts +3 -0
  70. package/dist/render/index.d.ts.map +1 -1
  71. package/dist/render/index.js +2 -0
  72. package/dist/render/index.js.map +1 -1
  73. package/dist/render/nutritionPort.d.ts +89 -0
  74. package/dist/render/nutritionPort.d.ts.map +1 -0
  75. package/dist/render/nutritionPort.js +183 -0
  76. package/dist/render/nutritionPort.js.map +1 -0
  77. package/dist/render/nutritionPortDocs.d.ts +6 -0
  78. package/dist/render/nutritionPortDocs.d.ts.map +1 -0
  79. package/dist/render/nutritionPortDocs.js +317 -0
  80. package/dist/render/nutritionPortDocs.js.map +1 -0
  81. package/dist/render/photo.d.ts.map +1 -1
  82. package/dist/render/photo.js +5 -1
  83. package/dist/render/photo.js.map +1 -1
  84. package/dist/render/planPlate.d.ts +2 -1
  85. package/dist/render/planPlate.d.ts.map +1 -1
  86. package/dist/render/planPlate.js +6 -1
  87. package/dist/render/planPlate.js.map +1 -1
  88. package/dist/render/receipt.d.ts +47 -1
  89. package/dist/render/receipt.d.ts.map +1 -1
  90. package/dist/render/receipt.js +67 -3
  91. package/dist/render/receipt.js.map +1 -1
  92. package/dist/render/sportDocs.d.ts +14 -0
  93. package/dist/render/sportDocs.d.ts.map +1 -0
  94. package/dist/render/sportDocs.js +540 -0
  95. package/dist/render/sportDocs.js.map +1 -0
  96. package/dist/render/sportPortDocs.d.ts +8 -0
  97. package/dist/render/sportPortDocs.d.ts.map +1 -0
  98. package/dist/render/sportPortDocs.js +468 -0
  99. package/dist/render/sportPortDocs.js.map +1 -0
  100. package/dist/render/trendDocs.d.ts +11 -0
  101. package/dist/render/trendDocs.d.ts.map +1 -0
  102. package/dist/render/trendDocs.js +445 -0
  103. package/dist/render/trendDocs.js.map +1 -0
  104. package/dist/render/trendMiscPort.d.ts +127 -0
  105. package/dist/render/trendMiscPort.d.ts.map +1 -0
  106. package/dist/render/trendMiscPort.js +277 -0
  107. package/dist/render/trendMiscPort.js.map +1 -0
  108. package/dist/render/trendMiscPortDocs.d.ts +10 -0
  109. package/dist/render/trendMiscPortDocs.d.ts.map +1 -0
  110. package/dist/render/trendMiscPortDocs.js +397 -0
  111. package/dist/render/trendMiscPortDocs.js.map +1 -0
  112. package/dist/render/wizardPort.d.ts +125 -0
  113. package/dist/render/wizardPort.d.ts.map +1 -0
  114. package/dist/render/wizardPort.js +393 -0
  115. package/dist/render/wizardPort.js.map +1 -0
  116. package/dist/render/wizardPortDocs.d.ts +6 -0
  117. package/dist/render/wizardPortDocs.d.ts.map +1 -0
  118. package/dist/render/wizardPortDocs.js +274 -0
  119. package/dist/render/wizardPortDocs.js.map +1 -0
  120. package/dist/triggers/routing.d.ts +21 -21
  121. package/dist/triggers/routing.d.ts.map +1 -1
  122. package/dist/triggers/routing.js +60 -30
  123. package/dist/triggers/routing.js.map +1 -1
  124. package/package.json +1 -1
  125. package/templates/diet.html +2 -2
  126. package/templates/exercise.html +2 -2
  127. package/templates/goal.html +2 -2
  128. package/templates/help.html +2 -2
  129. package/templates/home.html +2 -2
  130. package/templates/photo-gallery.html +2 -2
@@ -12,8 +12,11 @@
12
12
  * <SKILLS_DB_PATH>/calorie_html/<中文command>_<YYYYMMDD>_<HHMMSS>[_N].html(同秒冲突自动加后缀),
13
13
  * 中文 command 取 CALORIE_COMBOS[key].title;显式 --output(--html 为 legacy 别名)覆盖任意路径。
14
14
  * 落点随 envelope 的 data.output 回传(additive 字段,六形状守卫不校验 data 额外键)。
15
+ * #91 · `calorie.help.center` 承载**全量速查台**(Q9):`--params '{"mode":"file|inline|text"}'` 显式选交付形态
16
+ * (D6,缺省 `file`);`q`/`keyword` 保留**照片 10 键**语义(非空=现找、空串=全量 10 键)。envelope 恒五字段
17
+ * `version/skill/shape/key/data`(Q8:**无 `status`**),`data` 只回索引与落点/字节数,不回 1 MB 产物。
15
18
  */
16
- import { existsSync, writeFileSync } from 'node:fs';
19
+ import { existsSync } from 'node:fs';
17
20
  import { join } from 'node:path';
18
21
  import { openDb } from '../schema.js';
19
22
  import { DB_FILENAME } from '../paths.js';
@@ -32,18 +35,34 @@ import { buildGoalExpiringView, buildGoalPredictView, buildGoalVsActualView } fr
32
35
  import { buildPredictView, buildAnomalyView, buildContraView, buildDedupeView } from '../render/insightPlate.js';
33
36
  import { buildProfileView } from '../render/profilePlate.js';
34
37
  import { buildCombinedAnalysis, buildDeficitPlate, buildDietReview } from '../render/analysisPlate.js';
38
+ import { dietFoodRanking, dietMacroRatio } from '../analysis/diet.js';
39
+ import { buildAllRankingsDoc, buildDedupeDoc, buildDietReviewDoc, buildHealthDoc, buildLibraryDoc, buildRankingDoc, buildSearchDoc, buildTodayDietDoc, buildViewDietDoc, } from '../render/dietDocs.js';
40
+ import { buildBodyCompositionDoc, buildBodyMeasureDoc, buildExerciseDoc, buildExerciseGoalDoc, buildVolatilityDoc, buildWeightCompareDoc, buildWeightDoc, buildWeightHistoryDoc, buildWeightReviewDoc, } from '../render/sportDocs.js';
41
+ import { buildCardioDoc, buildDistributionDoc, buildRecapDoc, buildReviewDoc, buildStrengthDoc, buildTrendDoc, } from '../render/sportPortDocs.js';
42
+ import { buildCardioView, buildDistributionView, buildRecapView, buildReviewView, buildStrengthView, buildTrendView, } from '../render/exercisePort.js';
43
+ import { buildNutritionDetailView, buildNutritionRatioView, buildSourceStatsView, buildTodayWaterView, } from '../render/nutritionPort.js';
44
+ import { buildNutritionDetailDoc, buildNutritionRatioDoc, buildSourceStatsDoc, buildTodayWaterDoc, } from '../render/nutritionPortDocs.js';
45
+ import { buildBatchImportPreviewView, buildCalorieTrendView, buildLintHealthView, buildLongTrendView, buildNutritionAnalysisView, buildProcessProgressView, buildReviewTemplateView, buildSixFactorsView, } from '../render/trendMiscPort.js';
46
+ import { buildBatchImportPreviewDoc, buildCalorieTrendDoc, buildLintHealthDoc, buildLongTrendDoc, buildNutritionAnalysisDoc, buildProcessProgressDoc, buildReviewTemplateDoc, buildSixFactorsDoc, } from '../render/trendMiscPortDocs.js';
47
+ import { buildCompositionWizardView, buildGifPlannerView, buildMeasureWizardView, buildPhotoLogWizardView, } from '../render/wizardPort.js';
48
+ import { buildCompositionWizardDoc, buildGifPlannerDoc, buildMeasureWizardDoc, buildPhotoLogWizardDoc, } from '../render/wizardPortDocs.js';
49
+ import { buildAnomalyDoc, buildCombinedDoc, buildContraDoc, buildDeficitDoc, buildGoalPredictDoc, buildPredictDoc, } from '../render/trendDocs.js';
35
50
  import { buildHealthPlate } from '../render/health.js';
36
51
  import { buildAllRankings, buildFoodRankingPlate } from '../render/ranking.js';
37
52
  import { buildProductLibrary, buildProductSearch, buildProductStats } from '../render/library.js';
38
53
  import { buildCompareData, buildGalleryData, buildGifTask, buildViewerData } from '../render/photo.js';
39
54
  import { buildPhotoHelp, lookupPhotoHelp } from '../render/help.js';
40
- import { renderCombinedHtml, renderDeficitHtml, renderDietHtml, renderDietReviewHtml, renderExerciseHtml, renderGalleryHtml, renderCompareHtml, renderViewerHtml, renderGifHtml, renderPhotoHelpHtml, renderHelpLookupHtml, renderGoalConfigHtml, renderGoalRecommendHtml, renderGoalWeightHtml, renderGoalProgressHtml, renderGoalStatusHtml, renderGoalHtml, renderHealthHtml, renderHomeHtml, renderProductLibraryHtml, renderProductSearchHtml, renderRankingHtml, renderAllRankingsHtml, renderWeightHtml, renderWeightHistoryHtml, renderWeightCompareHtml, renderWeightReviewHtml, renderVolatilityHtml, renderBodyCompositionHtml, renderBodyMeasureHtml, renderPlanHtml, renderPlanWizardHtml, renderExerciseGoalHtml, renderGoalExpiringHtml, renderGoalPredictHtml, renderGoalVsActualHtml, renderPredictHtml, renderAnomalyHtml, renderContraHtml, renderDedupeHtml, renderProfileHtml, } from '../render/html.js';
41
- import { assertStatMetrics } from '../render/envelope.js';
55
+ // #91 · 全量速查台(Q9):只读消费 #88 `render/helpCenter.js`(三态同源,零改动)。
56
+ import { HELP_CENTER_MODES, buildHelpSceneData, renderHelpCenterHtml } from '../render/helpCenter.js';
57
+ import { renderGalleryHtml, renderCompareHtml, renderViewerHtml, renderGifHtml, renderPhotoHelpHtml, renderHelpLookupHtml, renderErrorHtml, renderGoalConfigHtml, renderGoalRecommendHtml, renderGoalWeightHtml, renderGoalProgressHtml, renderGoalStatusHtml, renderGoalHtml, renderHomeHtml, renderPlanHtml, renderPlanWizardHtml, renderGoalExpiringHtml, renderGoalVsActualHtml, renderProfileHtml, } from '../render/html.js';
58
+ import { assertStatMetrics, buildDelivery, withDelivery } from '../render/envelope.js';
59
+ import { buildErrorReceipt } from '../render/receipt.js';
60
+ import { buildDataText } from 'base-paint';
42
61
  import { CalorieRenderError } from '../render/errors.js';
43
62
  import { TRIGGERS, searchHelp } from '../triggers/index.js';
44
63
  import { shiftISODate, todayISO } from '../analysis/utils.js';
45
64
  import { CALORIE_COMBOS, ENVELOPE_VERSION, CALORIE_SKILL, calorieShapeFor, isCalorieWriteKey } from './keys.js';
46
- import { HTML_DIR_NAME, resolveDefaultHtmlPath, resolveExplicitHtmlPath } from '../output.js';
65
+ import { HTML_DIR_NAME, deliverHtml, resolveReceiptHtmlPath, } from '../output.js';
47
66
  import { openDbReadOnly } from '../db/readonly.js';
48
67
  import { dispatchWrite } from './write.js';
49
68
  const DEFAULT_TIMEOUT_MS = 30000;
@@ -122,7 +141,7 @@ function latestFoodDate(db) {
122
141
  return null;
123
142
  }
124
143
  }
125
- function defaultRange(db, params) {
144
+ function defaultRange(db, params, defDays = 7) {
126
145
  let end = optStr(params, 'end') ?? optStr(params, 'date') ?? optStr(params, 'today') ?? undefined;
127
146
  let start = optStr(params, 'start') ?? undefined;
128
147
  if (end)
@@ -142,7 +161,7 @@ function defaultRange(db, params) {
142
161
  fail(2, 'start 不得晚于 end');
143
162
  return { start, end: end };
144
163
  }
145
- return { start: shiftISODate(end, -6), end: end };
164
+ return { start: shiftISODate(end, -(defDays - 1)), end: end };
146
165
  }
147
166
  /** 本地 envelope 形状校验(镜像 link-core assertShapeData,不运行时 import)。 */
148
167
  function assertEnvelopeData(shape, data) {
@@ -203,6 +222,28 @@ function photosDirOf(params) {
203
222
  const e = process.env.CALORIE_PHOTOS_DIR;
204
223
  return e ? e : undefined;
205
224
  }
225
+ /** #91 · 全量速查台的信封载荷:**10 分组索引**(不把 1 MB 产物塞进 envelope)。
226
+ *
227
+ * `items` 恒为 10 条分组(`total` = `items.length`,与 `list` 形语义一致);
228
+ * `sceneTotal`/`subgroupTotal` 把「436 场景/54 子功能」如实回传,避免只报 10 丢掉全量口径。
229
+ */
230
+ function helpCenterIndex(data) {
231
+ let sceneTotal = 0;
232
+ let subgroupTotal = 0;
233
+ const items = data.groups.map((group) => {
234
+ const sceneCount = group.subgroups.reduce((n, sub) => n + sub.scenes.length, 0);
235
+ sceneTotal += sceneCount;
236
+ subgroupTotal += group.subgroups.length;
237
+ return {
238
+ id: group.id,
239
+ icon: typeof group.icon === 'string' ? group.icon : '',
240
+ label: group.label,
241
+ subgroupCount: group.subgroups.length,
242
+ sceneCount,
243
+ };
244
+ });
245
+ return { items, total: items.length, sceneTotal, subgroupTotal };
246
+ }
206
247
  // 全键分发:读走 render/fetch 读,HELP 走触发词现找;未知键上游已拦,此处再拦一道。
207
248
  /** #41 · 测试直调出口(纯 CLI 同逻辑,不经过 argv/spawn;CLI 唯一出口仍为 main)。 */
208
249
  export function dispatch(key, params, db) {
@@ -216,7 +257,9 @@ export function dispatch(key, params, db) {
216
257
  const items = rows.map((r) => ({ id: r.id, date: r.date, time: r.time, food_name: r.food_name, grams: r.grams, calories: r.calories, protein: r.protein, carbs: r.carbs, fat: r.fat }));
217
258
  const o = buildDietOverview(db, date, date);
218
259
  const dist = buildMealDistribution(db, date);
219
- return { data: { items, total: items.length }, html: renderDietHtml(o, dist) };
260
+ // #108 · 今日饮食全文档(餐次进度+营养配比+今日明细;配比无数据即 skip,不编数)。
261
+ const mt = dietMacroRatio(db, date, date);
262
+ return { data: { items, total: items.length }, html: buildTodayDietDoc({ overview: o, dist, meals: rows, macro: mt.status === 'ok' ? (mt.data ?? null) : null }) };
220
263
  }
221
264
  case 'calorie.view.home': {
222
265
  const date = optStr(params, 'date') ?? optStr(params, 'today') ?? latestFoodDate(db) ?? todayISO();
@@ -250,6 +293,22 @@ export function dispatch(key, params, db) {
250
293
  else
251
294
  throw e;
252
295
  }
296
+ // #108 · 窗口明细(逐日 listMeals 去水,上限 100 条并明示截断;单日失败跳过)。
297
+ const mealRows = [];
298
+ for (const d of o.series) {
299
+ try {
300
+ for (const r of listMeals(db, d.date)) {
301
+ if (r.food_name !== '💧水')
302
+ mealRows.push(r);
303
+ }
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ }
309
+ const MEAL_CAP = 100;
310
+ const mealTotal = mealRows.length;
311
+ const mealSlice = mealRows.slice(0, MEAL_CAP);
253
312
  const metrics = nums({
254
313
  totalCalories: o.totalCalories, avgCalories: o.avgCalories, calorieGoal: o.calorieGoal,
255
314
  loggedDays: o.loggedDays, days: o.days, distTotal: dist.totalCalories,
@@ -258,7 +317,10 @@ export function dispatch(key, params, db) {
258
317
  'meal.晚餐': dist.slices.find((s) => s.meal === '晚餐')?.calories,
259
318
  'meal.加餐': dist.slices.find((s) => s.meal === '加餐')?.calories,
260
319
  });
261
- return { data: { metrics }, html: renderDietHtml(o, dist) };
320
+ return { data: { metrics }, html: buildViewDietDoc({
321
+ overview: o, dist, distDate: date, days: o.series,
322
+ meals: mealSlice, mealTotal, mealsTruncated: mealTotal > MEAL_CAP,
323
+ }) };
262
324
  }
263
325
  case 'calorie.view.exercise': {
264
326
  const { start, end } = defaultRange(db, params);
@@ -268,7 +330,222 @@ export function dispatch(key, params, db) {
268
330
  activeDays: v.review.activeDays, totalBurnedSeries: v.totalBurnedSeries,
269
331
  avgBurnedPerLoggedDay: v.avgBurnedPerLoggedDay, seriesActiveDays: v.activeDays,
270
332
  });
271
- return { data: { metrics }, html: renderExerciseHtml(v) };
333
+ return { data: { metrics }, html: buildExerciseDoc(v) };
334
+ }
335
+ // #111 · 运动移植 6 键(t71 需移植 exercise_*;envelope stat metrics 只收确定数字)。
336
+ case 'calorie.view.exercise-strength': {
337
+ const { start, end } = defaultRange(db, params);
338
+ const v = buildStrengthView(db, start, end);
339
+ const metrics = nums({
340
+ movementCount: v.movementCount, totalSets: v.totalSets,
341
+ totalVolumeKg: v.totalVolumeKg, totalReps: v.totalReps,
342
+ });
343
+ return { data: { metrics }, html: buildStrengthDoc(v) };
344
+ }
345
+ case 'calorie.view.exercise-cardio': {
346
+ const { start, end } = defaultRange(db, params);
347
+ const v = buildCardioView(db, start, end);
348
+ const metrics = nums({
349
+ sessions: v.sessions, totalMinutes: v.totalMinutes,
350
+ totalDistanceKm: v.totalDistanceKm, avgPaceMinPerKm: v.avgPaceMinPerKm,
351
+ });
352
+ return { data: { metrics }, html: buildCardioDoc(v) };
353
+ }
354
+ case 'calorie.view.exercise-distribution': {
355
+ const { start, end } = defaultRange(db, params);
356
+ const v = buildDistributionView(db, start, end);
357
+ const metrics = nums({
358
+ sessions: v.sessions, activeDays: v.activeDays, days: v.days, totalBurned: v.totalBurned,
359
+ intakeCal: v.intakeCal, tdeeTotal: v.tdeeTotal, deficit: v.deficit,
360
+ });
361
+ return { data: { metrics }, html: buildDistributionDoc(v) };
362
+ }
363
+ case 'calorie.view.exercise-recap': {
364
+ const { start, end } = defaultRange(db, params);
365
+ const v = buildRecapView(db, start, end);
366
+ const metrics = nums({
367
+ sessions: v.sessions, totalMinutes: v.totalMinutes, totalBurned: v.totalBurned,
368
+ activeDays: v.activeDays, days: v.days,
369
+ });
370
+ return { data: { metrics }, html: buildRecapDoc(v) };
371
+ }
372
+ case 'calorie.view.exercise-review': {
373
+ const { start, end } = defaultRange(db, params);
374
+ const v = buildReviewView(db, start, end);
375
+ const metrics = nums({
376
+ plannedSessions: v.plannedSessions, hitSessions: v.hitSessions,
377
+ completionPct: v.completionPct, plannedMovements: v.plannedMovements,
378
+ hitMovements: v.hitMovements, movementPct: v.movementPct,
379
+ });
380
+ return { data: { metrics }, html: buildReviewDoc(v) };
381
+ }
382
+ case 'calorie.view.exercise-trend': {
383
+ const { start, end } = defaultRange(db, params);
384
+ const v = buildTrendView(db, start, end);
385
+ const metrics = nums({
386
+ activeDays: v.activeDays, totalMinutes: v.totalMinutes,
387
+ totalBurned: v.totalBurned, peakBurned: v.peak?.burned,
388
+ });
389
+ return { data: { metrics }, html: buildTrendDoc(v) };
390
+ }
391
+ // #112 · 营养移植 4 键(t71 需移植 nutrition_*/source_stats/today_water;envelope stat metrics 只收确定数字)。
392
+ case 'calorie.view.nutrition-ratio': {
393
+ const { start, end } = defaultRange(db, params);
394
+ const v = buildNutritionRatioView(db, start, end);
395
+ const metrics = nums({
396
+ totalCalorie: v.totalCalorie, proteinG: v.proteinG, proteinPct: v.proteinPct,
397
+ carbG: v.carbG, carbPct: v.carbPct, fatG: v.fatG, fatPct: v.fatPct,
398
+ targetProteinG: v.targetProteinG, targetCarbG: v.targetCarbG, targetFatG: v.targetFatG,
399
+ });
400
+ return { data: { metrics }, html: buildNutritionRatioDoc(v) };
401
+ }
402
+ case 'calorie.view.nutrition-detail': {
403
+ const { start, end } = defaultRange(db, params);
404
+ const v = buildNutritionDetailView(db, start, end);
405
+ const metrics = nums({
406
+ days: v.days,
407
+ matchedMeals: v.matchedMeals,
408
+ missingFoods: v.missingFoods.length,
409
+ fiberAvg: v.items[0]?.avg,
410
+ sodiumAvg: v.items[1]?.avg,
411
+ sugarAvg: v.items[2]?.avg,
412
+ fiberPct: v.items[0]?.pct,
413
+ sodiumPct: v.items[1]?.pct,
414
+ sugarPct: v.items[2]?.pct,
415
+ });
416
+ return { data: { metrics }, html: buildNutritionDetailDoc(v) };
417
+ }
418
+ case 'calorie.view.source-stats': {
419
+ const v = buildSourceStatsView(db);
420
+ const metrics = nums({ total: v.total, sources: v.sources });
421
+ return { data: { metrics }, html: buildSourceStatsDoc(v) };
422
+ }
423
+ case 'calorie.view.today-water': {
424
+ const date = optStr(params, 'date') ?? latestFoodDate(db) ?? todayISO();
425
+ assertISO(date, 'date');
426
+ const v = buildTodayWaterView(db, date);
427
+ const metrics = nums({
428
+ todayMl: v.todayMl, targetMl: v.targetMl, pct: v.pct, remainMl: v.remainMl, cups: v.cups.length,
429
+ });
430
+ return { data: { metrics }, html: buildTodayWaterDoc(v) };
431
+ }
432
+ // #113 · 趋势 2+其他 6 移植 8 键(t71 需移植八模板;envelope stat metrics 只收确定数字)。
433
+ case 'calorie.view.calorie-trend': {
434
+ const { start, end } = defaultRange(db, params);
435
+ const v = buildCalorieTrendView(db, start, end);
436
+ const s = v.data.summary;
437
+ const metrics = nums({
438
+ avg: s.avg, target: s.target, trendValue: s.trendValue,
439
+ startAvg: s.startAvg, endAvg: s.endAvg, weekdayAvg: s.weekdayAvg,
440
+ weekendAvg: s.weekendAvg, weekendDiff: s.weekendDiff,
441
+ compliantDays: s.compliantDays, complianceRate: s.complianceRate,
442
+ });
443
+ return { data: { metrics }, html: buildCalorieTrendDoc(v) };
444
+ }
445
+ case 'calorie.view.long-trend': {
446
+ const end = optStr(params, 'end') ?? latestFoodDate(db) ?? todayISO();
447
+ assertISO(end, 'end');
448
+ const v = buildLongTrendView(db, optStr(params, 'group'), optStr(params, 'window'), end);
449
+ const metrics = nums({
450
+ windowDays: v.windowDays, avgCalorie: v.avgCalorie, weightChange: v.weightChange,
451
+ });
452
+ return { data: { metrics }, html: buildLongTrendDoc(v) };
453
+ }
454
+ case 'calorie.view.nutrition-analysis': {
455
+ const { start, end } = defaultRange(db, params);
456
+ const v = buildNutritionAnalysisView(db, start, end);
457
+ const metrics = nums({
458
+ days: v.days, totalCalorie: v.totalCalorie,
459
+ proteinG: v.proteinG, proteinPct: v.proteinPct,
460
+ carbG: v.carbG, carbPct: v.carbPct, fatG: v.fatG, fatPct: v.fatPct,
461
+ fiberAvg: v.fiberAvg, sodiumAvg: v.sodiumAvg, sugarAvg: v.sugarAvg,
462
+ adviceCount: v.advice.length,
463
+ });
464
+ return { data: { metrics }, html: buildNutritionAnalysisDoc(v) };
465
+ }
466
+ case 'calorie.view.six-factors': {
467
+ const date = optStr(params, 'date') ?? latestFoodDate(db) ?? todayISO();
468
+ assertISO(date, 'date');
469
+ const v = buildSixFactorsView(db, date);
470
+ const metrics = nums({
471
+ score: v.score,
472
+ calorie: v.factors[0]?.ok ? 1 : 0,
473
+ protein: v.factors[1]?.ok ? 1 : 0,
474
+ water: v.factors[2]?.ok ? 1 : 0,
475
+ exercise: v.factors[3]?.ok ? 1 : 0,
476
+ weigh: v.factors[4]?.ok ? 1 : 0,
477
+ meals: v.factors[5]?.ok ? 1 : 0,
478
+ });
479
+ return { data: { metrics }, html: buildSixFactorsDoc(v) };
480
+ }
481
+ case 'calorie.view.measure-wizard': {
482
+ const v = buildMeasureWizardView(db, params);
483
+ const metrics = nums({
484
+ filledCount: v.filledCount, hasRecent: v.recent ? 1 : 0,
485
+ });
486
+ return { data: { metrics }, html: buildMeasureWizardDoc(v) };
487
+ }
488
+ case 'calorie.view.composition-wizard': {
489
+ const v = buildCompositionWizardView(db, params);
490
+ const metrics = nums({
491
+ filledCount: (v.source ? 1 : 0) + (v.bodyFatPct === null ? 0 : 1) + v.calipers.length,
492
+ caliperCount: v.calipers.length, sum7: v.sum7, hasRecent: v.recent ? 1 : 0,
493
+ });
494
+ return { data: { metrics }, html: buildCompositionWizardDoc(v) };
495
+ }
496
+ case 'calorie.view.photo-log-wizard': {
497
+ const v = buildPhotoLogWizardView(params);
498
+ const metrics = nums({
499
+ fileCount: v.srcPaths.length, hasTag: v.tag ? 1 : 0,
500
+ });
501
+ return { data: { metrics }, html: buildPhotoLogWizardDoc(v) };
502
+ }
503
+ case 'calorie.view.gif-planner': {
504
+ const dir = photosDirOf(params);
505
+ const v = buildGifPlannerView(db, params, dir ?? null);
506
+ const metrics = nums({
507
+ photoCount: v.photos.length, selectedCount: v.selectedIds.length,
508
+ missingCount: v.missingIds.length, cropCount: v.photos.filter((p) => p.crop).length,
509
+ });
510
+ return { data: { metrics }, html: buildGifPlannerDoc(v) };
511
+ }
512
+ case 'calorie.view.lint-health': {
513
+ const v = buildLintHealthView(db);
514
+ const metrics = nums({
515
+ issueCount: v.issueCount,
516
+ unmatched: v.checks[0]?.count,
517
+ badCalorie: v.checks[1]?.count,
518
+ future: v.checks[2]?.count,
519
+ duplicate: v.checks[3]?.count,
520
+ });
521
+ return { data: { metrics }, html: buildLintHealthDoc(v) };
522
+ }
523
+ case 'calorie.view.batch-import-preview': {
524
+ const v = buildBatchImportPreviewView(db, params['items']);
525
+ const metrics = nums({
526
+ total: v.total, matched: v.matched, missing: v.missing, totalCalorie: v.totalCalorie,
527
+ });
528
+ return { data: { metrics }, html: buildBatchImportPreviewDoc(v) };
529
+ }
530
+ case 'calorie.view.process-progress': {
531
+ const end = optStr(params, 'end') ?? latestFoodDate(db) ?? todayISO();
532
+ assertISO(end, 'end');
533
+ const v = buildProcessProgressView(db, end);
534
+ const metrics = nums({
535
+ hasPlan: v.hasPlan ? 1 : 0, plannedDays: v.plannedDays,
536
+ sessions7d: v.sessions7d, minutes7d: v.minutes7d,
537
+ });
538
+ return { data: { metrics }, html: buildProcessProgressDoc(v) };
539
+ }
540
+ case 'calorie.view.review-template': {
541
+ const { start, end } = defaultRange(db, params);
542
+ const v = buildReviewTemplateView(db, start, end);
543
+ const metrics = nums({
544
+ days: v.days, meals: v.meals, avgCalorie: v.avgCalorie,
545
+ sessions: v.sessions, minutes: v.minutes, weightChange: v.weightChange,
546
+ points: v.points.length,
547
+ });
548
+ return { data: { metrics }, html: buildReviewTemplateDoc(v) };
272
549
  }
273
550
  case 'calorie.view.goal': {
274
551
  const { start, end } = defaultRange(db, params);
@@ -343,7 +620,7 @@ export function dispatch(key, params, db) {
343
620
  correlationR: c.analysis.correlation.r, correlationN: c.analysis.correlation.n, days: c.analysis.days,
344
621
  seriesDays: c.series.length,
345
622
  });
346
- return { data: { metrics }, html: renderCombinedHtml(c) };
623
+ return { data: { metrics }, html: buildCombinedDoc(c) };
347
624
  }
348
625
  case 'calorie.view.deficit': {
349
626
  const { start, end } = defaultRange(db, params);
@@ -354,7 +631,7 @@ export function dispatch(key, params, db) {
354
631
  days: d.meta.days, weekdayCount: d.meta.weekdayCount, weekendCount: d.meta.weekendCount,
355
632
  targetIntake: d.target.intake, targetTdee: d.target.tdee,
356
633
  });
357
- return { data: { metrics }, html: renderDeficitHtml(d) };
634
+ return { data: { metrics }, html: buildDeficitDoc(d) };
358
635
  }
359
636
  case 'calorie.view.diet-review': {
360
637
  const { start, end } = defaultRange(db, params);
@@ -366,13 +643,15 @@ export function dispatch(key, params, db) {
366
643
  'meal.晚餐': r.byMeal.find((s) => s.meal === '晚餐')?.totalCalories,
367
644
  'meal.加餐': r.byMeal.find((s) => s.meal === '加餐')?.totalCalories,
368
645
  });
369
- return { data: { metrics }, html: renderDietReviewHtml(r) };
646
+ // #108 · 复盘全文档(趋势折线+配比环+高频 TOP5+按餐汇总;TOP5 取数失败即空态,不编数)。
647
+ const fr = dietFoodRanking(db, start, end, 'frequent', 5);
648
+ return { data: { metrics }, html: buildDietReviewDoc(r, fr.status === 'ok' ? (fr.data ?? null) : null) };
370
649
  }
371
650
  case 'calorie.view.health': {
372
651
  const { start, end } = defaultRange(db, params);
373
652
  const h = buildHealthPlate(db, start, end);
374
653
  const metrics = nums({ loggedDays: h.loggedDays, avgIntake: h.avgIntake, avgDeficit: h.avgDeficit });
375
- return { data: { metrics }, html: renderHealthHtml(h) };
654
+ return { data: { metrics }, html: buildHealthDoc(h) };
376
655
  }
377
656
  case 'calorie.view.ranking': {
378
657
  const { start, end } = defaultRange(db, params);
@@ -384,11 +663,11 @@ export function dispatch(key, params, db) {
384
663
  const one = buildFoodRankingPlate(db, start, end, category, topN);
385
664
  const top = one.items[0];
386
665
  const metrics = nums({ total: one.items.length, topN: one.topN, topCal: top?.totalCal, topCnt: top?.cnt, topRank: top?.rank });
387
- return { data: { metrics }, html: renderRankingHtml(one) };
666
+ return { data: { metrics }, html: buildRankingDoc(one) };
388
667
  }
389
668
  const all = buildAllRankings(db, start, end, topN);
390
669
  const metrics = nums({ okCount: all.okCount, topN: all.topN });
391
- return { data: { metrics }, html: renderAllRankingsHtml(all) };
670
+ return { data: { metrics }, html: buildAllRankingsDoc(all) };
392
671
  }
393
672
  case 'calorie.view.library': {
394
673
  const category = optStr(params, 'category') ?? null;
@@ -398,7 +677,7 @@ export function dispatch(key, params, db) {
398
677
  const lib = buildProductLibrary(db, category, limit);
399
678
  const stats = buildProductStats(db);
400
679
  const metrics = nums({ total: lib.total, statsTotal: stats.total });
401
- return { data: { metrics }, html: renderProductLibraryHtml(lib) };
680
+ return { data: { metrics }, html: buildLibraryDoc(lib, stats.total) };
402
681
  }
403
682
  case 'calorie.view.search': {
404
683
  const keyword = needStr(params, 'keyword');
@@ -407,7 +686,7 @@ export function dispatch(key, params, db) {
407
686
  fail(2, 'limit 须为 1..100 整数');
408
687
  const s = buildProductSearch(db, keyword, limit);
409
688
  const metrics = nums({ total: s.total, limit: limit });
410
- return { data: { metrics }, html: renderProductSearchHtml(s) };
689
+ return { data: { metrics }, html: buildSearchDoc(s) };
411
690
  }
412
691
  case 'calorie.photo.list': {
413
692
  const dir = photosDirOf(params);
@@ -450,12 +729,39 @@ export function dispatch(key, params, db) {
450
729
  return { data: { summary: 'GIF 任务:标签 ' + gif.tag + ' 共 ' + gif.photoCount + ' 张(' + (gif.firstDate ?? '—') + ' ~ ' + (gif.lastDate ?? '—') + ')· ' + gif.note }, html: renderGifHtml(gif) };
451
730
  }
452
731
  case 'calorie.help.center': {
732
+ // #91 · Q9:本键承载**全量速查台**(#88 壳,三态同源);**照片 10 键兼容走 `q`**(既有语义逐字不变)。
733
+ // D6:交付形态**显式** `mode`(`file`/`inline`/`text`),不靠猜、不从别的参数推;非法值即 exit 2。
453
734
  const q = optStr(params, 'q') ?? optStr(params, 'keyword') ?? undefined;
454
- const hits = q ? lookupPhotoHelp(q) : buildPhotoHelp();
455
- if (q && hits.length === 0)
456
- throw new CalorieRenderError('missing-data', 'HELP 无命中:' + q);
457
- const items = hits.map((h) => ({ wakeWord: h.wakeWord, key: h.key, desc: h.desc, exec: h.exec }));
458
- return { data: { items, total: items.length }, html: renderPhotoHelpHtml(hits, q) };
735
+ const modeRaw = optStr(params, 'mode');
736
+ const photoLookup = q !== undefined && q !== '';
737
+ if (photoLookup && modeRaw !== undefined) {
738
+ fail(2, '参数 q mode 互斥:q=照片 HELP 现找(10 键),mode=全量速查台交付形态('
739
+ + HELP_CENTER_MODES.join('/') + ')');
740
+ }
741
+ if (q !== undefined && modeRaw === undefined) {
742
+ // 照片路径(原样保留):非空 q = 现找(无命中 exit 4);`q:""` = 全量 10 键。
743
+ const hits = photoLookup ? lookupPhotoHelp(q) : buildPhotoHelp();
744
+ if (photoLookup && hits.length === 0)
745
+ throw new CalorieRenderError('missing-data', 'HELP 无命中:' + q);
746
+ const items = hits.map((h) => ({ wakeWord: h.wakeWord, key: h.key, desc: h.desc, exec: h.exec }));
747
+ return { data: { items, total: items.length }, html: renderPhotoHelpHtml(hits, q) };
748
+ }
749
+ // 全量速查台:缺省 `file`(#88 的缺省同值);`text` 态把文本一并回传(file/inline 只回落点,不塞 1 MB)。
750
+ const mode = (modeRaw ?? 'file');
751
+ if (!HELP_CENTER_MODES.includes(mode)) {
752
+ fail(2, '参数 mode 非法(' + String(mode) + '):须为 ' + HELP_CENTER_MODES.join('/'));
753
+ }
754
+ const sceneData = buildHelpSceneData();
755
+ const rendered = renderHelpCenterHtml({ mode, sceneData });
756
+ const data = {
757
+ ...helpCenterIndex(sceneData),
758
+ mode,
759
+ bytes: Buffer.byteLength(rendered.html, 'utf8'),
760
+ };
761
+ if (mode === 'text')
762
+ data['text'] = rendered.html;
763
+ // #83 · 渲染层已定文本交付:产物即文本(③ 文本态之一),交付装配层据此走文本通道。
764
+ return { data, html: rendered.html, deliveryKind: mode === 'text' ? 'text' : 'html' };
459
765
  }
460
766
  case 'calorie.help.lookup': {
461
767
  const q = needStr(params, 'q');
@@ -495,7 +801,7 @@ export function dispatch(key, params, db) {
495
801
  changeKg: w.trend.changeKg, dailyChangeG: w.trend.dailyChangeG,
496
802
  weightGoal: w.weightGoal, gapKg: w.gapKg,
497
803
  });
498
- return { data: { metrics }, html: renderWeightHtml(w) };
804
+ return { data: { metrics }, html: buildWeightDoc(w) };
499
805
  }
500
806
  case 'calorie.view.weight-history': {
501
807
  const startDate = optStr(params, 'startDate') ?? optStr(params, 'start');
@@ -515,7 +821,7 @@ export function dispatch(key, params, db) {
515
821
  spanDays: h.change?.spanDays, first: h.change?.first, last: h.change?.last,
516
822
  delta: h.change?.delta, dailyAvg: h.change?.dailyAvg,
517
823
  });
518
- return { data: { metrics }, html: renderWeightHistoryHtml(h) };
824
+ return { data: { metrics }, html: buildWeightHistoryDoc(h) };
519
825
  }
520
826
  case 'calorie.view.weight-compare': {
521
827
  const start = needStr(params, 'start');
@@ -528,7 +834,7 @@ export function dispatch(key, params, db) {
528
834
  currentAvg: v.compare.currentPeriod.avgWeight, compareAvg: v.compare.comparePeriod.avgWeight,
529
835
  currentChange: v.compare.currentPeriod.changeKg, compareChange: v.compare.comparePeriod.changeKg,
530
836
  });
531
- return { data: { metrics }, html: renderWeightCompareHtml(v) };
837
+ return { data: { metrics }, html: buildWeightCompareDoc(v) };
532
838
  }
533
839
  case 'calorie.view.weight-review': {
534
840
  const today = optStr(params, 'today') ?? optStr(params, 'date');
@@ -538,7 +844,7 @@ export function dispatch(key, params, db) {
538
844
  gapKg: v.milestone.gapKg, actualDailyChangeKg: v.milestone.actualDailyChangeKg,
539
845
  estDays: v.milestone.estDays, calorieAdjustment: v.milestone.calorieAdjustment,
540
846
  });
541
- return { data: { metrics }, html: renderWeightReviewHtml(v) };
847
+ return { data: { metrics }, html: buildWeightReviewDoc(v) };
542
848
  }
543
849
  case 'calorie.view.volatility': {
544
850
  const { start, end } = defaultRange(db, params);
@@ -550,7 +856,7 @@ export function dispatch(key, params, db) {
550
856
  points: v.volatility.points.length, anomalies: v.volatility.recentAnomalies.length,
551
857
  deviationKg: v.volatility.earlyWarning.deviationKg,
552
858
  });
553
- return { data: { metrics }, html: renderVolatilityHtml(v) };
859
+ return { data: { metrics }, html: buildVolatilityDoc(v) };
554
860
  }
555
861
  case 'calorie.view.body-composition': {
556
862
  const days = optNum(params, 'days') ?? 90;
@@ -558,7 +864,7 @@ export function dispatch(key, params, db) {
558
864
  const limit = optNum(params, 'limit') ?? 20;
559
865
  const v = buildBodyCompositionView(db, { days: days, source: source ?? undefined, limit: limit });
560
866
  const metrics = nums({ total: v.total, latestPct: v.latestPct, trendDays: v.trend.length });
561
- return { data: { metrics }, html: renderBodyCompositionHtml(v) };
867
+ return { data: { metrics }, html: buildBodyCompositionDoc(v) };
562
868
  }
563
869
  case 'calorie.view.body-measure': {
564
870
  const metric = optStr(params, 'metric');
@@ -568,7 +874,7 @@ export function dispatch(key, params, db) {
568
874
  const dateTo = optStr(params, 'dateTo');
569
875
  const v = buildBodyMeasureView(db, { metric: metric ?? undefined, days: days, limit: limit, dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined });
570
876
  const metrics = nums({ total: v.total, latestVal: v.latestVal, trendDays: v.trend.length });
571
- return { data: { metrics }, html: renderBodyMeasureHtml(v) };
877
+ return { data: { metrics }, html: buildBodyMeasureDoc(v) };
572
878
  }
573
879
  case 'calorie.view.plan': {
574
880
  const v = buildPlanView(db);
@@ -581,14 +887,14 @@ export function dispatch(key, params, db) {
581
887
  fail(2, '缺参数 plan(PlanInput 对象)');
582
888
  const catalog = params['catalog'];
583
889
  const v = buildPlanWizardView(plan, catalog ?? undefined);
584
- const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount, insertedCount: v.insertedCount });
890
+ const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount, checkedSessions: v.checkedSessions });
585
891
  return { data: { metrics }, html: renderPlanWizardHtml(v) };
586
892
  }
587
893
  case 'calorie.view.exercise-goal': {
588
894
  const { start, end } = defaultRange(db, params);
589
895
  const v = buildExerciseGoalView(db, start, end);
590
896
  const metrics = nums({ dailyGoal: v.dailyGoal, goalTotal: v.goalTotal, actual: v.actual, pct: v.pct, gap: v.gap, achieved: v.achieved ? 1 : 0, days: v.days });
591
- return { data: { metrics }, html: renderExerciseGoalHtml(v) };
897
+ return { data: { metrics }, html: buildExerciseGoalDoc(v) };
592
898
  }
593
899
  case 'calorie.view.goal-expiring': {
594
900
  const withinDays = optNum(params, 'withinDays') ?? optNum(params, 'days') ?? 14;
@@ -598,10 +904,11 @@ export function dispatch(key, params, db) {
598
904
  return { data: { metrics }, html: renderGoalExpiringHtml(v) };
599
905
  }
600
906
  case 'calorie.view.goal-predict': {
601
- const { start, end } = defaultRange(db, params);
907
+ // #103 G2 · 目标预测需 ≥14 条体重记录(simulate SIM_MIN_DAYS=14),7 天默认窗结构性不可达 → 默认 14 天。
908
+ const { start, end } = defaultRange(db, params, 14);
602
909
  const v = buildGoalPredictView(db, start, end);
603
910
  const metrics = nums({ targetKg: v.targetKg, current: v.current, daysLeft: v.daysLeft, ratePerWeek: v.ratePerWeek, feasible: v.feasible ? 1 : 0 });
604
- return { data: { metrics }, html: renderGoalPredictHtml(v) };
911
+ return { data: { metrics }, html: buildGoalPredictDoc(v) };
605
912
  }
606
913
  case 'calorie.view.goal-vs-actual': {
607
914
  const { start, end } = defaultRange(db, params);
@@ -614,29 +921,30 @@ export function dispatch(key, params, db) {
614
921
  return { data: { metrics }, html: renderGoalVsActualHtml(v) };
615
922
  }
616
923
  case 'calorie.view.predict': {
617
- const { start, end } = defaultRange(db, params);
924
+ // #103 G2 · goal-predict:默认 14 天,否则缺省调用恒走 missing-data。
925
+ const { start, end } = defaultRange(db, params, 14);
618
926
  const horizonDays = optNum(params, 'horizonDays') ?? optNum(params, 'days') ?? 30;
619
927
  const v = buildPredictView(db, start, end, horizonDays);
620
928
  const metrics = nums({ current: v.current, ratePerWeek: v.ratePerWeek, forecastValue: v.forecastValue, forecastLo: v.forecastLo, forecastHi: v.forecastHi, horizonDays: v.horizonDays });
621
- return { data: { metrics }, html: renderPredictHtml(v) };
929
+ return { data: { metrics }, html: buildPredictDoc(v) };
622
930
  }
623
931
  case 'calorie.view.anomaly': {
624
932
  const kind = needStr(params, 'kind');
625
933
  const { start, end } = defaultRange(db, params);
626
934
  const v = buildAnomalyView(db, kind, start, end);
627
935
  const metrics = nums({ findingCount: v.findingCount, days: v.diagnosis.days, degraded: v.diagnosis.degraded ? 1 : 0 });
628
- return { data: { metrics }, html: renderAnomalyHtml(v) };
936
+ return { data: { metrics }, html: buildAnomalyDoc(v) };
629
937
  }
630
938
  case 'calorie.view.contraindication': {
631
939
  const part = optStr(params, 'part') ?? 'all';
632
940
  const v = buildContraView(db, part);
633
941
  const metrics = nums({ scannedSessions: v.scannedSessions, scannedMovements: v.scannedMovements, errorCount: v.errorCount, warnCount: v.warnCount, infoCount: v.infoCount });
634
- return { data: { metrics }, html: renderContraHtml(v) };
942
+ return { data: { metrics }, html: buildContraDoc(v) };
635
943
  }
636
944
  case 'calorie.view.dedupe': {
637
945
  const v = buildDedupeView(db);
638
946
  const metrics = nums({ groupCount: v.groupCount, rowCount: v.rowCount, totalProducts: v.totalProducts });
639
- return { data: { metrics }, html: renderDedupeHtml(v) };
947
+ return { data: { metrics }, html: buildDedupeDoc(v) };
640
948
  }
641
949
  case 'calorie.view.profile': {
642
950
  const v = buildProfileView(db);
@@ -652,6 +960,106 @@ export function dispatch(key, params, db) {
652
960
  throw new Error('unreachable');
653
961
  }
654
962
  }
963
+ /* ── #83 · 三态交付装配(M4 HTML-First + 渲染失败回执) ───────────────────────────────── */
964
+ /** 交付落点的可读描述(回执文案用;默认目录名必须出现在文案里,便于用户定位)。 */
965
+ function describeDeliveryTarget(explicit) {
966
+ return explicit !== undefined ? '显式落点 ' + explicit : '默认目录 <SKILLS_DB_PATH>/' + HTML_DIR_NAME;
967
+ }
968
+ /** ③ 文本态的结构化文本:**同源**取 `buildDataText`(#77 契约,五 shape 投影)。
969
+ * `fallback` 形不在 `SERIALIZABLE_SHAPES` 内(#93 登记:无 CLI 出口)——此时退化为缩进 JSON,
970
+ * 仍是「结构化文本」且零编造;本退化分支由 `delivery-83.test.mjs` 直接钉住。 */
971
+ function dataTextOf(shape, key, data) {
972
+ try {
973
+ return buildDataText({
974
+ envelope: { version: ENVELOPE_VERSION, skill: CALORIE_SKILL, shape, key, data },
975
+ format: 'text',
976
+ });
977
+ }
978
+ catch {
979
+ return JSON.stringify(data, null, 2);
980
+ }
981
+ }
982
+ /** 三态判定 + envelope 装配(**唯一交付落点**:同一 key、同一份 `data`,绝不各自取数):
983
+ * ③ 文本态:用户**明确**要文本(通用 `--params '{"delivery":"text"}'`)/渲染层已产出文本
984
+ * (#91 `help.center` 的 `mode:'text'`)/**无 HTML 产物**(结构缝:`html` 为空 ⇒ 无对应模板,允许文字答);
985
+ * ② 内联态:有 HTML 产物但写不进去(只读/沙箱 `EACCES|EPERM|EROFS|EBUSY`)⇒ 产物随 envelope 回传;
986
+ * ① 文件态(默认):落盘 `calorie_html/*.html`,`data.output` 与 `delivery.path` 同值同源。
987
+ * `delivery` 为 envelope 的**顶层追加字段**(既有五字段一字不改);P9「stdout 一行 JSON」不变。 */
988
+ export function buildDeliveredEnvelope(input) {
989
+ const { key, shape, params, out } = input;
990
+ const html = typeof out.html === 'string' ? out.html : '';
991
+ const askedText = params['delivery'] === 'text';
992
+ const kind = askedText ? 'text' : (out.deliveryKind ?? (html.trim() === '' ? 'text' : 'html'));
993
+ if (kind === 'text') {
994
+ // 三态同源:文本由**同一份** envelope data 经 #77 `buildDataText` 投影(技能侧不自产第二套序列化)。
995
+ const text = typeof out.data['text'] === 'string' ? out.data['text'] : dataTextOf(shape, key, out.data);
996
+ const data = { ...out.data, text };
997
+ // 渲染层已定文本交付的键(#91 `help.center` text 态)**保留既有落盘**(`data.output` 指向该文本文件);
998
+ // 其余键的文本态只走 envelope(③ 产物=结构化文本,不落 HTML 文件)。
999
+ if (out.deliveryKind === 'text') {
1000
+ const d = deliverHtml({ key, params, explicit: input.explicit, html: text });
1001
+ if (d.mode === 'file')
1002
+ data['output'] = d.path;
1003
+ return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
1004
+ mode: 'text', path: d.mode === 'file' ? d.path : undefined, shape, html: text, bytes: d.bytes, template: 'text',
1005
+ }));
1006
+ }
1007
+ return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
1008
+ mode: 'text', shape, html: text, bytes: Buffer.byteLength(text, 'utf8'), template: 'text',
1009
+ }));
1010
+ }
1011
+ const d = deliverHtml({ key, params, explicit: input.explicit, html });
1012
+ const data = d.mode === 'file'
1013
+ ? { ...out.data, output: d.path }
1014
+ : { ...out.data, html };
1015
+ return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
1016
+ mode: d.mode, path: d.mode === 'file' ? d.path : undefined, shape, html, bytes: d.bytes,
1017
+ }));
1018
+ }
1019
+ /** #83 · M4「渲染失败回执」:**模板化**回执(`buildErrorReceipt` + `renderErrorHtml`,旧
1020
+ * `render_error_receipt.py` 的等价物)——**严禁手写 HTML 兜底**。回执自身也走三态:默认目录可写即落盘,
1021
+ * 否则内联随 stderr 回传。机器可读回执以一行 `RECEIPT {…}` 落 **stderr**(P9:stdout 保持纯净,
1022
+ * 不吐半截 envelope),exit 5 与既有「渲染/落盘失败」口径一致。 */
1023
+ function failWithReceipt(reason, key) {
1024
+ console.error('ERR 5: ' + reason);
1025
+ try {
1026
+ const receipt = buildErrorReceipt({
1027
+ sceneName: '渲染',
1028
+ wakeWord: key ?? '渲染失败',
1029
+ op: '渲染/落盘未完成',
1030
+ reason,
1031
+ suggestions: [
1032
+ '检查 SKILLS_DB_PATH 与 ' + HTML_DIR_NAME + ' 目录权限(只读/沙箱会自动转内联交付)',
1033
+ '用 --output <可写绝对路径> 显式指定落点后重试',
1034
+ '确认 ' + HTML_DIR_NAME + ' 未被同名文件占位(占位会挡住落点解析)',
1035
+ ],
1036
+ fixPrompt: 'calorie-cmd-read ' + (key ?? '<key>') + " --params '{…}' --output <可写绝对路径>",
1037
+ });
1038
+ const receiptHtml = renderErrorHtml(receipt);
1039
+ let delivery;
1040
+ try {
1041
+ const d = deliverHtml({
1042
+ key: key ?? 'calorie.help.center', params: {}, target: resolveReceiptHtmlPath(), html: receiptHtml,
1043
+ });
1044
+ delivery = buildDelivery({
1045
+ mode: d.mode, path: d.mode === 'file' ? d.path : undefined, shape: 'receipt', html: receiptHtml, bytes: d.bytes,
1046
+ });
1047
+ }
1048
+ catch {
1049
+ delivery = buildDelivery({
1050
+ mode: 'inline', shape: 'receipt', html: receiptHtml, bytes: Buffer.byteLength(receiptHtml, 'utf8'),
1051
+ });
1052
+ }
1053
+ // 内联回执把模板化回执页面一并回传(否则调用方拿不到回执正文);落盘态只回路径。
1054
+ console.error('RECEIPT ' + JSON.stringify({
1055
+ ok: false, ...receipt, delivery, html: delivery.mode === 'inline' ? receiptHtml : undefined,
1056
+ }));
1057
+ }
1058
+ catch (e) {
1059
+ console.error('TOAST: 回执生成失败(' + (e.message || String(e)) + ')');
1060
+ }
1061
+ process.exit(5);
1062
+ }
655
1063
  function parseReadArgs(a) {
656
1064
  return parseArgs(a);
657
1065
  }
@@ -695,25 +1103,21 @@ async function main() {
695
1103
  const out = isCalorieWriteKey(o.key)
696
1104
  ? dispatchWrite(o.key, params, db)
697
1105
  : dispatch(o.key, params, db);
698
- // #87 · 输出落点:--output(显式覆盖)> --html(legacy 别名)> 默认 calorie_html/<中文command>_<TS>[_N].html
699
- // #87 返修 F4A2 S2-4):落点**解析**本身也会失败(如 <DB>/calorie_html 被同名文件占位 → EEXIST);
700
- // 旧写法把它漏到外层「未知失败」分支exit 4(=取数/超时)+「未知失败」文案。此处按渲染失败计:
701
- // exit 5 + 明确文案(与紧邻的「HTML 写盘失败」同码同形态)。
702
- let htmlTarget;
1106
+ // #83 · 三态交付(M4 HTML-First):① 文件态(默认)/② 内联态(只读·沙箱回退)/③ 文本态。
1107
+ // 落点:--output(显式覆盖)> --htmllegacy 别名)> 默认 calorie_html/<中文command>_<TS>[_N].html(#87/#119)。
1108
+ // 只读类写失败内联交付(产物随 envelope 回传,绝不因写不进去而文字答);
1109
+ // 结构错/渲染错 → 渲染失败回执(模板化回执,exit 5;严禁手写 HTML 兜底)。
703
1110
  try {
704
- htmlTarget = o.output ?? o.html ?? resolveDefaultHtmlPath(o.key);
1111
+ env = buildDeliveredEnvelope({
1112
+ key: o.key, shape: shape, out, params, explicit: o.output ?? o.html,
1113
+ });
705
1114
  }
706
1115
  catch (e) {
707
- fail(5, '渲染失败:HTML 落点解析失败(' + (o.output ?? o.html ?? '默认目录 <SKILLS_DB_PATH>/' + HTML_DIR_NAME)
708
- + '):' + e.message);
709
- }
710
- try {
711
- writeFileSync(resolveExplicitHtmlPath(htmlTarget), out.html, 'utf8');
712
- }
713
- catch (e) {
714
- fail(5, 'HTML 写盘失败:' + htmlTarget + '(' + e.message + ')');
1116
+ if (e instanceof CalorieRenderError)
1117
+ throw e;
1118
+ failWithReceipt('渲染失败:' + describeDeliveryTarget(o.output ?? o.html) + ':'
1119
+ + (e.message || String(e)), o.key);
715
1120
  }
716
- env = buildEnvelope(o.key, shape, { ...out.data, output: htmlTarget });
717
1121
  }
718
1122
  finally {
719
1123
  db.close();
@@ -723,7 +1127,9 @@ async function main() {
723
1127
  if (e instanceof CalorieRenderError) {
724
1128
  if (e.code === 'missing-data')
725
1129
  fail(4, '取数失败(缺失阻断):' + e.message);
726
- fail(e.code === 'bad-input' ? 2 : 5, (e.code === 'bad-input' ? '参数失败:' : '渲染失败:') + e.message);
1130
+ if (e.code === 'bad-input')
1131
+ fail(2, '参数失败:' + e.message);
1132
+ failWithReceipt('渲染失败:' + e.message, o.key);
727
1133
  }
728
1134
  if (e instanceof FetchError)
729
1135
  fail(4, '取数失败:' + e.message);