feat: paginate candidate model recommendations

This commit is contained in:
ar51
2026-09-01 09:48:01 +08:00
parent d97b788e0e
commit 8a695900f3
9 changed files with 598 additions and 52 deletions

View File

@@ -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

View File

@@ -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: