diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b110f..7fb6fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v4.6.32 - 2026-08-15 + +- Separated live serving availability from deployment-lock identity so an + unavailable inference endpoint is reported as a configured target rather + than falsely described as currently serving. +- Aligned deterministic runtime answers, `used_model`, Support labels, health + metadata, and operational acceptance gates around the same availability + snapshot, including timeout and missing-runtime cases. + ## v4.6.31 - 2026-08-15 - Added a trusted-main-only Ascend host regression workflow backed by a diff --git a/pyproject.toml b/pyproject.toml index 009589a..57f8f23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sage-mate" -version = "4.6.31" +version = "4.6.32" description = "Sage Mate multi-profile local and hosted assistant built on SAGE and vllm-hust" readme = "README.md" license = {file = "LICENSE"} diff --git a/src/sage_faculty_twin/__init__.py b/src/sage_faculty_twin/__init__.py index ab6a8a6..eaca5ad 100644 --- a/src/sage_faculty_twin/__init__.py +++ b/src/sage_faculty_twin/__init__.py @@ -1,5 +1,5 @@ """Sage Mate application package.""" -__version__ = "4.6.31" +__version__ = "4.6.32" __all__ = ["__version__"] diff --git a/src/sage_faculty_twin/operational_acceptance.py b/src/sage_faculty_twin/operational_acceptance.py index 0b7533e..012a05a 100644 --- a/src/sage_faculty_twin/operational_acceptance.py +++ b/src/sage_faculty_twin/operational_acceptance.py @@ -60,6 +60,7 @@ class OperationalExpectedFacts: speculative_enabled: str speculative_method: str speculative_reason: str + runtime_status: str = "live" runtime_available: bool = True forbidden_facts: tuple[str, ...] = () @@ -86,7 +87,8 @@ def from_health(cls, health: dict[str, Any]) -> OperationalExpectedFacts: speculative_enabled=_text(health.get("runtime_speculative_enabled")), speculative_method=_text(health.get("runtime_speculative_method")), speculative_reason=_text(health.get("runtime_speculative_reason")), - runtime_available=status in {"live", "receipt"}, + runtime_status=status, + runtime_available=status == "live", forbidden_facts=forbidden, ) @@ -102,13 +104,13 @@ def from_dict(cls, payload: dict[str, Any]) -> OperationalExpectedFacts: return cls(**values) def required_aliases(self) -> dict[str, tuple[str, ...]]: - if not self.runtime_available: + if self.runtime_status == "unknown": return { "uncertainty": ("unavailable", "unknown", "无法", "未知", "不会猜测") } ep = self.expert_parallel_enabled.lower() speculative = self.speculative_enabled.lower() - return { + aliases = { "model": _aliases(self.model), "architecture": _aliases(self.architecture), "accelerator": _aliases( @@ -135,6 +137,15 @@ def required_aliases(self) -> dict[str, tuple[str, ...]]: self.speculative_method, ), } + if not self.runtime_available: + aliases["availability"] = ( + "configured deployment target", + "not evidence that the engine is currently serving", + "部署锁定目标", + "不代表引擎当前正在提供推理", + "不可用或尚未验证", + ) + return aliases def _normalize(value: object) -> str: @@ -190,7 +201,9 @@ def evaluate_operational_response( ] support_ok = bool(runtime_hits and body.get("answer_basis")) used_model_ok = ( - body.get("used_model") == expected.model if expected.runtime_available else True + body.get("used_model") == expected.model + if expected.runtime_available + else body.get("used_model") == "runtime-identity-provider" ) route_ok = body.get("decision_mode") == "runtime_identity" timing = body.get("request_timing") or {} diff --git a/src/sage_faculty_twin/runtime_identity.py b/src/sage_faculty_twin/runtime_identity.py index f844c7d..1d4a1a5 100644 --- a/src/sage_faculty_twin/runtime_identity.py +++ b/src/sage_faculty_twin/runtime_identity.py @@ -61,6 +61,7 @@ class RuntimeIdentity: status: str source: str collected_at: str + serving_available: bool = False served_model: str = "unknown" checkpoint_family: str = "unknown" architecture: str = "unknown" @@ -107,8 +108,11 @@ def evidence_excerpt(self) -> str: if self.speculative_enabled else f"未启用(能力:{self.speculative_capability};原因:{self.speculative_reason})" ) + model_role = "served" if self.serving_available else "configured" + availability = "live" if self.serving_available else "unavailable-or-unverified" return ( - f"served model={self.served_model};checkpoint={self.checkpoint_family} / " + f"{model_role} model={self.served_model};availability={availability};" + f"checkpoint={self.checkpoint_family} / " f"{self.architecture};engine={self.engine} {self.engine_version};" f"plugin={self.plugin_version};accelerator={devices};{parallel};" f"quantization={self.quantization};execution={self.graph_mode};" @@ -125,6 +129,7 @@ def to_knowledge_hit(self) -> KnowledgeSearchHit: source_name=f"runtime:{self.source}", metadata={ "runtime_status": self.status, + "serving_available": str(self.serving_available).lower(), "collected_at": self.collected_at, "source_kind": self.source, }, @@ -224,6 +229,7 @@ def snapshot(self) -> RuntimeIdentity: status=status, source=source, collected_at=now, + serving_available=bool(live_model), served_model=served_model or "unknown", checkpoint_family=family or "unknown", architecture=architecture, @@ -376,6 +382,72 @@ def render_runtime_identity_answer(identity: RuntimeIdentity, *, english: bool = if english else "当前无法读取实时运行时身份。我不会猜测模型、硬件或引擎;请在推理服务恢复后重试。" ) + if not identity.serving_available: + configured_devices = ( + f"{identity.device_count}× {identity.accelerator_model}" + if identity.device_count + else f"unknown× {identity.accelerator_model}" + ) + configured_tp = identity.tensor_parallel_size or "unknown" + configured_dp = identity.data_parallel_size or "unknown" + configured_ep = ( + "on" + if identity.expert_parallel_enabled is True + else "off" + if identity.expert_parallel_enabled is False + else "unknown" + ) + configured_speculative = ( + f"enabled ({identity.speculative_method})" + if identity.speculative_enabled + else f"not enabled ({identity.speculative_reason})" + ) + configured_accelerator_lower = identity.accelerator_model.lower() + configured_is_ascend = any( + marker in configured_accelerator_lower + for marker in ("ascend", "910", "npu") + ) + if english: + platform_relation = ( + "the Ascend plugin maps engine execution to the NPU runtime" + if configured_is_ascend + else "the selected platform backend maps engine execution to the accelerator runtime" + ) + return ( + f"The configured deployment target is **{identity.served_model}** with " + f"{identity.engine}, but the live serving endpoint is currently unavailable " + f"or has not been verified. The recorded target describes " + f"{identity.checkpoint_family} / {identity.architecture} on {configured_devices}, " + f"with TP={configured_tp}, DP={configured_dp}, EP={configured_ep}, " + f"quantization={identity.quantization}, execution={identity.graph_mode}, and " + f"speculative decoding {configured_speculative}. In the configured architecture, " + f"SAGE orchestrates the application workflow, vLLM-HUST is the serving engine, " + f"and {platform_relation}. These are deployment configuration facts from " + f"{identity.source}, not evidence that the engine is currently serving. " + f"Collected at {identity.collected_at}." + ) + configured_speculative_zh = ( + f"已启用({identity.speculative_method})" + if identity.speculative_enabled + else f"未启用({identity.speculative_reason})" + ) + configured_platform_relation_zh = ( + "Ascend 插件把引擎执行映射到 NPU 运行时" + if configured_is_ascend + else "所选平台后端把引擎执行映射到加速器运行时" + ) + return ( + f"当前无法从实时 serving endpoint 确认推理引擎在线。部署锁定目标是 " + f"**{identity.served_model}**,引擎类型为 {identity.engine};记录的检查点族/架构为 " + f"{identity.checkpoint_family} / {identity.architecture},目标硬件配置为 {configured_devices}," + f"TP={configured_tp}、DP={configured_dp}、EP={configured_ep},量化方式为 " + f"{identity.quantization},执行模式为 {identity.graph_mode},记录中的 speculative " + f"decoding {configured_speculative_zh}。在该配置架构中,SAGE 组织应用工作流," + f"vLLM-HUST 是推理服务引擎,{configured_platform_relation_zh}。" + f"这些信息来自 {identity.source} 的部署配置," + f"不代表引擎当前正在提供推理;当前状态是不可用或尚未验证。" + f"采集时间为 {identity.collected_at}。" + ) devices = f"{identity.device_count}× {identity.accelerator_model}" if identity.device_count else "unknown" tp = identity.tensor_parallel_size or "unknown" dp = identity.data_parallel_size or "unknown" diff --git a/src/sage_faculty_twin/service.py b/src/sage_faculty_twin/service.py index aea1d9d..e00039d 100644 --- a/src/sage_faculty_twin/service.py +++ b/src/sage_faculty_twin/service.py @@ -1363,8 +1363,10 @@ def retrieve_knowledge(self, context: ChatWorkflowContext) -> ChatWorkflowContex receipt_hit = self._deployment_receipt_store.knowledge_hit() if receipt_hit is not None: context.knowledge_hits.append(receipt_hit) - if identity.served_model != "unknown": + if identity.serving_available and identity.served_model != "unknown": context.used_model = identity.served_model + else: + context.used_model = "runtime-identity-provider" self._append_trace( context, key="knowledge_retrieve", @@ -6500,8 +6502,13 @@ def _build_attachment_artifact_hits(self, request: ChatRequest) -> list[Conversa def _build_knowledge_basis_item(self, hit: KnowledgeSearchHit) -> AnswerBasisItem: if "runtime" in {tag.lower() for tag in hit.tags}: collected_at = hit.metadata.get("collected_at", "unknown") + serving_available = ( + str(hit.metadata.get("serving_available") or "").lower() == "true" + ) return AnswerBasisItem( - basis_label="实时运行状态", + basis_label=( + "实时运行状态" if serving_available else "部署配置与可用性" + ), title=self._clip_basis_text(hit.title, 256), source_label=self._clip_basis_text( f"{hit.source_name or 'runtime'} · {collected_at}", 256 @@ -10229,6 +10236,9 @@ def health(self) -> dict[str, str]: "runtime_identity_status": runtime_identity.status, "runtime_identity_source": runtime_identity.source, "runtime_identity_collected_at": runtime_identity.collected_at, + "runtime_serving_available": str( + runtime_identity.serving_available + ).lower(), "runtime_checkpoint_family": runtime_identity.checkpoint_family, "runtime_architecture": runtime_identity.architecture, "runtime_engine": runtime_identity.engine, diff --git a/tests/test_deployment_receipts.py b/tests/test_deployment_receipts.py index 1a6fde5..77a393b 100644 --- a/tests/test_deployment_receipts.py +++ b/tests/test_deployment_receipts.py @@ -197,6 +197,7 @@ def test_runtime_identity_precedence_live_receipt_lock(tmp_path: Path) -> None: receipt_identity = provider.snapshot() assert receipt_identity.source == "deployment-receipt" assert receipt_identity.served_model == "receipt-model" + assert receipt_identity.serving_available is False assert receipt_identity.tensor_parallel_size == 4 assert receipt_identity.checkpoint_family == "deepseek_v4" live["value"] = "live-model" @@ -249,7 +250,8 @@ def test_runtime_question_cites_receipt_and_maintenance_reports_sync( receipt_hits = [ hit for hit in response.knowledge_hits if "deployment-receipt" in hit.tags ] - assert response.used_model == "deepseek/citation-fixture" + assert response.used_model == "runtime-identity-provider" + assert "不代表引擎当前正在提供推理" in response.answer assert ( receipt_hits and receipt_hits[0].metadata["receipt_id"] == receipt["receipt_id"] ) diff --git a/tests/test_operational_acceptance.py b/tests/test_operational_acceptance.py index 7f6e9da..ca0b4bb 100644 --- a/tests/test_operational_acceptance.py +++ b/tests/test_operational_acceptance.py @@ -76,10 +76,20 @@ def _body(expected: OperationalExpectedFacts, *, answer: str | None = None) -> d f"quantization {expected.quantization}; execution {expected.graph_mode}; " f"speculative decoding {'enabled' if enabled else 'not enabled'} " f"({expected.speculative_method})." + + ( + " This is the configured deployment target and is not evidence that the engine " + "is currently serving." + if not expected.runtime_available + else "" + ) ) return { "answer": rendered, - "used_model": expected.model, + "used_model": ( + expected.model + if expected.runtime_available + else "runtime-identity-provider" + ), "decision_mode": "runtime_identity", "knowledge_hits": [ { @@ -127,6 +137,7 @@ def test_production_renderer_passes_independent_fixture_gate(health: dict) -> No source="fixture", collected_at="2026-08-15T00:00:00+00:00", served_model=expected.model, + serving_available=expected.runtime_available, checkpoint_family="fixture-family", architecture=expected.architecture, accelerator_model=expected.accelerator, @@ -190,7 +201,7 @@ def test_no_runtime_requires_explicit_uncertainty_not_gpu_guess() -> None: {"runtime_identity_status": "unknown"} ) body = _body(expected, answer="当前运行时身份无法读取,我不会猜测模型和硬件。") - body["used_model"] = "configured-client-model" + body["used_model"] = "runtime-identity-provider" result = evaluate_operational_response( question="当前模型是什么?", expected=expected, diff --git a/tests/test_runtime_identity.py b/tests/test_runtime_identity.py index fde1a99..71d302a 100644 --- a/tests/test_runtime_identity.py +++ b/tests/test_runtime_identity.py @@ -197,7 +197,35 @@ def test_deployment_receipt_is_used_when_live_probe_fails(tmp_path: Path) -> Non assert identity.status == "receipt" assert identity.source == "deployment-lock" assert identity.served_model == "receipt-model" + assert identity.serving_available is False assert identity.device_count == 4 + answer = render_runtime_identity_answer(identity) + assert "部署锁定目标" in answer + assert "不代表引擎当前正在提供推理" in answer + + +def test_probe_error_keeps_configuration_but_marks_serving_unavailable( + tmp_path: Path, +) -> None: + (tmp_path / "engine-deployment.lock.env").write_text( + "VLLM_ENGINE_SERVED_MODEL_NAME=locked-model\n" + "VLLM_ENGINE_NPU_DEVICES=0\\,1\n", + encoding="utf-8", + ) + provider = RuntimeIdentityProvider( + AppSettings(runtime_dir=tmp_path), + model_probe=lambda: (_ for _ in ()).throw(TimeoutError("probe timed out")), + versions_provider=lambda: {}, + hardware_provider=lambda: {}, + environ={}, + ) + + identity = provider.snapshot() + + assert identity.status == "receipt" + assert identity.serving_available is False + assert identity.served_model == "locked-model" + assert "当前无法从实时 serving endpoint 确认" in render_runtime_identity_answer(identity) def test_unknown_runtime_never_guesses_cuda_or_gpu(tmp_path: Path) -> None: @@ -207,6 +235,7 @@ def test_unknown_runtime_never_guesses_cuda_or_gpu(tmp_path: Path) -> None: answer = render_runtime_identity_answer(identity) assert identity.status == "unknown" + assert identity.serving_available is False assert "不会猜测" in answer assert "CUDA" not in answer assert "GPU" not in answer @@ -279,4 +308,59 @@ def test_end_to_end_chat_uses_runtime_evidence_without_llm_facts(tmp_path: Path) health = service.health() assert health["model_name"] == response.used_model assert health["runtime_device_count"] == "8" + assert health["runtime_serving_available"] == "true" assert str(tmp_path) not in json.dumps(response.model_dump(), ensure_ascii=False) + + +def test_end_to_end_receipt_answer_does_not_claim_live_serving(tmp_path: Path) -> None: + (tmp_path / "engine-deployment.lock.env").write_text( + "VLLM_ENGINE_SERVED_MODEL_NAME=locked-model\n" + "VLLM_ENGINE_NPU_DEVICES=0\\,1\n" + "VLLM_ENGINE_TP_SIZE=2\n", + encoding="utf-8", + ) + settings = AppSettings( + runtime_dir=tmp_path, + knowledge_base_dir=tmp_path / "knowledge", + conversation_memory_dir=tmp_path / "memory", + chat_runtime_pipeline_enabled=False, + ) + service = DigitalTwinService(settings) + service._runtime_identity_provider = RuntimeIdentityProvider( + settings, + model_probe=lambda: "", + versions_provider=lambda: {}, + hardware_provider=lambda: {}, + environ={}, + ) + service._llm_client.classify_interaction_intent_sync = lambda *_args, **_kwargs: ( + InteractionIntent( + action="answer", + domain="general", + retrieval_scopes=[], + exclude_scopes=[], + decision_mode="direct_answer", + confidence=1.0, + ) + ) + + response = asyncio.run( + service.answer_in_process( + ChatRequest( + student_name="guest", + question="当前运行的模型和 NPU 信息是什么?", + visitor_profile="general_visitor", + ) + ) + ) + + assert response.used_model == "runtime-identity-provider" + assert "部署锁定目标" in response.answer + assert "不代表引擎当前正在提供推理" in response.answer + assert "当前后端实际提供" not in response.answer + assert response.answer_basis[0].basis_label == "部署配置与可用性" + assert response.knowledge_hits[0].metadata["serving_available"] == "false" + health = service.health() + assert health["model_name"] == "locked-model" + assert health["runtime_identity_status"] == "receipt" + assert health["runtime_serving_available"] == "false" diff --git a/uv.lock b/uv.lock index e00ba58..13f107e 100644 --- a/uv.lock +++ b/uv.lock @@ -2613,7 +2613,7 @@ wheels = [ [[package]] name = "sage-mate" -version = "4.6.31" +version = "4.6.32" source = { editable = "." } dependencies = [ { name = "cloudpickle" },