v0.3.1: knowledge-base retrieval (kb/ dir) for group chats; ima MCP bridge ticketed

This commit is contained in:
kenyanghui
2026-09-29 14:13:09 +08:00
parent bcd2e84ed8
commit fd9668103a
3 changed files with 159 additions and 7 deletions
+5
View File
@@ -34,3 +34,8 @@ macOS 视觉自动化技能初版:vision.py + 单文件 SKILL.md(坐标绝不落
- **FT 图标模板匹配**:颜色候选+9x9 模板最小距离择优,核验成功后自学习模板(#2,待实测关闭)
其他:config 新增 self_nick/personas/merge_wait;send 前消息合并去抖;AI 时间感知;心跳持久化防重启重发;Python3 推导式崩溃修复(红点状态复位)。
## v0.3.1 (2026-09-29)
- **知识库接入**: kb/ 目录 + 轻量检索(kb_context 按关键词重合度取段落),群聊按 kb_chats 配置注入「AI教育杨老师」知识库参考;群人设升级为 AI 知识助教
- ima 知识库 MCP 直连列入升级票(#13)
+152 -7
View File
@@ -291,18 +291,112 @@ def send_text(text):
SAFE_ACK = "这条我记下来了,回头我详细答复你"
DANGER = re.compile(r"转账|红包|密码|验证码|汇款|付款")
URL_RE = re.compile(r"https?://[^\s\"'<>]+")
def fetch_url_text(url):
"""链接阅读(学自 WXAUTO_SE):抓网页正文,失败返回 None 走兜底"""
try:
import requests
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", r.text)
text = re.sub(r"<[^>]+>", " ", text)
return re.sub(r"\s+", " ", text)[:3000]
except Exception as e:
log.warning("链接抓取失败 %s: %s" % (url, e))
return None
def memory_file(chat):
safe = re.sub(r"[^\w\u4e00-\u9fff-]", "_", chat) or "unknown"
return os.path.join(BASE, "memory", safe + ".md")
def memory_context(chat):
mf = memory_file(chat)
if not os.path.exists(mf):
return ""
lines = open(mf, encoding="utf-8").readlines()
return "".join(lines[-40:])
def remember_exchange(chat, last_them, reply):
"""对话落记忆文件;每 10 次交互让 GLM 压缩一份摘要(学自 WXAUTO_SE 记忆功能)"""
mf = memory_file(chat)
os.makedirs(os.path.dirname(mf), exist_ok=True)
stamp = datetime.now().strftime("%m-%d %H:%M")
with open(mf, "a", encoding="utf-8") as f:
f.write("[%s] 对方: %s\n[%s] 我: %s\n" % (
stamp, (last_them or "")[:80], stamp, reply[:80].replace("\n", " ")))
try:
n = sum(1 for _ in open(mf, encoding="utf-8"))
if n >= 20 and n % 20 < 2:
head = "".join(open(mf, encoding="utf-8").readlines()[-40:])
summary = think("把以下微信对话记录压缩成不超过300字的记忆要点(事实/承诺/偏好/待办),直接输出要点:\n" + head)
sf = os.path.join(os.path.dirname(mf), "summary-" + os.path.basename(mf))
open(sf, "w", encoding="utf-8").write(summary.strip())
log.info("记忆摘要已更新: %s" % os.path.basename(sf))
except Exception:
log.warning("记忆压缩失败", exc_info=True)
KB_DIR = os.path.join(BASE, "kb")
def kb_context(question):
"""知识库检索(轻量RAG):扫 kb/ 下 md/txt 的段落,按问题关键词重合度取最优段。
知识来源是 ima 知识库「AI教育杨老师」的导出文档;MCP 直连见 Loong 工单 #13。"""
try:
if not os.path.isdir(KB_DIR):
return ""
q = set(question)
best, best_score = [], 0
for fn in os.listdir(KB_DIR):
if not fn.lower().endswith((".md", ".txt")):
continue
try:
content = open(os.path.join(KB_DIR, fn), encoding="utf-8").read()
except Exception:
continue
for para in re.split(r"\n\s*\n", content):
para = para.strip()
if len(para) < 30:
continue
score = sum(1 for ch in q if ch in para and not ch.isspace())
if score > best_score and score >= 3:
best, best_score = ["[%s] %s" % (fn, para[:600])], score
elif score == best_score and score >= 3 and len(best) < 3:
best.append("[%s] %s" % (fn, para[:600]))
return ("\n\n".join(best))[:2500] if best else ""
except Exception:
log.warning("知识库检索失败", exc_info=True)
return ""
def compose_reply(info):
chat = info.get("chat_name", "未知")
last_them = next((m["text"] for m in reversed(info.get("messages", []))
if m["from"] == "them"), "")
history = "\n".join(("对方: " if m["from"] == "them" else "我: ") + m["text"]
for m in info["messages"])
prompt = (CFG["persona"] +
persona = CFG.get("personas", {}).get(chat, CFG["persona"]) # 每会话独立人设(学自 WXAUTO_SE)
memory = memory_context(chat)
link_note = ""
urls = URL_RE.findall(last_them)
if urls:
content = fetch_url_text(urls[0])
link_note = ("\n对方消息含链接 %s,网页正文如下:\n%s\n请结合网页内容回复。" % (urls[0], content)
if content else
"\n对方消息含链接 %s(抓取失败,可建议对方粘贴正文)。" % urls[0])
prompt = (persona +
"\n\n当前时间: " + datetime.now().strftime("%Y-%m-%d %H:%M %A") + # AI时间感知(学自 WXAUTO_SE)
"\n当前聊天对象: " + info.get("chat_name", "未知") +
"\n当前聊天对象: " + chat +
(("\n相关历史记忆:\n" + memory) if memory else "") +
link_note +
"\n最近对话:\n" + history +
"\n\n请以「我」的身份回复对方最后一条消息。只输出回复正文,不要任何解释、引号或表情符号堆砌。")
if chat in CFG.get("kb_chats", []):
kb = kb_context(last_them or history[-200:])
if kb:
prompt += "\n\n知识库参考(「AI教育杨老师」知识库,回答以下列内容为准,没有的如实说需要查证):\n" + kb
reply = think(prompt).strip().strip('"')
if DANGER.search(reply):
return SAFE_ACK
remember_exchange(chat, last_them, reply)
return reply
def in_quiet_hours():
@@ -314,6 +408,17 @@ def in_quiet_hours():
return h >= a or h < b
RATE = {"hour": None, "count": 0}
def group_needs_reply(chat_name, messages):
"""群聊仅在被 @ 本号昵称时响应(学自 WXAUTO_SE)。群标题带成员数括号;
未配置 self_nick 或非群会话保持原行为。"""
if "(" not in (chat_name or ""):
return True
nick = CFG.get("self_nick", "")
if not nick:
return True
them = " ".join(m.get("text", "") for m in messages if m.get("from") == "them")
return nick in them
def rate_ok():
h = datetime.now().hour
if RATE["hour"] != h:
@@ -340,7 +445,7 @@ def handle_trigger(slots, img):
for s in slots:
name = slot_map.get(s, "槽位%d" % s)
log_inbox("新消息 来自 %s" % name)
if name not in CFG["whitelist"]:
if not any(w and (w in name or name in w) for w in CFG["whitelist"]):
log.info("跳过非白名单: %s" % name)
continue
if not brain_ready():
@@ -358,6 +463,9 @@ def handle_trigger(slots, img):
real_name not in name and name not in real_name:
log.warning("窗口标题(%s)与预期(%s)不符,放弃回复" % (real_name, name))
continue
if not group_needs_reply(info.get("chat_name"), info.get("messages", [])):
log.info("群内未被@本号,跳过: %s" % info.get("chat_name"))
continue
if not rate_ok():
log.warning("超时速率限制,跳过回复 %s" % name)
continue
@@ -386,11 +494,23 @@ def expand_pinned_if_collapsed(img):
log.warning("检查折叠条失败: %s" % e)
return False
def _ft_icon_ref():
return os.path.join(BASE, "ft_icon_ref.png")
def save_ft_template(img, y_top):
"""标题核验成功后把该图标存为参考模板(自学习),供后续模板匹配择优"""
try:
img.crop((74, y_top, 110, y_top + 36)).save(_ft_icon_ref())
log.info("FT 图标参考模板已更新")
except Exception:
log.warning("FT 模板保存失败", exc_info=True)
def find_green_icon_y(img):
"""聊天列表图标列里找 File Transfer 图标:绿圈+大白箭头双特征,取最底部的匹配"""
"""聊天列表图标列找 File Transfer 图标。候选=绿圈+白箭头双特征(green>250,white>250);
存在参考模板时按 9x9 缩略图最小距离择优(修颜色质量误配,坑#2),无模板取最底部候选。"""
c = CFG["coords"]
px = img.load()
best = None
cands = []
y_cap = min(c["list_y1"] - 44, 826) # 避开底部 pinned chats 开关区
for y in range(c["list_y0"], y_cap, 2):
green = white = 0
@@ -402,8 +522,26 @@ def find_green_icon_y(img):
elif r > 220 and g > 220 and b > 220:
white += 1
if green > 250 and white > 250:
best = y + 18
return best
cands.append(y)
if not cands:
return None
ref_path = _ft_icon_ref()
if os.path.exists(ref_path):
try:
ref = Image.open(ref_path).convert("RGB").resize((9, 9))
ref_data = list(ref.getdata())
best, best_d = None, None
for y in cands:
patch = img.crop((74, y, 110, y + 36)).convert("RGB").resize((9, 9))
d = sum((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2
for a, b in zip(patch.getdata(), ref_data))
if best_d is None or d < best_d:
best, best_d = y, d
log.info("FT 图标候选 %s,模板择优取 y=%s (距离%s)" % (cands, best, best_d))
return best + 18
except Exception:
log.warning("模板匹配异常,回退颜色规则", exc_info=True)
return cands[-1] + 18
def open_chat_from_list(name):
"""在聊天列表里用视觉找到目标会话并点开(适用于搜索索引搜不到的内置会话如 File Transfer)"""
@@ -453,12 +591,19 @@ def heartbeat():
log.warning("未找到 File Transfer 绿色图标,跳过心跳")
return
click(91, gy, 2.0) # 点图标中心,避开下方折叠开关
click_y_top = gy - 18
elif not (open_chat_from_list(hb) or open_chat_via_search(hb)):
click_y_top = None
return
header = chat_header_name()
if hb.lower() not in header.lower():
log.warning("心跳目标核验失败(标题:%s),不发送" % header)
return
try:
if click_y_top is not None:
save_ft_template(scrot(), click_y_top) # 核验成功,自学习参考模板
except Exception:
pass
free = sh("free -m | awk '/Mem:/{print $7}'").stdout.decode().strip()
send_text("〔自动心跳〕Agent在线 ✓ 可用内存%sMB 时间%s" % (free, datetime.now().strftime("%m-%d %H:%M")))
open(hb_file, "w").write(datetime.now().isoformat())
+2
View File
@@ -7,6 +7,8 @@
"联系人A",
"File Transfer"
],
"self_nick": "",
"personas": {},
"verify_chat_name": true,
"max_replies_per_hour": 10,
"quiet_hours": [