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:
parent
67ccd7c893
commit
dd7b747260
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
"""
|
||||
PassiveValidator:被动验证机制(v3.0 增强)
|
||||
|
||||
原理:当牧尘在对话/任务中引用某记忆时,视为对该记录的"被动验证"。
|
||||
这个过程完全自动化,不需要牧尘主动参与——他只需要在对话中自然地提到相关内容,
|
||||
验证就会自动发生。
|
||||
|
||||
验证触发时机:
|
||||
- 每次 /commit 时:当前 episode 与已有 Distilled 的 entity/fact 相似度检测
|
||||
|
||||
置信度更新规则:
|
||||
- 高度相似直接验证(sim >= 0.5): +0.15
|
||||
- 中度相似累积验证(3次): +0.1
|
||||
- 验证后自动记录到 Redis(不修改原始 JSONL,append-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 coefficient(Ochiai similarity):|A ∩ B| / min(|A|, |B|)
|
||||
|
||||
适用于短文本——只要有交集就能得到较高分数。
|
||||
例如:query="牧尘的Tailscale" vs doc="牧尘Tailscale地址"
|
||||
tokens_a={"牧尘的","牧尘","tailscale","主设备"...}
|
||||
tokens_b={"牧尘","tailscale","地址",...}
|
||||
intersection={"牧尘","tailscale"} → |A∩B|=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', []))
|
||||
|
||||
# 策略 1:entity/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
|
||||
|
||||
# 策略 3:overlap 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:
|
||||
# 单字加入 unigram,2字及以上加入 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
|
||||
Loading…
Reference in New Issue