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

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