Files
replypier/backend/tests/services/test_ticket_investigation.py
WangShen007 164f5f9615 test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
2026-10-08 22:35:00 +08:00

443 lines
16 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Phase 3 investigation engine: evidence recording, hypothesis loop, budgets,
RCA synthesis/versioning.
"""
from unittest.mock import patch
from uuid import uuid4
import pytest
from app.models.investigation import (
HypothesisStatus,
InvestigationEvent,
InvestigationHypothesis,
InvestigationRun,
RCADocument,
)
from app.models.schemas.investigation import (
HypothesisPlan,
HypothesisSpec,
HypothesisVerdict,
RCAResult,
RCATimelineEntry,
)
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
from app.services.ticket_investigation import (
EvidenceRecorder,
enforce_evidence_backed_verdict,
redact_snippet,
run_investigation_phases,
synthesize_and_store_rca,
)
class _SessionShim:
"""Stands in for SessionLocal so recorder writes land in the test session
(and the shared session is never closed)."""
def __init__(self, db):
self.db = db
def __call__(self):
return self
def __enter__(self):
return self.db
def __exit__(self, *args):
return False
@pytest.fixture
def ticket(db, test_organization) -> Ticket:
t = Ticket(
organization_id=test_organization.id,
ticket_number=1,
title="Payout stuck for merchant",
description="Merchant reports payout not arriving since yesterday.",
status=TicketStatus.OPEN,
priority=TicketPriority.HIGH,
source=TicketSource.MANUAL,
)
db.add(t)
db.commit()
return t
@pytest.fixture
def run(db, ticket) -> InvestigationRun:
r = InvestigationRun(
ticket_id=ticket.id,
organization_id=ticket.organization_id,
run_type="investigation",
status="running",
)
db.add(r)
db.commit()
return r
@pytest.fixture
def recorder(db, run, ticket) -> EvidenceRecorder:
with patch("app.services.ticket_investigation.SessionLocal", _SessionShim(db)):
yield EvidenceRecorder(run.id, ticket.id)
class _FakeAgent:
"""Deterministic stand-in for TicketInvestigatorAgent."""
def __init__(self, plan=None, verdict=None, rca=None, last_provider_error=None):
self.plan = plan
self.verdict = verdict
self.rca = rca
self.test_calls = 0
self.last_provider_error = last_provider_error
async def generate_hypotheses(self, context):
return self.plan
async def test_hypothesis(self, context, title, rationale, **kwargs):
self.test_calls += 1
return self.verdict
async def synthesize_rca(self, context, hypotheses_digest, evidence_digest, partial=False):
return self.rca
class TestRedaction:
def test_redacts_pii_and_truncates(self):
text = "customer email is jane.doe@example.com " + "x" * 5000
snippet = redact_snippet(text, 200)
assert "jane.doe@example.com" not in snippet
assert "[EMAIL_REDACTED]" in snippet
assert len(snippet) <= 200
def test_empty(self):
assert redact_snippet("", 100) == ""
class TestEvidenceRecorder:
@pytest.mark.asyncio
async def test_tool_hook_records_call(self, db, recorder, run):
async def next_func(**kwargs):
return {"hits": 3, "contact": "bob@corp.io"}
result = await recorder.tool_hook("search_logs", next_func, {"query": "payout error"})
assert result["hits"] == 3
assert recorder.tool_calls == 1
events = db.query(InvestigationEvent).filter_by(run_id=run.id).all()
assert len(events) == 1
event = events[0]
assert event.tool_name == "search_logs"
assert "payout error" in event.tool_input
assert "bob@corp.io" not in (event.tool_result or "")
assert event.duration_ms is not None
assert event.error is None
@pytest.mark.asyncio
async def test_tool_hook_records_error_and_reraises(self, db, recorder, run):
async def next_func(**kwargs):
raise RuntimeError("connection refused")
with pytest.raises(RuntimeError):
await recorder.tool_hook("query_metrics", next_func, {"q": "cpu"})
event = db.query(InvestigationEvent).filter_by(run_id=run.id).one()
assert "connection refused" in event.error
@pytest.mark.asyncio
async def test_json_capture_tool_is_not_evidence(self, db, recorder, run):
async def next_func(**kwargs):
return "ok"
await recorder.tool_hook("json", next_func, {"status": "validated"})
assert recorder.tool_calls == 0
assert db.query(InvestigationEvent).filter_by(run_id=run.id).count() == 0
def test_phase_events(self, db, recorder, run):
recorder.record_phase("Generating hypotheses")
event = db.query(InvestigationEvent).filter_by(run_id=run.id).one()
assert event.event_type == "phase"
assert event.label == "Generating hypotheses"
class TestInvestigationPhases:
@pytest.mark.asyncio
async def test_hypotheses_created_and_verdicts_applied(self, db, run, ticket, recorder):
agent = _FakeAgent(
plan=HypothesisPlan(
hypotheses=[
HypothesisSpec(title="Fraud filter hold", rationale="pattern matches"),
HypothesisSpec(title="Webhook timeout", rationale="delayed acks"),
]
),
verdict=HypothesisVerdict(
status="validated", confidence=0.9, conclusion="Logs confirm the hold."
),
)
hypotheses, budget_exhausted = await run_investigation_phases(
db, run, ticket, agent, "context", [], recorder
)
assert len(hypotheses) == 2
assert budget_exhausted is False
rows = db.query(InvestigationHypothesis).filter_by(run_id=run.id).order_by(
InvestigationHypothesis.idx
).all()
assert [r.idx for r in rows] == [1, 2]
assert all(r.status == "validated" for r in rows)
assert rows[0].confidence == 0.9
@pytest.mark.asyncio
async def test_unparseable_verdict_is_inconclusive(self, db, run, ticket, recorder):
agent = _FakeAgent(
plan=HypothesisPlan(hypotheses=[HypothesisSpec(title="H", rationale="r")]),
verdict=None,
)
hypotheses, _ = await run_investigation_phases(db, run, ticket, agent, "ctx", [], recorder)
assert hypotheses[0].status == HypothesisStatus.INCONCLUSIVE.value
assert hypotheses[0].conclusion == "The tester produced no parseable verdict."
@pytest.mark.asyncio
async def test_provider_rejection_is_not_reported_as_a_missing_verdict(
self, db, run, ticket, recorder
):
"""A provider that refused the request is a hard, fixable connector
problem; calling it "no parseable verdict" reads as model quality and
sends operators looking in the wrong place (#303)."""
agent = _FakeAgent(
plan=HypothesisPlan(hypotheses=[HypothesisSpec(title="H", rationale="r")]),
verdict=None,
last_provider_error="The model provider rejected a connected tool's schema.",
)
hypotheses, _ = await run_investigation_phases(db, run, ticket, agent, "ctx", [], recorder)
assert hypotheses[0].status == HypothesisStatus.INCONCLUSIVE.value
assert hypotheses[0].conclusion == "The model provider rejected a connected tool's schema."
@pytest.mark.asyncio
async def test_invalid_verdict_status_whitelisted(self, db, run, ticket, recorder):
agent = _FakeAgent(
plan=HypothesisPlan(hypotheses=[HypothesisSpec(title="H", rationale="r")]),
verdict=HypothesisVerdict(status="TOTALLY_SURE", confidence=1.0, conclusion="?"),
)
hypotheses, _ = await run_investigation_phases(db, run, ticket, agent, "ctx", [], recorder)
assert hypotheses[0].status == HypothesisStatus.INCONCLUSIVE.value
@pytest.mark.asyncio
async def test_tool_budget_exhausted_skips_testing(self, db, run, ticket, recorder):
run.max_tool_calls = 0
db.commit()
agent = _FakeAgent(
plan=HypothesisPlan(
hypotheses=[
HypothesisSpec(title="A", rationale="r"),
HypothesisSpec(title="B", rationale="r"),
]
),
)
# A non-empty tool list makes the budget meaningful.
hypotheses, budget_exhausted = await run_investigation_phases(
db, run, ticket, agent, "ctx", [object()], recorder
)
assert budget_exhausted is True
assert agent.test_calls == 0
assert all(h.status == HypothesisStatus.INCONCLUSIVE.value for h in hypotheses)
assert "budget" in hypotheses[0].conclusion
@pytest.mark.asyncio
async def test_no_plan_raises(self, db, run, ticket, recorder):
with pytest.raises(RuntimeError):
await run_investigation_phases(
db, run, ticket, _FakeAgent(plan=None), "ctx", [], recorder
)
class TestRcaSynthesis:
def _agent(self):
return _FakeAgent(
rca=RCAResult(
summary="Fraud filter held the payout.",
impact="One merchant, 26 hours.",
timeline=[RCATimelineEntry(time="T0", event="payout initiated")],
contributing_factors=["stale allowlist"],
conclusion="Root cause: fraud hold [H1 · 0.92].",
remediation="Release the hold.",
prevention="Alert on holds older than 4h.",
customer_summary="Your payout was delayed by a safety check and is on its way.",
confidence=0.9,
)
)
@pytest.mark.asyncio
async def test_rca_stored_and_versioned(self, db, run, ticket, recorder):
rca1 = await synthesize_and_store_rca(
db, run, ticket, self._agent(), "ctx", [], recorder, partial=False
)
assert rca1.version == 1
assert rca1.is_partial is False
rca2 = await synthesize_and_store_rca(
db, run, ticket, self._agent(), "ctx", [], recorder, partial=True
)
assert rca2.version == 2
assert rca2.is_partial is True
assert db.query(RCADocument).filter_by(ticket_id=ticket.id).count() == 2
@pytest.mark.asyncio
async def test_unparseable_rca_returns_none(self, db, run, ticket, recorder):
result = await synthesize_and_store_rca(
db, run, ticket, _FakeAgent(rca=None), "ctx", [], recorder, partial=False
)
assert result is None
assert db.query(RCADocument).count() == 0
class TestEvidenceBackedVerdicts:
"""Tool errors are the absence of evidence, never evidence of absence.
A run whose every search failed could still report "the record does not
exist — validated, 0.90", which a support agent would act on (#318).
"""
@staticmethod
def _agent_claiming(status, confidence=0.9):
return _FakeAgent(
plan=HypothesisPlan(hypotheses=[HypothesisSpec(title="H", rationale="r")]),
verdict=HypothesisVerdict(
status=status, confidence=confidence,
conclusion="No record of the order exists in any index.",
),
)
@pytest.mark.asyncio
async def test_a_verdict_is_downgraded_when_every_tool_call_failed(
self, db, run, ticket, recorder
):
async def failing_tool(**kwargs):
raise RuntimeError("index_not_found_exception: no such index [app-logs-*]")
agent = self._agent_claiming("validated")
async def test_hypothesis(context, title, rationale, **kwargs):
for _ in range(3):
with pytest.raises(RuntimeError):
await recorder.tool_hook("search_logs", failing_tool, {"q": "x"})
return agent.verdict
agent.test_hypothesis = test_hypothesis
hypotheses, _ = await run_investigation_phases(
db, run, ticket, agent, "context", [object()], recorder
)
assert hypotheses[0].status == HypothesisStatus.INCONCLUSIVE
assert hypotheses[0].confidence == 0.0
assert "all 3 tool calls in this phase failed" in hypotheses[0].conclusion
# The model's claim is recorded, but its wording is not — the RCA
# writer would otherwise quote "no record exists" as a finding.
assert "validated" in hypotheses[0].conclusion
assert "No record of the order exists" not in hypotheses[0].conclusion
@pytest.mark.asyncio
async def test_an_invalidated_verdict_is_downgraded_too(self, db, run, ticket, recorder):
"""The reported case: 'the record does not exist' is a claim of
absence, and absence is exactly what a failed search cannot prove."""
async def failing_tool(**kwargs):
raise RuntimeError("boom")
agent = self._agent_claiming("invalidated")
async def test_hypothesis(context, title, rationale, **kwargs):
with pytest.raises(RuntimeError):
await recorder.tool_hook("search_logs", failing_tool, {})
return agent.verdict
agent.test_hypothesis = test_hypothesis
hypotheses, _ = await run_investigation_phases(
db, run, ticket, agent, "context", [object()], recorder
)
assert hypotheses[0].status == HypothesisStatus.INCONCLUSIVE
assert "all 1 tool call in this phase failed" in hypotheses[0].conclusion
@pytest.mark.asyncio
async def test_a_verdict_survives_when_one_call_succeeded(self, db, run, ticket, recorder):
"""Partial failure still leaves real evidence; only a total washout
removes the basis for a finding."""
async def failing_tool(**kwargs):
raise RuntimeError("boom")
async def working_tool(**kwargs):
return "1 hit: status 500"
agent = self._agent_claiming("validated")
async def test_hypothesis(context, title, rationale, **kwargs):
with pytest.raises(RuntimeError):
await recorder.tool_hook("search_logs", failing_tool, {})
await recorder.tool_hook("search_logs", working_tool, {})
return agent.verdict
agent.test_hypothesis = test_hypothesis
hypotheses, _ = await run_investigation_phases(
db, run, ticket, agent, "context", [object()], recorder
)
assert hypotheses[0].status == "validated"
assert hypotheses[0].confidence == 0.9
@pytest.mark.asyncio
async def test_a_hypothesis_with_no_tool_calls_is_left_alone(
self, db, run, ticket, recorder
):
"""Reasoning from the ticket alone is legitimate — the instructions
explicitly allow it when there are no tools."""
agent = self._agent_claiming("validated")
hypotheses, _ = await run_investigation_phases(
db, run, ticket, agent, "context", [], recorder
)
assert hypotheses[0].status == "validated"
assert hypotheses[0].confidence == 0.9
def test_the_rule_leaves_an_already_inconclusive_verdict_untouched(self, recorder):
hypothesis = InvestigationHypothesis(
id=uuid4(), run_id=uuid4(), ticket_id=uuid4(), idx=1, title="H",
status=HypothesisStatus.INCONCLUSIVE, confidence=0.4, conclusion="Not enough.",
)
recorder.calls_by_hypothesis[hypothesis.id] = [2, 2]
assert enforce_evidence_backed_verdict(hypothesis, recorder) is False
assert hypothesis.conclusion == "Not enough."
@pytest.mark.asyncio
async def test_the_run_reports_how_many_calls_errored(self, db, recorder, run):
"""connector_status is what the dashboard reads; a connector that
loads and then errors on every query shows up nowhere else."""
async def failing_tool(**kwargs):
raise RuntimeError("boom")
async def working_tool(**kwargs):
return "ok"
with pytest.raises(RuntimeError):
await recorder.tool_hook("t", failing_tool, {})
await recorder.tool_hook("t", working_tool, {})
assert recorder.tool_calls == 2
assert recorder.failed_tool_calls == 1