diet_pipeline.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. """
  2. 阶段 2:Nutritionist → Coach → Habit 三 Agent 串行流水线;
  3. 每阶段 LLM 输出经 Pydantic 校验,失败自动重试;统一错误码与降级。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import json
  8. import logging
  9. import re
  10. import uuid
  11. from typing import Any, Dict, List, Optional, Tuple, Type
  12. from pydantic import BaseModel, ValidationError
  13. from core.llm_adapter import get_llm_adapter
  14. from memory.store import format_reflect_memory_for_prompt, save_diet_run
  15. from rag.indexers import index_diet_run
  16. from rag.retriever import retrieve
  17. from service.diet_errors import DietErrorCode, diet_error_record
  18. from service.diet_schemas import (
  19. SCHEMA_VERSION,
  20. CoachOutput,
  21. FoodParseOutput,
  22. HabitOutput,
  23. MealPlan,
  24. MealPlanItem,
  25. NutritionistOutput,
  26. NutritionSummary,
  27. )
  28. from tools.diet_tools import dispatch_tool
  29. logger = logging.getLogger(__name__)
  30. DIET_STAGE_TIMEOUT_SEC = 95.0
  31. MAX_STAGE_ATTEMPTS = 2
  32. def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
  33. if not text:
  34. return None
  35. t = text.strip()
  36. m = re.search(r"```(?:json)?\s*([\s\S]*?)```", t)
  37. if m:
  38. t = m.group(1).strip()
  39. try:
  40. return json.loads(t)
  41. except json.JSONDecodeError:
  42. i = t.find("{")
  43. j = t.rfind("}")
  44. if i >= 0 and j > i:
  45. try:
  46. return json.loads(t[i : j + 1])
  47. except json.JSONDecodeError:
  48. return None
  49. return None
  50. def _goal_target_protein(context: Dict[str, Any]) -> float:
  51. goal = str(context.get("goal") or "maintain")
  52. if goal == "muscle_gain":
  53. return 130.0
  54. if goal == "fat_loss":
  55. return 95.0
  56. return 105.0
  57. def _fallback_food_parse(context: Dict[str, Any]) -> FoodParseOutput:
  58. raw = str(context.get("today_food_log_text") or "")
  59. pieces = [p.strip(" ,。;;\n\t") for p in re.split(r"[,,;;。]\s*", raw) if p.strip()]
  60. items = []
  61. for p in pieces[:10]:
  62. items.append(
  63. {
  64. "meal_time": "",
  65. "food_name": p[:40],
  66. "portion_text": "未明确",
  67. "confidence": 0.45,
  68. }
  69. )
  70. return FoodParseOutput(
  71. items=items,
  72. nutrition_summary=NutritionSummary(),
  73. parse_notes="(降级)食物解析阶段失败,已按日志片段做粗略拆分;营养值未估算。",
  74. )
  75. def _fallback_nutritionist(context: Dict[str, Any], nutrition_summary: NutritionSummary) -> NutritionistOutput:
  76. tgt = _goal_target_protein(context)
  77. cur = float(nutrition_summary.protein_g or 0)
  78. gap = max(0.0, tgt - cur)
  79. return NutritionistOutput(
  80. protein_gap_g=gap,
  81. rationale="(降级)根据目标与日志解析结果估算蛋白缺口;LLM 阶段未通过校验或超时。",
  82. suggested_lookup_queries=["鸡蛋,希腊酸奶,牛奶,豆浆,即食鸡胸肉"],
  83. candidate_focus=["便利店高蛋白", "训练后补充"],
  84. )
  85. def _fallback_coach(context: Dict[str, Any]) -> CoachOutput:
  86. activity_text = str(context.get("activity_context") or "")
  87. train = any(k in activity_text for k in ["训练", "力量", "健身", "workout", "training"])
  88. return CoachOutput(
  89. training_recovery_note="(降级)晚间安排力量训练时需优先补充蛋白与适量碳水;具体强度以当日体感为准。"
  90. if train
  91. else "(降级)非训练日仍以均衡蛋白为主,避免睡前过饱。",
  92. timing_constraints="训练后 1~2 小时内尽量安排一餐;便利店即食优先选成分表蛋白较高的品类。"
  93. if train
  94. else "晚餐时间尽量规律,避免过晚大量进食。",
  95. energy_note="",
  96. coach_constraints_for_menu=["少油炸", "避免单次过量乳糖不耐受品类"],
  97. )
  98. def _fallback_habit(
  99. context: Dict[str, Any], reflect_mem: str, nutrition_summary: NutritionSummary
  100. ) -> HabitOutput:
  101. tgt = _goal_target_protein(context)
  102. cur = float(nutrition_summary.protein_g or 0)
  103. gap = max(25.0, min(80.0, max(0.0, tgt - cur)))
  104. return HabitOutput(
  105. reflect_alignment="(降级)未能生成完整习惯层输出;已忽略部分 Reflect 细节,仅做安全兜底推荐。"
  106. + (" 近期有用户反馈记录,建议下次缩短决策链或检查模型输出格式。" if "暂无" not in reflect_mem else ""),
  107. execution_hints=["优先买得到、可立即食用的组合", "若仍失败请改选外卖蛋白套餐"],
  108. meal_plan=MealPlan(
  109. items=[
  110. MealPlanItem(
  111. name="希腊酸奶",
  112. portion="约 150g×1 杯",
  113. est_protein_g=min(18.0, gap * 0.35),
  114. why="便利店常见,蛋白密度较高",
  115. ),
  116. MealPlanItem(
  117. name="水煮蛋",
  118. portion="2 个",
  119. est_protein_g=12.0,
  120. why="易购买、蛋白稳定",
  121. ),
  122. MealPlanItem(
  123. name="豆浆",
  124. portion="300ml",
  125. est_protein_g=min(12.0, gap * 0.2),
  126. why="补充液体蛋白与水分",
  127. ),
  128. ],
  129. total_est_protein_g=round(min(gap, 45.0), 1),
  130. tips=["此为 schema/LLM 失败时的安全兜底菜单,建议重试或检查 API。"],
  131. ),
  132. )
  133. async def _run_validated_stage(
  134. llm: Any,
  135. stage: str,
  136. prompt: str,
  137. model_cls: Type[BaseModel],
  138. errors: List[Dict[str, Any]],
  139. timeout_sec: float = DIET_STAGE_TIMEOUT_SEC,
  140. ) -> Tuple[Optional[BaseModel], List[Dict[str, Any]]]:
  141. attempts: List[Dict[str, Any]] = []
  142. repair_hint = ""
  143. for attempt in range(MAX_STAGE_ATTEMPTS):
  144. full_prompt = prompt
  145. if repair_hint:
  146. full_prompt += (
  147. "\n\n【修正要求】上一输出未通过 schema 校验或无法解析:\n"
  148. f"{repair_hint}\n请只输出 **一个** JSON 对象,字段齐全、类型正确,不要 Markdown。"
  149. )
  150. try:
  151. raw = await asyncio.wait_for(llm.ainvoke(full_prompt), timeout=timeout_sec)
  152. except asyncio.TimeoutError:
  153. errors.append(
  154. diet_error_record(
  155. stage,
  156. DietErrorCode.LLM_TIMEOUT,
  157. "LLM 调用超时",
  158. attempt=attempt,
  159. )
  160. )
  161. attempts.append(
  162. {"attempt": attempt, "ok": False, "error_code": DietErrorCode.LLM_TIMEOUT.value}
  163. )
  164. repair_hint = "上次超时;请输出更紧凑的 JSON,保留所有必填字段。"
  165. continue
  166. except Exception as e:
  167. # 上游模型网关 5xx / SDK 异常都归一为阶段中止错误,避免接口直接 500。
  168. errors.append(
  169. diet_error_record(
  170. stage,
  171. DietErrorCode.STAGE_ABORTED,
  172. f"LLM 调用异常: {type(e).__name__}",
  173. attempt=attempt,
  174. detail=str(e)[:1200],
  175. )
  176. )
  177. attempts.append(
  178. {
  179. "attempt": attempt,
  180. "ok": False,
  181. "error_code": DietErrorCode.STAGE_ABORTED.value,
  182. "exception": type(e).__name__,
  183. }
  184. )
  185. repair_hint = "上轮调用失败,请仅输出合法 JSON。"
  186. continue
  187. obj = _extract_json_object(raw)
  188. if obj is None:
  189. errors.append(
  190. diet_error_record(
  191. stage,
  192. DietErrorCode.LLM_PARSE_ERROR,
  193. "无法从模型输出解析 JSON",
  194. attempt=attempt,
  195. detail=(raw[:1200] if raw else ""),
  196. )
  197. )
  198. attempts.append(
  199. {
  200. "attempt": attempt,
  201. "ok": False,
  202. "error_code": DietErrorCode.LLM_PARSE_ERROR.value,
  203. "llm_preview": (raw[:1500] if raw else ""),
  204. }
  205. )
  206. repair_hint = "模型输出不是合法 JSON;请严格输出 JSON only。"
  207. continue
  208. try:
  209. validated = model_cls.model_validate(obj)
  210. attempts.append(
  211. {
  212. "attempt": attempt,
  213. "ok": True,
  214. "error_code": None,
  215. "llm_preview": raw[:2500] if raw else "",
  216. }
  217. )
  218. return validated, attempts
  219. except ValidationError as ve:
  220. err_text = ve.json()[:2000]
  221. errors.append(
  222. diet_error_record(
  223. stage,
  224. DietErrorCode.VALIDATION_FAILED,
  225. "Pydantic 校验失败",
  226. attempt=attempt,
  227. detail=err_text,
  228. )
  229. )
  230. attempts.append(
  231. {
  232. "attempt": attempt,
  233. "ok": False,
  234. "error_code": DietErrorCode.VALIDATION_FAILED.value,
  235. "validation_detail": err_text,
  236. "parsed_shape": {k: type(v).__name__ for k, v in obj.items()}
  237. if isinstance(obj, dict)
  238. else None,
  239. }
  240. )
  241. repair_hint = err_text
  242. return None, attempts
  243. def _prefetch_tools(user_id: str, context: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
  244. trace_tools: List[Dict[str, Any]] = []
  245. activity: Dict[str, Any] = {}
  246. nutrition: Dict[str, Any] = {}
  247. try:
  248. activity = dispatch_tool(
  249. "activity_sleep_summary", {"user_id": user_id}, user_id
  250. )
  251. except Exception as e:
  252. trace_tools.append(
  253. {
  254. "tool": "activity_sleep_summary",
  255. "ok": False,
  256. "error": str(e),
  257. }
  258. )
  259. else:
  260. trace_tools.append({"tool": "activity_sleep_summary", "ok": True, "result": activity})
  261. default_q = "鸡蛋,希腊酸奶,牛奶,豆浆,即食鸡胸肉"
  262. try:
  263. nutrition = dispatch_tool(
  264. "nutrition_lookup",
  265. {"query": context.get("nutrition_prefetch_query") or default_q},
  266. user_id,
  267. )
  268. except Exception as e:
  269. trace_tools.append({"tool": "nutrition_lookup", "ok": False, "error": str(e)})
  270. else:
  271. trace_tools.append({"tool": "nutrition_lookup", "ok": True, "result": nutrition})
  272. return {"activity": activity, "nutrition": nutrition}, trace_tools
  273. class DietMultiAgentPipeline:
  274. def __init__(self) -> None:
  275. self.llm = get_llm_adapter()
  276. async def run(
  277. self,
  278. user_id: str,
  279. context: Dict[str, Any],
  280. *,
  281. replayed_from_run_id: Optional[str] = None,
  282. ) -> Dict[str, Any]:
  283. run_id = str(uuid.uuid4())
  284. reflect_mem = format_reflect_memory_for_prompt(user_id, limit=8)
  285. errors: List[Dict[str, Any]] = []
  286. pipeline_trace: List[Dict[str, Any]] = []
  287. rag_result = await asyncio.to_thread(
  288. retrieve,
  289. user_id,
  290. {
  291. "scenario": "diet_recommendation",
  292. "goal": context.get("goal"),
  293. "free_notes": context.get("free_notes", ""),
  294. "today_food_log_text": str(context.get("today_food_log_text") or "")[:600],
  295. "query": "训练后蛋白补齐与执行阻碍规避",
  296. },
  297. )
  298. rag_summary = rag_result.get("summary", "(暂无召回记忆)")
  299. pipeline_trace.append({"phase": "rag_retrieve", "debug": rag_result.get("debug", {})})
  300. tool_bundle, tool_trace = _prefetch_tools(user_id, context)
  301. pipeline_trace.append({"phase": "tool_prefetch", "tools": tool_trace})
  302. degraded = False
  303. # ----- Food Parse (LLM) -----
  304. fp_prompt = f"""你是食物日志解析 Agent。请把用户自然语言饮食记录解析为 JSON。只输出一个 JSON 对象,不要 Markdown。
  305. 结构:
  306. {{
  307. "items": [
  308. {{
  309. "meal_time": string, // breakfast/lunch/dinner/snack 或空字符串
  310. "food_name": string,
  311. "portion_text": string,
  312. "confidence": number // 0~1
  313. }}
  314. ],
  315. "nutrition_summary": {{
  316. "protein_g": number,
  317. "carb_g": number,
  318. "fat_g": number,
  319. "fiber_g": number,
  320. "sodium_mg": number,
  321. "calories_kcal": number
  322. }},
  323. "parse_notes": string
  324. }}
  325. 要求:
  326. - 从 today_food_log_text 中尽可能提取食物与份量;没有明确份量可写“未明确”。
  327. - nutrition_summary 给出粗略估计值;无法判断可填 0。
  328. - 字段齐全且类型正确。
  329. 用户场景:
  330. {json.dumps(context, ensure_ascii=False, indent=2)}
  331. """
  332. fp, fp_attempts = await _run_validated_stage(
  333. self.llm, "food_parse", fp_prompt, FoodParseOutput, errors
  334. )
  335. fp_fb = False
  336. if fp is None:
  337. fp = _fallback_food_parse(context)
  338. fp_fb = True
  339. degraded = True
  340. errors.append(
  341. diet_error_record(
  342. "food_parse",
  343. DietErrorCode.DEGRADED_FALLBACK,
  344. "食物解析阶段失败,已使用规则降级输出",
  345. )
  346. )
  347. pipeline_trace.append(
  348. {
  349. "phase": "food_parse",
  350. "fallback_used": fp_fb,
  351. "attempts": fp_attempts,
  352. "output": fp.model_dump(),
  353. }
  354. )
  355. # ----- Nutritionist -----
  356. n_prompt = f"""你是 **Nutritionist(营养师)Agent**。只输出 **一个 JSON**,不要其它文字。
  357. 字段与类型必须完全一致:
  358. {{
  359. "protein_gap_g": number,
  360. "rationale": string,
  361. "suggested_lookup_queries": string[],
  362. "candidate_focus": string[]
  363. }}
  364. 用户场景:
  365. {json.dumps(context, ensure_ascii=False, indent=2)}
  366. 食物解析结果(LLM):
  367. {json.dumps(fp.model_dump(), ensure_ascii=False, indent=2)}
  368. Reflect 记忆(调整推荐策略):
  369. {reflect_mem}
  370. 历史记忆召回(RAG):
  371. {rag_summary}
  372. Mock 营养表检索结果(供参考):
  373. {json.dumps(tool_bundle.get("nutrition", {}), ensure_ascii=False, indent=2)}
  374. """
  375. nu, nu_attempts = await _run_validated_stage(
  376. self.llm, "nutritionist", n_prompt, NutritionistOutput, errors
  377. )
  378. nu_fb = False
  379. if nu is None:
  380. nu = _fallback_nutritionist(context, fp.nutrition_summary)
  381. nu_fb = True
  382. degraded = True
  383. errors.append(
  384. diet_error_record(
  385. "nutritionist",
  386. DietErrorCode.DEGRADED_FALLBACK,
  387. "营养师阶段失败,已使用规则降级输出",
  388. )
  389. )
  390. pipeline_trace.append(
  391. {
  392. "phase": "nutritionist",
  393. "fallback_used": nu_fb,
  394. "attempts": nu_attempts,
  395. "output": nu.model_dump(),
  396. }
  397. )
  398. # 按营养师建议追加一次营养查询(可选)
  399. extra_nutrition: Dict[str, Any] = {}
  400. if nu.suggested_lookup_queries:
  401. q = ",".join(nu.suggested_lookup_queries[:3])
  402. try:
  403. extra_nutrition = dispatch_tool(
  404. "nutrition_lookup", {"query": q[:200]}, user_id
  405. )
  406. except Exception as e:
  407. errors.append(
  408. diet_error_record(
  409. "tool",
  410. DietErrorCode.TOOL_ERROR,
  411. f"nutrition_lookup 追加查询失败: {e}",
  412. )
  413. )
  414. extra_nutrition = {"error": str(e)}
  415. tool_bundle["nutrition_extra"] = extra_nutrition
  416. # ----- Coach -----
  417. c_prompt = f"""你是 **Coach(运动恢复)Agent**。只输出 **一个 JSON**。
  418. 结构:
  419. {{
  420. "training_recovery_note": string,
  421. "timing_constraints": string,
  422. "energy_note": string,
  423. "coach_constraints_for_menu": string[]
  424. }}
  425. 用户场景:
  426. {json.dumps(context, ensure_ascii=False, indent=2)}
  427. 食物解析(营养汇总):
  428. {json.dumps(fp.nutrition_summary.model_dump(), ensure_ascii=False, indent=2)}
  429. 营养师结论:
  430. {json.dumps(nu.model_dump(), ensure_ascii=False, indent=2)}
  431. 活动/睡眠摘要:
  432. {json.dumps(tool_bundle.get("activity", {}), ensure_ascii=False, indent=2)}
  433. 历史记忆召回(RAG):
  434. {rag_summary}
  435. """
  436. co, co_attempts = await _run_validated_stage(
  437. self.llm, "coach", c_prompt, CoachOutput, errors
  438. )
  439. co_fb = False
  440. if co is None:
  441. co = _fallback_coach(context)
  442. co_fb = True
  443. degraded = True
  444. errors.append(
  445. diet_error_record(
  446. "coach",
  447. DietErrorCode.DEGRADED_FALLBACK,
  448. "Coach 阶段失败,已使用模板降级输出",
  449. )
  450. )
  451. pipeline_trace.append(
  452. {
  453. "phase": "coach",
  454. "fallback_used": co_fb,
  455. "attempts": co_attempts,
  456. "output": co.model_dump(),
  457. }
  458. )
  459. # ----- Habit -----
  460. h_prompt = f"""你是 **Habit(习惯养成)Agent**。只输出 **一个 JSON**。
  461. 结构:
  462. {{
  463. "reflect_alignment": string,
  464. "execution_hints": string[],
  465. "meal_plan": {{
  466. "items": [{{ "name": string, "portion": string, "est_protein_g": number, "why": string }}],
  467. "total_est_protein_g": number,
  468. "tips": string[]
  469. }}
  470. }}
  471. 要求:
  472. - meal_plan.items 至少 1 条;份量具体、可执行;适合便利店/外卖。
  473. - 结合 Reflect 记忆,说明本次如何规避上次失败原因。
  474. - est_protein_g 为粗略估计。
  475. 用户场景:
  476. {json.dumps(context, ensure_ascii=False, indent=2)}
  477. 食物解析结果:
  478. {json.dumps(fp.model_dump(), ensure_ascii=False, indent=2)}
  479. Reflect 记忆:
  480. {reflect_mem}
  481. 历史记忆召回(RAG):
  482. {rag_summary}
  483. 营养师:
  484. {json.dumps(nu.model_dump(), ensure_ascii=False, indent=2)}
  485. Coach:
  486. {json.dumps(co.model_dump(), ensure_ascii=False, indent=2)}
  487. 营养数据(含追加查询):
  488. {json.dumps(tool_bundle, ensure_ascii=False, indent=2)[:12000]}
  489. """
  490. ha, ha_attempts = await _run_validated_stage(
  491. self.llm, "habit", h_prompt, HabitOutput, errors
  492. )
  493. ha_fb = False
  494. if ha is None:
  495. ha = _fallback_habit(context, reflect_mem, fp.nutrition_summary)
  496. ha_fb = True
  497. degraded = True
  498. errors.append(
  499. diet_error_record(
  500. "habit",
  501. DietErrorCode.DEGRADED_FALLBACK,
  502. "Habit 阶段失败,已使用安全兜底菜单",
  503. )
  504. )
  505. pipeline_trace.append(
  506. {
  507. "phase": "habit",
  508. "fallback_used": ha_fb,
  509. "attempts": ha_attempts,
  510. "output": ha.model_dump(),
  511. }
  512. )
  513. meal_plan = ha.meal_plan.model_dump()
  514. planning = {
  515. "reasoning": nu.rationale,
  516. "plan_steps": [
  517. "FoodParse:从饮食日志抽取食物与份量并估算营养",
  518. f"Nutritionist:缺口约 {nu.protein_gap_g:.1f}g 蛋白",
  519. "Coach:训练/进食窗口与恢复约束",
  520. "Habit:对齐 Reflect 的可执行菜单",
  521. ],
  522. "agent_pipeline": [
  523. "FoodParseAgent",
  524. "NutritionistAgent",
  525. "CoachAgent",
  526. "HabitAgent",
  527. ],
  528. }
  529. output: Dict[str, Any] = {
  530. "run_id": run_id,
  531. "user_id": user_id,
  532. "schema_version": SCHEMA_VERSION,
  533. "pipeline_mode": "multi_agent",
  534. "replayed_from": replayed_from_run_id,
  535. "degraded": degraded,
  536. "errors": errors,
  537. "planning": planning,
  538. "stages": {
  539. "nutritionist": {
  540. "ok": not nu_fb,
  541. "fallback_used": nu_fb,
  542. "output": nu.model_dump(),
  543. },
  544. "coach": {
  545. "ok": not co_fb,
  546. "fallback_used": co_fb,
  547. "output": co.model_dump(),
  548. },
  549. "habit": {
  550. "ok": not ha_fb,
  551. "fallback_used": ha_fb,
  552. "output": ha.model_dump(),
  553. },
  554. },
  555. "meal_plan": meal_plan,
  556. "food_parse": fp.model_dump(),
  557. "nutrition_summary": fp.nutrition_summary.model_dump(),
  558. "habit_extras": {
  559. "reflect_alignment": ha.reflect_alignment,
  560. "execution_hints": ha.execution_hints,
  561. },
  562. "react_trace": pipeline_trace,
  563. "reflect_memory_used": reflect_mem,
  564. "retrieved_memory": rag_summary,
  565. "rag_debug": rag_result.get("debug", {}),
  566. }
  567. try:
  568. save_diet_run(
  569. user_id=user_id,
  570. run_id=run_id,
  571. input_payload=context,
  572. steps_trace=pipeline_trace,
  573. output_payload=output,
  574. replayed_from_run_id=replayed_from_run_id,
  575. )
  576. except Exception as e:
  577. logger.exception("diet_runs 落库失败: %s", e)
  578. try:
  579. # 最佳努力索引,不影响主流程
  580. await asyncio.to_thread(index_diet_run, run_id)
  581. except Exception as e:
  582. logger.warning("diet run 向量索引失败(不影响返回): %s", e)
  583. return output