You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
10 KiB
Python
227 lines
10 KiB
Python
import logging
|
|
import json
|
|
from typing import List, Dict, Any, Tuple
|
|
from datetime import datetime
|
|
from qwen_agent.llm import get_chat_model
|
|
from src.ircfa_interaction.llm.gemma_llamacpp import GemmaLlamaCppLLM
|
|
from src.ircfa_interaction.config import DEFAULT_CONTEXT_WINDOW
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class ContextCompactor:
|
|
def __init__(self, llm_config: dict):
|
|
self.llm_config = llm_config
|
|
self.llm = None
|
|
|
|
# Determine context size limit from config or user spec (default: DEFAULT_CONTEXT_WINDOW)
|
|
self.num_ctx = DEFAULT_CONTEXT_WINDOW
|
|
model_name = llm_config.get("name", "").lower()
|
|
if "gemma" in model_name:
|
|
self.num_ctx = DEFAULT_CONTEXT_WINDOW
|
|
else:
|
|
# Check config defaults or settings
|
|
self.num_ctx = llm_config.get("generate_cfg", {}).get("num_ctx", DEFAULT_CONTEXT_WINDOW)
|
|
|
|
# Threshold at 75% of context window size
|
|
self.threshold_tokens = int(self.num_ctx * 0.75)
|
|
|
|
def _init_llm(self):
|
|
if self.llm is not None:
|
|
return
|
|
if "gemma" in self.llm_config.get("name", "").lower() and "llamacpp" in self.llm_config.get("name", "").lower():
|
|
self.llm = GemmaLlamaCppLLM(self.llm_config)
|
|
else:
|
|
self.llm = get_chat_model(self.llm_config)
|
|
|
|
def estimate_tokens(self, text: str) -> int:
|
|
"""Estimate tokens based on characters and words (1 token approx 4 characters)."""
|
|
if not text:
|
|
return 0
|
|
return max(len(text) // 4, int(len(text.split()) * 1.3))
|
|
|
|
def estimate_messages_tokens(self, messages: List[Dict[str, str]]) -> int:
|
|
"""Estimate total tokens in a list of chat messages."""
|
|
total = 0
|
|
for m in messages:
|
|
total += self.estimate_tokens(m.get("content", ""))
|
|
return total
|
|
|
|
async def _call_llm(self, prompt: str) -> str:
|
|
"""Call the LLM to get a response (handling sync, generator, and async responses)."""
|
|
self._init_llm()
|
|
try:
|
|
# We run the chat model
|
|
resp = self.llm.chat(
|
|
messages=[{"role": "user", "content": prompt}],
|
|
stream=False
|
|
)
|
|
|
|
content = ""
|
|
# Handle if generator/iterator (e.g. streaming adapter returned by chat)
|
|
if hasattr(resp, "__next__"):
|
|
for chunk in resp:
|
|
if isinstance(chunk, list) and chunk:
|
|
# qwen-agent returns a list of Messages
|
|
msg = chunk[-1]
|
|
content = msg.get("content", "") if hasattr(msg, "get") else getattr(msg, "content", "")
|
|
elif hasattr(resp, "__aiter__"):
|
|
async for chunk in resp:
|
|
if isinstance(chunk, list) and chunk:
|
|
msg = chunk[-1]
|
|
content = msg.get("content", "") if hasattr(msg, "get") else getattr(msg, "content", "")
|
|
else:
|
|
if isinstance(resp, list) and resp:
|
|
msg = resp[-1]
|
|
content = msg.get("content", "") if hasattr(msg, "get") else getattr(msg, "content", "")
|
|
elif hasattr(resp, "content"):
|
|
content = getattr(resp, "content", "")
|
|
else:
|
|
content = str(resp)
|
|
|
|
return content.strip()
|
|
except Exception as e:
|
|
logger.error(f"Error in ContextCompactor LLM call: {e}")
|
|
raise
|
|
|
|
async def summarize_history(self, messages_to_summarize: List[Dict[str, str]]) -> str:
|
|
"""Generate a concise summary of the provided messages."""
|
|
if not messages_to_summarize:
|
|
return ""
|
|
|
|
formatted_history = ""
|
|
for m in messages_to_summarize:
|
|
role = "User" if m["role"] == "user" else "Asisten"
|
|
formatted_history += f"{role}: {m['content']}\n\n"
|
|
|
|
prompt = (
|
|
"Ringkas percakapan berikut secara sangat singkat dan padat dalam Bahasa Indonesia. "
|
|
"Fokus pada gejala kerusakan, equipment/location tag yang dibahas, "
|
|
"hasil analisis, dan kesimpulan sementara. Jangan hilangkan detail penting:\n\n"
|
|
f"{formatted_history}\n\n"
|
|
"Ringkasan Singkat:"
|
|
)
|
|
|
|
try:
|
|
summary = await self._call_llm(prompt)
|
|
return summary
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate summary: {e}")
|
|
return "[Percakapan sebelumnya dipadatkan secara otomatis untuk menghemat memori.]"
|
|
|
|
async def combine_summaries(self, prev_summary: str, new_summary: str) -> str:
|
|
"""Combine old summary and new summary into a single condensed summary."""
|
|
prompt = (
|
|
"Gabungkan dua ringkasan percakapan berikut menjadi satu ringkasan yang padat, "
|
|
"kohesif, dan kronologis dalam Bahasa Indonesia:\n\n"
|
|
f"Ringkasan 1:\n{prev_summary}\n\n"
|
|
f"Ringkasan 2:\n{new_summary}\n\n"
|
|
"Ringkasan Gabungan:"
|
|
)
|
|
try:
|
|
combined = await self._call_llm(prompt)
|
|
return combined
|
|
except Exception as e:
|
|
logger.error(f"Failed to combine summaries: {e}")
|
|
return f"{prev_summary}\n{new_summary}"
|
|
|
|
async def compact_context(
|
|
self,
|
|
system_prompt: str,
|
|
history_context: List[Dict[str, str]],
|
|
doc_summary: str,
|
|
current_query: str,
|
|
rcfa_state: Dict[str, Any]
|
|
) -> Tuple[List[Dict[str, str]], Dict[str, Any], bool]:
|
|
"""
|
|
Check context token size and perform compaction (summarization or trimming) if threshold is exceeded.
|
|
Returns:
|
|
- Updated history_context
|
|
- Updated rcfa_state
|
|
- Boolean flag indicating if compaction occurred
|
|
"""
|
|
# Calculate current token usage
|
|
sys_tokens = self.estimate_tokens(system_prompt)
|
|
doc_tokens = self.estimate_tokens(doc_summary)
|
|
query_tokens = self.estimate_tokens(current_query)
|
|
history_tokens = self.estimate_messages_tokens(history_context)
|
|
|
|
total_tokens = sys_tokens + doc_tokens + query_tokens + history_tokens
|
|
|
|
logger.info(
|
|
f"Context check: total={total_tokens} tokens (sys={sys_tokens}, doc={doc_tokens}, "
|
|
f"query={query_tokens}, history={history_tokens}). Limit={self.num_ctx}, Threshold={self.threshold_tokens}"
|
|
)
|
|
|
|
if total_tokens <= self.threshold_tokens:
|
|
return history_context, rcfa_state, False
|
|
|
|
# Context window is full! We need to compact
|
|
compaction_count = rcfa_state.get("context_compaction_count", 0)
|
|
logger.warning(f"Context window limit threatened ({total_tokens} tokens). Current compaction count: {compaction_count}")
|
|
|
|
if compaction_count < 3:
|
|
# We can compact (summarize older messages)
|
|
# We keep the last 3 messages (approx 1.5 turns) intact to maintain conversation continuity
|
|
keep_count = min(3, len(history_context))
|
|
messages_to_summarize = history_context[:-keep_count] if keep_count > 0 else history_context
|
|
messages_to_keep = history_context[-keep_count:] if keep_count > 0 else []
|
|
|
|
# Filter out any pre-existing system summary message from what we summarize
|
|
# (since that's already in the accumulated summary)
|
|
messages_to_summarize = [m for m in messages_to_summarize if m["role"] != "system"]
|
|
|
|
if not messages_to_summarize:
|
|
# No older messages to summarize (history is already minimal, maybe docs are huge)
|
|
# Fallback: Trim documents or history_context
|
|
return history_context, rcfa_state, False
|
|
|
|
logger.info(f"Summarizing {len(messages_to_summarize)} older history messages...")
|
|
new_summary = await self.summarize_history(messages_to_summarize)
|
|
|
|
# Combine with any previous summary stored in rcfa_state
|
|
prev_summary = rcfa_state.get("accumulated_conversation_summary", "")
|
|
if prev_summary:
|
|
logger.info("Combining new summary with previous accumulated summary...")
|
|
accumulated_summary = await self.combine_summaries(prev_summary, new_summary)
|
|
else:
|
|
accumulated_summary = new_summary
|
|
|
|
# Find the latest timestamp of the summarized messages to set the cutoff
|
|
cutoff_timestamp = None
|
|
for m in reversed(messages_to_summarize):
|
|
if m.get("timestamp"):
|
|
cutoff_timestamp = m.get("timestamp")
|
|
break
|
|
|
|
# Increment compaction count and update state
|
|
compaction_count += 1
|
|
rcfa_state["context_compaction_count"] = compaction_count
|
|
rcfa_state["accumulated_conversation_summary"] = accumulated_summary
|
|
if cutoff_timestamp:
|
|
rcfa_state["last_compacted_timestamp"] = cutoff_timestamp
|
|
|
|
logger.info(f"Compaction successful. New compaction count: {compaction_count}, cutoff: {cutoff_timestamp}")
|
|
return messages_to_keep, rcfa_state, True
|
|
|
|
else:
|
|
# Compaction count has reached 3! We must trim/discard older messages instead of summarizing
|
|
logger.warning("Max compaction limit (3) reached. Trimming oldest history messages to free space.")
|
|
# Keep only the last 4 messages (approx 2 turns)
|
|
keep_count = min(4, len(history_context))
|
|
new_history = history_context[-keep_count:] if keep_count > 0 else []
|
|
|
|
# If the oldest kept message has a timestamp, update the database cutoff so they don't load next time
|
|
cutoff_timestamp = None
|
|
# Find the message just before the kept messages to discard them
|
|
discarded_messages = history_context[:-keep_count] if keep_count > 0 else history_context
|
|
for m in reversed(discarded_messages):
|
|
if m.get("timestamp"):
|
|
cutoff_timestamp = m.get("timestamp")
|
|
break
|
|
|
|
if cutoff_timestamp:
|
|
rcfa_state["last_compacted_timestamp"] = cutoff_timestamp
|
|
rcfa_state["context_trimmed"] = True
|
|
|
|
return new_history, rcfa_state, True
|