feat: paginate candidate model recommendations
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -48,13 +48,26 @@ messages are ignored silently: the bridge sends no Feishu reply, makes no API
|
||||
request, and appends no record. Only a message containing exactly one complete,
|
||||
rule-valid eRob model and exactly one positive quantity enters the query workflow.
|
||||
This gate applies even when the bot receives every message in a group; an @mention
|
||||
alone does not bypass it. The bridge invokes only the existing controlled
|
||||
delivery-prediction script. It generates the full Cartesian
|
||||
alone does not bypass it. After a valid initial query, the same sender in the same
|
||||
chat may ask whether there are other candidate models. The bridge keeps that
|
||||
continuation context in memory for six hours, never shares it across senders or
|
||||
chats, and stays silent if no matching context exists. A bridge restart clears
|
||||
the context, so the sender must resend the complete model and quantity. The
|
||||
continuation wording is flexible: `还有吗`, `有没有别的`, `再推荐几个`, `继续`,
|
||||
`下一批`, and `more candidates` are examples rather than fixed commands. The
|
||||
bridge still requires both continuation intent and valid context, so unrelated
|
||||
phrases such as `more interesting` or `继续查询英语例句` do not trigger the old query.
|
||||
|
||||
The bridge invokes only the existing controlled delivery-prediction script. It
|
||||
generates the full Cartesian
|
||||
set of candidates allowed by the packaged Skill: brake upgrades, encoder
|
||||
upgrades, sensor upgrades, low-temperature grease upgrades, and DZ/LF reducer
|
||||
brand variants. Strong constraints and version stay unchanged. The original
|
||||
model is queried first; valid candidates are reported in rule order, while
|
||||
missing or unsupported candidates are summarized under `不可用候选`.
|
||||
model is queried first as a baseline and does not count toward the recommendation
|
||||
batch. The first reply queries at most four candidate models; a valid continuation
|
||||
queries the next four without repeating earlier candidates or the original
|
||||
baseline. Valid candidates are reported in rule order, while missing or
|
||||
unsupported candidates are summarized under `不可用候选`.
|
||||
|
||||
On a managed network, the bridge uses the macOS system trust store. The proxy
|
||||
root certificate must be installed and trusted under the AR51-approved network-access procedure; never disable TLS
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -76,6 +77,77 @@ ALLOWED_VERSIONS = {
|
||||
"170F": {"V3"},
|
||||
"170H": {"V3"},
|
||||
}
|
||||
CANDIDATE_BATCH_SIZE = 4
|
||||
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
||||
CANDIDATE_CONTINUATION_SHORT_PHRASES = {
|
||||
"还有吗",
|
||||
"还有呢",
|
||||
"还有没有",
|
||||
"有没有",
|
||||
"有吗",
|
||||
"其他呢",
|
||||
"其他的呢",
|
||||
"别的呢",
|
||||
"剩下的呢",
|
||||
"余下的呢",
|
||||
"后面的呢",
|
||||
"继续",
|
||||
"继续吧",
|
||||
"继续看",
|
||||
"继续看看",
|
||||
"继续推荐",
|
||||
"接着来",
|
||||
"接着看",
|
||||
"再来几个",
|
||||
"再给几个",
|
||||
"再给我几个",
|
||||
"再推荐几个",
|
||||
"再查几个",
|
||||
"再看几个",
|
||||
"再看看",
|
||||
"多来几个",
|
||||
"多推荐几个",
|
||||
"推荐更多",
|
||||
"更多",
|
||||
"下一批",
|
||||
"下一组",
|
||||
"换一批",
|
||||
"再来一批",
|
||||
"下一个",
|
||||
"往下看",
|
||||
}
|
||||
CANDIDATE_CONTINUATION_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:还有没有|还有|是否还有|有没有|有无)"
|
||||
r"(?:其他|别的|更多|剩余|余下|后续|类似)?(?:的)?"
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案|推荐)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:继续|接着|再|多)(?:推荐|查询|查看|展示|看看|看|给|来).{0,6}"
|
||||
r"(?:其他|别的|更多|剩余|余下|后续)?(?:的)?"
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)"
|
||||
r".{0,6}(?:还有|有吗|更多|其他|别的|剩余|余下|继续|再来)"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:还有|有没有)(?:别的|其他|更多|新的|剩余|余下|类似)"
|
||||
r"(?:的)?(?:吗|呢|有吗)?$"
|
||||
),
|
||||
re.compile(r"(?:还有|有没有)(?:哪些|什么)(?:其他)?(?:候选|型号|方案|选择|选项|推荐)?$"),
|
||||
re.compile(
|
||||
r"(?:可以|请|麻烦|帮我)?(?:再|多)(?:推荐|查询|查看|看|给|来)"
|
||||
r"(?:我)?(?:几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$"
|
||||
),
|
||||
re.compile(r"(?:下一批|下一组)(?:候选|型号|方案|选择|选项)?$"),
|
||||
re.compile(r"(?:换|再来|再给我)(?:一批|一组|几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$"),
|
||||
re.compile(r"(?:more|other|remaining|next)(?:candidates?|models?|options?|alternatives?|batch)"),
|
||||
re.compile(
|
||||
r"(?:continue|showme|giveme|recommend)(?:more|other|remaining)?"
|
||||
r"(?:candidates?|models?|options?|alternatives?)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -84,6 +156,14 @@ class RequestInput:
|
||||
quantity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateContinuation:
|
||||
request: RequestInput
|
||||
next_offset: int
|
||||
total_candidates: int
|
||||
expires_at: float
|
||||
|
||||
|
||||
class InputError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -92,6 +172,10 @@ class CandidateUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
||||
_candidate_continuation_lock = threading.Lock()
|
||||
|
||||
|
||||
def load_dotenv() -> None:
|
||||
"""Load simple KEY=VALUE pairs from the ignored local .env file."""
|
||||
env_file = PROJECT_ROOT / ".env"
|
||||
@@ -166,6 +250,69 @@ def parse_trigger_request(text: str) -> RequestInput | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_candidate_continuation_request(text: str) -> bool:
|
||||
normalized_text = unicodedata.normalize("NFKC", text).lower()
|
||||
compact_text = re.sub(r"[\s,。!?、;:,.!?;:]+", "", normalized_text)
|
||||
compact_text = re.sub(r"^(?:@?_user_\d+)+", "", compact_text)
|
||||
if compact_text in CANDIDATE_CONTINUATION_SHORT_PHRASES:
|
||||
return True
|
||||
return any(pattern.search(compact_text) for pattern in CANDIDATE_CONTINUATION_PATTERNS)
|
||||
|
||||
|
||||
def conversation_key(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None:
|
||||
event = getattr(data, "event", None)
|
||||
message = getattr(event, "message", None)
|
||||
sender = getattr(event, "sender", None)
|
||||
sender_id = getattr(sender, "sender_id", None)
|
||||
chat_id = getattr(message, "chat_id", None)
|
||||
open_id = getattr(sender_id, "open_id", None)
|
||||
if not isinstance(chat_id, str) or not chat_id:
|
||||
return None
|
||||
if not isinstance(open_id, str) or not open_id:
|
||||
return None
|
||||
return f"{chat_id}:{open_id}"
|
||||
|
||||
|
||||
def clear_candidate_continuation(key: str | None) -> None:
|
||||
if not key:
|
||||
return
|
||||
with _candidate_continuation_lock:
|
||||
_candidate_continuations.pop(key, None)
|
||||
|
||||
|
||||
def remember_candidate_continuation(
|
||||
key: str | None,
|
||||
request: RequestInput,
|
||||
next_offset: int,
|
||||
total_candidates: int,
|
||||
) -> None:
|
||||
if not key:
|
||||
return
|
||||
with _candidate_continuation_lock:
|
||||
if next_offset >= total_candidates:
|
||||
_candidate_continuations.pop(key, None)
|
||||
return
|
||||
_candidate_continuations[key] = CandidateContinuation(
|
||||
request=request,
|
||||
next_offset=next_offset,
|
||||
total_candidates=total_candidates,
|
||||
expires_at=time.monotonic() + CANDIDATE_CONTINUATION_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def get_candidate_continuation(key: str | None) -> CandidateContinuation | None:
|
||||
if not key:
|
||||
return None
|
||||
with _candidate_continuation_lock:
|
||||
state = _candidate_continuations.get(key)
|
||||
if state is None:
|
||||
return None
|
||||
if state.expires_at <= time.monotonic():
|
||||
_candidate_continuations.pop(key, None)
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def build_model(fields: dict[str, str]) -> str:
|
||||
closing_bracket = "]" if fields["bracket"] == "[" else ")"
|
||||
return (
|
||||
@@ -663,15 +810,29 @@ def write_record(
|
||||
file.write(record)
|
||||
|
||||
|
||||
def build_report(request: RequestInput) -> str:
|
||||
def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
||||
if candidate_offset < 0:
|
||||
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
||||
|
||||
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 = [
|
||||
candidates.append(
|
||||
{
|
||||
"index": 1,
|
||||
"record_order": 1,
|
||||
"model": request.model,
|
||||
"type": "原型号,DZ" if "[" in request.model else "原型号,LF",
|
||||
"details": baseline,
|
||||
@@ -679,9 +840,9 @@ def build_report(request: RequestInput) -> str:
|
||||
"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,7 +852,10 @@ 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:
|
||||
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
|
||||
}
|
||||
@@ -702,29 +866,63 @@ def build_report(request: RequestInput) -> str:
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user