Skip to content
Open
Show file tree
Hide file tree
Changes from all 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