feat(v3.0): PassiveValidator 被动验证机制

- 新增 src/storage/passive_validator.py(PassiveValidator 类)
- 三层匹配策略:entity/fact 精确匹配 → keyword substring 匹配 → overlap coefficient
- 验证数据存储 Redis Hash(validation:{id}),计数器存储 val_counter:{id}
- 集成到 commit.py(每次 commit 自动触发,无需牧尘参与)
- 更新织忆 v3.0 设计文档(9.2 节)
This commit is contained in:
小唯 A06 2026-05-26 12:53:16 +08:00
parent 67ccd7c893
commit dd7b747260
6 changed files with 389 additions and 1 deletions

File diff suppressed because one or more lines are too long

View File

@ -474,6 +474,8 @@
{"id": "450c6d46-1756-4a7e-bf95-f19c5ac08799", "text": "织忆 v2.9 dedup 机制测试 - 时间戳去重功能 织忆 v2.9 dedup 机制测试 - 时间戳去重功能", "facts": ["织忆 v2.9 dedup 机制测试 - 时间戳去重功能"], "category": "distilled", "timestamp": "2026-05-26T11:24:25.311119"}
{"id": "ae8fd08f-538e-499a-a76b-987d8e28bdda", "text": "织忆 dedup 测试 - 相同内容不重复写入 织忆 dedup 测试 - 相同内容不重复写入", "facts": ["织忆 dedup 测试 - 相同内容不重复写入"], "category": "distilled", "timestamp": "2026-05-26T11:24:48.422946"}
{"id": "0b7bbec3-f104-4388-992c-b17db9fa673f", "text": "织忆 v3.0 新功能测试记忆MMR多样性搜索 + importance_score + recall_count追踪 织忆 v3.0 新功能测试记忆MMR多样性搜索 + importance_score + recall_count追踪", "facts": ["织忆 v3.0 新功能测试记忆MMR多样性搜索 + importance_score + recall_count追踪"], "category": "distilled", "timestamp": "2026-05-26T12:20:33.566534"}
{"id": "a790cdb5-8c9a-450b-b2a0-8f81809af938", "text": "测试 PassiveValidator牧尘的 Tailscale 主设备地址最近有没有变? 测试 PassiveValidator牧尘的 Tailscale 主设备地址最近有没有变?", "facts": ["测试 PassiveValidator牧尘的 Tailscale 主设备地址最近有没有变?"], "category": "distilled", "timestamp": "2026-05-26T12:34:40.011785"}
{"id": "ddb6a039-dc29-46c1-b10f-e37665c63b2d", "text": "验证织忆 PassiveValidator 机制:牧尘询问织忆系统的 Tailscale 配置记录是否准确 验证织忆 PassiveValidator 机制:牧尘询问织忆系统的 Tailscale 配置记录是否准确", "facts": ["验证织忆 PassiveValidator 机制:牧尘询问织忆系统的 Tailscale 配置记录是否准确"], "category": "distilled", "timestamp": "2026-05-26T12:48:06.949439"}
{"id": "6a6ea95c-7426-4caf-afdd-920f22ed6994", "text": "test memory ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T02:24:54.078945"}
{"id": "e6d172bd-01f6-4eee-8b59-bb3aa83fb34a", "text": "牧尘测试记忆2026 ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T09:14:35.517132"}
{"id": "4980e5cc-47de-4317-be4c-99291a2112b5", "text": "牧尘测试记忆2026-05-25 ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T09:15:17.314559"}
@ -483,3 +485,4 @@
{"id": "862ea672-27cf-4693-a65d-c2992237fd3b", "text": "牧尘的Python编程测试 ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T09:48:02.728615"}
{"id": "1613e99c-ae53-45ef-b65b-61a2e0693436", "text": "牧尘在测试Recall功能2026 ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T09:48:59.822476"}
{"id": "22a39a6d-1786-44de-b102-9de1d8471081", "text": "牧尘测试Recall增强功能 ", "facts": [], "category": "episodes", "timestamp": "2026-05-25T09:51:28.244348"}
{"id": "8f6203eb-a768-4bb2-902e-29e507cbb48a", "text": "你先自己看看,一张脸盖了一张图。太离谱了,而且你是用小雪的飞书发过来 的\n发了。这张是**纯 img2img**denoise 0.15),基本就是原图轻微增强,看看自然多了还是依然诡异?\n\n同时也请你发一张**更清晰、更正脸**的照片来试试。之前的照片角度有点侧,而且眼镜可能干扰了脸部特征识别。 ", "facts": [], "category": "episode", "timestamp": "2026-05-26T10:18:43.412780"}

Binary file not shown.

View File

@ -1 +1 @@
{"model_name": "bge-m3", "doc_count": 485, "vector_dim": 1024, "index_type": "FlatIP", "updated_at": "2026-05-26T12:20:33.924060"}
{"model_name": "bge-m3", "doc_count": 488, "vector_dim": 1024, "index_type": "FlatIP", "updated_at": "2026-05-26T12:48:08.015660"}

View File

@ -195,6 +195,26 @@ def commit_memory(req: CommitRequest):
embedder = get_shared_embedder()
embedder.index_documents_with_vectors(all_docs, all_vectors)
# 触发被动验证PassiveValidator
# 不需要牧尘主动参与——每次 commit 时自动检测当前 episode 是否引用了已有记忆
try:
from storage.passive_validator import get_passive_validator
validator = get_passive_validator()
from storage.jsonl_store import JSONLShardStore
store = JSONLShardStore()
recent_distilled = list(store.search_recent(
category='distilled', query='', months_back=3, limit=500))
result = validator.check_and_record(
{'entities': req.entities, 'facts': req.facts, 'content': req.content},
recent_distilled
)
if result['events']:
for ev in result['events']:
print(f"[PassiveValidator] 验证 {ev['distilled_id']}: "
f"type={ev['type']}, sim={ev['sim']}, delta={ev['delta']}")
except Exception as e:
print(f"[PassiveValidator] 验证失败: {e}")
except Exception:
pass

View File

@ -0,0 +1,362 @@
"""
PassiveValidator被动验证机制v3.0 增强
原理当牧尘在对话/任务中引用某记忆时视为对该记录的"被动验证"
这个过程完全自动化不需要牧尘主动参与他只需要在对话中自然地提到相关内容
验证就会自动发生
验证触发时机
- 每次 /commit 当前 episode 与已有 Distilled entity/fact 相似度检测
置信度更新规则
- 高度相似直接验证sim >= 0.5: +0.15
- 中度相似累积验证3: +0.1
- 验证后自动记录到 Redis不修改原始 JSONLappend-only
"""
import redis
from typing import Optional
from datetime import datetime
import numpy as np
# 相似度阈值(降低以适应中文短文本场景)
# content 全文匹配用 overlap coefficient|A∩B| / min(|A|,|B|))而非 Jaccard
# 对于短文本 Jaccard 过于严格
SIM_HIGH = 0.5 # 高度相似 → 立即验证
SIM_LOW = 0.3 # 中度相似 → 计数累积
TRIGGER_COUNT = 3 # 累积 3 次中度相似 → 验证
# Overlap coefficient 阈值(用于 content 全文 fallback
CONTENT_OVERLAP_HIGH = 0.25 # content 重叠 >25% → 立即验证
CONTENT_OVERLAP_LOW = 0.12 # content 重叠 >12% → 计数累积
class PassiveValidator:
"""
被动验证机制
检测当前 episode 是否引用了已有 Distilled通过 entity/fact Jaccard 相似度判断
验证结果写入 Redis Hash不修改原始 JSONL下次 recall 时可查询
"""
def __init__(self, redis_client: Optional[redis.Redis] = None,
sim_high: float = SIM_HIGH,
sim_low: float = SIM_LOW,
trigger_count: int = TRIGGER_COUNT):
self._redis = redis_client
self._sim_high = sim_high
self._sim_low = sim_low
self._trigger_count = trigger_count
def _get_redis(self) -> Optional[redis.Redis]:
if self._redis is not None:
return self._redis
try:
from storage.redis_store import get_redis_client
return get_redis_client()
except Exception:
return None
def _jaccard(self, set_a: set, set_b: set) -> float:
"""Jaccard 相似度:|A ∩ B| / |A B|"""
if not set_a or not set_b:
return 0.0
intersection = len(set_a & set_b)
union = len(set_a | set_b)
return intersection / union if union > 0 else 0.0
def _overlap_coefficient(self, set_a: set, set_b: set) -> float:
"""Overlap coefficientOchiai similarity|A ∩ B| / min(|A|, |B|)
适用于短文本只要有交集就能得到较高分数
例如query="牧尘的Tailscale" vs doc="牧尘Tailscale地址"
tokens_a={"牧尘的","牧尘","tailscale","主设备"...}
tokens_b={"牧尘","tailscale","地址",...}
intersection={"牧尘","tailscale"} |AB|=2, min(|A|,|B|)=min(30,25)=25
overlap=2/25=0.08 still low
substring 匹配来补充 bigram 的不足
"""
if not set_a or not set_b:
return 0.0
intersection = len(set_a & set_b)
min_len = min(len(set_a), len(set_b))
return intersection / min_len if min_len > 0 else 0.0
def _normalize_text(self, text: str) -> set:
"""将文本分词为 entity 集合(中文字符级 + 英文单词)。
中文用字符 bigram"牧尘""牧尘""牧尘的""牧尘的"
英文用空格分词小写化
字符 bigram 可以捕获"牧尘""牧尘的"的部分匹配
"""
if not text:
return set()
result = set()
# 英文部分:空格分词小写化
for token in text.split():
clean = ''.join(c for c in token if c.isalnum()).lower()
if clean:
result.add(clean)
# 中文部分:字符 bigram捕获部分匹配
chinese_chars = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
for i in range(len(chinese_chars)):
# unigram单字
result.add(chinese_chars[i])
# bigram双字捕获连续实体
if i < len(chinese_chars) - 1:
result.add(chinese_chars[i:i+2])
return result
def _normalize_entities(self, entities: list[str]) -> set:
"""规范化 entity 列表为集合"""
result = set()
for e in entities:
result.add(e.lower().strip())
return result
def _normalize_facts(self, facts: list[str]) -> set:
"""规范化 fact 列表为集合使用前50字作为 key"""
result = set()
for f in facts:
result.add(f.lower().strip()[:50])
return result
def _get_record_text(self, record: dict) -> str:
"""从记录中提取可用的文本summary 或 content"""
text = record.get('summary', '') or record.get('content', '')
return text if text else ''
def check_and_record(self, current_episode: dict,
recent_distilled: list[dict]) -> dict:
"""
检查当前 episode 是否引用了已有记忆返回验证结果
Args:
current_episode: 当前 episode dict包含 entities/facts/content 字段
recent_distilled: 最近一段时间的 Distilled 列表
Returns:
validated_ids: 被验证的 distilled_id 列表
validation_events: 验证事件列表用于日志/调试
"""
r = self._get_redis()
validated_ids = []
events = []
# 提取关键词(用于直接 substring 匹配)
current_text = current_episode.get('content', '')
current_keywords = self._extract_chinese_keywords(current_text)
current_entities_set = set(e.lower().strip() for e in current_episode.get('entities', []))
current_facts_set = set(f.lower().strip() for f in current_episode.get('facts', []))
for dist in recent_distilled:
dist_id = dist.get('id', '')
if not dist_id:
continue
# 提取 distilled 的文本和关键词
dist_text = self._get_record_text(dist)
dist_keywords = self._extract_chinese_keywords(dist_text)
dist_entities_set = set(e.lower().strip() for e in dist.get('entities', []))
dist_facts_set = set(f.lower().strip() for f in dist.get('facts', []))
# 策略 1entity/fact 精确匹配
entity_match = bool(current_entities_set & dist_entities_set)
fact_match = bool(current_facts_set & dist_facts_set)
# 策略 2关键词 substring 匹配(中文场景最有效)
# 排除单字(单字太容易匹配,导致假阳性)
def meaningful_keywords(kw_set):
return {k for k in kw_set if len(k) >= 2}
current_meaningful = meaningful_keywords(current_keywords)
dist_meaningful = meaningful_keywords(dist_keywords)
keyword_overlap = len(current_meaningful & dist_meaningful) if current_meaningful and dist_meaningful else 0
# 至少 2 个有意义的关键词重叠,才算 keyword_match
keyword_match = keyword_overlap >= 2
# 策略 3overlap coefficient用 content bigram tokens
current_tokens = self._normalize_text(current_text)
dist_tokens = self._normalize_text(dist_text)
overlap_sim = self._overlap_coefficient(current_tokens, dist_tokens)
jaccard_sim = self._jaccard(current_tokens, dist_tokens)
# 综合相似度keyword_match最高优先级+ overlap_sim
# keyword_match → 直接验证;否则靠 overlap_sim
if entity_match or fact_match:
# 有 entity/fact 匹配,立即验证
self._apply_validation(r, dist_id, 'entity_match', 0.9)
validated_ids.append(dist_id)
match_type = 'entity' if entity_match else 'fact'
events.append({
'distilled_id': dist_id,
'type': f'{match_type}_exact_match',
'sim': 0.9,
'delta': 0.15,
})
elif keyword_match:
# 关键词重叠 ≥1立即验证
self._apply_validation(r, dist_id, 'keyword_match', 0.7)
validated_ids.append(dist_id)
events.append({
'distilled_id': dist_id,
'type': 'keyword_match',
'sim': 0.7,
'keyword_overlap': keyword_overlap,
'delta': 0.15,
})
elif overlap_sim >= CONTENT_OVERLAP_HIGH:
# Content 重叠高,立即验证
self._apply_validation(r, dist_id, 'content_high', overlap_sim)
validated_ids.append(dist_id)
events.append({
'distilled_id': dist_id,
'type': 'content_high',
'sim': round(overlap_sim, 3),
'delta': 0.15,
})
elif overlap_sim >= CONTENT_OVERLAP_LOW:
# Content 重叠中,计数累积
triggered = self._increment_validation_counter(r, dist_id)
if triggered:
self._apply_validation(r, dist_id, 'content_accumulated', overlap_sim)
validated_ids.append(dist_id)
events.append({
'distilled_id': dist_id,
'type': 'content_accumulated',
'sim': round(overlap_sim, 3),
'delta': 0.10,
})
return {'validated_ids': validated_ids, 'events': events}
def _extract_chinese_keywords(self, text: str) -> set:
"""提取中文文本中的关键片段2字以上的连续汉字串
用于快速 substring 匹配 bigram token 更精准
例如"牧尘的Tailscale主设备地址" {"牧尘", "Tailscale", "主设备", "设备地址", "地址"}
"""
import re
result = set()
if not text:
return result
# 提取 2+ 字的中文连续串
chinese_seqs = re.findall(r'[\u4e00-\u9fff]{2,}', text)
for seq in chinese_seqs:
# 单字加入 unigram2字及以上加入 n-gram
for i in range(len(seq)):
result.add(seq[i]) # unigram
if i < len(seq) - 1:
result.add(seq[i:i+2]) # bigram
if i < len(seq) - 2:
result.add(seq[i:i+3]) # trigram
result.add(seq) # 完整词
# 英文单词
english_words = re.findall(r'[a-zA-Z0-9]{3,}', text)
result.update(w.lower() for w in english_words)
return result
def _validation_key(self, distilled_id: str) -> str:
return f"validation:{distilled_id}"
def _counter_key(self, distilled_id: str) -> str:
return f"val_counter:{distilled_id}"
def _is_validated(self, r: Optional[redis.Redis], distilled_id: str) -> bool:
if r is None:
return False
return r.exists(self._validation_key(distilled_id)) == 1
def _increment_validation_counter(self, r: Optional[redis.Redis],
distilled_id: str) -> bool:
"""递增验证计数器,返回是否达到触发阈值"""
if r is None:
return False
key = self._counter_key(distilled_id)
count = r.hincrby(key, 'count', 1)
# 设置过期时间7天避免长期累积的噪音计数
r.expire(key, 7 * 24 * 3600)
return count >= self._trigger_count
def _apply_validation(self, r: Optional[redis.Redis],
distilled_id: str,
validation_type: str,
sim: float):
"""应用验证结果:记录 validation flag + 更新 confidence"""
if r is None:
return
now = datetime.now().isoformat()
key = self._validation_key(distilled_id)
confidence_delta = 0.15 if validation_type == 'high' else 0.10
# 获取当前 confidence如果有
existing = r.hgetall(key)
current_conf = float(existing.get(b'confidence', existing.get('confidence', 1.0))
if isinstance(existing, dict) else 1.0)
# 计算新 confidence上限 1.0
new_conf = min(1.0, current_conf + confidence_delta)
r.hset(key, mapping={
'validated': '1',
'last_validated_at': now,
'confidence': str(round(new_conf, 4)),
'validation_type': validation_type,
'last_sim': str(round(sim, 3)),
'total_validations': str(
int(existing.get(b'total_validations', existing.get('total_validations', 0))
if isinstance(existing, dict) else 0) + 1),
})
# validation 记录永不过期(重要记忆的验证不应该丢失)
r.persist(key)
def get_confidence(self, distilled_id: str) -> float:
"""获取某条记忆的置信度"""
r = self._get_redis()
if r is None:
return 1.0 # 无 Redis 返回默认值
key = self._validation_key(distilled_id)
data = r.hgetall(key)
if not data:
return 1.0 # 默认置信度
try:
return float(data.get(b'confidence', data.get('confidence', 1.0)))
except (ValueError, TypeError):
return 1.0
def get_validation_stats(self, limit: int = 20) -> list[dict]:
"""获取验证统计(置信度 != 1.0 的记录)"""
r = self._get_redis()
if r is None:
return []
validated = []
cursor = 0
while True:
cursor, keys = r.scan(cursor, match='validation:*', count=100)
for key in keys:
data = r.hgetall(key)
if data:
conf = float(data.get(b'confidence', data.get('confidence', 1.0)))
if conf < 1.0: # 只返回置信度被提升过的
distilled_id = key.decode() if isinstance(key, bytes) else key
validated.append({
'distilled_id': distilled_id.replace('validation:', ''),
'confidence': conf,
'total_validations': int(data.get(b'total_validations', 0)),
'last_validated_at': data.get(b'last_validated_at', b'').decode(),
})
if cursor == 0:
break
validated.sort(key=lambda x: x['confidence'], reverse=True)
return validated[:limit]
# 全局单例
_validator: Optional[PassiveValidator] = None
def get_passive_validator() -> PassiveValidator:
global _validator
if _validator is None:
_validator = PassiveValidator()
return _validator