| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- """结果平台独立性、业务编码、固定报文重试和 SQLite 幂等接收。"""
- from datetime import date, datetime
- import json
- import httpx
- import pytest
- from algorithms.main_fan.models import Evidence, PerformancePoint, PerformanceReport
- from db import business_store as store, db_conn
- from extensions.main_fan import service
- from extensions.main_fan.result_output import report_text
- from extensions.result_platform.client import ResultPlatformClient, ResultPlatformError
- from extensions.result_platform.settings import ResultPlatformSettings
- from mock_gateway.operating_data import load_operating_data
- from mock_result_platform.app import LOCAL_TOKEN, ResultStore, create_app
- from runtime.business_tasks import BusinessTaskController, submit
- from runtime.engine import ExecutionResult
- from runtime.result_models import PlatformResultPayload
- from pydantic import ValidationError
- def complete_data(*, after_flow=None, angle=35):
- # 完整的测试输入;不依赖外部模型、原件缓存或既有研判结论。
- incoming = load_operating_data(db_conn.config.ROOT / "examples/main_fan")
- data = dict(mine_code="mock-zy", mine_name="陕西众源煤业集团有限公司",
- fan_id="mock-report-fan-1", fan_name="1号通风机", fan_pair=["mock-report-fan-1", "mock-report-fan-2"],
- evaluation_time="2026-10-14T11:00:00+08:00", use_gateway=False,
- **{key: incoming[key] for key in ("devices", "fan_state_records", "operating_series")})
- for device in data["devices"]:
- device.update(fan_role="standby" if device["device_code"].endswith("-1") else "in_service",
- availability_status="available", observed_at=data["evaluation_time"])
- reports = []
- for number in (1, 2):
- evidence = Evidence(source=f"/workspace/fan-{number}.pdf", method="synthetic", page=1)
- points = [PerformancePoint(label="运行工况" if index == 1 else f"测点{index}",
- frequency_hz=50, flow_m3_s=q, static_pressure_pa=500, shaft_power_kw=80,
- reported_efficiency_pct=q*500/800, evidence=evidence) for index, q in enumerate((40, 50, 60, 70))]
- reports.append(PerformanceReport(report_id=f"R-{number}", mine_code=data["mine_code"], mine_name=data["mine_name"],
- fan_name=f"{number}号通风机", model="FBCDZ-No24", rated_power_raw="2×185kW", blade_angle_deg=angle,
- inspected_on=date(2026, 10, 14), evidence=evidence, points=points).model_dump(mode="json"))
- data["reports"] = reports
- if after_flow is not None:
- for series in data["operating_series"]:
- if series["fan_id"].endswith("-2"):
- for point in series["samples"]:
- if point["value"] > 3000:
- point["value"] = after_flow
- return data
- def settings(url="http://results.test/api/judgments", **kwargs):
- return ResultPlatformSettings(url=url, token=LOCAL_TOKEN, **kwargs)
- async def result_payload():
- result = await service.analyze(complete_data(), "test", "session")
- return result["platform_payload"]
- @pytest.mark.asyncio
- async def test_complete_case_emits_the_agreed_normal_schema_and_whole_report():
- result = await service.analyze(complete_data(), "normal", "session")
- payload = result["platform_payload"]
- assert set(payload) == {"agentCode", "agentName", "taskId", "checkedAt", "mineCode", "mineName", "objectDetail", "dataDetail", "judgment"}
- assert set(payload["judgment"]) == {"resultCode", "result", "reason", "evidenceLink"}
- assert payload["judgment"]["resultCode"] == "NORMAL"
- assert "NORMAL" in result["result"]
- assert all(name in payload["objectDetail"] for name in ("1号通风机", "2号通风机"))
- assert "6分钟" in payload["judgment"]["result"] and "5.00%" in payload["judgment"]["result"]
- assert "3610" in payload["dataDetail"] and "3800" in payload["dataDetail"]
- assert payload["judgment"]["reason"] == report_text(result["result"])
- assert "data:image" not in payload["judgment"]["reason"]
- assert payload["judgment"]["evidenceLink"] == ""
- assert all(isinstance(value, str) for key, value in payload.items() if key != "judgment")
- assert all(isinstance(value, str) for value in payload["judgment"].values())
- restored = service.validate_result({**result, "platform_payload": {**payload, "judgment": {**payload["judgment"], "resultCode": "ABNORMAL"}}}, "normal", "session")
- assert restored == result
- @pytest.mark.asyncio
- async def test_four_codes_are_the_entire_public_contract_and_leaf_values_are_strings():
- payload = await result_payload()
- for code in ("NORMAL", "WARNING", "ABNORMAL", "UNDETERMINED"):
- assert PlatformResultPayload.model_validate({**payload, "judgment": {**payload["judgment"], "resultCode": code}}).judgment.resultCode == code
- for code in ("PARTIAL", "EXEMPTED", "Warning"):
- with pytest.raises(ValidationError):
- PlatformResultPayload.model_validate({**payload, "judgment": {**payload["judgment"], "resultCode": code}})
- with pytest.raises(ValidationError):
- PlatformResultPayload.model_validate({**payload, "mineCode": 123})
- @pytest.mark.asyncio
- async def test_start_does_not_register_or_subscribe_to_data_gateway():
- class DataOnlyGateway:
- def __getattr__(self, name):
- raise AssertionError(f"启动时不应调用取数网关:{name}")
- controller = BusinessTaskController(DataOnlyGateway())
- await controller.start()
- try:
- assert not any(task.get_name().startswith("business-gateway") for task in controller.tasks)
- finally:
- await controller.close()
- @pytest.mark.asyncio
- @pytest.mark.parametrize("change,code", [("no_angle", "NORMAL"), ("power", "WARNING"),
- ("flow", "ABNORMAL"), ("flow_warning", "WARNING"), ("severe_boundary", "ABNORMAL"), ("boundary", "NORMAL")])
- async def test_code_comes_from_actual_assessment_and_preserves_inclusive_boundaries(change, code):
- data = complete_data()
- if change == "no_angle":
- for report in data["reports"]:
- report["blade_angle_deg"] = None
- elif change == "power":
- data["reports"][1]["rated_power_raw"] = "2×200kW"
- elif change in {"flow", "flow_warning", "severe_boundary", "boundary"}:
- data = complete_data(after_flow={"flow":3040, "flow_warning":3382, "severe_boundary":3230, "boundary":3420}[change])
- result = await service.analyze(data, "code", "session")
- assert result["platform_payload"]["judgment"]["resultCode"] == code
- assert code in result["result"]
- assert "PARTIAL" not in json.dumps(result["platform_payload"])
- assert "EXEMPTED" not in json.dumps(result["platform_payload"])
- @pytest.mark.asyncio
- async def test_three_passes_are_normal_despite_unknown_metadata_ocr_and_table_graph_difference(monkeypatch):
- data = complete_data(angle=None)
- for device in data["devices"]:
- device.update(fan_role="unknown", availability_status="unknown", observed_at=None)
- point = data["reports"][0]["points"][1]
- data["reports"][0]["curves"] = [{**point, "label":"性能曲线", "static_pressure_pa":480,
- "reported_efficiency_pct":30}]
- original = service.parse_fan_evidence_documents
- async def with_ocr_notes(*args, **kwargs):
- parsed = await original(*args, **kwargs)
- parsed[1].extend(f"PDF页{page}:封面或声明页,未载明叶片角度。" for page in range(1,27))
- return parsed
- monkeypatch.setattr(service, "parse_fan_evidence_documents", with_ocr_notes)
- result = await service.analyze(data, "three-pass", "session")
- assert result["assessment"]["decision"]["items"] == dict(parameters="pass", switch_interval="pass", flow_change="pass")
- assert result["assessment"]["decision"]["result_code"] == "NORMAL"
- assert result["platform_payload"]["judgment"]["resultCode"] == "NORMAL"
- assert any(f["code"] == "document_extraction" for f in result["analysis"]["findings"])
- assert any(f["code"] == "curve_table_disagreement" for f in result["analysis"]["findings"])
- assert "检测资料一致性事项" in result["result"] and "相差20.00 Pa" in result["result"]
- assert "PDF页" not in result["result"] and "需复核" not in result["result"]
- @pytest.mark.asyncio
- async def test_supplementary_curve_and_pressure_failures_cannot_veto_three_passes():
- data = complete_data()
- for point in data["reports"][1]["points"]:
- point.update(static_pressure_pa=900, reported_efficiency_pct=point["flow_m3_s"]*900/800)
- for series in list(data["operating_series"]):
- data["operating_series"].append({**series, "metric":"static_pressure", "unit":"Pa",
- "sensor_id":series["sensor_id"]+"-pressure", "samples":[{**p, "value":500 if series["fan_id"].endswith("-1") else 1000} for p in series["samples"]]})
- result = await service.analyze(data, "supplementary", "session")
- assessment = result["assessment"]
- assert assessment["performance_comparison"]["status"] == "fail"
- assert assessment["switch_comparison"]["status"] == "fail"
- assert assessment["switch_comparison"]["events"][0]["checks"] == dict(switch_interval="pass", flow_change="pass")
- assert result["platform_payload"]["judgment"]["resultCode"] == "NORMAL"
- @pytest.mark.asyncio
- @pytest.mark.parametrize("change", ["other_parameter", "long_interval"])
- async def test_clear_minor_issue_is_warning_instead_of_abnormal(change):
- data = complete_data()
- if change == "other_parameter":
- for number, report in enumerate(data["reports"]):
- report["motor_model"] = f"MOTOR-{number}"
- else:
- start = datetime.fromisoformat("2026-10-14T10:11:00+08:00")
- for record in data["fan_state_records"]:
- if record["fan_id"].endswith("-2"):
- record["state"] = "running" if datetime.fromisoformat(record["observed_at"]) >= start else "stopped"
- result = await service.analyze(data, "minor-issue", "session")
- assert result["platform_payload"]["judgment"]["resultCode"] == "WARNING"
- assert result["assessment"]["decision"]["severe_findings"] == []
- @pytest.mark.asyncio
- @pytest.mark.parametrize("change", ["unconfirmed", "missing", "conflicting", "missing_parameters"])
- async def test_invalid_core_data_is_undetermined_and_not_assumed_to_pass(change):
- data = complete_data()
- if change == "unconfirmed":
- data["operating_series"][0]["mapping_confirmed"] = False
- elif change == "missing":
- data["operating_series"][0]["samples"] = []
- elif change == "conflicting":
- series = data["operating_series"][0]
- series["samples"].extend({**p,"value":p["value"]+30} for p in list(series["samples"]))
- else:
- for report in data["reports"]:
- report["model"] = ""
- result = await service.analyze(data, "bad-core", "session")
- assert result["platform_payload"]["judgment"]["resultCode"] == "UNDETERMINED"
- assert "insufficient" in result["assessment"]["decision"]["items"].values()
- @pytest.mark.asyncio
- async def test_partial_scope_is_not_normal_and_does_not_list_absent_projects():
- data = complete_data()
- data.update(fan_state_records=[], operating_series=[], devices=[])
- result = await service.analyze(data, "scope", "session")
- payload = result["platform_payload"]
- assert payload["judgment"]["resultCode"] == "UNDETERMINED"
- assert "切换" not in payload["judgment"]["reason"]
- assert "备用状态" not in payload["dataDetail"]
- assert "开停" not in payload["dataDetail"]
- def test_report_tables_are_independent_even_after_operating_point_paragraphs():
- text = report_text("## 比较\n| 设备 | 风量 |\n| --- | ---: |\n| 1号 | 3800 |\n\n说明\n\n| 指标 | 偏差 |\n| --- | --- |\n| 静压 | 2% |\n")
- assert "设备:1号;风量:3800" in text and "指标:静压;偏差:2%" in text
- assert "base64" not in text
- @pytest.mark.asyncio
- async def test_multiple_frequency_operating_points_keep_headers_and_both_fans_in_export():
- data = complete_data()
- for report in data["reports"]:
- points = list(report["points"])
- report["points"] = [{**point, "frequency_hz": frequency} for frequency in (30, 40, 50) for point in points]
- result = await service.analyze(data, "multi-frequency", "session")
- reason = result["platform_payload"]["judgment"]["reason"]
- for frequency in (30, 40, 50):
- for number in (1, 2):
- assert f"频率与设备:{frequency} Hz {number}号通风机;风量" in reason
- @pytest.mark.asyncio
- async def test_local_result_platform_persists_deduplicates_and_rejects_changed_task(tmp_path):
- payload = await result_payload()
- storage = ResultStore(tmp_path / "received.sqlite")
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(storage)), base_url="http://results.test") as http:
- assert (await http.post("/api/judgments", json=payload)).status_code == 401
- headers = {"Authorization": f"Bearer {LOCAL_TOKEN}", "Idempotency-Key": payload["taskId"]}
- first = await http.post("/api/judgments", headers=headers, json=payload)
- again = await http.post("/api/judgments", headers=headers, json=payload)
- assert first.status_code == 200 and first.json() == again.json()
- altered = {**payload, "dataDetail": "changed"}
- assert (await http.post("/api/judgments", headers=headers, json=altered)).status_code == 409
- assert storage.count() == 1
- invalid = {**payload, "judgment": {**payload["judgment"], "resultCode": "PARTIAL"}}
- assert (await http.post("/api/judgments", headers=headers, json=invalid)).status_code == 422
- reopened = ResultStore(storage.path)
- assert reopened.count() == 1 and reopened.rows()[0]["payload"] == payload
- @pytest.mark.asyncio
- async def test_retries_read_frozen_payload_after_controller_restart_and_do_not_touch_gateway(tmp_path):
- storage = ResultStore(tmp_path / "received.sqlite")
- app = create_app(storage, fail_first=1)
- publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=app))
- calls = []
- class DataGateway:
- def __getattr__(self, name):
- raise AssertionError(f"本地资料研判和结果推送不应调用网关:{name}")
- async def executor(**kwargs):
- calls.append(kwargs)
- options = kwargs["graph_config"]["configurable"]
- result = await service.analyze(options["business_task_input"], kwargs["user_name"], kwargs["session_id"], job_id=options["business_task_id"])
- result["platform_payload"]["judgment"]["resultCode"] = "ABNORMAL"
- return ExecutionResult("run", kwargs["session_id"], "success", "", "", {}, [], [], result)
- controller = BusinessTaskController(DataGateway(), publisher=publisher, executor=executor)
- try:
- job, _ = store.enqueue("main_fan", "retry", complete_data(), [], publish=True)
- await controller.run_job(store.claim("main_fan"))
- completed = store.get(job["id"])
- before = completed["push_payload"]
- assert completed["push_status"] == "pending"
- assert json.loads(before)["judgment"]["resultCode"] == "NORMAL"
- await controller.publish_job(completed)
- failed = store.get(job["id"])
- assert failed["push_status"] == "pending" and failed["push_attempts"] == 1
- restored = BusinessTaskController(publisher=publisher, executor=executor)
- await restored.publish_job(failed)
- saved = store.get(job["id"])
- assert saved["push_status"] == "pushed"
- assert saved["push_payload"] == before
- assert json.loads(saved["receipt_json"])["data"]["taskId"] == job["id"]
- assert len(calls) == 1 and storage.count() == 1
- assert storage.rows()[0]["payload"] == json.loads(before)
- finally:
- await publisher.close()
- @pytest.mark.asyncio
- async def test_automatic_chat_delivery_only_enqueues_once_and_no_model_is_reexecuted(monkeypatch, tmp_path):
- monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "1")
- monkeypatch.setenv("RESULT_PLATFORM_URL", "http://results.test/api/judgments")
- monkeypatch.setenv("RESULT_PLATFORM_TOKEN", LOCAL_TOKEN)
- monkeypatch.setenv("MAIN_FAN_AUTO_PUBLISH", "1")
- result = await service.analyze(complete_data(), "chat", "session")
- payload = result["platform_payload"]
- job = store.get(payload["taskId"])
- assert job["source"] == "chat" and job["push_status"] == "pending"
- assert job["status"] == "completed"
- assert store.claim("main_fan") is None
- store.record_completed_result("main_fan", "chat", "session", {}, result, "http://results.test/api/judgments")
- assert len(store.list_jobs("main_fan", "chat")) == 1
- publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=create_app(ResultStore(tmp_path / "received.sqlite"))))
- try:
- await BusinessTaskController(publisher=publisher).publish_job(job)
- assert store.get(job["id"])["push_status"] == "pushed"
- finally:
- await publisher.close()
- @pytest.mark.asyncio
- async def test_changed_destination_blocks_old_pending_payload(tmp_path):
- result = await service.analyze(complete_data(), "test", "session")
- payload = result["platform_payload"]
- job = store.record_completed_result("main_fan", "test", "session", {}, result, "http://old.test/api/judgments")
- def forbidden(request):
- raise AssertionError("配置变化后不能自动向新地址发送历史结果")
- publisher = ResultPlatformClient(settings(), transport=httpx.MockTransport(forbidden))
- try:
- await BusinessTaskController(publisher=publisher).publish_job(job)
- assert store.get(job["id"])["push_status"] == "failed"
- assert json.loads(store.get(job["id"])["push_payload"])["taskId"] == payload["taskId"]
- finally:
- await publisher.close()
- def test_publish_configuration_is_independent_from_data_gateway(monkeypatch):
- monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "1")
- monkeypatch.setenv("SHANXI_GATEWAY_ENABLED", "0")
- job = submit("main_fan", complete_data(), publish=True)
- assert job["status"] == "queued"
- monkeypatch.setenv("RESULT_PLATFORM_ENABLED", "0")
- with pytest.raises(ValueError, match="结果接收平台"):
- submit("main_fan", complete_data(), publish=True)
- @pytest.mark.asyncio
- async def test_client_checks_business_receipts_and_rate_limit_without_gateway_credentials():
- payload = await result_payload()
- replies = [httpx.Response(429, headers={"Retry-After": "120"}),
- httpx.Response(200, json={"code": 500, "success": False}),
- httpx.Response(200, text="login page"), httpx.Response(302, headers={"Location": "http://elsewhere.test"}),
- httpx.Response(200, json={"code": 200, "data": {"taskId": "other"}}), httpx.Response(204)]
- def handler(request):
- assert request.headers["Authorization"] == f"Bearer {LOCAL_TOKEN}"
- assert request.url.host == "results.test"
- assert "X-Agent-Code" not in request.headers
- return replies.pop(0)
- client = ResultPlatformClient(settings(), transport=httpx.MockTransport(handler))
- try:
- with pytest.raises(ResultPlatformError) as limited:
- await client.push(payload)
- assert limited.value.retryable and limited.value.retry_after == 120
- for _ in range(4):
- with pytest.raises(ResultPlatformError):
- await client.push(payload)
- assert (await client.push(payload))["httpStatus"] == 204
- finally:
- await client.close()
- @pytest.mark.asyncio
- async def test_custom_auth_header_and_success_contract():
- payload = await result_payload()
- def handler(request):
- assert request.headers["X-API-Key"] == "result-secret"
- assert "Authorization" not in request.headers
- return httpx.Response(200, json={"status": {"value": "OK"}})
- client = ResultPlatformClient(ResultPlatformSettings(url="http://results.test/save", token="result-secret",
- auth_header="X-API-Key", auth_scheme="", success_field="status.value", success_values=("OK",)),
- transport=httpx.MockTransport(handler))
- try:
- assert (await client.push(payload))["status"]["value"] == "OK"
- finally:
- await client.close()
- @pytest.mark.asyncio
- async def test_export_and_publish_api_respect_saved_result_and_owner(monkeypatch, tmp_path):
- from main import app
- from runtime import business_tasks
- monkeypatch.setenv("BUSINESS_API_TOKEN", "business-secret")
- result = await service.analyze(complete_data(), "owner", "session")
- job = store.record_completed_result("main_fan", "owner", "session", {}, result, "http://results.test/api/judgments")
- publisher = ResultPlatformClient(settings(), transport=httpx.ASGITransport(app=create_app(ResultStore(tmp_path / "received.sqlite"))))
- monkeypatch.setattr(business_tasks, "_controller", BusinessTaskController(publisher=publisher))
- base = f"/api/business-agents/main_fan/jobs/{job['id']}"
- try:
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://agent.test") as http:
- headers = {"Authorization": "Bearer business-secret"}
- assert (await http.get(base + "/result")).status_code == 401
- assert (await http.get(base + "/result", headers=headers, params={"user_name": "other"})).status_code == 404
- exported = await http.get(base + "/result", headers=headers, params={"user_name": "owner"})
- assert exported.json() == result["platform_payload"]
- again = await http.post(base + "/publish", headers=headers, params={"user_name": "owner"})
- assert again.status_code == 202 and again.json()["push_status"] == "pending"
- analysis = await http.get(f"/api/business-agents/main_fan/analyses/{result['analysis_id']}/result", headers=headers,
- params={"session_id": "session", "user_name": "owner"})
- assert analysis.json() == result["platform_payload"]
- finally:
- await publisher.close()
|