Compare commits

...

8 Commits

2 changed files with 128 additions and 13 deletions

View File

@ -201,6 +201,8 @@ class AnswerEngine:
classify.setdefault("needs_snapshot", True)
classify.setdefault("answer_style", "direct")
classify.setdefault("follow_up", False)
classify.setdefault("focus_entity", "unknown")
classify.setdefault("focus_metric", "unknown")
_debug_log("route_parsed", {"classify": classify, "normalized": normalized})
cluster_terms = (
"atlas",
@ -227,14 +229,27 @@ class AnswerEngine:
)
if any(term in normalized.lower() for term in cluster_terms):
classify["needs_snapshot"] = True
lowered_norm = normalized.lower()
if (
("namespace" in lowered_norm and ("pod" in lowered_norm or "pods" in lowered_norm))
or re.search(r"\bmost\s+pods\b", lowered_norm)
or re.search(r"\bpods\s+running\b", lowered_norm)
):
classify["question_type"] = "metric"
classify["needs_snapshot"] = True
if re.search(r"\b(how many|count|number of|list)\b", normalized.lower()):
classify["question_type"] = "metric"
hottest_terms = ("hottest", "highest", "lowest", "most")
metric_terms = ("cpu", "ram", "memory", "net", "network", "io", "disk", "load", "usage")
lowered_question = normalized.lower()
metric_terms = ("cpu", "ram", "memory", "net", "network", "io", "disk", "load", "usage", "pod", "pods", "namespace")
lowered_question = f"{question} {normalized}".lower()
if any(term in lowered_question for term in hottest_terms) and any(term in lowered_question for term in metric_terms):
classify["question_type"] = "metric"
if not classify.get("follow_up") and state and state.claims:
follow_terms = ("there", "that", "those", "these", "it", "them", "that one", "this", "former", "latter")
if any(term in lowered_question for term in follow_terms) or len(normalized.split()) <= 6:
classify["follow_up"] = True
if classify.get("follow_up") and state and state.claims:
if observer:
observer("followup", "answering follow-up")
@ -257,6 +272,8 @@ class AnswerEngine:
sub_questions = _select_subquestions(parts, normalized, plan.max_subquestions)
_debug_log("decompose_parsed", {"sub_questions": sub_questions})
keyword_tokens = _extract_keywords(question, normalized, sub_questions=sub_questions, keywords=keywords)
focus_entity = str(classify.get("focus_entity") or "unknown").lower()
focus_metric = str(classify.get("focus_metric") or "unknown").lower()
snapshot_context = ""
if classify.get("needs_snapshot"):
@ -282,12 +299,15 @@ class AnswerEngine:
hardware_facts = [seg.strip() for seg in line.split(" | ") if seg.strip().startswith("hardware_usage_avg:")]
break
metric_facts = [line for line in key_facts if re.search(r"\d", line)]
if hardware_facts:
if focus_entity == "node" and hottest_facts:
metric_facts = hottest_facts
key_facts = _merge_fact_lines(metric_facts, key_facts)
elif hardware_facts:
metric_facts = hardware_facts
key_facts = _merge_fact_lines(metric_facts, key_facts)
if classify.get("question_type") in {"metric", "diagnostic"}:
lowered_q = f"{question} {normalized}".lower()
if any(tok in lowered_q for tok in ("hardware", "class", "type", "rpi", "jetson", "amd64", "arm64")) and any(
if focus_entity != "node" and any(tok in lowered_q for tok in ("hardware", "class", "type", "rpi", "jetson", "amd64", "arm64")) and any(
tok in lowered_q for tok in ("average", "avg", "mean", "ram", "memory", "cpu", "load")
):
hw_top = None
@ -300,7 +320,7 @@ class AnswerEngine:
if hw_top:
metric_facts = [hw_top]
key_facts = _merge_fact_lines(metric_facts, key_facts)
if hottest_facts and not hardware_facts:
if hottest_facts and not hardware_facts and focus_entity != "class":
metric_facts = hottest_facts
key_facts = _merge_fact_lines(metric_facts, key_facts)
if classify.get("question_type") in {"metric", "diagnostic"} and not hottest_facts and not hardware_facts:
@ -312,15 +332,24 @@ class AnswerEngine:
if self._settings.debug_pipeline:
_debug_log("metric_facts_selected", {"facts": metric_facts})
if classify.get("question_type") in {"metric", "diagnostic"} and not metric_facts:
for line in summary_lines:
if "hardware_usage_top:" in line:
metric_facts = [seg.strip() for seg in line.split(" | ") if seg.strip().startswith("hardware_usage_top:")]
break
if not metric_facts:
lowered_q = f"{question} {normalized}".lower()
if "namespace" in lowered_q and "pod" in lowered_q:
for line in summary_lines:
if "hardware_usage_avg:" in line:
metric_facts = [seg.strip() for seg in line.split(" | ") if seg.strip().startswith("hardware_usage_avg:")]
if line.startswith("namespaces_top:"):
metric_facts = [line]
break
if not metric_facts:
hardware_tokens = ("hardware", "class", "type", "rpi", "jetson", "amd64", "arm64")
if any(tok in lowered_q for tok in hardware_tokens):
for line in summary_lines:
if "hardware_usage_top:" in line:
metric_facts = [seg.strip() for seg in line.split(" | ") if seg.strip().startswith("hardware_usage_top:")]
break
if not metric_facts:
for line in summary_lines:
if "hardware_usage_avg:" in line:
metric_facts = [seg.strip() for seg in line.split(" | ") if seg.strip().startswith("hardware_usage_avg:")]
break
if metric_facts:
key_facts = _merge_fact_lines(metric_facts, key_facts)
if self._settings.debug_pipeline:
@ -513,6 +542,61 @@ class AnswerEngine:
if classify.get("question_type") in {"metric", "diagnostic"} and metric_facts:
reply = _metric_fact_guard(reply, metric_facts, keyword_tokens)
if classify.get("question_type") in {"metric", "diagnostic"}:
lowered_q = f"{question} {normalized}".lower()
if any(tok in lowered_q for tok in ("how many", "count", "number of")) and any(
tok in lowered_q for tok in ("jetson", "rpi4", "rpi5", "amd64", "arm64", "rpi")
):
hw_line = next((line for line in summary_lines if line.startswith("hardware:")), None)
hw_nodes_line = next((line for line in summary_lines if line.startswith("hardware_nodes:")), None)
if hw_line:
def _find_value(key: str, line: str) -> str | None:
match = re.search(rf"{re.escape(key)}=([^;|]+)", line)
return match.group(1).strip() if match else None
target = None
if "jetson" in lowered_q:
target = "jetson"
elif "rpi5" in lowered_q:
target = "rpi5"
elif "rpi4" in lowered_q:
target = "rpi4"
elif "amd64" in lowered_q:
target = "amd64"
elif "arm64" in lowered_q:
target = "arm64"
elif "rpi" in lowered_q:
target = "rpi"
if target:
count = _find_value(target, hw_line)
nodes = _find_value(target, hw_nodes_line or "")
if count:
if nodes and "(" not in count:
reply = f"From the latest snapshot: {target}={count} ({nodes})."
else:
reply = f"From the latest snapshot: {target}={count}."
if ("io" in lowered_q or "i/o" in lowered_q) and ("node" in lowered_q or "nodes" in lowered_q):
io_facts = _extract_hottest_facts(summary_lines, lowered_q)
io_line = next((fact for fact in io_facts if fact.startswith("hottest_io_node")), None)
if io_line:
reply = f"From the latest snapshot: {io_line}."
if "namespace" in lowered_q and "pod" in lowered_q:
ns_line = None
for line in summary_lines:
if line.startswith("namespaces_top:"):
ns_line = line
break
cpu_line = None
if any(tok in lowered_q for tok in ("cpu", "hottest", "highest cpu", "highest")):
cpu_facts = _extract_hottest_facts(summary_lines, lowered_q)
cpu_line = next((fact for fact in cpu_facts if fact.startswith("hottest_cpu_node")), None)
if ns_line:
if cpu_line:
reply = f"From the latest snapshot: {cpu_line}; {ns_line}."
else:
reply = f"From the latest snapshot: {ns_line}."
# do not fall through to other overrides
if classify.get("question_type") in {"metric", "diagnostic"}:
lowered_q = f"{question} {normalized}".lower()
if any(tok in lowered_q for tok in ("hardware", "class", "type", "rpi", "jetson", "amd64", "arm64")) and any(
@ -724,6 +808,7 @@ class AnswerEngine:
tag="followup_fix",
)
reply = await self._dedup_reply(reply, plan, call_llm, tag="dedup_followup")
reply = _strip_followup_meta(reply)
return reply
async def _select_claims(
@ -771,6 +856,26 @@ class AnswerEngine:
self._store.cleanup()
def _strip_followup_meta(reply: str) -> str:
cleaned = reply.strip()
if not cleaned:
return cleaned
prefixes = [
"The draft is correct based on the provided context.",
"The draft is correct based on the context.",
"The draft is correct based on the provided evidence.",
"The draft is correct.",
"Based on the provided context,",
"Based on the context,",
"Based on the provided evidence,",
]
for prefix in prefixes:
if cleaned.lower().startswith(prefix.lower()):
cleaned = cleaned[len(prefix) :].lstrip(" .")
break
return cleaned
def _build_meta(
mode: str,
call_count: int,
@ -1091,6 +1196,8 @@ def _extract_hottest_facts(lines: list[str], question: str) -> list[str]:
if not facts:
return []
wanted = []
if ("io" in lowered or "i/o" in lowered) and ("disk" in lowered or "storage" in lowered):
return [fact for fact in facts if fact.startswith("hottest_io_node")]
if any(term in lowered for term in ("cpu", "processor")):
wanted.append("hottest_cpu_node")
if any(term in lowered for term in ("ram", "memory")):
@ -1156,6 +1263,13 @@ def _metric_candidate_lines(lines: list[str], keywords: list[str] | None, limit:
"p95",
"percent",
"pressure",
"pod",
"pods",
"namespace",
"anomaly",
"anomalies",
"pvc",
"storage",
}
candidates: list[str] = []
for line in lines:

View File

@ -30,7 +30,8 @@ ROUTE_SYSTEM = (
ROUTE_PROMPT = (
"Return JSON with fields: needs_snapshot (bool), needs_kb (bool), needs_tool (bool), "
"answer_style (direct|insightful), follow_up (bool), question_type (metric|diagnostic|planning|open_ended)."
"answer_style (direct|insightful), follow_up (bool), question_type (metric|diagnostic|planning|open_ended), "
"focus_entity (node|class|namespace|service|cluster|unknown), focus_metric (cpu|ram|net|io|disk|load|pods|storage|unknown)."
)
DECOMPOSE_SYSTEM = (