* mental models * DRAFT: refactor entity observations * fix db patch * agentic * agentic * reflect agent * new style * more * fix ci * fix * fix
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""
|
|
Emergent mental model detection and promotion.
|
|
|
|
Emergent models are discovered from data patterns:
|
|
- Named entity extraction (people, projects, systems)
|
|
- Temporal clustering (events with multiple references)
|
|
- Causal patterns ("Because X, we do Y")
|
|
- Behavioral anchors ("After X, we started Y")
|
|
- Reference frequency (anything mentioned repeatedly)
|
|
|
|
When a pattern is detected, it goes through a mission filter to check relevance,
|
|
and if relevant, is promoted to a mental model.
|
|
"""
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .models import EmergentCandidate
|
|
|
|
if TYPE_CHECKING:
|
|
from ..llm_wrapper import LLMConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MissionFilterCandidate(BaseModel):
|
|
"""Result of mission filtering for a single candidate."""
|
|
|
|
name: str
|
|
promote: bool = Field(description="True if this is a specific named entity worth tracking")
|
|
reason: str = Field(description="Brief explanation for the decision")
|
|
|
|
|
|
class MissionFilterResponse(BaseModel):
|
|
"""Response from LLM for mission filtering."""
|
|
|
|
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
|
|
|
|
|
|
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
|
|
"""Build the prompt for filtering candidates by mission relevance."""
|
|
candidate_list = "\n".join(
|
|
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
|
|
)
|
|
|
|
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
|
|
|
|
MISSION: {mission}
|
|
|
|
DETECTED ENTITIES:
|
|
{candidate_list}
|
|
|
|
=== DECISION RULES ===
|
|
|
|
Set promote=true ONLY for specific, named entities:
|
|
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
|
|
- Named organizations: "Google", "Acme Corp", "Frontend Team"
|
|
- Named places: "Central Park Zoo", "NYC Office", "Building A"
|
|
- Named projects: "Project Phoenix", "Auth Service v2"
|
|
|
|
Set promote=false for EVERYTHING ELSE, including:
|
|
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
|
|
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
|
|
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
|
|
|
|
THE TEST: Is this a specific name you'd find in a contact list or org chart?
|
|
- "John" → YES (promote=true)
|
|
- "kids" → NO (promote=false)
|
|
- "community" → NO (promote=false)
|
|
- "Maria" → YES (promote=true)
|
|
- "park" → NO (promote=false)
|
|
|
|
When in doubt, set promote=false."""
|
|
|
|
|
|
def get_mission_filter_system_message() -> str:
|
|
"""System message for mission filtering."""
|
|
return """You filter entities for promotion. Output JSON with 'candidates' array.
|
|
|
|
Rules:
|
|
- promote=true ONLY for specific names (people, organizations, named places/projects)
|
|
- promote=false for common words, generic categories, abstract concepts
|
|
|
|
Examples:
|
|
- "John" → promote=true (person name)
|
|
- "kids" → promote=false (generic category)
|
|
- "community" → promote=false (abstract concept)
|
|
- "Google" → promote=true (organization name)
|
|
- "motivation" → promote=false (abstract concept)
|
|
|
|
When in doubt, promote=false. Most entities should be rejected."""
|
|
|
|
|
|
async def filter_candidates_by_mission(
|
|
llm_config: "LLMConfig",
|
|
mission: str,
|
|
candidates: list[EmergentCandidate],
|
|
) -> list[EmergentCandidate]:
|
|
"""
|
|
Filter emergent candidates to keep only specific, named entities.
|
|
|
|
Args:
|
|
llm_config: LLM configuration
|
|
mission: The bank's mission (used for context)
|
|
candidates: List of detected candidates
|
|
|
|
Returns:
|
|
Filtered list of candidates that are specific named entities
|
|
"""
|
|
if not candidates:
|
|
return []
|
|
|
|
if not mission:
|
|
# No mission = no filtering, keep all candidates
|
|
logger.debug("[EMERGENT] No mission set, skipping filter")
|
|
return candidates
|
|
|
|
prompt = build_mission_filter_prompt(mission, candidates)
|
|
|
|
try:
|
|
result = await llm_config.call(
|
|
messages=[
|
|
{"role": "system", "content": get_mission_filter_system_message()},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
response_format=MissionFilterResponse,
|
|
scope="mental_model_mission_filter",
|
|
)
|
|
|
|
# Build name -> promote map
|
|
promote_map = {c.name: c.promote for c in result.candidates}
|
|
|
|
# Filter candidates
|
|
filtered = []
|
|
for candidate in candidates:
|
|
if candidate.name in promote_map:
|
|
if promote_map[candidate.name]:
|
|
filtered.append(candidate)
|
|
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
|
|
else:
|
|
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
|
|
else:
|
|
# Candidate not in response - reject by default
|
|
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
|
|
|
|
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
|
|
return filtered
|
|
|
|
except Exception as e:
|
|
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
|
|
return []
|
|
|
|
|
|
async def evaluate_emergent_models(
|
|
llm_config: "LLMConfig",
|
|
models: list[dict],
|
|
) -> list[str]:
|
|
"""
|
|
Evaluate existing emergent models to check if they should be kept.
|
|
|
|
This re-evaluates emergent models using the same filtering criteria
|
|
as new candidates. Models that are generic/abstract will be removed.
|
|
|
|
Args:
|
|
llm_config: LLM configuration
|
|
models: List of existing emergent model dicts with 'name', 'id'
|
|
|
|
Returns:
|
|
List of model IDs that should be REMOVED (no longer valid)
|
|
"""
|
|
if not models:
|
|
return []
|
|
|
|
# Convert existing models to candidates for evaluation
|
|
candidates = [
|
|
EmergentCandidate(
|
|
name=m["name"],
|
|
detection_method="existing_emergent_model",
|
|
mention_count=0,
|
|
)
|
|
for m in models
|
|
]
|
|
|
|
# Build a simple prompt for re-evaluation
|
|
names_list = "\n".join([f"- {m['name']}" for m in models])
|
|
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
|
|
|
|
EXISTING MODELS:
|
|
{names_list}
|
|
|
|
=== DECISION RULES ===
|
|
|
|
Set promote=true ONLY for specific, named entities:
|
|
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
|
|
- Named organizations: "Google", "Acme Corp", "Frontend Team"
|
|
- Named places: "Central Park Zoo", "NYC Office", "Building A"
|
|
- Named projects: "Project Phoenix", "Auth Service v2"
|
|
|
|
Set promote=false for EVERYTHING ELSE, including:
|
|
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
|
|
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
|
|
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
|
|
|
|
THE TEST: Is this a specific name you'd find in a contact list or org chart?
|
|
- "John" → YES (promote=true)
|
|
- "kids" → NO (promote=false)
|
|
- "community" → NO (promote=false)
|
|
|
|
When in doubt, set promote=false."""
|
|
|
|
try:
|
|
result = await llm_config.call(
|
|
messages=[
|
|
{"role": "system", "content": get_mission_filter_system_message()},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
response_format=MissionFilterResponse,
|
|
scope="mental_model_emergent_evaluation",
|
|
)
|
|
|
|
# Build name -> promote map
|
|
promote_map = {c.name: c.promote for c in result.candidates}
|
|
|
|
# Find models to remove
|
|
models_to_remove = []
|
|
for model in models:
|
|
name = model["name"]
|
|
if name in promote_map:
|
|
if not promote_map[name]:
|
|
models_to_remove.append(model["id"])
|
|
else:
|
|
logger.debug(f"[EMERGENT] Keeping '{name}'")
|
|
else:
|
|
# Model not in response - remove to be safe
|
|
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
|
|
models_to_remove.append(model["id"])
|
|
|
|
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
|
|
return models_to_remove
|
|
|
|
except Exception as e:
|
|
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
|
|
return []
|
|
|
|
|
|
async def detect_entity_candidates(
|
|
pool,
|
|
bank_id: str,
|
|
min_mentions: int = 5,
|
|
top_percent: int = 20,
|
|
) -> list[EmergentCandidate]:
|
|
"""
|
|
Detect entities that are candidates for promotion to mental models.
|
|
|
|
Args:
|
|
pool: Database connection pool
|
|
bank_id: Bank identifier
|
|
min_mentions: Minimum mention count to consider
|
|
top_percent: Only consider top X% by mention count
|
|
|
|
Returns:
|
|
List of entity candidates
|
|
"""
|
|
from ..db_utils import acquire_with_retry
|
|
from ..memory_engine import fq_table
|
|
|
|
candidates = []
|
|
|
|
async with acquire_with_retry(pool) as conn:
|
|
# Get entities that meet criteria and don't already have mental models
|
|
rows = await conn.fetch(
|
|
f"""
|
|
WITH ranked AS (
|
|
SELECT
|
|
e.id,
|
|
e.canonical_name,
|
|
e.mention_count,
|
|
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
|
|
FROM {fq_table("entities")} e
|
|
LEFT JOIN {fq_table("mental_models")} mm
|
|
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
|
|
WHERE e.bank_id = $1
|
|
AND e.mention_count >= $2
|
|
AND mm.id IS NULL -- Not already a mental model
|
|
)
|
|
SELECT id, canonical_name, mention_count
|
|
FROM ranked
|
|
WHERE rank_pct <= $3
|
|
ORDER BY mention_count DESC
|
|
LIMIT 50
|
|
""",
|
|
bank_id,
|
|
min_mentions,
|
|
top_percent / 100.0,
|
|
)
|
|
|
|
for row in rows:
|
|
candidates.append(
|
|
EmergentCandidate(
|
|
name=row["canonical_name"],
|
|
detection_method="named_entity_extraction",
|
|
mention_count=row["mention_count"],
|
|
entity_id=str(row["id"]),
|
|
relevance_score=0.0,
|
|
)
|
|
)
|
|
|
|
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
|
|
return candidates
|