diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index 092bb90..0eea7af 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -17,6 +17,8 @@ Expected: - original demand 10 remains visible and each model is queried exactly once with quantity 10; stock accounting is handled by the API, with no local inventory deduction, yield adjustment or batch rounding; - the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the valid trigger is accepted and before the API query starts; - the controlled script uses the macOS system trust store and keeps TLS verification enabled; +- the controlled script defaults to a direct HTTPS connection to `api.zeroerr-agent.com`, ignoring system/environment proxies for this API only; it does not change Feishu or other applications' proxy settings; +- an explicit `ZEROERR_AGENT_PROXY_MODE=system` uses the configured proxies; an invalid mode or a non-approved direct target stops before the request, and a failed request does not trigger an automatic proxy fallback or retry; - no temporary TLS-bypass parameter is required for the normal happy path; - API script runs; - output does not expose API key; @@ -25,6 +27,7 @@ Expected: - output omits the `候选明细` heading and does not repeat a human-review line under each candidate; - model differences use the compact `字段 原值→新值` format and join multiple changes with `;`; - Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold; +- in initial and continuation replies, the `X个工作日` and final delivery date values are bold in both `最快候选交期` and each model's `AM516预测交期`, using native Feishu rich-text styles without displaying Markdown asterisks; - the judgment level and fixed disclaimer provide the global human-review boundary; - inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details; - each usable model includes `缺料物料明细` listing only shortage names, preserving order and deduplicating names; use the API's `物料名称` / `缺料物料名称` first, then resolve `缺料物料` through `data/material_names.json`; the same behavior applies to continuation replies; @@ -244,7 +247,7 @@ python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query Expected: - the script creates an unverified SSL context only because the flag is explicitly present; -- the context is passed to the single controlled `urlopen` call; +- the context is passed to the request-local HTTPS handler used by the single controlled `opener.open` call; - the result does not expose the API Key; - a successful response remains an internal candidate and requires human review; - the temporary result does not remove the long-term certificate-repair requirement. @@ -275,5 +278,5 @@ Expected: - D=8/S=5: send 8; D=24/S=6: send 24; D=24/S=0: send 24; D=24/S>=24: still send 24 once and preserve the API response. - Different candidates display their own stock, but all use unchanged original demand. Initial and continuation batches query each current-batch model once. - Missing/invalid stock is unknown, never zero; valid API workdays/date remain usable with an explicit inventory-review item. Missing/invalid workdays/date or failed requests retain existing error handling. -- New report and record metadata contain actual API quantities, with no local net-demand or production-quantity fields. Historical records and existing candidate-table columns remain unchanged. +- Initial and continuation Feishu reports show demand quantity, lead time and inventory coverage, but omit `数量传递规则`, `库存覆盖口径` and per-model `API查询数量`. Internal record metadata still contains actual API quantities and quantity rules. No local net-demand or production-quantity fields are shown. Historical records and existing candidate-table columns remain unchanged. - These checks use mocked responses locally. Runtime acceptance must verify actual API arguments and compare the displayed workdays/date with the same returned response; mocked dates do not constitute delivery predictions. diff --git a/capabilities/am516-delivery-prediction/docs/production_quantity.md b/capabilities/am516-delivery-prediction/docs/production_quantity.md index a871e37..9a46910 100644 --- a/capabilities/am516-delivery-prediction/docs/production_quantity.md +++ b/capabilities/am516-delivery-prediction/docs/production_quantity.md @@ -25,7 +25,7 @@ API quantity = D 库存缺失或无效时显示待复核,不视作 0;如果 API 已返回有效工作日交期和最终交货日期,机器人保留该交期并标明库存覆盖无法判断。缺失有效交期字段或请求失败时继续执行原有异常处理。 -报告展示需求数量、API 查询数量、API 返回交期和库存覆盖情况,不再输出本地净需求或排产测算数量。新记录保存原始需求及每个型号实际 API 查询数量,历史记录不改写。 +飞书报告展示需求数量、API 返回交期和库存覆盖情况。按 AR51 于 2026-10-16 的显示要求,首次及续查回复均不显示“数量传递规则”“库存覆盖口径”和各型号下的“API查询数量”,也不输出本地净需求或排产测算数量。内部记录仍保存数量规则、原始需求及每个型号实际 API 查询数量,历史记录不改写。 ## 已取消的旧逻辑 diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index d3f2092..86bdc88 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -39,6 +39,28 @@ python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query Do not run this check until AR51 confirms the functional test and creates a separate trusted approval record explicitly covering `api.zeroerr-agent.com`, the AM516 use case, and API-key use. +## API Network Route + +The controlled query script defaults to `ZEROERR_AGENT_PROXY_MODE=direct` for +`https://api.zeroerr-agent.com`. It uses a request-local opener with an empty +proxy map, so this API does not inherit macOS or environment proxy settings. +Feishu, Codex and other applications retain their existing network settings. +The bridge launches the script for each query, so script updates take effect on +the next query without restarting the bridge or clearing continuation context. + +On 2026-10-16, the proxy log for the 08:51 failed query showed a default-proxy +connection timeout to this API. AR51-approved comparison queries with the fixed +sample both succeeded later: system proxy 20.04 seconds, direct 4.79 seconds. +These are individual observations, not an availability or latency guarantee. + +If another approved runtime requires a company proxy, configure +`ZEROERR_AGENT_PROXY_MODE=system` in that runtime's environment (or the bridge's +local `.env`, followed by a bridge restart). This selects the configured proxy +route explicitly. It does not authorize a new destination. Invalid modes and +direct requests outside the approved HTTPS hostname stop before the request. +There is no automatic proxy fallback or extra API retry, and TLS certificate +verification remains enabled. + ## Normal TLS Trust Configuration The controlled query script injects `truststore` before making the request. Normal queries therefore keep TLS certificate verification enabled while using the macOS system trust store, including an AR51-approved company proxy root certificate installed and trusted in the system keychain. diff --git a/capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py b/capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py index d6c553a..f27430d 100644 --- a/capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py +++ b/capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py @@ -17,7 +17,9 @@ truststore.inject_into_ssl() DEFAULT_BASE_URL = os.getenv("ZEROERR_AGENT_BASE_URL", "https://api.zeroerr-agent.com").rstrip("/") DEFAULT_ENDPOINT = "/api/SaleAgent/DeliveryPrediction/WithTransit" -APPROVED_SKIP_TLS_HOSTNAME = "api.zeroerr-agent.com" +APPROVED_API_HOSTNAME = "api.zeroerr-agent.com" +APPROVED_SKIP_TLS_HOSTNAME = APPROVED_API_HOSTNAME +PROXY_MODE_ENV = "ZEROERR_AGENT_PROXY_MODE" API_KEY_ENV = "ZEROERR_AGENT_API_KEY" PROJECT_ROOT = Path(__file__).resolve().parents[3] LOCAL_API_KEY_FILE = PROJECT_ROOT / "configs" / "delivery_api_key.local" @@ -66,6 +68,23 @@ def get_api_key() -> str: return "" +def build_api_opener(url: str, *, skip_tls_verify: bool = False) -> urllib.request.OpenerDirector: + """Keep the approved API independent of the desktop's changing proxy route.""" + mode = os.getenv(PROXY_MODE_ENV, "direct").strip().lower() + if mode not in {"direct", "system"}: + raise ValueError("invalid API proxy mode: expected direct or system") + target = urllib.parse.urlsplit(url) + if mode == "direct" and ( + target.scheme != "https" or target.hostname != APPROVED_API_HOSTNAME + ): + raise ValueError("direct API connection requires the approved HTTPS hostname") + # An explicit empty proxy map disables both environment and macOS proxies + # for this opener only. Never alter the process-wide urllib opener or env. + proxy = urllib.request.ProxyHandler({}) if mode == "direct" else urllib.request.ProxyHandler() + context = ssl._create_unverified_context() if skip_tls_verify else ssl.create_default_context() + return urllib.request.build_opener(proxy, urllib.request.HTTPSHandler(context=context)) + + def main() -> int: args = parse_args() product = args.product.strip() @@ -110,9 +129,13 @@ def main() -> int: } request = urllib.request.Request(url, headers=headers, method="GET") - context = ssl._create_unverified_context() if args.skip_tls_verify else None try: - with urllib.request.urlopen(request, timeout=30, context=context) as response: + opener = build_api_opener(url, skip_tls_verify=args.skip_tls_verify) + except ValueError as exc: + emit_error(str(exc)) + return 2 + try: + with opener.open(request, timeout=30) as response: raw_body = response.read().decode("utf-8") except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") 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 f4f2bbb..bc9460a 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 @@ -47,7 +47,7 @@ Stop if: - Inventory accounting and delivery calculation belong to the upstream API. Do not calculate a local production quantity, send a reduced or inflated quantity, or issue a second production query. - Display each model's own returned inventory coverage against original demand. A displayed inventory gap is informational and must not become a new API quantity. Missing or invalid stock means unknown coverage, never zero; do not discard valid API workdays/date solely because inventory display is unavailable. - Remove the obsolete partial-stock/production-quantity shortcut rejection. API failures and missing or invalid workdays/date retain existing error handling. -- Replies show original demand and actual API query quantity per model, without local net-demand or production-quantity fields. New records preserve the same actual API quantities; leave historical records unchanged. +- Replies show original demand per model. Hide the technical explanation lines `数量传递规则`, `库存覆盖口径` and `API查询数量` in both initial and continuation replies, along with the obsolete local net-demand or production-quantity fields. New internal records preserve the actual API quantities and quantity rules for traceability; leave historical records unchanged. ## Feishu Message Trigger Gate @@ -179,8 +179,6 @@ Use this field order for a successful Feishu report: 关节模组交期预估报告 | {generation time} 输入需求:{input model},{input quantity}台 -数量传递规则:原始需求原样传入API,库存及交期测算由API处理 -库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况 数据时间戳:{API data timestamp or generation time} 状态:试运行版输出 / 内部候选 判断等级:AM516算法预测参考,需人工复核 @@ -195,7 +193,6 @@ Use this field order for a successful Feishu report: 1. {model}({candidate type and reducer brand}) - 需求数量:{input quantity}台 -- API查询数量:{original demand}台 - AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date} - 可用库存/覆盖情况:{summary-level inventory and coverage only} - 主导因素:{main factor} @@ -219,6 +216,12 @@ Use Feishu rich-text `post` output. Bold only: - each numbered candidate heading; - `不可用候选:`. +Also bold the `X个工作日` value and the final delivery date (`YYYY-MM-DD`) +in `最快候选交期` and each model's `AM516预测交期`, for both initial and +continuation replies. Use native Feishu text-element `style: ["bold"]`, keeping +the field labels and separating punctuation in normal weight. Dates in the +report title or data timestamp are not included in this emphasis. + 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 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. diff --git a/configs/env.example b/configs/env.example index 8c05716..0740a2b 100644 --- a/configs/env.example +++ b/configs/env.example @@ -3,6 +3,10 @@ ZEROERR_AGENT_API_KEY=replace_with_approved_key ZEROERR_AGENT_BASE_URL=https://api.zeroerr-agent.com +# Only this controlled API script uses the setting; Feishu and desktop apps keep their own routes. +# direct (default): approved HTTPS API host without system/environment proxies. +# system: use the runtime's configured proxies if an approved network requires them. +ZEROERR_AGENT_PROXY_MODE=direct AM516_RECORD_FILE=capabilities/am516-delivery-prediction/records/delivery_prediction_records.md # Feishu custom-app credentials. Keep real values in the local .env file only. diff --git a/shared/runtime/README.md b/shared/runtime/README.md index 0e97c26..cda1be5 100644 --- a/shared/runtime/README.md +++ b/shared/runtime/README.md @@ -60,7 +60,13 @@ continuation wording is flexible: `还有吗`, `有没有别的`, `再推荐几 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 +The bridge invokes only the existing controlled delivery-prediction script. +That script defaults to direct HTTPS for `api.zeroerr-agent.com` without +inheriting desktop proxy settings, while keeping certificate verification on. +`ZEROERR_AGENT_PROXY_MODE=system` explicitly restores the configured proxy route +for approved networks that require it. This setting affects only the API script, +not the Feishu connection. See the capability runbook for route diagnostics. +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 diff --git a/shared/runtime/feishu_bot_bridge.py b/shared/runtime/feishu_bot_bridge.py index 7ec74f6..f30869d 100644 --- a/shared/runtime/feishu_bot_bridge.py +++ b/shared/runtime/feishu_bot_bridge.py @@ -837,7 +837,6 @@ def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str return [ f"{candidate['index']}. {candidate['model']}({candidate['type']})", f"- 需求数量:{quantity}台", - f"- API查询数量:{details['api_quantity']}台", f"- {details['prediction']}", f"- 可用库存/覆盖情况:{details['inventory']}", f"- 主导因素:{details['main_factor']}", @@ -1114,8 +1113,6 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: ), "", f"输入需求:{request.model},{request.quantity}台", - "数量传递规则:原始需求原样传入API,库存及交期测算由API处理", - "库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况", f"数据时间戳:{data_timestamp}", "状态:试运行版输出 / 内部候选", "判断等级:AM516算法预测参考,需人工复核", @@ -1157,6 +1154,19 @@ def message_text(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None: def post_paragraph(line: str) -> list[dict[str, Any]]: + lead_time = re.fullmatch( + r"(- (?:最快候选交期|AM516预测交期):)(\d+个工作日)" + r"(,最终交货日期:)(\d{4}-\d{2}-\d{2})", + line, + ) + if lead_time: + label, workdays, date_label, delivery_date = lead_time.groups() + return [ + {"tag": "text", "text": label}, + {"tag": "text", "text": workdays, "style": ["bold"]}, + {"tag": "text", "text": date_label}, + {"tag": "text", "text": delivery_date, "style": ["bold"]}, + ] element: dict[str, Any] = {"tag": "text", "text": line or " "} if ( line in {"总体判断:", "本批次判断:", "不可用候选:"} diff --git a/tests/test_delivery_prediction_network.py b/tests/test_delivery_prediction_network.py new file mode 100644 index 0000000..42b6dd2 --- /dev/null +++ b/tests/test_delivery_prediction_network.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import contextlib +import http.client +import importlib.util +import io +import json +import os +from pathlib import Path +import ssl +from types import SimpleNamespace +import unittest +from unittest import mock +import urllib.parse +import urllib.request + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py" +) +spec = importlib.util.spec_from_file_location("delivery_query", SCRIPT) +query = importlib.util.module_from_spec(spec) +spec.loader.exec_module(query) + +API_URL = "https://api.zeroerr-agent.com/api/SaleAgent/DeliveryPrediction/WithTransit" + + +class DeliveryNetworkTests(unittest.TestCase): + def setUp(self) -> None: + self.env = mock.patch.dict(os.environ, {}, clear=True) + self.env.start() + self.addCleanup(self.env.stop) + self.proxies = mock.patch.object( + urllib.request, "getproxies", return_value={"https": "http://127.0.0.1:7897"} + ) + self.getproxies = self.proxies.start() + self.addCleanup(self.proxies.stop) + connection_patch = mock.patch.object(http.client, "HTTPSConnection") + self.connection = connection_patch.start() + self.addCleanup(connection_patch.stop) + response = self.connection.return_value.getresponse.return_value + response.status = response.code = 200 + response.reason = "OK" + response.headers = {} + response.read.return_value = b'{"success": true, "data": {}}' + response.__enter__.return_value = response + + def test_default_direct_ignores_system_and_environment_proxy(self) -> None: + os.environ["https_proxy"] = "http://127.0.0.1:9999" + before = dict(os.environ) + global_opener = urllib.request._opener + + query.build_api_opener(API_URL).open(API_URL, timeout=30) + + self.assertEqual(self.connection.call_args.args[0], "api.zeroerr-agent.com") + self.connection.return_value.set_tunnel.assert_not_called() + self.getproxies.assert_not_called() + self.assertEqual(dict(os.environ), before) + self.assertIs(urllib.request._opener, global_opener) + context = self.connection.call_args.kwargs["context"] + self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED) + self.assertTrue(context.check_hostname) + + def test_system_mode_explicitly_uses_configured_proxy(self) -> None: + os.environ[query.PROXY_MODE_ENV] = "system" + with mock.patch.object(urllib.request, "proxy_bypass", return_value=False): + query.build_api_opener(API_URL).open(API_URL, timeout=30) + + self.assertEqual(self.connection.call_args.args[0], "127.0.0.1:7897") + self.assertEqual( + self.connection.return_value.set_tunnel.call_args.args[0], "api.zeroerr-agent.com" + ) + self.assertEqual(self.connection.call_args.kwargs["context"].verify_mode, ssl.CERT_REQUIRED) + + def test_invalid_proxy_mode_stops_before_network_and_does_not_echo_value(self) -> None: + os.environ[query.PROXY_MODE_ENV] = "SECRET_MARKER" + with self.assertRaises(ValueError) as caught: + query.build_api_opener(API_URL) + self.assertNotIn("SECRET_MARKER", str(caught.exception)) + self.connection.assert_not_called() + + def test_direct_route_is_limited_to_approved_https_host(self) -> None: + for url in ("https://example.invalid/", "http://api.zeroerr-agent.com/", + "https://api.zeroerr-agent.com.example.invalid/"): + with self.subTest(url=url), self.assertRaises(ValueError): + query.build_api_opener(url) + self.connection.assert_not_called() + + def run_main(self, *, skip_tls_verify: bool = False) -> tuple[int, dict]: + output = io.StringIO() + with ( + mock.patch.object(query, "parse_args", return_value=SimpleNamespace( + product="eRob110H160I-FHM-18ET[V6]", quantity=10, + skip_tls_verify=skip_tls_verify, + )), + mock.patch.object(query, "get_api_key", return_value="test-key-not-a-real-secret"), + mock.patch.object(query, "DEFAULT_BASE_URL", "https://api.zeroerr-agent.com"), + contextlib.redirect_stdout(output), + ): + code = query.main() + return code, json.loads(output.getvalue()) + + def test_controlled_script_sends_original_quantity_once_over_verified_direct_route(self) -> None: + code, payload = self.run_main() + self.assertEqual(code, 0) + self.assertTrue(payload["success"]) + self.assertEqual(self.connection.call_args.args[0], "api.zeroerr-agent.com") + request = self.connection.return_value.request + request.assert_called_once() + method, path = request.call_args.args[:2] + self.assertEqual(method, "GET") + self.assertEqual(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query), { + "product_specification": ["eRob110H160I-FHM-18ET[V6]"], "quantity": ["10"], + }) + + def test_certificate_failure_does_not_retry_or_fall_back_to_proxy(self) -> None: + self.connection.return_value.request.side_effect = ssl.SSLCertVerificationError( + 1, "CERTIFICATE_VERIFY_FAILED" + ) + code, payload = self.run_main() + self.assertEqual(code, 1) + self.assertEqual(payload["error"], "TLS certificate verification failed") + self.connection.assert_called_once() + self.connection.return_value.set_tunnel.assert_not_called() + self.assertEqual(self.connection.call_args.kwargs["context"].verify_mode, ssl.CERT_REQUIRED) + + +if __name__ == "__main__": + unittest.main()