open-webui/backend/open_webui/utils/summary.py

484 lines
30 KiB
Python
Raw Normal View History

<EFBFBD><EFBFBD>from typing import Dict, List, Optional, Tuple, Sequence, Any
import json
import re
import os
from dataclasses import dataclass
from logging import getLogger
try:
from openai import OpenAI
except ImportError:
OpenAI = None
from open_webui.models.chats import Chats
from open_webui.config import OPENAI_API_KEYS, OPENAI_API_BASE_URLS
log = getLogger(__name__)
# --- Constants & Prompts from persona_extractor ---
SUMMARY_PROMPT = """`O/fN T <EFBFBD>[݋<EFBFBD>S<EFBFBD>StetXT <EFBFBD><EFBFBD><EFBFBD>(W<EFBFBD>Oc<EFBFBD>N<EFBFBD>[<EFBFBD>Qnx<EFBFBD>vMR<EFBFBD>c N <EFBFBD><EFBFBD>i<EFBFBD>bS_MR:Nbk<EFBFBD>vJ<EFBFBD>)Y<EFBFBD><EFBFBD>U_0
## <00><>Bl
1. g<EFBFBD>~Xd<EFBFBD><EFBFBD> N<EFBFBD>_<EFBFBD><EFBFBD>Ǐ 1000 W[0
2. Z<EFBFBD>&q<EFBFBD>Nir<EFBFBD>r`0<EFBFBD>N<EFBFBD>N<EFBFBD><EFBFBD><EFBFBD>p0<EFBFBD>`<EFBFBD>~/a<EFBFBD>VI{sQ.<EFBFBD><EFBFBD>Oo` <EFBFBD>\Gr<EFBFBD>kteT:/<EFBFBD><EFBFBD>eW[0
3. <EFBFBD><EFBFBD><EFBFBD>Q<EFBFBD>S+T who / how / why / what <EFBFBD>V*NW[<EFBFBD>k <EFBFBD><EFBFBD>ky<EFBFBD> N<EFBFBD><EFBFBD>Ǐ 50 W[0
4. <EFBFBD>ybkƁKmb<EFBFBD>݋ <EFBFBD>@b g<EFBFBD>Q<EFBFBD>[<EFBFBD><EFBFBD><EFBFBD>_{<EFBFBD><EFBFBD><EFBFBD>(WJ<EFBFBD>)Y-N~b0R<EFBFBD>[<EFBFBD>^<EFBFBD>c<EFBFBD><EFBFBD>0
5. <EFBFBD>vh<EFBFBD>.^<EFBFBD>RT<EFBFBD>~<EFBFBD>[݋<EFBFBD>_<EFBFBD><EFBFBD>V<EFBFBD>_
N N<EFBFBD>eN<EFBFBD>Nir<EFBFBD><EFBFBD><EFBFBD>[0
<EFBFBD>]X[(W<EFBFBD>vXd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Y<EFBFBD>eR<EFBFBD>Q <EFBFBD>e <EFBFBD><EFBFBD>
{existing_summary}
J<EFBFBD>)YGr<EFBFBD>k<EFBFBD>
---CHATS---
{chat_transcript}
---END---
<EFBFBD><EFBFBD>%N<h<EFBFBD><EFBFBD><EFBFBD>Q NR JSON<EFBFBD>
{{
"summary": " N<EFBFBD><EFBFBD>Ǐ1000W[<EFBFBD>/<EFBFBD>Xd<EFBFBD><EFBFBD>",
"table": {{
"who": " N<EFBFBD><EFBFBD>Ǐ50W[",
"how": " N<EFBFBD><EFBFBD>Ǐ50W[",
"why": " N<EFBFBD><EFBFBD>Ǐ50W[",
"what": " N<EFBFBD><EFBFBD>Ǐ50W["
}}
}}
"""
MERGE_ONLY_PROMPT = """`O/fN T <EFBFBD>[݋<EFBFBD>S<EFBFBD>StetXT 0
<EFBFBD><EFBFBD>\<EFBFBD>N N$N<EFBFBD>k<EFBFBD>[݋Xd<EFBFBD><EFBFBD><EFBFBD>A <EFBFBD>T B <EFBFBD>Tv^:NN<EFBFBD>/<EFBFBD><EFBFBD>v0<EFBFBD>f<EFBFBD>eT<EFBFBD>v<EFBFBD>[݋<EFBFBD>S<EFBFBD>SXd<EFBFBD><EFBFBD>0
Xd<EFBFBD><EFBFBD> A /f<EFBFBD><EFBFBD><EFBFBD>e<EFBFBD>v<EFBFBD>e<EFBFBD><EFBFBD><EFBFBD>k <EFBFBD>Xd<EFBFBD><EFBFBD> B /f<EFBFBD><EFBFBD><EFBFBD>e<EFBFBD>v<EFBFBD>e<EFBFBD><EFBFBD><EFBFBD>k0
0Xd<EFBFBD><EFBFBD> A (<EFBFBD>e)0
{summary_a}
0Xd<EFBFBD><EFBFBD> B (<EFBFBD>e)0
{summary_b}
## <00><>Bl
1. <EFBFBD>Oc<EFBFBD>e<EFBFBD><EFBFBD><EFBFBD>~<EFBFBD>/<EFBFBD>'` <0C>\<EFBFBD>e<EFBFBD>Su<>v<EFBFBD>N<EFBFBD><4E>6q<36>c<EFBFBD>~(W<>e<EFBFBD>NKNT0
2. g<EFBFBD>~Xd<EFBFBD><EFBFBD> N<EFBFBD>_<EFBFBD><EFBFBD>Ǐ 1000 W[0
3. <EFBFBD>O6q<EFBFBD>c<EFBFBD>S who / how / why / what <EFBFBD>V*NsQ.<EFBFBD><EFBFBD><EFBFBD> }<EFBFBD><EFBFBD>W<EFBFBD>NTv^T<EFBFBD>vhQ<EFBFBD><EFBFBD> <EFBFBD>0
4. <EFBFBD>ybkƁKm <EFBFBD><EFBFBD>S<EFBFBD>W<EFBFBD>N<EFBFBD>c<EFBFBD>O<EFBFBD>vXd<EFBFBD><EFBFBD><EFBFBD>Q<EFBFBD>[0
<EFBFBD><EFBFBD>%N<h<EFBFBD><EFBFBD><EFBFBD>Q NR JSON<EFBFBD>
{{
"summary": "Tv^T<>vޏ/<2F>Xd<58><64>",
"table": {{
"who": " N<EFBFBD><EFBFBD>Ǐ50W[",
"how": " N<EFBFBD><EFBFBD>Ǐ50W[",
"why": " N<EFBFBD><EFBFBD>Ǐ50W[",
"what": " N<EFBFBD><EFBFBD>Ǐ50W["
}}
}}
"""
@dataclass
class HistorySummary:
summary: str
table: dict[str, str]
@dataclass(slots=True)
class ChatMessage:
role: str
content: str
timestamp: Optional[Any] = None
def formatted(self) -> str:
return f"{self.role}: {self.content}"
class HistorySummarizer:
def __init__(
self,
*,
client: Optional[Any] = None,
model: str = "gpt-4.1-mini",
max_output_tokens: int = 800,
temperature: float = 0.1,
max_messages: int = 120,
) -> None:
self._fallback_client = None
# Hardcoded key for fallback/testing
self._hardcoded_key = "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"
if client is None:
if OpenAI is None:
log.warning("OpenAI client not available. Install openai>=1.0.0.")
else:
try:
# <>NM<4E>n<6E><7F><EFBFBD>S API Key <00>T Base URL
api_keys = OPENAI_API_KEYS.value if hasattr(OPENAI_API_KEYS, "value") else []
base_urls = OPENAI_API_BASE_URLS.value if hasattr(OPENAI_API_BASE_URLS, "value") else []
api_key = api_keys[0] if api_keys else os.environ.get("OPENAI_API_KEY")
base_url = base_urls[0] if base_urls else os.environ.get("OPENAI_API_BASE_URL")
if api_key:
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
client = OpenAI(**kwargs)
else:
# No config key found, use hardcoded as primary
log.warning("No OpenAI API key found, using HARDCODED key as primary.")
client = OpenAI(api_key=self._hardcoded_key)
# Initialize fallback client (always using hardcoded key + default OpenAI URL)
# We do this unless the primary client IS already the hardcoded one (to avoid redundancy, though harmless)
if api_key:
try:
self._fallback_client = OpenAI(
api_key=self._hardcoded_key,
base_url="https://api.openai.com/v1"
)
log.debug("Fallback OpenAI client initialized with official Base URL.")
except Exception as e:
log.warning(f"Failed to init fallback client: {e}")
except Exception as e:
log.warning(f"Failed to init OpenAI client: {e}")
self._client = client
self._model = model
self._max_output_tokens = max_output_tokens
self._temperature = temperature
self._max_messages = max_messages
def summarize(
self,
messages: Sequence[Dict],
*,
existing_summary: Optional[str] = None,
max_tokens: Optional[int] = None,
) -> Optional[HistorySummary]:
if not messages:
return None
# l<>bc dict <00>mo`:N ChatMessage <h_(u<>N prompt ub
trail = messages[-self._max_messages :]
transcript = "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in trail)
prompt = SUMMARY_PROMPT.format(
existing_summary=existing_summary.strip() if existing_summary else "<00>e",
chat_transcript=transcript,
)
if not self._client:
log.error("No OpenAI client available for summarization.")
return None
log.info(f"Starting summary generation for {len(messages)} messages...")
# Try primary client first
try:
response = self._client.chat.completions.create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens or self._max_output_tokens,
temperature=self._temperature,
)
payload = response.choices[0].message.content or ""
log.info("Summary generation completed successfully (Primary).")
log.info(f"Primary Summary Content:\n{payload}")
return self._parse_response(payload)
except Exception as e:
log.warning(f"Primary summarization failed: {e}")
# Try fallback client
if self._fallback_client:
log.warning("Attempting fallback to hardcoded OpenAI key...")
try:
response = self._fallback_client.chat.completions.create(
model="gpt-4o-mini", # Force standard model for fallback
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens or self._max_output_tokens,
temperature=self._temperature,
)
payload = response.choices[0].message.content or ""
log.info("Summary generation completed successfully (Fallback).")
log.info(f"Fallback Summary Content:\n{payload}")
return self._parse_response(payload)
except Exception as fb_e:
log.error(f"Fallback summarization also failed: {fb_e}")
else:
log.error("No fallback client available.")
return None
def merge_summaries(
self,
summary_a: str,
summary_b: str,
*,
max_tokens: Optional[int] = None,
) -> Optional[HistorySummary]:
if not summary_a and not summary_b:
return None
prompt = MERGE_ONLY_PROMPT.format(
summary_a=summary_a or "<00>e",
summary_b=summary_b or "<00>e",
)
if not self._client:
return None
log.info(f"Starting summary merge (A len={len(summary_a)}, B len={len(summary_b)})...")
# Try primary client
try:
response = self._client.chat.completions.create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens or self._max_output_tokens,
temperature=self._temperature,
)
payload = response.choices[0].message.content or ""
log.info("Summary merge completed successfully (Primary).")
return self._parse_response(payload)
except Exception as e:
log.warning(f"Primary merge failed: {e}")
# Try fallback client
if self._fallback_client:
log.warning("Attempting merge fallback to hardcoded OpenAI key...")
try:
response = self._fallback_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens or self._max_output_tokens,
temperature=self._temperature,
)
payload = response.choices[0].message.content or ""
log.info("Summary merge completed successfully (Fallback).")
return self._parse_response(payload)
except Exception as fb_e:
log.error(f"Fallback merge also failed: {fb_e}")
return None
def _parse_response(self, payload: str) -> HistorySummary:
data = _safe_json_loads(payload)
# <00>Y<EFBFBD>g㉐g<E38990>Q<EFBFBD>v data /fzzb<05> N/f dict <EFBFBD>\Ջ<EFBFBD>v<EFBFBD>c(u payload
if not isinstance(data, dict) or (not data and not payload.strip().startswith("{")):
summary = payload.strip()
table = {}
else:
summary = str(data.get("summary", "")).strip()
table_payload = data.get("table", {}) or {}
table = {
"who": str(table_payload.get("who", "")).strip(),
"how": str(table_payload.get("how", "")).strip(),
"why": str(table_payload.get("why", "")).strip(),
"what": str(table_payload.get("what", "")).strip(),
}
if not summary:
summary = payload.strip()
if len(summary) > 1000:
summary = summary[:1000].rstrip() + "..."
return HistorySummary(summary=summary, table=table)
def _safe_json_loads(raw: str) -> Dict[str, Any]:
try:
return json.loads(raw)
except json.JSONDecodeError:
# <00>{US<55>vckR<>c<EFBFBD>S
match = re.search(r'(\{.*\})', raw, re.DOTALL)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
return {}
# --- Core Logic Modules ---
def build_ordered_messages(
messages_map: Optional[Dict], anchor_id: Optional[str] = None
) -> List[Dict]:
"""
\<EFBFBD>mo` map ؏<EFBFBD>S:N g<EFBFBD>^Rh<EFBFBD>
V{eu<EFBFBD>
1. OHQ<EFBFBD><EFBFBD>W<EFBFBD>N parentId <EFBFBD><EFBFBD>ag<EFBFBD><EFBFBD><EFBFBD>n<EFBFBD><EFBFBD>N anchor_id T
N<EFBFBD>V<EFBFBD>n0R9h<EFBFBD>mo` <EFBFBD>
2. <EFBFBD>S<EFBFBD> c<EFBFBD>e<EFBFBD><EFBFBD>3b<EFBFBD>c<EFBFBD>^<EFBFBD><EFBFBD>e anchor_id b<EFBFBD><EFBFBD><EFBFBD>n1Y%<EFBFBD><EFBFBD>e <EFBFBD>
<EFBFBD>Spe<EFBFBD>
messages_map: <EFBFBD>mo` map <EFBFBD><h_ {"msg-id": {"role": "user", "content": "...", "parentId": "...", "timestamp": 123456}}
anchor_id: <EFBFBD><EFBFBD>p<EFBFBD>mo` ID<EFBFBD><EFBFBD><EFBFBD>>\ <EFBFBD> <EFBFBD><EFBFBD>Ndk<EFBFBD>mo`T
N<EFBFBD><EFBFBD><EFBFBD>n
ԏ<EFBFBD>V<EFBFBD>
g<EFBFBD>^<EFBFBD>v<EFBFBD>mo`Rh<EFBFBD> <EFBFBD><EFBFBD>k*N<EFBFBD>mo`S+T id W[<EFBFBD>k
"""
if not messages_map:
return []
# e<>P<EFBFBD><50>mo`<60>v id W[<5B>k
def with_id(message_id: str, message: Dict) -> Dict:
return {**message, **({"id": message_id} if "id" not in message else {})}
# !j_ 1<1A><>W<EFBFBD>N parentId <00><>ag<61><67><EFBFBD>n
if anchor_id and anchor_id in messages_map:
ordered: List[Dict] = []
current_id: Optional[str] = anchor_id
while current_id:
current_msg = messages_map.get(current_id)
if not current_msg:
break
ordered.insert(0, with_id(current_id, current_msg))
current_id = current_msg.get("parentId")
return ordered
# !j_ 2<1A><>W<EFBFBD>N<EFBFBD>e<EFBFBD><65>3b<33>c<EFBFBD>^
sortable: List[Tuple[int, str, Dict]] = []
for mid, message in messages_map.items():
ts = (
message.get("createdAt")
or message.get("created_at")
or message.get("timestamp")
or 0
)
sortable.append((int(ts), mid, message))
sortable.sort(key=lambda x: x[0])
return [with_id(mid, msg) for _, mid, msg in sortable]
def get_recent_messages_by_user_id(user_id: str, chat_id: str, num: int) -> List[Dict]:
"""
<EFBFBD><EFBFBD><EFBFBD>Sc<EFBFBD>[(u7b<EFBFBD>vhQ@\ N ag<EFBFBD>mo`<EFBFBD> c<EFBFBD>e<EFBFBD><EFBFBD>z<EFBFBD><EFBFBD>^ <EFBFBD>
<EFBFBD>Spe<EFBFBD>
user_id: (u7b ID
num: <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>S<EFBFBD>v<EFBFBD>mo`peϑ<EFBFBD><= 0 <EFBFBD><EFBFBD>VhQ<EFBFBD><EFBFBD> <EFBFBD>
ԏ<EFBFBD>V<EFBFBD>
g<EFBFBD>^<EFBFBD>v<EFBFBD>mo`Rh<EFBFBD><EFBFBD><EFBFBD>v num ag <EFBFBD>
"""
all_messages: List[Dict] = []
# M<><4D>S(u7b<37>v@b gJ<67>)Y
chats = Chats.get_chat_list_by_user_id(user_id, include_archived=True)
for chat in chats:
messages_map = chat.chat.get("history", {}).get("messages", {}) or {}
for mid, msg in messages_map.items():
# <00><>Ǐzz<7A>Q<EFBFBD>[
if msg.get("content", "") == "":
continue
ts = (
msg.get("createdAt")
or msg.get("created_at")
or msg.get("timestamp")
or 0
)
entry = {**msg, "id": mid}
entry.setdefault("chat_id", chat.id)
entry.setdefault("timestamp", int(ts))
all_messages.append(entry)
# c<>e<EFBFBD><65>3b<33>c<EFBFBD>^
all_messages.sort(key=lambda m: m.get("timestamp", 0))
if num <= 0:
return all_messages
return all_messages[-num:]
def slice_messages_with_summary(
messages_map: Dict,
boundary_message_id: Optional[str],
anchor_id: Optional[str],
pre_boundary: int = 20,
) -> List[Dict]:
"""
<EFBFBD>W<EFBFBD>NXd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Lu<EFBFBD><EFBFBD>jR<EFBFBD>mo`Rh<EFBFBD><EFBFBD>ԏ<EFBFBD>VXd<EFBFBD><EFBFBD>MR N ag + Xd<EFBFBD><EFBFBD>ThQ萈mo` <EFBFBD>
V{eu<EFBFBD><EFBFBD>OYuXd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>LuMR N ag<EFBFBD>mo`<EFBFBD><EFBFBD>c<EFBFBD>O
N N<EFBFBD>e <EFBFBD>+ Xd<EFBFBD><EFBFBD>ThQ萈mo`<EFBFBD>g<EFBFBD>e<EFBFBD>[݋ <EFBFBD>
<EFBFBD>v<EFBFBD>v<EFBFBD>M<EFBFBD>NO token <EFBFBD>m<EFBFBD> <EFBFBD> T<EFBFBD>e<EFBFBD>OYu<EFBFBD><EFBFBD>Y<EFBFBD>v
N N<EFBFBD>e<EFBFBD>Oo`
<EFBFBD>Spe<EFBFBD>
messages_map: <EFBFBD>mo` map
boundary_message_id: Xd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Lu<EFBFBD>mo` ID<EFBFBD>None <EFBFBD><EFBFBD>VhQϑ<EFBFBD>mo` <EFBFBD>
anchor_id: <EFBFBD><EFBFBD>p<EFBFBD>mo` ID<EFBFBD><EFBFBD><EFBFBD>>\ <EFBFBD>
pre_boundary: Xd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>LuMR<EFBFBD>OYu<EFBFBD>v<EFBFBD>mo`peϑ<EFBFBD>؞<EFBFBD><EFBFBD> 20 <EFBFBD>
ԏ<EFBFBD>V<EFBFBD>
<EFBFBD><EFBFBD>jRT<EFBFBD>v g<EFBFBD>^<EFBFBD>mo`Rh<EFBFBD>
:y<EFBFBD>O<EFBFBD>
100 ag<EFBFBD>mo` <EFBFBD>Xd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Lu(W,{ 50 ag <EFBFBD>pre_boundary=20
<EFBFBD>! ԏ<EFBFBD>V<EFBFBD>mo` 29-99<EFBFBD>qQ 71 ag <EFBFBD>
"""
ordered = build_ordered_messages(messages_map, anchor_id)
if boundary_message_id:
try:
# <00>g~bXd<58><64><EFBFBD><EFBFBD>Lu<4C>mo`<60>v"}_
boundary_idx = next(
idx for idx, msg in enumerate(ordered) if msg.get("id") == boundary_message_id
)
# <00><><EFBFBD>{<7B><>jRw<52><77>p
start_idx = max(boundary_idx - pre_boundary, 0)
ordered = ordered[start_idx:]
except StopIteration:
# <00><>Lu<4C>mo` NX[(W <EFBFBD>ԏ<EFBFBD>VhQϑ
pass
return ordered
def summarize(messages: List[Dict], old_summary: Optional[str] = None) -> str:
"""
ub<EFBFBD>[݋Xd<EFBFBD><EFBFBD><EFBFBD>`SMO<EFBFBD>c<EFBFBD>S <EFBFBD>
<EFBFBD>Spe<EFBFBD>
messages: <EFBFBD><EFBFBD><EFBFBD>Xd<EFBFBD><EFBFBD><EFBFBD>v<EFBFBD>mo`Rh<EFBFBD>
old_summary: <EFBFBD>eXd<EFBFBD><EFBFBD><EFBFBD><EFBFBD>S <EFBFBD> <EFBFBD>S_MR*gO(u <EFBFBD>
ԏ<EFBFBD>V<EFBFBD>
Xd<EFBFBD><EFBFBD>W[&{2N
TODO<EFBFBD>
- <EFBFBD>[<EFBFBD>s<EFBFBD>XϑXd<EFBFBD><EFBFBD>;<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>W<EFBFBD>N old_summary ub<EFBFBD>eXd<EFBFBD><EFBFBD> <EFBFBD>
- /ecXd<EFBFBD><EFBFBD>V{euM<EFBFBD>n<EFBFBD><EFBFBD><EFBFBD>^0<EFBFBD><EFBFBD><EFBFBD>~ z<EFBFBD>^ <EFBFBD>
"""
summarizer = HistorySummarizer()
result = summarizer.summarize(messages, existing_summary=old_summary)
return result.summary if result else ""
def compute_token_count(messages: List[Dict]) -> int:
"""
<EFBFBD><EFBFBD><EFBFBD>{<EFBFBD>mo`<EFBFBD>v token peϑ<EFBFBD>`SMO<EFBFBD>[<EFBFBD>s <EFBFBD>
S_MR<EFBFBD>{<EFBFBD>l<EFBFBD>4 W[&{ H" 1 token<08><>|eu0O<30>{ <09>
TODO<EFBFBD><EFBFBD>ceQw<EFBFBD>[ tokenizer<EFBFBD><EFBFBD>Y tiktoken for OpenAI models <EFBFBD>
"""
total_chars = 0
for msg in messages:
content = msg.get('content')
if isinstance(content, str):
total_chars += len(content)
elif isinstance(content, list):
for item in content:
if isinstance(item, dict) and 'text' in item:
total_chars += len(item['text'])
return max(total_chars // 4, 0)