From 90688c244d87731b542e83bc8c9ba4f39813afa7 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 1 Feb 2026 03:37:52 -0300 Subject: [PATCH] atlasbot: improve runbook and hardware snapshot --- atlasbot/engine/answerer.py | 17 +++++++++++++++++ atlasbot/llm/prompts.py | 11 +++++++++++ atlasbot/snapshot/builder.py | 25 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/atlasbot/engine/answerer.py b/atlasbot/engine/answerer.py index 005c295..575b7e3 100644 --- a/atlasbot/engine/answerer.py +++ b/atlasbot/engine/answerer.py @@ -297,6 +297,18 @@ class AnswerEngine: runbook_fix = _needs_runbook_fix(reply, runbook_paths) runbook_needed = _needs_runbook_reference(normalized, runbook_paths, reply) needs_evidence = _needs_evidence_fix(reply, classify) + resolved_runbook = None + if runbook_paths and (runbook_fix or runbook_needed): + resolver_prompt = prompts.RUNBOOK_SELECT_PROMPT + "\nQuestion: " + normalized + resolver_raw = await call_llm( + prompts.RUNBOOK_SELECT_SYSTEM, + resolver_prompt, + context="AllowedRunbooks:\n" + "\n".join(runbook_paths), + model=plan.fast_model, + tag="runbook_select", + ) + resolver = _parse_json_block(resolver_raw, fallback={}) + resolved_runbook = resolver.get("path") if isinstance(resolver.get("path"), str) else None if (snapshot_context and needs_evidence) or unknown_nodes or unknown_namespaces or runbook_fix or runbook_needed: if observer: observer("evidence_fix", "repairing missing evidence") @@ -307,6 +319,8 @@ class AnswerEngine: extra_bits.append("UnknownNamespaces: " + ", ".join(sorted(unknown_namespaces))) if runbook_paths: extra_bits.append("AllowedRunbooks: " + ", ".join(runbook_paths)) + if resolved_runbook: + extra_bits.append("ResolvedRunbook: " + resolved_runbook) if allowed_nodes: extra_bits.append("AllowedNodes: " + ", ".join(allowed_nodes)) if allowed_namespaces: @@ -847,11 +861,14 @@ def _needs_evidence_fix(reply: str, classify: dict[str, Any]) -> bool: "need to", "would need", "does not provide", + "does not mention", + "not mention", "not provided", "not in context", "not referenced", "missing", "no specific", + "no information", ) if classify.get("needs_snapshot") and any(marker in lowered for marker in missing_markers): return True diff --git a/atlasbot/llm/prompts.py b/atlasbot/llm/prompts.py index 74eae38..2fc9ac3 100644 --- a/atlasbot/llm/prompts.py +++ b/atlasbot/llm/prompts.py @@ -104,6 +104,17 @@ EVIDENCE_FIX_PROMPT = ( "documentation or checklist questions and do not invent new paths." ) +RUNBOOK_SELECT_SYSTEM = ( + CLUSTER_SYSTEM + + " Select the single best runbook path from the allowed list. " + + "Return JSON only." +) + +RUNBOOK_SELECT_PROMPT = ( + "Pick the best runbook path for the question from the AllowedRunbooks list. " + "Return JSON with field: path. If none apply, return {\"path\": \"\"}." +) + DRAFT_SELECT_PROMPT = ( "Pick the best draft for accuracy, clarity, and helpfulness. " "Return JSON with field: best (1-based index)." diff --git a/atlasbot/snapshot/builder.py b/atlasbot/snapshot/builder.py index eaec939..8e93e18 100644 --- a/atlasbot/snapshot/builder.py +++ b/atlasbot/snapshot/builder.py @@ -625,6 +625,21 @@ def _append_hardware(lines: list[str], summary: dict[str, Any]) -> None: lines.append("hardware: " + "; ".join(sorted(parts))) +def _append_hardware_groups(lines: list[str], summary: dict[str, Any]) -> None: + hardware = summary.get("hardware") if isinstance(summary.get("hardware"), dict) else {} + if not hardware: + return + parts = [] + for key, names in hardware.items(): + if not isinstance(names, list): + continue + name_list = _format_names([str(name) for name in names if name]) + if name_list: + parts.append(f"{key}={name_list}") + if parts: + lines.append("hardware_nodes: " + "; ".join(sorted(parts))) + + def _append_node_ages(lines: list[str], summary: dict[str, Any]) -> None: ages = summary.get("node_ages") if isinstance(summary.get("node_ages"), list) else [] if not ages: @@ -1308,6 +1323,15 @@ def _append_postgres(lines: list[str], summary: dict[str, Any]) -> None: hottest=hottest, ) ) + used = postgres.get("used") + max_conn = postgres.get("max") + if used is not None or max_conn is not None: + lines.append( + "postgres_connections_total: used={used}, max={max}".format( + used=_format_float(used), + max=_format_float(max_conn), + ) + ) by_db = postgres.get("by_db") if isinstance(by_db, list) and by_db: parts = [] @@ -1805,6 +1829,7 @@ def summary_text(snapshot: dict[str, Any] | None) -> str: lines.append("snapshot: " + ", ".join(bits)) _append_nodes(lines, summary) _append_hardware(lines, summary) + _append_hardware_groups(lines, summary) _append_lexicon(lines, summary) _append_pressure(lines, summary) _append_node_facts(lines, summary)