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

325 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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