test_assistant.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """
  2. tests/test_assistant.py — 工作台 AI 助手入口(方案 A+B 后端)。
  3. 覆盖:
  4. - GET /status/recent-runs: 跨智能体最近运行、运行中排最前、租户隔离。
  5. - POST /assistant/route: 唯一智能体直返、分类命中、拿不准回退、
  6. 分类器异常降级、choice 越界保护(注入 fake 分类器,零 LLM 调用)。
  7. """
  8. from __future__ import annotations
  9. import json
  10. import os
  11. import pytest
  12. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  13. os.environ.setdefault("AGENTPAAS_TESTING", "1")
  14. @pytest.fixture()
  15. def api_client(tmp_path, monkeypatch):
  16. import secrets
  17. from fastapi.testclient import TestClient
  18. from agentpaas.api.app import app
  19. from agentpaas.api.middleware.auth import hash_key
  20. from agentpaas.config import settings
  21. from agentpaas.db.models import Database, gen_id, now_utc
  22. import agentpaas.db.session as _session_mod
  23. monkeypatch.setattr(settings, "workspace_base", str(tmp_path / "Workspace"))
  24. prev = _session_mod._db
  25. _session_mod._db = Database("sqlite:///:memory:")
  26. db = _session_mod._db
  27. tid, uid = gen_id("tn_"), gen_id("usr_")
  28. raw_key = f"ap_{secrets.token_hex(16)}"
  29. now = now_utc()
  30. db.execute("INSERT INTO tenants (id, name, plan, status, created_at) "
  31. "VALUES (?, 'test', 'free', 'active', ?)", (tid, now))
  32. db.execute("INSERT INTO users (id, tenant_id, email, role, created_at) "
  33. "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now))
  34. db.execute(
  35. "INSERT INTO api_keys (id, tenant_id, user_id, key_hash, key_prefix, name, "
  36. "scopes, rate_limit, status, created_at) "
  37. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  38. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  39. json.dumps(["agents:*"]), now))
  40. db.commit()
  41. with TestClient(app) as client:
  42. yield client, raw_key, tid, db
  43. _session_mod._db = prev
  44. def _auth(k):
  45. return {"Authorization": f"Bearer {k}"}
  46. def _mk_agent(client, key, name, desc=""):
  47. cfg = {"name": name, "type": "simple",
  48. "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "t"}
  49. r = client.post("/api/v1/agents", headers=_auth(key),
  50. json={"name": name, "description": desc, "config": cfg})
  51. assert r.status_code in (200, 201), r.text[:200]
  52. return r.json()["agent_id"]
  53. # ── recent-runs ──────────────────────────────────────────────────────────────
  54. def test_recent_runs_running_first_and_truncated(api_client):
  55. from agentpaas.db.models import gen_id, now_utc
  56. client, key, tid, db = api_client
  57. aid = _mk_agent(client, key, "甲")
  58. now = now_utc()
  59. for i, status in enumerate(["completed", "running", "failed"]):
  60. db.execute(
  61. "INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, "
  62. "status, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)",
  63. (gen_id("run_"), aid, tid, f"输入{i}" + "长" * 200, status, now))
  64. db.commit()
  65. r = client.get("/api/v1/status/recent-runs?limit=10", headers=_auth(key))
  66. assert r.status_code == 200
  67. items = r.json()["items"]
  68. assert len(items) == 3
  69. assert items[0]["status"] == "running" # 运行中排最前
  70. assert items[0]["agent_name"] == "甲"
  71. assert len(items[0]["input_preview"]) <= 120 # 截断
  72. def test_recent_runs_tenant_scoped(api_client):
  73. from agentpaas.db.models import gen_id, now_utc
  74. client, key, tid, db = api_client
  75. # 别的租户的 run 不可见
  76. other_t = gen_id("tn_")
  77. db.execute("INSERT INTO tenants (id, name, plan, status, created_at) "
  78. "VALUES (?, 'o', 'free', 'active', ?)", (other_t, now_utc()))
  79. other_a = gen_id("ag_")
  80. db.execute("INSERT INTO agents (id, tenant_id, name, current_version, status, "
  81. "created_at, updated_at) VALUES (?, ?, 'x', 1, 'active', ?, ?)",
  82. (other_a, other_t, now_utc(), now_utc()))
  83. db.execute("INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, "
  84. "status, created_at) VALUES (?, ?, 1, ?, 'i', 'completed', ?)",
  85. (gen_id("run_"), other_a, other_t, now_utc()))
  86. db.commit()
  87. r = client.get("/api/v1/status/recent-runs", headers=_auth(key))
  88. assert r.json()["items"] == []
  89. # ── assistant/route ──────────────────────────────────────────────────────────
  90. def test_route_single_agent_no_llm(api_client):
  91. client, key, *_ = api_client
  92. aid = _mk_agent(client, key, "唯一助手")
  93. r = client.post("/api/v1/assistant/route", headers=_auth(key),
  94. json={"input": "随便什么任务"})
  95. assert r.status_code == 200
  96. d = r.json()
  97. assert d["matched"] is True and d["agent_id"] == aid
  98. def test_route_classifier_picks(api_client, monkeypatch):
  99. from agentpaas.api.v1 import assistant as mod
  100. client, key, *_ = api_client
  101. _mk_agent(client, key, "审稿助手", "论文评审")
  102. aid_exam = _mk_agent(client, key, "试卷助手", "出试卷与题库")
  103. monkeypatch.setattr(mod, "_pick_classifier_model",
  104. lambda: {"provider": "fake", "name": "f"})
  105. seen = {}
  106. def fake_classify(user_input, candidates, model):
  107. seen["input"] = user_input
  108. seen["names"] = [c["name"] for c in candidates]
  109. # 选「试卷助手」
  110. idx = next(i for i, c in enumerate(candidates, 1) if c["name"] == "试卷助手")
  111. return idx, "出题类请求", {"input_tokens": 50, "output_tokens": 10}
  112. monkeypatch.setattr(mod, "_classify_fn", fake_classify)
  113. r = client.post("/api/v1/assistant/route", headers=_auth(key),
  114. json={"input": "给数据结构出一份期末卷"})
  115. d = r.json()
  116. assert d["matched"] is True and d["agent_id"] == aid_exam
  117. assert d["reason"] == "出题类请求"
  118. assert "试卷助手" in seen["names"] and "期末卷" in seen["input"]
  119. def test_route_unsure_and_out_of_range(api_client, monkeypatch):
  120. from agentpaas.api.v1 import assistant as mod
  121. client, key, *_ = api_client
  122. _mk_agent(client, key, "甲")
  123. _mk_agent(client, key, "乙")
  124. monkeypatch.setattr(mod, "_pick_classifier_model",
  125. lambda: {"provider": "fake", "name": "f"})
  126. # choice=0 拿不准
  127. monkeypatch.setattr(mod, "_classify_fn", lambda *a: (0, "都不像", None))
  128. assert client.post("/api/v1/assistant/route", headers=_auth(key),
  129. json={"input": "x"}).json()["matched"] is False
  130. # choice 越界
  131. monkeypatch.setattr(mod, "_classify_fn", lambda *a: (99, "", None))
  132. assert client.post("/api/v1/assistant/route", headers=_auth(key),
  133. json={"input": "x"}).json()["matched"] is False
  134. # 分类器抛异常 → 降级 matched=false 而非 500
  135. def boom(*a):
  136. raise RuntimeError("provider down")
  137. monkeypatch.setattr(mod, "_classify_fn", boom)
  138. r = client.post("/api/v1/assistant/route", headers=_auth(key), json={"input": "x"})
  139. assert r.status_code == 200 and r.json()["matched"] is False
  140. def test_route_no_classifier_model(api_client, monkeypatch):
  141. from agentpaas.api.v1 import assistant as mod
  142. client, key, *_ = api_client
  143. _mk_agent(client, key, "甲")
  144. _mk_agent(client, key, "乙")
  145. monkeypatch.setattr(mod, "_pick_classifier_model", lambda: None)
  146. r = client.post("/api/v1/assistant/route", headers=_auth(key), json={"input": "x"})
  147. assert r.json()["matched"] is False