feat: paginate candidate model recommendations
This commit is contained in:
@@ -48,13 +48,26 @@ messages are ignored silently: the bridge sends no Feishu reply, makes no API
|
||||
request, and appends no record. Only a message containing exactly one complete,
|
||||
rule-valid eRob model and exactly one positive quantity enters the query workflow.
|
||||
This gate applies even when the bot receives every message in a group; an @mention
|
||||
alone does not bypass it. The bridge invokes only the existing controlled
|
||||
delivery-prediction script. It generates the full Cartesian
|
||||
alone does not bypass it. After a valid initial query, the same sender in the same
|
||||
chat may ask whether there are other candidate models. The bridge keeps that
|
||||
continuation context in memory for six hours, never shares it across senders or
|
||||
chats, and stays silent if no matching context exists. A bridge restart clears
|
||||
the context, so the sender must resend the complete model and quantity. The
|
||||
continuation wording is flexible: `还有吗`, `有没有别的`, `再推荐几个`, `继续`,
|
||||
`下一批`, and `more candidates` are examples rather than fixed commands. The
|
||||
bridge still requires both continuation intent and valid context, so unrelated
|
||||
phrases such as `more interesting` or `继续查询英语例句` do not trigger the old query.
|
||||
|
||||
The bridge 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 `不可用候选`.
|
||||
model is queried first as a baseline and does not count toward the recommendation
|
||||
batch. The first reply queries at most four candidate models; a valid continuation
|
||||
queries the next four without repeating earlier candidates or the original
|
||||
baseline. 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
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -76,6 +77,77 @@ ALLOWED_VERSIONS = {
|
||||
"170F": {"V3"},
|
||||
"170H": {"V3"},
|
||||
}
|
||||
CANDIDATE_BATCH_SIZE = 4
|
||||
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
||||
CANDIDATE_CONTINUATION_SHORT_PHRASES = {
|
||||
"还有吗",
|
||||
"还有呢",
|
||||
"还有没有",
|
||||
"有没有",
|
||||
"有吗",
|
||||
"其他呢",
|
||||
"其他的呢",
|
||||
"别的呢",
|
||||
"剩下的呢",
|
||||
"余下的呢",
|
||||
"后面的呢",
|
||||
"继续",
|
||||
"继续吧",
|
||||
"继续看",
|
||||
"继续看看",
|
||||
"继续推荐",
|
||||
"接着来",
|
||||
"接着看",
|
||||
"再来几个",
|
||||
"再给几个",
|
||||
"再给我几个",
|
||||
"再推荐几个",
|
||||
"再查几个",
|
||||
"再看几个",
|
||||
"再看看",
|
||||
"多来几个",
|
||||
"多推荐几个",
|
||||
"推荐更多",
|
||||
"更多",
|
||||
"下一批",
|
||||
"下一组",
|
||||
"换一批",
|
||||
"再来一批",
|
||||
"下一个",
|
||||
"往下看",
|
||||
}
|
||||
CANDIDATE_CONTINUATION_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:还有没有|还有|是否还有|有没有|有无)"
|
||||
r"(?:其他|别的|更多|剩余|余下|后续|类似)?(?:的)?"
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案|推荐)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:继续|接着|再|多)(?:推荐|查询|查看|展示|看看|看|给|来).{0,6}"
|
||||
r"(?:其他|别的|更多|剩余|余下|后续)?(?:的)?"
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)"
|
||||
r".{0,6}(?:还有|有吗|更多|其他|别的|剩余|余下|继续|再来)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:还有|有没有)(?:别的|其他|更多|新的|剩余|余下|类似)"
|
||||
r"(?:的)?(?:吗|呢|有吗)?$"
|
||||
),
|
||||
re.compile(r"(?:还有|有没有)(?:哪些|什么)(?:其他)?(?:候选|型号|方案|选择|选项|推荐)?$"),
|
||||
re.compile(
|
||||
r"(?:可以|请|麻烦|帮我)?(?:再|多)(?:推荐|查询|查看|看|给|来)"
|
||||
r"(?:我)?(?:几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$"
|
||||
),
|
||||
re.compile(r"(?:下一批|下一组)(?:候选|型号|方案|选择|选项)?$"),
|
||||
re.compile(r"(?:换|再来|再给我)(?:一批|一组|几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$"),
|
||||
re.compile(r"(?:more|other|remaining|next)(?:candidates?|models?|options?|alternatives?|batch)"),
|
||||
re.compile(
|
||||
r"(?:continue|showme|giveme|recommend)(?:more|other|remaining)?"
|
||||
r"(?:candidates?|models?|options?|alternatives?)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -84,6 +156,14 @@ class RequestInput:
|
||||
quantity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateContinuation:
|
||||
request: RequestInput
|
||||
next_offset: int
|
||||
total_candidates: int
|
||||
expires_at: float
|
||||
|
||||
|
||||
class InputError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -92,6 +172,10 @@ class CandidateUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
||||
_candidate_continuation_lock = threading.Lock()
|
||||
|
||||
|
||||
def load_dotenv() -> None:
|
||||
"""Load simple KEY=VALUE pairs from the ignored local .env file."""
|
||||
env_file = PROJECT_ROOT / ".env"
|
||||
@@ -166,6 +250,69 @@ def parse_trigger_request(text: str) -> RequestInput | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_candidate_continuation_request(text: str) -> bool:
|
||||
normalized_text = unicodedata.normalize("NFKC", text).lower()
|
||||
compact_text = re.sub(r"[\s,。!?、;:,.!?;:]+", "", normalized_text)
|
||||
compact_text = re.sub(r"^(?:@?_user_\d+)+", "", compact_text)
|
||||
if compact_text in CANDIDATE_CONTINUATION_SHORT_PHRASES:
|
||||
return True
|
||||
return any(pattern.search(compact_text) for pattern in CANDIDATE_CONTINUATION_PATTERNS)
|
||||
|
||||
|
||||
def conversation_key(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None:
|
||||
event = getattr(data, "event", None)
|
||||
message = getattr(event, "message", None)
|
||||
sender = getattr(event, "sender", None)
|
||||
sender_id = getattr(sender, "sender_id", None)
|
||||
chat_id = getattr(message, "chat_id", None)
|
||||
open_id = getattr(sender_id, "open_id", None)
|
||||
if not isinstance(chat_id, str) or not chat_id:
|
||||
return None
|
||||
if not isinstance(open_id, str) or not open_id:
|
||||
return None
|
||||
return f"{chat_id}:{open_id}"
|
||||
|
||||
|
||||
def clear_candidate_continuation(key: str | None) -> None:
|
||||
if not key:
|
||||
return
|
||||
with _candidate_continuation_lock:
|
||||
_candidate_continuations.pop(key, None)
|
||||
|
||||
|
||||
def remember_candidate_continuation(
|
||||
key: str | None,
|
||||
request: RequestInput,
|
||||
next_offset: int,
|
||||
total_candidates: int,
|
||||
) -> None:
|
||||
if not key:
|
||||
return
|
||||
with _candidate_continuation_lock:
|
||||
if next_offset >= total_candidates:
|
||||
_candidate_continuations.pop(key, None)
|
||||
return
|
||||
_candidate_continuations[key] = CandidateContinuation(
|
||||
request=request,
|
||||
next_offset=next_offset,
|
||||
total_candidates=total_candidates,
|
||||
expires_at=time.monotonic() + CANDIDATE_CONTINUATION_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def get_candidate_continuation(key: str | None) -> CandidateContinuation | None:
|
||||
if not key:
|
||||
return None
|
||||
with _candidate_continuation_lock:
|
||||
state = _candidate_continuations.get(key)
|
||||
if state is None:
|
||||
return None
|
||||
if state.expires_at <= time.monotonic():
|
||||
_candidate_continuations.pop(key, None)
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def build_model(fields: dict[str, str]) -> str:
|
||||
closing_bracket = "]" if fields["bracket"] == "[" else ")"
|
||||
return (
|
||||
@@ -663,25 +810,39 @@ def write_record(
|
||||
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("原型号接口未返回可用交期汇总,未生成候选结果。")
|
||||
def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
||||
if candidate_offset < 0:
|
||||
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
||||
|
||||
candidates = [
|
||||
{
|
||||
"index": 1,
|
||||
"model": request.model,
|
||||
"type": "原型号,DZ" if "[" in request.model else "原型号,LF",
|
||||
"details": baseline,
|
||||
"reason": "原始需求基准,用于交期测算与候选比较。",
|
||||
"difference": "无",
|
||||
"confirmation": "无型号差异;需确认正式交付节点和承诺边界。",
|
||||
}
|
||||
all_candidate_specs = generate_candidate_specs(request.model)
|
||||
candidate_specs = all_candidate_specs[
|
||||
candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE
|
||||
]
|
||||
is_initial_batch = candidate_offset == 0
|
||||
if not is_initial_batch and not candidate_specs:
|
||||
return f"AM516 已无其他规则候选型号。\n\n{DISCLAIMER}"
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
baseline: dict[str, Any] | None = None
|
||||
if is_initial_batch:
|
||||
baseline_payload = run_controlled_query(request)
|
||||
baseline = lead_time_details(baseline_payload, request.quantity)
|
||||
if not baseline["has_lead_time"]:
|
||||
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
|
||||
candidates.append(
|
||||
{
|
||||
"index": 1,
|
||||
"record_order": 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:
|
||||
@@ -691,40 +852,77 @@ def build_report(request: RequestInput) -> str:
|
||||
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})
|
||||
if candidate_specs:
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=min(CANDIDATE_BATCH_SIZE, len(candidate_specs))
|
||||
) 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"]])
|
||||
candidate_order = {
|
||||
spec["model"]: index for index, spec in enumerate(all_candidate_specs)
|
||||
}
|
||||
candidate_start = 1 if is_initial_batch else 0
|
||||
candidates[candidate_start:] = sorted(
|
||||
candidates[candidate_start:], 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:]:
|
||||
for candidate in candidates[candidate_start:]:
|
||||
candidate["record_order"] = candidate_order[candidate["model"]] + 2
|
||||
candidate["index"] = candidate["record_order"]
|
||||
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}"
|
||||
data_timestamp = (
|
||||
baseline["timestamp"]
|
||||
if baseline and baseline["timestamp"]
|
||||
else next(
|
||||
(
|
||||
candidate["details"]["timestamp"]
|
||||
for candidate in candidates
|
||||
if candidate["details"].get("timestamp")
|
||||
),
|
||||
f"{generation_time:%Y-%m-%d %H:%M}",
|
||||
)
|
||||
)
|
||||
queried_end = candidate_offset + len(candidate_specs)
|
||||
remaining_count = len(all_candidate_specs) - queried_end
|
||||
if is_initial_batch:
|
||||
batch_scope = (
|
||||
f"本次先推荐{len(candidate_specs)}个候选型号(原型号另作基准)"
|
||||
if remaining_count
|
||||
else f"本次已推荐全部{len(candidate_specs)}个候选型号(原型号另作基准)"
|
||||
)
|
||||
else:
|
||||
batch_scope = f"本次继续推荐第{candidate_offset + 1}-{queried_end}个候选型号"
|
||||
if remaining_count:
|
||||
batch_scope += f";尚有{remaining_count}个规则候选未查询"
|
||||
else:
|
||||
batch_scope += ";规则候选已全部查询"
|
||||
|
||||
report_lines = [
|
||||
f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}",
|
||||
(
|
||||
f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}"
|
||||
if is_initial_batch
|
||||
else f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}(其他候选)"
|
||||
),
|
||||
"",
|
||||
f"输入需求:{request.model},{request.quantity}台",
|
||||
f"数据时间戳:{data_timestamp}",
|
||||
"状态:试运行版输出 / 内部候选",
|
||||
"判断等级:AM516算法预测参考,需人工复核",
|
||||
f"候选范围:{batch_scope}",
|
||||
"",
|
||||
"总体判断:",
|
||||
"总体判断:" if is_initial_batch else "本批次判断:",
|
||||
*overall_judgment_lines(candidates),
|
||||
"",
|
||||
]
|
||||
@@ -739,6 +937,13 @@ def build_report(request: RequestInput) -> str:
|
||||
)
|
||||
else:
|
||||
report_lines.append("- 无(本次已查询候选均返回交期汇总)")
|
||||
if remaining_count:
|
||||
report_lines.append(
|
||||
f"其他候选:尚有{remaining_count}个未查询;"
|
||||
"如需继续,可说“还有吗”“再推荐几个”“继续”等类似表达。"
|
||||
)
|
||||
else:
|
||||
report_lines.append("其他候选:规则候选已全部查询。")
|
||||
report_lines.extend(["", DISCLAIMER])
|
||||
return "\n".join(report_lines)
|
||||
|
||||
@@ -765,7 +970,10 @@ def is_allowed_sender(data: lark.im.v1.P2ImMessageReceiveV1) -> bool:
|
||||
|
||||
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):
|
||||
if (
|
||||
line in {"总体判断:", "本批次判断:", "不可用候选:"}
|
||||
or re.match(r"^\d+\.\s+eRob", line)
|
||||
):
|
||||
element["style"] = ["bold"]
|
||||
return [element]
|
||||
|
||||
@@ -831,16 +1039,42 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
|
||||
if not text or not message_id:
|
||||
return
|
||||
|
||||
key = conversation_key(data)
|
||||
request = parse_trigger_request(text)
|
||||
candidate_offset = 0
|
||||
continuation_state: CandidateContinuation | None = None
|
||||
if request is None and is_candidate_continuation_request(text):
|
||||
continuation_state = get_candidate_continuation(key)
|
||||
if continuation_state is not None:
|
||||
request = continuation_state.request
|
||||
candidate_offset = continuation_state.next_offset
|
||||
if request is None:
|
||||
logging.info(
|
||||
"Feishu message ignored: no complete AM516 model-and-quantity request; message_id=%s",
|
||||
"Feishu message ignored: no complete AM516 request or active candidate continuation; message_id=%s",
|
||||
message_id,
|
||||
)
|
||||
return
|
||||
|
||||
if continuation_state is None:
|
||||
clear_candidate_continuation(key)
|
||||
|
||||
try:
|
||||
report = build_report(request)
|
||||
report = (
|
||||
build_report(request)
|
||||
if candidate_offset == 0
|
||||
else build_report(request, candidate_offset=candidate_offset)
|
||||
)
|
||||
total_candidates = (
|
||||
continuation_state.total_candidates
|
||||
if continuation_state is not None
|
||||
else len(generate_candidate_specs(request.model))
|
||||
)
|
||||
remember_candidate_continuation(
|
||||
key,
|
||||
request,
|
||||
min(candidate_offset + CANDIDATE_BATCH_SIZE, total_candidates),
|
||||
total_candidates,
|
||||
)
|
||||
except InputError as exc:
|
||||
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
||||
except RuntimeError as exc:
|
||||
|
||||
Reference in New Issue
Block a user