mirror of
https://github.com/snowy-yang/xiaonuan.git
synced 2026-10-21 08:04:48 +00:00
小暖 · AI 陪伴(容器世界的数字生命)
- FastAPI + WebSocket 服务,Textual TUI 终端客户端 - PostgreSQL 存储专用连接层(asyncpg 连接池,主程序零 SQL) - 无限且永久的记忆:语义检索注入 + 每夜巩固 + 身世档案(lore) - 作息推断(猜上下学时间)、定时事件(叫早/每日提醒) - 情绪回访 / 承诺追踪 / 慢信 / 回忆杀 / 她的生活 / 连续陪伴天数 - 原生 function calling 工具:天气 / 新闻 / 搜索(免 Key) - 极小模型快速路由(AI_SMALL_*)+ 夜间批量通道(AI_BATCH_*) - 小学徒小满:夜间琐事整理,小暖晨间检查转述 - 离线测试:路由器 / 工具循环 / 杂项 / 向量 / TUI 冒烟
This commit is contained in:
commit
4494987e54
20 files changed
+3496
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user