2155f65c76
- 驱动/技能执行/: oneshot + system 解释器. 读自带 技能/*.json -> 按 tier 选模型 -> 拼 prompt -> 调 OpenAI 兼容端点 -> 剥 JSON -> 逐条按档校验 -> 落盘 + 写 events main 档只锁外层 (plan 是数组 + 每步有 want); small 档锁死 (单值 want + 必须在契约 清单里 + args 键白名单 + confidence 只许 0/0.5/1) - 契约清单只读 PG 的 drivers 表拿 (不 import 别的驱动、不翻别人的文件夹); 也支持 EFI_SKILL_CONTRACTS 显式给一份 (手工跑 / PG 不在时) - 思考型模型把 max_tokens 吃光时, 把预算翻倍重试一次; 本机端点强制不走代理 - 驱动/Json解码: 补 provides ["Json解码:解析"] -- skill 样例里一直在用这个契约名, 之前没人提供 - 文档同步: README / 文档 02 / 文档 06 / 文档 10 (坑 27~30) / 设计 01 / 设计 05 (补记执行侧) - .gitignore: 盖住 驱动/*/输出/ (运行时产物不进 git)
587 lines
26 KiB
Python
587 lines
26 KiB
Python
"""技能执行: 示例驱动 -- 把驱动自带的 skill 真正执行一遍.
|
|
|
|
[这个示例演示了什么]
|
|
|
|
1. 底座最下面那层是"确定性代码能力接入层". 驱动把一种外部能力接进来, 这里接的是
|
|
"一次模型调用"; 上面两层(引导器 / 内核)完全不知道下面在用模型.
|
|
2. **skill 归驱动管**. 读哪份 / 选哪个模型 / prompt 怎么拼 / 输出怎么校验 / 怎么记账,
|
|
全在本文件里, 内核不知道 skill 的存在 (依据 设计/01 第 7 节 与 设计/05 技能规范).
|
|
3. 两档 skill 的差别落到代码上 -- 同一件事写两份, 校验强度不同:
|
|
main : 只锁外层 (plan 是数组 + 每步有 want + confidence 在 0~1)
|
|
small: 全锁死 (want 单值 + 必须在契约清单里 + args 键白名单 + confidence 只许 0/0.5/1)
|
|
4. 与底座的全部接口只有两处:
|
|
环境变量 EFI_DB -- 内核注入的库连接串 (没有也能跑, 只是不汇报)
|
|
events 表 -- 汇报的唯一通道, 往表里插一行 (没有协议)
|
|
另外它**只读** drivers 表拿契约清单 -- 真实驱动就该这么拿底座里的信息, 而不是去
|
|
翻别的驱动文件夹 (驱动之间零耦合, 见 设计/01 第 3 节).
|
|
|
|
[怎么改]
|
|
|
|
模型端点 / 模型名 / 跑哪一档 / 喂什么话, 全在 配置.efi.json 的 env 段, 源码不硬编码;
|
|
密钥类(EFI_LLM_KEY)不写进配置, 从环境变量继承 (密钥不落盘).
|
|
不需要内核也能手工跑一遍: python3 驱动/技能执行/执行.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
驱动名 = "技能执行"
|
|
驱动目录 = Path(__file__).resolve().parent
|
|
技能目录 = 驱动目录 / "技能"
|
|
输出目录 = 驱动目录 / "输出"
|
|
# 模型端点超时: 本地小模型带思考时会慢, 给宽一点; 但绝不无限等 (等不到就如实报错退出)
|
|
模型超时秒 = 240.0
|
|
# 单次最多让模型吐多少 token; 思考型模型会把预算全用在思考上, 所以 content 空时自动翻倍重试
|
|
模型默认最大输出 = 4096
|
|
最大输出封顶 = 16384
|
|
# 允许的置信度取值 (small 档写死的三档); 用集合判"是不是这三个之一"
|
|
小模型置信度 = {0.0, 0.5, 1.0}
|
|
|
|
|
|
# ---------------------------------------------------------------- 与底座打交道
|
|
|
|
def 取环境(键: str, 默认: str = "") -> str:
|
|
"""取一个配置值: 内核把 配置.efi.json 的 env 段注入环境变量, 手工跑就是普通环境变量."""
|
|
值 = os.environ.get(键, "").strip()
|
|
return 值 if 值 else 默认
|
|
|
|
|
|
def 汇报(消息: str, kind: str = "log", 数据: dict[str, Any] | None = None) -> None:
|
|
"""往 events 表插一行 -- 驱动唯一的汇报方式 (没有协议, 内核只读表, 不解析 stdout).
|
|
|
|
连不上 PG / 没装 psycopg2 都不算失败: 打印一行就够了 (设计 01 第 3 节). 汇报失败
|
|
也不许把驱动搞死. 这里刻意每次新开连接: 本驱动是一次性任务, 用完就关最省事.
|
|
"""
|
|
串 = os.environ.get("EFI_DB", "")
|
|
if not 串:
|
|
return
|
|
try:
|
|
import psycopg2 # type: ignore[import-untyped]
|
|
except ImportError:
|
|
return
|
|
try:
|
|
连接对象 = psycopg2.connect(串)
|
|
连接对象.autocommit = True
|
|
游标 = 连接对象.cursor()
|
|
游标.execute(
|
|
"INSERT INTO events (source, driver, level, kind, message, data)"
|
|
" VALUES (%s, %s, 'info', %s, %s, %s::jsonb)",
|
|
(驱动名, 驱动名, kind, 消息, json.dumps(数据 or {}, ensure_ascii=False)),
|
|
)
|
|
游标.close()
|
|
连接对象.close()
|
|
except Exception as 错: # 汇报失败不该把驱动搞死
|
|
print(f"{驱动名}: 汇报失败: {错}", flush=True)
|
|
|
|
|
|
def 读契约清单() -> list[dict[str, Any]]:
|
|
"""问底座"现在有哪些契约可以用" -- 只读 PG 的 drivers 表 (内核扫描时写进去的).
|
|
|
|
真实驱动就该这么拿信息: 不 import 别的驱动, 也不去翻别人的文件夹. 参数列表库里没有
|
|
(契约名本身不带参数说明), 一律给空表 -- 对应 skill 里那条"参数列表为空就表示这个契约
|
|
不接受参数, args 填 {}".
|
|
"""
|
|
显式 = 取环境("EFI_SKILL_CONTRACTS")
|
|
if 显式:
|
|
# 手工跑 / PG 不在时也能演示: 直接给一份清单
|
|
try:
|
|
读了: Any = json.loads(显式)
|
|
except json.JSONDecodeError as 错:
|
|
print(f"{驱动名}: EFI_SKILL_CONTRACTS 不是合法 JSON: {错}", flush=True)
|
|
return []
|
|
if isinstance(读了, list):
|
|
return [cast(dict[str, Any], 项) for 项 in cast(list[Any], 读了) if isinstance(项, dict)]
|
|
return []
|
|
|
|
串 = os.environ.get("EFI_DB", "")
|
|
if not 串:
|
|
print(f"{驱动名}: 没拿到 EFI_DB, 契约清单当空 (手工跑就这样, 不算错)", flush=True)
|
|
return []
|
|
try:
|
|
import psycopg2 # type: ignore[import-untyped]
|
|
|
|
连接对象 = psycopg2.connect(串)
|
|
连接对象.autocommit = True
|
|
游标 = 连接对象.cursor()
|
|
游标.execute("SELECT provides FROM drivers WHERE provides IS NOT NULL ORDER BY name")
|
|
行集 = 游标.fetchall()
|
|
游标.close()
|
|
连接对象.close()
|
|
except Exception as 错:
|
|
print(f"{驱动名}: 读 drivers 表失败 (当没有契约): {错}", flush=True)
|
|
return []
|
|
|
|
清单: list[dict[str, Any]] = []
|
|
for 行 in cast(list[Any], 行集):
|
|
契约们 = 行[0]
|
|
if not isinstance(契约们, list):
|
|
continue
|
|
for 契约 in cast(list[Any], 契约们):
|
|
文本 = str(契约).strip()
|
|
if 文本:
|
|
清单.append({"want": 文本, "参数": []})
|
|
return 清单
|
|
|
|
|
|
# ---------------------------------------------------------------- 解释 skill
|
|
|
|
def 取对象(值: Any) -> dict[str, Any]:
|
|
"""值确实是 dict 就给 dict, 否则空 dict -- 窄化只做一次, 别在代码里到处 cast."""
|
|
if isinstance(值, dict):
|
|
return cast(dict[str, Any], 值)
|
|
return {}
|
|
|
|
|
|
def 字符串表(值: Any) -> list[str]:
|
|
"""把 json 里读出来的 list 收成 list[str] (脏数据不炸: 非字符串项直接丢)."""
|
|
if not isinstance(值, list):
|
|
return []
|
|
出: list[str] = []
|
|
for 项 in cast(list[Any], 值):
|
|
if isinstance(项, str) and 项.strip():
|
|
出.append(项)
|
|
return 出
|
|
|
|
|
|
def 编号(项们: list[str]) -> str:
|
|
"""1. xxx / 2. xxx 排下来 (步骤和红线都用这个, 数字让模型逐步跟)."""
|
|
return "\n".join(f" {序}. {文}" for 序, 文 in enumerate(项们, 1))
|
|
|
|
|
|
def 读技能(档: str) -> tuple[str, dict[str, Any]] | None:
|
|
"""按 tier 挑一份 skill 读出来; 找不到 / 读不了就说清为什么 (不静默跳过).
|
|
|
|
skill 放 技能/*.json, 一个 skill 一个文件, 文件名不参与语义 (name 字段才是名字).
|
|
"""
|
|
if not 技能目录.is_dir():
|
|
print(f"{驱动名}: 没有 技能/ 目录, 没东西可执行", flush=True)
|
|
return None
|
|
有档: list[str] = []
|
|
for 路径 in sorted(技能目录.glob("*.json")):
|
|
try:
|
|
内容: Any = json.loads(路径.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as 错:
|
|
print(f"{驱动名}: 读不了 {路径.name}: {错}", flush=True)
|
|
continue
|
|
if not isinstance(内容, dict):
|
|
print(f"{驱动名}: {路径.name} 顶层必须是对象, 跳过", flush=True)
|
|
continue
|
|
对象 = cast(dict[str, Any], 内容)
|
|
现档 = str(对象.get("tier") or "")
|
|
有档.append(现档)
|
|
if 现档 == 档:
|
|
return 路径.name, 对象
|
|
print(f"{驱动名}: 没有 tier={档} 的 skill (现有档: {sorted(有档)})", flush=True)
|
|
return None
|
|
|
|
|
|
def 拼系统提示(技能: dict[str, Any]) -> str:
|
|
"""把 skill 声明拼成给模型的系统提示 -- 这就是"驱动解释 skill"的落点.
|
|
|
|
骨架只有一份: 两份 skill 的结构一样, 差别全在文件里写的步骤 / 输出结构 / 边界 / 样例,
|
|
所以**换档只是换一份文件, 换模型也不用改这里**.
|
|
"""
|
|
段: list[str] = []
|
|
段.append(f"你是 {驱动名} 里的解析器. 任务: {str(技能.get('description') or '')}")
|
|
|
|
触发 = 字符串表(技能.get("when"))
|
|
if 触发:
|
|
段.append("什么情况下用:\n" + 编号(触发))
|
|
|
|
步骤 = 字符串表(技能.get("steps"))
|
|
if 步骤:
|
|
段.append("执行步骤:\n" + 编号(步骤))
|
|
|
|
输出 = 取对象(技能.get("output"))
|
|
if 输出:
|
|
模式 = 取对象(输出.get("schema"))
|
|
if 模式:
|
|
字段行 = "\n".join(f" {键}: {值}" for 键, 值 in 模式.items())
|
|
段.append("输出结构 (必须是 JSON):\n" + 字段行)
|
|
说明 = str(输出.get("说明") or "")
|
|
if 说明:
|
|
段.append(说明)
|
|
|
|
样例 = 技能.get("examples")
|
|
if isinstance(样例, list) and cast(list[Any], 样例):
|
|
块: list[str] = []
|
|
for 项 in cast(list[Any], 样例):
|
|
对 = 取对象(项)
|
|
块.append("输入: " + json.dumps(对.get("输入"), ensure_ascii=False))
|
|
块.append("输出: " + json.dumps(对.get("输出"), ensure_ascii=False))
|
|
段.append("示例:\n" + "\n".join(块))
|
|
|
|
边界 = 字符串表(技能.get("boundaries"))
|
|
if 边界:
|
|
段.append("红线 (违反即错):\n" + 编号(边界))
|
|
|
|
段.append("只输出 JSON 本体: 前后不要任何解释文字, 不要 markdown 代码块标记.")
|
|
if 取环境("EFI_LLM_NO_THINK"):
|
|
# 带思考的模型(DeepSeek / Qwen3 系)会把 max_tokens 全用在思考上, content 回来是空串.
|
|
# Qwen3 系的软开关 /no_think 放在**提示末尾**才认, 比塞进请求体的字段更通用 (2026-09-17 实测).
|
|
段.append("/no_think")
|
|
return "\n\n".join(段)
|
|
|
|
|
|
def 拼用户输入(技能: dict[str, Any], 原话: str, 清单: list[dict[str, Any]]) -> str:
|
|
"""按这份 skill 自己声明的 input.schema 结构, 把真实输入拼成 JSON.
|
|
|
|
两档的输入字段名不一样 (main 要"可用契约"是字符串表, small 要"契约清单"带参数名),
|
|
这里**照 skill 写的 schema 填**, 不写死字段名 -- 又是一处"驱动解释 skill"而不是"驱动写死 skill".
|
|
"""
|
|
模式 = 取对象(取对象(技能.get("input")).get("schema"))
|
|
载荷: dict[str, Any] = {}
|
|
for 键 in 模式:
|
|
if 键 == "用户原话":
|
|
载荷[键] = 原话
|
|
elif 键 == "可用契约":
|
|
载荷[键] = [str(项.get("want") or "") for 项 in 清单]
|
|
elif 键 == "契约清单":
|
|
载荷[键] = 清单
|
|
else:
|
|
载荷[键] = None
|
|
if not 载荷: # skill 没写 input.schema 就退回一个最小结构, 不让模型空手答
|
|
载荷 = {"用户原话": 原话, "契约清单": 清单}
|
|
return json.dumps(载荷, ensure_ascii=False, indent=2)
|
|
|
|
|
|
# ---------------------------------------------------------------- 调模型
|
|
|
|
def 建打开器(端点: str) -> urllib.request.OpenerDirector:
|
|
"""本机端点直连: 本机装了 Clash 这类代理时, http_proxy 会把 127.0.0.1 的请求也劫走."""
|
|
主机 = urllib.parse.urlsplit(端点).hostname or ""
|
|
if 主机 in ("127.0.0.1", "localhost", "::1"):
|
|
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
return urllib.request.build_opener()
|
|
|
|
|
|
def 调模型(端点: str, 模型: str, 系统提示: str, 用户输入: str, 最大输出: int) -> tuple[bool, str, bool]:
|
|
"""调一次 OpenAI 兼容的 /chat/completions (只用标准库, 驱动不引第三方依赖).
|
|
|
|
返回 (成没成, 模型原文 或 错误原文, 是不是"思考把输出吃光了"). 失败**不吞错**,
|
|
也不编一个假结果出来; 最后那个标志位让调用方决定要不要加大预算重试一次.
|
|
"""
|
|
请求体: dict[str, Any] = {
|
|
"model": 模型,
|
|
"messages": [
|
|
{"role": "system", "content": 系统提示},
|
|
{"role": "user", "content": 用户输入},
|
|
],
|
|
"temperature": 0,
|
|
"max_tokens": 最大输出,
|
|
"stream": False,
|
|
}
|
|
if 取环境("EFI_LLM_NO_THINK"):
|
|
# 带思考的模型(DeepSeek / Qwen3 系)会把 max_tokens 全用在思考上, content 回来是空串.
|
|
# 这是 Qwen3 系的通用写法; 换成不认这个字段的端点(如 OpenAI 官方)就把配置里那行删掉.
|
|
请求体["chat_template_kwargs"] = {"enable_thinking": False}
|
|
头 = {"Content-Type": "application/json"}
|
|
密钥 = 取环境("EFI_LLM_KEY")
|
|
if 密钥:
|
|
头["Authorization"] = f"Bearer {密钥}"
|
|
请求 = urllib.request.Request(
|
|
url=端点.rstrip("/") + "/chat/completions",
|
|
data=json.dumps(请求体, ensure_ascii=False).encode("utf-8"),
|
|
headers=头,
|
|
method="POST",
|
|
)
|
|
打开器 = 建打开器(端点)
|
|
try:
|
|
with 打开器.open(请求, timeout=模型超时秒) as 应答:
|
|
原文 = 应答.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as 错:
|
|
try:
|
|
详情 = 错.read().decode("utf-8", errors="replace")
|
|
except OSError:
|
|
详情 = ""
|
|
return False, f"模型端点回了 HTTP {错.code}: {详情[:400]}", False
|
|
except (urllib.error.URLError, TimeoutError, OSError) as 错:
|
|
return False, f"连不上模型端点 {端点}: {错}", False
|
|
|
|
try:
|
|
包: Any = json.loads(原文)
|
|
except json.JSONDecodeError as 错:
|
|
return False, f"端点回的不是 JSON: {错} / 原文 {原文[:200]}", False
|
|
选择 = 取对象(包).get("choices")
|
|
if not isinstance(选择, list) or not cast(list[Any], 选择):
|
|
return False, f"端点没给 choices: {原文[:300]}", False
|
|
首项 = 取对象(cast(list[Any], 选择)[0])
|
|
内容 = 取对象(首项.get("message")).get("content")
|
|
if not isinstance(内容, str) or not 内容.strip():
|
|
# 带思考的模型把 max_tokens 全用在思考上时, content 就是空的 (2026-09-17 真踩到)
|
|
return False, (
|
|
"模型 content 是空的 (带思考的模型把 max_tokens 用在思考上了; "
|
|
"配置里开 EFI_LLM_NO_THINK=1 关掉思考, 或者把 max_tokens / 上下文调大): "
|
|
+ 原文[:300]
|
|
), True
|
|
return True, 内容, False
|
|
|
|
|
|
def 剥JSON(文本: str) -> tuple[bool, Any]:
|
|
"""从模型原文里抠出 JSON 对象 -- 模型爱加 ``` 围栏或前后废话, 清洗是驱动该干的活."""
|
|
清洗 = 文本.strip()
|
|
if 清洗.startswith("```"):
|
|
换行 = 清洗.find("\n")
|
|
清洗 = 清洗[换行 + 1 :] if 换行 >= 0 else 清洗
|
|
右围栏 = 清洗.rfind("```")
|
|
if 右围栏 >= 0:
|
|
清洗 = 清洗[:右围栏]
|
|
清洗 = 清洗.strip()
|
|
try:
|
|
return True, json.loads(清洗)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# 兜底: 取第一个 { 到最后一个 } (前后还有废话时用)
|
|
左 = 清洗.find("{")
|
|
右 = 清洗.rfind("}")
|
|
if 左 >= 0 and 右 > 左:
|
|
try:
|
|
return True, json.loads(清洗[左 : 右 + 1])
|
|
except json.JSONDecodeError as 错:
|
|
return False, f"剥出来的片段不是合法 JSON: {错}"
|
|
return False, "模型输出里没有 JSON 对象"
|
|
|
|
|
|
# ---------------------------------------------------------------- 校验输出
|
|
|
|
def 校验输出(档: str, 结果: Any, 清单: list[dict[str, Any]]) -> list[tuple[bool, str]]:
|
|
"""按这一档的规矩逐条核对模型输出 (每条给"判据 + 结论", 可解释、可审计).
|
|
|
|
main : 只锁外层 -- plan 是数组 + 每步有 want + confidence 在 0~1
|
|
small: 全锁死 -- want 单值 + 必须在契约清单里 + args 键白名单 + confidence 只许 0/0.5/1
|
|
"""
|
|
判据: list[tuple[bool, str]] = []
|
|
对象 = 取对象(结果)
|
|
if not 对象:
|
|
判据.append((False, "顶层是 JSON 对象"))
|
|
return 判据
|
|
判据.append((True, "顶层是 JSON 对象"))
|
|
|
|
if 档 == "small":
|
|
判据.extend(_校小模型(对象, 清单))
|
|
else:
|
|
判据.extend(_校主模型(对象))
|
|
return 判据
|
|
|
|
|
|
def _校小模型(对象: dict[str, Any], 清单: list[dict[str, Any]]) -> list[tuple[bool, str]]:
|
|
"""small 档的校验: 结构全锁死, 差一格都算不过 (这一档靠结构稳, 不靠模型聪明)."""
|
|
判据: list[tuple[bool, str]] = []
|
|
可用 = {str(项.get("want") or ""): 字符串表(项.get("参数")) for 项 in 清单}
|
|
|
|
if "want" not in 对象:
|
|
判据.append((False, "有 want 字段"))
|
|
return 判据
|
|
|
|
要的 = 对象.get("want")
|
|
if 要的 is None:
|
|
判据.append((True, "want 是 null (回问, 合法)"))
|
|
elif isinstance(要的, str):
|
|
判据.append((True, "want 是字符串"))
|
|
if 要的 in 可用:
|
|
判据.append((True, f"want 在契约清单里 ({要的})"))
|
|
elif not 可用:
|
|
判据.append((False, f"want={要的} 但契约清单是空的 (不许自造契约名)"))
|
|
else:
|
|
判据.append((False, f"want={要的} 不在契约清单里 ({sorted(可用)})"))
|
|
else:
|
|
判据.append((False, "want 必须是字符串或 null"))
|
|
|
|
参数 = 对象.get("args")
|
|
if isinstance(参数, dict):
|
|
参数对象 = cast(dict[str, Any], 参数)
|
|
判据.append((True, "args 是对象"))
|
|
白名单 = 可用.get(str(要的 or ""), [])
|
|
越界 = [键 for 键 in 参数对象 if 键 not in 白名单]
|
|
if 越界:
|
|
判据.append((False, f"args 里有多余的键 {越界} (白名单 {白名单})"))
|
|
else:
|
|
判据.append((True, f"args 的键都在白名单里 {白名单}"))
|
|
else:
|
|
判据.append((False, "args 必须是对象"))
|
|
|
|
置信 = 对象.get("confidence")
|
|
if isinstance(置信, bool) or not isinstance(置信, (int, float)):
|
|
判据.append((False, "confidence 必须是 0 / 0.5 / 1 之一"))
|
|
elif float(置信) in 小模型置信度:
|
|
判据.append((True, f"confidence={float(置信)} 在三档之内"))
|
|
else:
|
|
判据.append((False, f"confidence={置信} 不在 {{0, 0.5, 1}} 里"))
|
|
|
|
if 要的 is None:
|
|
回问 = 对象.get("need_user")
|
|
if isinstance(回问, str) and 回问.strip():
|
|
判据.append((True, "want 为 null 时给了 need_user"))
|
|
else:
|
|
判据.append((False, "want 为 null 却没给 need_user"))
|
|
return 判据
|
|
|
|
|
|
def _校主模型(对象: dict[str, Any]) -> list[tuple[bool, str]]:
|
|
"""main 档的校验: 只锁外层结构 (plan 是数组、每步有 want),细节交给模型."""
|
|
判据: list[tuple[bool, str]] = []
|
|
计划 = 对象.get("plan")
|
|
if not isinstance(计划, list):
|
|
判据.append((False, "plan 是数组"))
|
|
return 判据
|
|
步们 = cast(list[Any], 计划)
|
|
判据.append((True, f"plan 是数组 ({len(步们)} 步)"))
|
|
|
|
坏步: list[int] = []
|
|
for 序, 步 in enumerate(步们, 1):
|
|
步对象 = 取对象(步)
|
|
要的 = 步对象.get("want")
|
|
if not isinstance(要的, str) or not 要的.strip():
|
|
坏步.append(序)
|
|
if 坏步:
|
|
判据.append((False, f"第 {坏步} 步缺 want (每步都必须有)"))
|
|
else:
|
|
判据.append((True, "每一步都有 want"))
|
|
|
|
# plan 空 + 也不回问 = 既没规划也没说缺什么, 等于什么都没答 (2026-09-17 实测抓到)
|
|
回问 = 对象.get("need_user")
|
|
有话说 = isinstance(回问, str) and bool(回问.strip())
|
|
if 步们 or 有话说:
|
|
判据.append((True, "plan 非空, 或者给了 need_user (有结论)"))
|
|
else:
|
|
判据.append((False, "plan 是空的, 又没有 need_user (等于没答)"))
|
|
|
|
置信 = 对象.get("confidence")
|
|
if isinstance(置信, bool) or not isinstance(置信, (int, float)):
|
|
判据.append((False, "confidence 必须是 0~1 的数"))
|
|
elif 0.0 <= float(置信) <= 1.0:
|
|
判据.append((True, f"confidence={float(置信)} 在 0~1"))
|
|
else:
|
|
判据.append((False, f"confidence={置信} 超出 0~1"))
|
|
return 判据
|
|
|
|
|
|
# ---------------------------------------------------------------- 主流程
|
|
|
|
def 现在文本() -> str:
|
|
"""带时区的时刻 (项目里一律带 +08:00, 事后看日志不用猜时区)."""
|
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def 落盘(记录: dict[str, Any]) -> Path:
|
|
"""把这一趟的原样记下来 (skill / 输入 / 提示 / 模型原文 / 校验结论), 方便事后审计."""
|
|
输出目录.mkdir(parents=True, exist_ok=True)
|
|
路径 = 输出目录 / f"结果-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
|
路径.write_text(json.dumps(记录, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return 路径
|
|
|
|
|
|
def 主() -> int:
|
|
"""跑一趟: 读 skill -> 拿契约清单 -> 拼 prompt -> 调模型 -> 剥 JSON -> 校验 -> 记账."""
|
|
档 = 取环境("EFI_SKILL_TIER", "small")
|
|
原话 = 取环境("EFI_SKILL_INPUT")
|
|
端点 = 取环境("EFI_LLM_BASE")
|
|
模型 = 取环境("EFI_LLM_MODEL_MAIN" if 档 == "main" else "EFI_LLM_MODEL_SMALL")
|
|
|
|
print(f"{驱动名}: 示例驱动 (skill 归驱动管, 内核不知道它的存在)", flush=True)
|
|
读到的 = 读技能(档)
|
|
if 读到的 is None:
|
|
汇报(f"{驱动名} 执行失败: 没有 tier={档} 的 skill", kind="error")
|
|
return 1
|
|
文件名, 技能 = 读到的
|
|
步数 = len(字符串表(技能.get("steps")))
|
|
样例数 = len(技能.get("examples") or [])
|
|
边界数 = len(字符串表(技能.get("boundaries")))
|
|
print(f"{驱动名}: skill [{档}] {技能.get('name') or 文件名} (步骤 {步数} / 样例 {样例数} / 边界 {边界数})", flush=True)
|
|
|
|
if not 原话:
|
|
print(f"{驱动名}: 没给 EFI_SKILL_INPUT, 不知道要解析什么", flush=True)
|
|
汇报(f"{驱动名} 执行失败: 没给输入", kind="error")
|
|
return 1
|
|
if not 端点:
|
|
print(f"{驱动名}: 没给 EFI_LLM_BASE (模型端点), 没法执行 -- 看 配置.efi.json 的 env 段", flush=True)
|
|
汇报(f"{驱动名} 执行失败: 没配模型端点", kind="error")
|
|
return 1
|
|
|
|
清单 = 读契约清单()
|
|
来源 = "环境变量" if 取环境("EFI_SKILL_CONTRACTS") else "PG 的 drivers 表"
|
|
print(f"{驱动名}: 契约清单 {len(清单)} 条 (来源 {来源}): {[项['want'] for 项 in 清单]}", flush=True)
|
|
|
|
系统提示 = 拼系统提示(技能)
|
|
用户输入 = 拼用户输入(技能, 原话, 清单)
|
|
print(f"{驱动名}: 模型 {模型} @ {端点} 输入: {原话}", flush=True)
|
|
print(f"{驱动名}: 提示拼好 {len(系统提示)} 字, 调模型中 (超时 {int(模型超时秒)} 秒)...", flush=True)
|
|
|
|
起 = time.monotonic()
|
|
# max_tokens 可以配置; 没配就用默认. 思考型模型会把预算全用在思考上 -> content 空,
|
|
# 那就**加大预算重试一次**(设计 05 把"重试"明确划给驱动实现), 还空就如实报错退出.
|
|
预算文本 = 取环境("EFI_LLM_MAX_TOKENS")
|
|
最大输出 = int(预算文本) if 预算文本.isdigit() and int(预算文本) > 0 else 模型默认最大输出
|
|
成, 原文, 被吃光 = 调模型(端点, 模型, 系统提示, 用户输入, 最大输出)
|
|
while (not 成) and 被吃光 and 最大输出 < 最大输出封顶:
|
|
最大输出 = min(最大输出 * 2, 最大输出封顶)
|
|
print(f"{驱动名}: 思考把输出预算吃光了, max_tokens 提到 {最大输出} 重试一次...", flush=True)
|
|
成, 原文, 被吃光 = 调模型(端点, 模型, 系统提示, 用户输入, 最大输出)
|
|
用时 = time.monotonic() - 起
|
|
if not 成:
|
|
print(f"{驱动名}: 调模型失败 ({用时:.1f}s): {原文}", flush=True)
|
|
汇报(f"{驱动名} 调模型失败: {原文}", kind="error", 数据={"模型": 模型, "端点": 端点})
|
|
return 1
|
|
print(f"{驱动名}: 模型回了 {len(原文)} 字, 用时 {用时:.1f}s", flush=True)
|
|
|
|
剥好, 结果 = 剥JSON(原文)
|
|
判据: list[tuple[bool, str]] = []
|
|
if not 剥好:
|
|
判据.append((False, str(结果)))
|
|
else:
|
|
print(f"{驱动名}: 模型输出 = {json.dumps(结果, ensure_ascii=False)}", flush=True)
|
|
判据 = 校验输出(档, 结果, 清单)
|
|
|
|
print(f"{驱动名}: 校验 --", flush=True)
|
|
for 过没过, 说明 in 判据:
|
|
print(f" [{'通过' if 过没过 else '不过'}] {说明}", flush=True)
|
|
通过数 = sum(1 for 过没过, _ in 判据 if 过没过)
|
|
|
|
记录: dict[str, Any] = {
|
|
"技能": str(技能.get("name") or 文件名),
|
|
"档": 档,
|
|
"模型": 模型,
|
|
"端点": 端点,
|
|
"输入": 原话,
|
|
"契约清单": 清单,
|
|
"系统提示": 系统提示,
|
|
"用户输入": 用户输入,
|
|
"模型原文": 原文,
|
|
"模型输出": 结果 if 剥好 else None,
|
|
"校验": [{"判据": 说明, "通过": 过没过} for 过没过, 说明 in 判据],
|
|
"用时秒": round(用时, 1),
|
|
"时刻": 现在文本(),
|
|
}
|
|
路径 = 落盘(记录)
|
|
print(f"{驱动名}: 原样记到 {路径}", flush=True)
|
|
|
|
全过 = bool(判据) and 通过数 == len(判据)
|
|
if 全过:
|
|
汇报(
|
|
f"{驱动名}: {档} 档执行成功 ({技能.get('name') or 文件名}, {通过数}/{len(判据)} 判据通过)",
|
|
kind="produce",
|
|
数据={"模型": 模型, "档": 档, "结果": 结果, "用时秒": round(用时, 1)},
|
|
)
|
|
print(f"{驱动名}: 收工 -- {通过数}/{len(判据)} 条判据全过", flush=True)
|
|
return 0
|
|
|
|
汇报(
|
|
f"{驱动名}: {档} 档输出没过校验 ({通过数}/{len(判据)})",
|
|
kind="error",
|
|
数据={"模型": 模型, "档": 档, "结果": 结果, "用时秒": round(用时, 1)},
|
|
)
|
|
print(f"{驱动名}: 收工 -- 只过了 {通过数}/{len(判据)} 条, 退出码 1 (不装成功)", flush=True)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(主())
|