diff --git a/atlasbot/engine/answerer.py b/atlasbot/engine/answerer.py index 5b904e8..a37069a 100644 --- a/atlasbot/engine/answerer.py +++ b/atlasbot/engine/answerer.py @@ -203,7 +203,15 @@ class AnswerEngine: timeout_sec = min(self._settings.ollama_timeout_sec, time_left) call_count += 1 messages = build_messages(system, prompt, context=context) - response = await self._llm.chat(messages, model=model or plan.model, timeout_sec=timeout_sec) + try: + llm_call = self._llm.chat(messages, model=model or plan.model, timeout_sec=timeout_sec) + if timeout_sec is not None: + response = await asyncio.wait_for(llm_call, timeout=max(0.001, timeout_sec)) + else: + response = await llm_call + except asyncio.TimeoutError as exc: + time_budget_hit = True + raise LLMTimeBudgetExceeded("time_budget") from exc log.info( "atlasbot_llm_call", extra={"extra": {"mode": mode, "tag": tag, "call": call_count, "limit": call_cap}}, @@ -240,6 +248,61 @@ class AnswerEngine: classify: dict[str, Any] = {} tool_hint: dict[str, Any] | None = None try: + if mode in {"quick", "fast"} and not limitless: + if observer: + observer("factsheet", "building fact sheet") + kb_lines = ( + self._kb.chunk_lines( + max_files=plan.kb_max_files, + max_chars=max(1200, plan.kb_max_chars), + ) + if self._kb + else [] + ) + fact_lines = _quick_fact_sheet_lines( + question, + summary_lines, + kb_lines, + limit=max(14, plan.chunk_top * 5), + ) + if observer: + observer("quick", "answering from fact sheet") + classify = { + "needs_snapshot": True, + "needs_kb": bool(kb_lines), + "question_type": "quick_factsheet", + "answer_style": "direct", + "follow_up": False, + } + quick_context = _quick_fact_sheet_text(fact_lines) + quick_prompt = ( + "Question: " + + question + + "\nAnswer using only the Fact Sheet. Keep it to 1-3 sentences. " + + "If the Fact Sheet is missing key data, say exactly what is missing and suggest atlas-smart." + ) + reply = await call_llm( + prompts.ANSWER_SYSTEM, + quick_prompt, + context=quick_context, + model=plan.fast_model, + tag="quick_factsheet", + ) + reply = _strip_followup_meta(reply) + scores = _default_scores() + meta = _build_meta( + mode, + call_count, + call_cap, + limit_hit, + time_budget_hit, + time_budget_sec, + classify, + tool_hint, + started, + ) + return AnswerResult(reply, scores, meta) + if observer: observer("normalize", "normalizing") normalize_prompt = prompts.NORMALIZE_PROMPT + "\nQuestion: " + question @@ -3307,6 +3370,92 @@ def _state_from_payload(payload: dict[str, Any] | None) -> ConversationState | N ) +def _quick_fact_sheet_lines( + question: str, + summary_lines: list[str], + kb_lines: list[str], + *, + limit: int, +) -> list[str]: + tokens = { + token + for token in re.findall(r"[a-z0-9][a-z0-9_-]{2,}", question.lower()) + if token not in GENERIC_METRIC_TOKENS + } + priority_markers = ( + "snapshot:", + "nodes_total", + "nodes_ready", + "nodes_not_ready", + "workers_ready", + "workers_not_ready", + "control_plane", + "worker_nodes", + "hottest", + "postgres", + "pods", + "longhorn", + "titan-", + "rpi5", + "rpi4", + "jetson", + "amd64", + ) + scored: list[tuple[int, str]] = [] + for raw in summary_lines: + line = raw.strip() + if not line: + continue + lowered = line.lower() + score = 0 + if any(marker in lowered for marker in priority_markers): + score += 4 + overlap = sum(1 for token in tokens if token in lowered) + score += overlap * 3 + if len(line) <= 180: + score += 1 + if score > 0: + scored.append((score, line)) + + scored.sort(key=lambda item: item[0], reverse=True) + selected = [line for _, line in scored[:limit]] + if not selected: + selected = [line.strip() for line in summary_lines if line.strip()][:limit] + + kb_selected: list[str] = [] + for raw in kb_lines: + line = raw.strip() + if not line or len(line) > 220: + continue + lowered = line.lower() + if "kb file:" in lowered or "kb: atlas.json" in lowered: + continue + overlap = sum(1 for token in tokens if token in lowered) + if overlap > 0: + kb_selected.append(line) + elif any(marker in lowered for marker in ("runbook", "titan-", "rpi5", "rpi4", "amd64", "jetson")): + kb_selected.append(line) + if len(kb_selected) >= max(4, limit // 3): + break + + merged = [] + seen: set[str] = set() + for line in selected + kb_selected: + if line not in seen: + seen.add(line) + merged.append(line) + if len(merged) >= limit: + break + return merged + + +def _quick_fact_sheet_text(lines: list[str]) -> str: + if not lines: + return "Fact Sheet:\n- No snapshot facts available." + body = "\n".join([f"- {line}" for line in lines]) + return "Fact Sheet:\n" + body + + def _json_excerpt(summary: dict[str, Any], max_chars: int = 12000) -> str: raw = json.dumps(summary, ensure_ascii=False) return raw[:max_chars] diff --git a/tests/test_engine.py b/tests/test_engine.py index 34768bc..f66d5af 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -22,6 +22,8 @@ class FakeLLM: return '[{"id":"q1","question":"What is Atlas?","priority":1}]' if "sub-question" in prompt: return "Atlas has 22 nodes." + if "Answer using only the Fact Sheet" in prompt: + return "Atlas has 22 nodes." if "final response" in prompt: return "Atlas has 22 nodes." if "Score response quality" in prompt: