Initial release: iai-mcp v0.1.0
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
This commit is contained in:
commit
f6b876fbe7
332 changed files with 97258 additions and 0 deletions
225
src/iai_mcp/curiosity.py
Normal file
225
src/iai_mcp/curiosity.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Active curiosity (LEARN-04, D-23, D-24) -- Task 4.
|
||||
|
||||
D-23 trigger: prediction entropy > 0.7 bits AND 3-turn cooldown since last
|
||||
curiosity question in this session.
|
||||
|
||||
D-24 tiered style:
|
||||
- entropy in [ENTROPY_LOW, ENTROPY_MID) -> silent log event, no question
|
||||
- entropy in [ENTROPY_MID, ENTROPY_HIGH) -> inline hint
|
||||
- entropy >= ENTROPY_HIGH -> direct clarifying question
|
||||
|
||||
Every question creates curiosity_bridge edges from each triggering record to
|
||||
the question's UUID (used as a stable hub id). The question itself lives in
|
||||
the events table (kind=curiosity_question); callers may insert a first-class
|
||||
record if persistent text is desired, but keeps questions
|
||||
event-sourced to minimise LanceDB write volume.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from iai_mcp.events import query_events, write_event
|
||||
from iai_mcp.store import MemoryStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- constants
|
||||
|
||||
|
||||
ENTROPY_LOW: float = 0.4
|
||||
ENTROPY_MID: float = 0.7
|
||||
ENTROPY_HIGH: float = 0.9
|
||||
COOLDOWN_TURNS: int = 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- types
|
||||
|
||||
|
||||
@dataclass
|
||||
class CuriosityQuestion:
|
||||
"""One curiosity question surfaced by fire_curiosity."""
|
||||
|
||||
id: UUID
|
||||
text: str
|
||||
triggered_by_record_ids: list[UUID] = field(default_factory=list)
|
||||
entropy: float = 0.0
|
||||
tier: str = "question" # "silent" | "inline" | "question"
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def compute_entropy(scores: list[float]) -> float:
|
||||
"""Shannon entropy (base-2, bits) over a score distribution.
|
||||
|
||||
Returns 0.0 for empty or degenerate inputs. Negative scores are clamped
|
||||
to 0 before normalisation so the probability vector is well-defined.
|
||||
"""
|
||||
if not scores:
|
||||
return 0.0
|
||||
positive = [max(0.0, float(s)) for s in scores]
|
||||
total = sum(positive)
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
probs = [p / total for p in positive]
|
||||
h = 0.0
|
||||
for p in probs:
|
||||
if p > 0:
|
||||
h -= p * math.log2(p)
|
||||
return h
|
||||
|
||||
|
||||
def _last_curiosity_turn(store: MemoryStore, session_id: str) -> int | None:
|
||||
"""Return the turn of the most recent curiosity_question in this session."""
|
||||
events = query_events(store, kind="curiosity_question", limit=20)
|
||||
for e in events:
|
||||
if e.get("session_id") == session_id:
|
||||
try:
|
||||
return int(e["data"].get("turn", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fire_curiosity
|
||||
|
||||
|
||||
def fire_curiosity(
|
||||
store: MemoryStore,
|
||||
hits: list,
|
||||
cue: str,
|
||||
entropy: float,
|
||||
session_id: str,
|
||||
turn: int,
|
||||
) -> CuriosityQuestion | None:
|
||||
"""D-23 gate + tiering.
|
||||
|
||||
Returns a CuriosityQuestion (or None) and, as a side effect:
|
||||
- emits a curiosity_silent_log event for low-entropy misses
|
||||
- emits a curiosity_question event for mid/high fires
|
||||
- creates curiosity_bridge edges from each triggering record -> question
|
||||
"""
|
||||
if entropy < ENTROPY_LOW:
|
||||
return None
|
||||
|
||||
# Low-mid band -> silent log, no question.
|
||||
if entropy < ENTROPY_MID:
|
||||
write_event(
|
||||
store,
|
||||
kind="curiosity_silent_log",
|
||||
data={
|
||||
"cue": cue[:200],
|
||||
"entropy": float(entropy),
|
||||
"source_ids": [str(h.record_id) for h in hits[:3]],
|
||||
},
|
||||
severity="info",
|
||||
session_id=session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Cooldown check.
|
||||
last = _last_curiosity_turn(store, session_id)
|
||||
if last is not None and (turn - last) < COOLDOWN_TURNS:
|
||||
return None
|
||||
|
||||
q_id = uuid4()
|
||||
if entropy < ENTROPY_HIGH:
|
||||
tier = "inline"
|
||||
text = f"I'm not fully sure -- did you mean {cue!r}?"
|
||||
else:
|
||||
tier = "question"
|
||||
text = f"Could you clarify: {cue!r}?"
|
||||
|
||||
trigger_ids: list[UUID] = [h.record_id for h in hits[:5]]
|
||||
question = CuriosityQuestion(
|
||||
id=q_id,
|
||||
text=text,
|
||||
triggered_by_record_ids=trigger_ids,
|
||||
entropy=float(entropy),
|
||||
tier=tier,
|
||||
)
|
||||
|
||||
# curiosity_bridge edges. Delta proportional to entropy so higher-entropy
|
||||
# questions get stronger edges.
|
||||
# R3: batch all triggers into a single boost_edges call
|
||||
# (one merge_insert + one tbl.add at most). The diagnostic try/except
|
||||
# boundary is preserved at the SINGLE-call level — failure of the batched
|
||||
# write must never block the curiosity fire path.
|
||||
bridge_pairs = [(tid, q_id) for tid in trigger_ids]
|
||||
if bridge_pairs:
|
||||
try:
|
||||
store.boost_edges(
|
||||
bridge_pairs,
|
||||
edge_type="curiosity_bridge",
|
||||
delta=float(entropy),
|
||||
)
|
||||
except Exception:
|
||||
# Diagnostic; never block the curiosity fire on edge failure.
|
||||
pass
|
||||
|
||||
write_event(
|
||||
store,
|
||||
kind="curiosity_question",
|
||||
data={
|
||||
"question_id": str(q_id),
|
||||
"text": text,
|
||||
"tier": tier,
|
||||
"entropy": float(entropy),
|
||||
"turn": int(turn),
|
||||
"triggered_by": [str(t) for t in trigger_ids],
|
||||
},
|
||||
severity="info",
|
||||
session_id=session_id,
|
||||
source_ids=trigger_ids,
|
||||
)
|
||||
return question
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pending
|
||||
|
||||
|
||||
def pending_questions(
|
||||
store: MemoryStore,
|
||||
session_id: str | None = None,
|
||||
) -> list[CuriosityQuestion]:
|
||||
"""Return unresolved curiosity questions, optionally scoped to a session."""
|
||||
events = query_events(store, kind="curiosity_question", limit=200)
|
||||
resolved_events = query_events(store, kind="curiosity_resolved", limit=500)
|
||||
resolved_ids = {
|
||||
r["data"].get("question_id")
|
||||
for r in resolved_events
|
||||
if r["data"].get("question_id")
|
||||
}
|
||||
out: list[CuriosityQuestion] = []
|
||||
for e in events:
|
||||
if session_id is not None and e.get("session_id") != session_id:
|
||||
continue
|
||||
data = e["data"]
|
||||
qid_raw = data.get("question_id")
|
||||
if not qid_raw:
|
||||
continue
|
||||
if qid_raw in resolved_ids:
|
||||
continue
|
||||
try:
|
||||
qid = UUID(qid_raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
triggered: list[UUID] = []
|
||||
for t in data.get("triggered_by", []):
|
||||
try:
|
||||
triggered.append(UUID(t))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.append(
|
||||
CuriosityQuestion(
|
||||
id=qid,
|
||||
text=data.get("text", ""),
|
||||
triggered_by_record_ids=triggered,
|
||||
entropy=float(data.get("entropy", 0.0)),
|
||||
tier=data.get("tier", "question"),
|
||||
resolved=False,
|
||||
)
|
||||
)
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue