Files
AM516/shared/runtime/feishu_bot_bridge.py
2026-07-13 14:46:39 +08:00

874 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()