confluence_web/backend/history.py
Artur Mukhamadiev e65fbf4b67 backend: FastAPI backend track handoff (contract revision 1)
FastAPI app, upstream Confluence/model adapters, authoritative history,
rootless container lifecycle, artifact storage and downloads, fake peers
under backend/dev, tests under tests/backend. Root pytest.ini deselects
the live marker by default; requirements gain the backend dependencies.
2026-09-14 21:57:54 +03:00

209 lines
9.0 KiB
Python

"""Authoritative in-memory access history, page auditing, and warnings."""
from __future__ import annotations
import datetime
import json
import urllib.parse
from backend.errors import sanitize_message
from typing import Any, Dict, List, Optional
MAX_HISTORY_ENTRIES = 100
MAX_HISTORY_BYTES = 128 * 1024 * 1024 # 128 MiB
RESERVED_METADATA_BYTES_PER_ENTRY = 64 * 1024 # 64 KiB
MAX_PAGE_SUMMARIES_BYTES = 16 * 1024 * 1024 # 16 MiB
MAX_WARNINGS = 100
def rfc3339_utc(dt: Optional[datetime.datetime] = None) -> str:
"""Format a datetime as RFC 3339 UTC string with Z suffix."""
if dt is None:
dt = datetime.datetime.now(datetime.timezone.utc)
elif dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
dt = dt.astimezone(datetime.timezone.utc)
return dt.isoformat(timespec="microseconds").replace("+00:00", "Z")
class WarningsManager:
"""Bounded, aggregated warning collector."""
def __init__(self, max_warnings: int = MAX_WARNINGS):
self.max_warnings = max_warnings
self._warnings: List[Dict[str, Any]] = []
self._seen_keys: Dict[str, int] = {}
def add_warning(
self,
code: str,
message: str,
tool_call_id: Optional[str] = None,
name: Optional[str] = None,
) -> None:
clean_code = sanitize_message(str(code), 64)
message = sanitize_message(str(message))
name = sanitize_message(str(name), 256) if name else None
tool_call_id = str(tool_call_id)[:128] if tool_call_id else None
key = json.dumps([clean_code, message, tool_call_id, name])
if key in self._seen_keys:
# Aggregate repeat counts for duplicate warnings
idx = self._seen_keys[key]
w = self._warnings[idx]
w["count"] = w.get("count", 1) + 1
return
if len(self._warnings) >= self.max_warnings:
return # bound to max_warnings
self._seen_keys[key] = len(self._warnings)
item: Dict[str, Any] = {
"code": clean_code,
"message": message[:1024],
}
if tool_call_id:
item["tool_call_id"] = tool_call_id
if name:
item["name"] = name[:256]
self._warnings.append(item)
def get_warnings(self) -> List[Dict[str, Any]]:
return list(self._warnings)
class HistoryManager:
"""Authoritative in-memory tool execution history and page auditing."""
def __init__(self, warnings_manager: Optional[WarningsManager] = None):
self.warnings = warnings_manager or WarningsManager()
self.entries: List[Dict[str, Any]] = []
self._current_size_bytes = 2
self._pages: Dict[str, Dict[str, Any]] = {}
def record_call(
self,
tool_call_id: str,
tool: str,
parameters: Dict[str, Any],
started_at: str,
completed_at: str,
status: str, # "success" | "error"
cache_hit: bool,
result: Optional[Dict[str, Any]],
error: Optional[Dict[str, Any]],
) -> None:
"""Record an authoritative tool dispatch entry."""
if len(self.entries) >= MAX_HISTORY_ENTRIES:
self.warnings.add_warning("history_overflow", "Maximum tool history entries (100) reached")
return
if status == "success" and tool == "confluence_view" and isinstance(result, dict):
page_id = str(result.get("page_id", ""))[:128]
if page_id and page_id not in self._pages:
url = str(result.get("url", ""))
try:
parsed = urllib.parse.urlsplit(url)
usable = parsed.scheme in ("http", "https") and parsed.hostname and not parsed.username
_ = parsed.port
except ValueError:
usable = False
# Never shorten a URL into a different clickable destination.
if not usable or len(json.dumps(url).encode()) > 32 * 1024:
url = ""
self.warnings.add_warning("unusable_page_url", "Page URL is malformed or too large", tool_call_id=tool_call_id)
self._pages[page_id] = {
"page_id": page_id,
"title": str(result.get("title", ""))[:8192],
"space": str(result.get("space", ""))[:256],
"url": url,
"accessed_at": completed_at,
}
if len(str(result.get("title", ""))) > 8192:
self.warnings.add_warning("page_summaries_truncated", "Page summary display text shortened")
def size(value):
return len(json.dumps(value).encode("utf-8"))
def snapshot(value, budget):
if value is None:
return None, False
if size(value) <= budget:
return json.loads(json.dumps(value)), False
if isinstance(value, dict):
reduced = {}
for key, val in value.items():
candidate = {**reduced, key: val}
if size(candidate) <= budget:
reduced = candidate
elif isinstance(val, str):
low, high = 0, len(val)
while low < high:
mid = (low + high + 1) // 2
if size({**reduced, key: val[:mid] + "...[truncated]"}) <= budget:
low = mid
else:
high = mid - 1
if low:
reduced[key] = val[:low] + "...[truncated]"
return json.loads(json.dumps(reduced)), True
return None, True
def result_snapshot(value, budget):
if size(value) <= budget:
return json.loads(json.dumps(value)), False
# Preserve the expected tool result fields and types when shortening a snapshot.
if tool == "confluence_view":
reduced = {k: str(value.get(k, ""))[:256] for k in ("page_id", "title", "space", "url")}
reduced.update(markdown="", truncated=True)
low, high = 0, len(value.get("markdown", ""))
while low < high:
mid = (low + high + 1) // 2
if size({**reduced, "markdown": value["markdown"][:mid]}) <= budget:
low = mid
else:
high = mid - 1
reduced["markdown"] = value.get("markdown", "")[:low]
if reduced["url"] != value.get("url", ""):
reduced["url"] = ""
return reduced, True
array_key = "pages" if tool == "confluence_search" else "spaces" if tool == "confluence_list_spaces" else None
if array_key:
reduced = {array_key: [], "pagination": dict(value.get("pagination", {}))}
for item in value.get(array_key, []):
shortened = {k: str(v)[:256] for k, v in item.items()}
if "url" in shortened and shortened["url"] != item["url"]:
shortened["url"] = ""
if size({**reduced, array_key: reduced[array_key] + [shortened]}) > budget:
break
reduced[array_key].append(shortened)
return reduced, True
return snapshot(value, budget)
stored_parameters, parameters_truncated = snapshot(parameters, 24 * 1024)
stored_error, _ = snapshot(error, 8 * 1024)
entry = {
"tool_call_id": str(tool_call_id)[:128], "tool": str(tool)[:128],
"parameters": stored_parameters, "parameters_truncated": parameters_truncated,
"started_at": str(started_at)[:64], "completed_at": str(completed_at)[:64],
"status": status, "cache_hit": bool(cache_hit),
"result": None, "error": stored_error if status == "error" else None,
"result_truncated": False,
}
remaining_slots = MAX_HISTORY_ENTRIES - len(self.entries) - 1
available = max(0, MAX_HISTORY_BYTES - self._current_size_bytes - remaining_slots * RESERVED_METADATA_BYTES_PER_ENTRY - size(entry) - 2)
if status == "success" and result is not None:
stored_result, truncated = result_snapshot(result, available)
entry["result"] = stored_result
entry["result_truncated"] = truncated or bool(result.get("truncated", False))
if truncated:
self.warnings.add_warning("result_truncated", "Tool result snapshot shortened to fit history budget", tool_call_id=tool_call_id)
self._current_size_bytes += size(entry) + 2
self.entries.append(entry)
def get_tool_history(self) -> List[Dict[str, Any]]:
return list(self.entries)
def get_pages_accessed(self) -> List[Dict[str, Any]]:
"""Page audit is captured before result snapshots are truncated."""
return list(self._pages.values())