Skip to content

Commit 1ea4d90

Browse files
authored
Merge pull request #90 from zestones/21-m35-kb-builder-en-mode-agent-appelé-ask_kb_builder-handler
21 m35 kb builder en mode agent appelé ask kb builder handler
2 parents 9e140ef + 0294818 commit 1ea4d90

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

backend/agents/kb_builder/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- :mod:`agents.kb_builder.onboarding` — multi-turn onboarding session that
88
calibrates the KB with operator answers (4 questions, Sonnet-backed patch
99
extraction, MCP write).
10+
- :mod:`agents.kb_builder.qa` — pure async ``answer_kb_question`` handler
11+
called by the M4.6 Investigator orchestrator (``ask_kb_builder`` tool).
1012
1113
Public symbols are re-exported here so callers can keep using
1214
``from agents.kb_builder import ...``.
@@ -19,10 +21,12 @@
1921
submit_onboarding_message,
2022
)
2123
from agents.kb_builder.pdf_extraction import bootstrap_thresholds, extract_from_pdf
24+
from agents.kb_builder.qa import answer_kb_question
2225

2326
__all__ = [
2427
"OnboardingPatch",
2528
"OnboardingSession",
29+
"answer_kb_question",
2630
"bootstrap_thresholds",
2731
"extract_from_pdf",
2832
"start_onboarding",

backend/agents/kb_builder/qa.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""KB Builder Q&A handler — M3.5 (`ask_kb_builder` tool target).
2+
3+
This module exposes :func:`answer_kb_question`, a pure async function called by
4+
the M4.6 Investigator orchestrator when it needs to look up a factual detail
5+
from an equipment knowledge base.
6+
7+
Contract (see issue #21):
8+
9+
- **No DB writes.** The function only reads via :func:`mcp_client.call_tool`
10+
(``get_equipment_kb``).
11+
- **No WebSocket broadcasts.** All ``agent_handoff`` / ``agent_start`` /
12+
``agent_end`` events are emitted by the M4.6 orchestrator wrapper. If this
13+
function also broadcast, the Activity Feed would show duplicates.
14+
- **Always Sonnet.** Uses ``model_for("chat")`` so a demo-day flip to
15+
``ARIA_MODEL=opus`` does not silently 10x the cost of a simple factual lookup.
16+
- **Safe fallback on failure.** Returns a ``{answer, source, confidence}`` dict
17+
on every error path so the Investigator's tool loop can continue with an
18+
``is_error=True`` ``tool_result`` rather than crashing the investigation.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import logging
24+
25+
from agents.anthropic_client import anthropic, model_for, parse_json_response
26+
from aria_mcp.client import mcp_client
27+
28+
_log = logging.getLogger("aria.kb_builder")
29+
30+
_KB_QUESTION_SYSTEM = (
31+
"You answer factual questions from a colleague agent investigating an "
32+
"equipment failure. Use the knowledge base below. If the information is "
33+
"missing, say 'unknown' — do not guess. Response format: JSON object with "
34+
"keys: answer (str), source (str|null), confidence (0.0-1.0)."
35+
)
36+
37+
38+
async def answer_kb_question(cell_id: int, question: str) -> dict:
39+
"""Answer a factual KB question on behalf of the Investigator.
40+
41+
Args:
42+
cell_id: The production cell whose equipment KB should be consulted.
43+
question: Free-text question from the Investigator agent.
44+
45+
Returns:
46+
``{"answer": str, "source": str | None, "confidence": float}``.
47+
Always returns a dict — never raises — so the Investigator tool loop
48+
can keep reasoning even if the KB is missing or the LLM call fails.
49+
"""
50+
try:
51+
kb_result = await mcp_client.call_tool("get_equipment_kb", {"cell_id": cell_id})
52+
if kb_result.is_error:
53+
return {
54+
"answer": f"KB not available for cell {cell_id}",
55+
"source": None,
56+
"confidence": 0.0,
57+
}
58+
59+
response = await anthropic.messages.create(
60+
model=model_for("chat"), # always Sonnet — see module docstring
61+
max_tokens=1024,
62+
system=_KB_QUESTION_SYSTEM,
63+
messages=[
64+
{
65+
"role": "user",
66+
"content": (f"Equipment KB:\n{kb_result.content}\n\nQuestion: {question}"),
67+
}
68+
],
69+
)
70+
71+
return parse_json_response(response)
72+
73+
except Exception as exc: # noqa: BLE001 — safe fallback for tool-loop continuation
74+
_log.warning("answer_kb_question failed for cell %d: %s", cell_id, exc)
75+
return {
76+
"answer": "KB query failed — information unavailable",
77+
"source": None,
78+
"confidence": 0.0,
79+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Unit tests for :mod:`agents.kb_builder.qa.answer_kb_question` (M3.5, issue #21).
2+
3+
Covers the acceptance criteria that are testable without a live Anthropic
4+
call or live MCP server:
5+
6+
- Returns ``{answer, source, confidence}`` on the happy path.
7+
- Returns the documented fallback dict (no raise) when the KB is missing.
8+
- Returns the documented fallback dict (no raise) when the LLM call fails.
9+
- Performs no DB writes (verified by stubbing — there is no DB call to make).
10+
- Performs no WebSocket broadcasts (verified by absence — no ``ws_manager``
11+
import in the module under test, asserted below).
12+
- Uses ``model_for("chat")`` so a demo-day flip to ``ARIA_MODEL=opus`` does
13+
not silently switch this handler to Opus.
14+
15+
End-to-end validation (real Sonnet, real MCP, real Postgres) is deferred —
16+
the M2.5 integration tests already cover ``get_equipment_kb``, and this
17+
function adds no DB writes of its own.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass
23+
from typing import Any
24+
25+
import pytest
26+
from agents.kb_builder import qa
27+
28+
29+
@dataclass
30+
class _ToolResult:
31+
content: str = '{"thresholds": {"vibration": {"nominal": 4.5}}}'
32+
is_error: bool = False
33+
34+
35+
class _FakeMCP:
36+
def __init__(self, result: _ToolResult | None = None) -> None:
37+
self.calls: list[tuple[str, dict[str, Any]]] = []
38+
self._result = result or _ToolResult()
39+
40+
async def call_tool(self, name: str, args: dict[str, Any]) -> _ToolResult:
41+
self.calls.append((name, args))
42+
return self._result
43+
44+
45+
class _FakeAnthropic:
46+
"""Minimal stand-in for the ``anthropic`` async client.
47+
48+
Captures the kwargs of each ``messages.create`` call so tests can assert
49+
on the model used and the prompt shape, then returns a canned ``Message``
50+
that ``parse_json_response`` will accept.
51+
"""
52+
53+
def __init__(self, response_text: str) -> None:
54+
self.calls: list[dict[str, Any]] = []
55+
self._response_text = response_text
56+
self.messages = self._Messages(self)
57+
58+
class _Messages:
59+
def __init__(self, outer: "_FakeAnthropic") -> None:
60+
self._outer = outer
61+
62+
async def create(self, **kwargs: Any) -> Any:
63+
self._outer.calls.append(kwargs)
64+
from anthropic.types import Message, TextBlock, Usage
65+
66+
return Message(
67+
id="msg_test",
68+
type="message",
69+
role="assistant",
70+
model=kwargs.get("model", "claude-sonnet-4-5"),
71+
content=[TextBlock(type="text", text=self._outer._response_text)],
72+
stop_reason="end_turn",
73+
stop_sequence=None,
74+
usage=Usage(input_tokens=10, output_tokens=10),
75+
)
76+
77+
78+
def _patch(
79+
monkeypatch: pytest.MonkeyPatch,
80+
*,
81+
mcp: _FakeMCP,
82+
anthropic_client: _FakeAnthropic | None = None,
83+
) -> None:
84+
monkeypatch.setattr(qa, "mcp_client", mcp)
85+
if anthropic_client is not None:
86+
monkeypatch.setattr(qa, "anthropic", anthropic_client)
87+
88+
89+
# ── happy path ───────────────────────────────────────────────────────────────
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_returns_parsed_json_on_happy_path(monkeypatch):
94+
mcp = _FakeMCP()
95+
fake = _FakeAnthropic(
96+
response_text='{"answer": "120 Nm", "source": "thresholds.bolt_torque", "confidence": 0.9}'
97+
)
98+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
99+
100+
out = await qa.answer_kb_question(2, "What is the max bolt torque?")
101+
102+
assert out == {"answer": "120 Nm", "source": "thresholds.bolt_torque", "confidence": 0.9}
103+
assert mcp.calls == [("get_equipment_kb", {"cell_id": 2})]
104+
# Single Anthropic call with the expected wiring
105+
assert len(fake.calls) == 1
106+
call = fake.calls[0]
107+
assert call["model"] == "claude-sonnet-4-5" # always Sonnet — see acceptance #2 / cost guard
108+
assert call["max_tokens"] == 1024
109+
assert "knowledge base" in call["system"].lower()
110+
user_content = call["messages"][0]["content"]
111+
assert "Equipment KB:" in user_content
112+
assert "What is the max bolt torque?" in user_content
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_unknown_answer_is_passed_through_not_hallucinated(monkeypatch):
117+
"""If the LLM says ``unknown``, we forward it verbatim — no second-guessing."""
118+
119+
mcp = _FakeMCP()
120+
fake = _FakeAnthropic(response_text='{"answer": "unknown", "source": null, "confidence": 0.0}')
121+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
122+
123+
out = await qa.answer_kb_question(2, "What colour is the casing?")
124+
125+
assert out["answer"] == "unknown"
126+
assert out["source"] is None
127+
assert out["confidence"] == 0.0
128+
129+
130+
# ── KB missing ───────────────────────────────────────────────────────────────
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_returns_kb_unavailable_when_mcp_reports_error(monkeypatch):
135+
mcp = _FakeMCP(_ToolResult(content="cell 99 not found", is_error=True))
136+
# Anthropic must NOT be called when the KB lookup fails — pass a sentinel
137+
# whose .messages.create would raise if called.
138+
sentinel = _FakeAnthropic(response_text="should-not-be-called")
139+
140+
async def _boom(**_: Any) -> Any:
141+
raise AssertionError("anthropic.messages.create must not be invoked when KB is missing")
142+
143+
sentinel.messages.create = _boom # type: ignore[assignment]
144+
_patch(monkeypatch, mcp=mcp, anthropic_client=sentinel)
145+
146+
out = await qa.answer_kb_question(99, "Anything?")
147+
148+
assert out == {
149+
"answer": "KB not available for cell 99",
150+
"source": None,
151+
"confidence": 0.0,
152+
}
153+
assert mcp.calls == [("get_equipment_kb", {"cell_id": 99})]
154+
155+
156+
# ── error fallback ───────────────────────────────────────────────────────────
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_returns_safe_fallback_when_anthropic_raises(monkeypatch):
161+
"""A Sonnet timeout or transport error must NOT bubble up to the caller —
162+
the Investigator tool loop relies on getting a dict back."""
163+
164+
mcp = _FakeMCP()
165+
166+
class _ExplodingAnthropic(_FakeAnthropic):
167+
class _Messages:
168+
async def create(self, **_: Any) -> Any:
169+
raise RuntimeError("simulated transport failure")
170+
171+
def __init__(self) -> None:
172+
self.messages = self._Messages()
173+
174+
_patch(monkeypatch, mcp=mcp, anthropic_client=_ExplodingAnthropic())
175+
176+
out = await qa.answer_kb_question(2, "Anything?")
177+
178+
assert out == {
179+
"answer": "KB query failed — information unavailable",
180+
"source": None,
181+
"confidence": 0.0,
182+
}
183+
184+
185+
@pytest.mark.asyncio
186+
async def test_returns_safe_fallback_when_response_is_not_json(monkeypatch):
187+
"""``parse_json_response`` raising ``ValueError`` must be swallowed too."""
188+
189+
mcp = _FakeMCP()
190+
fake = _FakeAnthropic(response_text="I am sorry, I cannot answer that.")
191+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
192+
193+
out = await qa.answer_kb_question(2, "Anything?")
194+
195+
assert out["answer"] == "KB query failed — information unavailable"
196+
assert out["confidence"] == 0.0
197+
198+
199+
# ── contract guard: no WS broadcasts, no DB writes ───────────────────────────
200+
201+
202+
def test_module_does_not_import_ws_manager_or_db():
203+
"""Static guard for the issue's "pure handler" contract.
204+
205+
The M4.6 orchestrator owns ``agent_handoff`` / ``agent_start`` /
206+
``agent_end``. If this module ever starts broadcasting on its own, the
207+
Activity Feed will show duplicates — fail fast at test time.
208+
"""
209+
210+
import ast
211+
import inspect
212+
213+
tree = ast.parse(inspect.getsource(qa))
214+
# Drop module/function/class docstrings so the guard only inspects code.
215+
for node in ast.walk(tree):
216+
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
217+
if (
218+
node.body
219+
and isinstance(node.body[0], ast.Expr)
220+
and isinstance(node.body[0].value, ast.Constant)
221+
and isinstance(node.body[0].value.value, str)
222+
):
223+
node.body.pop(0)
224+
code_only = ast.unparse(tree)
225+
226+
assert "ws_manager" not in code_only
227+
assert "broadcast" not in code_only
228+
# No direct DB import either — all KB access must go through MCP.
229+
assert "from core import database" not in code_only
230+
assert "import asyncpg" not in code_only

0 commit comments

Comments
 (0)