mirror of
https://github.com/snowy-yang/xiaonuan.git
synced 2026-10-21 08:04:48 +00:00
完善测试:pytest 套件(路由器/工具循环/WS全链路/投递/PG集成)
- tests/conftest.py: 内存版 FakeDB 复刻 db.py 全部签名,绕过 lifespan 的测试应用 - test_chat_flow.py: WebSocket 全链路(回声/路由信号落库/档案/慢信/连续天数) - test_delivery.py: 事件/每日任务/关怀投递(在线与离线)、天气工具城市链、system prompt 注入 - test_router.py: 小模型路由清洗(去重/丢过去事件/非法作息/500优雅降级/无模型空判定) - test_tool_loop.py: 原生工具调用循环(喂结果/超轮次强制收尾/坏参数) - test_pure.py: 纯逻辑(连续天数/哈希槽位/时间间隔/模板/向量) - test_db_pg.py: PG 集成测试,无 PG_DSN 自动跳过,配好即真实跑库 - 旧散装脚本移除;tools_live/ws_client/tui_smoke 保留为手动脚本
This commit is contained in:
1 parent
fe96acbda2
commit
6981c6df4d
13 files changed
+1107
-192
No files matched your search
@@ -1,52 +0,0 @@
|
||||
"""杂项离线测试(连续天数/彩蛋时刻/容器感知/学徒模板):
|
||||
uv run python extra_test.py
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from app.connector import ASSISTANT_NAME
|
||||
from app.main import _daily_slot, absence_text, compute_streak, digest_echo, \
|
||||
human_gap
|
||||
|
||||
NOW = datetime(2026, 9, 21, 19, 30) # 周一晚上
|
||||
|
||||
# 情绪识别已交给小模型路由(见 router_test.py);这里只测不依赖模型的纯逻辑。
|
||||
|
||||
# 连续陪伴天数
|
||||
assert compute_streak("2026-09-20", 5, NOW) == 6 # 昨天来过 -> +1
|
||||
assert compute_streak("2026-09-19", 5, NOW) == 1 # 断了 -> 重新数
|
||||
assert compute_streak("", 0, NOW) == 1 # 第一次
|
||||
assert compute_streak("2026-09-21", 7, NOW) == 7 # 今天已记过,不变
|
||||
|
||||
# 每日彩蛋时刻:确定性 + 落在窗口内
|
||||
s1 = _daily_slot("sess-a", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
s2 = _daily_slot("sess-a", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
s3 = _daily_slot("sess-b", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
assert s1 == s2, "同一会话同一天应恒定"
|
||||
assert 19 * 60 <= s1 < 22 * 60, "应落在窗口内"
|
||||
assert 19 * 60 <= s3 < 22 * 60
|
||||
|
||||
slot_share = _daily_slot("sess-a", "share", 10 * 60, 21 * 60, "2026-09-21")
|
||||
assert 10 * 60 <= slot_share < 21 * 60
|
||||
|
||||
# 容器感知:时间间隔的说法
|
||||
assert human_gap(59) == "1 分钟"
|
||||
assert human_gap(5 * 60) == "5 分钟"
|
||||
assert human_gap(2 * 3600 + 47 * 60) == "2 小时 47 分钟"
|
||||
assert human_gap(3 * 86400) == "3 天"
|
||||
|
||||
|
||||
# 缺勤迎接(回声模板;真实模式由模型写,接入后由 ws 测试覆盖)
|
||||
async def absence_check():
|
||||
line = await absence_text("s1", 3700)
|
||||
assert "小时" in line, line
|
||||
assert ("安静" in line) or ("心跳" in line) or ("开灯" in line), line
|
||||
|
||||
|
||||
asyncio.run(absence_check())
|
||||
|
||||
# 回顾草稿模板(小学徒署名)
|
||||
d = digest_echo({"msgs": 12, "mem": 3}, 45)
|
||||
assert "12" in d and "3" in d and "45" in d and ASSISTANT_NAME in d, d
|
||||
|
||||
print("扩展功能测试通过")
|
||||
@@ -10,3 +10,15 @@ dependencies = [
|
||||
"textual>=8.2.8",
|
||||
"uvicorn[standard]>=0.53.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
@@ -1,74 +0,0 @@
|
||||
"""小模型路由的离线测试(mock 模型服务):uv run python router_test.py"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app import connector
|
||||
|
||||
NOW = datetime(2026, 9, 21, 19, 30)
|
||||
|
||||
RAW = json.dumps({
|
||||
"view": "memories",
|
||||
"letter": {"days": 7, "body": "给未来的我:加油"},
|
||||
"negative_mood": True,
|
||||
"city_hint": "杭州",
|
||||
"facts": ["用户叫小明", "用户叫小明", "用户喜欢打篮球"],
|
||||
"events": [{"kind": "alarm", "fire_at": "2026-09-22T07:00:00",
|
||||
"text": "起床", "repeat": "daily"},
|
||||
{"kind": "reminder", "fire_at": "2020-01-01T00:00:00",
|
||||
"text": "过去的应被丢掉"}],
|
||||
"routines": [{"label": "到校", "time": "07:40", "kind": "inferred"},
|
||||
{"label": "打游戏", "time": "22:00", "kind": "explicit"}],
|
||||
"promises": [{"note": "面试", "due_at": None}],
|
||||
"lore": [{"topic": "诞生", "body": "小暖是用户亲手造的"}],
|
||||
"extra_field": "无关字段应被忽略"
|
||||
})
|
||||
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "好的:" + RAW + " (解析我)"}}]})
|
||||
|
||||
|
||||
async def main():
|
||||
# 测试环境没有 .env,手动给路由器配上"模型"(走 mock)
|
||||
connector.SMALL_KEY = "test-key"
|
||||
connector.SMALL_MODEL = "test-model"
|
||||
connector._TEST_TRANSPORT = httpx.MockTransport(handler)
|
||||
sig = await connector.route(
|
||||
"我叫小明,我在杭州,明早7点叫我起床,明天有面试", NOW, ["用户叫小明"])
|
||||
|
||||
assert sig["view"] == "memories", sig
|
||||
assert sig["letter"] == (7, "给未来的我:加油"), sig["letter"]
|
||||
assert sig["negative_mood"] is True
|
||||
assert sig["city_hint"] == "杭州"
|
||||
# facts 去重(已有记忆里的不要)
|
||||
assert sig["facts"] == ["用户喜欢打篮球"], sig["facts"]
|
||||
# events:过去的被丢弃,daily 保留
|
||||
assert len(sig["events"]) == 1
|
||||
assert sig["events"][0]["kind"] == "alarm"
|
||||
assert sig["events"][0]["repeat"] == "daily"
|
||||
assert sig["events"][0]["fire_at"].day == 22
|
||||
# routines:非法标签被丢掉
|
||||
assert sig["routines"] == [{"label": "到校", "minute": 460,
|
||||
"kind": "inferred"}], sig["routines"]
|
||||
# promises:due_at=null 回落为次日 18:00
|
||||
assert sig["promises"][0]["note"] == "面试"
|
||||
assert (sig["promises"][0]["due_at"].day,
|
||||
sig["promises"][0]["due_at"].hour) == (22, 18)
|
||||
assert sig["lore"] == [{"topic": "诞生", "body": "小暖是用户亲手造的"}]
|
||||
|
||||
# 模型挂掉/输出乱码 → 空判定,系统照常运转
|
||||
connector._TEST_TRANSPORT = httpx.MockTransport(
|
||||
lambda r: httpx.Response(500))
|
||||
sig2 = await connector.route("随便", NOW, [])
|
||||
assert sig2["view"] is None and sig2["facts"] == [] and \
|
||||
sig2["letter"] is None
|
||||
|
||||
print("路由器测试通过")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,341 @@
|
||||
"""共享测试设施:内存版 FakeDB + 不依赖 PG 的测试应用。
|
||||
|
||||
FakeDB 完整复刻 app/db.py 的函数签名,让服务端全链路逻辑
|
||||
(WebSocket 聊天流、调度投递、路由信号落库)可以脱离 PG 测试。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import connector, db, main
|
||||
from app.db import cosine
|
||||
|
||||
|
||||
class FakeDB:
|
||||
"""和 app/db.py 一一对应的内存实现(字段同名,行为简化)"""
|
||||
|
||||
def __init__(self):
|
||||
self.history = [] # {id, session, role, content, ts}
|
||||
self.memories = [] # {id, session, text, ts, embedding}
|
||||
self.events = [] # {id, session, kind, fire_at, text, repeat,
|
||||
# minute_of_day, status, last_fired, created_at}
|
||||
self.routines = {} # (session, label) -> {id, session, label, minute,
|
||||
# evidence, kind, last_fired, updated_at}
|
||||
self.care = [] # {id, session, kind, note, due_at, status, created_at}
|
||||
self.letters = [] # {id, session, body, deliver_at, status, created_at}
|
||||
self.meta = {} # (session, key) -> str
|
||||
self.lore = {} # (session, topic) -> body
|
||||
self.diary = {} # (session, day) -> body
|
||||
self.digests = {} # (session, day) -> body
|
||||
self._id = 0
|
||||
|
||||
def _next(self):
|
||||
self._id += 1
|
||||
return self._id
|
||||
|
||||
# ---- history ----
|
||||
async def add_message(self, session, role, content):
|
||||
self.history.append({"id": self._next(), "session": session,
|
||||
"role": role, "content": content,
|
||||
"ts": time.time()})
|
||||
|
||||
async def get_history(self, session, limit=40):
|
||||
rows = [r for r in self.history if r["session"] == session]
|
||||
return [{"role": r["role"], "content": r["content"]}
|
||||
for r in rows[-limit:]]
|
||||
|
||||
# ---- memories ----
|
||||
async def add_memories(self, session, facts, embeddings=None):
|
||||
for i, f in enumerate(facts):
|
||||
self.memories.append({"id": self._next(), "session": session,
|
||||
"text": f, "ts": time.time(),
|
||||
"embedding": embeddings[i] if embeddings else None})
|
||||
|
||||
async def get_memories(self, session):
|
||||
return [{"id": m["id"], "text": m["text"]}
|
||||
for m in self.memories if m["session"] == session]
|
||||
|
||||
async def search_memories(self, session, query_vec, k=6):
|
||||
rows = [m for m in self.memories if m["session"] == session
|
||||
and m["embedding"]]
|
||||
scored = [{"id": m["id"], "text": m["text"],
|
||||
"score": cosine(query_vec, m["embedding"])} for m in rows]
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:k]
|
||||
|
||||
async def replace_memories(self, session, facts, embeddings):
|
||||
self.memories = [m for m in self.memories if m["session"] != session]
|
||||
await self.add_memories(session, facts, embeddings)
|
||||
|
||||
async def memory_from_days_ago(self, session, days, now_ts):
|
||||
lo, hi = now_ts - days * 86400, now_ts - (days - 1) * 86400
|
||||
out = [{"content": m["text"], "ts": m["ts"]}
|
||||
for m in self.memories if m["session"] == session
|
||||
and lo <= m["ts"] <= hi]
|
||||
out += [{"content": h["content"], "ts": h["ts"]}
|
||||
for h in self.history if h["session"] == session
|
||||
and h["role"] == "user" and lo <= h["ts"] <= hi]
|
||||
return sorted(out, key=lambda x: x["ts"])[:20]
|
||||
|
||||
# ---- events ----
|
||||
async def add_event(self, session, kind, fire_at, text,
|
||||
repeat="once", minute_of_day=None):
|
||||
eid = self._next()
|
||||
self.events.append({"id": eid, "session": session, "kind": kind,
|
||||
"fire_at": fire_at, "text": text,
|
||||
"repeat": repeat, "minute_of_day": minute_of_day,
|
||||
"status": "pending", "last_fired": None,
|
||||
"created_at": time.time()})
|
||||
return eid
|
||||
|
||||
async def due_events(self, session=None):
|
||||
now = time.time()
|
||||
rows = [e for e in self.events
|
||||
if e["status"] == "pending" and e["repeat"] == "once"
|
||||
and e["fire_at"] <= now
|
||||
and (session is None or e["session"] == session)]
|
||||
return [dict(e) for e in sorted(rows, key=lambda x: x["fire_at"])]
|
||||
|
||||
async def due_daily_events(self, today, minute_now, window=10):
|
||||
rows = [e for e in self.events
|
||||
if e["status"] == "pending" and e["repeat"] == "daily"
|
||||
and e["minute_of_day"] is not None
|
||||
and e["minute_of_day"] <= minute_now
|
||||
and e["minute_of_day"] > minute_now - window
|
||||
and e["last_fired"] != today]
|
||||
return [dict(e) for e in rows]
|
||||
|
||||
async def today_events(self, session, day_start):
|
||||
rows = [e for e in self.events if e["session"] == session
|
||||
and e["status"] == "pending"
|
||||
and (e["repeat"] == "daily" or e["fire_at"] >= day_start)]
|
||||
return [dict(e) for e in sorted(rows, key=lambda x: x["fire_at"])]
|
||||
|
||||
async def mark_event_done(self, event_id):
|
||||
for e in self.events:
|
||||
if e["id"] == event_id:
|
||||
e["status"] = "done"
|
||||
|
||||
async def mark_event_fired(self, event_id, today):
|
||||
for e in self.events:
|
||||
if e["id"] == event_id:
|
||||
e["last_fired"] = today
|
||||
|
||||
# ---- routines ----
|
||||
async def upsert_routine(self, session, label, minute, kind):
|
||||
key = (session, label)
|
||||
row = self.routines.get(key)
|
||||
if row is None:
|
||||
self.routines[key] = {"id": self._next(), "session": session,
|
||||
"label": label, "minute": minute,
|
||||
"evidence": 1, "kind": kind,
|
||||
"last_fired": None, "updated_at": time.time()}
|
||||
else:
|
||||
if kind == "explicit":
|
||||
row["minute"] = minute
|
||||
row["kind"] = "explicit"
|
||||
else:
|
||||
row["minute"] = (row["minute"] * row["evidence"] + minute) \
|
||||
// (row["evidence"] + 1)
|
||||
row["evidence"] = min(row["evidence"] + 1, 1000)
|
||||
row["updated_at"] = time.time()
|
||||
|
||||
async def get_routines(self, session):
|
||||
rows = [r for r in self.routines.values() if r["session"] == session]
|
||||
return [{k: r[k] for k in ("label", "minute", "evidence", "kind")}
|
||||
for r in sorted(rows, key=lambda x: x["minute"])]
|
||||
|
||||
async def due_routines(self, today, minute_now, window=10):
|
||||
return [dict(r) for r in self.routines.values()
|
||||
if r["minute"] <= minute_now and r["minute"] > minute_now - window
|
||||
and r["last_fired"] != today]
|
||||
|
||||
async def mark_routine_fired(self, routine_id, today):
|
||||
for r in self.routines.values():
|
||||
if r["id"] == routine_id:
|
||||
r["last_fired"] = today
|
||||
|
||||
# ---- care ----
|
||||
async def add_care(self, session, kind, note, due_at):
|
||||
cid = self._next()
|
||||
self.care.append({"id": cid, "session": session, "kind": kind,
|
||||
"note": note, "due_at": due_at,
|
||||
"status": "pending", "created_at": time.time()})
|
||||
return cid
|
||||
|
||||
async def due_care(self, now_ts):
|
||||
return [dict(c) for c in self.care
|
||||
if c["status"] == "pending" and c["due_at"] <= now_ts]
|
||||
|
||||
async def pending_care(self, session):
|
||||
return [{"id": c["id"], "kind": c["kind"], "note": c["note"],
|
||||
"due_at": c["due_at"]}
|
||||
for c in self.care
|
||||
if c["session"] == session and c["status"] == "pending"]
|
||||
|
||||
async def mark_care_done(self, care_id):
|
||||
for c in self.care:
|
||||
if c["id"] == care_id:
|
||||
c["status"] = "done"
|
||||
|
||||
# ---- letters ----
|
||||
async def add_letter(self, session, body, deliver_at):
|
||||
lid = self._next()
|
||||
self.letters.append({"id": lid, "session": session, "body": body,
|
||||
"deliver_at": deliver_at, "status": "sealed",
|
||||
"created_at": time.time()})
|
||||
return lid
|
||||
|
||||
async def due_letters(self):
|
||||
now = time.time()
|
||||
return [dict(l) for l in self.letters
|
||||
if l["status"] == "sealed" and l["deliver_at"] <= now]
|
||||
|
||||
async def pending_letters(self, session):
|
||||
return [{"id": l["id"], "body": l["body"],
|
||||
"deliver_at": l["deliver_at"]}
|
||||
for l in self.letters
|
||||
if l["session"] == session and l["status"] == "sealed"]
|
||||
|
||||
async def mark_letter_delivered(self, letter_id):
|
||||
for l in self.letters:
|
||||
if l["id"] == letter_id:
|
||||
l["status"] = "delivered"
|
||||
|
||||
# ---- meta ----
|
||||
async def get_meta(self, session, key, default=""):
|
||||
return self.meta.get((session, key), default)
|
||||
|
||||
async def set_meta(self, session, key, value):
|
||||
self.meta[(session, key)] = value
|
||||
|
||||
# ---- lore ----
|
||||
async def upsert_lore(self, session, topic, body):
|
||||
self.lore[(session, topic)] = body
|
||||
|
||||
async def get_lore(self, session):
|
||||
return [{"topic": t[1], "body": b} for t, b in self.lore.items()
|
||||
if t[0] == session]
|
||||
|
||||
# ---- diary / digest ----
|
||||
async def add_diary(self, session, day, body):
|
||||
self.diary.setdefault((session, day), body)
|
||||
|
||||
async def get_diary(self, session, limit=5):
|
||||
rows = [{"day": d[1], "body": b} for d, b in self.diary.items()
|
||||
if d[0] == session]
|
||||
return sorted(rows, key=lambda x: x["day"], reverse=True)[:limit]
|
||||
|
||||
async def add_digest(self, session, day, body):
|
||||
self.digests.setdefault((session, day), body)
|
||||
|
||||
async def get_digest(self, session, day):
|
||||
return self.digests.get((session, day))
|
||||
|
||||
async def count_digests(self, session):
|
||||
return sum(1 for d in self.digests if d[0] == session)
|
||||
|
||||
# ---- stats ----
|
||||
async def day_counts(self, session, day_start_ts):
|
||||
return {"msgs": sum(1 for h in self.history
|
||||
if h["session"] == session and h["ts"] >= day_start_ts),
|
||||
"mem": sum(1 for m in self.memories
|
||||
if m["session"] == session and m["ts"] >= day_start_ts)}
|
||||
|
||||
async def get_stats(self, session):
|
||||
rows = [h for h in self.history if h["session"] == session]
|
||||
return {"memories": sum(1 for m in self.memories
|
||||
if m["session"] == session),
|
||||
"messages": len(rows),
|
||||
"first_ts": rows[0]["ts"] if rows else None,
|
||||
"db_bytes": 1048576}
|
||||
|
||||
async def active_sessions(self, days=3):
|
||||
cut = time.time() - days * 86400
|
||||
return list({h["session"] for h in self.history if h["ts"] > cut})
|
||||
|
||||
|
||||
# 需要替换到 app.db 模块上的函数名(与 db.py 对应)
|
||||
FAKE_FUNCS = [
|
||||
"add_message", "get_history",
|
||||
"add_memories", "get_memories", "search_memories", "replace_memories",
|
||||
"memory_from_days_ago",
|
||||
"add_event", "due_events", "due_daily_events", "today_events",
|
||||
"mark_event_done", "mark_event_fired",
|
||||
"upsert_routine", "get_routines", "due_routines", "mark_routine_fired",
|
||||
"add_care", "due_care", "pending_care", "mark_care_done",
|
||||
"add_letter", "due_letters", "pending_letters", "mark_letter_delivered",
|
||||
"get_meta", "set_meta",
|
||||
"upsert_lore", "get_lore",
|
||||
"add_diary", "get_diary",
|
||||
"add_digest", "get_digest", "count_digests",
|
||||
"day_counts", "get_stats", "active_sessions",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fdb(monkeypatch):
|
||||
"""把 FakeDB 的每个函数替换到 app.db 模块上"""
|
||||
fake = FakeDB()
|
||||
for name in FAKE_FUNCS:
|
||||
monkeypatch.setattr(db, name, getattr(fake, name))
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offline(monkeypatch):
|
||||
"""强制回声模式 + 路由器离线,保证测试确定性"""
|
||||
for mod in (main, connector):
|
||||
monkeypatch.setattr(mod, "API_KEY", "", raising=False)
|
||||
monkeypatch.setattr(connector, "SMALL_KEY", "", raising=False)
|
||||
monkeypatch.setattr(connector, "SMALL_MODEL", "", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(offline, fdb):
|
||||
"""不带 lifespan 的测试应用(绕过 PG 连接)"""
|
||||
app = FastAPI()
|
||||
app.websocket("/ws")(main.chat)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route_sig(monkeypatch):
|
||||
"""指定小模型路由的返回结果"""
|
||||
def use(sig):
|
||||
async def fake_route(text, now, existing):
|
||||
base = {"view": None, "letter": None, "negative_mood": False,
|
||||
"city_hint": "", "facts": [], "events": [], "routines": [],
|
||||
"promises": [], "lore": []}
|
||||
base.update(sig)
|
||||
return base
|
||||
monkeypatch.setattr(main, "route", fake_route)
|
||||
return use
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_chat(client):
|
||||
"""开一条 WS,依次发送消息,收集到每条 chat 的 done 为止"""
|
||||
def _run(messages, session="s1"):
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
out = []
|
||||
for m in messages:
|
||||
ws.send_json({"session": session, **m})
|
||||
expected = sum(1 for m in messages if m.get("type") == "chat")
|
||||
done = 0
|
||||
while done < expected:
|
||||
m = ws.receive_json()
|
||||
out.append(m)
|
||||
if m["type"] == "done":
|
||||
done += 1
|
||||
return out
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reply_text():
|
||||
return lambda received: "".join(m["text"] for m in received
|
||||
if m["type"] == "delta")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""WebSocket 全链路流测试:路由信号 → 回复 → 落库(FakeDB,无需 PG)"""
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import main
|
||||
|
||||
|
||||
def test_echo_reply_and_history(client, route_sig, run_chat, reply_text):
|
||||
route_sig({}) # 路由器:什么信号都没有
|
||||
received = run_chat([{"type": "chat", "text": "你好呀"}])
|
||||
text = reply_text(received)
|
||||
assert "回声模式" in text and "你好呀" in text
|
||||
assert received[-1]["type"] == "done"
|
||||
|
||||
|
||||
def test_route_signals_land_in_db(client, route_sig, fdb, run_chat):
|
||||
future = datetime.now() + timedelta(hours=1)
|
||||
route_sig({
|
||||
"facts": ["用户叫小明"],
|
||||
"events": [{"kind": "alarm", "fire_at": future,
|
||||
"text": "起床", "repeat": "once"}],
|
||||
"negative_mood": True,
|
||||
"promises": [{"note": "面试",
|
||||
"due_at": datetime.now() + timedelta(days=1)}],
|
||||
"lore": [{"topic": "诞生", "body": "小暖是用户亲手造的"}],
|
||||
})
|
||||
run_chat([{"type": "chat", "text": "我叫小明,明早叫我起床,"
|
||||
"昨天心情不好,明天面试,你是我造的"}])
|
||||
# remember_later 是后台任务,等它跑完
|
||||
time.sleep(0.5)
|
||||
assert [m["text"] for m in fdb.memories] == ["用户叫小明"]
|
||||
assert len(fdb.events) == 1 and fdb.events[0]["text"] == "起床"
|
||||
assert len(fdb.care) == 2 # 情绪回访 + 承诺追踪
|
||||
kinds = {c["kind"] for c in fdb.care}
|
||||
assert kinds == {"emotion", "promise"}
|
||||
assert fdb.lore[("s1", "诞生")] == "小暖是用户亲手造的"
|
||||
# 对话历史:user + assistant
|
||||
assert len(fdb.history) == 2
|
||||
|
||||
|
||||
def test_view_reply_reads_db(client, route_sig, fdb, run_chat, reply_text):
|
||||
asyncio.run(fdb.add_memories("s1", ["用户叫小明", "用户喜欢打篮球"], None))
|
||||
route_sig({"view": "memories"})
|
||||
received = run_chat([{"type": "chat", "text": "你的记忆里有什么"}])
|
||||
text = reply_text(received)
|
||||
assert "用户叫小明" in text and "用户喜欢打篮球" in text
|
||||
# 档案回复也进历史(assistant 角色)
|
||||
assert any(h["role"] == "assistant" and "小明" in h["content"]
|
||||
for h in fdb.history)
|
||||
|
||||
|
||||
def test_letter_flow(client, route_sig, fdb, run_chat, reply_text):
|
||||
route_sig({"letter": (7, "给未来的我:加油")})
|
||||
received = run_chat([{"type": "chat", "text": "给7天后的自己写信:加油"}])
|
||||
assert "✉️" in reply_text(received)
|
||||
assert len(fdb.letters) == 1 and fdb.letters[0]["body"] == "给未来的我:加油"
|
||||
|
||||
|
||||
def test_letter_ask_when_incomplete(client, route_sig, fdb, run_chat,
|
||||
reply_text):
|
||||
route_sig({"letter": ("ask", "")})
|
||||
received = run_chat([{"type": "chat", "text": "我想写慢信"}])
|
||||
assert "给7天后的自己写信" in reply_text(received)
|
||||
assert fdb.letters == []
|
||||
|
||||
|
||||
def test_streak_increments(fdb, offline):
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
asyncio.run(fdb.set_meta("s1", "last_active", yesterday))
|
||||
asyncio.run(fdb.set_meta("s1", "streak", "3"))
|
||||
sig = {"facts": [], "events": [], "routines": [], "promises": [],
|
||||
"lore": [], "letter": None, "view": None, "negative_mood": False}
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, payload):
|
||||
pass
|
||||
|
||||
asyncio.run(main.remember_later(FakeWS(), "s1", "我来了", sig))
|
||||
assert asyncio.run(fdb.get_meta("s1", "streak")) == "4"
|
||||
|
||||
|
||||
def test_streak_resets_after_gap(fdb, offline):
|
||||
asyncio.run(fdb.set_meta("s1", "last_active", "2020-01-01"))
|
||||
sig = {"facts": [], "events": [], "routines": [], "promises": [],
|
||||
"lore": [], "letter": None, "view": None, "negative_mood": False}
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, payload):
|
||||
pass
|
||||
|
||||
asyncio.run(main.remember_later(FakeWS(), "s1", "好久不见", sig))
|
||||
assert asyncio.run(fdb.get_meta("s1", "streak")) == "1"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PostgreSQL 集成测试:真实跑一遍 db.py 的每一层。
|
||||
|
||||
未设置 PG_DSN 时整组跳过;设置后需要库已存在(表会自动建)。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import app.db as db
|
||||
from app.connector import hash_embed
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("PG_DSN", "").strip(),
|
||||
reason="未配置 PG_DSN,跳过 PG 集成测试")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def conn():
|
||||
await db.init(os.environ["PG_DSN"].strip())
|
||||
yield
|
||||
await db.close()
|
||||
|
||||
|
||||
def session():
|
||||
return "test-" + uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
async def test_history_roundtrip(conn):
|
||||
s = session()
|
||||
await db.add_message(s, "user", "第一条")
|
||||
await db.add_message(s, "assistant", "第二条")
|
||||
await db.add_message(s, "user", "第三条")
|
||||
rows = await db.get_history(s)
|
||||
assert [(r["role"], r["content"]) for r in rows] == [
|
||||
("user", "第一条"), ("assistant", "第二条"), ("user", "第三条")]
|
||||
rows = await db.get_history(s, limit=2)
|
||||
assert len(rows) == 2 and rows[0]["content"] == "第二条"
|
||||
|
||||
|
||||
async def test_memories_and_semantic_search(conn):
|
||||
s = session()
|
||||
facts = ["用户喜欢打篮球", "用户住在杭州", "用户叫小明"]
|
||||
vecs = [hash_embed(f) for f in facts]
|
||||
await db.add_memories(s, facts, vecs)
|
||||
rows = await db.get_memories(s)
|
||||
assert [r["text"] for r in rows] == facts
|
||||
hits = await db.search_memories(s, hash_embed("打篮球真开心"), k=2)
|
||||
assert hits and hits[0]["text"] == "用户喜欢打篮球"
|
||||
# 整组替换(夜间巩固用)
|
||||
new_facts = ["用户叫小明", "用户在杭州上学"]
|
||||
await db.replace_memories(s, new_facts, [hash_embed(f) for f in new_facts])
|
||||
rows = await db.get_memories(s)
|
||||
assert [r["text"] for r in rows] == new_facts
|
||||
|
||||
|
||||
async def test_routine_upsert_averaging(conn):
|
||||
s = session()
|
||||
await db.upsert_routine(s, "到校", 7 * 60 + 40, "inferred")
|
||||
await db.upsert_routine(s, "到校", 7 * 60 + 50, "inferred")
|
||||
rows = await db.get_routines(s)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["minute"] == (7 * 60 + 40 + 7 * 60 + 50) // 2
|
||||
assert rows[0]["evidence"] == 2
|
||||
# explicit 直接定时间
|
||||
await db.upsert_routine(s, "到校", 7 * 60 + 30, "explicit")
|
||||
rows = await db.get_routines(s)
|
||||
assert rows[0]["minute"] == 7 * 60 + 30 and rows[0]["kind"] == "explicit"
|
||||
|
||||
|
||||
async def test_events_one_shot_and_daily(conn):
|
||||
s = session()
|
||||
now = time.time()
|
||||
eid = await db.add_event(s, "reminder", now - 10, "喝水", "once", 0)
|
||||
did = await db.add_event(s, "reminder", now + 3600, "吃药", "daily", 22 * 60)
|
||||
|
||||
due = await db.due_events(s)
|
||||
assert [e["id"] for e in due] == [eid]
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
daily = await db.due_daily_events(today, 22 * 60 + 1, window=10)
|
||||
assert [e["id"] for e in daily] == [did]
|
||||
await db.mark_event_fired(did, today)
|
||||
assert await db.due_daily_events(today, 22 * 60 + 1, window=10) == []
|
||||
|
||||
rows = await db.today_events(s, now - 60)
|
||||
assert {e["id"] for e in rows} == {eid, did}
|
||||
await db.mark_event_done(eid)
|
||||
assert await db.due_events(s) == []
|
||||
|
||||
|
||||
async def test_care_and_letters(conn):
|
||||
s = session()
|
||||
cid = await db.add_care(s, "emotion", "心情不好", time.time() - 5)
|
||||
due = await db.due_care(time.time())
|
||||
assert [c["id"] for c in due] == [cid]
|
||||
await db.mark_care_done(cid)
|
||||
assert await db.due_care(time.time()) == []
|
||||
|
||||
lid = await db.add_letter(s, "给未来", time.time() - 5)
|
||||
assert [l["id"] for l in await db.due_letters()] == [lid]
|
||||
await db.mark_letter_delivered(lid)
|
||||
assert await db.pending_letters(s) == []
|
||||
|
||||
|
||||
async def test_meta_lore_diary_digest(conn):
|
||||
s = session()
|
||||
await db.set_meta(s, "streak", "3")
|
||||
assert await db.get_meta(s, "streak") == "3"
|
||||
assert await db.get_meta(s, "nope", "d") == "d"
|
||||
|
||||
await db.upsert_lore(s, "诞生", "v1")
|
||||
await db.upsert_lore(s, "诞生", "v2") # 同话题=更正
|
||||
lore = await db.get_lore(s)
|
||||
assert lore == [{"topic": "诞生", "body": "v2"}]
|
||||
|
||||
day = time.strftime("%Y-%m-%d")
|
||||
await db.add_diary(s, day, "今天…")
|
||||
assert (await db.get_diary(s))[0]["body"] == "今天…"
|
||||
|
||||
await db.add_digest(s, day, "回顾")
|
||||
assert await db.get_digest(s, day) == "回顾"
|
||||
assert await db.count_digests(s) == 1
|
||||
|
||||
|
||||
async def test_stats_and_day_counts(conn):
|
||||
s = session()
|
||||
await db.add_message(s, "user", "计数")
|
||||
await db.add_memories(s, ["用户是测试"], None)
|
||||
stats = await db.get_stats(s)
|
||||
assert stats["messages"] == 1 and stats["memories"] == 1
|
||||
assert stats["first_ts"] and stats["db_bytes"] > 0
|
||||
counts = await db.day_counts(s, time.time() - 60)
|
||||
assert counts["msgs"] == 1 and counts["mem"] == 1
|
||||
|
||||
|
||||
async def test_memory_from_days_ago(conn):
|
||||
s = session()
|
||||
await db.add_message(s, "user", "半个月前的话")
|
||||
rows = await db.memory_from_days_ago(s, 15, time.time())
|
||||
assert rows == [] # 刚插入的不在 15 天前
|
||||
rows = await db.memory_from_days_ago(s, 0, time.time() + 86400)
|
||||
assert any(r["content"] == "半个月前的话" for r in rows)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""投递与工具执行测试(FakeDB + stub 工具)"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import main
|
||||
|
||||
|
||||
class _State:
|
||||
name = "CONNECTED"
|
||||
|
||||
|
||||
class FakeWS:
|
||||
client_state = _State()
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send_text(self, payload):
|
||||
self.sent.append(payload)
|
||||
|
||||
def last_text(self):
|
||||
"""最后一条主动消息的纯文本(服务端 JSON 转义了中文,要解一下)"""
|
||||
return json.loads(self.sent[-1])["text"]
|
||||
|
||||
|
||||
async def test_one_shot_event_offline_stays_pending(fdb, offline):
|
||||
ev = {"id": 1, "session": "s1", "kind": "alarm",
|
||||
"fire_at": time.time(), "text": "起床", "repeat": "once"}
|
||||
ok = await main.deliver_event(ev)
|
||||
assert ok is False
|
||||
assert fdb.events == [] # 没投递就不写历史、不标完成
|
||||
|
||||
|
||||
async def test_one_shot_event_online_delivers(fdb, offline):
|
||||
ws = FakeWS()
|
||||
main.connected.setdefault("s1", set()).add(ws)
|
||||
try:
|
||||
ev = {"id": 2, "session": "s1", "kind": "reminder",
|
||||
"fire_at": time.time(), "text": "喝水", "repeat": "once"}
|
||||
ok = await main.deliver_event(ev)
|
||||
assert ok is True
|
||||
assert "喝水" in ws.last_text()
|
||||
# 主动消息写进了历史
|
||||
assert [h["role"] for h in fdb.history] == ["assistant"]
|
||||
finally:
|
||||
main.connected.get("s1", set()).discard(ws)
|
||||
|
||||
|
||||
async def test_daily_event_marks_fired_not_done(fdb, offline):
|
||||
ws = FakeWS()
|
||||
main.connected.setdefault("s1", set()).add(ws)
|
||||
try:
|
||||
await fdb.add_event("s1", "reminder", time.time() + 3600, "吃药",
|
||||
"daily", 22 * 60)
|
||||
ev = fdb.events[0]
|
||||
ok = await main.deliver_daily_event(ev)
|
||||
assert ok is True
|
||||
assert "吃药" in ws.last_text()
|
||||
assert ev["last_fired"] == datetime.now().strftime("%Y-%m-%d")
|
||||
assert ev["status"] == "pending" # 每天还要再响
|
||||
finally:
|
||||
main.connected.get("s1", set()).discard(ws)
|
||||
|
||||
|
||||
async def test_care_delivery(fdb, offline):
|
||||
cid = await fdb.add_care("s1", "emotion", "昨天心情不好",
|
||||
time.time() - 10)
|
||||
ok = await main.deliver_care({"id": cid, "session": "s1",
|
||||
"kind": "emotion", "note": "昨天心情不好"})
|
||||
assert ok is False # 没人在线,保持 pending
|
||||
|
||||
ws = FakeWS()
|
||||
main.connected.setdefault("s1", set()).add(ws)
|
||||
try:
|
||||
ok = await main.deliver_care({"id": cid, "session": "s1",
|
||||
"kind": "emotion", "note": "昨天心情不好"})
|
||||
assert ok is True
|
||||
assert "心情" in ws.last_text()
|
||||
assert fdb.care[0]["status"] == "done"
|
||||
finally:
|
||||
main.connected.get("s1", set()).discard(ws)
|
||||
|
||||
|
||||
async def test_routine_echo_greeting(fdb, offline):
|
||||
r = {"session": "s1", "label": "到校", "minute": 7 * 60 + 40,
|
||||
"kind": "inferred", "evidence": 3}
|
||||
text = await main.routine_message(r)
|
||||
assert "到学校" in text
|
||||
|
||||
|
||||
async def test_weather_tool_city_chain(fdb, offline, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_weather(city):
|
||||
calls.append(city)
|
||||
return f"{city} 晴 25°C"
|
||||
|
||||
monkeypatch.setattr(main.tools, "get_weather", fake_weather)
|
||||
|
||||
# 三层都推不出 -> UNKNOWN_CITY,且不假装有天气
|
||||
out = await main.execute_tool("s1", "get_weather", {})
|
||||
assert out.startswith("UNKNOWN_CITY") and calls == []
|
||||
|
||||
# 路由器的城市提示 -> 查询并记住
|
||||
out = await main.execute_tool("s1", "get_weather", {}, city_hint="杭州")
|
||||
assert "杭州" in out and calls == ["杭州"]
|
||||
assert await fdb.get_meta("s1", "city") == "杭州"
|
||||
|
||||
# 模型显式给了城市 -> 优先用,并覆盖记住的城市
|
||||
out = await main.execute_tool("s1", "get_weather", {"city": "北京"})
|
||||
assert calls[-1] == "北京"
|
||||
assert await fdb.get_meta("s1", "city") == "北京"
|
||||
|
||||
# 之后不带城市 -> 用最近一次记住的
|
||||
out = await main.execute_tool("s1", "get_weather", {})
|
||||
assert calls[-1] == "北京"
|
||||
|
||||
|
||||
async def test_tool_executor_other_tools(fdb, offline, monkeypatch):
|
||||
async def fake_news(q):
|
||||
return f"新闻:{q}"
|
||||
|
||||
async def fake_search(q):
|
||||
return f"搜索:{q}"
|
||||
|
||||
monkeypatch.setattr(main.tools, "get_news", fake_news)
|
||||
monkeypatch.setattr(main.tools, "web_search", fake_search)
|
||||
assert await main.execute_tool("s1", "get_news", {}) == "新闻:今日热点"
|
||||
assert await main.execute_tool("s1", "web_search",
|
||||
{"query": "量子"}) == "搜索:量子"
|
||||
assert await main.execute_tool("s1", "web_search", {}) == "缺少搜索词。"
|
||||
assert "未知工具" in await main.execute_tool("s1", "nope", {})
|
||||
|
||||
|
||||
async def test_system_prompt_includes_lore_and_relevant(fdb, offline):
|
||||
await fdb.upsert_lore("s1", "诞生", "小暖是用户亲手造的")
|
||||
prompt = await main.system_prompt(
|
||||
"s1", [{"text": "用户喜欢打篮球"}])
|
||||
assert "既定事实" in prompt
|
||||
assert "小暖是用户亲手造的" in prompt
|
||||
assert "用户喜欢打篮球" in prompt
|
||||
|
||||
|
||||
async def test_compose_views(fdb, offline):
|
||||
# 记忆
|
||||
await fdb.add_memories("s1", ["用户叫小明"], None)
|
||||
text = await main.compose_view("s1", "memories")
|
||||
assert "用户叫小明" in text
|
||||
# 空记忆的说法
|
||||
text = await main.compose_view("s2", "memories")
|
||||
assert "还不太认识你" in text
|
||||
# 身世
|
||||
text = await main.compose_view("s2", "lore")
|
||||
assert "一个字都不会忘" in text
|
||||
# 时间表空表
|
||||
text = await main.compose_view("s2", "timetable")
|
||||
assert "时间表还空着" in text
|
||||
@@ -0,0 +1,98 @@
|
||||
"""纯逻辑测试:连续天数 / 彩蛋时刻 / 时间间隔 / 模板 / 向量"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.connector import ASSISTANT_NAME, hash_embed
|
||||
from app.db import cosine
|
||||
from app.main import (_daily_slot, absence_text, compute_streak, digest_echo,
|
||||
human_gap)
|
||||
|
||||
NOW = datetime(2026, 9, 21, 19, 30) # 周一晚上
|
||||
|
||||
|
||||
# ---- 连续陪伴天数 ----
|
||||
|
||||
def test_streak_yesterday_increments():
|
||||
assert compute_streak("2026-09-20", 5, NOW) == 6
|
||||
|
||||
|
||||
def test_streak_gap_resets():
|
||||
assert compute_streak("2026-09-19", 5, NOW) == 1
|
||||
|
||||
|
||||
def test_streak_first_time():
|
||||
assert compute_streak("", 0, NOW) == 1
|
||||
|
||||
|
||||
def test_streak_same_day_unchanged():
|
||||
assert compute_streak("2026-09-21", 7, NOW) == 7
|
||||
|
||||
|
||||
# ---- 每日彩蛋时刻(哈希槽位)----
|
||||
|
||||
def test_daily_slot_deterministic():
|
||||
a = _daily_slot("sess-a", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
b = _daily_slot("sess-a", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_daily_slot_within_window():
|
||||
for key in ("replay", "share", "diary", "digest"):
|
||||
slot = _daily_slot("sess-a", key, 10 * 60, 21 * 60, "2026-09-21")
|
||||
assert 10 * 60 <= slot < 21 * 60
|
||||
|
||||
|
||||
def test_daily_slot_differs_per_session():
|
||||
a = _daily_slot("sess-a", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
b = _daily_slot("sess-b", "replay", 19 * 60, 22 * 60, "2026-09-21")
|
||||
# 不保证必然不同,但同窗口内两者都合法;这里验证可分布
|
||||
assert 19 * 60 <= a < 22 * 60 and 19 * 60 <= b < 22 * 60
|
||||
|
||||
|
||||
# ---- 时间间隔说法 ----
|
||||
|
||||
def test_human_gap_minutes():
|
||||
assert human_gap(59) == "1 分钟"
|
||||
assert human_gap(5 * 60) == "5 分钟"
|
||||
|
||||
|
||||
def test_human_gap_hours():
|
||||
assert human_gap(2 * 3600 + 47 * 60) == "2 小时 47 分钟"
|
||||
assert human_gap(2 * 3600) == "2 小时"
|
||||
|
||||
|
||||
def test_human_gap_days():
|
||||
assert human_gap(3 * 86400) == "3 天"
|
||||
|
||||
|
||||
# ---- 缺勤迎接(回声模板)----
|
||||
|
||||
async def test_absence_text_mentions_the_gap():
|
||||
line = await absence_text("s1", 3700)
|
||||
assert "小时" in line
|
||||
assert ("安静" in line) or ("心跳" in line) or ("开了灯" in line)
|
||||
|
||||
|
||||
# ---- 学徒回顾模板 ----
|
||||
|
||||
def test_digest_echo_signature_and_numbers():
|
||||
d = digest_echo({"msgs": 12, "mem": 3}, 45)
|
||||
assert "12" in d and "3" in d and "45" in d and ASSISTANT_NAME in d
|
||||
|
||||
|
||||
# ---- 向量(回声模式哈希 embedding)----
|
||||
|
||||
def test_hash_embed_self_similarity():
|
||||
v = hash_embed("我喜欢打篮球")
|
||||
assert len(v) == 256
|
||||
assert abs(cosine(v, v) - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_hash_embed_related_beats_unrelated():
|
||||
base = hash_embed("我喜欢打篮球")
|
||||
near = hash_embed("打篮球的时候我很开心")
|
||||
far = hash_embed("明天北京的天气怎么样")
|
||||
assert cosine(base, near) > cosine(base, far)
|
||||
|
||||
|
||||
def test_cosine_handles_empty():
|
||||
assert cosine(hash_embed("x"), []) == 0.0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""小模型路由的离线测试(mock 模型服务,不发真实请求)"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app import connector
|
||||
|
||||
NOW = datetime(2026, 9, 21, 19, 30)
|
||||
|
||||
|
||||
def mock_model(content: str):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": content}}]})
|
||||
return handler
|
||||
|
||||
|
||||
def enable_router(monkeypatch, content=None, status=200):
|
||||
monkeypatch.setattr(connector, "SMALL_KEY", "k")
|
||||
monkeypatch.setattr(connector, "SMALL_MODEL", "m")
|
||||
if status == 200:
|
||||
transport = httpx.MockTransport(mock_model(content or ""))
|
||||
else:
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(status))
|
||||
monkeypatch.setattr(connector, "_TEST_TRANSPORT", transport)
|
||||
|
||||
|
||||
def test_route_full_parse(monkeypatch):
|
||||
raw = json.dumps({
|
||||
"view": "memories",
|
||||
"letter": {"days": 7, "body": "给未来的我:加油"},
|
||||
"negative_mood": True,
|
||||
"city_hint": "杭州",
|
||||
"facts": ["用户叫小明", "用户叫小明", "用户喜欢打篮球"],
|
||||
"events": [{"kind": "alarm", "fire_at": "2026-09-22T07:00:00",
|
||||
"text": "起床", "repeat": "daily"},
|
||||
{"kind": "reminder", "fire_at": "2020-01-01T00:00:00",
|
||||
"text": "过去的应被丢弃"}],
|
||||
"routines": [{"label": "到校", "time": "07:40", "kind": "inferred"},
|
||||
{"label": "打游戏", "time": "22:00", "kind": "explicit"}],
|
||||
"promises": [{"note": "面试", "due_at": None}],
|
||||
"lore": [{"topic": "诞生", "body": "小暖是用户亲手造的"}],
|
||||
})
|
||||
enable_router(monkeypatch, "好的 " + raw + " (忽略我)")
|
||||
|
||||
sig = asyncio.run(connector.route(
|
||||
"我叫小明,我在杭州,明早7点叫我起床,明天面试", NOW, ["用户叫小明"]))
|
||||
|
||||
assert sig["view"] == "memories"
|
||||
assert sig["letter"] == (7, "给未来的我:加油")
|
||||
assert sig["negative_mood"] is True
|
||||
assert sig["city_hint"] == "杭州"
|
||||
assert sig["facts"] == ["用户喜欢打篮球"] # 去重 + 已有的不要
|
||||
assert len(sig["events"]) == 1
|
||||
assert sig["events"][0]["kind"] == "alarm"
|
||||
assert sig["events"][0]["repeat"] == "daily"
|
||||
assert sig["events"][0]["fire_at"].day == 22
|
||||
assert sig["routines"] == [{"label": "到校", "minute": 460,
|
||||
"kind": "inferred"}]
|
||||
assert sig["promises"][0]["note"] == "面试"
|
||||
assert (sig["promises"][0]["due_at"].day,
|
||||
sig["promises"][0]["due_at"].hour) == (22, 18) # null -> 次日18点
|
||||
assert sig["lore"] == [{"topic": "诞生", "body": "小暖是用户亲手造的"}]
|
||||
|
||||
|
||||
def test_route_garbage_returns_empty(monkeypatch):
|
||||
enable_router(monkeypatch, "这不是 JSON")
|
||||
sig = asyncio.run(connector.route("随便", NOW, []))
|
||||
assert sig["view"] is None and sig["facts"] == [] and sig["letter"] is None
|
||||
|
||||
|
||||
def test_route_server_error_returns_empty(monkeypatch):
|
||||
enable_router(monkeypatch, status=500)
|
||||
sig = asyncio.run(connector.route("随便", NOW, []))
|
||||
assert sig["view"] is None
|
||||
|
||||
|
||||
def test_route_without_model_is_noop(monkeypatch):
|
||||
monkeypatch.setattr(connector, "SMALL_KEY", "")
|
||||
monkeypatch.setattr(connector, "SMALL_MODEL", "")
|
||||
sig = asyncio.run(connector.route("明早7点叫我起床", NOW, []))
|
||||
assert sig["view"] is None and sig["events"] == []
|
||||
@@ -0,0 +1,102 @@
|
||||
"""原生工具调用循环的离线测试(mock 模型服务)"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from app import connector
|
||||
|
||||
|
||||
def test_tool_loop_executes_and_feeds_back(monkeypatch):
|
||||
calls = {"model": 0}
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
calls["model"] += 1
|
||||
if calls["model"] == 1:
|
||||
assert body.get("tools"), "第一轮应带上工具声明"
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "",
|
||||
"tool_calls": [{"id": "call-1", "type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": json.dumps({"city": "杭州"})}}]}}]})
|
||||
tool_msg = body["messages"][-1]
|
||||
captured["tool_msg"] = tool_msg
|
||||
assert tool_msg["role"] == "tool"
|
||||
assert tool_msg["tool_call_id"] == "call-1"
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "杭州今天有小雨,出门记得带伞哦~"}}]})
|
||||
|
||||
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
||||
httpx.MockTransport(handler))
|
||||
|
||||
async def exec_(name, args):
|
||||
captured["exec"] = (name, args)
|
||||
return "杭州 小雨 15~22°C 降水概率70%"
|
||||
|
||||
out = asyncio.run(connector.run_model_tools(
|
||||
[{"role": "user", "content": "今天出门要带伞吗"}], exec_))
|
||||
|
||||
assert out == "杭州今天有小雨,出门记得带伞哦~"
|
||||
assert captured["exec"] == ("get_weather", {"city": "杭州"})
|
||||
assert calls["model"] == 2
|
||||
|
||||
|
||||
def test_tool_loop_gives_up_after_max_rounds(monkeypatch):
|
||||
calls = {"model": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
calls["model"] += 1
|
||||
if "tool_calls" not in json.dumps(body["messages"][-1]) and \
|
||||
calls["model"] <= 3:
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "",
|
||||
"tool_calls": [{"id": f"c{calls['model']}", "type": "function",
|
||||
"function": {"name": "web_search",
|
||||
"arguments": "{}"}}]}}]})
|
||||
# 第 4 次(超过 max_rounds 的强制收尾)不带 tools
|
||||
assert "tools" not in body
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "我直接说吧"}}]})
|
||||
|
||||
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
||||
httpx.MockTransport(handler))
|
||||
|
||||
async def exec_(name, args):
|
||||
return "结果"
|
||||
|
||||
out = asyncio.run(connector.run_model_tools(
|
||||
[{"role": "user", "content": "搜一下"}], exec_, max_rounds=3))
|
||||
assert out == "我直接说吧"
|
||||
assert calls["model"] == 4
|
||||
|
||||
|
||||
def test_tool_loop_bad_arguments_become_empty_dict(monkeypatch):
|
||||
calls = {"model": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["model"] += 1
|
||||
if calls["model"] == 1:
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "",
|
||||
"tool_calls": [{"id": "c1", "type": "function",
|
||||
"function": {"name": "get_weather",
|
||||
"arguments": "{坏了"}}]}}]})
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "好"}}]})
|
||||
|
||||
monkeypatch.setattr(connector, "_TEST_TRANSPORT",
|
||||
httpx.MockTransport(handler))
|
||||
seen = {}
|
||||
|
||||
async def exec_(name, args):
|
||||
seen["args"] = args
|
||||
return "ok"
|
||||
|
||||
out = asyncio.run(connector.run_model_tools(
|
||||
[{"role": "user", "content": "天气"}], exec_))
|
||||
assert out == "好" and seen["args"] == {}
|
||||
@@ -1,49 +0,0 @@
|
||||
"""原生工具调用循环的离线测试(mock 模型服务):uv run python tool_agent_test.py"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from app import connector
|
||||
|
||||
calls = {"model": 0}
|
||||
captured = {}
|
||||
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
calls["model"] += 1
|
||||
if calls["model"] == 1:
|
||||
# 第一轮:模型决定调用天气工具
|
||||
assert body.get("tools"), "第一轮应带上工具声明"
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant", "content": "",
|
||||
"tool_calls": [{"id": "call-1", "type": "function",
|
||||
"function": {"name": "get_weather",
|
||||
"arguments": json.dumps({"city": "杭州"})}}]}}]})
|
||||
# 第二轮:应带着工具结果回来
|
||||
tool_msg = body["messages"][-1]
|
||||
captured["tool_msg"] = tool_msg
|
||||
assert tool_msg["role"] == "tool" and tool_msg["tool_call_id"] == "call-1"
|
||||
return httpx.Response(200, json={"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "杭州今天有小雨,出门记得带伞哦~"}}]})
|
||||
|
||||
|
||||
async def main():
|
||||
connector._TEST_TRANSPORT = httpx.MockTransport(handler)
|
||||
|
||||
async def exec_(name, args):
|
||||
captured["exec"] = (name, args)
|
||||
return "杭州 小雨 15~22°C 降水概率70%"
|
||||
|
||||
out = await connector.run_model_tools(
|
||||
[{"role": "user", "content": "今天出门要带伞吗"}], exec_)
|
||||
|
||||
assert out == "杭州今天有小雨,出门记得带伞哦~", out
|
||||
assert captured["exec"] == ("get_weather", {"city": "杭州"}), captured
|
||||
assert calls["model"] == 2, calls
|
||||
print("工具调用循环测试通过")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -75,6 +75,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
@@ -159,6 +168,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.2.0"
|
||||
@@ -206,6 +224,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.11"
|
||||
@@ -215,6 +242,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
@@ -280,6 +316,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
@@ -569,6 +633,12 @@ dependencies = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
@@ -577,3 +647,9 @@ requires-dist = [
|
||||
{ name = "textual", specifier = ">=8.2.8" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||
]
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
"""向量逻辑离线测试:uv run python vec_test.py"""
|
||||
from app.connector import hash_embed
|
||||
from app.db import cosine
|
||||
|
||||
base = hash_embed("我喜欢打篮球")
|
||||
near = hash_embed("打篮球的时候我很开心")
|
||||
far = hash_embed("明天北京的天气怎么样")
|
||||
|
||||
print("self:", round(cosine(base, base), 3))
|
||||
print("near:", round(cosine(base, near), 3))
|
||||
print("far: ", round(cosine(base, far), 3))
|
||||
|
||||
assert abs(cosine(base, base) - 1.0) < 1e-9, "自身相似度应为 1"
|
||||
assert cosine(base, near) > cosine(base, far), "相近文本应比无关文本相似"
|
||||
assert cosine(base, []) == 0.0 and cosine([], base) == 0.0, "空向量应返回 0"
|
||||
assert len(base) == 256, "维度应为 256"
|
||||
print("向量逻辑测试通过")
|
||||
Reference in new issue
Block a user