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

@@ -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. 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 ## 4. API Rule
Use only: Use only:
@@ -83,6 +89,7 @@ Include:
- input model and quantity; - input model and quantity;
- candidate summary; - candidate summary;
- current candidate batch and remaining-candidate count;
- predicted lead time or API exception; - predicted lead time or API exception;
- main factor; - main factor;
- model difference; - model difference;

View File

@@ -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; - 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; - shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details;
- result is marked as internal candidate; - 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; - all Skill-permitted brake, encoder, sensor, grease, and DZ/LF combinations are generated in a stable rule order 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 `不可用候选`; - the original model is queried as a baseline and does not count toward the recommendation batch;
- when the full report exceeds one Feishu rich-text message, it is split into ordered continuation posts and no candidate is dropped; - 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. - 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; - 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预测交期、可用库存、主导因素; - 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 `—`; - 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 ## Sample 2 | Missing Quantity
Input: Input:

View File

@@ -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. - [ ] 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 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. - [ ] 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. - [ ] Output includes the fixed internal-candidate disclaimer.
- [ ] Records append to `records/delivery_prediction_records.md`. - [ ] 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. - [ ] 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.

View File

@@ -7,12 +7,22 @@
3. Confirm AR51 has approved the functional test. 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. 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. 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. 6. Use model recommendation rules to generate the complete ordered internal candidate pool.
7. Run the controlled script for the original model and approved candidates. 7. Run the controlled script for the original model baseline and only the first four candidate models.
8. Summarize the internal candidate result. 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. 9. Append a non-secret record.
10. Return Feishu-friendly text with the fixed disclaimer. 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 ## Approved Script
```text ```text

View File

@@ -49,6 +49,16 @@ Stop if:
- An @mention by itself does not bypass this content gate. - An @mention by itself does not bypass this content gate.
- After a valid message passes the gate, API or runtime failures may still return - After a valid message passes the gate, API or runtime failures may still return
the bounded internal error response defined by this capability. 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 ## 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 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. - 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 ## Reducer Shortage Detail Rule
- Treat a shortage item as a reducer only when `data.采购信息.缺料明细[].缺料物料` starts with `20.30.`. - Treat a shortage item as a reducer only when `data.采购信息.缺料明细[].缺料物料` starts with `20.30.`.
@@ -105,6 +135,7 @@ Include:
- title and generation time; - title and generation time;
- input model and quantity; - input model and quantity;
- overall judgment; - overall judgment;
- current candidate batch and remaining-candidate count;
- candidate details; - candidate details;
- main factor; - main factor;
- API exception if any; - API exception if any;
@@ -120,6 +151,7 @@ Use this field order for a successful Feishu report:
数据时间戳:{API data timestamp or generation time} 数据时间戳:{API data timestamp or generation time}
状态:试运行版输出 / 内部候选 状态:试运行版输出 / 内部候选
判断等级AM516算法预测参考需人工复核 判断等级AM516算法预测参考需人工复核
候选范围本次先推荐4个候选型号原型号另作基准尚有N个规则候选未查询
总体判断: 总体判断:
- 现货候选:{fully covered candidate or none} - 现货候选:{fully covered candidate or none}
@@ -148,12 +180,13 @@ Use Feishu rich-text `post` output. Bold only:
- the report title; - the report title;
- `总体判断:`; - `总体判断:`;
- `本批次判断:` for a continuation reply;
- each numbered candidate heading; - 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. 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 ## Fixed Disclaimer

View File

@@ -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. - 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. - Include the original model separately as the baseline and deduplicate all generated models.
- Never generate a brake, encoder, sensor, or grease downgrade. - Never generate a brake, encoder, sensor, or grease downgrade.
- Query every generated candidate through the controlled delivery-prediction script. - Preserve the complete deduplicated candidate pool in rule order so the lead-time capability can page through it deterministically.
- Show candidates with usable lead-time summaries in full; summarize missing or unsupported models under `不可用候选`. - 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 ## Output

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, 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. 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 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 alone does not bypass it. After a valid initial query, the same sender in the same
delivery-prediction script. It generates the full Cartesian 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 set of candidates allowed by the packaged Skill: brake upgrades, encoder
upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer
brand variants. Strong constraints and version stay unchanged. The original brand variants. Strong constraints and version stay unchanged. The original
model is queried first; valid candidates are reported in rule order, while model is queried first as a baseline and does not count toward the recommendation
missing or unsupported candidates are summarized under `不可用候选`. 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 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 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 subprocess
import sys import sys
import threading import threading
import time
import unicodedata import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -76,6 +77,77 @@ ALLOWED_VERSIONS = {
"170F": {"V3"}, "170F": {"V3"},
"170H": {"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) @dataclass(frozen=True)
@@ -84,6 +156,14 @@ class RequestInput:
quantity: int quantity: int
@dataclass(frozen=True)
class CandidateContinuation:
request: RequestInput
next_offset: int
total_candidates: int
expires_at: float
class InputError(ValueError): class InputError(ValueError):
pass pass
@@ -92,6 +172,10 @@ class CandidateUnavailableError(RuntimeError):
pass pass
_candidate_continuations: dict[str, CandidateContinuation] = {}
_candidate_continuation_lock = threading.Lock()
def load_dotenv() -> None: def load_dotenv() -> None:
"""Load simple KEY=VALUE pairs from the ignored local .env file.""" """Load simple KEY=VALUE pairs from the ignored local .env file."""
env_file = PROJECT_ROOT / ".env" env_file = PROJECT_ROOT / ".env"
@@ -166,6 +250,69 @@ def parse_trigger_request(text: str) -> RequestInput | None:
return 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: def build_model(fields: dict[str, str]) -> str:
closing_bracket = "]" if fields["bracket"] == "[" else ")" closing_bracket = "]" if fields["bracket"] == "[" else ")"
return ( return (
@@ -663,25 +810,39 @@ def write_record(
file.write(record) file.write(record)
def build_report(request: RequestInput) -> str: def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
baseline_payload = run_controlled_query(request) if candidate_offset < 0:
baseline = lead_time_details(baseline_payload, request.quantity) raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
if not baseline["has_lead_time"]:
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
candidates = [ all_candidate_specs = generate_candidate_specs(request.model)
{ candidate_specs = all_candidate_specs[
"index": 1, candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE
"model": request.model,
"type": "原型号DZ" if "[" in request.model else "原型号LF",
"details": baseline,
"reason": "原始需求基准,用于交期测算与候选比较。",
"difference": "",
"confirmation": "无型号差异;需确认正式交付节点和承诺边界。",
}
] ]
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]] = [] 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]: def query_candidate(spec: dict[str, str]) -> tuple[dict[str, str], dict[str, Any] | None]:
try: try:
@@ -691,40 +852,77 @@ def build_report(request: RequestInput) -> str:
details = lead_time_details(payload, request.quantity) details = lead_time_details(payload, request.quantity)
return spec, details if details["has_lead_time"] else None return spec, details if details["has_lead_time"] else None
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: if candidate_specs:
future_map = { with concurrent.futures.ThreadPoolExecutor(
executor.submit(query_candidate, spec): spec for spec in candidate_specs max_workers=min(CANDIDATE_BATCH_SIZE, len(candidate_specs))
} ) as executor:
for future in concurrent.futures.as_completed(future_map): future_map = {
spec, details = future.result() executor.submit(query_candidate, spec): spec for spec in candidate_specs
if details is None: }
unavailable.append(spec) for future in concurrent.futures.as_completed(future_map):
continue spec, details = future.result()
candidates.append({**spec, "details": details}) 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)} candidate_order = {
candidates[1:] = sorted(candidates[1:], key=lambda item: candidate_order[item["model"]]) 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"]]) unavailable.sort(key=lambda item: candidate_order[item["model"]])
candidates[0]["record_order"] = 1 for candidate in candidates[candidate_start:]:
for candidate in candidates[1:]:
candidate["record_order"] = candidate_order[candidate["model"]] + 2 candidate["record_order"] = candidate_order[candidate["model"]] + 2
candidate["index"] = candidate["record_order"]
for candidate in unavailable: for candidate in unavailable:
candidate["record_order"] = candidate_order[candidate["model"]] + 2 candidate["record_order"] = candidate_order[candidate["model"]] + 2
for index, candidate in enumerate(candidates, start=1):
candidate["index"] = index
generation_time = datetime.now().astimezone() generation_time = datetime.now().astimezone()
write_record(request, candidates, unavailable, generation_time) 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 = [ 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"输入需求:{request.model}{request.quantity}",
f"数据时间戳:{data_timestamp}", f"数据时间戳:{data_timestamp}",
"状态:试运行版输出 / 内部候选", "状态:试运行版输出 / 内部候选",
"判断等级AM516算法预测参考需人工复核", "判断等级AM516算法预测参考需人工复核",
f"候选范围:{batch_scope}",
"", "",
"总体判断:", "总体判断:" if is_initial_batch else "本批次判断:",
*overall_judgment_lines(candidates), *overall_judgment_lines(candidates),
"", "",
] ]
@@ -739,6 +937,13 @@ def build_report(request: RequestInput) -> str:
) )
else: else:
report_lines.append("- 无(本次已查询候选均返回交期汇总)") report_lines.append("- 无(本次已查询候选均返回交期汇总)")
if remaining_count:
report_lines.append(
f"其他候选:尚有{remaining_count}个未查询;"
"如需继续,可说“还有吗”“再推荐几个”“继续”等类似表达。"
)
else:
report_lines.append("其他候选:规则候选已全部查询。")
report_lines.extend(["", DISCLAIMER]) report_lines.extend(["", DISCLAIMER])
return "\n".join(report_lines) 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]]: def post_paragraph(line: str) -> list[dict[str, Any]]:
element: dict[str, Any] = {"tag": "text", "text": line or " "} 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"] element["style"] = ["bold"]
return [element] return [element]
@@ -831,16 +1039,42 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
if not text or not message_id: if not text or not message_id:
return return
key = conversation_key(data)
request = parse_trigger_request(text) 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: if request is None:
logging.info( 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, message_id,
) )
return return
if continuation_state is None:
clear_candidate_continuation(key)
try: 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: except InputError as exc:
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}" report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
except RuntimeError as exc: except RuntimeError as exc:

View File

@@ -28,7 +28,36 @@ if "lark_oapi" not in sys.modules:
from shared.runtime import feishu_bot_bridge as bridge 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): 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: def test_complete_model_and_quantity_trigger(self) -> None:
request = bridge.parse_trigger_request("eRob70H50I-BHM-18CTC[V5] 10台") 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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()