mirror of
https://github.com/snowy-yang/xiaonuan.git
synced 2026-10-21 08:04:48 +00:00
- app/onebot.py: /onebot/v11/ws 反向 WS;私聊全回、群聊仅回应 @她 并自动 @ 回去 - CQ 码字符串与消息段数组双格式解析;会话 id ob-<self_id>-p/g-<目标> 与 TUI 隔离 - action echo 配对调用(send_private_msg / send_group_msg),retcode==0 才落历史 - remember_later 的推送(事件确认/关怀/连续天数/身世)自动转成 QQ 消息 - 主动消息路由:push_proactive 优先 TUI、ob- 会话走 QQ,均不在线保持 pending - 主干重构:chat_turn() 供 TUI 与 OneBot 共用;助手消息由传输层确认送达后落库 - 可选 OB_ACCESS_TOKEN 令牌校验;8 个新测试(回复/CQ剥离/群@/通知转换/主动推送/令牌)
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""共享测试设施:内存版 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 连接)"""
|
|
from app import onebot
|
|
|
|
app = FastAPI()
|
|
app.websocket("/ws")(main.chat)
|
|
app.include_router(onebot.router)
|
|
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")
|