Totoro fe70cfc5ae
feat(plugins): add standalone Jev text classification example (#5735)
* feat(examples): add a standalone text classification plugin

A deployment-installed package contributes one model tool, classify_texts,
and a status action through the full-stack plugin contract. The tool labels a
list of texts against caller-supplied categories with a deployment-configured
backend: Jev (one choice question per item over a shared batch state, checked
per item) or an OpenAI-compatible chat endpoint (a JSON label list validated
in code). Items are packed into requests under a size limit and sent with
bounded concurrency; every failure is explicit per call or per item;
credentials come only from deployment-named environment variables; item text
never enters instructions, question keys, results or errors, and the served
model name is kept to a short identifier. Each batch runs under an asyncio
timeout bounded by the call deadline, so the whole call stays inside the
host's 30-second limit, and ids and category names are bounded in bytes so
300 results fit the host's 64 KiB output limit.

* docs(examples): add an opt-in live smoke test for the text classification plugin

The script loads the package through the real extension path and classifies 20
synthetic English and Chinese sentences plus a blank and an over-long item with
the configured backend, printing labels and statuses only. Against real Jev it
agreed on all 20 labels in two requests and 1.6 seconds, with the served model
reported as jev-1.13.0.

* fix(examples): measure request size as sent and cover transport failures

httpx serializes json= bodies with the json.dumps default, escaping every
non-ASCII character as six bytes, while the size estimators measured raw
UTF-8, so CJK batches could reach about twice the 256 KiB limit. The
estimators now measure the same encoding; a test with CJK categories and
texts checks the bytes the transport actually received. The network status
gets a test through connect and protocol errors, and the deadline test's
wall-clock slack is widened for loaded runners.

* fix(examples): match classifier request wire encoding

* fix(examples): bound classification output keys by JSON size

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-09-24 16:36:30 +08:00

117 lines
5.8 KiB
Python

"""Opt-in live smoke test: classify a small synthetic list through the real plugin path.
Calls the configured backend for real and costs money; nothing here is a benchmark.
Credentials are read only from the named environment variable and never printed.
Run from ``backend/`` in a deployment checkout:
export TYPESAFE_API_KEY='your-deployment-secret'
uv run python ../examples/deerflow-extension-jev-classify/scripts/verify_live.py
For a chat backend add ``--backend llm --llm-url ... --llm-model ... --api-key-env NAME``.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from deerflow_extension_api.auth import ExtensionPrincipal # noqa: E402
from deerflow_extension_api.plugins import ToolContext # noqa: E402
from deerflow.extensions.loader import ExtensionSpec, load_extensions # noqa: E402
CATEGORIES = [
{"name": "billing", "description": "Payments, invoices, refunds and subscription charges."},
{"name": "technical", "description": "Errors, crashes, integration or API failures."},
{"name": "account", "description": "Login, password, profile and permission requests."},
{"name": "other", "description": "Anything that fits none of the other categories."},
]
# Synthetic sentences with an obvious label; a disagreement is worth a look, not a failure.
ITEMS = [
("en-1", "I was charged twice for last month's subscription.", "billing"),
("en-2", "The webhook endpoint returns HTTP 500 since yesterday.", "technical"),
("en-3", "How do I reset my password?", "account"),
("en-4", "Please send me the invoice for order 4471.", "billing"),
("en-5", "The app crashes when I open the settings page.", "technical"),
("en-6", "Can you add my colleague as an admin on our workspace?", "account"),
("en-7", "What are your office hours?", "other"),
("en-8", "Refund request: the product never arrived.", "billing"),
("en-9", "The API key I generated does not work with the SDK.", "technical"),
("en-10", "I want to change the email address on my profile.", "account"),
("zh-1", "上个月的订阅费被扣了两次。", "billing"),
("zh-2", "从昨天开始 webhook 接口一直返回 500。", "technical"),
("zh-3", "怎么重置密码?", "account"),
("zh-4", "请把订单 4471 的发票发给我。", "billing"),
("zh-5", "打开设置页面应用就崩溃。", "technical"),
("zh-6", "能把我同事加为工作区管理员吗?", "account"),
("zh-7", "你们的营业时间是几点?", "other"),
("zh-8", "申请退款:商品一直没有收到。", "billing"),
("zh-9", "我生成的 API key 在 SDK 里用不了。", "technical"),
("zh-10", "我想修改个人资料里的邮箱。", "account"),
("blank", " ", None),
("long", "x" * 2001, None),
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
parser.add_argument("--backend", choices=["jev", "llm"], default="jev")
parser.add_argument("--api-key-env", default=None, help="environment variable holding the key (default: the plugin's default for the backend)")
parser.add_argument("--model", default=None, help="Jev model name (default jev-latest)")
parser.add_argument("--llm-url", default=None)
parser.add_argument("--llm-model", default=None)
parser.add_argument("--batch-size", type=int, default=10)
args = parser.parse_args()
config: dict[str, object] = {"enabled": True, "backend": args.backend, "batch_size": args.batch_size}
if args.backend == "jev":
if args.model:
config["jev_model"] = args.model
if args.api_key_env:
config["jev_api_key_env"] = args.api_key_env
else:
config.update({"llm_url": args.llm_url, "llm_model": args.llm_model})
if args.api_key_env:
config["llm_api_key_env"] = args.api_key_env
key_env = str(config.get("jev_api_key_env" if args.backend == "jev" else "llm_api_key_env", "TYPESAFE_API_KEY" if args.backend == "jev" else "CLASSIFY_LLM_API_KEY"))
if not os.environ.get(key_env):
print(f"no credential in environment variable {key_env}", file=sys.stderr)
return 2
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_jev_classify:install", config=config)])
if diagnostics or not loaded.plugins:
print(f"extension did not load: {[str(d) for d in diagnostics]}", file=sys.stderr)
return 2
((_, plugin),) = loaded.plugins
(tool,) = plugin.tools
payload = {"items": [{"id": identifier, "text": text} for identifier, text, _ in ITEMS], "categories": CATEGORIES}
started = time.perf_counter()
result = asyncio.run(tool.handler(payload, ToolContext(ExtensionPrincipal("smoke"), {"enabled": True}, None)))
elapsed = time.perf_counter() - started
if "error" in result:
print(json.dumps(result, ensure_ascii=False))
return 1
expected = {identifier: label for identifier, _, label in ITEMS}
agree = sum(1 for entry in result["results"] if entry["status"] == "ok" and entry["label"] == expected[entry["id"]])
for entry in result["results"]:
flag = "" if entry["label"] == expected[entry["id"]] or entry["status"] != "ok" else " <- expected " + str(expected[entry["id"]])
print(f"{entry['id']:>6} {entry['status']:<13} {entry['error']:<16} {entry['label'] or '-'}{flag}")
print(
json.dumps(
{"backend": result["backend"], "model": result["model"], "counts": result["counts"], "requests": result["requests"], "agreement": f"{agree}/{sum(1 for e in expected.values() if e)}", "elapsed_s": round(elapsed, 2)},
ensure_ascii=False,
)
)
return 0 if result["requests"] else 1
if __name__ == "__main__":
sys.exit(main())