Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/main_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,13 @@ def _get_session_manager(name):
return rs.session_manager if rs is not None else None


try:
from main_logic.topic_delivery import register_topic_session_manager_getter
register_topic_session_manager_getter(_get_session_manager)
except Exception:
logger.warning("Failed to register topic session manager getter", exc_info=True)


def _select_fallback_session_manager():
"""Return a single connected session manager as a safe fallback, if unambiguous."""
connected = []
Expand Down
360 changes: 360 additions & 0 deletions config/prompts/prompts_activity.py

Large diffs are not rendered by default.

121 changes: 118 additions & 3 deletions main_logic/activity/llm_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@
import time
from typing import Any

from config.prompts.prompts_activity import ACTIVITY_GUESS_PROMPTS, OPEN_THREADS_PROMPTS
from config.prompts.prompts_activity import (
ACTIVITY_GUESS_PROMPTS,
OPEN_THREADS_PROMPTS,
TOPIC_CANDIDATE_PROMPTS,
)
from utils.file_utils import robust_json_loads

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -74,17 +78,33 @@ def _normalize_lang(lang: str) -> str:
if not lang:
return 'zh'
low = lang.lower()
if low.startswith('zh-tw') or low.startswith('zh_hant') or low.startswith('zh-hant'):
return 'zh-TW'
if low.startswith('zh'):
return 'zh'
if low.startswith('ja'):
return 'ja'
if low.startswith('ko'):
return 'ko'
if low.startswith('es'):
return 'es'
if low.startswith('pt'):
return 'pt'
if low.startswith('ru'):
return 'ru'
return 'en'


def _topic_interest_too_short(interest: str) -> bool:
if len(interest) >= 4:
return False
compact = re.sub(r'\s+', '', interest)
cjk_chars = re.findall(r'[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]', compact)
if len(cjk_chars) >= 2:
return False
return True


def _format_conversation(
user_msgs: list[tuple[float, str]],
ai_msgs: list[tuple[float, str]],
Expand Down Expand Up @@ -243,6 +263,102 @@ async def call_open_threads(
return cleaned


async def call_topic_candidates(
*,
user_msgs: list[tuple[float, str]],
ai_msgs: list[tuple[float, str]],
lang: str,
global_signals: str = "",
timeout: float = 8.0,
) -> list[dict[str, Any]] | None:
"""Extract low-frequency deeper topic hooks for the background pool.

This is intentionally a background-only helper. It summarizes raw recent
turns into short topic materials, so proactive chat never needs to pull raw
conversation text synchronously.
"""
lang_key = _normalize_lang(lang)
template = TOPIC_CANDIDATE_PROMPTS.get(lang_key, TOPIC_CANDIDATE_PROMPTS['en'])

if not user_msgs and not ai_msgs:
return []

prompt = template.format(
conversation=_format_conversation(user_msgs, ai_msgs),
global_signals=(global_signals or "(no global signals yet)").strip(),
)

raw = await _invoke_emotion_tier(prompt, timeout=timeout, label='topic_candidates')
if raw is None:
return None

parsed = _safe_parse_json(raw)
if not isinstance(parsed, dict):
logger.debug('topic_candidates: LLM did not return a JSON object: %r', raw[:200])
return None

topics = parsed.get('topics')
if not isinstance(topics, list):
return None

cleaned: list[dict[str, Any]] = []
for item in topics[:4]:
if not isinstance(item, dict):
continue
interest = str(item.get('interest') or '').strip()
if _topic_interest_too_short(interest):
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
priority = int(item.get('priority', 80))
except (TypeError, ValueError):
priority = 80
if priority < 50:
continue
try:
readiness = int(item.get('readiness', priority))
except (TypeError, ValueError):
readiness = priority
try:
collection_score = int(item.get('collection_score', readiness))
except (TypeError, ValueError):
collection_score = readiness
try:
confidence = int(item.get('confidence', priority))
except (TypeError, ValueError):
confidence = priority
try:
risk = int(item.get('risk', 20))
except (TypeError, ValueError):
risk = 20
readiness = max(0, min(100, readiness))
collection_score = max(0, min(100, collection_score))
confidence = max(0, min(100, confidence))
risk = max(0, min(100, risk))
if collection_score < 80 or readiness < 70 or confidence < 55 or risk > 65:
continue
material = {
'interest': interest[:90],
'hook': str(item.get('hook') or '').strip()[:120],
'opening_intent': str(item.get('opening_intent') or '').strip()[:90],
'deepening_hint': str(item.get('deepening_hint') or '').strip()[:90],
'readiness': readiness,
'collection_score': collection_score,
'confidence': confidence,
'risk': risk,
'why_now': str(item.get('why_now') or '').strip()[:140],
'search_query': str(item.get('search_query') or '').strip()[:80],
'priority': max(0, min(100, priority)),
}
if not material['why_now']:
material['why_now'] = ''
if not material['search_query']:
material['search_query'] = ''
cleaned.append(material)
if len(cleaned) >= 2:
break
return cleaned


# ── Internal LLM driver ─────────────────────────────────────────────

async def _invoke_emotion_tier(prompt: str, *, timeout: float, label: str) -> str | None:
Expand All @@ -252,9 +368,8 @@ async def _invoke_emotion_tier(prompt: str, *, timeout: float, label: str) -> st
full LLM stack — useful for tests that exercise prompt formatting
without a live model.
"""
from langchain_core.messages import HumanMessage
from utils.config_manager import get_config_manager
from utils.llm_client import create_chat_llm
from utils.llm_client import HumanMessage, create_chat_llm
from utils.token_tracker import set_call_type

try:
Expand Down
25 changes: 23 additions & 2 deletions main_logic/activity/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@

logger = logging.getLogger(__name__)


def _topic_pool_language() -> str:
try:
from utils.language_utils import get_global_language, normalize_language_code
return normalize_language_code(get_global_language(), format='short') or 'en'
except Exception:
return 'en'


# Conversation buffers: small enough to keep prompt sizes tight, large
# enough to give the emotion-tier LLM real recent context.
_CONV_BUFFER_MAXLEN = 12
Expand Down Expand Up @@ -343,7 +352,13 @@ def on_user_message(self, *, text: str | None = None, now: float | None = None)
# 旧数据被 enrichment LLM 二次曝光。state machine 的时间戳还要更新
# (下游 idle / focused_work 判定依赖),文本扔了即可。
if text and not _privacy_mode_active():
self._user_msg_buffer.append((ts, text.strip()[:1000]))
cleaned = text.strip()[:1000]
self._user_msg_buffer.append((ts, cleaned))
try:
from main_logic.topic_pipeline import get_topic_hook_pool
get_topic_hook_pool().note_user_message(self.lanlan_name, cleaned, lang=_topic_pool_language())
except Exception:
logger.debug('[%s] topic pool user-message note failed', self.lanlan_name, exc_info=True)

def on_ai_message(self, *, text: str | None = None, now: float | None = None) -> None:
"""Stamp an "AI just spoke" event.
Expand All @@ -361,7 +376,13 @@ def on_ai_message(self, *, text: str | None = None, now: float | None = None) ->
ts = now if now is not None else time.time()
self._sm.update_ai_message(text=text, now=ts)
if text and not _privacy_mode_active():
self._ai_msg_buffer.append((ts, text.strip()[:1000]))
cleaned = text.strip()[:1000]
self._ai_msg_buffer.append((ts, cleaned))
try:
from main_logic.topic_pipeline import get_topic_hook_pool
get_topic_hook_pool().note_ai_message(self.lanlan_name, cleaned, lang=_topic_pool_language())
except Exception:
logger.debug('[%s] topic pool ai-message note failed', self.lanlan_name, exc_info=True)
# AI also opens threads (promises, abandoned mid-sentences) →
# bump _conv_seq so kickoff_open_threads_compute will recompute.
# Empty / no-text turns (errors / silenced) skip the bump,
Expand Down
Loading
Loading