feat: mail-triage via llm-connect (no host Claude)
Add LLMConnectClient and mail-triage: CSV metadata → OpenRouter-backed llm-connect JSON plan → deterministic mail-log apply + commit. Server path for Binky mail intake on Railiance (BINKY-WP-0006).
This commit is contained in:
parent
66ccd4fa01
commit
7520a53831
7 changed files with 730 additions and 4 deletions
195
tests/test_mail_triage.py
Normal file
195
tests/test_mail_triage.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_harness import mail_triage
|
||||
from agent_harness.llm_connect_client import LLMConnectClient, LLMConnectError
|
||||
|
||||
|
||||
def _git_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "binky-control"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "test"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(repo / "mailmeta" / "reports").mkdir(parents=True)
|
||||
(repo / "mailmeta" / "mail-log.md").write_text(
|
||||
"# Mail Log\n\n## E-mail triage log\n\n"
|
||||
"| Date | Sender/Topic | Outcome |\n"
|
||||
"|------|--------------|---------|\n"
|
||||
"| 2026-07-01 | old@example.com: Old | ignore |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
csv_path = repo / "mailmeta" / "reports" / "email-channel-evidence-report-test.csv"
|
||||
csv_path.write_text(
|
||||
"mailbox_received_at,source_from,source_subject,detected_message_class,"
|
||||
"normalized_event_type,assessment_category,assessment_subclass,confidence\n"
|
||||
"2026-07-21T10:00:00+00:00,stripe@stripe.com,Webhook failed,notification,"
|
||||
"notification.fail,fail,fail.webhook,high\n"
|
||||
"2026-07-21T11:00:00+00:00,evil@phish.example,Urgent wire,human_reply,"
|
||||
"interaction.reply_received,undef,undef,low\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "init"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def test_parse_triage_response_strips_fences() -> None:
|
||||
text = """```json
|
||||
{"log_entries": [{"date": "2026-07-21", "sender": "a", "subject": "b",
|
||||
"action": "log", "outcome": "noted"}]}
|
||||
```"""
|
||||
entries = mail_triage.parse_triage_response(text)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].action == "log"
|
||||
assert entries[0].sender == "a"
|
||||
|
||||
|
||||
def test_parse_suspicious_forces_safe_outcome() -> None:
|
||||
entries = mail_triage.parse_triage_response(
|
||||
json.dumps(
|
||||
{
|
||||
"log_entries": [
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"sender": "evil@x",
|
||||
"subject": "wire",
|
||||
"action": "suspicious",
|
||||
"outcome": "click link",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "never acted" in entries[0].outcome.lower()
|
||||
|
||||
|
||||
def test_apply_log_entries_inserts_rows(tmp_path: Path) -> None:
|
||||
log = tmp_path / "mail-log.md"
|
||||
log.write_text(
|
||||
"## E-mail triage log\n\n"
|
||||
"| Date | Sender/Topic | Outcome |\n"
|
||||
"|------|--------------|---------|\n"
|
||||
"| 2026-01-01 | old | x |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
n = mail_triage.apply_log_entries(
|
||||
log,
|
||||
[
|
||||
mail_triage.LogEntry(
|
||||
"2026-07-21", "stripe", "Webhook", "log", "actionable"
|
||||
),
|
||||
mail_triage.LogEntry(
|
||||
"2026-07-21", "noise", "sale", "ignore", "skip"
|
||||
),
|
||||
],
|
||||
)
|
||||
assert n == 1
|
||||
text = log.read_text(encoding="utf-8")
|
||||
assert "stripe" in text
|
||||
assert "noise" not in text
|
||||
# new row above old
|
||||
assert text.index("2026-07-21") < text.index("2026-01-01")
|
||||
|
||||
|
||||
def test_run_mail_triage_with_mock_complete(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = _git_repo(tmp_path)
|
||||
monkeypatch.setattr(mail_triage.hub, "post_progress_event", lambda **kw: True)
|
||||
|
||||
def fake_complete(prompt: str) -> str:
|
||||
assert "stripe@stripe.com" in prompt
|
||||
assert "evil@phish.example" in prompt
|
||||
return json.dumps(
|
||||
{
|
||||
"log_entries": [
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"sender": "stripe@stripe.com",
|
||||
"subject": "Webhook failed",
|
||||
"action": "queue",
|
||||
"outcome": "Webhook failures — OH cleanup",
|
||||
},
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"sender": "evil@phish.example",
|
||||
"subject": "Urgent wire",
|
||||
"action": "suspicious",
|
||||
"outcome": "external unknown",
|
||||
},
|
||||
],
|
||||
"notes": "2 items",
|
||||
}
|
||||
)
|
||||
|
||||
result = mail_triage.run_mail_triage(
|
||||
repo,
|
||||
complete_fn=fake_complete,
|
||||
report_to_hub=True,
|
||||
commit=True,
|
||||
)
|
||||
assert result.ok
|
||||
assert result.entries_applied == 2
|
||||
assert result.committed
|
||||
log = (repo / "mailmeta" / "mail-log.md").read_text(encoding="utf-8")
|
||||
assert "Webhook" in log
|
||||
assert "Suspicious" in log
|
||||
|
||||
|
||||
def test_run_mail_triage_no_report(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = tmp_path / "empty"
|
||||
repo.mkdir()
|
||||
(repo / "mailmeta" / "reports").mkdir(parents=True)
|
||||
monkeypatch.setattr(mail_triage.hub, "post_progress_event", lambda **kw: True)
|
||||
result = mail_triage.run_mail_triage(repo, complete_fn=lambda p: "{}")
|
||||
assert not result.ok
|
||||
assert "no CSV" in result.reason
|
||||
|
||||
|
||||
def test_llm_connect_client_complete(monkeypatch) -> None:
|
||||
import agent_harness.llm_connect_client as mod
|
||||
|
||||
class FakeResp:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"content": "hello", "model": "test-model", "usage": {"total": 1}}
|
||||
|
||||
def fake_post(url, json=None, timeout=None): # noqa: A002
|
||||
assert url.endswith("/execute")
|
||||
assert "prompt" in json
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "post", fake_post)
|
||||
client = LLMConnectClient("http://llm.test", timeout_seconds=5)
|
||||
out = client.complete("hi", model="m")
|
||||
assert out == "hello"
|
||||
assert client.last_response_metadata.get("model") == "test-model"
|
||||
|
||||
|
||||
def test_get_client_requires_env(monkeypatch) -> None:
|
||||
monkeypatch.delenv("LLM_CONNECT_URL", raising=False)
|
||||
with pytest.raises(LLMConnectError, match="LLM_CONNECT_URL"):
|
||||
from agent_harness.llm_connect_client import get_llm_connect_client
|
||||
|
||||
get_llm_connect_client()
|
||||
Loading…
Add table
Add a link
Reference in a new issue