AI 工具

用 Langflow 自定义组件规范化食堂报修单

Langflow 的强项是用 Python 写自定义组件。食堂报修单经常是口语化的「305 室水龙头一直漏水」,进流程前最好先结构化。 这篇实现 TicketNormalizer:可在无 Langflow 时单测的 normalizeticket,外加符合官方自定义组件形状的类骨架;再用 checkcomponent.py 用 AST 检查 displayn

BINBHO · 图文教程

Langflow 的强项是用 Python 写自定义组件。食堂报修单经常是口语化的「305 室水龙头一直漏水」,进流程前最好先结构化。 这篇实现 TicketNormalizer:可在无 Langflow 时单测的 normalize_ticket,外加符合官方自定义组件形状的类骨架;再用 check_component.py 用 AST 检查 display_name / 输出方法等。

先看清成品

  • normalize_ticket("305室水龙头一直漏水") → room=305, category=漏水, urgent=true。
  • 组件骨架检查通过;bad_component.py 报出缺属性/缺方法。
  • 5 项 pytest 通过。未加载进真实 Langflow。

合规提醒:用自己的 Langflow 实例与模型密钥;自定义组件里的代码会在服务器上执行,不要加载不可信流程。密钥放环境变量,导出流程前确认不会带出 Key。费用以模型供应商为准,本文不写价格。

运行说明:我没有安装完整 Langflow 服务。真实运行过的是:组件骨架检查、normalize 逻辑、Run API 客户端对 mock 的请求、.env 检查,以及 pytest(2026-10-23,UTC+8)。服务端行为来自官方文档。

做完之后你会得到什么?

成果 说明
components/ticket_normalizer.py 自定义组件 + 可单测核心
check_component.py 骨架检查
bad_component.py 反例
test_92a.py 5 项测试

需要准备哪些环境?

项目 说明
Python 3.10–3.13 推荐;pip install pytest
Langflow(可选) 官方推荐 uv 安装;或 Docker
API Key Langflow 控制台创建,或本文 mock

关键事实

事实 来源
自定义组件继承 Component,声明 inputs/outputs,输出 method 名与 outputs[].method 对应 官方 Custom Components
新版包名常为 lfx;未安装时本教程用 try/except 保留同类骨架便于本地检查 官方文档
组件在服务器进程内执行,等同信任该代码 官方安全相关说明 / 部署实践

文件结构

92-langflow-A-src/
├── components/
│   ├── __init__.py
│   └── ticket_normalizer.py
├── check_component.py
├── bad_component.py
└── test_92a.py

第 1 步:组件

"""Langflow-style custom component: normalize cafeteria repair tickets.

This file follows the official custom-component shape (display_name, inputs,
outputs, and a method matching the output). It also runs standalone so we can
test the logic without installing the full Langflow server.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

# --- Standalone core (also used by tests) ---------------------------------

ROOM_RE = re.compile(r"(?P<room>\d{2,4})\s*(室|房间|房)?")
CATS = [
    ("漏水", ["漏水", "滴水", "水管", "水龙头"]),
    ("电路", ["停电", "跳闸", "插座", "灯不亮", "电线"]),
    ("门窗", ["门锁", "钥匙", "窗户", "玻璃"]),
    ("空调", ["空调", "不制冷", "外机"]),
]


def normalize_ticket(text: str) -> dict[str, Any]:
    text = (text or "").strip()
    if not text:
        raise ValueError("empty ticket")
    room = None
    m = ROOM_RE.search(text)
    if m:
        room = m.group("room")
    cat = "其他"
    for name, kws in CATS:
        if any(k in text for k in kws):
            cat = name
            break
    urgent = any(k in text for k in ("一直", "紧急", "整层", "冒烟", "漏电"))
    return {
        "room": room,
        "category": cat,
        "urgent": urgent,
        "summary": text[:80],
    }


# --- Langflow Component skeleton (docs.langflow.org/components-custom-components)

try:
    from lfx.custom.custom_component.component import Component  # type: ignore
    from lfx.io import MessageTextInput, Output  # type: ignore
    from lfx.schema import Data  # type: ignore

    class TicketNormalizer(Component):
        display_name = "Ticket Normalizer"
        description = "Normalize cafeteria repair tickets into structured fields."
        icon = "wrench"
        name = "ticket_normalizer"

        inputs = [
            MessageTextInput(name="text", display_name="Ticket text", required=True),
        ]
        outputs = [
            Output(display_name="Ticket", name="ticket", method="build_ticket"),
        ]

        def build_ticket(self) -> Data:
            data = normalize_ticket(self.text)
            return Data(data=data)

except ImportError:
    # Langflow / lfx not installed — skeleton still documents the interface.
    class TicketNormalizer:  # type: ignore
        display_name = "Ticket Normalizer"
        description = "Normalize cafeteria repair tickets into structured fields."
        icon = "wrench"
        name = "ticket_normalizer"

        def build_ticket(self, text: str) -> dict:
            return normalize_ticket(text)

第 2 步:检查器

#!/usr/bin/env python3
"""Validate that a Python file looks like a Langflow custom component."""
from __future__ import annotations

import argparse
import ast
import sys
from pathlib import Path

REQUIRED_ATTRS = ("display_name", "description", "name")


def check(path: Path) -> list[str]:
    src = path.read_text(encoding="utf-8")
    tree = ast.parse(src, filename=str(path))
    errs: list[str] = []
    classes = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]
    # Prefer a class that defines display_name (may live inside try/except ImportError)
    target = None
    for c in classes:
        names = {n.targets[0].id for n in c.body if isinstance(n, ast.Assign) for t in n.targets if isinstance(t, ast.Name)}
        # also AnnAssign
        for n in c.body:
            if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name):
                names.add(n.target.id)
        if "display_name" in names or any(isinstance(n, ast.Assign) and any(getattr(t, "id", None) == "display_name" for t in getattr(n, "targets", [])) for n in c.body):
            target = c
            break
    if target is None and classes:
        target = classes[-1]
    if target is None:
        return ["没有 class 定义"]

    attrs: set[str] = set()
    methods: set[str] = set()
    for n in target.body:
        if isinstance(n, ast.Assign):
            for t in n.targets:
                if isinstance(t, ast.Name):
                    attrs.add(t.id)
        elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name):
            attrs.add(n.target.id)
        elif isinstance(n, ast.FunctionDef):
            methods.add(n.name)

    for a in REQUIRED_ATTRS:
        if a not in attrs:
            errs.append(f"缺少类属性 {a}")
    if "build_" not in " ".join(methods) and "process" not in methods and not any(m.startswith("build") for m in methods):
        # Official pattern: output method name matches outputs[].method
        if not methods - {"__init__", "__doc__"}:
            errs.append("缺少处理用的方法(例如 build_ticket)")
    # Soft check: mentions Component inheritance in source
    if "Component" not in src and "lfx" not in src and "langflow" not in src:
        errs.append("未看到 Component / lfx / langflow 引用(可能不是可加载的组件)")
    return errs


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("path", type=Path)
    args = p.parse_args(argv)
    errs = check(args.path)
    if errs:
        print(f"✗ {args.path.name}")
        for e in errs:
            print(f"  - {e}")
        return 1
    print(f"✓ {args.path.name}: 组件骨架检查通过")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

第 3 步:测试

from pathlib import Path
from components.ticket_normalizer import normalize_ticket
from check_component import check, main

ROOT = Path(__file__).parent

def test_normalize():
    r = normalize_ticket("302 卫生间水龙头一直滴水")
    assert r["room"] == "302"
    assert r["category"] == "漏水"
    assert r["urgent"] is True

def test_empty():
    try:
        normalize_ticket("  ")
        assert False
    except ValueError:
        pass

def test_component_ok():
    assert check(ROOT / "components/ticket_normalizer.py") == []

def test_component_bad():
    errs = check(ROOT / "bad_component.py")
    assert errs

def test_cli():
    assert main([str(ROOT / "components/ticket_normalizer.py")]) == 0
    assert main([str(ROOT / "bad_component.py")]) == 1

第 4 步:运行

# 2026-10-23 12:37 UTC+8
$ python check_component.py components/ticket_normalizer.py
✓ ticket_normalizer.py: 组件骨架检查通过
$ python check_component.py bad_component.py
✗ bad_component.py
  - 缺少类属性 display_name
  - 缺少类属性 description
  - 缺少类属性 name
  - 缺少处理用的方法(例如 build_ticket)
  - 未看到 Component / lfx / langflow 引用(可能不是可加载的组件)
$ python -c "from components.ticket_normalizer import normalize_ticket; import json; print(json.dumps(normalize_ticket('305室水龙头一直漏水'), ensure_ascii=False, indent=2))"
{
  "room": "305",
  "category": "漏水",
  "urgent": true,
  "summary": "305室水龙头一直漏水"
}
$ python -m pytest -q
5 passed

怎么检查结果?

  • 骨架检查必须通过;bad 必须失败。
  • normalize 对漏水/紧急关键词分类正确。
  • 装好 Langflow 后,把 components 目录按官方方式挂载再在画布里选组件。

调试示例

AST 看不到 try 里的 class。 第一版 check_component 只扫 tree.body 顶层 ClassDef。组件文件把 TicketNormalizer 放在 try: import lfx ... except ImportError: 里,顶层没有 ClassDef,于是报「没有 class 定义」。改成 ast.walk 后才能找到。自定义组件经常要兼容「有/无 lfx」,检查器必须能进 try/except。

常见问题

现象 原因 处理
画布找不到组件 目录未挂载或未刷新 按官方自定义组件路径配置
ImportError lfx 未装 Langflow 本地仍可用 normalize_ticket 单测
urgent 不对 关键词表不全 按业务扩充 CATS

模板规则

  • 核心逻辑与框架 import 解耦,便于 pytest。
  • 提交前跑 check_component。
  • 不加载不可信自定义组件。

项目资源

用途 地址
Custom Components(官方) https://docs.langflow.org/components-custom-components
Langflow 文档 https://docs.langflow.org/
官网 https://www.langflow.org

先把结构化逻辑测绿,再拖进画布。