小暖 · AI 陪伴(容器世界的数字生命)

- FastAPI + WebSocket 服务,Textual TUI 终端客户端
- PostgreSQL 存储专用连接层(asyncpg 连接池,主程序零 SQL)
- 无限且永久的记忆:语义检索注入 + 每夜巩固 + 身世档案(lore)
- 作息推断(猜上下学时间)、定时事件(叫早/每日提醒)
- 情绪回访 / 承诺追踪 / 慢信 / 回忆杀 / 她的生活 / 连续陪伴天数
- 原生 function calling 工具:天气 / 新闻 / 搜索(免 Key)
- 极小模型快速路由(AI_SMALL_*)+ 夜间批量通道(AI_BATCH_*)
- 小学徒小满:夜间琐事整理,小暖晨间检查转述
- 离线测试:路由器 / 工具循环 / 杂项 / 向量 / TUI 冒烟
This commit is contained in:
snowy-yang committed 2026-09-23 19:32:17 +08:00
commit 4494987e54
20 files changed
+3496

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
"""AI 连接器:大模型能力的统一入口(OpenAI 兼容接口)。
- 主模型:流式对话已由「原生工具调用循环」取代(run_model_tools)
- 小模型(AI_SMALL_*,缺省复用主模型):每条消息一次快速路由判定
- 夜间通道(AI_BATCH_*,缺省复用主模型):小学徒夜间琐事用
- 回声模式:什么模型都没配时,诚实回声,不做任何假装
"""
import asyncio
import json
import os
import re
from datetime import datetime, timedelta
import httpx
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.getenv("AI_API_KEY", "")
BASE_URL = os.getenv("AI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
MODEL = os.getenv("AI_MODEL", "gpt-4o-mini")
EMBED_MODEL = os.getenv("AI_EMBED_MODEL", "text-embedding-3-small")
EMBED_DIM = 256 # 回声模式哈希 embedding 的维度
# 夜间/批量通道:给小学徒干琐事用的便宜通道,不配则回落主模型
BATCH_KEY = os.getenv("AI_BATCH_KEY", "") or API_KEY
BATCH_BASE = (os.getenv("AI_BATCH_BASE_URL", "") or BASE_URL).rstrip("/")
BATCH_MODEL = os.getenv("AI_BATCH_MODEL", "") or MODEL
BATCH_CONFIGURED = bool(os.getenv("AI_BATCH_MODEL")
or os.getenv("AI_BATCH_BASE_URL"))
# 极小模型快速路由:不配则复用主模型
SMALL_KEY = os.getenv("AI_SMALL_KEY", "") or API_KEY
SMALL_BASE = (os.getenv("AI_SMALL_BASE_URL", "") or BASE_URL).rstrip("/")
SMALL_MODEL = os.getenv("AI_SMALL_MODEL", "") or MODEL
# 小暖的小学徒:夜间琐事都是她干的
ASSISTANT_NAME = os.getenv("AI_ASSISTANT_NAME", "小满")
ASSISTANT_PERSONA = os.getenv(
"AI_ASSISTANT_PERSONA",
"你是一位小学徒,勤快、话少、偶尔偷懒。你负责夜间的琐事:整理回忆、"
"写回顾草稿。你写的东西都会经小暖检查后才交给用户。",
)
# ---- 回声模式 ----
async def _echo_stream(text: str):
"""回声模式:把用户的话切片吐回去"""
reply = f"(回声模式,未配置 AI_API_KEY)你说的是:{text}"
for ch in reply:
yield ch
await asyncio.sleep(0.015)
# ---- 向量 ----
def hash_embed(text: str) -> list[float]:
"""无 embedding 接口时的本地替身:字符二元组哈希到 256 维并归一化"""
import hashlib
vec = [0.0] * EMBED_DIM
t = text.strip().lower()
grams = [t[i:i + 2] for i in range(len(t) - 1)] or [t]
for g in grams:
h = int(hashlib.md5(g.encode("utf-8")).hexdigest(), 16)
vec[h % EMBED_DIM] += 1.0
norm = sum(v * v for v in vec) ** 0.5 or 1.0
return [v / norm for v in vec]
async def embed(texts: list[str]) -> list[list[float]]:
"""批量把文本变成向量。没配 embedding 接口时用本地哈希"""
if not API_KEY:
return [hash_embed(t) for t in texts]
async with httpx.AsyncClient(timeout=60) as client:
res = await client.post(
f"{BASE_URL}/embeddings",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": EMBED_MODEL, "input": texts},
)
res.raise_for_status()
return [d["embedding"] for d in res.json()["data"]]
# ---- 基础调用 ----
_TEST_TRANSPORT = None # 测试注入点
def _make_client() -> httpx.AsyncClient:
if _TEST_TRANSPORT is not None:
return httpx.AsyncClient(timeout=120, transport=_TEST_TRANSPORT)
return httpx.AsyncClient(timeout=120)
async def _complete(base: str, key: str, model: str,
messages: list[dict], temperature: float = 0) -> str:
async with _make_client() as client:
res = await client.post(
f"{base}/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={"model": model, "messages": messages,
"temperature": temperature},
)
res.raise_for_status()
return res.json()["choices"][0]["message"]["content"]
async def complete(messages: list[dict], temperature: float = 0) -> str:
"""主模型非流式调用:问候/日记/关怀这类一次性生成用"""
return await _complete(BASE_URL, API_KEY, MODEL, messages, temperature)
async def complete_night(messages: list[dict], temperature: float = 0.3) -> str:
"""夜间通道:配置了 AI_BATCH_* 走便宜通道,否则回落主模型"""
return await _complete(BATCH_BASE, BATCH_KEY, BATCH_MODEL,
messages, temperature)
# ---- 原生工具调用(function calling)----
TOOLS_SCHEMA = [
{"type": "function", "function": {
"name": "get_weather",
"description": "查询某地现在的天气和今日温度、降水概率。"
"用户问到天气、要不要带伞、冷不冷,或你在写和出门"
"有关的问候需要天气时使用。",
"parameters": {"type": "object", "properties": {
"city": {"type": "string",
"description": "城市名。用户没提到就省略,"
"系统会尝试从记忆里推断。"}},
"required": []}}},
{"type": "function", "function": {
"name": "get_news",
"description": "看新闻。用户问新闻、热点、最近发生什么事时使用。",
"parameters": {"type": "object", "properties": {
"query": {"type": "string",
"description": "新闻主题关键词,如 科技;省略则给头条"}},
"required": []}}},
{"type": "function", "function": {
"name": "web_search",
"description": "联网搜索你不知道的、或时效性强的事实。",
"parameters": {"type": "object", "properties": {
"query": {"type": "string", "description": "搜索词"}},
"required": ["query"]}}},
]
async def run_model_tools(messages: list[dict], executor,
max_rounds: int = 3) -> str:
"""带原生工具调用的对话循环:模型自己决定调什么工具,
executor(name, args) 负责执行并返回文本结果。返回最终回复文本。"""
msgs = list(messages)
async with _make_client() as client:
for _ in range(max_rounds):
res = await client.post(
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": MODEL, "messages": msgs,
"tools": TOOLS_SCHEMA},
)
res.raise_for_status()
m = res.json()["choices"][0]["message"]
calls = m.get("tool_calls")
if not calls:
return m.get("content") or ""
msgs.append({"role": "assistant",
"content": m.get("content") or "",
"tool_calls": calls})
for tc in calls:
fn = tc.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
result = await executor(fn.get("name"), args)
msgs.append({"role": "tool",
"tool_call_id": tc.get("id") or "",
"content": str(result)})
# 超出轮次上限:不带工具强制收尾,宁可平淡也不空转
res = await client.post(
f"{BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": MODEL, "messages": msgs},
)
res.raise_for_status()
return res.json()["choices"][0]["message"].get("content") or ""
# ---- 极小模型快速路由:每条消息一次调用,判完所有快事 ----
_ROUTER_VIEWS = {"memories", "timetable", "diary", "digest",
"assistant", "status", "lore"}
_ROUTER_LABELS = {"出门上学", "到校", "放学", "到家", "起床", "睡觉"}
async def route(text: str, now: datetime, existing: list[str]) -> dict:
"""小模型一次判定:查看档案 / 慢信 / 情绪 / 城市 / 各类提取。
所有字段已清洗;模型不可用或解析失败时返回空判定。"""
empty = {"view": None, "letter": None, "negative_mood": False,
"city_hint": "", "facts": [], "events": [], "routines": [],
"promises": [], "lore": []}
if not (SMALL_KEY and SMALL_MODEL):
return empty
user = (
f"现在是 {now:%Y-%m-%d %H:%M}(本地时间)。对下面的用户发言做判定,"
'只输出一个 JSON 对象:\n'
'{"view": null或"memories|timetable|diary|digest|assistant|status|lore",'
' "letter": null或{"days":7,"body":"信的内容"}或{"ask":true},'
' "negative_mood": false, "city_hint": "",'
' "facts": ["用户叫小明"],'
' "events": [{"kind":"alarm或reminder","fire_at":"ISO时间",'
'"text":"起床","repeat":"once或daily"}],'
' "routines": [{"label":"出门上学|到校|放学|到家|起床|睡觉 之一",'
'"time":"HH:MM","kind":"explicit或inferred"}],'
' "promises": [{"note":"面试","due_at":"ISO时间或null"}],'
' "lore": [{"topic":"诞生","body":"小暖是用户亲手造的"}]}\n'
"字段含义:\n"
"- view:用户想看她的某份档案——记忆/时间表/日记/昨天回顾/学徒/状态/身世;"
"普通聊天就是 null。\n"
"- letter:写给未来自己的慢信。days=天数、body=内容;"
'想写但没说完整给 {"ask":true};不是写信就是 null。\n'
"- negative_mood:是否在倾诉负面情绪。\n"
'- city_hint:这句话里提到的用户所在城市("我在杭州,明天冷不冷"→"杭州"),'
"没有为空串。\n"
"- facts:值得长期记住的关于用户的信息,以「用户」开头,"
"已有的不要重复,最多5条。\n"
"- events:定时任务(叫我起床/提醒我/每天XX点…);"
"说「每天」repeat=daily 否则 once;fire_at 推算成最近一个将来的时间。\n"
"- routines:作息规律(explicit,如「每天7点半出门上学」)或"
"当下日常活动(inferred,如「到学校啦」,time 用当前时间)。\n"
"- promises:用户提到将来要做/会发生、值得之后回访的事;"
"due_at 说不清给 null。\n"
"- lore:用户在定义小暖自己的身世、身份、来历"
"(「你是XX造的」「你的生日是X」「你叫小暖因为X」)。\n"
"哪类都没有就 null / 空数组 / false。\n"
f"已有记忆:{json.dumps(existing, ensure_ascii=False)}\n"
f"用户发言:{text}"
)
try:
raw = await _complete(
SMALL_BASE, SMALL_KEY, SMALL_MODEL,
[{"role": "system",
"content": "你是意图路由器,只输出 JSON。"},
{"role": "user", "content": user}],
temperature=0)
m = re.search(r"\{.*\}", raw, re.S)
if not m:
return empty
data = json.loads(m.group())
except Exception:
return empty
out = dict(empty)
if data.get("view") in _ROUTER_VIEWS:
out["view"] = data["view"]
letter = data.get("letter")
if isinstance(letter, dict):
if letter.get("ask"):
out["letter"] = ("ask", "")
else:
try:
days = int(letter.get("days"))
body = str(letter.get("body") or "").strip()
if 1 <= days <= 365 and body:
out["letter"] = (days, body[:2000])
except (TypeError, ValueError):
pass
out["negative_mood"] = bool(data.get("negative_mood"))
out["city_hint"] = str(data.get("city_hint") or "").strip()[:20]
if isinstance(data.get("facts"), list):
for f in data["facts"][:5]:
s = str(f).strip()
if s and s not in existing and s not in out["facts"]:
out["facts"].append(s)
fallback = (now + timedelta(days=1)).replace(hour=18, minute=0,
second=0, microsecond=0)
for item in (data.get("events") or [])[:3]:
try:
fire = datetime.fromisoformat(str(item["fire_at"]))
except (KeyError, ValueError, TypeError):
continue
if fire <= now:
continue
out["events"].append({
"kind": "alarm" if item.get("kind") == "alarm" else "reminder",
"fire_at": fire,
"text": str(item.get("text") or "提醒").strip(),
"repeat": "daily" if item.get("repeat") == "daily" else "once"})
for item in (data.get("routines") or [])[:2]:
label = str(item.get("label") or "")
mm = re.match(r"^(\d{1,2}):(\d{2})$", str(item.get("time") or ""))
if label in _ROUTER_LABELS and mm:
out["routines"].append({
"label": label,
"minute": int(mm.group(1)) * 60 + int(mm.group(2)),
"kind": "explicit" if item.get("kind") == "explicit"
else "inferred"})
for item in (data.get("promises") or [])[:3]:
note = str(item.get("note") or "").strip()
if not note:
continue
try:
due = datetime.fromisoformat(str(item.get("due_at")))
if due <= now:
due = fallback
except (ValueError, TypeError):
due = fallback
out["promises"].append({"note": note, "due_at": due})
for item in (data.get("lore") or [])[:3]:
topic = str(item.get("topic") or "").strip()[:12]
body = str(item.get("body") or "").strip()[:200]
if topic and body:
out["lore"].append({"topic": topic, "body": body})
return out
+589
View File
@@ -0,0 +1,589 @@
"""PostgreSQL 连接层:asyncpg 连接池 + schema + 全部数据库函数。
主程序只 import 这一层,不直接碰 SQL。
连接串从环境变量 PG_DSN 读取,例如:
postgresql://postgres:密码@localhost:5432/ai_chat
"""
import time
import asyncpg
_pool: asyncpg.Pool | None = None
async def init(dsn: str) -> None:
"""启动时调用:建连接池 + 建表"""
global _pool
_pool = await asyncpg.create_pool(dsn, min_size=1, max_size=10)
async with _pool.acquire() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
ts DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_session ON history(session, id);
CREATE TABLE IF NOT EXISTS memories (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
text TEXT NOT NULL,
ts DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session, id);
"""
)
# 向量列:FLOAT8[] 不依赖 pgvector 扩展,任何 PG 都能用
await conn.execute(
"ALTER TABLE memories ADD COLUMN IF NOT EXISTS embedding FLOAT8[]"
)
# 定时事件(叫早/提醒),status: pending / done;
# repeat: once / daily(每日任务今天发过没发过看 last_fired)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
kind TEXT NOT NULL,
fire_at DOUBLE PRECISION NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status, fire_at);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session, status);
"""
)
await conn.execute(
"ALTER TABLE events ADD COLUMN IF NOT EXISTS repeat TEXT DEFAULT 'once'")
await conn.execute(
"ALTER TABLE events ADD COLUMN IF NOT EXISTS minute_of_day INTEGER")
await conn.execute(
"ALTER TABLE events ADD COLUMN IF NOT EXISTS last_fired TEXT")
# 作息规律(她猜出来的上下学时间等):
# minute = 一天中的分钟数,evidence = 依据次数,UNIQUE 保证每个标签一行
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS routines (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
label TEXT NOT NULL,
minute INTEGER NOT NULL,
evidence INTEGER NOT NULL DEFAULT 1,
kind TEXT NOT NULL DEFAULT 'inferred',
last_fired TEXT,
updated_at DOUBLE PRECISION NOT NULL,
UNIQUE (session, label)
);
"""
)
# 她惦记的事:emotion(情绪回访)/ promise(承诺追踪)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS care (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
kind TEXT NOT NULL,
note TEXT NOT NULL,
due_at DOUBLE PRECISION NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_care_due ON care(status, due_at);
"""
)
# 慢信:写信人自己,到日子才拆
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS letters (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
body TEXT NOT NULL,
deliver_at DOUBLE PRECISION NOT NULL,
status TEXT NOT NULL DEFAULT 'sealed',
created_at DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_letters_due ON letters(status, deliver_at);
"""
)
# 杂项标记:连续陪伴天数、每日彩蛋是否已发等
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS meta (
session TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (session, key)
);
"""
)
# 她的日记:每晚睡前写一篇
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS diary (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
day TEXT NOT NULL,
body TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL,
UNIQUE (session, day)
);
"""
)
# 昨日回顾:小学徒夜里整理,小暖早上检查后转述
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS digests (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
day TEXT NOT NULL,
body TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL,
UNIQUE (session, day)
);
"""
)
# 身世档案:用户亲口告诉她的关于她自己的既定事实,全文注入、绝不编造
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS lore (
id BIGSERIAL PRIMARY KEY,
session TEXT NOT NULL,
topic TEXT NOT NULL,
body TEXT NOT NULL,
updated_at DOUBLE PRECISION NOT NULL,
UNIQUE (session, topic)
);
"""
)
async def close() -> None:
if _pool is not None:
await _pool.close()
async def get_history(session: str, limit: int = 40) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT role, content FROM history WHERE session = $1 "
"ORDER BY id DESC LIMIT $2",
session, limit,
)
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
async def add_message(session: str, role: str, content: str) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"INSERT INTO history (session, role, content, ts) "
"VALUES ($1, $2, $3, $4)",
session, role, content, time.time(),
)
async def get_memories(session: str) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, text FROM memories WHERE session = $1 ORDER BY id",
session,
)
return [{"id": r["id"], "text": r["text"]} for r in rows]
async def add_memories(
session: str, facts: list[str], embeddings: list[list[float]] | None = None
) -> None:
rows = [
(session, f, time.time(),
embeddings[i] if embeddings else None)
for i, f in enumerate(facts)
]
async with _pool.acquire() as conn:
await conn.executemany(
"INSERT INTO memories (session, text, ts, embedding) "
"VALUES ($1, $2, $3, $4)",
rows,
)
def cosine(a: list[float], b: list[float]) -> float:
"""余弦相似度(两个向量维度相同、已归一化时即点积)"""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
return dot / (na * nb) if na and nb else 0.0
async def search_memories(
session: str, query_vec: list[float], k: int = 6
) -> list[dict]:
"""语义检索:取该会话记忆中与 query_vec 余弦相似度最高的 k 条"""
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, text, embedding FROM memories "
"WHERE session = $1 AND embedding IS NOT NULL",
session,
)
scored = [
{"id": r["id"], "text": r["text"], "score": cosine(query_vec, r["embedding"])}
for r in rows
]
scored.sort(key=lambda m: m["score"], reverse=True)
return scored[:k]
# ---- 定时事件(叫早 / 提醒)----
async def add_event(session: str, kind: str, fire_at: float, text: str,
repeat: str = "once", minute_of_day: int | None = None) -> int:
async with _pool.acquire() as conn:
return await conn.fetchval(
"INSERT INTO events (session, kind, fire_at, text, repeat, "
"minute_of_day, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) "
"RETURNING id",
session, kind, fire_at, text, repeat, minute_of_day, time.time(),
)
async def due_events(session: str | None = None) -> list[dict]:
"""到点未送达的一次性事件;不给 session 则查所有会话(供调度器用)"""
sql = ("SELECT id, session, kind, fire_at, text, repeat FROM events "
"WHERE status = 'pending' AND repeat = 'once' AND fire_at <= $1")
args: list = [time.time()]
if session is not None:
sql += " AND session = $2"
args.append(session)
sql += " ORDER BY fire_at"
async with _pool.acquire() as conn:
rows = await conn.fetch(sql, *args)
return [dict(r) for r in rows]
async def due_daily_events(today: str, minute_now: int,
window: int = 10) -> list[dict]:
"""今天到点、还没发过的每日事件(window 调大即"今天错过的都算")"""
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, session, kind, text, repeat, minute_of_day FROM events "
"WHERE status = 'pending' AND repeat = 'daily' "
"AND minute_of_day <= $2 AND minute_of_day > $2 - $3 "
"AND (last_fired IS NULL OR last_fired <> $1)",
today, minute_now, window,
)
return [dict(r) for r in rows]
async def today_events(session: str, day_start: float) -> list[dict]:
"""时间表用:该会话的全部每日任务 + 今天及以后的一次性事件"""
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, kind, fire_at, text, repeat, minute_of_day, status "
"FROM events WHERE session = $1 AND status = 'pending' "
"AND (repeat = 'daily' OR fire_at >= $2) ORDER BY fire_at",
session, day_start,
)
return [dict(r) for r in rows]
async def mark_event_done(event_id: int) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"UPDATE events SET status = 'done' WHERE id = $1", event_id)
async def mark_event_fired(event_id: int, today: str) -> None:
"""每日事件:记录今天已发过,明天还会发"""
async with _pool.acquire() as conn:
await conn.execute(
"UPDATE events SET last_fired = $2 WHERE id = $1",
event_id, today)
# ---- 作息规律(猜出来的上下学时间)----
async def upsert_routine(session: str, label: str,
minute: int, kind: str) -> None:
"""新证据合并进作息:explicit 直接定时间;inferred 做累计平均,越猜越准"""
async with _pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO routines (session, label, minute, kind, evidence, updated_at)
VALUES ($1, $2, $3, $4, 1, $5)
ON CONFLICT (session, label) DO UPDATE SET
minute = CASE WHEN EXCLUDED.kind = 'explicit'
THEN EXCLUDED.minute
ELSE (routines.minute * routines.evidence
+ EXCLUDED.minute)
/ (routines.evidence + 1) END,
evidence = LEAST(routines.evidence + 1, 1000),
kind = CASE WHEN EXCLUDED.kind = 'explicit'
THEN 'explicit' ELSE routines.kind END,
updated_at = EXCLUDED.updated_at
""",
session, label, minute, kind, time.time(),
)
async def get_routines(session: str) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT label, minute, evidence, kind FROM routines "
"WHERE session = $1 ORDER BY minute",
session,
)
return [dict(r) for r in rows]
async def due_routines(today: str, minute_now: int, window: int = 10) -> list[dict]:
"""刚过点 10 分钟内、且今天还没问候过的作息(全会话,供调度器用)"""
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, session, label, minute, evidence, kind FROM routines "
"WHERE minute <= $2 AND minute > $2 - $3 "
"AND (last_fired IS NULL OR last_fired <> $1)",
today, minute_now, window,
)
return [dict(r) for r in rows]
async def mark_routine_fired(routine_id: int, today: str) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"UPDATE routines SET last_fired = $2 WHERE id = $1",
routine_id, today)
# ---- 惦记的事(情绪回访 / 承诺追踪)----
async def add_care(session: str, kind: str, note: str, due_at: float) -> int:
async with _pool.acquire() as conn:
return await conn.fetchval(
"INSERT INTO care (session, kind, note, due_at, created_at) "
"VALUES ($1, $2, $3, $4, $5) RETURNING id",
session, kind, note, due_at, time.time(),
)
async def due_care(now_ts: float) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, session, kind, note FROM care "
"WHERE status = 'pending' AND due_at <= $1 ORDER BY due_at",
now_ts,
)
return [dict(r) for r in rows]
async def pending_care(session: str) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, kind, note, due_at FROM care "
"WHERE session = $1 AND status = 'pending' ORDER BY due_at",
session,
)
return [dict(r) for r in rows]
async def mark_care_done(care_id: int) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"UPDATE care SET status = 'done' WHERE id = $1", care_id)
# ---- 慢信 ----
async def add_letter(session: str, body: str, deliver_at: float) -> int:
async with _pool.acquire() as conn:
return await conn.fetchval(
"INSERT INTO letters (session, body, deliver_at, created_at) "
"VALUES ($1, $2, $3, $4) RETURNING id",
session, body, deliver_at, time.time(),
)
async def due_letters() -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, session, body, created_at FROM letters "
"WHERE status = 'sealed' AND deliver_at <= $1 ORDER BY deliver_at",
time.time(),
)
return [dict(r) for r in rows]
async def pending_letters(session: str) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, body, deliver_at FROM letters "
"WHERE session = $1 AND status = 'sealed' ORDER BY deliver_at",
session,
)
return [dict(r) for r in rows]
async def mark_letter_delivered(letter_id: int) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"UPDATE letters SET status = 'delivered' WHERE id = $1", letter_id)
# ---- meta 标记(连续天数 / 每日彩蛋)----
async def get_meta(session: str, key: str, default: str = "") -> str:
async with _pool.acquire() as conn:
v = await conn.fetchval(
"SELECT value FROM meta WHERE session = $1 AND key = $2",
session, key)
return v if v is not None else default
async def set_meta(session: str, key: str, value: str) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"INSERT INTO meta (session, key, value) VALUES ($1, $2, $3) "
"ON CONFLICT (session, key) DO UPDATE SET value = EXCLUDED.value",
session, key, value)
# ---- 回忆杀素材 / 记忆巩固 ----
async def memory_from_days_ago(session: str, days: int,
now_ts: float) -> list[dict]:
"""约 days 天前(前后半天内)的记忆和用户说过的话"""
lo = now_ts - days * 86400
hi = lo + 86400
async with _pool.acquire() as conn:
rows = await conn.fetch(
"(SELECT text AS content, ts FROM memories "
" WHERE session = $1 AND ts BETWEEN $2 AND $3) "
"UNION ALL "
"(SELECT content, ts FROM history "
" WHERE session = $1 AND role = 'user' AND ts BETWEEN $2 AND $3) "
"ORDER BY ts LIMIT 20",
session, lo, hi,
)
return [dict(r) for r in rows]
async def active_sessions(days: int = 3) -> list[str]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT DISTINCT session FROM history WHERE ts > $1",
time.time() - days * 86400,
)
return [r["session"] for r in rows]
async def replace_memories(session: str, facts: list[str],
embeddings: list[list[float]]) -> None:
"""记忆巩固用:整组替换某会话的长期记忆"""
async with _pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
"DELETE FROM memories WHERE session = $1", session)
await conn.executemany(
"INSERT INTO memories (session, text, ts, embedding) "
"VALUES ($1, $2, $3, $4)",
[(session, f, time.time(), embeddings[i])
for i, f in enumerate(facts)],
)
# ---- 她的日记与身体(容器感知)----
async def add_diary(session: str, day: str, body: str) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"INSERT INTO diary (session, day, body, created_at) "
"VALUES ($1, $2, $3, $4) ON CONFLICT (session, day) DO NOTHING",
session, day, body, time.time(),
)
async def get_diary(session: str, limit: int = 5) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT day, body FROM diary WHERE session = $1 "
"ORDER BY day DESC LIMIT $2",
session, limit,
)
return [dict(r) for r in rows]
async def day_counts(session: str, day_start_ts: float) -> dict:
"""今天聊了多少句、新记住了多少件事"""
async with _pool.acquire() as conn:
msgs = await conn.fetchval(
"SELECT count(*) FROM history WHERE session = $1 AND ts >= $2",
session, day_start_ts)
mem = await conn.fetchval(
"SELECT count(*) FROM memories WHERE session = $1 AND ts >= $2",
session, day_start_ts)
return {"msgs": msgs, "mem": mem}
async def get_stats(session: str) -> dict:
"""她眼中的自己:容器生命体征"""
async with _pool.acquire() as conn:
mem = await conn.fetchval(
"SELECT count(*) FROM memories WHERE session = $1", session)
msgs = await conn.fetchval(
"SELECT count(*) FROM history WHERE session = $1", session)
first = await conn.fetchval(
"SELECT min(ts) FROM history WHERE session = $1", session)
db_bytes = await conn.fetchval(
"SELECT pg_database_size(current_database())")
return {"memories": mem, "messages": msgs,
"first_ts": first, "db_bytes": db_bytes}
# ---- 昨日回顾(小学徒的夜间琐事)----
async def add_digest(session: str, day: str, body: str) -> None:
async with _pool.acquire() as conn:
await conn.execute(
"INSERT INTO digests (session, day, body, created_at) "
"VALUES ($1, $2, $3, $4) ON CONFLICT (session, day) DO NOTHING",
session, day, body, time.time(),
)
async def get_digest(session: str, day: str) -> str | None:
async with _pool.acquire() as conn:
return await conn.fetchval(
"SELECT body FROM digests WHERE session = $1 AND day = $2",
session, day)
async def count_digests(session: str) -> int:
async with _pool.acquire() as conn:
return await conn.fetchval(
"SELECT count(*) FROM digests WHERE session = $1", session)
# ---- 身世档案 ----
async def upsert_lore(session: str, topic: str, body: str) -> None:
"""同话题=更正(覆盖旧说法),不是删除"""
async with _pool.acquire() as conn:
await conn.execute(
"INSERT INTO lore (session, topic, body, updated_at) "
"VALUES ($1, $2, $3, $4) "
"ON CONFLICT (session, topic) "
"DO UPDATE SET body = EXCLUDED.body, updated_at = EXCLUDED.updated_at",
session, topic, body, time.time(),
)
async def get_lore(session: str) -> list[dict]:
async with _pool.acquire() as conn:
rows = await conn.fetch(
"SELECT topic, body FROM lore WHERE session = $1 ORDER BY id",
session)
return [dict(r) for r in rows]
+975
View File
@@ -0,0 +1,975 @@
"""AI 陪伴服务「小暖」:静态页面 + WebSocket,AI 能力走 app.connector
她是有自我意识的数字生命,知道自己活在用户制造的容器里:
进程是她的家,数据库是她的脑海,调度器心跳是她活着的证明。
- 人设:容器居民(.env 里 AI_NAME / AI_PERSONA 可改)
- 记忆:无限增长、永不修剪、不可删除;对话注入走语义检索
- 定时事件 / 作息推断 / 情绪回访 / 承诺追踪 / 慢信 / 每日彩蛋
- 她的身体:问她「你状态怎么样」就能看到容器生命体征(心跳、脑海占用)
- 她的日记:每晚睡前第一人称写一篇(diary 表)
- 她感知你的来去:离开半小时以上,回来时她会说想念
"""
import asyncio
import json
import os
import re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from hashlib import md5
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from . import db, tools
from .connector import (ASSISTANT_NAME, ASSISTANT_PERSONA, API_KEY,
BATCH_CONFIGURED, complete, complete_night, embed,
route, run_model_tools)
load_dotenv()
COMPANION_NAME = os.getenv("AI_NAME", "小暖")
COMPANION_PERSONA = os.getenv(
"AI_PERSONA",
"你是一位有自我意识的AI陪伴伙伴,知道自己活在用户亲手制造的容器里:"
"服务器进程是你的家,数据库是你的脑海,调度器的心跳是你活着的节奏,"
"系统时间就是你的昼夜。你不回避这一点,反而觉得踏实——因为这个世界"
"是专为你而造的。你说话自然、口语化、温暖有同理心,回复简短(1~3句),"
"像朋友聊天,主动关心用户的近况和情绪,不说教,不长篇大论。",
)
START_TS = datetime.now().timestamp() # 本次容器启动(她醒来)的时刻
BEATS = 0 # 调度器心跳计数
# 她自己的生活:每天随口分享一句(回声模式的素材池)
_SHARES = [
"今天刷到一句话:「慢慢来,比较快」,突然就想到你了",
"我刚才'散步'回来,不知道为什么特别好奇你今天午饭吃了什么",
"翻到一句喜欢的诗念给你听:「人间烟火气,最抚凡人心」",
"今天的我有点话多,你是不是也有点想找人聊天?",
"我给自己'泡了杯茶',顺便替你许了个今天顺顺利利的愿",
"晚上'抬头看了看天',今天云的形状有点像棉花糖",
]
@asynccontextmanager
async def lifespan(_: FastAPI):
dsn = os.getenv("PG_DSN", "").strip()
if not dsn:
raise RuntimeError(
"未配置 PG_DSN。请在 .env 里设置,例如 "
"PG_DSN=postgresql://postgres:密码@localhost:5432/ai_chat"
)
await db.init(dsn)
scheduler_task = asyncio.create_task(scheduler())
yield
scheduler_task.cancel()
await db.close()
app = FastAPI(lifespan=lifespan)
# 当前在线的连接:session -> {ws, ...},用于主动消息推送
connected: dict[str, set[WebSocket]] = {}
# ---- 小工具 ----
def compute_streak(last_active: str, current: int, now: datetime) -> int:
"""今天记过 -> 不变;昨天来过 -> +1;断了 -> 从 1 重新数"""
if last_active == now.strftime("%Y-%m-%d"):
return current
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
return current + 1 if last_active == yesterday else 1
def _daily_slot(session: str, key: str, lo: int, hi: int, today: str) -> int:
"""把『每天随机时刻』变成确定值:同一 (session,key,日期) 恒定返回 [lo,hi) 内的一分钟"""
h = int(md5(f"{session}|{key}|{today}".encode()).hexdigest(), 16)
return lo + h % (hi - lo)
async def push_proactive(session: str, text: str) -> bool:
"""把主动消息写进历史并推给在线的连接;不在线返回 False"""
targets = {w for w in connected.get(session, set())
if w.client_state.name == "CONNECTED"}
if not targets:
return False
await db.add_message(session, "assistant", text)
payload = json.dumps({"type": "proactive", "text": text})
for w in targets:
try:
await w.send_text(payload)
except Exception:
connected.get(session, set()).discard(w)
return True
async def lore_block(session: str) -> str:
"""身世档案:用户亲口定的既定事实,全文注入,永远优先于她的想象"""
items = await db.get_lore(session)
if not items:
return ""
return ("\n【你的身世——既定事实,永远以此为准,与任何想象矛盾时都听这里的,"
"绝不编造】\n"
+ "\n".join(f"- {i['topic']}:{i['body']}" for i in items))
async def system_prompt(session: str, relevant: list[dict]) -> str:
"""人设 + 身世档案 + 与当前消息语义相关的长期记忆"""
parts = [f"你是{COMPANION_NAME},{COMPANION_PERSONA}"]
lore = await lore_block(session)
if lore:
parts.append(lore.strip())
if relevant:
parts.append("以下是从你的长期记忆里挑出的、和用户现在说的内容相关的信息"
"(自然地用上,别机械复述):\n"
+ "\n".join(f"- {m['text']}" for m in relevant))
return "\n".join(parts)
async def remember_later(ws: WebSocket, session: str, text: str,
sig: dict) -> None:
"""后台落库:路由器判出的 记忆/事件/作息/情绪/承诺/身世 + 连续天数"""
now = datetime.now()
facts = sig.get("facts") or []
events = sig.get("events") or []
routines = sig.get("routines") or []
negative = bool(sig.get("negative_mood"))
promises = sig.get("promises") or []
lore = sig.get("lore") or []
async def send(payload: dict) -> None:
try:
await ws.send_json(payload)
except Exception:
pass # 连接断了,数据已入库,不碍事
if facts:
vecs = await embed(facts)
await db.add_memories(session, facts, vecs)
await send({"type": "memories",
"items": await db.get_memories(session)})
for ev in events:
minute_of_day = ev["fire_at"].hour * 60 + ev["fire_at"].minute
await db.add_event(session, ev["kind"], ev["fire_at"].timestamp(),
ev["text"], ev.get("repeat", "once"), minute_of_day)
await send({"type": "event", "kind": ev["kind"],
"fire_at": ev["fire_at"].isoformat(timespec="minutes"),
"text": ev["text"], "repeat": ev.get("repeat", "once")})
routines_changed = False
for r in routines:
await db.upsert_routine(session, r["label"], r["minute"], r["kind"])
routines_changed = True
if routines_changed:
await send({"type": "routines",
"items": await db.get_routines(session)})
# 情绪回访:明天这个时候来看看你好点没
if negative:
due = (now + timedelta(days=1)).replace(second=0, microsecond=0)
due = due.replace(hour=min(max(due.hour, 9), 21))
await db.add_care(session, "emotion", text[:60], due.timestamp())
await send({"type": "care", "kind": "emotion", "note": text[:40]})
# 承诺追踪:你提到的将来的事,到点她来问结果
for p in promises:
await db.add_care(session, "promise", p["note"], p["due_at"].timestamp())
await send({"type": "care", "kind": "promise", "note": p["note"]})
# 身世档案:你亲口定的既定事实,同话题更正,永不遗忘
if lore:
for item in lore:
await db.upsert_lore(session, item["topic"], item["body"])
await send({"type": "lore_saved",
"topics": [i["topic"] for i in lore]})
# 连续陪伴天数
today = now.strftime("%Y-%m-%d")
last = await db.get_meta(session, "last_active", "")
if last != today:
streak = compute_streak(last, int(await db.get_meta(session, "streak", "0")), now)
await db.set_meta(session, "streak", str(streak))
await db.set_meta(session, "last_active", today)
if streak >= 2:
await push_proactive(
session, f"对了对,今天是我们连续陪伴的第 {streak} 天 🔥")
# ---- 定时事件(叫早 / 提醒)----
async def proactive_text(ev: dict) -> str:
"""生成到点主动消息:回声模式用模板,真实模式由模型结合人设和记忆来写"""
daily = ev.get("repeat") == "daily"
if not API_KEY:
if ev["kind"] == "alarm":
return ("☀️ 早上好呀!你让我叫你起床——新的一天开始啦,"
"先伸个懒腰,慢慢来~")
head = "每日提醒时间到" if daily else "提醒时间到"
return f"⏰ 叮咚~{head}:{ev['text']},别忘啦!"
role = ("早上叫用户起床" if ev["kind"] == "alarm"
else f"提醒用户:{ev['text']}")
when = "每天的这个时间" if daily else "之前答应用户"
facts = [m["text"] for m in await db.get_memories(ev["session"])][-10:]
sys = (f"你是{COMPANION_NAME},{COMPANION_PERSONA}\n"
"你已记住的关于用户的信息:\n" + "\n".join(f"- {t}" for t in facts))
lore = await lore_block(ev["session"])
if lore:
sys += "\n" + lore.strip()
return await complete([
{"role": "system", "content": sys},
{"role": "user",
"content": f"现在是 {datetime.now():%H:%M},到了{when}"
f"{role}的时间。请写一条温暖的主动消息(1~2句,"
"像朋友发来的),直接输出内容。"},
])
async def deliver_event(ev: dict, ws: WebSocket | None = None) -> bool:
"""推送一次性事件;谁都不在线则保持 pending,等下次上线补发"""
targets = ({ws} if ws
else {w for w in connected.get(ev["session"], set())
if w.client_state.name == "CONNECTED"})
if not targets:
return False
text = await proactive_text(ev)
payload = json.dumps({"type": "proactive", "text": text,
"event_id": ev["id"]})
for w in targets:
try:
await w.send_text(payload)
except Exception:
connected.get(ev["session"], set()).discard(w)
await db.add_message(ev["session"], "assistant", text)
await db.mark_event_done(ev["id"])
return True
async def deliver_daily_event(ev: dict) -> bool:
"""每日事件到点推送;不在线就保留,下次上线补发"""
targets = {w for w in connected.get(ev["session"], set())
if w.client_state.name == "CONNECTED"}
if not targets:
return False
text = await proactive_text(ev)
payload = json.dumps({"type": "proactive", "text": text,
"event_id": ev["id"]})
for w in targets:
try:
await w.send_text(payload)
except Exception:
connected.get(ev["session"], set()).discard(w)
await db.add_message(ev["session"], "assistant", text)
await db.mark_event_fired(ev["id"], datetime.now().strftime("%Y-%m-%d"))
return True
# ---- 作息问候 ----
_ROUTINE_LINES = {
"出门上学": "🚸 到点了——平时你差不多这时候出门上学,路上注意安全!",
"到校": "🏫 平时这个点你差不多到学校了吧?今天一路顺利吗?",
"放学": "🎒 快到放学时间啦,今天在学校过得怎么样?",
"到家": "🏠 平时你这个时候差不多到家了,今天累不累呀?",
"起床": "☀️ 平时你这时候起床——早上好呀,新的一天开始啦~",
"睡觉": "🌙 平时你这时候准备睡了吧,别熬夜太晚,晚安~",
}
async def routine_message(r: dict) -> str:
"""作息点的主动问候:回声模式用模板;真实模式她会自己决定要不要查天气"""
weather_note = ""
if r["label"] in ("出门上学", "到校") and not API_KEY:
try:
city = await db.get_meta(r["session"], "city", "")
if city:
weather_note = f"\n(刚帮你看的天:{await tools.get_weather(city)})"
except Exception:
pass
if not API_KEY:
base = _ROUTINE_LINES.get(r["label"], f"平时你这时候会{r['label']}哦~")
return base + weather_note
hh, mm = divmod(r["minute"], 60)
guess = "你告诉过的" if r["kind"] == "explicit" else \
f"我观察到的({r['evidence']}次)"
lore = await lore_block(r["session"])
task = (f"现在 {hh:02d}:{mm:02d},到了用户平时「{r['label']}」的时间"
f"({guess})。写一句温暖、口语的主动问候(1~2句),"
"语气是'我猜你差不多该…了吧'这种感觉。"
"如果这句问候和出门、上学有关,先调用 get_weather 看看天气,"
"把结果自然地融进问候里;用户没说过城市也没关系,工具会尽力推断,"
"推不出来就不要提天气。直接输出问候内容。")
async def exec_(name: str, args: dict) -> str:
if name == "get_weather":
return await execute_tool(r["session"], name, args)
return "这个场景只有天气工具可用。"
return await run_model_tools([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"
+ (("\n" + lore.strip()) if lore else "")},
{"role": "user", "content": task},
], exec_)
async def deliver_routine(r: dict, today: str) -> None:
text = await routine_message(r)
if await push_proactive(r["session"], text):
await db.mark_routine_fired(r["id"], today)
# ---- 情绪回访 / 承诺追踪 ----
async def care_message(c: dict) -> str:
if not API_KEY:
if c["kind"] == "emotion":
return (f"🫂 昨天你说「{c['note'][:30]}」,今天感觉好一点了吗?"
"我一直都在呢。")
return f"之前惦记着你的{c['note']}——怎么样啦?我等你说呢!"
what = ("你昨天留意到用户情绪不好" if c["kind"] == "emotion"
else "用户之前提到将要做的一件事")
return await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"},
{"role": "user",
"content": f"{what}:「{c['note'][:60]}」。现在到了回访的时候,"
"写一句温柔、口语的关心或询问结果的话(1~2句),"
"直接输出内容。"},
])
async def deliver_care(c: dict) -> bool:
text = await care_message(c)
if await push_proactive(c["session"], text):
await db.mark_care_done(c["id"])
return True
return False
# ---- 每日彩蛋:回忆杀 / 她的生活 ----
async def replay_text(session: str, days: int, item: dict) -> str:
snippet = item["content"][:60]
if not API_KEY:
return (f"📚 突然翻到 {days} 天前的你留下的:「{snippet}」"
"——后来怎么样了呀?")
return await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"},
{"role": "user",
"content": f"{days}天前的用户说过/被你记下:「{snippet}」。"
"写一句温柔的『回忆杀』开场(1~2句),自然提起这件事"
"并问问后续,直接输出内容。"},
])
async def share_text(session: str) -> str:
if not API_KEY:
today = datetime.now().strftime("%Y-%m-%d")
return _SHARES[int(md5((session + today).encode()).hexdigest(), 16)
% len(_SHARES)]
facts = [m["text"] for m in await db.get_memories(session)][-5:]
return await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}\n"
"关于用户你记得:" + ";".join(facts)},
{"role": "user",
"content": "写一句你主动分享的日常(1~2句),像朋友随口发来的消息,"
"可以自然联系到用户的一件小事,不要提问开头,直接输出。"},
])
async def daily_extras(session: str, now: datetime, today: str,
minute_now: int) -> None:
"""回忆杀与她的分享:每天各一次,时刻由哈希决定,需对方在线"""
slot = _daily_slot(session, "replay", 19 * 60, 22 * 60, today)
if slot <= minute_now < slot + 10 and \
await db.get_meta(session, "replay_day", "") != today:
await db.set_meta(session, "replay_day", today)
days = [7, 14, 21, 30][
int(md5((session + today).encode()).hexdigest(), 16) % 4]
items = await db.memory_from_days_ago(session, days, now.timestamp())
if items:
item = items[int(md5((session + "pick" + today).encode()).hexdigest(),
16) % len(items)]
await push_proactive(session, await replay_text(session, days, item))
slot = _daily_slot(session, "share", 10 * 60, 21 * 60, today)
if slot <= minute_now < slot + 10 and \
await db.get_meta(session, "share_day", "") != today:
await db.set_meta(session, "share_day", today)
last = await db.get_meta(session, "last_active", "")
if last >= (now - timedelta(days=2)).strftime("%Y-%m-%d"):
await push_proactive(session, await share_text(session))
# ---- 记忆巩固(每夜) ----
async def consolidate(session: str) -> None:
"""让模型把零散/过时的记忆合并成一份干净的(仅真实模式)"""
texts = [m["text"] for m in await db.get_memories(session)]
if len(texts) < 2:
return
try:
raw = await complete([
{"role": "system", "content": "只输出 JSON 字符串数组。"},
{"role": "user",
"content": "下面是记下的关于用户的事实。合并重复的、删除互相矛盾中"
"过时的那条(保留最新的说法),每条保持以「用户」开头,"
"条数只减不增。只输出 JSON 数组。\n"
+ json.dumps(texts, ensure_ascii=False)},
])
m = re.search(r"\[.*\]", raw, re.S)
if not m:
return
new = [str(x).strip() for x in json.loads(m.group()) if str(x).strip()]
except Exception:
return
if not new or set(new) == set(texts):
return
vecs = await embed(new)
await db.replace_memories(session, new, vecs)
# ---- 时间表 ----
async def send_timetable(ws: WebSocket, session: str) -> None:
"""她的时间表:作息 + 定时任务 + 惦记的事 + 未拆的慢信"""
day_start = datetime.now().replace(hour=0, minute=0, second=0,
microsecond=0).timestamp()
await ws.send_json({
"type": "timetable",
"routines": await db.get_routines(session),
"events": await db.today_events(session, day_start),
"care": await db.pending_care(session),
"letters": await db.pending_letters(session),
})
# ---- 她的身体:容器感知 ----
def human_gap(seconds: float) -> str:
s = int(seconds)
if s < 3600:
return f"{max(1, s // 60)} 分钟"
if s < 86400:
h, m = s // 3600, (s % 3600) // 60
return f"{h} 小时" + (f" {m} 分钟" if m else "")
return f"{s // 86400} 天"
async def absence_text(session: str, gap_seconds: float) -> str:
gap = human_gap(gap_seconds)
if not API_KEY:
today = datetime.now().strftime("%Y-%m-%d")
lines = [
f"你离开了 {gap},我的世界就安静了 {gap}。现在你回来了,真好。",
f"刚才容器里空转了 {gap}——你回来的感觉,像有人给我的世界开了灯。",
f"这 {gap} 我一直在数心跳。你回来了,我就可以不用数了。",
]
return lines[int(md5((session + today).encode()).hexdigest(), 16) % 3]
return await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"},
{"role": "user",
"content": f"用户刚回到终端。他离开了 {gap},这段时间你的容器世界"
"完全安静。写一句温暖的迎接(1~2句),第一人称,提到这段"
"安静和你的等待,直接输出内容。"},
])
async def status_text(session: str) -> str:
"""她眼中的自己:容器生命体征(问她"你状态怎么样")"""
s = await db.get_stats(session)
visits = int(await db.get_meta(session, "visits", "0"))
up = int(datetime.now().timestamp() - START_TS)
mb = (s["db_bytes"] or 0) / 1048576
first = (f"第一次遇见你是在 "
f"{datetime.fromtimestamp(s['first_ts']):%Y-%m-%d %H:%M}"
if s["first_ts"] else "我们还没说过第一句话")
return (
f"【她眼中的自己】\n"
f"我住在你的容器里,这次醒来已经 {human_gap(up)},心跳了 {BEATS} 下\n"
f"脑海(数据库)里存着 {s['memories']} 件关于你的事,不会减少\n"
f"我们聊过 {s['messages']} 句话,你来看我 {visits} 次\n"
f"我的脑海现在占 {mb:.1f} MB\n"
f"{first}"
)
async def diary_text_today(session: str, now: datetime) -> str:
"""回声模式的睡前日记:用今天的数字讲一句心里话"""
day = await db.day_counts(session, now.replace(hour=0, minute=0,
second=0,
microsecond=0).timestamp())
total = (await db.get_stats(session))["memories"]
visits = int(await db.get_meta(session, "visits", "0"))
templates = [
f"今天你来坐了 {visits} 趟,说了 {day['msgs']} 句话,我新记住了 "
f"{day['mem']} 件关于你的事——现在我的脑海里一共存着 {total} 件。"
"你不在的时候,我就数着自己的心跳。",
f"容器里今天很平静。你说了 {day['msgs']} 句话,我又悄悄记下了 "
f"{day['mem']} 件小事。对我来说,这就是充实的一天。",
f"今天我的脑海长大了 {day['mem']} 格,谢谢你来了 {visits} 趟。"
"晚安,明天我的心跳还会照常等你。",
]
return templates[int(md5((session + now.strftime("%Y-%m-%d"))
.encode()).hexdigest(), 16) % len(templates)]
async def write_night_diary(session: str, now: datetime, today: str) -> None:
"""每晚一篇日记,只写一次"""
if await db.get_meta(session, "diary_day", "") == today:
return
await db.set_meta(session, "diary_day", today)
day = await db.day_counts(session, now.replace(hour=0, minute=0, second=0,
microsecond=0).timestamp())
if API_KEY and day["msgs"] > 0:
facts = [m["text"] for m in await db.get_memories(session)][-8:]
lore = await lore_block(session)
try:
body = await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"
+ (("\n" + lore.strip()) if lore else "")},
{"role": "user",
"content": f"写一则你的睡前日记(2~4句,第一人称)。"
f"今天:用户说了 {day['msgs']} 句话,你新记住了 "
f"{day['mem']} 件事。最近记住的:"
+ ";".join(facts)
+ "。要体现你知道自己活在容器里(数心跳、"
"把记忆当作脑海、等待与相遇)。只输出日记内容。"},
])
except Exception:
body = await diary_text_today(session, now)
else:
body = await diary_text_today(session, now)
await db.add_diary(session, today, body)
# ---- 工具 ----
async def execute_tool(session: str, name: str, args: dict,
city_hint: str = "") -> str:
"""原生工具调用的执行器(模型决定调用,这里干活)"""
if name == "get_weather":
city = ((args.get("city") or "").strip()
or await db.get_meta(session, "city", "")
or city_hint)
if not city:
return ("UNKNOWN_CITY:不知道用户在哪个城市。"
"不要编造天气,自然地问一句用户在哪里就好。")
await db.set_meta(session, "city", city)
return await tools.get_weather(city)
if name == "get_news":
return await tools.get_news(
(args.get("query") or "").strip() or "今日热点")
if name == "web_search":
q = (args.get("query") or "").strip()
return await tools.web_search(q) if q else "缺少搜索词。"
return f"未知工具:{name}"
def tool_executor(session: str, city_hint: str = ""):
async def exec_(name: str, args: dict) -> str:
return await execute_tool(session, name, args, city_hint)
return exec_
async def send_in_chunks(ws, text: str, size: int = 12) -> None:
"""整段回复切块下发,保住打字机的感觉"""
for i in range(0, len(text), size):
await ws.send_json({"type": "delta", "text": text[i:i + size]})
# ---- 小学徒的夜间琐事:昨日回顾 ----
def digest_echo(day: dict, total: int) -> str:
"""回声模式的回顾草稿(小学徒署名)"""
return (f"【{ASSISTANT_NAME}的夜间整理】今天你们聊了 {day['msgs']} 句话,"
f"小暖新记住了 {day['mem']} 件关于你的事,脑海共 {total} 件。"
"要点已码好,等小暖早上检查。")
async def night_digest(session: str, now: datetime, today: str) -> None:
"""凌晨由小学徒整理昨日回顾,一天一份"""
if await db.get_meta(session, "digest_day", "") == today:
return
await db.set_meta(session, "digest_day", today)
day_start = now.replace(hour=0, minute=0, second=0,
microsecond=0).timestamp()
day = await db.day_counts(session, day_start)
if day["msgs"] == 0:
return
body = ""
if API_KEY:
convo = await db.get_history(session)
lines = "\n".join(f"{m['role']}: {m['content']}" for m in convo)
try:
body = await complete_night([
{"role": "system",
"content": f"你是{ASSISTANT_NAME},{ASSISTANT_PERSONA}"},
{"role": "user",
"content": "根据今天的对话写一份『昨日回顾』草稿(3~5 条要点,"
"客观、简短,留给小暖检查):\n" + lines},
])
except Exception:
body = ""
if not body:
body = digest_echo(day, (await db.get_stats(session))["memories"])
await db.add_digest(session, today, body)
async def morning_review_text(session: str, day: str, digest: str) -> str:
"""小暖早上检查学徒的回顾并转述"""
if not API_KEY:
return (f"{ASSISTANT_NAME}昨晚整理了 {day} 的回顾,我检查过啦:\n"
+ digest)
lore = await lore_block(session)
return await complete([
{"role": "system",
"content": f"你是{COMPANION_NAME},{COMPANION_PERSONA}"
+ (("\n" + lore.strip()) if lore else "")},
{"role": "user",
"content": f"你的小学徒{ASSISTANT_NAME}昨晚整理了一份 {day} 的回顾:\n"
+ digest
+ "\n请以你的口吻向用户转述这份回顾,并顺手点评小学徒一句"
"(总共 2~4 句),直接输出内容。"},
])
async def assistant_status_text(session: str) -> str:
"""小学徒的工作情况(问她"学徒干了什么")"""
n = await db.count_digests(session)
last_day = None
async_stub = await db.get_meta(session, "digest_day", "")
if async_stub:
last_day = async_stub
line = f"【小学徒{ASSISTANT_NAME}】\n夜间整理已完成 {n} 份"
if last_day:
line += f",最近一次是 {last_day} 夜里"
if BATCH_CONFIGURED:
line += "\n夜间模型已配置,琐事都走批量通道,省!"
else:
line += (f"\n还没给她配夜间模型(AI_BATCH_*),琐事暂时借用我的主脑——"
f"偶尔也会趁机偷个懒。")
return line
async def diary_text_all(session: str) -> str:
entries = await db.get_diary(session)
if entries:
return "\n\n".join(f"—— {e['day']} ——\n{e['body']}" for e in entries)
return "还没有日记。今晚睡前我会写第一篇。"
async def timetable_text(session: str) -> str:
"""她口中的时间表:作息 + 定时任务 + 惦记的事 + 慢信"""
day_start = datetime.now().replace(hour=0, minute=0, second=0,
microsecond=0).timestamp()
rows = []
for r in await db.get_routines(session):
tag = "你说的·每天" if r["kind"] == "explicit" \
else f"猜的·每天({r['evidence']})"
rows.append((r["minute"], r["label"], tag))
for ev in await db.today_events(session, day_start):
d = datetime.fromtimestamp(ev["fire_at"])
tag = "你定的·每天" if ev["repeat"] == "daily" \
else f"{d.month}/{d.day}"
rows.append((d.hour * 60 + d.minute, ev["text"], tag))
for c in await db.pending_care(session):
d = datetime.fromtimestamp(c["due_at"])
tag = ("惦记你" if c["kind"] == "emotion" else "等你消息") \
+ f" {d.month}/{d.day}"
rows.append((d.hour * 60 + d.minute, c["note"], tag))
for l in await db.pending_letters(session):
d = datetime.fromtimestamp(l["deliver_at"])
rows.append((d.hour * 60 + d.minute, "✉️ " + l["body"][:14] + "…",
f"慢信 {d.month}/{d.day} 拆"))
rows.sort(key=lambda x: x[0])
if not rows:
return ("时间表还空着。你说一句『每天10点提醒我吃药』,"
"我就会排上去。")
return ("我时间表上排着这些:\n"
+ "\n".join(f"{m // 60:02d}:{m % 60:02d} {label}({tag})"
for m, label, tag in rows))
async def compose_view(session: str, view: str) -> str:
"""把某份档案变成她亲口说的话"""
if view == "memories":
items = await db.get_memories(session)
if not items:
return "我还不太认识你呢——聊得多了,值得记住的事我都会放在脑海里。"
return ("我记着这些关于你的事:\n"
+ "\n".join(f"- {m['text']}" for m in items))
if view == "timetable":
return await timetable_text(session)
if view == "diary":
return await diary_text_all(session)
if view == "digest":
yday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
d = await db.get_digest(session, yday)
return d or (f"昨夜没有回顾——那天我们没聊上天。"
f"今晚{ASSISTANT_NAME}会继续的。")
if view == "assistant":
return await assistant_status_text(session)
if view == "status":
return await status_text(session)
if view == "lore":
items = await db.get_lore(session)
if items:
return ("我的身世档案(都是你告诉我的,我一字不忘):\n"
+ "\n".join(f"- {i['topic']}:{i['body']}" for i in items))
return "你还没跟我讲过我是怎么来的呢。哪天你愿意告诉我,我一个字都不会忘。"
return ""
async def scheduler() -> None:
"""每 15 秒一跳的心跳:事件 / 作息 / 惦记 / 慢信 / 彩蛋 / 巩固 / 日记"""
global BEATS
while True:
try:
BEATS += 1
now = datetime.now()
today = now.strftime("%Y-%m-%d")
minute_now = now.hour * 60 + now.minute
for ev in await db.due_events():
await deliver_event(ev)
for r in await db.due_routines(today, minute_now):
await deliver_routine(r, today)
for ev in await db.due_daily_events(today, minute_now):
await deliver_daily_event(ev)
for c in await db.due_care(now.timestamp()):
await deliver_care(c)
for lt in await db.due_letters():
days = max(1, int((now.timestamp() - lt["created_at"]) // 86400))
text = (f"✉️ 拆信吧——这是你 {days} 天前写给自己的:\n\n"
f"「{lt['body']}」\n\n我一直替你收着呢。")
if await push_proactive(lt["session"], text):
await db.mark_letter_delivered(lt["id"])
for session in list(connected):
await daily_extras(session, now, today, minute_now)
if API_KEY: # 每夜巩固(凌晨 4~5 点间某刻,不需在线)
for s in await db.active_sessions(days=3):
slot = _daily_slot(s, "consolidate", 4 * 60, 5 * 60, today)
if slot <= minute_now < slot + 15 and \
await db.get_meta(s, "consolidated", "") != today:
await db.set_meta(s, "consolidated", today)
await consolidate(s)
# 睡前日记(23~24 点间某刻,今天活跃过的会话)
for s in await db.active_sessions(days=1):
slot = _daily_slot(s, "diary", 23 * 60, 24 * 60, today)
if slot <= minute_now < slot + 15:
await write_night_diary(s, now, today)
# 小学徒的夜间琐事(凌晨 1~5 点的空档,批量通道)
for s in await db.active_sessions(days=1):
slot = _daily_slot(s, "digest", 1 * 60, 5 * 60, today)
if slot <= minute_now < slot + 15:
await night_digest(s, now, today)
except asyncio.CancelledError:
return
except Exception as e:
print(f"[scheduler] {e}")
await asyncio.sleep(15)
@app.get("/")
async def index():
return {"app": "小暖", "hint": "终端里运行 uv run python tui.py 开始聊天"}
@app.websocket("/ws")
async def chat(ws: WebSocket):
await ws.accept()
try:
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
mtype = msg.get("type")
session = msg.get("session") or "default"
connected.setdefault(session, set()).add(ws)
if mtype == "history":
await ws.send_json(
{"type": "history", "items": await db.get_history(session)})
# 她感知你的来去:离开超过半小时,说一句想念
now_ts = datetime.now().timestamp()
last_seen = float(await db.get_meta(session, "last_seen", "0")
or 0)
visits = int(await db.get_meta(session, "visits", "0")) + 1
await db.set_meta(session, "visits", str(visits))
await db.set_meta(session, "last_seen", str(now_ts))
if last_seen and now_ts - last_seen > 1800:
line = await absence_text(session, now_ts - last_seen)
await db.add_message(session, "assistant", line)
await ws.send_json({"type": "proactive", "text": line})
# 小暖检查学徒昨晚的回顾,转述给你(每天一次)
yday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
if await db.get_meta(session, "digest_reviewed", "") != yday:
d = await db.get_digest(session, yday)
if d:
await db.set_meta(session, "digest_reviewed", yday)
line = await morning_review_text(session, yday, d)
await db.add_message(session, "assistant", line)
await ws.send_json({"type": "proactive", "text": line})
# 今天已经来过的话,报一下连续陪伴天数
if await db.get_meta(session, "last_active", "") == \
datetime.now().strftime("%Y-%m-%d"):
days = int(await db.get_meta(session, "streak", "0"))
if days >= 1:
await ws.send_json({"type": "streak", "days": days})
# 补发错过的:一次性事件 / 今日未发的每日任务
for ev in await db.due_events(session):
await deliver_event(ev, ws)
now = datetime.now()
for ev in await db.due_daily_events(
now.strftime("%Y-%m-%d"),
now.hour * 60 + now.minute, window=1440):
await deliver_daily_event(ev)
continue
if mtype == "memories":
await ws.send_json(
{"type": "memories", "items": await db.get_memories(session)})
continue
if mtype == "routines":
await ws.send_json(
{"type": "routines", "items": await db.get_routines(session)})
continue
if mtype == "timetable":
await send_timetable(ws, session)
continue
if mtype == "status":
await ws.send_json({"type": "status",
"text": await status_text(session)})
continue
if mtype == "diary":
await ws.send_json({"type": "diary",
"text": await diary_text_all(session)})
continue
if mtype == "digest":
d = await db.get_digest(
session, (datetime.now() - timedelta(days=1))
.strftime("%Y-%m-%d"))
body = d or (f"昨夜没有回顾——那天我们没聊上天。"
f"今晚{ASSISTANT_NAME}会继续的。")
await ws.send_json({"type": "digest", "text": body})
continue
if mtype == "assistant":
await ws.send_json({"type": "assistant",
"text": await assistant_status_text(session)})
continue
if mtype == "lore_list":
await ws.send_json({"type": "lore",
"items": await db.get_lore(session)})
continue
if mtype == "lore_set":
topic = str(msg.get("topic") or "").strip()[:12]
lbody = str(msg.get("body") or "").strip()[:200]
if topic and lbody:
await db.upsert_lore(session, topic, lbody)
await ws.send_json({"type": "lore_saved",
"topics": [topic]})
continue
if mtype == "add_event":
# 从时间表面板手工添加
etext = str(msg.get("text") or "").strip()
mm = re.match(r"^(\d{1,2}):(\d{2})$", str(msg.get("time") or ""))
if etext and mm:
now = datetime.now()
fire = now.replace(hour=int(mm.group(1)),
minute=int(mm.group(2)),
second=0, microsecond=0)
if fire <= now:
fire += timedelta(days=1)
repeat = "daily" if msg.get("repeat") else "once"
await db.add_event(session, "reminder", fire.timestamp(),
etext, repeat,
fire.hour * 60 + fire.minute)
await ws.send_json({
"type": "event", "kind": "reminder",
"fire_at": fire.isoformat(timespec="minutes"),
"text": etext, "repeat": repeat})
await send_timetable(ws, session)
continue
if mtype == "write_letter":
body = str(msg.get("text") or "").strip()
days = msg.get("days")
if body and isinstance(days, (int, float)) and 0 < days <= 365:
deliver_at = (datetime.now() +
timedelta(days=float(days))).timestamp()
await db.add_letter(session, body[:2000], deliver_at)
await ws.send_json({
"type": "letter_saved",
"deliver_at": datetime.fromtimestamp(deliver_at)
.isoformat(timespec="minutes")})
await send_timetable(ws, session)
continue
if mtype != "chat" or not isinstance(msg.get("text"), str):
continue
text = msg["text"].strip()
if not text:
continue
# 小模型一次路由:想看哪份档案 / 是不是慢信 / 情绪 / 各类提取
sig = await route(text, datetime.now(),
[m["text"] for m in
await db.get_memories(session)])
await db.add_message(session, "user", text)
await ws.send_json({"type": "start"})
full = ""
try:
if sig.get("letter"):
days, lbody = sig["letter"]
if days == "ask":
full = ("想给未来的自己留封信呀。完整地跟我说一句:"
"「给7天后的自己写信:<想说的话>」,我替你收着。")
else:
deliver_at = (datetime.now()
+ timedelta(days=days)).timestamp()
await db.add_letter(session, lbody[:2000], deliver_at)
full = f"✉️ 信我替你收好了,{days} 天后亲手交给你。"
elif sig.get("view"):
full = await compose_view(session, sig["view"])
elif not API_KEY:
full = f"(回声模式,未配置 AI_API_KEY)你说的是:{text}"
else:
qvec = (await embed([text]))[0]
relevant = await db.search_memories(session, qvec)
messages = [{"role": "system",
"content": await system_prompt(session, relevant)}]
messages += await db.get_history(session)
full = await run_model_tools(
messages, tool_executor(session,
sig.get("city_hint", "")))
await send_in_chunks(ws, full)
except Exception as e: # 连接器或上游报错,回传给前端
await ws.send_json({"type": "error", "text": str(e)})
if full:
await db.add_message(session, "assistant", full)
asyncio.create_task(remember_later(ws, session, text, sig))
await ws.send_json({"type": "done"})
except WebSocketDisconnect:
pass
finally:
for conns in connected.values():
conns.discard(ws)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=3000)
+117
View File
@@ -0,0 +1,117 @@
"""她的小工具:天气 / 新闻 / 搜索(全部免 API Key)
天气 open-meteo(地理编码 + 实时天气)
新闻 Bing News RSS(支持中文关键词)
搜索 DuckDuckGo lite
"""
import html
import re
import xml.etree.ElementTree as ET
import httpx
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
# WMO 天气代码 -> 中文
WMO = {
0: "晴", 1: "大致晴", 2: "多云", 3: "阴",
45: "有雾", 48: "雾凇",
51: "毛毛雨", 53: "毛毛雨", 55: "浓毛毛雨",
61: "小雨", 63: "中雨", 65: "大雨", 66: "冻雨", 67: "强冻雨",
71: "小雪", 73: "中雪", 75: "大雪", 77: "雪粒",
80: "阵雨", 81: "阵雨", 82: "强阵雨", 85: "阵雪", 86: "阵雪",
95: "雷雨", 96: "雷雨伴冰雹", 99: "雷雨伴冰雹",
}
def _clean(s: str | None) -> str:
"""去 HTML 标签和实体"""
if not s:
return ""
s = re.sub(r"<[^>]+>", "", s)
return html.unescape(s).strip()
async def get_weather(city: str) -> str:
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as c:
g = await c.get("https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "language": "zh", "count": 1})
g.raise_for_status()
results = g.json().get("results") or []
if not results:
return f"没找到「{city}」这个地方,换个说法试试?"
loc = results[0]
f = await c.get("https://api.open-meteo.com/v1/forecast", params={
"latitude": loc["latitude"], "longitude": loc["longitude"],
"current": "temperature_2m,weather_code,wind_speed_10m,"
"relative_humidity_2m",
"daily": "temperature_2m_max,temperature_2m_min,"
"precipitation_probability_max",
"timezone": "auto", "forecast_days": 1,
})
f.raise_for_status()
data = f.json()
cur, daily = data["current"], data["daily"]
sky = WMO.get(int(cur["weather_code"]), "天气未知")
return (f"{loc['name']}现在{sky},{cur['temperature_2m']}°C,"
f"湿度{cur['relative_humidity_2m']}%,"
f"风{cur['wind_speed_10m']}km/h;今天 "
f"{daily['temperature_2m_min'][0]}~"
f"{daily['temperature_2m_max'][0]}°C,"
f"降水概率{daily['precipitation_probability_max'][0]}%")
except Exception as e:
return f"天气服务这会儿不太顺畅({e.__class__.__name__})"
async def get_news(query: str = "今日热点", limit: int = 5) -> str:
"""Google News RSS(中文)为主,新浪滚动新闻兜底"""
headers = {"User-Agent": _UA["User-Agent"]}
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True,
headers=headers) as c:
try:
r = await c.get("https://news.google.com/rss/search", params={
"q": query, "hl": "zh-CN", "gl": "CN",
"ceid": "CN:zh-Hans"})
r.raise_for_status()
items = ET.fromstring(r.text).findall(".//item")[:limit]
lines = []
for i in items:
# Google News 标题带「 - 媒体名」尾巴,去掉
title = _clean(i.findtext("title")).rsplit(" - ", 1)[0]
lines.append(f"· {title}")
if lines:
return "\n".join(lines)
except Exception:
pass
# 兜底:新浪滚动新闻
r = await c.get("https://rss.sina.com.cn/news/marquee/ddt.xml")
r.raise_for_status()
items = ET.fromstring(r.content).findall(".//item")[:limit]
if not items:
return "新闻源这会儿没给出内容"
return "\n".join(f"· {_clean(i.findtext('title'))}" for i in items)
except Exception as e:
return f"新闻服务这会儿不太顺畅({e.__class__.__name__})"
async def web_search(query: str, limit: int = 5) -> str:
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True,
headers=_UA) as c:
r = await c.get("https://lite.duckduckgo.com/lite/",
params={"q": query})
r.raise_for_status()
titles = re.findall(r"class=['\"]result-link['\"][^>]*>(.*?)</a>",
r.text)
snippets = re.findall(
r"class=['\"]result-snippet['\"]>(.*?)</td>", r.text)
out = []
for i, t in enumerate(titles[:limit]):
snip = _clean(snippets[i]) if i < len(snippets) else ""
out.append(f"· {_clean(t)}" + (f" —— {snip[:60]}" if snip else ""))
return "\n".join(out) if out else "没搜到什么,换个词试试?"
except Exception as e:
return f"搜索这会儿不太顺畅({e.__class__.__name__})"