From 6981c6df4d16f0ad88afe0ab3d1de1ed14b94766 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:20:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B5=8B=E8=AF=95=EF=BC=9Apy?= =?UTF-8?q?test=20=E5=A5=97=E4=BB=B6=EF=BC=88=E8=B7=AF=E7=94=B1=E5=99=A8/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=BE=AA=E7=8E=AF/WS=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF/=E6=8A=95=E9=80=92/PG=E9=9B=86=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 保留为手动脚本 --- extra_test.py | 52 ------ pyproject.toml | 12 ++ router_test.py | 74 --------- tests/conftest.py | 341 ++++++++++++++++++++++++++++++++++++++++ tests/test_chat_flow.py | 93 +++++++++++ tests/test_db_pg.py | 143 +++++++++++++++++ tests/test_delivery.py | 158 +++++++++++++++++++ tests/test_pure.py | 98 ++++++++++++ tests/test_router.py | 84 ++++++++++ tests/test_tool_loop.py | 102 ++++++++++++ tool_agent_test.py | 49 ------ uv.lock | 76 +++++++++ vec_test.py | 17 -- 13 files changed, 1107 insertions(+), 192 deletions(-) delete mode 100644 extra_test.py delete mode 100644 router_test.py create mode 100644 tests/conftest.py create mode 100644 tests/test_chat_flow.py create mode 100644 tests/test_db_pg.py create mode 100644 tests/test_delivery.py create mode 100644 tests/test_pure.py create mode 100644 tests/test_router.py create mode 100644 tests/test_tool_loop.py delete mode 100644 tool_agent_test.py delete mode 100644 vec_test.py diff --git a/extra_test.py b/extra_test.py deleted file mode 100644 index 4e16df2..0000000 --- a/extra_test.py +++ /dev/null @@ -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("扩展功能测试通过") diff --git a/pyproject.toml b/pyproject.toml index 7eca2ff..b1c66c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/router_test.py b/router_test.py deleted file mode 100644 index f4ec366..0000000 --- a/router_test.py +++ /dev/null @@ -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()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4600883 --- /dev/null +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_chat_flow.py b/tests/test_chat_flow.py new file mode 100644 index 0000000..2242b2e --- /dev/null +++ b/tests/test_chat_flow.py @@ -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" diff --git a/tests/test_db_pg.py b/tests/test_db_pg.py new file mode 100644 index 0000000..264415a --- /dev/null +++ b/tests/test_db_pg.py @@ -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) diff --git a/tests/test_delivery.py b/tests/test_delivery.py new file mode 100644 index 0000000..e570057 --- /dev/null +++ b/tests/test_delivery.py @@ -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 diff --git a/tests/test_pure.py b/tests/test_pure.py new file mode 100644 index 0000000..400f466 --- /dev/null +++ b/tests/test_pure.py @@ -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 diff --git a/tests/test_router.py b/tests/test_router.py new file mode 100644 index 0000000..3c15894 --- /dev/null +++ b/tests/test_router.py @@ -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"] == [] diff --git a/tests/test_tool_loop.py b/tests/test_tool_loop.py new file mode 100644 index 0000000..76c9972 --- /dev/null +++ b/tests/test_tool_loop.py @@ -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"] == {} diff --git a/tool_agent_test.py b/tool_agent_test.py deleted file mode 100644 index 84a2ce9..0000000 --- a/tool_agent_test.py +++ /dev/null @@ -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()) diff --git a/uv.lock b/uv.lock index 9300abf..4bb2acf 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +] diff --git a/vec_test.py b/vec_test.py deleted file mode 100644 index 13e4d52..0000000 --- a/vec_test.py +++ /dev/null @@ -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("向量逻辑测试通过")