手把手elasticsearch学习之构建 HITL AI 代理

基于LangGraph+Elasticsearch 9.x 构建人机协同(HITL)AI Agent


一、概念,先懂逻辑再动手

1. 什么是人机协同(HITL)?

可以把它理解成「AI的精准求助机制」:

  • 不是让人工全程盯着AI干活,而是AI只有搞不定的时候,才会停下来叫人,其余时间全自动运行

  • 核心原则:只在「信息缺失、意图模糊、高风险决策」3种场景触发人工干预,避免无效打断

  • 举个例子:AI从法律库里找了5个相似案例,但不知道你要用哪个,就停下来问你;AI写分析时发现缺合同细节,就停下来让你补充,而不是瞎编内容。

2. 为什么必须用LangGraph,不能用普通LangChain?

普通LangChain是「单线程流水线」:一条道走到黑,一旦启动就没法中途暂停、没法回退、没法分叉。

而LangGraph是「带存档的流程图+状态机」,也是实现HITL的核心:

  • 它能让工作流精准暂停,完整保存当前所有进度,等你输入后,从暂停的地方继续跑,不用从头再来

  • 支持条件分支(比如你确认案例选错了,能直接退回选择步骤)

  • 自带「共享记事本(State)」:所有步骤都能读写同一个全局状态,数据不会乱

3. Elasticsearch 9.x在这里干什么?

它是「AI的精准检索放大镜」,核心解决2个痛点:

  • 如果你有10000条法律案例,直接全丢给LLM,不仅费钱,还会让LLM混乱、幻觉

  • ES先做第一层过滤:把你的问题转成向量,从海量案例里精准捞出最相关的5条,再交给LLM和你选择,兼顾效率、成本和准确性

  • 9.x版本专属优势:向量检索速度更快、本地部署安全配置更简单、对LangChain兼容性更好,完全向下兼容8.x的语法


二、环境

前置必装工具

  1. Node.js 18+ :https://nodejs.cn/ (装LTS版本,装完用 node -v 验证)

  2. Docker Desktop :https://www.docker.com/ (Windows用户必须开启WSL2引擎,Linux直接装)

  3. 能正常调用的OpenAI API Key


步骤1:1条命令部署Elasticsearch 9.x本地单节点

直接用Docker部署,不用管复杂的环境配置

  1. 打开终端(Windows用CMD/PowerShell,Mac/Linux用终端),先创建Docker网络:

    Bash 复制代码
    docker network create es-net
  2. 执行下面的命令启动ES 9.x容器(直接复制全段运行):

    Bash 复制代码
    docker run -d \
      --name es01 \
      --net es-net \
      -p 9200:9200 \
      -p 9300:9300 \
      -e "discovery.type=single-node" \
      -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \
      -e "xpack.security.enabled=true" \
      -e "xpack.security.enrollment.enabled=true" \
      docker.elastic.co/elasticsearch/elasticsearch:9.3.0

    关键参数解释:

    • discovery.type=single-node:单节点模式,不用配置集群,本地开发专用

    • ES_JAVA_OPTS=-Xms2g -Xmx2g:给ES分配2G内存,低于1G会启动失败

    • xpack.security.enabled=true:开启安全认证,才能生成API Key连接代码

  3. 验证ES是否启动成功:

执行 docker ps,能看到es01容器的状态是Up(健康运行),就成功了。如果启动失败,大概率是内存不够,把2g改成1g再试。


步骤2:配置ES安全,生成连接用的API Key

这一步是新手踩坑重灾区,严格跟着执行,每一步都有验证

  1. 重置elastic超级用户的密码:

    Bash 复制代码
    docker exec -it es01 bin/elasticsearch-reset-password -u elastic -i

    执行后会提示你输入新密码,自己设一个好记的(比如Es@123456),输完回车,提示Password updated successfully就成功了,这个密码一定要记好

  2. 生成代码连接用的API Key:

    Bash 复制代码
    # 把<elastic的密码>换成你上一步设的密码,比如Es@123456,然后全段复制运行
    docker exec -it es01 curl -u elastic:<elastic的密码> -X POST "https://localhost:9200/_security/api_key?pretty" -H "Content-Type: application/json" -d'
    {
      "name": "hitl-legal-agent-key",
      "expiration": "30d"
    }
    '
  3. 保存API Key:

运行后会输出一段JSON,里面的api_key字段(一串随机字符串)就是你要的,完整复制下来,后面要用,示例如下:

JSON 复制代码
{
  "id" : "xxx",
  "name" : "hitl-legal-agent-key",
  "api_key" : "abc123def456xxx" // 就复制这个值!
}
  1. 最终验证:

浏览器打开 https://localhost:9200,账号输入elastic,密码输入你刚才设的,能看到ES的版本信息(9.3.0),就说明环境完全没问题了。


步骤3:初始化项目,安装依赖

  1. 新建一个空文件夹(比如es-hitl-legal-agent),用VS Code打开,在终端里执行:

    Bash 复制代码
    # 初始化Node项目,一路回车就行
    npm init -y
  2. 安装固定版本的依赖(避免版本兼容问题,直接复制运行):

    Bash 复制代码
    # 安装核心依赖
    npm install @elastic/elasticsearch@8.15.0 @langchain/community@0.3.10 @langchain/core@0.3.15 @langchain/langgraph@0.2.18 @langchain/openai@0.3.11 dotenv@16.4.5 --legacy-peer-deps
    
    # 安装开发依赖(TypeScript运行工具)
    npm install --save-dev tsx@4.19.1 typescript@5.6.3
  3. 新建TypeScript配置文件tsconfig.json,直接复制下面的内容:

    JSON 复制代码
    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "node",
        "esModuleInterop": true,
        "strict": true,
        "skipLibCheck": true,
        "outDir": "./dist"
      },
      "include": ["*.ts"],
      "exclude": ["node_modules"]
    }
  4. 新建环境变量文件.env,复制下面的内容,替换成你自己的信息:

    Plain 复制代码
    # ES连接地址,本地Docker部署就是这个固定值
    ELASTICSEARCH_ENDPOINT=https://localhost:9200
    # 刚才生成的ES API Key
    ELASTICSEARCH_API_KEY=你复制的api_key值
    # 你的OpenAI API Key
    OPENAI_API_KEY=你的OpenAI API Key

三、全流程逻辑:先看全貌,再写代码

整个HITL系统的工作流,就像下面的流程图,一共9步,2次核心人工干预,1次校验回退,你先在脑子里有个全貌,后面写代码就不会乱:

Plain 复制代码
用户输入法律问题 → 1.ES检索相关案例 → 【HITL1:暂停,让用户选案例】
→ 2.LLM解析用户选择,锁定案例 → 【HITL1.5:暂停,让用户确认案例是否正确】
→ 3.校验结果:不对→退回HITL1重选;对了→往下走
→ 4.LLM生成分析草稿,检查有没有缺信息
→ 5.分支判断:没缺信息→直接生成最终分析;缺信息→【HITL2:暂停,让用户补充信息】
→ 6.拿到补充信息,生成最终分析 → 结束

核心原理提醒:所有的「暂停等用户输入」,都是靠LangGraph的interrupt()实现的,它会把当前所有数据都存档,等用户输入后,从暂停的地方继续跑,这就是HITL的核心。


四、逐模块代码实现:先讲原理,再给代码

我们一共要写2个核心文件:

  1. dataIngestion.ts:负责创建ES索引、定义数据结构、导入法律案例数据

  2. main.ts:核心工作流代码,实现HITL全流程


文件1:dataIngestion.ts 数据摄入模块(原教程一笔带过,这里完整实现)

先讲原理:这个文件干什么?
  • 给ES创建索引,定义数据结构(Mapping),告诉ES「每个字段存什么类型,向量字段怎么存」

  • 把法律案例数据批量导入ES,同时给每个案例生成向量,方便后面检索

  • 新手必懂:向量字段的维度必须和Embedding模型的输出维度完全一致,OpenAI的text-embedding-3-small输出的是1536维,所以我们这里定义1536维,不然ES存不了。

完整代码(直接复制到dataIngestion.ts里)
TypeScript 复制代码
import { Client } from "@elastic/elasticsearch";
import { OpenAIEmbeddings } from "@langchain/openai";
import dotenv from "dotenv";

// 加载.env环境变量
dotenv.config();

// ===================== 1. 初始化客户端 =====================
// ES客户端,连接本地9.x实例
export const esClient = new Client({
  node: process.env.ELASTICSEARCH_ENDPOINT || "https://localhost:9200",
  auth: { apiKey: process.env.ELASTICSEARCH_API_KEY || "" },
  tls: { rejectUnauthorized: false }, // 本地开发忽略自签名证书,新手必加,不然连不上
});

// OpenAI Embedding客户端,生成向量用
export const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

// 固定索引名称,后面所有地方都用这个
export const VECTOR_INDEX = "legal-precedents";

// ===================== 2. 定义数据类型 =====================
// 法律案例文档的结构,和ES的Mapping一一对应
export interface LegalDocument {
  pageContent: string; // 案例的完整内容
  metadata: {
    caseId: string; // 案例唯一编号
    contractType: string; // 合同类型
    delayPeriod: string; // 延误时长
    outcome: string; // 判决结果
    reasoning: string; // 判决核心逻辑
    keyTerms: string; // 核心关键词
    title: string; // 案例标题
  };
  vector?: number[]; // 向量字段,用于检索
}

// ===================== 3. ES索引Mapping定义(核心!) =====================
const INDEX_MAPPING = {
  mappings: {
    properties: {
      // 案例正文,用于全文检索
      pageContent: { type: "text" },
      // 元数据,用于过滤和展示
      metadata: {
        properties: {
          caseId: { type: "keyword" },
          contractType: { type: "keyword" },
          delayPeriod: { type: "keyword" },
          outcome: { type: "keyword" },
          reasoning: { type: "text" },
          keyTerms: { type: "text" },
          title: { type: "text" },
        },
      },
      // 向量字段,ES9.x专用配置,用于语义检索
      vector: {
        type: "dense_vector",
        dims: 1536, // 和Embedding模型输出维度完全一致
        index: true, // 开启索引,才能做相似度搜索
        similarity: "cosine", // 余弦相似度,匹配OpenAI Embedding的计算方式
      },
    },
  },
};

// ===================== 4. 示例法律案例数据集 =====================
// 你可以在这里无限添加更多案例,结构完全一致就行
export const LEGAL_CASES: LegalDocument[] = [
  {
    pageContent: "Legal precedent: Case B - Service delay not considered breach. A consulting contract used term 'timely delivery' without specific dates. A three-week delay occurred but contract lacked explicit schedule. Court ruled no breach as parties had not defined concrete timeline and delay did not cause demonstrable harm.",
    metadata: {
      caseId: "CASE-B-2022",
      contractType: "consulting agreement",
      delayPeriod: "three weeks",
      outcome: "no breach found",
      reasoning: "no explicit deadline defined, no demonstrable harm",
      keyTerms: "timely delivery, open terms, schedule definition",
      title: "Case B: Delay Without Explicit Schedule"
    }
  },
  {
    pageContent: "Legal precedent: Case H - Pattern of repeated delays constitutes breach. An ongoing service agreement required consistent performance. The vendor had 8 minor delays over 6 months, each 2-4 days. Court ruled breach, as the cumulative pattern demonstrated a systematic failure to perform, even if individual delays were minor, and caused financial harm to the client.",
    metadata: {
      caseId: "CASE-H-2021",
      contractType: "ongoing service agreement",
      delayPeriod: "multiple instances",
      outcome: "breach found",
      reasoning: "pattern demonstrated failure to perform, cumulative effect, demonstrable financial harm",
      keyTerms: "repeated delays, cumulative effect, material breach",
      title: "Case H: Pattern of Repeated Delays"
    }
  },
  {
    pageContent: "Legal precedent: Case E - Minor delay with quality maintained. A service agreement had a 5-day delay, but the final deliverable quality was fully up to standard. Court ruled only minor breach, termination was unjustified, as the delay did not cause material harm to the client.",
    metadata: {
      caseId: "CASE-E-2022",
      contractType: "service agreement",
      delayPeriod: "five days",
      outcome: "minor breach only",
      reasoning: "delay minimal, quality maintained, no material harm",
      keyTerms: "minor delay, materiality, performance quality",
      title: "Case E: Minor Delay Quality Maintained"
    }
  }
];

// ===================== 5. 数据摄入主函数 =====================
export async function ingestData() {
  console.log("🔄 Starting data ingestion to ES 9.x...");

  // 开发环境:如果索引已经存在,先删除(避免重复数据)
  if (await esClient.indices.exists({ index: VECTOR_INDEX })) {
    await esClient.indices.delete({ index: VECTOR_INDEX });
    console.log(`🗑️  Deleted existing index: ${VECTOR_INDEX}`);
  }

  // 创建索引,应用我们定义的Mapping
  await esClient.indices.create({
    index: VECTOR_INDEX,
    body: INDEX_MAPPING,
  });
  console.log(`✅ Created index: ${VECTOR_INDEX} with ES 9.x mapping`);

  // 给每个案例生成向量,准备批量导入
  const operations: any[] = [];
  for (const caseItem of LEGAL_CASES) {
    // 给案例正文生成向量,用于后续检索
    const vector = await embeddings.embedQuery(caseItem.pageContent);
    // 批量导入的格式:先写插入指令,再写文档内容
    operations.push(
      { index: { _index: VECTOR_INDEX } },
      { ...caseItem, vector }
    );
  }

  // 批量导入ES,bulk比单条插入效率高10倍以上
  await esClient.bulk({ refresh: true, operations });
  console.log(`✅ Successfully ingested ${LEGAL_CASES.length} legal cases`);
  console.log("🎉 Data ingestion completed!\n");
}

文件2:main.ts 核心HITL工作流模块

我们会把整个工作流拆成5个部分,逐部分讲原理,再给代码,你完全能看懂:

  1. 初始化客户端和工具函数

  2. 定义全局共享状态(State)

  3. 实现每个工作流节点(Node)

  4. 构建工作流图(Graph),定义节点和分支

  5. 主执行函数,处理中断和用户输入

完整代码(直接复制到main.ts里)
TypeScript 复制代码
import { StateGraph, Annotation, interrupt, MemorySaver } from "@langchain/langgraph";
import { Command } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { ElasticVectorSearch } from "@langchain/community/vectorstores/elasticsearch";
import * as readline from "readline";
import dotenv from "dotenv";
// 从dataIngestion导入我们写好的函数和变量
import { ingestData, VECTOR_INDEX, esClient, embeddings, LegalDocument } from "./dataIngestion";

// 加载环境变量
dotenv.config();

// ===================== 1. 初始化客户端和工具函数 =====================
// 1.1 初始化LLM,temperature=0 让输出更稳定,不瞎编
const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

// 1.2 初始化ES向量存储,用于相似度检索
const vectorStore = new ElasticVectorSearch(embeddings, {
  client: esClient,
  indexName: VECTOR_INDEX,
});

// 1.3 【新手必看】终端用户输入工具函数
// 用于读取用户在终端输入的内容,和interrupt()配合实现HITL
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

function getUserInput(prompt: string): Promise<string> {
  return new Promise((resolve) => rl.question(prompt, resolve));
}

// ===================== 2. 定义全局共享状态(State)【核心原理】 =====================
// 你可以把它理解成整个工作流的「共享记事本」
// 所有节点都能读这个记事本里的内容,也能往里面写内容,节点之间靠它传递数据
export const LegalResearchState = Annotation.Root({
  query: Annotation<string>(), // 用户输入的法律问题
  precedents: Annotation<LegalDocument[]>(), // ES检索到的相关案例
  userChoice: Annotation<string>(), // 用户选择的案例(自然语言输入)
  selectedPrecedent: Annotation<LegalDocument | null>(), // LLM解析后锁定的案例
  validation: Annotation<string>(), // 用户对案例的确认结果(yes/no)
  draftAnalysis: Annotation<string>(), // LLM生成的分析草稿
  ambiguityDetected: Annotation<boolean>(), // 是否检测到信息缺失/歧义
  userClarification: Annotation<string>(), // 用户补充的澄清信息
  finalAnalysis: Annotation<string>(), // 最终生成的完整分析
});

// ===================== 3. 实现每个工作流节点(Node) =====================
// 每个节点就是一个函数,输入是当前的State,输出是要更新到State里的内容

// -------------------- 节点1:检索相关法律案例 --------------------
// 原理:把用户的问题转成向量,去ES里找最相似的5个案例,存到State里
async function searchPrecedents(state: typeof LegalResearchState.State) {
  console.log("📚 Searching for relevant legal precedents with query:\n", state.query);

  // 相似度搜索,最多返回5个最相关的案例
  const results = await vectorStore.similaritySearch(state.query, 5);
  const precedents = results.map((d) => d as unknown as LegalDocument);

  // 把检索到的案例打印出来,给用户看
  console.log(`\n✅ Found ${precedents.length} relevant precedents:\n`);
  for (let i = 0; i < precedents.length; i++) {
    const p = precedents[i];
    const m = p.metadata;
    console.log(
      `${i + 1}. ${m.title} (${m.caseId})\n` +
      `   Contract Type: ${m.contractType}\n` +
      `   Outcome: ${m.outcome}\n` +
      `   Core Reasoning: ${m.reasoning}\n` +
      `   Delay Period: ${m.delayPeriod}\n`
    );
  }

  // 把检索到的案例更新到State里,给后面的节点用
  return { precedents };
}

// -------------------- 节点2:【HITL1】让用户选择案例 --------------------
// 原理:用interrupt()让工作流暂停,等用户输入选择后,再继续运行
function precedentSelection(state: typeof LegalResearchState.State) {
  console.log("\n⚖️  HITL #1: Human Input Required\n");
  // interrupt()会让工作流完全暂停,保存当前所有状态,直到用户输入后恢复
  const result = interrupt({
    question: "👨‍⚖️  Which precedent is most similar to your case? (You can enter the number or case name): ",
  });

  // 把用户的输入更新到State里
  return { userChoice: result as string };
}

// -------------------- 节点3:LLM解析用户的选择,锁定案例 --------------------
// 原理:用户可以用自然语言输入(比如"Case H"或者"第1个"),LLM会解析成对应的案例编号
async function selectPrecedent(state: typeof LegalResearchState.State) {
  const precedents = state.precedents || [];
  const userInput = state.userChoice || "";

  // 把所有案例拼成列表,给LLM看
  const precedentsList = precedents
    .map((p, i) => `${i + 1}. ${m.caseId}: ${m.title} - ${m.outcome}`)
    .join("\n");

  // 【关键技巧】用withStructuredOutput约束LLM必须返回固定格式,避免幻觉
  // 强制LLM只返回一个数字,就是用户选的案例编号
  const structuredLlm = llm.withStructuredOutput({
    name: "precedent_selection",
    schema: {
      type: "object",
      properties: {
        selected_number: {
          type: "number",
          description: "The number of the precedent selected by the user (1-based index)",
          minimum: 1,
          maximum: precedents.length,
        },
      },
      required: ["selected_number"],
    },
  });

  // 给LLM的提示词
  const prompt = `
    The user said: "${userInput}"
    Available precedents:
    ${precedentsList}
    Which precedent number (1-${precedents.length}) matches the user's selection? Only return the number.
  `;

  // 调用LLM,解析用户的选择
  const response = await structuredLlm.invoke([
    {
      role: "system",
      content: "You are a legal assistant that only returns the number of the selected precedent.",
    },
    { role: "user", content: prompt },
  ]);

  // 锁定用户选的案例
  const selectedIndex = response.selected_number - 1;
  const selectedPrecedent = precedents[selectedIndex] || precedents[0];

  console.log(`\n✅ Selected Precedent: ${selectedPrecedent.metadata.title}\n`);
  return { selectedPrecedent };
}

// -------------------- 节点4:【HITL1.5】让用户确认案例是否正确 --------------------
// 原理:二次校验,避免LLM解析错了用户的选择,导致后面的分析全错
function validatePrecedentSelection(state: typeof LegalResearchState.State) {
  const precedent = state.selectedPrecedent;
  if (!precedent) return {};

  const m = precedent.metadata;
  console.log("\n⚖️  HITL #1.5: Validation Required\n");
  console.log(
    `Selected Precedent: ${m.title} (${m.caseId})\n` +
    `Contract Type: ${m.contractType}\n` +
    `Outcome: ${m.outcome}\n` +
    `Core Reasoning: ${m.reasoning}\n`
  );

  // 再次暂停,等用户确认
  const result = interrupt({
    question: "👨‍⚖️  Is this the correct precedent? (Please enter yes/no): ",
  });

  const validation = typeof result === "string" ? result : (result as any)?.value || "";
  return { validation };
}

// -------------------- 节点5:处理用户的确认结果,决定流程走向 --------------------
// 原理:用户选yes就继续往下走,选no就清空选择,回到案例选择步骤
function processValidation(state: typeof LegalResearchState.State) {
  const userInput = (state.validation || "").toLowerCase().trim();
  const isValid = userInput === "yes" || userInput === "y";

  // 如果用户说不对,就清空当前选择,回到选择步骤
  if (!isValid) {
    console.log("❌ Precedent not confirmed. Returning to selection step...\n");
    return { selectedPrecedent: null, userChoice: "" };
  }

  console.log("✅ Precedent confirmed. Proceeding to analysis...\n");
  return {};
}

// -------------------- 节点6:生成分析草稿,检测是否有信息缺失 --------------------
// 原理:基于选中的案例生成分析,同时判断有没有缺信息,缺的话就触发第二次HITL
async function createDraft(state: typeof LegalResearchState.State) {
  console.log("📝 Drafting initial legal analysis...\n");

  const precedent = state.selectedPrecedent;
  if (!precedent) return { draftAnalysis: "" };

  const m = precedent.metadata;

  // 再次用结构化输出,约束LLM必须返回固定格式:是否需要澄清、分析文本、缺失的信息
  const structuredLlm = llm.withStructuredOutput({
    name: "draft_analysis",
    schema: {
      type: "object",
      properties: {
        needs_clarification: {
          type: "boolean",
          description: "Whether more information is needed to complete the analysis",
        },
        analysis_text: {
          type: "string",
          description: "The draft analysis or explanation of the ambiguity",
        },
        missing_information: {
          type: "array",
          items: { type: "string" },
          description: "List of specific information needed, empty if no clarification needed",
        },
      },
      required: ["needs_clarification", "analysis_text", "missing_information"],
    },
  });

  const prompt = `
    Based on this legal precedent:
    Case Title: ${m.title}
    Court Outcome: ${m.outcome}
    Core Reasoning: ${m.reasoning}
    Key Terms: ${m.keyTerms}

    And the user's legal question: "${state.query}"

    Draft a legal analysis applying this precedent to the user's question.
    If you need more context (like contract terms, delay details, harm caused) to give an accurate analysis, set needs_clarification to true and list exactly what information is missing.
    Otherwise, provide the complete analysis directly.
  `;

  const response = await structuredLlm.invoke([
    {
      role: "system",
      content: "You are a professional legal research assistant, be precise and concise.",
    },
    { role: "user", content: prompt },
  ]);

  // 把结果格式化打印出来
  let displayText: string;
  if (response.needs_clarification) {
    const missingInfoList = response.missing_information
      .map((info: string, i: number) => `${i + 1}. ${info}`)
      .join("\n");
    displayText = `⚠️  AMBIGUITY DETECTED:\n${response.analysis_text}\n\nMissing Information:\n${missingInfoList}`;
  } else {
    displayText = `✅ DRAFT ANALYSIS:\n${response.analysis_text}`;
  }

  console.log(displayText + "\n");

  // 把结果更新到State里
  return {
    draftAnalysis: displayText,
    ambiguityDetected: response.needs_clarification,
  };
}

// -------------------- 节点7:【HITL2】让用户补充缺失的信息 --------------------
// 原理:只有LLM检测到信息缺失时,才会走到这个节点,触发人工干预
function requestClarification(state: typeof LegalResearchState.State) {
  console.log("\n⚖️  HITL #2: Additional Context Required\n");
  const userClarification = interrupt({
    question: "👨‍⚖️  Please provide the missing information to complete the analysis: ",
  });
  return { userClarification: userClarification as string };
}

// -------------------- 节点8:生成最终的完整法律分析 --------------------
async function generateFinalAnalysis(state: typeof LegalResearchState.State) {
  console.log("\n📋 Generating final legal analysis...\n");

  const precedent = state.selectedPrecedent;
  if (!precedent) return { finalAnalysis: "" };

  const m = precedent.metadata;

  // 最终分析的提示词,整合了用户补充的所有信息
  const prompt = `
    Original Legal Question: "${state.query}"
    
    Selected Precedent: ${m.title}
    Court Outcome: ${m.outcome}
    Core Reasoning: ${m.reasoning}
    
    User's Additional Clarification: "${state.userClarification || "No additional clarification provided"}"
    
    Write a comprehensive, professional legal analysis that includes:
    1. Application of the selected precedent's reasoning to the user's case
    2. Clear conditions for breach vs. no breach
    3. Practical, actionable recommendations for the user
    4. Key risks and considerations
  `;

  const response = await llm.invoke([
    {
      role: "system",
      content: "You are a senior legal analyst, write professional, clear, and actionable analysis.",
    },
    { role: "user", content: prompt },
  ]);

  const finalAnalysis = response.content as string;

  // 把最终分析格式化打印出来
  console.log(
    "\n" + "=".repeat(100) + "\n" +
    "⚖️  FINAL PROFESSIONAL LEGAL ANALYSIS\n" +
    "=".repeat(100) + "\n\n" +
    finalAnalysis + "\n\n" +
    "=".repeat(100) + "\n"
  );

  return { finalAnalysis };
}

// ===================== 4. 构建工作流图(Graph)【核心】 =====================
// 原理:把上面的节点串起来,定义先后顺序、条件分支,实现完整的工作流
const workflow = new StateGraph(LegalResearchState)
  // 第一步:添加所有节点
  .addNode("searchPrecedents", searchPrecedents)
  .addNode("precedentSelection", precedentSelection)
  .addNode("selectPrecedent", selectPrecedent)
  .addNode("validatePrecedentSelection", validatePrecedentSelection)
  .addNode("processValidation", processValidation)
  .addNode("createDraft", createDraft)
  .addNode("requestClarification", requestClarification)
  .addNode("generateFinalAnalysis", generateFinalAnalysis)

  // 第二步:定义节点的先后顺序(边)
  .addEdge("__start__", "searchPrecedents") // 流程起点:先检索案例
  .addEdge("searchPrecedents", "precedentSelection") // 检索完,进入HITL1选案例
  .addEdge("precedentSelection", "selectPrecedent") // 用户选完,LLM解析选择
  .addEdge("selectPrecedent", "validatePrecedentSelection") // 解析完,进入校验步骤
  .addEdge("validatePrecedentSelection", "processValidation") // 校验完,处理校验结果

  // 第三步:定义条件分支(岔路口)
  // 分支1:校验结果,yes就去生成草稿,no就回到选案例步骤
  .addConditionalEdges(
    "processValidation",
    (state: typeof LegalResearchState.State) => {
      const userInput = (state.validation || "").toLowerCase().trim();
      const isValid = userInput === "yes" || userInput === "y";
      return isValid ? "validated" : "reselect";
    },
    {
      validated: "createDraft", // 校验通过,去生成草稿
      reselect: "precedentSelection", // 校验不通过,回到选案例步骤
    }
  )

  // 分支2:草稿生成后,有没有歧义?有就去要补充信息,没有就直接生成最终分析
  .addConditionalEdges(
    "createDraft",
    (state: typeof LegalResearchState.State) => {
      return state.ambiguityDetected ? "needsClarification" : "final";
    },
    {
      needsClarification: "requestClarification", // 有歧义,进入HITL2要信息
      final: "generateFinalAnalysis", // 无歧义,直接生成最终分析
    }
  )

  // 第四步:收尾的边
  .addEdge("requestClarification", "generateFinalAnalysis") // 用户补充完信息,生成最终分析
  .addEdge("generateFinalAnalysis", "__end__"); // 生成完,流程结束

// ===================== 5. 主执行函数 =====================
async function main() {
  // 第一步:先执行数据摄入,把案例导入ES
  await ingestData();

  // 【新手必看!】编译工作流,必须加checkpointer,不然interrupt()不会生效!
  // MemorySaver就是工作流的「存档器」,用来保存暂停时的所有状态
  const app = workflow.compile({ checkpointer: new MemorySaver() });
  // 线程ID,用来区分不同的对话,同一个ID能恢复之前的状态
  const config = { configurable: { thread_id: "hitl-legal-agent-001" } };

  // 你可以在这里修改成你自己的法律问题
  const legalQuestion = "Does a pattern of repeated delays constitute breach even if each individual delay is minor?";
  console.log(`\n⚖️  USER'S LEGAL QUESTION: "${legalQuestion}"\n`);

  // 启动工作流,传入用户的问题
  let currentState = await app.invoke({ query: legalQuestion }, config);

  // 【核心HITL处理】循环处理所有中断,直到工作流完全结束
  while ((currentState as any).__interrupt__?.length > 0) {
    console.log("\n💭 APPLICATION PAUSED. WAITING FOR YOUR INPUT...\n");
    // 获取中断时的问题,提示用户输入
    const interruptQuestion = (currentState as any).__interrupt__[0]?.value?.question;
    // 读取用户输入,不能为空
    let userInput = "";
    while (!userInput.trim()) {
      userInput = await getUserInput(interruptQuestion || "👤 Please enter your response: ");
      if (!userInput.trim()) {
        console.log("⚠️  Input cannot be empty, please try again.\n");
      }
    }
    // 把用户的输入传给工作流,从暂停的地方继续运行
    currentState = await app.invoke(
      new Command({ resume: userInput.trim() }),
      config
    );
  }

  // 关闭终端输入
  rl.close();
  console.log("\n🎉 HITL Legal Agent Workflow Completed!");
}

// 执行主函数
main().catch(console.error);

五、一键运行,全流程验证

所有文件都写好之后,在终端里执行下面的命令,就能直接运行:

Bash 复制代码
npx tsx main.ts

运行后的完整流程

  1. 首先会执行数据摄入,把案例导入ES,提示Data ingestion completed

  2. 打印你的法律问题,然后ES检索相关案例,把所有案例列出来

  3. 第一次暂停(HITL1):提示你选案例,你可以输入Case H或者1,回车

  4. LLM解析你的选择,打印选中的案例,然后第二次暂停(HITL1.5):问你是不是这个案例,输入yes回车

  5. LLM生成分析草稿,检测有没有缺信息,会列出缺失的内容

  6. 第三次暂停(HITL2):让你补充缺失的信息,你可以输入Contract requires 'prompt delivery' without timelines. 8 delays of 2-4 days over 6 months. $50K in losses from 3 missed client deadlines. Vendor notified but pattern continued.,回车

  7. 系统生成最终的完整法律分析,打印出来,流程结束


六、新手100%避坑指南(90%的问题都在这里)

  1. ES连不上,报证书错误 :检查esClient里有没有加tls: { rejectUnauthorized: false },本地开发必须加

  2. 工作流不会暂停,直接跑完了 :检查workflow.compile()里有没有加checkpointer: new MemorySaver(),没有存档器,interrupt()不会生效

  3. ES检索不到案例 :检查dataIngestion.ts里有没有给每个案例生成向量,有没有用refresh: true强制刷新索引

  4. LLM返回的格式不对,代码报错 :检查withStructuredOutput的schema有没有写对,temperature是不是设成了0

  5. OpenAI API报错:检查API Key有没有权限,网络能不能通,模型名称有没有写错

  6. TypeScript报错 :检查tsconfig.json有没有创建,依赖版本是不是和教程里的一致


七、扩展优化方向

  1. 添加更多案例 :直接在dataIngestion.tsLEGAL_CASES数组里加就行,结构完全一致

  2. 换场景使用:把法律案例换成金融审批、医疗指南、内容审核数据,改一改提示词,就能直接用在其他低容错场景

  3. 添加Kibana可视化:用Kibana查看ES里的案例数据,监控检索效果

  4. 优化检索精度:给ES检索添加元数据过滤,比如只检索「合同类型=服务协议」的案例,减少无关结果

  5. 添加输入校验:在HITL步骤里,校验用户的输入是不是符合要求,比如确认步骤只能输入yes/no,不符合就重新提示

相关推荐
G***技15 小时前
IB3-771:为智慧工厂巡检机器人打造“感知-决策-执行”一体化控制核心
人工智能·嵌入式主板
zhangxingchao15 小时前
AI 大模型核心五:从 Transformer、RAG 到 Agent 架构
前端·人工智能·后端
love8888_cnsd15 小时前
Git & Linux 速查表
java·linux·git·后端·elasticsearch
和blue一起变得更好15 小时前
day4 element plus+vue3+vite实现简单学习任务管理
javascript·vue.js·学习
weixin_3975740915 小时前
食品包装AI质检系统技术实现:从OCR提取到合规检测全链路
人工智能·ocr
仰望星空的代码15 小时前
市场兴亡,AI有责
人工智能·财经·股市行情
weixin_4684668515 小时前
Transformer 模型新手入门与实战指南
人工智能·python·深度学习·机器学习·transformer·热力图·注意力机制
AI周红伟16 小时前
中国第一大DRAM,长鑫科技,迈向算力第二巨头
大数据·人工智能·科技·elasticsearch·搜索引擎
爱喝水的鱼丶16 小时前
SAP-ABAP:条件判断与循环控制语句(7篇)第七篇:性能优化:条件与循环代码的常见性能瓶颈与优化方案
学习·算法·性能优化·sap·abap