Initial AM516 Feishu bot package
This commit is contained in:
54
shared/runtime/README.md
Normal file
54
shared/runtime/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Shared Runtime Notes
|
||||
|
||||
This folder is reserved for runtime notes shared by multiple future capabilities.
|
||||
|
||||
Current rules:
|
||||
|
||||
- Do not store API keys here.
|
||||
- Do not store account passwords here.
|
||||
- Do not store raw customer, contract, BOM, or ERP export data here.
|
||||
- Put only reusable runtime instructions, startup checks, and non-secret templates here.
|
||||
- Treat the project tree as read-only when handling Feishu messages. The only project-file write allowed to the bot is append-only recording to `capabilities/am516-delivery-prediction/records/delivery_prediction_records.md`.
|
||||
- Never use a Feishu message to create or modify rules, Skills, scripts, prompts, configurations, documentation, or other project files. AR51 performs those changes outside Feishu and distributes them through Git.
|
||||
|
||||
## Feishu Long-Connection Bridge
|
||||
|
||||
`feishu_bot_bridge.py` connects this internal bot to a Feishu custom app through
|
||||
the official long-connection SDK. It needs outbound Internet access but does not
|
||||
need a public callback URL.
|
||||
|
||||
Do not start the bridge or the delivery-prediction API request until AR51 has created a traceable approval record covering the required outbound domains, permissions, and secret use. In particular, outbound access to `api.zeroerr-agent.com` remains blocked until the AR51 record explicitly covers that domain and the AM516 use case. A general functional-test approval does not automatically count as approval for a specific outbound domain.
|
||||
|
||||
1. Create a local `.env` file from `configs/env.example` and populate the
|
||||
approved secrets. The local file is ignored by Git.
|
||||
2. Install dependencies with `python3 -m pip install -r requirements.txt`.
|
||||
3. Start the bridge with `python3 shared/runtime/feishu_bot_bridge.py`.
|
||||
4. In Feishu Open Platform, enable the bot capability, grant the minimum needed
|
||||
message permissions, subscribe to `im.message.receive_v1`, select long
|
||||
connection, then publish the app version.
|
||||
|
||||
For this bridge, request only these scopes: `im:message` for reading and
|
||||
replying, plus `im:message.p2p_msg` for direct messages and/or
|
||||
`im:message.group_at_msg` for messages that @ the bot in a group. Do not grant
|
||||
the group-wide message scope unless the business case requires it.
|
||||
|
||||
For persistent operation on the shared Mac, use the included
|
||||
`com.am516.feishu-bot.plist` as a user-level `launchd` service. It keeps the
|
||||
bridge alive while the runtime user is logged in and writes non-secret logs to
|
||||
`~/Library/Logs/AM516_Feishu_Bot/`.
|
||||
|
||||
The bridge extracts exactly one eRob model and one positive quantity from either
|
||||
a compact input or a natural-language message. Text such as “请查询
|
||||
eRob142H100I-BHM-18ET[V4],这个数量20台的交期” is accepted. The bridge stops when
|
||||
the model is missing, multiple different models are present, the quantity is
|
||||
missing, or multiple possible quantities remain ambiguous. It invokes only the
|
||||
existing controlled delivery-prediction script. It generates the full Cartesian
|
||||
set of candidates allowed by the packaged Skill: brake upgrades, encoder
|
||||
upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer
|
||||
brand variants. Strong constraints and version stay unchanged. The original
|
||||
model is queried first; valid candidates are reported in rule order, while
|
||||
missing or unsupported candidates are summarized under `不可用候选`.
|
||||
|
||||
On a managed network, the bridge uses the macOS system trust store. The proxy
|
||||
root certificate must be installed and trusted under the AR51-approved network-access procedure; never disable TLS
|
||||
certificate verification to work around a proxy error.
|
||||
32
shared/runtime/com.am516.feishu-bot.plist
Normal file
32
shared/runtime/com.am516.feishu-bot.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.am516.feishu-bot</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Library/Frameworks/Python.framework/Versions/3.14/bin/python3</string>
|
||||
<string>/Users/th5-ar541/Documents/AM516_Feishu_Bot/shared/runtime/feishu_bot_bridge.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/th5-ar541/Documents/AM516_Feishu_Bot</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>/Users/th5-ar541</string>
|
||||
<key>PYTHONUNBUFFERED</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/th5-ar541/Library/Logs/AM516_Feishu_Bot/feishu_bot_bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/th5-ar541/Library/Logs/AM516_Feishu_Bot/feishu_bot_bridge.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
873
shared/runtime/feishu_bot_bridge.py
Normal file
873
shared/runtime/feishu_bot_bridge.py
Normal file
@@ -0,0 +1,873 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import truststore
|
||||
|
||||
truststore.inject_into_ssl()
|
||||
|
||||
import lark_oapi as lark
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
QUERY_SCRIPT = (
|
||||
PROJECT_ROOT
|
||||
/ "capabilities"
|
||||
/ "am516-delivery-prediction"
|
||||
/ "scripts"
|
||||
/ "delivery_prediction_query_template.py"
|
||||
)
|
||||
RECORD_FILE = (
|
||||
PROJECT_ROOT
|
||||
/ "capabilities"
|
||||
/ "am516-delivery-prediction"
|
||||
/ "records"
|
||||
/ "delivery_prediction_records.md"
|
||||
)
|
||||
RULE_SOURCE_DISPLAY = "capabilities/am516-delivery-prediction/rules/eRob关节模组相似型号推荐规则_v3.1.md"
|
||||
API_SOURCE_DISPLAY = "/api/SaleAgent/DeliveryPrediction/WithTransit"
|
||||
DISCLAIMER = (
|
||||
"以上为内部候选方案,不构成客户正式交期承诺;需 AR516 或授权责任人结合库存、生产、采购、"
|
||||
"客户优先级和最新系统数据人工复核后使用。"
|
||||
)
|
||||
MODEL_SEARCH_PATTERN = re.compile(
|
||||
r"eRob(?:70|80|90|110|142|170)[FH](?:50|80|100|120|160)[IT]"
|
||||
r"-[BF](?:HS|HM|S|M)-18[CE][NT]C?[\[(]V[3-6][\])]"
|
||||
)
|
||||
MODEL_PATTERN = re.compile(
|
||||
r"^eRob(?P<diameter>70|80|90|110|142|170)(?P<structure>[FH])"
|
||||
r"(?P<ratio>50|80|100|120|160)(?P<mount>[IT])-(?P<brake>[BF])"
|
||||
r"(?P<encoder>HS|HM|S|M)-18(?P<communication>[CE])(?P<sensor>[NT])"
|
||||
r"(?P<grease>C?)(?P<bracket>[\[(])(?P<version>V[3-6])[\])]$"
|
||||
)
|
||||
QUANTITY_LABEL_PATTERN = re.compile(
|
||||
r"(?:需求数量|订单数量|采购数量|查询数量|数量|需求量|qty|quantity)"
|
||||
r"\s*(?:为|是|[::=])?\s*(?P<quantity>[1-9]\d*)\s*(?:台|套|件)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
QUANTITY_UNIT_PATTERN = re.compile(
|
||||
r"(?<![A-Za-z0-9])(?P<quantity>[1-9]\d*)\s*(?:台|套|件)"
|
||||
)
|
||||
STANDALONE_QUANTITY_PATTERN = re.compile(
|
||||
r"(?<![A-Za-z0-9])(?P<quantity>[1-9]\d*)(?![A-Za-z0-9])"
|
||||
)
|
||||
ALLOWED_VERSIONS = {
|
||||
"70F": {"V3", "V4"},
|
||||
"70H": {"V4", "V5"},
|
||||
"80F": {"V4"},
|
||||
"80H": {"V5", "V6"},
|
||||
"90H": {"V3", "V6"},
|
||||
"110H": {"V4", "V6"},
|
||||
"142F": {"V4"},
|
||||
"142H": {"V4"},
|
||||
"170F": {"V3"},
|
||||
"170H": {"V3"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestInput:
|
||||
model: str
|
||||
quantity: int
|
||||
|
||||
|
||||
class InputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CandidateUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def load_dotenv() -> None:
|
||||
"""Load simple KEY=VALUE pairs from the ignored local .env file."""
|
||||
env_file = PROJECT_ROOT / ".env"
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value.strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def extract_quantity(text_without_models: str) -> int:
|
||||
strong_candidates = {
|
||||
int(match.group("quantity"))
|
||||
for pattern in (QUANTITY_LABEL_PATTERN, QUANTITY_UNIT_PATTERN)
|
||||
for match in pattern.finditer(text_without_models)
|
||||
}
|
||||
if len(strong_candidates) == 1:
|
||||
return strong_candidates.pop()
|
||||
if len(strong_candidates) > 1:
|
||||
raise InputError("识别到多个可能的数量,请明确一个正整数数量后重试。")
|
||||
|
||||
fallback_candidates = {
|
||||
int(match.group("quantity"))
|
||||
for match in STANDALONE_QUANTITY_PATTERN.finditer(text_without_models)
|
||||
}
|
||||
if len(fallback_candidates) == 1:
|
||||
return fallback_candidates.pop()
|
||||
if len(fallback_candidates) > 1:
|
||||
raise InputError("识别到多个可能的数量,请使用“数量20台”等明确表达后重试。")
|
||||
raise InputError("已识别型号,但未识别到正整数数量,请补充“数量20台”等信息后重试。")
|
||||
|
||||
|
||||
def parse_request(text: str) -> RequestInput:
|
||||
normalized_text = unicodedata.normalize("NFKC", text)
|
||||
model_matches = [match.group(0) for match in MODEL_SEARCH_PATTERN.finditer(normalized_text)]
|
||||
unique_models = list(dict.fromkeys(model_matches))
|
||||
if not unique_models:
|
||||
raise InputError("未识别到完整 eRob 型号,请核对型号后重试。")
|
||||
if len(unique_models) > 1:
|
||||
raise InputError("识别到多个 eRob 型号,请一次只查询一个型号。")
|
||||
|
||||
model = unique_models[0]
|
||||
model_match = MODEL_PATTERN.fullmatch(model)
|
||||
if not model_match:
|
||||
raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。")
|
||||
|
||||
fields = model_match.groupdict()
|
||||
series = f"{fields['diameter']}{fields['structure']}"
|
||||
if fields["version"] not in ALLOWED_VERSIONS.get(series, set()):
|
||||
raise InputError("型号版本与外径/结构组合不在当前规则包中,请 AR51 或 AR516 人工确认。")
|
||||
|
||||
text_without_models = MODEL_SEARCH_PATTERN.sub(" ", normalized_text)
|
||||
quantity = extract_quantity(text_without_models)
|
||||
return RequestInput(model=model, quantity=quantity)
|
||||
|
||||
|
||||
def build_model(fields: dict[str, str]) -> str:
|
||||
closing_bracket = "]" if fields["bracket"] == "[" else ")"
|
||||
return (
|
||||
f"eRob{fields['diameter']}{fields['structure']}{fields['ratio']}{fields['mount']}"
|
||||
f"-{fields['brake']}{fields['encoder']}-18{fields['communication']}"
|
||||
f"{fields['sensor']}{fields['grease']}{fields['bracket']}"
|
||||
f"{fields['version']}{closing_bracket}"
|
||||
)
|
||||
|
||||
|
||||
def encoder_upgrade_options(encoder: str) -> list[str]:
|
||||
options = {
|
||||
"S": ["S", "M", "HS", "HM"],
|
||||
"M": ["M", "HS", "HM"],
|
||||
"HS": ["HS", "HM"],
|
||||
"HM": ["HM"],
|
||||
}
|
||||
return options[encoder]
|
||||
|
||||
|
||||
def candidate_type(source_model: str, candidate_model: str) -> str:
|
||||
source = MODEL_PATTERN.fullmatch(source_model)
|
||||
candidate = MODEL_PATTERN.fullmatch(candidate_model)
|
||||
if not source or not candidate:
|
||||
raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。")
|
||||
source_fields = source.groupdict()
|
||||
candidate_fields = candidate.groupdict()
|
||||
upgrades: list[str] = []
|
||||
if source_fields["brake"] != candidate_fields["brake"]:
|
||||
upgrades.append("制动")
|
||||
if source_fields["encoder"] != candidate_fields["encoder"]:
|
||||
upgrades.append(f"{candidate_fields['encoder']}编码器")
|
||||
if source_fields["sensor"] != candidate_fields["sensor"]:
|
||||
upgrades.append("传感器")
|
||||
if source_fields["grease"] != candidate_fields["grease"]:
|
||||
upgrades.append("低温油脂")
|
||||
base_type = "+".join(upgrades) + "升级候选" if upgrades else "品牌替代候选"
|
||||
brand = "DZ" if candidate_fields["bracket"] == "[" else "LF"
|
||||
return f"{base_type},{brand}"
|
||||
|
||||
|
||||
def candidate_reason(source_model: str, candidate_model: str) -> str:
|
||||
difference = model_difference_text(source_model, candidate_model)
|
||||
return f"强约束保持一致;{difference}。"
|
||||
|
||||
|
||||
def candidate_confirmation(source_model: str, candidate_model: str) -> str:
|
||||
source = MODEL_PATTERN.fullmatch(source_model)
|
||||
candidate = MODEL_PATTERN.fullmatch(candidate_model)
|
||||
if not source or not candidate:
|
||||
raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。")
|
||||
source_fields = source.groupdict()
|
||||
candidate_fields = candidate.groupdict()
|
||||
items: list[str] = []
|
||||
if source_fields["brake"] != candidate_fields["brake"]:
|
||||
items.append("是否接受带制动配置")
|
||||
if source_fields["encoder"] != candidate_fields["encoder"]:
|
||||
items.append(f"是否接受{candidate_fields['encoder']}编码器")
|
||||
if source_fields["sensor"] != candidate_fields["sensor"]:
|
||||
items.append("是否接受力矩传感器")
|
||||
if source_fields["grease"] != candidate_fields["grease"]:
|
||||
items.append("是否接受低温油脂")
|
||||
if source_fields["bracket"] != candidate_fields["bracket"]:
|
||||
items.append("客户、项目或认证是否有减速器品牌限制")
|
||||
items.extend(["价格差异", "正式承诺边界"])
|
||||
return ";".join(items) + "。"
|
||||
|
||||
|
||||
def generate_candidate_specs(model: str) -> list[dict[str, str]]:
|
||||
match = MODEL_PATTERN.fullmatch(model)
|
||||
if not match:
|
||||
raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。")
|
||||
original = match.groupdict()
|
||||
brake_options = [original["brake"]] + (["B"] if original["brake"] == "F" else [])
|
||||
encoder_options = encoder_upgrade_options(original["encoder"])
|
||||
sensor_options = [original["sensor"]] + (["T"] if original["sensor"] == "N" else [])
|
||||
grease_options = [original["grease"]] + (["C"] if original["grease"] == "" else [])
|
||||
bracket_options = [original["bracket"], "(" if original["bracket"] == "[" else "["]
|
||||
|
||||
candidates: list[dict[str, str]] = []
|
||||
seen = {model}
|
||||
for brake, encoder, sensor, grease, bracket in itertools.product(
|
||||
brake_options,
|
||||
encoder_options,
|
||||
sensor_options,
|
||||
grease_options,
|
||||
bracket_options,
|
||||
):
|
||||
fields = dict(original)
|
||||
fields.update(
|
||||
brake=brake,
|
||||
encoder=encoder,
|
||||
sensor=sensor,
|
||||
grease=grease,
|
||||
bracket=bracket,
|
||||
)
|
||||
candidate_model = build_model(fields)
|
||||
if candidate_model in seen:
|
||||
continue
|
||||
seen.add(candidate_model)
|
||||
candidates.append(
|
||||
{
|
||||
"model": candidate_model,
|
||||
"type": candidate_type(model, candidate_model),
|
||||
"reason": candidate_reason(model, candidate_model),
|
||||
"difference": model_difference_text(model, candidate_model),
|
||||
"confirmation": candidate_confirmation(model, candidate_model),
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def model_difference_text(source_model: str, candidate_model: str) -> str:
|
||||
source = MODEL_PATTERN.fullmatch(source_model)
|
||||
candidate = MODEL_PATTERN.fullmatch(candidate_model)
|
||||
if not source or not candidate:
|
||||
raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。")
|
||||
|
||||
source_fields = source.groupdict()
|
||||
candidate_fields = candidate.groupdict()
|
||||
field_specs = (
|
||||
("diameter", "外径", None),
|
||||
("structure", "结构", None),
|
||||
("ratio", "减速比", None),
|
||||
("mount", "安装", None),
|
||||
("brake", "制动", None),
|
||||
("encoder", "编码器", None),
|
||||
("communication", "通讯", None),
|
||||
("sensor", "传感器", None),
|
||||
("grease", "油脂", {"": "标准", "C": "低温"}),
|
||||
("version", "版本", None),
|
||||
("bracket", "品牌", {"[": "DZ", "(": "LF"}),
|
||||
)
|
||||
differences: list[str] = []
|
||||
for field, label, value_labels in field_specs:
|
||||
source_value = source_fields[field]
|
||||
candidate_value = candidate_fields[field]
|
||||
if source_value == candidate_value:
|
||||
continue
|
||||
if value_labels:
|
||||
source_value = value_labels.get(source_value, source_value)
|
||||
candidate_value = value_labels.get(candidate_value, candidate_value)
|
||||
differences.append(f"{label} {source_value}→{candidate_value}")
|
||||
return ";".join(differences) if differences else "无"
|
||||
|
||||
|
||||
def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(QUERY_SCRIPT),
|
||||
"--product",
|
||||
request.model,
|
||||
"--quantity",
|
||||
str(request.quantity),
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
output = completed.stdout.strip()
|
||||
json_start = output.find("{")
|
||||
if json_start < 0:
|
||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
||||
try:
|
||||
payload = json.loads(output[json_start:])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("交期预测接口返回格式异常,未生成候选结果。") from exc
|
||||
if completed.returncode != 0 or not payload.get("ok", True):
|
||||
status_code = payload.get("status_code")
|
||||
if status_code in {400, 404, 422}:
|
||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
||||
if payload.get("success") is False:
|
||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
||||
return payload
|
||||
|
||||
|
||||
def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[str, Any]:
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
||||
"prediction_value": None,
|
||||
"total_days": None,
|
||||
"main_factor": "接口未返回,需人工复核",
|
||||
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
||||
"available_inventory": None,
|
||||
"full_coverage": None,
|
||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||
"timestamp": None,
|
||||
"has_lead_time": False,
|
||||
}
|
||||
summary = data.get("交期汇总")
|
||||
if not isinstance(summary, dict):
|
||||
return {
|
||||
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
||||
"prediction_value": None,
|
||||
"total_days": None,
|
||||
"main_factor": "接口未返回,需人工复核",
|
||||
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
||||
"available_inventory": None,
|
||||
"full_coverage": None,
|
||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||
"timestamp": data.get("时间戳") if isinstance(data.get("时间戳"), str) else None,
|
||||
"has_lead_time": False,
|
||||
}
|
||||
|
||||
main_factor = summary.get("AM516主导因素")
|
||||
main_factor_text = (
|
||||
str(main_factor)
|
||||
if isinstance(main_factor, (str, int, float))
|
||||
else "接口未返回,需人工复核"
|
||||
)
|
||||
predicted_date = summary.get("AM516预测交期")
|
||||
total_days = summary.get("AM516总交期", summary.get("总交期"))
|
||||
if isinstance(predicted_date, (str, int, float)):
|
||||
days_suffix = f"({total_days}天)" if isinstance(total_days, (int, float)) else ""
|
||||
prediction = f"AM516预测交期:{predicted_date}{days_suffix}"
|
||||
prediction_value = str(predicted_date)
|
||||
elif isinstance(total_days, (int, float)):
|
||||
prediction = f"原算法总交期参考:{total_days}天"
|
||||
prediction_value = f"{total_days}天"
|
||||
else:
|
||||
prediction = "接口未返回可用交期汇总,需人工复核。"
|
||||
prediction_value = None
|
||||
|
||||
inventory_info = data.get("库存信息")
|
||||
available_inventory = (
|
||||
inventory_info.get("可用库存(扣除了待产数量)")
|
||||
if isinstance(inventory_info, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(available_inventory, (int, float)):
|
||||
if available_inventory >= requested_quantity:
|
||||
inventory_text = f"整机可用库存{available_inventory:g}台(完全覆盖)"
|
||||
full_coverage: bool | None = True
|
||||
elif available_inventory > 0:
|
||||
gap = requested_quantity - available_inventory
|
||||
inventory_text = f"整机可用库存{available_inventory:g}台(部分覆盖,仍需补齐{gap:g}台)"
|
||||
full_coverage = False
|
||||
else:
|
||||
inventory_text = f"整机可用库存{available_inventory:g}台(需生产/采购)"
|
||||
full_coverage = False
|
||||
else:
|
||||
inventory_text = "接口未返回可用库存汇总,需人工复核。"
|
||||
full_coverage = None
|
||||
|
||||
bom_info = data.get("BOM信息")
|
||||
has_bom = (
|
||||
bom_info.get("是否有BOM(False则需提醒无BOM)")
|
||||
if isinstance(bom_info, dict)
|
||||
else None
|
||||
)
|
||||
purchase_info = data.get("采购信息")
|
||||
shortages = purchase_info.get("缺料明细") if isinstance(purchase_info, dict) else None
|
||||
if has_bom is False:
|
||||
evidence_lines = ["- 缺料/BOM摘要:API未返回有效BOM,需补充数据源。"]
|
||||
elif isinstance(shortages, list) and shortages:
|
||||
reducer_shortages = [
|
||||
item
|
||||
for item in shortages
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("缺料物料"), str)
|
||||
and item["缺料物料"].startswith("20.30.")
|
||||
]
|
||||
evidence_lines = []
|
||||
real_shortage_key = (
|
||||
"真实缺料数量(每套需求×订单数量 + 存量需求数量 − 库存数量 − 待检数量 − 在途数量)"
|
||||
)
|
||||
for item in reducer_shortages:
|
||||
real_shortage = item.get(real_shortage_key)
|
||||
coverage = (
|
||||
"不足"
|
||||
if isinstance(real_shortage, (int, float)) and real_shortage > 0
|
||||
else "满足"
|
||||
if isinstance(real_shortage, (int, float))
|
||||
else "待确认"
|
||||
)
|
||||
evidence_lines.append(
|
||||
"- 减速器缺料展开:"
|
||||
f"物料编码{item['缺料物料']};"
|
||||
f"库存数量{item.get('库存数量', '未返回')};"
|
||||
f"待检数量{item.get('待检数量', '未返回')};"
|
||||
f"在途数量{item.get('在途数量', '未返回')};"
|
||||
f"存量需求数量{item.get('存量需求数量', '未返回')};"
|
||||
f"含在途覆盖:{coverage};"
|
||||
)
|
||||
if not evidence_lines:
|
||||
evidence_lines = [
|
||||
f"- 缺料/BOM摘要:存在{len(shortages)}项缺料记录;无减速器缺料,不展开其他物料。"
|
||||
]
|
||||
elif isinstance(shortages, list):
|
||||
evidence_lines = ["- 缺料/BOM摘要:API未返回缺料记录。"]
|
||||
else:
|
||||
evidence_lines = ["- 缺料/BOM摘要:接口未返回可识别缺料汇总,需人工复核。"]
|
||||
|
||||
timestamp = data.get("时间戳") if isinstance(data.get("时间戳"), str) else None
|
||||
return {
|
||||
"prediction": prediction,
|
||||
"prediction_value": prediction_value,
|
||||
"total_days": total_days if isinstance(total_days, (int, float)) else None,
|
||||
"main_factor": main_factor_text,
|
||||
"inventory": inventory_text,
|
||||
"available_inventory": (
|
||||
available_inventory if isinstance(available_inventory, (int, float)) else None
|
||||
),
|
||||
"full_coverage": full_coverage,
|
||||
"evidence_lines": evidence_lines,
|
||||
"timestamp": timestamp,
|
||||
"has_lead_time": prediction_value is not None,
|
||||
}
|
||||
|
||||
|
||||
def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]:
|
||||
available = [candidate for candidate in candidates if candidate["details"]["has_lead_time"]]
|
||||
if not available:
|
||||
return [
|
||||
"- 现货候选:无法判断",
|
||||
"- 最快候选型号:无可用交期汇总",
|
||||
"- 最快候选交期:无法判断",
|
||||
"- 是否完全覆盖数量:无法判断",
|
||||
"- 主导因素:接口未返回,需人工复核",
|
||||
]
|
||||
fastest = min(
|
||||
available,
|
||||
key=lambda candidate: (
|
||||
candidate["details"]["total_days"]
|
||||
if candidate["details"]["total_days"] is not None
|
||||
else float("inf")
|
||||
),
|
||||
)
|
||||
stock_models = [
|
||||
candidate["model"]
|
||||
for candidate in available
|
||||
if candidate["details"]["full_coverage"] is True
|
||||
]
|
||||
coverage = fastest["details"]["full_coverage"]
|
||||
coverage_text = "是" if coverage is True else "否" if coverage is False else "无法判断"
|
||||
return [
|
||||
f"- 现货候选:{'; '.join(stock_models) if stock_models else '无'}",
|
||||
f"- 最快候选型号:{fastest['model']}",
|
||||
f"- 最快候选交期:{fastest['details']['prediction'].split(':', 1)[-1]}",
|
||||
f"- 是否完全覆盖数量:{coverage_text}",
|
||||
f"- 主导因素:{fastest['details']['main_factor']}",
|
||||
]
|
||||
|
||||
|
||||
def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str]:
|
||||
details = candidate["details"]
|
||||
return [
|
||||
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
|
||||
f"- 数量:{quantity}台",
|
||||
f"- {details['prediction']}",
|
||||
f"- 可用库存/覆盖情况:{details['inventory']}",
|
||||
f"- 主导因素:{details['main_factor']}",
|
||||
f"- 推荐理由:{candidate['reason']}",
|
||||
f"- 差异项:{candidate['difference']}",
|
||||
*details["evidence_lines"],
|
||||
f"- 需客户确认的关键差异:{candidate['confirmation']}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def markdown_cell(value: Any) -> str:
|
||||
"""Keep candidate values inside one Markdown table cell."""
|
||||
return str(value).replace("|", "\\|").replace("\n", "<br>")
|
||||
|
||||
|
||||
def model_analysis_lines(model: str) -> list[str]:
|
||||
match = MODEL_PATTERN.fullmatch(model)
|
||||
if not match:
|
||||
return ["- 型号解析失败,需人工复核。"]
|
||||
fields = match.groupdict()
|
||||
labels = {
|
||||
"structure": {"F": "扁平", "H": "长筒"},
|
||||
"mount": {"I": "直筒", "T": "转角"},
|
||||
"brake": {"B": "带制动", "F": "无制动"},
|
||||
"encoder": {"S": "单圈", "M": "多圈", "HS": "高精度单圈", "HM": "高精度多圈"},
|
||||
"communication": {"C": "CANopen", "E": "EtherCAT"},
|
||||
"sensor": {"N": "无", "T": "有"},
|
||||
}
|
||||
return [
|
||||
f"- 外径:{fields['diameter']} | 结构:{fields['structure']} {labels['structure'][fields['structure']]} "
|
||||
f"| 减速比:{fields['ratio']} | 安装:{fields['mount']} {labels['mount'][fields['mount']]}",
|
||||
f"- 制动:{fields['brake']} {labels['brake'][fields['brake']]} "
|
||||
f"| 编码器:{fields['encoder']} {labels['encoder'][fields['encoder']]} | 轴径:18",
|
||||
f"- 通讯:{fields['communication']} {labels['communication'][fields['communication']]} "
|
||||
f"| 传感器:{fields['sensor']} {labels['sensor'][fields['sensor']]} "
|
||||
f"| 油脂:{'低温' if fields['grease'] else '标准'}",
|
||||
f"- 版本/品牌:{fields['version']} "
|
||||
f"{'DZ' if fields['bracket'] == '[' else 'LF'}",
|
||||
]
|
||||
|
||||
|
||||
def record_data_timestamp(candidates: list[dict[str, Any]], fallback: datetime) -> str:
|
||||
timestamps = sorted(
|
||||
{
|
||||
candidate["details"]["timestamp"]
|
||||
for candidate in candidates
|
||||
if candidate["details"].get("timestamp")
|
||||
}
|
||||
)
|
||||
if not timestamps:
|
||||
return fallback.strftime("%Y-%m-%d %H:%M:%S %z")
|
||||
if len(timestamps) == 1:
|
||||
return timestamps[0]
|
||||
return f"{timestamps[0]} ~ {timestamps[-1]}"
|
||||
|
||||
|
||||
def render_record(
|
||||
request: RequestInput,
|
||||
candidates: list[dict[str, Any]],
|
||||
unavailable: list[dict[str, str]],
|
||||
generation_time: datetime,
|
||||
) -> str:
|
||||
all_count = len(candidates) + len(unavailable)
|
||||
lines = [
|
||||
f"\n## {generation_time:%Y-%m-%d %H:%M:%S %z} | {request.model} | {request.quantity}台",
|
||||
"",
|
||||
f"**输入型号**:{request.model}",
|
||||
f"**输入数量**:{request.quantity}台",
|
||||
f"**生成时间**:{generation_time:%Y-%m-%d %H:%M:%S %z}",
|
||||
f"**规则源实际路径**:`{RULE_SOURCE_DISPLAY}`",
|
||||
f"**接口依据**:`{API_SOURCE_DISPLAY}`",
|
||||
f"**数据时间戳**:{record_data_timestamp(candidates, generation_time)}",
|
||||
"",
|
||||
"### 型号解析",
|
||||
"",
|
||||
*model_analysis_lines(request.model),
|
||||
"",
|
||||
f"### 候选型号清单及 API 结果(共{all_count}项)",
|
||||
"",
|
||||
"| 序号 | 型号 | 推荐类型 | 差异项 | API状态 | AM516预测交期 | 可用库存 | 主导因素 |",
|
||||
"|---:|---|---|---|---|---|---:|---|",
|
||||
]
|
||||
record_rows = [
|
||||
(candidate["record_order"], True, candidate) for candidate in candidates
|
||||
] + [
|
||||
(candidate["record_order"], False, candidate) for candidate in unavailable
|
||||
]
|
||||
for record_order, is_available, candidate in sorted(record_rows):
|
||||
if is_available:
|
||||
details = candidate["details"]
|
||||
inventory = details.get("available_inventory")
|
||||
inventory_text = f"{inventory:g}台" if isinstance(inventory, (int, float)) else "—"
|
||||
api_status = "✅ 现货" if details.get("full_coverage") is True else "✅ 返回"
|
||||
row = [
|
||||
record_order,
|
||||
candidate["model"],
|
||||
candidate["type"],
|
||||
candidate["difference"],
|
||||
api_status,
|
||||
details.get("prediction_value") or "—",
|
||||
inventory_text,
|
||||
details.get("main_factor") or "—",
|
||||
]
|
||||
else:
|
||||
row = [
|
||||
record_order,
|
||||
candidate["model"],
|
||||
candidate["type"],
|
||||
candidate["difference"],
|
||||
"❌ 不存在",
|
||||
"—",
|
||||
"—",
|
||||
"—",
|
||||
]
|
||||
lines.append("| " + " | ".join(markdown_cell(value) for value in row) + " |")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### 复核边界",
|
||||
"",
|
||||
DISCLAIMER,
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_record(
|
||||
request: RequestInput,
|
||||
candidates: list[dict[str, Any]],
|
||||
unavailable: list[dict[str, str]],
|
||||
generation_time: datetime,
|
||||
) -> None:
|
||||
record = render_record(request, candidates, unavailable, generation_time)
|
||||
with RECORD_FILE.open("a", encoding="utf-8") as file:
|
||||
file.write(record)
|
||||
|
||||
|
||||
def build_report(request: RequestInput) -> str:
|
||||
baseline_payload = run_controlled_query(request)
|
||||
baseline = lead_time_details(baseline_payload, request.quantity)
|
||||
if not baseline["has_lead_time"]:
|
||||
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
|
||||
|
||||
candidates = [
|
||||
{
|
||||
"index": 1,
|
||||
"model": request.model,
|
||||
"type": "原型号,DZ" if "[" in request.model else "原型号,LF",
|
||||
"details": baseline,
|
||||
"reason": "原始需求基准,用于交期测算与候选比较。",
|
||||
"difference": "无",
|
||||
"confirmation": "无型号差异;需确认正式交付节点和承诺边界。",
|
||||
}
|
||||
]
|
||||
unavailable: list[dict[str, str]] = []
|
||||
candidate_specs = generate_candidate_specs(request.model)
|
||||
|
||||
def query_candidate(spec: dict[str, str]) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||
try:
|
||||
payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
|
||||
except CandidateUnavailableError:
|
||||
return spec, None
|
||||
details = lead_time_details(payload, request.quantity)
|
||||
return spec, details if details["has_lead_time"] else None
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
future_map = {
|
||||
executor.submit(query_candidate, spec): spec for spec in candidate_specs
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
spec, details = future.result()
|
||||
if details is None:
|
||||
unavailable.append(spec)
|
||||
continue
|
||||
candidates.append({**spec, "details": details})
|
||||
|
||||
candidate_order = {spec["model"]: index for index, spec in enumerate(candidate_specs)}
|
||||
candidates[1:] = sorted(candidates[1:], key=lambda item: candidate_order[item["model"]])
|
||||
unavailable.sort(key=lambda item: candidate_order[item["model"]])
|
||||
candidates[0]["record_order"] = 1
|
||||
for candidate in candidates[1:]:
|
||||
candidate["record_order"] = candidate_order[candidate["model"]] + 2
|
||||
for candidate in unavailable:
|
||||
candidate["record_order"] = candidate_order[candidate["model"]] + 2
|
||||
for index, candidate in enumerate(candidates, start=1):
|
||||
candidate["index"] = index
|
||||
|
||||
generation_time = datetime.now().astimezone()
|
||||
write_record(request, candidates, unavailable, generation_time)
|
||||
data_timestamp = baseline["timestamp"] or f"{generation_time:%Y-%m-%d %H:%M}"
|
||||
report_lines = [
|
||||
f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}",
|
||||
"",
|
||||
f"输入需求:{request.model},{request.quantity}台",
|
||||
f"数据时间戳:{data_timestamp}",
|
||||
"状态:试运行版输出 / 内部候选",
|
||||
"判断等级:AM516算法预测参考,需人工复核",
|
||||
"",
|
||||
"总体判断:",
|
||||
*overall_judgment_lines(candidates),
|
||||
"",
|
||||
]
|
||||
for candidate_item in candidates:
|
||||
report_lines.extend(candidate_report_lines(candidate_item, request.quantity))
|
||||
report_lines.append("不可用候选:")
|
||||
if unavailable:
|
||||
preview = " / ".join(item["model"] for item in unavailable[:5])
|
||||
suffix = f" 等{len(unavailable)}项" if len(unavailable) > 5 else ""
|
||||
report_lines.append(
|
||||
f"- {preview}{suffix}:不存在或接口未返回交期汇总。"
|
||||
)
|
||||
else:
|
||||
report_lines.append("- 无(本次已查询候选均返回交期汇总)")
|
||||
report_lines.extend(["", DISCLAIMER])
|
||||
return "\n".join(report_lines)
|
||||
|
||||
|
||||
def message_text(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None:
|
||||
message = data.event.message
|
||||
if message.message_type != "text" or not message.content:
|
||||
return None
|
||||
try:
|
||||
content = json.loads(message.content)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
text = content.get("text")
|
||||
return text if isinstance(text, str) else None
|
||||
|
||||
|
||||
def is_allowed_sender(data: lark.im.v1.P2ImMessageReceiveV1) -> bool:
|
||||
allowed = {item.strip() for item in os.getenv("FEISHU_BOT_ALLOWED_OPEN_IDS", "").split(",") if item.strip()}
|
||||
if not allowed:
|
||||
return True
|
||||
sender_id = data.event.sender.sender_id
|
||||
return bool(sender_id and sender_id.open_id in allowed)
|
||||
|
||||
|
||||
def post_paragraph(line: str) -> list[dict[str, Any]]:
|
||||
element: dict[str, Any] = {"tag": "text", "text": line or " "}
|
||||
if line == "总体判断:" or line == "不可用候选:" or re.match(r"^\d+\.\s+eRob", line):
|
||||
element["style"] = ["bold"]
|
||||
return [element]
|
||||
|
||||
|
||||
def build_post_contents(text: str, max_bytes: int = 28000) -> list[dict[str, Any]]:
|
||||
lines = text.splitlines()
|
||||
title = lines.pop(0) if lines and lines[0].startswith("关节模组交期预估报告 |") else ""
|
||||
pages: list[dict[str, Any]] = []
|
||||
content: list[list[dict[str, Any]]] = []
|
||||
for line in lines:
|
||||
paragraph = post_paragraph(line)
|
||||
page_title = title if not pages else f"{title}(续)" if title else ""
|
||||
tentative = {"zh_cn": {"title": page_title, "content": [*content, paragraph]}}
|
||||
if content and len(json.dumps(tentative, ensure_ascii=False).encode("utf-8")) > max_bytes:
|
||||
pages.append({"zh_cn": {"title": page_title, "content": content}})
|
||||
content = [paragraph]
|
||||
else:
|
||||
content.append(paragraph)
|
||||
page_title = title if not pages else f"{title}(续)" if title else ""
|
||||
pages.append({"zh_cn": {"title": page_title, "content": content or [post_paragraph(" ")]}})
|
||||
return pages
|
||||
|
||||
|
||||
def build_post_content(text: str) -> dict[str, Any]:
|
||||
return build_post_contents(text)[0]
|
||||
|
||||
|
||||
def reply(client: lark.Client, message_id: str, text: str) -> None:
|
||||
post_contents = build_post_contents(text)
|
||||
for index, post_content in enumerate(post_contents):
|
||||
reply_uuid = message_id if index == 0 else f"{message_id[:44]}-{index + 1}"
|
||||
request = (
|
||||
lark.im.v1.ReplyMessageRequest.builder()
|
||||
.message_id(message_id)
|
||||
.request_body(
|
||||
lark.im.v1.ReplyMessageRequestBody.builder()
|
||||
.msg_type("post")
|
||||
.content(json.dumps(post_content, ensure_ascii=False))
|
||||
.uuid(reply_uuid)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = client.im.v1.message.reply(request)
|
||||
if not response.success():
|
||||
logging.error(
|
||||
"Feishu reply failed: code=%s part=%s/%s",
|
||||
response.code,
|
||||
index + 1,
|
||||
len(post_contents),
|
||||
)
|
||||
return
|
||||
logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents))
|
||||
|
||||
|
||||
_seen_message_ids: set[str] = set()
|
||||
_seen_message_lock = threading.Lock()
|
||||
|
||||
|
||||
def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
text = message_text(data)
|
||||
message_id = data.event.message.message_id
|
||||
if not text or not message_id:
|
||||
return
|
||||
try:
|
||||
report = build_report(parse_request(text))
|
||||
except InputError as exc:
|
||||
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
||||
except RuntimeError as exc:
|
||||
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
||||
except subprocess.TimeoutExpired:
|
||||
report = f"AM516 内部候选查询已停止:交期预测接口超时,未生成候选结果。\n\n{DISCLAIMER}"
|
||||
except Exception:
|
||||
logging.exception("Unexpected AM516 bridge error")
|
||||
report = f"AM516 内部候选查询已停止:运行异常,未生成候选结果。\n\n{DISCLAIMER}"
|
||||
reply(client, message_id, report)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
app_id = os.getenv("FEISHU_APP_ID", "").strip()
|
||||
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise SystemExit("FEISHU_APP_ID and FEISHU_APP_SECRET must be configured in the local environment.")
|
||||
if not QUERY_SCRIPT.is_file() or not RECORD_FILE.is_file():
|
||||
raise SystemExit("Required AM516 capability files are missing; bridge did not start.")
|
||||
|
||||
client = lark.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||
|
||||
def on_message(data: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
message_id = data.event.message.message_id
|
||||
sender_type = data.event.sender.sender_type
|
||||
logging.info("Feishu event received: message_id=%s sender_type=%s", message_id, sender_type)
|
||||
if data.event.sender.sender_type != "user" or not is_allowed_sender(data):
|
||||
return
|
||||
if not message_id:
|
||||
return
|
||||
with _seen_message_lock:
|
||||
if message_id in _seen_message_ids:
|
||||
return
|
||||
_seen_message_ids.add(message_id)
|
||||
threading.Thread(target=handle_message, args=(client, data), daemon=True).start()
|
||||
|
||||
event_handler = (
|
||||
lark.EventDispatcherHandler.builder("", "")
|
||||
.register_p2_im_message_receive_v1(on_message)
|
||||
.build()
|
||||
)
|
||||
logging.info("Starting AM516 Feishu long-connection bridge.")
|
||||
lark.ws.Client(
|
||||
app_id,
|
||||
app_secret,
|
||||
log_level=lark.LogLevel.WARNING,
|
||||
event_handler=event_handler,
|
||||
).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
main()
|
||||
Reference in New Issue
Block a user