Codette-Reasoning-Demo / reasoning_forge /factual_coherence_tracker.py
Raiff1982's picture
Upgrade to REAL orchestrated Codette on ZeroGPU (transformers backend for the llama.cpp pipeline)
c8fbdf1 verified
Raw
History Blame Contribute Delete
8.49 kB
"""
Factual Coherence Tracker — cross-turn Q&A anchor memory.
Stores what Codette has asserted in prior turns and injects those anchors
into the system prompt before subsequent similar questions, preventing
silent factual drift without re-consulting the generation stack.
Two-phase design:
1. get_coherence_block(query) — called BEFORE generation to inject prior answers
2. record(query, response, turn) — called AFTER generation to store new anchor
This mirrors the constraint_tracker pattern: detect early, inject early.
"""
import re
from dataclasses import dataclass, field
from typing import List, Tuple, FrozenSet
@dataclass
class AnswerAnchor:
"""A Q→A fact stored from a prior turn."""
query_key: FrozenSet[str] # normalized content-word fingerprint
query_raw: str # original query (truncated) for display
answer_summary: str # first meaningful sentence of response
turn: int # turn number when stored
class FactualCoherenceTracker:
"""
Session-scoped tracker that anchors factual Q→A pairs across turns.
Usage:
tracker = FactualCoherenceTracker()
# Before generating a response:
block = tracker.get_coherence_block(query)
if block:
inject into system prompt
# After receiving a response:
tracker.record(query, response, turn)
"""
_STOP_WORDS: FrozenSet[str] = frozenset({
# Core function words (caught by 4-char min too, but explicit for clarity)
"is", "are", "was", "were", "the", "a", "an", "and", "or", "but",
"in", "on", "at", "to", "for", "of", "with", "by", "it", "this",
"that", "what", "which", "how", "when", "where", "who", "why",
"can", "could", "would", "should", "do", "does", "did", "have",
"has", "had", "will", "be", "been", "being", "get", "got", "give",
"use", "used", "using", "its", "my", "your", "their", "our",
"me", "you", "we", "they", "he", "she", "not", "just", "also",
"some", "any", "one", "two", "three", "more", "most", "than",
"then", "about", "tell", "say", "said", "ask", "asked", "please",
# 4-char+ words that pass the length filter but carry no semantic payload
"them", "from", "after", "before", "these", "those", "each",
"such", "both", "very", "over", "here", "there", "into", "onto",
"upon", "many", "much", "like", "well", "even", "only", "just",
"make", "made", "take", "took", "come", "came", "know", "knew",
"look", "like", "good", "want", "need", "help", "mean", "case",
"same", "back", "down", "time", "year", "people", "think",
})
_MAX_ANCHORS = 30
_SIMILARITY_THRESHOLD = 0.35 # 0.30 false-positives on "proton mass" vs "electron mass"
_MAX_INJECT = 3 # max prior anchors to inject per turn
def __init__(self):
self.anchors: List[AnswerAnchor] = []
self._turn_count = 0
def record(self, query: str, response: str, turn: int) -> None:
"""Extract and store a Q→A anchor after generating a response."""
key = self._query_key(query)
if not key:
return
summary = self._answer_summary(response)
if not summary:
return
# Update existing anchor with same key (same question asked again)
for anchor in self.anchors:
if anchor.query_key == key:
anchor.answer_summary = summary
anchor.turn = turn
return
self.anchors.append(AnswerAnchor(
query_key=key,
query_raw=query[:200],
answer_summary=summary,
turn=turn,
))
# Rolling cap — keep most recent
if len(self.anchors) > self._MAX_ANCHORS:
self.anchors = self.anchors[-self._MAX_ANCHORS:]
def get_coherence_block(self, query: str) -> str:
"""Return a prompt-injectable block of relevant prior answers.
Call this BEFORE generation so the model sees its own prior answers.
Returns empty string when no relevant anchors exist.
"""
relevant = self._get_relevant_anchors(query)
if not relevant:
return ""
lines = ["[COHERENCE ANCHORS -- your prior answers; maintain consistency with these]"]
for anchor in relevant:
lines.append(f"- Turn {anchor.turn}: Q: \"{anchor.query_raw[:80]}\" -> A: {anchor.answer_summary}")
lines.append("")
return "\n".join(lines)
def check_contradiction(self, query: str, response: str) -> Tuple[bool, List[str]]:
"""Lightweight post-generation check for numeric factual contradictions.
Numbers are normalised to float before comparison so that "300" and
"300.0" are treated as equal, and "$1.10" and "1.10" agree. The
known limitation is unit-level equivalence ($0.05 vs 5 cents) — those
still appear as disjoint and will be flagged.
Returns:
(is_consistent, issues_list)
"""
relevant = self._get_relevant_anchors(query)
if not relevant:
return True, []
issues = []
for anchor in relevant:
prior_nums = self._extract_numbers(anchor.answer_summary)
resp_nums = self._extract_numbers(response)
if not prior_nums or not resp_nums:
continue
if prior_nums.isdisjoint(resp_nums):
issues.append(
f"COHERENCE_DRIFT: numeric answer differs from turn {anchor.turn} "
f"(prior: {anchor.answer_summary[:60]!r})"
)
return len(issues) == 0, issues
@staticmethod
def _extract_numbers(text: str) -> set:
"""Extract numeric values from text, normalised to float.
Strips currency symbols and unit suffixes so '300', '300.0', and
'$300' all resolve to the same float 300.0.
"""
raw = re.findall(r'\d+(?:\.\d+)?', text)
result = set()
for n in raw:
try:
result.add(float(n))
except ValueError:
pass
return result
# ── Internal helpers ────────────────────────────────────────────────────
def _get_relevant_anchors(self, query: str) -> List[AnswerAnchor]:
"""Find anchors whose query key overlaps significantly with current query."""
current_key = self._query_key(query)
if not current_key:
return []
scored = []
for anchor in self.anchors:
sim = self._jaccard(current_key, anchor.query_key)
if sim >= self._SIMILARITY_THRESHOLD:
scored.append((sim, anchor))
scored.sort(key=lambda x: -x[0])
return [a for _, a in scored[:self._MAX_INJECT]]
def _query_key(self, query: str) -> FrozenSet[str]:
"""Extract a content-word fingerprint from a query.
Returns empty frozenset when fewer than 2 meaningful content words
are found — single-word or stop-word-only queries don't anchor.
"""
tokens = re.findall(r'\b[a-z]{4,}\b', query.lower())
key = frozenset(t for t in tokens if t not in self._STOP_WORDS)
return key if len(key) >= 2 else frozenset()
def _answer_summary(self, response: str) -> str:
"""Extract the first meaningful sentence as an answer summary."""
clean = response.strip()
# Skip markdown headers, code blocks, empty lines
for line in clean.splitlines():
line = line.strip()
if not line or line.startswith('#') or line.startswith('```'):
continue
if len(line) >= 15:
return line[:150]
# Fallback: first 150 chars of full text
return clean[:150]
@staticmethod
def _jaccard(a: FrozenSet[str], b: FrozenSet[str]) -> float:
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def reset(self) -> None:
"""Clear all anchors — call between sessions."""
self.anchors.clear()
self._turn_count = 0