{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 23 人机协作\n", "\n", "## 学习目标\n", "1. 理解人机协作(Human-in-the-loop)在智能体中的重要性\n", "2. 掌握 LangGraph 中暂停(`interrupt`)和恢复(`resume`)机制\n", "3. 能够设计需要人工确认或补充输入的智能体流程\n", "4. 理解检查点在暂停与恢复中的作用\n", "5. 能够构建带人工审批环节的基础智能体工作流" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. 什么是人机协作\n", "\n", "人机协作,也叫 Human-in-the-loop,意思是:**智能体不是从头到尾完全自动执行,而是在关键步骤暂停下来,让人参与判断、确认或补充信息。**\n", "\n", "普通自动流程可能是这样:\n", "\n", "```\n", "输入 -> 智能体处理 -> 自动执行 -> 输出结果\n", "```\n", "\n", "人机协作流程更像这样:\n", "\n", "```\n", "输入 -> 智能体处理 -> 暂停等待人工确认 -> 继续执行 -> 输出结果\n", "```\n", "\n", "也就是说,人不是在流程外面旁观,而是成为流程中的一个关键环节。\n", "\n", "在人机协作中,AI 适合做:\n", "\n", "- 整理信息\n", "- 生成草稿\n", "- 给出建议\n", "- 自动处理低风险步骤\n", "\n", "人更适合做:\n", "\n", "- 最终确认\n", "- 风险判断\n", "- 补充缺失信息\n", "- 决定是否继续执行\n", "\n", "这就是人机协作的核心思想:**让 AI 提高效率,让人把控关键决策。**" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. 为什么智能体需要人工介入\n", "\n", "很多智能体任务不能完全交给模型自动完成。原因主要有三类。\n", "\n", "### 2.1 有些操作风险较高\n", "例如:\n", "\n", "- 发送正式邮件\n", "- 删除数据\n", "- 提交订单\n", "- 执行付款\n", "- 修改线上配置\n", "\n", "这些操作一旦执行,可能造成真实影响,所以最好先让人确认。\n", "\n", "### 2.2 有些信息 AI 不知道\n", "例如用户说:\n", "\n", "```text\n", "帮我给客户写一封邮件。\n", "```\n", "\n", "但 AI 可能不知道客户姓名、邮件目的、语气要求、是否要附带报价。\n", "\n", "这时流程应该暂停,让用户补充信息。\n", "\n", "### 2.3 有些判断需要人负责\n", "例如 AI 生成了一个方案,但最终是否采用,需要人做业务判断。\n", "\n", "所以,人机协作不是因为 AI 不够强,而是因为很多场景天然需要人工负责。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. LangGraph 中的人机协作机制\n", "\n", "在 LangGraph 中,人机协作通常依赖两个核心动作:\n", "\n", "| 动作 | 含义 | 作用 |\n", "| --- | --- | --- |\n", "| `interrupt(...)` | 暂停流程 | 把需要人工处理的信息抛出来 |\n", "| `Command(resume=...)` | 恢复流程 | 把人工输入传回图中,继续执行 |\n", "\n", "可以这样理解:\n", "\n", "- `interrupt` 像是智能体举手说:‘这里需要人来决定一下’\n", "- `Command(resume=...)` 像是人回复:‘我决定好了,你继续吧’\n", "\n", "不过要注意:暂停和恢复必须依赖检查点(checkpoint)。\n", "\n", "因为图暂停后,需要记住自己停在了哪里。等人给出答案后,才能从正确的位置继续执行。\n", "\n", "所以,人机协作一般会和 `MemorySaver` 一起使用。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. 第一个例子:人工确认后再继续\n", "\n", "先从最简单的例子开始:智能体准备执行一个操作,但在执行前需要人工确认。\n", "\n", "流程如下:\n", "\n", "```\n", "START -> request_approval -> perform_action -> END\n", "```\n", "\n", "其中 `request_approval` 会暂停,等待用户输入 `yes` 或 `no`。" ] }, { "cell_type": "code", "execution_count": null, "id": "7085b2d5", "metadata": {}, "outputs": [], "source": [ "from typing_extensions import TypedDict\n", "from langgraph.graph import StateGraph, START, END\n", "from langgraph.types import interrupt, Command\n", "from langgraph.checkpoint.memory import MemorySaver\n", "\n", "class ApprovalState(TypedDict):\n", " task: str\n", " approved: bool\n", " result: str\n", "\n", "def request_approval(state: ApprovalState):\n", " answer = interrupt({\n", " 'question': '是否允许执行这个任务?',\n", " 'task': state['task'],\n", " 'options': ['yes', 'no']\n", " })\n", " return {'approved': answer == 'yes'}\n", "\n", "def perform_action(state: ApprovalState):\n", " if state['approved']:\n", " return {'result': f'任务已执行:{state[\"task\"]}'}\n", " return {'result': f'任务已取消:{state[\"task\"]}'}\n", "\n", "builder = StateGraph(ApprovalState)\n", "builder.add_node('request_approval', request_approval)\n", "builder.add_node('perform_action', perform_action)\n", "\n", "builder.add_edge(START, 'request_approval')\n", "builder.add_edge('request_approval', 'perform_action')\n", "builder.add_edge('perform_action', END)\n", "\n", "memory = MemorySaver()\n", "graph = builder.compile(checkpointer=memory)\n", "\n", "config = {'configurable': {'thread_id': 'approval-demo'}}\n", "\n", "first_result = graph.invoke(\n", " {'task': '发送项目周报邮件', 'approved': False, 'result': ''},\n", " config=config\n", ")\n", "\n", "print(first_result)" ] }, { "cell_type": "markdown", "id": "07b99ca8", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这个例子第一次使用了 `interrupt`,所以需要重点理解。\n", "\n", "#### `ApprovalState`\n", "状态里有三个字段:\n", "\n", "- `task`:准备执行的任务\n", "- `approved`:人工是否批准\n", "- `result`:最终执行结果\n", "\n", "也就是说,整张图围绕一个任务展开,先确认,再执行或取消。\n", "\n", "#### `request_approval` 节点\n", "这个节点的核心代码是:\n", "\n", "```python\n", "answer = interrupt({...})\n", "```\n", "\n", "当代码执行到这里时,图不会继续往下走,而是暂停下来。\n", "\n", "`interrupt` 里面传入的是要展示给人的信息。这里包括:\n", "\n", "- 问题:是否允许执行这个任务\n", "- 任务内容:`state['task']`\n", "- 可选项:`yes` 或 `no`\n", "\n", "#### 为什么 `interrupt` 后面还有 return\n", "这行代码:\n", "\n", "```python\n", "return {'approved': answer == 'yes'}\n", "```\n", "\n", "不是第一次暂停时立刻执行完成的。\n", "\n", "第一次运行到 `interrupt` 时,图会停住。等我们用 `Command(resume='yes')` 恢复时,`interrupt` 才会把 `'yes'` 作为返回值交给 `answer`,然后继续执行后面的 `return`。\n", "\n", "#### `perform_action` 节点\n", "这个节点根据 `approved` 决定结果:\n", "\n", "- 如果 `approved=True`,说明人工批准,任务执行\n", "- 如果 `approved=False`,说明人工拒绝,任务取消\n", "\n", "#### 为什么需要 `MemorySaver`\n", "因为图会暂停。暂停之后,系统必须记住:\n", "\n", "- 当前执行到哪个节点\n", "- 当前状态是什么\n", "- 后面应该从哪里继续\n", "\n", "这些信息都需要检查点保存。`MemorySaver` 就是一个内存版检查点保存器。\n", "\n", "#### 第一次运行会得到什么\n", "第一次 `invoke` 不会直接得到最终结果,而是会得到一个包含中断信息的结果。\n", "\n", "这表示图已经暂停,正在等待人工输入。" ] }, { "cell_type": "markdown", "id": "185c4df9", "metadata": {}, "source": [ "## 5. 恢复流程:用 `Command(resume=...)` 传回人工决定\n", "\n", "上一步图已经暂停。现在我们模拟人工选择 `yes`,让流程继续执行。\n", "\n", "恢复时要使用同一个 `config`,尤其是同一个 `thread_id`。\n", "\n", "因为 LangGraph 需要根据 `thread_id` 找回刚才暂停的那一次运行。" ] }, { "cell_type": "code", "execution_count": null, "id": "0c18357d", "metadata": {}, "outputs": [], "source": [ "second_result = graph.invoke(\n", " Command(resume='yes'),\n", " config=config\n", ")\n", "\n", "print(second_result)" ] }, { "cell_type": "markdown", "id": "31abd5f8", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这一段代码负责恢复刚才暂停的流程。\n", "\n", "#### `Command(resume='yes')`\n", "它的意思是:把人工输入 `'yes'` 传回给刚才的 `interrupt`。\n", "\n", "也就是说,前面这行代码:\n", "\n", "```python\n", "answer = interrupt(...)\n", "```\n", "\n", "会在恢复后得到:\n", "\n", "```python\n", "answer = 'yes'\n", "```\n", "\n", "然后节点继续执行:\n", "\n", "```python\n", "return {'approved': answer == 'yes'}\n", "```\n", "\n", "于是 `approved` 会变成 `True`。\n", "\n", "#### 为什么还要传 `config=config`\n", "因为恢复流程必须知道要恢复哪一次暂停。\n", "\n", "`thread_id='approval-demo'` 就像这次流程的编号。\n", "\n", "如果换了另一个 `thread_id`,LangGraph 就找不到刚才暂停的位置。\n", "\n", "#### 恢复后的执行路径\n", "恢复后流程会继续:\n", "\n", "```\n", "request_approval -> perform_action -> END\n", "```\n", "\n", "最终会得到:\n", "\n", "```text\n", "任务已执行:发送项目周报邮件\n", "```\n", "\n", "这就是最基本的人机协作流程。" ] }, { "cell_type": "markdown", "id": "1fd00b84", "metadata": {}, "source": [ "## 6. 第二个例子:人工补充缺失信息\n", "\n", "人机协作不只是审批,也可以用来补充信息。\n", "\n", "例如用户想让智能体写邮件,但没有提供收件人。\n", "\n", "流程可以这样设计:\n", "\n", "```\n", "START -> check_info -> draft_email -> END\n", "```\n", "\n", "如果缺少收件人,`check_info` 就暂停,让用户补充。" ] }, { "cell_type": "code", "execution_count": null, "id": "4000512e", "metadata": {}, "outputs": [], "source": [ "from typing_extensions import TypedDict\n", "from langgraph.graph import StateGraph, START, END\n", "from langgraph.types import interrupt, Command\n", "from langgraph.checkpoint.memory import MemorySaver\n", "\n", "class EmailState(TypedDict):\n", " topic: str\n", " recipient: str\n", " email_draft: str\n", "\n", "def check_info(state: EmailState):\n", " if state['recipient']:\n", " return {}\n", "\n", " recipient = interrupt({\n", " 'question': '请补充邮件收件人',\n", " 'current_topic': state['topic']\n", " })\n", " return {'recipient': recipient}\n", "\n", "def draft_email(state: EmailState):\n", " draft = f'''收件人:{state[\"recipient\"]}\n", "主题:{state[\"topic\"]}\n", "\n", "你好,\n", "这里是一封关于“{state[\"topic\"]}”的邮件草稿。\n", "请根据具体情况补充正文细节。\n", "'''\n", " return {'email_draft': draft}\n", "\n", "builder = StateGraph(EmailState)\n", "builder.add_node('check_info', check_info)\n", "builder.add_node('draft_email', draft_email)\n", "\n", "builder.add_edge(START, 'check_info')\n", "builder.add_edge('check_info', 'draft_email')\n", "builder.add_edge('draft_email', END)\n", "\n", "email_graph = builder.compile(checkpointer=MemorySaver())\n", "email_config = {'configurable': {'thread_id': 'email-demo'}}\n", "\n", "first_result = email_graph.invoke(\n", " {'topic': '下周项目会议安排', 'recipient': '', 'email_draft': ''},\n", " config=email_config\n", ")\n", "\n", "print(first_result)" ] }, { "cell_type": "markdown", "id": "84056ad6", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这个例子展示了人机协作的另一种常见用途:补充缺失信息。\n", "\n", "#### `EmailState`\n", "状态包含三个字段:\n", "\n", "- `topic`:邮件主题\n", "- `recipient`:邮件收件人\n", "- `email_draft`:生成的邮件草稿\n", "\n", "#### `check_info` 节点\n", "这个节点负责检查信息是否完整。\n", "\n", "如果 `recipient` 已经有值,就返回 `{}`,表示不需要修改状态。\n", "\n", "如果 `recipient` 为空,就执行:\n", "\n", "```python\n", "recipient = interrupt({...})\n", "```\n", "\n", "这会暂停流程,并向用户请求收件人信息。\n", "\n", "#### 为什么这里不直接报错\n", "在真实智能体中,缺少信息不一定是错误。\n", "\n", "更好的做法是:停下来问人。\n", "\n", "这就是人机协作比普通异常处理更适合智能体流程的地方。\n", "\n", "#### `draft_email` 节点\n", "这个节点依赖完整信息生成邮件草稿。\n", "\n", "它会读取:\n", "\n", "- `state['recipient']`\n", "- `state['topic']`\n", "\n", "然后写入 `email_draft`。\n", "\n", "#### 第一次运行为什么会暂停\n", "因为初始状态中:\n", "\n", "```python\n", "'recipient': ''\n", "```\n", "\n", "所以流程会停在 `check_info`,等待人补充收件人。" ] }, { "cell_type": "markdown", "id": "cf5e9b4b", "metadata": {}, "source": [ "## 7. 恢复并补充收件人\n", "\n", "现在模拟人工输入收件人,例如:`张经理`。\n", "\n", "恢复后,流程会继续进入 `draft_email`,并生成邮件草稿。" ] }, { "cell_type": "code", "execution_count": null, "id": "081941b0", "metadata": {}, "outputs": [], "source": [ "final_result = email_graph.invoke(\n", " Command(resume='张经理'),\n", " config=email_config\n", ")\n", "\n", "print(final_result['email_draft'])" ] }, { "cell_type": "markdown", "id": "8c7dade8", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这一段代码把人工补充的信息传回给图。\n", "\n", "#### `Command(resume='张经理')`\n", "它会让刚才暂停的 `interrupt` 返回 `'张经理'`。\n", "\n", "于是这行代码:\n", "\n", "```python\n", "recipient = interrupt(...)\n", "```\n", "\n", "恢复后就相当于:\n", "\n", "```python\n", "recipient = '张经理'\n", "```\n", "\n", "然后节点返回:\n", "\n", "```python\n", "return {'recipient': recipient}\n", "```\n", "\n", "状态中的收件人就被补上了。\n", "\n", "#### 后续为什么能生成邮件草稿\n", "恢复后,图会继续执行 `draft_email`。\n", "\n", "这时状态已经包含:\n", "\n", "- `topic='下周项目会议安排'`\n", "- `recipient='张经理'`\n", "\n", "所以就可以生成完整的邮件草稿。\n", "\n", "#### 这个例子的重点\n", "人机协作不是只能做‘同意/不同意’。\n", "\n", "它也可以用来让人补充缺失信息,然后让图继续完成后续任务。" ] }, { "cell_type": "markdown", "id": "6090d74f", "metadata": {}, "source": [ "## 8. 第三个例子:人工修改 AI 草稿\n", "\n", "在很多真实场景中,AI 不一定直接执行最终结果,而是先生成一个草稿,让人修改或确认。\n", "\n", "例如:\n", "\n", "- AI 生成邮件草稿,人修改后再发送\n", "- AI 生成计划,人确认后再执行\n", "- AI 生成 SQL,人审核后再运行\n", "\n", "下面我们用一个简单的任务计划示例。" ] }, { "cell_type": "code", "execution_count": null, "id": "88a81e02", "metadata": {}, "outputs": [], "source": [ "from typing_extensions import TypedDict\n", "from langgraph.graph import StateGraph, START, END\n", "from langgraph.types import interrupt, Command\n", "from langgraph.checkpoint.memory import MemorySaver\n", "\n", "class PlanState(TypedDict):\n", " goal: str\n", " draft_plan: str\n", " final_plan: str\n", "\n", "def create_draft_plan(state: PlanState):\n", " draft = f'''目标:{state[\"goal\"]}\n", "步骤 1:明确当前需求\n", "步骤 2:拆分任务并安排执行顺序\n", "步骤 3:检查结果并总结经验\n", "'''\n", " return {'draft_plan': draft}\n", "\n", "def review_plan(state: PlanState):\n", " revised_plan = interrupt({\n", " 'question': '请审核并修改计划草稿',\n", " 'draft_plan': state['draft_plan']\n", " })\n", " return {'final_plan': revised_plan}\n", "\n", "builder = StateGraph(PlanState)\n", "builder.add_node('create_draft_plan', create_draft_plan)\n", "builder.add_node('review_plan', review_plan)\n", "\n", "builder.add_edge(START, 'create_draft_plan')\n", "builder.add_edge('create_draft_plan', 'review_plan')\n", "builder.add_edge('review_plan', END)\n", "\n", "plan_graph = builder.compile(checkpointer=MemorySaver())\n", "plan_config = {'configurable': {'thread_id': 'plan-demo'}}\n", "\n", "review_result = plan_graph.invoke(\n", " {'goal': '准备一次 LangGraph 分享', 'draft_plan': '', 'final_plan': ''},\n", " config=plan_config\n", ")\n", "\n", "print(review_result)" ] }, { "cell_type": "markdown", "id": "88b97a79", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这个例子展示的是‘AI 先生成草稿,人再修改确认’的模式。\n", "\n", "#### `PlanState`\n", "状态中有三个字段:\n", "\n", "- `goal`:目标\n", "- `draft_plan`:AI 或程序生成的草稿计划\n", "- `final_plan`:人工确认后的最终计划\n", "\n", "#### `create_draft_plan` 节点\n", "这个节点根据目标生成一个初步计划。\n", "\n", "这里为了教学简单,计划是用模板字符串生成的。\n", "\n", "在真实项目中,这一步也可以换成大模型生成。\n", "\n", "#### `review_plan` 节点\n", "这个节点调用 `interrupt`,把草稿交给人审核。\n", "\n", "传给人的信息包括:\n", "\n", "- 问题:请审核并修改计划草稿\n", "- 当前草稿:`state['draft_plan']`\n", "\n", "人可以直接接受,也可以修改后再返回。\n", "\n", "#### 为什么返回 `final_plan`\n", "AI 生成的是草稿,不一定等于最终结果。\n", "\n", "人工修改后的内容才写入 `final_plan`。\n", "\n", "这能清楚地区分:\n", "\n", "- AI 草稿是什么\n", "- 人工最终确认的版本是什么\n", "\n", "这在真实工作流中非常重要,因为最终责任通常属于人工确认后的结果。" ] }, { "cell_type": "markdown", "id": "f0da01c5", "metadata": {}, "source": [ "## 9. 恢复并提交人工修改后的计划\n", "\n", "现在模拟人工审核后,提交一个修改版计划。" ] }, { "cell_type": "code", "execution_count": null, "id": "31d862b2", "metadata": {}, "outputs": [], "source": [ "human_revised_plan = '''目标:准备一次 LangGraph 分享\n", "步骤 1:先介绍图结构、状态和边的概念\n", "步骤 2:演示一个最小可运行的 LangGraph 示例\n", "步骤 3:重点讲解人机协作中的 interrupt 和 resume\n", "步骤 4:最后总结适合人机协作的真实业务场景\n", "'''\n", "\n", "final_plan_result = plan_graph.invoke(\n", " Command(resume=human_revised_plan),\n", " config=plan_config\n", ")\n", "\n", "print(final_plan_result['final_plan'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 代码解释\n", "\n", "这段代码模拟人工修改草稿后,把最终版本交回给图。\n", "\n", "#### `human_revised_plan`\n", "这是人工修改后的计划。\n", "\n", "它和原始草稿相比,更具体,也更贴近分享主题。\n", "\n", "#### `Command(resume=human_revised_plan)`\n", "这表示把人工修改后的计划传回给 `interrupt`。\n", "\n", "恢复后,`review_plan` 节点会继续执行:\n", "\n", "```python\n", "return {'final_plan': revised_plan}\n", "```\n", "\n", "于是最终状态中就有了人工确认后的 `final_plan`。\n", "\n", "#### 这个模式有什么用\n", "这类流程非常适合高风险或高要求任务。\n", "\n", "例如:\n", "\n", "- AI 生成合同条款,人审核后确认\n", "- AI 生成客户邮件,人修改后发送\n", "- AI 生成执行计划,人确认后落地\n", "\n", "核心思想是:AI 负责初稿,人负责把关。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 10. 人机协作流程的关键点\n", "\n", "设计人机协作流程时,要特别注意下面几点。\n", "\n", "### 10.1 暂停点要放在关键位置\n", "不是每一步都需要人确认。\n", "\n", "一般只在这些地方暂停:\n", "\n", "- 信息缺失\n", "- 风险较高\n", "- 需要业务判断\n", "- 执行前需要最终确认\n", "\n", "### 10.2 给人的信息要清楚\n", "`interrupt` 传出的内容应该让人一眼看懂:\n", "\n", "- 当前要做什么\n", "- 为什么要暂停\n", "- 希望人提供什么\n", "- 可选项有哪些\n", "\n", "### 10.3 恢复时必须使用同一个线程\n", "恢复流程时,必须使用同一个 `thread_id`。\n", "\n", "否则图找不到之前暂停的位置。\n", "\n", "### 10.4 不要滥用人工确认\n", "如果每一步都让人确认,智能体就会变得很繁琐。\n", "\n", "合理做法是:低风险步骤自动执行,高风险步骤人工把关。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 11. 人机协作和普通 input() 的区别\n", "\n", "你可能会问:为什么不直接用 Python 的 `input()`?\n", "\n", "因为 LangGraph 的 `interrupt` 不只是简单输入,它更适合智能体工作流。\n", "\n", "| 对比项 | `input()` | `interrupt` |\n", "| --- | --- | --- |\n", "| 是否适合 notebook / 服务端流程 | 不太适合 | 更适合 |\n", "| 是否能保存暂停状态 | 不能 | 可以,依赖检查点 |\n", "| 是否能恢复到图中原位置 | 不能 | 可以 |\n", "| 是否适合多用户多会话 | 弱 | 强,配合 `thread_id` |\n", "| 是否和 LangGraph 状态集成 | 否 | 是 |\n", "\n", "所以在人机协作智能体中,推荐使用 `interrupt`,而不是普通 `input()`。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 12. 本节小结\n", "\n", "本节最重要的内容有五点:\n", "\n", "1. **人机协作是让人在关键节点参与智能体流程**\n", "2. **`interrupt` 用于暂停流程,并把需要人工处理的信息交出去**\n", "3. **`Command(resume=...)` 用于把人工输入传回图中,并恢复执行**\n", "4. **暂停和恢复必须依赖检查点,通常会配合 `MemorySaver` 使用**\n", "5. **人工介入适合审批、补充信息、修改草稿和高风险操作确认**\n", "\n", "掌握人机协作后,智能体就不再只是自动执行脚本,而可以变成一个真正能和人配合工作的流程系统。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 13. 本节练习\n", "\n", "1. 修改第一个示例,把任务改成‘删除临时文件’,观察批准和拒绝的结果\n", "2. 修改第二个示例,让缺少邮件主题时也触发一次人工补充\n", "3. 修改第三个示例,让人工可以输入 `accept` 表示直接接受草稿\n", "4. 思考:哪些智能体操作必须经过人工确认?\n", "5. 思考:如果一个系统有多个用户同时使用,为什么 `thread_id` 很重要?" ] } ], "metadata": { "kernelspec": { "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.14.4" } }, "nbformat": 4, "nbformat_minor": 5 }