874 lines
34 KiB
Python
874 lines
34 KiB
Python
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()
|