完善测试: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:
snowy-yang committed 2026-09-23 20:20:37 +08:00
1 parent fe96acbda2
commit 6981c6df4d
13 files changed
+1107 -192

No files matched your search

-52
View File
@@ -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("扩展功能测试通过")
+12
View File
@@ -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"
-74
View File
@@ -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())
+341
View File
@@ -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")
+93
View File
@@ -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"
+143
View File
@@ -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)
+158
View File
@@ -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
+98
View File
@@ -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
+84
View File
@@ -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"] == []
+102
View File
@@ -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"] == {}
-49
View File
@@ -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())
Generated
+76
View File
@@ -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
View File
@@ -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("向量逻辑测试通过")