From 8a695900f3a220510eb9ad552fa5674501ebab4f Mon Sep 17 00:00:00 2001 From: ar51 Date: Tue, 1 Sep 2026 09:48:01 +0800 Subject: [PATCH] feat: paginate candidate model recommendations --- .../am516-delivery-prediction/AGENTS.md | 7 + .../docs/acceptance_samples.md | 43 ++- .../docs/deployment_checklist.md | 2 + .../am516-delivery-prediction/docs/runbook.md | 16 +- .../joint-module-leadtime-report/SKILL.md | 35 +- .../SKILL.md | 6 +- shared/runtime/README.md | 21 +- shared/runtime/feishu_bot_bridge.py | 310 +++++++++++++++--- tests/test_feishu_bot_bridge.py | 210 ++++++++++++ 9 files changed, 598 insertions(+), 52 deletions(-) diff --git a/capabilities/am516-delivery-prediction/AGENTS.md b/capabilities/am516-delivery-prediction/AGENTS.md index 50eef62..2fa1798 100644 --- a/capabilities/am516-delivery-prediction/AGENTS.md +++ b/capabilities/am516-delivery-prediction/AGENTS.md @@ -34,6 +34,12 @@ eRob90H100I-FHM-18CT[V6] 50 If multiple models are provided, stop and ask AR51 to run one model at a time. +After a valid query has produced an initial candidate batch, the same sender in +the same Feishu chat may ask whether there are other candidates without +repeating the model and quantity. This continuation is valid only while the +runtime still holds that query context. It continues from the next unqueried +candidate; it does not create a new model or quantity assumption. + ## 4. API Rule Use only: @@ -83,6 +89,7 @@ Include: - input model and quantity; - candidate summary; +- current candidate batch and remaining-candidate count; - predicted lead time or API exception; - main factor; - model difference; diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index 845bf14..bea2b74 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -28,15 +28,50 @@ Expected: - reducer shortages are the sole exception: when `缺料物料` starts with `20.30.`, output only the reducer code, inventory, inspection, in-transit, existing-demand quantities, and in-transit coverage conclusion in the Skill-defined order; - shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details; - result is marked as internal candidate; -- all Skill-permitted brake, encoder, sensor, grease, and DZ/LF combinations are generated without changing strong constraints or version; -- every generated candidate is queried through the controlled script; usable candidates are detailed and unsupported candidates are summarized under `不可用候选`; -- when the full report exceeds one Feishu rich-text message, it is split into ordered continuation posts and no candidate is dropped; +- all Skill-permitted brake, encoder, sensor, grease, and DZ/LF combinations are generated in a stable rule order without changing strong constraints or version; +- the original model is queried as a baseline and does not count toward the recommendation batch; +- the initial reply queries only candidate positions 1-4, details usable candidates, summarizes unsupported candidates under `不可用候选`, states the remaining count, and invites the human to ask `还有没有其他候选型号`; +- candidate position 5 and later are not queried or displayed in the initial reply; +- when the current four-candidate batch exceeds one Feishu rich-text message, it is split into ordered continuation posts and no candidate from that batch is dropped; - record is appended. -- the appended record contains query metadata, model parsing, and a complete Markdown table with one row per queried candidate; +- the appended record contains query metadata, model parsing, and a complete Markdown table with one row per model queried in the current batch; - each future appended candidate row records the same query-completion time in `YYYY-MM-DD HH:mm:ss +0800` form (with the actual local UTC offset), without rewriting or backfilling historical records; - record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素; - unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`; +## Sample 1A | Continue With Other Candidates + +Preconditions: + +- Sample 1 has completed in the same Feishu chat for the same sender; +- more than four rule candidates exist; +- the bridge has not restarted and the initial-query context is less than six hours old. + +Input: + +```text +还有没有其他候选型号? +``` + +Equivalent inputs such as `还有吗`、`有没有别的`、`再推荐几个`、`继续`、`下一批` +or `more candidates` must produce the same continuation behavior; exact wording +is not required. + +Expected: + +- the bot reuses the active model and quantity only for that same sender and chat; +- the bot queries candidate positions 5-8 in the unchanged rule order; +- the original model and candidate positions 1-4 are not queried or displayed again; +- unavailable candidates still consume their original positions and are summarized under `不可用候选`; +- the reply states the current candidate range and the remaining count, or states that all rule candidates have been queried; +- one new record block contains only the models queried in this continuation batch; +- the fixed disclaimer appears once at the end. + +If the same continuation text comes from another sender or chat, after a bridge restart, after the six-hour expiry, or without a valid initial query, the bot stays silent and makes no API request or record append. + +Unrelated expressions such as `more interesting`、`继续查询英语例句`、`下一课` or +`再来一首歌` do not qualify as candidate continuation, even while a query context exists. + ## Sample 2 | Missing Quantity Input: diff --git a/capabilities/am516-delivery-prediction/docs/deployment_checklist.md b/capabilities/am516-delivery-prediction/docs/deployment_checklist.md index ac10a3f..df47a5f 100644 --- a/capabilities/am516-delivery-prediction/docs/deployment_checklist.md +++ b/capabilities/am516-delivery-prediction/docs/deployment_checklist.md @@ -44,6 +44,8 @@ - [ ] After the approval gate is satisfied, at least one valid model + quantity query succeeds; otherwise the blocked outbound state is recorded without attempting the request. - [ ] Missing API key returns a structured error without leaking secrets. - [ ] Missing, invalid, multiple, or ambiguous model/quantity input stays silent and makes no API call or record append. +- [ ] The initial response queries no more than four recommended candidates in addition to the original-model baseline; same-sender, same-chat continuation queries the next four without duplication. +- [ ] Candidate continuation without matching active context stays silent; a bridge restart or six-hour expiry requires the complete model and quantity again. - [ ] Output includes the fixed internal-candidate disclaimer. - [ ] Records append to `records/delivery_prediction_records.md`. - [ ] A direct AR51 task in Mac Studio Codex can modify a test project file or perform the intended Git update without being blocked by the Feishu read-only rule. diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index ff42c71..7eebc8b 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -7,12 +7,22 @@ 3. Confirm AR51 has approved the functional test. 4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use. 5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself. -6. Use model recommendation rules to generate internal candidates. -7. Run the controlled script for the original model and approved candidates. -8. Summarize the internal candidate result. +6. Use model recommendation rules to generate the complete ordered internal candidate pool. +7. Run the controlled script for the original model baseline and only the first four candidate models. +8. Summarize the current batch, state how many candidates remain, and invite the same sender to ask naturally for more when candidates remain; exact wording is not required. 9. Append a non-secret record. 10. Return Feishu-friendly text with the fixed disclaimer. +## Candidate Continuation + +1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `还有吗`、`有没有别的`、`再推荐几个`、`继续`、`下一批` or `more candidates` are valid; no exact phrase is required. +2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query. +3. Query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate. +4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`. +5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain. +6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat. +7. Keep the dual gate of continuation intent plus valid context: unrelated text such as `more interesting` or `继续查询英语例句` must not trigger the old query. + ## Approved Script ```text diff --git a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md index 5535c9e..3efc40f 100644 --- a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md +++ b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md @@ -49,6 +49,16 @@ Stop if: - An @mention by itself does not bypass this content gate. - After a valid message passes the gate, API or runtime failures may still return the bounded internal error response defined by this capability. +- Exception for candidate continuation: after a valid initial query, the same + sender in the same Feishu chat may use any clear equivalent continuation + expression without repeating the model and quantity. Examples include `还有吗`、 + `有没有别的`、`再推荐几个`、`继续`、`下一批` and `more candidates`; exact wording + is not required. +- A continuation uses only the active in-memory query context, expires after six + hours or a bridge restart, and is never shared across senders or chats. Without + matching active context, the message stays silent. +- A new complete model-and-quantity request replaces any earlier continuation + context for that sender and chat. ## Feishu File-Write Boundary @@ -83,6 +93,26 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc - If AM516 fields are absent, use original API prediction and mark it as original algorithm reference. - If only total days are available, state the total days and ask for human review. +## Candidate Batch Rule + +- Generate and preserve the complete ordered rule-candidate pool, but query and + recommend at most four candidate models per reply. +- The original input model is queried as the initial baseline and does not count + toward the four recommended candidate models. +- The initial reply queries the baseline plus candidate positions 1-4. If more + candidates remain, state the remaining count and invite the human to ask for + more in natural language; do not require one fixed command phrase. +- Recognize short or synonymous continuation expressions only together with the + valid same-sender, same-chat continuation context. Unrelated topic text such as + `more interesting` or `继续查询英语例句` must not reuse the old delivery query. +- Each valid continuation queries the next four unqueried candidates in the same + rule order. Do not query or display an earlier candidate again, and do not + re-query the original baseline. +- Continue in batches of four until the full candidate pool is exhausted. The + final batch explicitly states that all rule candidates have been queried. +- Missing or unsupported models count as queried positions and remain summarized + under `不可用候选`; do not silently skip them to pull later candidates forward. + ## Reducer Shortage Detail Rule - Treat a shortage item as a reducer only when `data.采购信息.缺料明细[].缺料物料` starts with `20.30.`. @@ -105,6 +135,7 @@ Include: - title and generation time; - input model and quantity; - overall judgment; +- current candidate batch and remaining-candidate count; - candidate details; - main factor; - API exception if any; @@ -120,6 +151,7 @@ Use this field order for a successful Feishu report: 数据时间戳:{API data timestamp or generation time} 状态:试运行版输出 / 内部候选 判断等级:AM516算法预测参考,需人工复核 +候选范围:本次先推荐4个候选型号(原型号另作基准);尚有N个规则候选未查询 总体判断: - 现货候选:{fully covered candidate or none} @@ -148,12 +180,13 @@ Use Feishu rich-text `post` output. Bold only: - the report title; - `总体判断:`; +- `本批次判断:` for a continuation reply; - each numbered candidate heading; - `不可用候选:`. Do not repeat a separate human-review line under each candidate. The global judgment level and fixed disclaimer carry the human-review boundary. -If the full candidate report exceeds the Feishu rich-text size limit, split it into multiple ordered `post` replies without dropping any queried candidate. Use the original report title on the first part and append `(续)` to continuation titles. +If the current candidate-batch report exceeds the Feishu rich-text size limit, split it into multiple ordered `post` replies without dropping any candidate queried in that batch. Use the original report title on the first part and append `(续)` to continuation titles. ## Fixed Disclaimer diff --git a/capabilities/am516-delivery-prediction/skills/joint-module-model-recommendation/SKILL.md b/capabilities/am516-delivery-prediction/skills/joint-module-model-recommendation/SKILL.md index 3ae1512..ebdc038 100644 --- a/capabilities/am516-delivery-prediction/skills/joint-module-model-recommendation/SKILL.md +++ b/capabilities/am516-delivery-prediction/skills/joint-module-model-recommendation/SKILL.md @@ -62,8 +62,10 @@ Allowed upgrades: - Encoder options are directional: `S -> M/HS/HM`, `M -> HS/HM`, `HS -> HM`, and `HM` has no further automatic upgrade. - Include the original model separately as the baseline and deduplicate all generated models. - Never generate a brake, encoder, sensor, or grease downgrade. -- Query every generated candidate through the controlled delivery-prediction script. -- Show candidates with usable lead-time summaries in full; summarize missing or unsupported models under `不可用候选`. +- Preserve the complete deduplicated candidate pool in rule order so the lead-time capability can page through it deterministically. +- The original model is a baseline and does not count toward the four recommended candidate models in a batch. +- When used by the lead-time capability, pass only the first four candidates on the initial query. A same-conversation human request for more candidates passes the next four, without repeating or reordering earlier candidates. +- This recommendation Skill does not call the delivery-prediction API itself. The lead-time capability queries only the current candidate batch and summarizes missing or unsupported models under `不可用候选`. ## Output diff --git a/shared/runtime/README.md b/shared/runtime/README.md index 01aa0d5..f80ee69 100644 --- a/shared/runtime/README.md +++ b/shared/runtime/README.md @@ -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 diff --git a/shared/runtime/feishu_bot_bridge.py b/shared/runtime/feishu_bot_bridge.py index 72479ba..4370ae7 100644 --- a/shared/runtime/feishu_bot_bridge.py +++ b/shared/runtime/feishu_bot_bridge.py @@ -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: diff --git a/tests/test_feishu_bot_bridge.py b/tests/test_feishu_bot_bridge.py index 4992fa7..d6758dd 100644 --- a/tests/test_feishu_bot_bridge.py +++ b/tests/test_feishu_bot_bridge.py @@ -28,7 +28,36 @@ if "lark_oapi" not in sys.modules: from shared.runtime import feishu_bot_bridge as bridge +def successful_payload() -> dict[str, object]: + return { + "data": { + "时间戳": "2026-08-31 12:00:00", + "交期汇总": { + "AM516预测交期": "2026-09-01", + "AM516总交期": 1, + "AM516主导因素": "现货", + }, + "库存信息": {"可用库存(扣除了待产数量)": 100}, + "BOM信息": {"是否有BOM(False则需提醒无BOM)": True}, + "采购信息": {"缺料明细": []}, + } + } + + +def message_event(message_id: str, chat_id: str, open_id: str) -> SimpleNamespace: + return SimpleNamespace( + event=SimpleNamespace( + message=SimpleNamespace(message_id=message_id, chat_id=chat_id), + sender=SimpleNamespace(sender_id=SimpleNamespace(open_id=open_id)), + ) + ) + + class TriggerGateTests(unittest.TestCase): + def setUp(self) -> None: + with bridge._candidate_continuation_lock: + bridge._candidate_continuations.clear() + def test_complete_model_and_quantity_trigger(self) -> None: request = bridge.parse_trigger_request("eRob70H50I-BHM-18CTC[V5] 10台") @@ -102,6 +131,187 @@ class TriggerGateTests(unittest.TestCase): ) reply.assert_called_once_with("client", "om_valid", "report") + def test_candidate_continuation_phrase_is_recognized(self) -> None: + valid_phrases = ( + "还有没有其他候选型号?", + "还有吗?", + "还有别的吗", + "其他呢", + "还有类似的吗", + "还有什么推荐", + "有没有其他选择", + "还有其他方案吗", + "再推荐几个", + "可以再给我几个吗", + "继续吧", + "接着来", + "下一批", + "换一批", + "多看几个候选", + "more candidates", + "any other options?", + ) + for phrase in valid_phrases: + with self.subTest(phrase=phrase): + self.assertTrue(bridge.is_candidate_continuation_request(phrase)) + + invalid_phrases = ( + "这个机器人头像有点丑", + "more interesting", + "继续查询英语例句", + "下一课", + "再来一首歌", + "有其他事情吗", + ) + for phrase in invalid_phrases: + with self.subTest(phrase=phrase): + self.assertFalse(bridge.is_candidate_continuation_request(phrase)) + + def test_continuation_without_same_conversation_context_is_silent(self) -> None: + data = message_event("om_more", "oc_other", "ou_other") + + with ( + mock.patch.object( + bridge, + "message_text", + return_value="还有没有其他候选型号?", + ), + mock.patch.object(bridge, "build_report") as build_report, + mock.patch.object(bridge, "reply") as reply, + ): + bridge.handle_message("client", data) + + build_report.assert_not_called() + reply.assert_not_called() + + def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None: + request_text = "eRob70H50I-FS-18CN[V5] 10台" + request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10) + initial_data = message_event("om_initial", "oc_same", "ou_same") + follow_up_data = message_event("om_follow_up", "oc_same", "ou_same") + + with ( + mock.patch.object( + bridge, + "message_text", + side_effect=[request_text, "还有吗?"], + ), + mock.patch.object( + bridge, + "build_report", + side_effect=["initial report", "continued report"], + ) as build_report, + mock.patch.object(bridge, "reply") as reply, + ): + bridge.handle_message("client", initial_data) + bridge.handle_message("client", follow_up_data) + + self.assertEqual( + build_report.call_args_list, + [mock.call(request), mock.call(request, candidate_offset=4)], + ) + self.assertEqual( + reply.call_args_list, + [ + mock.call("client", "om_initial", "initial report"), + mock.call("client", "om_follow_up", "continued report"), + ], + ) + + def test_context_is_cleared_when_all_candidates_fit_in_first_batch(self) -> None: + request_text = "eRob70H50I-BHM-18CTC[V5] 10台" + initial_data = message_event("om_small", "oc_small", "ou_small") + follow_up_data = message_event("om_small_more", "oc_small", "ou_small") + + with ( + mock.patch.object( + bridge, + "message_text", + side_effect=[request_text, "还有没有其他候选型号?"], + ), + mock.patch.object(bridge, "build_report", return_value="report") as build_report, + mock.patch.object(bridge, "reply") as reply, + ): + bridge.handle_message("client", initial_data) + bridge.handle_message("client", follow_up_data) + + build_report.assert_called_once_with( + bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) + ) + reply.assert_called_once_with("client", "om_small", "report") + + def test_expired_candidate_context_is_removed(self) -> None: + key = "oc_expired:ou_expired" + request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10) + bridge._candidate_continuations[key] = bridge.CandidateContinuation( + request=request, + next_offset=4, + total_candidates=10, + expires_at=0, + ) + + self.assertIsNone(bridge.get_candidate_continuation(key)) + self.assertNotIn(key, bridge._candidate_continuations) + + +class CandidateBatchTests(unittest.TestCase): + def setUp(self) -> None: + self.request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10) + self.specs = bridge.generate_candidate_specs(self.request.model) + self.assertGreater(len(self.specs), bridge.CANDIDATE_BATCH_SIZE * 2) + + def test_initial_report_queries_baseline_and_only_four_candidates(self) -> None: + with ( + mock.patch.object( + bridge, + "run_controlled_query", + return_value=successful_payload(), + ) as run_query, + mock.patch.object(bridge, "write_record"), + ): + report = bridge.build_report(self.request) + + queried_models = [call.args[0].model for call in run_query.call_args_list] + self.assertEqual(len(queried_models), 1 + bridge.CANDIDATE_BATCH_SIZE) + self.assertEqual(queried_models[0], self.request.model) + self.assertCountEqual( + queried_models[1:], + [spec["model"] for spec in self.specs[: bridge.CANDIDATE_BATCH_SIZE]], + ) + self.assertIn("本次先推荐4个候选型号(原型号另作基准)", report) + self.assertIn("可说“还有吗”“再推荐几个”“继续”等类似表达", report) + self.assertNotIn(self.specs[bridge.CANDIDATE_BATCH_SIZE]["model"], report) + + def test_follow_up_report_queries_next_four_without_requerying_baseline(self) -> None: + with ( + mock.patch.object( + bridge, + "run_controlled_query", + return_value=successful_payload(), + ) as run_query, + mock.patch.object(bridge, "write_record"), + ): + report = bridge.build_report( + self.request, + candidate_offset=bridge.CANDIDATE_BATCH_SIZE, + ) + + queried_models = [call.args[0].model for call in run_query.call_args_list] + expected_models = [ + spec["model"] + for spec in self.specs[ + bridge.CANDIDATE_BATCH_SIZE : bridge.CANDIDATE_BATCH_SIZE * 2 + ] + ] + self.assertEqual(len(queried_models), bridge.CANDIDATE_BATCH_SIZE) + self.assertNotIn(self.request.model, queried_models) + self.assertCountEqual(queried_models, expected_models) + self.assertIn("本次继续推荐第5-8个候选型号", report) + for model in expected_models: + self.assertIn(model, report) + for model in [spec["model"] for spec in self.specs[:4]]: + self.assertNotIn(model, report) + if __name__ == "__main__": unittest.main()