小暖 · 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