human behavior: classifier gate, pacing, splitting, quiet hours, stable prompt prefix

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 15:55:26 +02:00
parent 13b9569c07
commit ae870db181
15 changed files with 510 additions and 42 deletions
+57
View File
@@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import logging
from io import BytesIO
@@ -56,6 +57,29 @@ CONSOLIDATION_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {"name": "consolidation", "strict": True, "schema": CONSOLIDATION_SCHEMA},
}
# Reply/ignore + factual pre-pass (SPEC-010 BEH-01): one cheap call
CLASSIFIER_SCHEMA = {
"type": "object",
"properties": {
"reply": {"type": "boolean", "description": "Should the assistant answer this message?"},
"factual": {"type": "boolean", "description": "Does the user want concrete information (hours, prices, availability)?"},
"emoji": {"type": ["string", "null"], "description": "Optional single emoji reaction when not replying, else null."},
},
"required": ["reply", "factual", "emoji"],
"additionalProperties": False,
}
CLASSIFIER_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {"name": "reply_verdict", "strict": True, "schema": CLASSIFIER_SCHEMA},
}
CLASSIFIER_SYSTEM = (
"You watch a group chat that has an assistant bot. Decide whether the assistant should answer the LAST message:"
" reply=true when it addresses the assistant, asks something the assistant can help with, or continues a conversation"
" with the assistant; reply=false for human-to-human chatter the assistant should not butt into."
" factual=true when the user wants concrete information (opening hours, prices, availability, addresses)."
" When reply=false you may suggest one fitting emoji reaction, else null."
)
CONSOLIDATION_SYSTEM = (
"You maintain the long-term memory of a Discord assistant. From the observation log, extract NEW durable facts that users stated"
" about THEMSELVES only (never record what one user claims about another user), and write one short episode summary of the"
@@ -115,6 +139,16 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
@staticmethod
def _last_author(messages: List[Dict[str, Any]]) -> Optional[str]:
try:
content = messages[-1]["content"]
if not isinstance(content, str):
content = content[0]["text"]
return str(json.loads(content).get("user")) or None
except Exception:
return None
def _record_usage(self, result: Any) -> None:
usage = getattr(result, "usage", None)
prompt_tokens = getattr(usage, "prompt_tokens", None)
@@ -164,6 +198,10 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
"messages": messages,
"response_format": ENVELOPE_RESPONSE_FORMAT,
}
author = self._last_author(messages)
if author:
# hashed, never the raw Discord name (SAF-10)
chat_kwargs["safety_identifier"] = "discord-" + hashlib.sha256(author.encode()).hexdigest()[:16]
if self.igdb and self.config.get("enable-game-info", False):
try:
@@ -326,6 +364,25 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning(f"failed to translate the text: {repr(err)}")
return text
async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03)."""
if "classifier-model" not in self.config or not self.ledger.budget_ok():
return None
tail = "\n".join(str(entry.get("content", ""))[:300] for entry in history_tail[-6:])
messages = [
{"role": "system", "content": CLASSIFIER_SYSTEM},
{"role": "user", "content": f"Recent chat:\n{tail}\n\nLAST message:\n{str(message)}"},
]
try:
result = await openai_chat(
self.client, model=self.config["classifier-model"], messages=messages, response_format=CLASSIFIER_RESPONSE_FORMAT
)
self._record_usage(result)
return json.loads(result.choices[0].message.content)
except Exception as err:
logging.warning(f"classifier failed - failing open: {repr(err)}")
return None
async def consolidate(self, observations: List[Dict[str, Any]], known_facts: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Batched memory consolidation on memory-model (MEM-02)."""
if "memory-model" not in self.config or not self.ledger.budget_ok():