Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 | |
| 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 | |
| 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] | |
| 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 | |