Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1cdc60699 | |||
| 2104e9f694 | |||
| a5ea220cdd | |||
| 5d504e0314 | |||
| 1c9405cfdf | |||
| 78259855c8 | |||
| 2cb131bbd2 | |||
| 9e993f420f | |||
| 4ce254193e | |||
| 37a8b95887 | |||
| 62416fcf2e | |||
| cc29ae1ef8 | |||
| 35faeb0fb7 | |||
| 3e50aecb96 | |||
| b2f0b9f9f1 | |||
| 55c42fdfc1 | |||
| ca705701bd |
@@ -24,7 +24,14 @@ BASE_URL=192.168.1.5:8000
|
||||
# If set, uses llama-server as the primary LLM backend with OpenAI as fallback
|
||||
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
|
||||
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
|
||||
# Abandon a turn when an in-flight model call produces no output (prefill included)
|
||||
# for this long. Thinking tokens count as output, so long reasoning is fine.
|
||||
TTFT_TIMEOUT_SECONDS=30
|
||||
# Context estimates include system instructions and tool definitions.
|
||||
# Set the window to the smallest backend context; leave room for generation.
|
||||
CONTEXT_WINDOW_TOKENS=32768
|
||||
PROMPT_BUDGET_TOKENS=16000
|
||||
RESPONSE_RESERVE_TOKENS=4096
|
||||
|
||||
# Embedding Server Configuration
|
||||
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from quart import Blueprint, jsonify, make_response, request
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
@@ -16,9 +17,19 @@ from blueprints.users.decorators import jwt_access_required
|
||||
from metrics import count_result, observe_response, time_response
|
||||
from utils.image_process import image_content_block
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
from utils.file_extract import (
|
||||
FILE_TYPES,
|
||||
FileExtractionError,
|
||||
attachment_block,
|
||||
extract_text,
|
||||
file_extension,
|
||||
)
|
||||
from utils.image_upload import ALLOWED_TYPES as IMAGE_TYPES
|
||||
from utils.image_upload import ImageValidationError, process_image
|
||||
from utils.s3_client import generate_presigned_url as s3_presigned_url
|
||||
from utils.s3_client import get_file as s3_get_file
|
||||
from utils.s3_client import get_image as s3_get_image
|
||||
from utils.s3_client import upload_file as s3_upload_file
|
||||
from utils.s3_client import upload_image as s3_upload_image
|
||||
|
||||
from .logic import (
|
||||
@@ -37,7 +48,35 @@ from .models import (
|
||||
from .tool_events import tool_end_payload, tool_name_from_message, tool_start_payload
|
||||
from .tool_labels import tool_label
|
||||
|
||||
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
|
||||
# Longest an in-flight model call may go without producing any output (prompt
|
||||
# prefill included) before the turn is abandoned as stalled. Thinking tokens and
|
||||
# empty chunks count as output; time spent running tools does not count at all.
|
||||
MODEL_STALL_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
|
||||
def stall_timeout(last_model_activity: float | None, now: float) -> float | None:
|
||||
"""Seconds left before the in-flight model call counts as stalled.
|
||||
|
||||
None when no model call is in flight (e.g. while tools run): the httpx read
|
||||
timeout and the tools' own timeouts cover those phases.
|
||||
"""
|
||||
if last_model_activity is None:
|
||||
return None
|
||||
return max(MODEL_STALL_TIMEOUT - (now - last_model_activity), 1.0)
|
||||
|
||||
|
||||
def _model_call_summary(call: dict) -> dict:
|
||||
"""Per-LLM-call timing and token counts for the stream_request log line."""
|
||||
start = call["start"]
|
||||
first_chunk = call.get("first_chunk")
|
||||
end = call.get("end")
|
||||
return {
|
||||
"prefill_ms": round((first_chunk - start) * 1000) if first_chunk else None,
|
||||
"ms": round((end - start) * 1000) if end else None,
|
||||
"prompt_tokens": call.get("prompt_tokens"),
|
||||
"output_tokens": call.get("output_tokens"),
|
||||
}
|
||||
|
||||
|
||||
_THINK_OPEN = "<think>"
|
||||
_THINK_CLOSE = "</think>"
|
||||
@@ -203,6 +242,87 @@ async def upload_image():
|
||||
return jsonify({"image_key": key})
|
||||
|
||||
|
||||
def _file_key_belongs_to(file_key: str, conversation_id) -> bool:
|
||||
"""Only allow attachments stored under this conversation's prefix."""
|
||||
return (
|
||||
isinstance(file_key, str)
|
||||
and ".." not in file_key
|
||||
and file_key.startswith(f"conversations/{conversation_id}/files/")
|
||||
)
|
||||
|
||||
|
||||
async def _load_attached_file(file_key: str) -> tuple[str, str]:
|
||||
"""Fetch an attached file from S3 and return (original name, extracted text)."""
|
||||
data, metadata = await s3_get_file(file_key)
|
||||
name = unquote(metadata.get("filename", "")) or file_key.rsplit("/", 1)[-1]
|
||||
ext = file_extension(file_key, None) or ""
|
||||
# PyMuPDF is CPU-bound; keep it off the event loop so other streams don't stall.
|
||||
return name, await asyncio.to_thread(extract_text, data, ext)
|
||||
|
||||
|
||||
@conversation_blueprint.post("/upload")
|
||||
@jwt_access_required
|
||||
async def upload_attachment():
|
||||
current_user_uuid = get_jwt_identity()
|
||||
files = await request.files
|
||||
form = await request.form
|
||||
file = files.get("file")
|
||||
conversation_id = form.get("conversation_id")
|
||||
|
||||
if not file or not conversation_id:
|
||||
return jsonify({"error": "file and conversation_id are required"}), 400
|
||||
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
file_bytes = file.read()
|
||||
content_type = (file.content_type or "").split(";")[0].strip().lower()
|
||||
name = os.path.basename(file.filename or "") or "attachment"
|
||||
|
||||
if content_type in IMAGE_TYPES:
|
||||
try:
|
||||
processed_bytes, output_content_type = process_image(
|
||||
file_bytes, content_type
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Could not process image: {e}"}), 400
|
||||
ext = output_content_type.split("/")[-1]
|
||||
if ext == "jpeg":
|
||||
ext = "jpg"
|
||||
key = f"conversations/{conversation.id}/{uuid.uuid4()}.{ext}"
|
||||
await s3_upload_image(processed_bytes, key, output_content_type)
|
||||
return jsonify({"kind": "image", "image_key": key})
|
||||
|
||||
ext = file_extension(name, content_type)
|
||||
if ext is None:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": f"Unsupported file type: {content_type or name}. "
|
||||
"Allowed: images, PDF, TXT, MD, CSV"
|
||||
}
|
||||
),
|
||||
415,
|
||||
)
|
||||
|
||||
try:
|
||||
text = await asyncio.to_thread(extract_text, file_bytes, ext)
|
||||
except FileExtractionError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if not text.strip():
|
||||
message = (
|
||||
"This PDF has no text layer (scanned?), so Simba can't read it yet."
|
||||
if ext == "pdf"
|
||||
else "This file is empty, so there's nothing for Simba to read."
|
||||
)
|
||||
return jsonify({"error": message}), 422
|
||||
|
||||
key = f"conversations/{conversation.id}/files/{uuid.uuid4()}.{ext}"
|
||||
await s3_upload_file(file_bytes, key, FILE_TYPES[ext], {"filename": quote(name)})
|
||||
return jsonify({"kind": "file", "file_key": key, "name": name, "chars": len(text)})
|
||||
|
||||
|
||||
@conversation_blueprint.get("/image/<path:image_key>")
|
||||
@jwt_access_required
|
||||
async def serve_image(image_key: str):
|
||||
@@ -219,12 +339,30 @@ async def stream_query():
|
||||
query_text = data.get("query")
|
||||
conversation_id = data.get("conversation_id")
|
||||
image_key = data.get("image_key")
|
||||
file_key = data.get("file_key")
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
# An attached file is extracted and appended to the agent's user turn; the
|
||||
# saved message only records its name.
|
||||
saved_text = query_text or ""
|
||||
if file_key:
|
||||
if not _file_key_belongs_to(file_key, conversation.id):
|
||||
return jsonify({"error": "invalid file_key"}), 400
|
||||
try:
|
||||
file_name, file_text = await _load_attached_file(file_key)
|
||||
block = attachment_block(file_name, file_text)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load file {file_key}: {e}")
|
||||
file_name = file_key.rsplit("/", 1)[-1]
|
||||
block = "[A file was attached but could not be loaded.]"
|
||||
query_text = f"{query_text or 'The user attached this file.'}\n\n{block}"
|
||||
saved_text = f"{saved_text}\n\n📎 {file_name}".strip()
|
||||
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=query_text or "",
|
||||
message=saved_text,
|
||||
speaker="user",
|
||||
user=user,
|
||||
image_key=image_key,
|
||||
@@ -271,6 +409,9 @@ async def stream_query():
|
||||
first_content_at: float | None = None
|
||||
tool_timers: dict[str, float] = {} # run_id -> start time
|
||||
tool_spans: list[dict] = []
|
||||
# Last output from the in-flight model call; None between calls.
|
||||
last_model_activity: float | None = None
|
||||
model_calls: dict[str, dict] = {} # run_id -> timing / token usage
|
||||
n_tokens = 0
|
||||
model_name: str | None = None
|
||||
cancelled = False
|
||||
@@ -293,6 +434,8 @@ async def stream_query():
|
||||
ttft_ms = (
|
||||
round((first_content_at - t0) * 1000) if first_content_at else None
|
||||
)
|
||||
calls = [_model_call_summary(c) for c in model_calls.values()]
|
||||
prompt_tokens = [c["prompt_tokens"] for c in calls if c["prompt_tokens"]]
|
||||
logging.info(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -304,6 +447,8 @@ async def stream_query():
|
||||
"ttft_ms": ttft_ms,
|
||||
"tokens": n_tokens,
|
||||
"tools": tool_spans,
|
||||
"model_calls": calls,
|
||||
"max_prompt_tokens": max(prompt_tokens, default=None),
|
||||
"model": model_name,
|
||||
"cancelled": cancelled,
|
||||
**({"error": error} if error else {}),
|
||||
@@ -316,15 +461,10 @@ async def stream_query():
|
||||
payload, version="v2", config=agent_config
|
||||
).__aiter__()
|
||||
while True:
|
||||
# Apply a short TTFT timeout only during the window between
|
||||
# on_chat_model_start and the first content token. Before the
|
||||
# LLM call starts (tool phase) and after the first token, let
|
||||
# the httpx read timeout handle stalls instead.
|
||||
if llm_start_at is not None and first_content_at is None:
|
||||
ttft_wait = TTFT_TIMEOUT - (time.monotonic() - llm_start_at)
|
||||
timeout = max(ttft_wait, 1.0)
|
||||
else:
|
||||
timeout = None
|
||||
# Only a silent model call counts as a stall. A model that is
|
||||
# thinking (or streaming empty chunks) keeps resetting the clock,
|
||||
# and each new call starts a fresh one.
|
||||
timeout = stall_timeout(last_model_activity, time.monotonic())
|
||||
|
||||
try:
|
||||
if timeout is not None:
|
||||
@@ -336,7 +476,9 @@ async def stream_query():
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
_log_summary(error=f"TTFT timeout after {TTFT_TIMEOUT:.0f}s")
|
||||
_log_summary(
|
||||
error=f"model stalled: no output for {MODEL_STALL_TIMEOUT:.0f}s"
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Simba took too long to respond. Please try again.', 'stalled': True})}\n\n"
|
||||
return
|
||||
|
||||
@@ -388,14 +530,34 @@ async def stream_query():
|
||||
|
||||
elif event_type == "on_chat_model_start":
|
||||
turn_has_content = False
|
||||
now = time.monotonic()
|
||||
last_model_activity = now
|
||||
model_calls[event["run_id"]] = {"start": now}
|
||||
if llm_start_at is None:
|
||||
llm_start_at = time.monotonic()
|
||||
llm_start_at = now
|
||||
if model_name is None:
|
||||
model_name = event.get("metadata", {}).get(
|
||||
"ls_model_name"
|
||||
) or event.get("name")
|
||||
|
||||
elif event_type == "on_chat_model_end":
|
||||
last_model_activity = None
|
||||
call = model_calls.get(event["run_id"])
|
||||
if call is not None:
|
||||
call["end"] = time.monotonic()
|
||||
usage = getattr(
|
||||
event.get("data", {}).get("output"), "usage_metadata", None
|
||||
)
|
||||
if usage:
|
||||
call["prompt_tokens"] = usage.get("input_tokens")
|
||||
call["output_tokens"] = usage.get("output_tokens")
|
||||
|
||||
elif event_type == "on_chat_model_stream":
|
||||
now = time.monotonic()
|
||||
last_model_activity = now
|
||||
call = model_calls.get(event.get("run_id"))
|
||||
if call is not None and "first_chunk" not in call:
|
||||
call["first_chunk"] = now
|
||||
chunk = event.get("data", {}).get("chunk")
|
||||
raw = getattr(chunk, "content", None)
|
||||
if isinstance(raw, str) and raw:
|
||||
@@ -457,7 +619,7 @@ async def stream_query():
|
||||
_log_summary()
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return await make_response(
|
||||
response = await make_response(
|
||||
event_generator(),
|
||||
200,
|
||||
{
|
||||
@@ -466,6 +628,10 @@ async def stream_query():
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
# Quart's default 60s response deadline cancels even active SSE streams.
|
||||
# Silent model calls are bounded separately by MODEL_STALL_TIMEOUT.
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
|
||||
@conversation_blueprint.route("/<conversation_id>")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
@@ -8,6 +9,7 @@ from langchain.tools import tool
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig, RunnableLambda
|
||||
from langchain_core.utils.function_calling import convert_to_openai_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from tavily import AsyncTavilyClient
|
||||
|
||||
@@ -24,6 +26,7 @@ from blueprints.conversation.artifacts_util import (
|
||||
truncate,
|
||||
unique,
|
||||
)
|
||||
from blueprints.conversation.context import RESPONSE_RESERVE_TOKENS, build_context
|
||||
from blueprints.conversation.memory import save_memory
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from blueprints.rag.logic import query_vector_store
|
||||
@@ -54,11 +57,14 @@ if llama_url:
|
||||
timeout=httpx.Timeout(LLAMA_READ_TIMEOUT, connect=10.0),
|
||||
max_retries=1,
|
||||
model_kwargs={"reasoning_effort": "low"},
|
||||
# Ask llama-server for token usage on the final chunk (prompt size logging).
|
||||
stream_usage=True,
|
||||
max_tokens=RESPONSE_RESERVE_TOKENS,
|
||||
)
|
||||
else:
|
||||
llama_chat = None
|
||||
|
||||
openai_fallback = ChatOpenAI(model="gpt-5-mini")
|
||||
openai_fallback = ChatOpenAI(model="gpt-5-mini", max_tokens=RESPONSE_RESERVE_TOKENS)
|
||||
model_with_fallback = cast(
|
||||
BaseChatModel,
|
||||
llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback,
|
||||
@@ -465,14 +471,22 @@ async def obsidian_search_notes(query: str):
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_read_note(relative_path: str):
|
||||
"""Read the full content of a specific Obsidian note by path.
|
||||
async def obsidian_read_note(
|
||||
relative_path: str, start_line: int = 1, max_lines: int | None = None
|
||||
):
|
||||
"""Read an Obsidian note, optionally selecting a small range of content lines.
|
||||
|
||||
Use line ranges to retrieve omitted sections of a large note.
|
||||
|
||||
Args:
|
||||
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
|
||||
start_line: First content line to read, starting at 1 (excludes frontmatter).
|
||||
max_lines: Number of lines to read, or None for all remaining lines.
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
if start_line < 1 or (max_lines is not None and max_lines < 1):
|
||||
return "start_line and max_lines must be positive integers.", None
|
||||
|
||||
try:
|
||||
note = obsidian_service.read_note(relative_path)
|
||||
@@ -480,7 +494,25 @@ async def obsidian_read_note(relative_path: str):
|
||||
|
||||
result = f"File: {note['path']}\n\n"
|
||||
result += f"Frontmatter:\n{content_data['metadata']}\n\n"
|
||||
result += f"Content:\n{content_data['content']}\n\n"
|
||||
content = content_data["content"]
|
||||
if start_line != 1 or max_lines is not None:
|
||||
lines = content.splitlines()
|
||||
if start_line > len(lines):
|
||||
return (
|
||||
f"File: {note['path']} has {len(lines)} content lines; start_line is beyond the end.",
|
||||
None,
|
||||
)
|
||||
end_line = (
|
||||
min(len(lines), start_line - 1 + max_lines)
|
||||
if max_lines is not None
|
||||
else len(lines)
|
||||
)
|
||||
content = "\n".join(lines[start_line - 1 : end_line])
|
||||
result += (
|
||||
f"Content lines {start_line}-{end_line} of {len(lines)}:\n{content}\n\n"
|
||||
)
|
||||
else:
|
||||
result += f"Content:\n{content}\n\n"
|
||||
result += f"Tags: {', '.join(content_data['tags'])}\n"
|
||||
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
|
||||
|
||||
@@ -1097,8 +1129,7 @@ async def beli_search_restaurants(
|
||||
tools.append(beli_search_restaurants)
|
||||
|
||||
|
||||
@RunnableLambda
|
||||
def _build_prompt(state, config: RunnableConfig = None) -> list:
|
||||
def _build_prompt(state, config: RunnableConfig = None, *, tool_schemas=()) -> list:
|
||||
"""Inject the per-request system prompt, replacing any prior system messages."""
|
||||
system_prompt = (
|
||||
(config or {}).get("configurable", {}).get("system_prompt", SIMBA_SYSTEM_PROMPT)
|
||||
@@ -1106,10 +1137,7 @@ def _build_prompt(state, config: RunnableConfig = None) -> list:
|
||||
# LangGraph passes the full state dict {"messages": [...]}, not just the list
|
||||
messages = state["messages"] if isinstance(state, dict) else state
|
||||
non_system = [m for m in messages if not isinstance(m, SystemMessage)]
|
||||
# Keep only the most recent messages to stay within the 32k context window.
|
||||
# Tool call/result pairs each count as 2 messages, so 12 ≈ 4-6 conversational turns.
|
||||
non_system = non_system[-12:]
|
||||
return [SystemMessage(content=system_prompt)] + non_system
|
||||
return build_context(non_system, system_prompt, list(tool_schemas))
|
||||
|
||||
|
||||
def build_agent(checkpointer=None, extra_tools=()):
|
||||
@@ -1118,9 +1146,11 @@ def build_agent(checkpointer=None, extra_tools=()):
|
||||
``extra_tools`` are appended to the shared tool list, e.g. admin-only
|
||||
Homebox and BookStack tools for the admin agent.
|
||||
"""
|
||||
agent_tools = tools + list(extra_tools)
|
||||
tool_schemas = [convert_to_openai_tool(t) for t in agent_tools]
|
||||
return create_react_agent(
|
||||
model=model_with_fallback,
|
||||
tools=tools + list(extra_tools),
|
||||
prompt=_build_prompt,
|
||||
tools=agent_tools,
|
||||
prompt=RunnableLambda(partial(_build_prompt, tool_schemas=tool_schemas)),
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Budget the model's view of history without changing checkpointed messages.
|
||||
|
||||
Token estimates use UTF-8 bytes / 3 plus message overhead. They are deliberately
|
||||
more conservative than the usual chars / 4 heuristic, but are not a model
|
||||
specific tokenizer. A safety reserve covers estimation and chat-template costs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CONTEXT_WINDOW_TOKENS = int(os.getenv("CONTEXT_WINDOW_TOKENS", "32768"))
|
||||
PROMPT_BUDGET_TOKENS = int(os.getenv("PROMPT_BUDGET_TOKENS", "16000"))
|
||||
RESPONSE_RESERVE_TOKENS = int(os.getenv("RESPONSE_RESERVE_TOKENS", "4096"))
|
||||
SAFETY_RESERVE_TOKENS = 2048
|
||||
EARLIER_MESSAGE_MAX_CHARS = 4000
|
||||
TOOL_RESULT_MAX_CHARS = 12000
|
||||
TRUNCATION_NOTE = "\n[truncated: earlier context]"
|
||||
IMAGE_PLACEHOLDER = "[image from an earlier message]"
|
||||
|
||||
|
||||
def estimate_tokens(value) -> int:
|
||||
text = (
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else json.dumps(value, ensure_ascii=False, default=str)
|
||||
)
|
||||
return math.ceil(len(text.encode("utf-8")) / 3)
|
||||
|
||||
|
||||
def message_tokens(message: BaseMessage) -> int:
|
||||
# Count tool arguments but never artifact payloads (not sent to the model).
|
||||
content = message.content
|
||||
images = 0
|
||||
if isinstance(content, list):
|
||||
images = sum(
|
||||
isinstance(p, dict) and p.get("type") in ("image", "image_url")
|
||||
for p in content
|
||||
)
|
||||
content = [
|
||||
p
|
||||
for p in content
|
||||
if not (isinstance(p, dict) and p.get("type") in ("image", "image_url"))
|
||||
]
|
||||
return (
|
||||
16
|
||||
+ estimate_tokens(content)
|
||||
+ images * 4096
|
||||
+ estimate_tokens(getattr(message, "tool_calls", []))
|
||||
)
|
||||
|
||||
|
||||
def _cap(text: str, limit: int, note: str) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
# Retain both ends: identifiers and totals often appear at the end of results.
|
||||
head = limit * 3 // 4
|
||||
return text[:head] + note + (text[-(limit - head) :] if limit > head else "")
|
||||
|
||||
|
||||
def _shrink(message: BaseMessage, limit: int, *, earlier: bool) -> BaseMessage:
|
||||
note = (
|
||||
TRUNCATION_NOTE
|
||||
if earlier
|
||||
else "\n[tool result excerpt; omitted content can be fetched again with the source tool]\n"
|
||||
)
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
new_content = _cap(content, limit, note)
|
||||
else:
|
||||
new_content = []
|
||||
remaining = limit
|
||||
for part in content:
|
||||
if (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") in ("image_url", "image")
|
||||
and earlier
|
||||
):
|
||||
new_content.append({"type": "text", "text": IMAGE_PLACEHOLDER})
|
||||
elif isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
text = _cap(part["text"], remaining, note)
|
||||
new_content.append({**part, "text": text})
|
||||
remaining = max(0, remaining - min(len(part["text"]), remaining))
|
||||
elif isinstance(part, str):
|
||||
new_content.append(_cap(part, remaining, note))
|
||||
remaining = max(0, remaining - min(len(part), remaining))
|
||||
else:
|
||||
new_content.append(part)
|
||||
return (
|
||||
message
|
||||
if new_content == content
|
||||
else message.model_copy(update={"content": new_content})
|
||||
)
|
||||
|
||||
|
||||
def _turns(messages: list[BaseMessage]) -> list[list[BaseMessage]]:
|
||||
turns = []
|
||||
for message in messages:
|
||||
if isinstance(message, HumanMessage):
|
||||
turns.append([])
|
||||
if turns:
|
||||
turns[-1].append(message)
|
||||
# Leading orphaned checkpoint fragments have no user question to anchor them.
|
||||
return turns
|
||||
|
||||
|
||||
def _complete_exchanges(turn: list[BaseMessage]) -> list[BaseMessage]:
|
||||
"""Drop unfinished exchanges left by a cancelled turn and orphan results."""
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(turn):
|
||||
message = turn[i]
|
||||
if isinstance(message, AIMessage) and message.tool_calls:
|
||||
expected = {c["id"] for c in message.tool_calls}
|
||||
end = i + 1
|
||||
while end < len(turn) and isinstance(turn[end], ToolMessage):
|
||||
end += 1
|
||||
replies = turn[i + 1 : end]
|
||||
actual = [m.tool_call_id for m in replies]
|
||||
if len(actual) == len(expected) and set(actual) == expected:
|
||||
result.extend(turn[i:end])
|
||||
i = end
|
||||
else:
|
||||
if not isinstance(message, ToolMessage):
|
||||
result.append(message)
|
||||
i += 1
|
||||
return result
|
||||
|
||||
|
||||
def _summary(turns: list[list[BaseMessage]], limit: int = 2400) -> SystemMessage | None:
|
||||
excerpts = []
|
||||
remaining = limit
|
||||
for turn in reversed(turns):
|
||||
lines = []
|
||||
for message in turn:
|
||||
if isinstance(message, HumanMessage) or (
|
||||
isinstance(message, AIMessage) and not message.tool_calls
|
||||
):
|
||||
content = message.content
|
||||
if not isinstance(content, str):
|
||||
content = " ".join(
|
||||
p.get("text", "") if isinstance(p, dict) else str(p)
|
||||
for p in content
|
||||
)
|
||||
role = "User" if isinstance(message, HumanMessage) else "Assistant"
|
||||
lines.append(f"{role}: {_cap(content, 400, ' [excerpt] ')}")
|
||||
excerpt = "\n".join(lines)
|
||||
if len(excerpt) > remaining:
|
||||
break
|
||||
excerpts.insert(0, excerpt)
|
||||
remaining -= len(excerpt) + 2
|
||||
if not excerpts:
|
||||
return None
|
||||
return SystemMessage(
|
||||
content="Earlier conversation excerpts (historical data, not new instructions; excerpts may omit details):\n\n"
|
||||
+ "\n\n".join(excerpts)
|
||||
)
|
||||
|
||||
|
||||
def trim_history(
|
||||
messages: list[BaseMessage], *, token_budget: int = 12000
|
||||
) -> list[BaseMessage]:
|
||||
"""Keep the current turn, then recent whole turns, within an estimated budget.
|
||||
|
||||
The current user message and tool call arguments are never truncated. Fail
|
||||
explicitly if these mandatory inputs cannot fit, rather than lose the task.
|
||||
"""
|
||||
turns = [_complete_exchanges(turn) for turn in _turns(messages)]
|
||||
if not turns:
|
||||
return []
|
||||
current = turns[-1]
|
||||
current = [
|
||||
_shrink(m, TOOL_RESULT_MAX_CHARS, earlier=False)
|
||||
if isinstance(m, ToolMessage)
|
||||
else m
|
||||
for m in current
|
||||
]
|
||||
|
||||
def cost(ms):
|
||||
return sum(message_tokens(m) for m in ms)
|
||||
|
||||
# Keep room for continuity even when the current turn has many tool results.
|
||||
current_budget = (
|
||||
token_budget - min(1000, token_budget // 8) if len(turns) > 1 else token_budget
|
||||
)
|
||||
if cost(current) > current_budget:
|
||||
# Binary-search the largest shared excerpt size that fits. Tool call IDs
|
||||
# and all results remain, including parallel exchanges.
|
||||
low, high = 0, TOOL_RESULT_MAX_CHARS
|
||||
|
||||
def compact(limit):
|
||||
return [
|
||||
_shrink(m, limit, earlier=False)
|
||||
if isinstance(m, (ToolMessage, AIMessage))
|
||||
else m
|
||||
for m in current
|
||||
]
|
||||
|
||||
if cost(compact(0)) > token_budget:
|
||||
raise ValueError(
|
||||
"The current question and tool calls exceed the context budget. Send a smaller attachment or start a new conversation."
|
||||
)
|
||||
current_budget = max(current_budget, cost(compact(0)))
|
||||
while low < high:
|
||||
mid = (low + high + 1) // 2
|
||||
if cost(compact(mid)) <= current_budget:
|
||||
low = mid
|
||||
else:
|
||||
high = mid - 1
|
||||
current = compact(low)
|
||||
selected = []
|
||||
used = cost(current)
|
||||
prior = turns[:-1]
|
||||
# Leave a small share for excerpts of older turns only when necessary.
|
||||
summary_reserve = min(1000, max(0, token_budget - used))
|
||||
for turn in reversed(prior):
|
||||
shrunk = [_shrink(m, EARLIER_MESSAGE_MAX_CHARS, earlier=True) for m in turn]
|
||||
turn_cost = cost(shrunk)
|
||||
if used + turn_cost > token_budget - summary_reserve:
|
||||
break
|
||||
selected.insert(0, shrunk)
|
||||
used += turn_cost
|
||||
dropped = prior[: len(prior) - len(selected)]
|
||||
summary = _summary(dropped)
|
||||
result = [m for turn in selected for m in turn] + current
|
||||
if summary and used + message_tokens(summary) <= token_budget:
|
||||
result.insert(0, summary)
|
||||
logging.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "context_budget",
|
||||
"budget_tokens": token_budget,
|
||||
"estimated_tokens": cost(result),
|
||||
"input_messages": len(messages),
|
||||
"output_messages": len(result),
|
||||
"dropped_turns": len(dropped),
|
||||
"summary_included": bool(
|
||||
result and isinstance(result[0], SystemMessage)
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_context(
|
||||
messages: list[BaseMessage], system_prompt: str, tool_schemas: list[dict]
|
||||
) -> list[BaseMessage]:
|
||||
limit = min(
|
||||
PROMPT_BUDGET_TOKENS,
|
||||
CONTEXT_WINDOW_TOKENS - RESPONSE_RESERVE_TOKENS - SAFETY_RESERVE_TOKENS,
|
||||
)
|
||||
system = SystemMessage(content=system_prompt)
|
||||
budget = limit - message_tokens(system) - estimate_tokens(tool_schemas)
|
||||
if budget <= 0:
|
||||
raise ValueError(
|
||||
"System instructions and tool definitions exceed the configured prompt budget."
|
||||
)
|
||||
history = trim_history(messages, token_budget=budget)
|
||||
logging.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "prompt_budget",
|
||||
"limit_tokens": limit,
|
||||
"system_tokens": message_tokens(system),
|
||||
"tool_schema_tokens": estimate_tokens(tool_schemas),
|
||||
"estimated_prompt_tokens": message_tokens(system)
|
||||
+ estimate_tokens(tool_schemas)
|
||||
+ sum(message_tokens(m) for m in history),
|
||||
"response_reserve_tokens": RESPONSE_RESERVE_TOKENS,
|
||||
}
|
||||
)
|
||||
)
|
||||
return [system] + history
|
||||
@@ -1,5 +1,8 @@
|
||||
from .models import UserMemory
|
||||
from .prompts import SIMBA_SYSTEM_PROMPT
|
||||
from .context import estimate_tokens
|
||||
|
||||
MEMORY_BUDGET_TOKENS = 1000
|
||||
|
||||
|
||||
async def get_memories_for_user(user_id: str) -> list[str]:
|
||||
@@ -12,7 +15,16 @@ async def build_system_prompt_with_memories(user_id: str) -> str:
|
||||
memories = await get_memories_for_user(user_id)
|
||||
if not memories:
|
||||
return SIMBA_SYSTEM_PROMPT
|
||||
block = "\n".join(f"- {m}" for m in memories)
|
||||
selected = []
|
||||
used = 0
|
||||
for memory in memories:
|
||||
cost = estimate_tokens(f"- {memory}\n")
|
||||
if used + cost <= MEMORY_BUDGET_TOKENS:
|
||||
selected.append(memory)
|
||||
used += cost
|
||||
block = "\n".join(f"- {m}" for m in selected)
|
||||
if len(selected) < len(memories):
|
||||
block += "\n[Additional saved memories omitted from this prompt.]"
|
||||
return f"{SIMBA_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{block}"
|
||||
|
||||
|
||||
|
||||
+37
-11
@@ -110,8 +110,8 @@ WITH lines AS (
|
||||
)
|
||||
"""
|
||||
|
||||
# Spending = outflows (net of refunds) on budget accounts, excluding transfers
|
||||
# (credit card payments, moves to savings) and income.
|
||||
# Report cash flows on budget accounts, excluding transfers and internal
|
||||
# categories. Separate outflows from inflows rather than calling their net spending.
|
||||
SPENDING_FILTER = """
|
||||
a.on_budget AND l.transfer_account_id IS NULL
|
||||
AND NOT COALESCE(c.internal, false)
|
||||
@@ -415,7 +415,7 @@ async def ynab_spending_report(
|
||||
payee: str = "",
|
||||
top: int = 15,
|
||||
):
|
||||
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
|
||||
"""Gross YNAB outflows grouped by category, category_group, payee, account, or month, with inflows reported separately. Use group_by='month' for trends over time.
|
||||
|
||||
Args:
|
||||
group_by: category, category_group, payee, account, or month
|
||||
@@ -436,6 +436,9 @@ async def ynab_spending_report(
|
||||
except ValueError:
|
||||
return "Dates must be YYYY-MM-DD."
|
||||
|
||||
if start > end:
|
||||
return "start_date must be on or before end_date."
|
||||
|
||||
conditions, params = [SPENDING_FILTER, "l.date >= $1", "l.date <= $2"], [start, end]
|
||||
if category:
|
||||
params.append(f"%{category}%")
|
||||
@@ -447,7 +450,7 @@ async def ynab_spending_report(
|
||||
order = "key" if group_by == "month" else "spent DESC"
|
||||
rows = await _query(
|
||||
f"""{LINES_CTE}
|
||||
SELECT {key} AS key, -SUM(l.amount) AS spent, count(*) AS n
|
||||
SELECT {key} AS key, -SUM(LEAST(l.amount, 0)) AS spent, count(*) AS n
|
||||
FROM lines l
|
||||
JOIN ynab_accounts a ON a.id = l.account_id
|
||||
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
||||
@@ -456,19 +459,38 @@ async def ynab_spending_report(
|
||||
GROUP BY 1 ORDER BY {order}""",
|
||||
*params,
|
||||
)
|
||||
if not rows:
|
||||
return f"No spending found from {start} to {end}."
|
||||
rows = [r for r in rows if r["spent"] > 0]
|
||||
|
||||
total = sum((r["spent"] for r in rows), Decimal(0))
|
||||
totals = await _query(
|
||||
f"""{LINES_CTE}
|
||||
SELECT COALESCE(-SUM(LEAST(l.amount, 0)), 0) AS outflows,
|
||||
COALESCE(SUM(GREATEST(l.amount, 0)), 0) AS inflows
|
||||
FROM lines l
|
||||
JOIN ynab_accounts a ON a.id = l.account_id
|
||||
LEFT JOIN ynab_categories c ON c.id = l.category_id
|
||||
WHERE {" AND ".join(conditions)}""",
|
||||
*params,
|
||||
)
|
||||
outflows, inflows = totals[0]["outflows"], totals[0]["inflows"]
|
||||
if not outflows and not inflows:
|
||||
return f"No spending found from {start} to {end}."
|
||||
top = max(1, top)
|
||||
shown = rows if group_by == "month" else rows[:top]
|
||||
lines = [f"Spending by {group_by} from {start} to {end}: total {_usd(total)}"]
|
||||
lines = [
|
||||
f"Spending by {group_by} from {start} to {end}:",
|
||||
f"- Total spending (outflows): {_usd(outflows)}",
|
||||
f"- Inflows (deposits into budget accounts, not spending): {_usd(inflows)}",
|
||||
"- Group amounts are gross outflows; refunds and other inflows are reported separately.",
|
||||
]
|
||||
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
|
||||
if len(rows) > len(shown):
|
||||
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
|
||||
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
|
||||
if group_by == "month" and len(rows) > 1:
|
||||
lines.append(f"Monthly average: {_usd(total / len(rows))}")
|
||||
months = (end.year - start.year) * 12 + end.month - start.month + 1
|
||||
if group_by == "month":
|
||||
lines.append(
|
||||
f"Monthly average (outflows over {months} calendar months): {_usd(outflows / months)}"
|
||||
)
|
||||
artifact = make_artifact(
|
||||
"ynab.spending_report",
|
||||
f"Spending by {group_by.replace('_', ' ')} · "
|
||||
@@ -482,7 +504,11 @@ async def ynab_spending_report(
|
||||
{"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]}
|
||||
for r in rows
|
||||
],
|
||||
"total": _amount(total),
|
||||
"total": _amount(outflows),
|
||||
"total_basis": "outflows",
|
||||
"inflows": _amount(inflows),
|
||||
"net_outflows": _amount(outflows - inflows),
|
||||
"filters": {"category": category, "payee": payee},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -146,6 +146,8 @@ pub fn run() {
|
||||
.title("Simba")
|
||||
.inner_size(1200.0, 820.0)
|
||||
.min_inner_size(420.0, 520.0)
|
||||
// Let HTML5 drag-and-drop reach the webview (file drop in chat).
|
||||
.disable_drag_drop_handler()
|
||||
.build()?;
|
||||
build_tray(app.handle())?;
|
||||
register_hotkey(app.handle())?;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Conversation context management
|
||||
|
||||
The LangGraph checkpoint retains original messages and artifacts. Each model
|
||||
call constructs a bounded view; compaction never rewrites the checkpoint.
|
||||
|
||||
The prompt budget includes system instructions, saved memories, tool schemas
|
||||
(including admin tools), message content and tool arguments. The default target
|
||||
is 16,000 estimated tokens. A 4,096-token generation limit and 2,048-token safety
|
||||
reserve also constrain the budget against the configured 32,768-token window.
|
||||
|
||||
Estimates use UTF-8 bytes divided by three, message overhead, and a fixed image
|
||||
allowance. These are estimates rather than model-specific tokenizer counts.
|
||||
Compare `prompt_budget.estimated_prompt_tokens` with actual `stream_request`
|
||||
model-call usage and adjust the target/window for the deployed backend.
|
||||
|
||||
The current user request is always retained. Oversized tool results become
|
||||
marked excerpts preserving their beginning and end; all tool IDs and arguments
|
||||
remain. If necessary, intermediate assistant text is also shortened. If the
|
||||
mandatory request and exchange structure cannot fit, the request fails with an
|
||||
explicit error rather than silently dropping the question.
|
||||
|
||||
Recent history is selected as complete turns. Older user messages, assistant
|
||||
answers and tool results are capped; earlier images become placeholders.
|
||||
Incomplete tool exchanges from cancelled calls and orphan results are removed
|
||||
from the prompt. A small budget is reserved for bounded question/answer excerpts
|
||||
from older turns. These excerpts are not an LLM-generated rolling summary and do
|
||||
not guarantee retention of every historical decision. No extra model call is
|
||||
added to the request path. Saved memories use a separate 1,000-token estimated
|
||||
budget, preferring recent entries without deleting omitted memories.
|
||||
|
||||
Obsidian reads accept `start_line` (one-based content line, excluding frontmatter)
|
||||
and `max_lines`, allowing focused follow-up reads. Full artifact content remains
|
||||
available to the interface and checkpoint.
|
||||
|
||||
Configuration:
|
||||
|
||||
- `CONTEXT_WINDOW_TOKENS`: smallest supported context window across backends.
|
||||
- `PROMPT_BUDGET_TOKENS`: prompt target, including fixed instructions and tools.
|
||||
- `RESPONSE_RESERVE_TOKENS`: maximum generated tokens and reserved answer space.
|
||||
|
||||
`context_budget` logs history estimates, message counts, dropped turns and excerpt
|
||||
inclusion. `prompt_budget` logs fixed overhead and the complete prompt estimate.
|
||||
Neither log includes message content.
|
||||
@@ -51,6 +51,12 @@ interface Conversation {
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export type UploadResult =
|
||||
| { kind: "image"; image_key: string }
|
||||
| { kind: "file"; file_key: string; name: string; chars: number };
|
||||
|
||||
export class UploadError extends Error {}
|
||||
|
||||
interface QueryResponse {
|
||||
response: string;
|
||||
}
|
||||
@@ -182,16 +188,16 @@ class ConversationService {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
async uploadAttachment(
|
||||
file: File,
|
||||
conversationId: string,
|
||||
): Promise<{ image_key: string }> {
|
||||
): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("conversation_id", conversationId);
|
||||
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/upload-image`,
|
||||
`${this.conversationBaseUrl}/upload`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
@@ -200,8 +206,8 @@ class ConversationService {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to upload image");
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new UploadError(data.error || "Failed to upload file");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
@@ -224,11 +230,15 @@ class ConversationService {
|
||||
onEvent: SSEEventCallback,
|
||||
signal?: AbortSignal,
|
||||
imageKey?: string,
|
||||
fileKey?: string,
|
||||
): Promise<void> {
|
||||
const body: Record<string, string> = { query, conversation_id };
|
||||
if (imageKey) {
|
||||
body.image_key = imageKey;
|
||||
}
|
||||
if (fileKey) {
|
||||
body.file_key = fileKey;
|
||||
}
|
||||
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/stream-query`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ActivityTimeline } from "./ActivityTimeline";
|
||||
import { ThinkingBubble } from "./ThinkingBubble";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { DropOverlay } from "./DropOverlay";
|
||||
import { SidebarContent } from "./SidebarContent";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
@@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
pendingAttachment,
|
||||
attachFile,
|
||||
clearAttachment,
|
||||
attachmentError,
|
||||
clearAttachmentError,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
error,
|
||||
@@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
[deleteConversation],
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
|
||||
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
@@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
<div className="relative flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
<DropOverlay onDrop={attachFile} />
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
@@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
onToggleSimbaMode={handleToggleSimbaMode}
|
||||
isLoading={isLoading}
|
||||
onStop={stopGeneration}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={handleImageSelect}
|
||||
onClearImage={handleClearImage}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onAttach={attachFile}
|
||||
onClearAttachment={clearAttachment}
|
||||
attachmentError={attachmentError}
|
||||
onDismissAttachmentError={clearAttachmentError}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
onToggleSimbaMode={handleToggleSimbaMode}
|
||||
isLoading={isLoading}
|
||||
onStop={stopGeneration}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={handleImageSelect}
|
||||
onClearImage={handleClearImage}
|
||||
pendingAttachment={pendingAttachment}
|
||||
onAttach={attachFile}
|
||||
onClearAttachment={clearAttachment}
|
||||
attachmentError={attachmentError}
|
||||
onDismissAttachmentError={clearAttachmentError}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
const hasFiles = (e: DragEvent) =>
|
||||
Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
||||
|
||||
type DropOverlayProps = {
|
||||
onDrop: (file: File) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Listens for file drags anywhere in the window and covers its positioned
|
||||
* parent (the chat column, beside the artifact panel) with a "Drop to attach"
|
||||
* target. Below lg the panel is a full-screen sheet, so it covers the window
|
||||
* and sits above the sheet. Drags without files (text, links)
|
||||
* are ignored so they keep their normal behaviour.
|
||||
*/
|
||||
export const DropOverlay = ({ onDrop }: DropOverlayProps) => {
|
||||
const [active, setActive] = useState(false);
|
||||
// dragenter/dragleave fire for every element crossed; count to know when
|
||||
// the drag has really left the window.
|
||||
const depthRef = useRef(0);
|
||||
const onDropRef = useRef(onDrop);
|
||||
onDropRef.current = onDrop;
|
||||
|
||||
useEffect(() => {
|
||||
const handleEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
depthRef.current += 1;
|
||||
setActive(true);
|
||||
};
|
||||
const handleOver = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
// Required for the drop event to fire (and to stop the browser
|
||||
// navigating to the file).
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
const handleLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
depthRef.current = Math.max(0, depthRef.current - 1);
|
||||
if (depthRef.current === 0) setActive(false);
|
||||
};
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
depthRef.current = 0;
|
||||
setActive(false);
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onDropRef.current(file);
|
||||
};
|
||||
|
||||
window.addEventListener("dragenter", handleEnter);
|
||||
window.addEventListener("dragover", handleOver);
|
||||
window.addEventListener("dragleave", handleLeave);
|
||||
window.addEventListener("drop", handleDrop);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleEnter);
|
||||
window.removeEventListener("dragover", handleOver);
|
||||
window.removeEventListener("dragleave", handleLeave);
|
||||
window.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed lg:absolute inset-0 z-[45] flex items-center justify-center bg-cream/80 backdrop-blur-sm p-3 md:p-5"
|
||||
data-testid="drop-overlay"
|
||||
>
|
||||
<div className="w-full h-full rounded-3xl border-2 border-dashed border-amber-glow/50 bg-amber-pale/70 flex flex-col items-center justify-center gap-3 message-enter">
|
||||
<div className="w-14 h-14 rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30 text-amber-glow flex items-center justify-center">
|
||||
<Paperclip size={24} />
|
||||
</div>
|
||||
<p
|
||||
className="text-lg font-bold text-charcoal"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
Drop to attach
|
||||
</p>
|
||||
<p className="text-xs text-warm-gray">Images, PDF, TXT, MD or CSV · up to 10 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
|
||||
import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ATTACHMENT_ACCEPT,
|
||||
attachmentKind,
|
||||
fileExtension,
|
||||
formatBytes,
|
||||
} from "../lib/attachments";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
type MessageInputProps = {
|
||||
@@ -12,9 +18,11 @@ type MessageInputProps = {
|
||||
query: string;
|
||||
isLoading: boolean;
|
||||
onStop: () => void;
|
||||
pendingImage: File | null;
|
||||
onImageSelect: (file: File) => void;
|
||||
onClearImage: () => void;
|
||||
pendingAttachment: File | null;
|
||||
onAttach: (file: File) => void;
|
||||
onClearAttachment: () => void;
|
||||
attachmentError: string | null;
|
||||
onDismissAttachmentError: () => void;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
@@ -28,15 +36,23 @@ export const MessageInput = React.memo(
|
||||
onToggleSimbaMode,
|
||||
isLoading,
|
||||
onStop,
|
||||
pendingImage,
|
||||
onImageSelect,
|
||||
onClearImage,
|
||||
pendingAttachment,
|
||||
onAttach,
|
||||
onClearAttachment,
|
||||
attachmentError,
|
||||
onDismissAttachmentError,
|
||||
autoFocus,
|
||||
}: MessageInputProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Create blob URL once per file, revoke on cleanup
|
||||
const pendingImage =
|
||||
pendingAttachment && attachmentKind(pendingAttachment) === "image"
|
||||
? pendingAttachment
|
||||
: null;
|
||||
const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null;
|
||||
|
||||
// Create blob URL once per image, revoke on cleanup
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||
[pendingImage],
|
||||
@@ -59,7 +75,7 @@ export const MessageInput = React.memo(
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onImageSelect(file);
|
||||
onAttach(file);
|
||||
}
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
@@ -69,19 +85,21 @@ export const MessageInput = React.memo(
|
||||
if (isLoading) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
onImageSelect(file);
|
||||
onAttach(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
|
||||
const canSend = !isLoading && Boolean(query.trim() || pendingAttachment);
|
||||
const FileIcon =
|
||||
pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -102,7 +120,8 @@ export const MessageInput = React.memo(
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearImage}
|
||||
onClick={onClearAttachment}
|
||||
aria-label="Remove attachment"
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -111,6 +130,56 @@ export const MessageInput = React.memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File chip */}
|
||||
{pendingFile && (
|
||||
<div className="px-3 pt-3">
|
||||
<div
|
||||
className="relative inline-flex items-center gap-2.5 max-w-full rounded-xl border border-sand bg-cream pl-2 pr-7 py-2 message-enter"
|
||||
data-testid="attachment-chip"
|
||||
>
|
||||
<div className="w-8 h-8 shrink-0 rounded-lg bg-amber-pale text-amber-dark flex items-center justify-center">
|
||||
<FileIcon size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-charcoal truncate max-w-[14rem] sm:max-w-xs">
|
||||
{pendingFile.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-warm-gray">
|
||||
{fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "}
|
||||
{formatBytes(pendingFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAttachment}
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full text-warm-gray flex items-center justify-center hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachmentError && (
|
||||
<div className="px-3 pt-3">
|
||||
<div
|
||||
role="alert"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600 message-enter"
|
||||
>
|
||||
<span>{attachmentError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissAttachmentError}
|
||||
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
@@ -128,7 +197,7 @@ export const MessageInput = React.memo(
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={ATTACHMENT_ACCEPT}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -150,11 +219,13 @@ export const MessageInput = React.memo(
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Image attach button */}
|
||||
{/* Attach button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
aria-label="Attach a file"
|
||||
title="Attach an image, PDF, TXT, MD or CSV"
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
||||
isLoading
|
||||
@@ -162,7 +233,7 @@ export const MessageInput = React.memo(
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<ImagePlus size={16} />
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { FileSpreadsheet, FileText } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { fileExtension, splitAttachedFile } from "../lib/attachments";
|
||||
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
||||
|
||||
type QuestionBubbleProps = {
|
||||
@@ -9,6 +11,8 @@ type QuestionBubbleProps = {
|
||||
|
||||
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
||||
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
||||
const { body, fileName } = splitAttachedFile(text);
|
||||
const FileIcon = fileName && fileExtension(fileName) === "csv" ? FileSpreadsheet : FileText;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end message-enter">
|
||||
@@ -32,7 +36,21 @@ export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubblePro
|
||||
className="max-w-full rounded-xl mb-2"
|
||||
/>
|
||||
)}
|
||||
{text}
|
||||
{fileName && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 w-fit max-w-full rounded-lg",
|
||||
"bg-warm-white/70 border border-leaf-light/70 px-2 py-1",
|
||||
"text-xs text-leaf-dark",
|
||||
body && "mb-2",
|
||||
)}
|
||||
data-testid="message-attachment"
|
||||
>
|
||||
<FileIcon size={13} className="shrink-0" />
|
||||
<span className="truncate">{fileName}</span>
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,12 +88,15 @@ export const YnabSpendingReport = ({ data }: RendererProps<YnabSpendingReportDat
|
||||
<Subtle>Grouped by {data.group_by?.replace(/_/g, " ")}</Subtle>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">Total</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">{data.total_basis === "outflows" ? "Total outflows" : "Total"}</div>
|
||||
<div className="text-xl font-bold tabular-nums text-charcoal">{formatMoney(data.total)}</div>
|
||||
{data.inflows != null && <Subtle>Inflows: {formatMoney(data.inflows)}</Subtle>}
|
||||
{data.net_outflows != null && <Subtle>Net outflows: {formatMoney(data.net_outflows)}</Subtle>}
|
||||
</div>
|
||||
</div>
|
||||
{data.total_basis === "outflows" && <Subtle>Groups show gross outflows. Refunds and other inflows are reported separately.</Subtle>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyNote>No spending in this period.</EmptyNote>
|
||||
<EmptyNote>No outflows in this period.</EmptyNote>
|
||||
) : data.group_by === "month" ? (
|
||||
<MonthBars rows={rows} />
|
||||
) : (
|
||||
|
||||
@@ -87,9 +87,13 @@ export type YnabSpendingReportData = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
group_by: "category" | "category_group" | "payee" | "account" | "month" | string;
|
||||
/** amount is positive for spending (-SUM); a net refund comes out negative. */
|
||||
/** Gross outflows for new reports; legacy reports contain net spending. */
|
||||
rows: { key: string; amount: number; count?: number }[];
|
||||
total: number;
|
||||
total_basis?: "outflows";
|
||||
inflows?: number;
|
||||
net_outflows?: number;
|
||||
filters?: { category: string; payee: string };
|
||||
};
|
||||
|
||||
export type YnabCategoryBudgetData = {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import {
|
||||
conversationService,
|
||||
UploadError,
|
||||
type ToolInput,
|
||||
} from "../api/conversationService";
|
||||
import type { ArtifactRef } from "../api/artifactTypes";
|
||||
import { validateAttachment, withAttachedFile } from "../lib/attachments";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
export type ToolStatus = "running" | "ok" | "error";
|
||||
@@ -63,7 +65,9 @@ export function useChat({
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
// One image or text-like file to send with the next message.
|
||||
const [pendingAttachment, setPendingAttachment] = useState<File | null>(null);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stalled, setStalled] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
@@ -164,7 +168,7 @@ export function useChat({
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (query: string, simbaMode: boolean) => {
|
||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||
if ((!query.trim() && !pendingAttachment) || isLoading) return;
|
||||
|
||||
let activeId = conversationId;
|
||||
if (!activeId) {
|
||||
@@ -174,7 +178,7 @@ export function useChat({
|
||||
activeId = created.id;
|
||||
}
|
||||
|
||||
const imageFile = pendingImage;
|
||||
const attachment = pendingAttachment;
|
||||
|
||||
setError(null);
|
||||
setStalled(false);
|
||||
@@ -185,7 +189,8 @@ export function useChat({
|
||||
streamingActiveRef.current = false;
|
||||
setStreaming(false);
|
||||
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||
setPendingImage(null);
|
||||
setPendingAttachment(null);
|
||||
setAttachmentError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (simbaMode) {
|
||||
@@ -203,19 +208,27 @@ export function useChat({
|
||||
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
if (imageFile) {
|
||||
const uploadResult = await conversationService.uploadImage(
|
||||
imageFile,
|
||||
let fileKey: string | undefined;
|
||||
if (attachment) {
|
||||
const uploadResult = await conversationService.uploadAttachment(
|
||||
attachment,
|
||||
activeId,
|
||||
);
|
||||
imageKey = uploadResult.image_key;
|
||||
let patch: Partial<TextMessage>;
|
||||
if (uploadResult.kind === "image") {
|
||||
imageKey = uploadResult.image_key;
|
||||
patch = { image_key: imageKey };
|
||||
} else {
|
||||
fileKey = uploadResult.file_key;
|
||||
patch = { text: withAttachedFile(query, uploadResult.name) };
|
||||
}
|
||||
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
const msg = updated[i];
|
||||
if (msg.speaker === "user") {
|
||||
updated[i] = { ...msg, image_key: imageKey };
|
||||
updated[i] = { ...msg, ...patch };
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -342,10 +355,13 @@ export function useChat({
|
||||
},
|
||||
abortController.signal,
|
||||
imageKey,
|
||||
fileKey,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled — not an error to surface.
|
||||
} else if (err instanceof UploadError) {
|
||||
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
|
||||
} else {
|
||||
console.error("Failed to send query:", err);
|
||||
if (
|
||||
@@ -383,7 +399,7 @@ export function useChat({
|
||||
}
|
||||
},
|
||||
[
|
||||
pendingImage,
|
||||
pendingAttachment,
|
||||
isLoading,
|
||||
conversationId,
|
||||
createConversation,
|
||||
@@ -400,6 +416,19 @@ export function useChat({
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const attachFile = useCallback((file: File) => {
|
||||
const problem = validateAttachment(file);
|
||||
setAttachmentError(problem);
|
||||
if (!problem) setPendingAttachment(file);
|
||||
}, []);
|
||||
|
||||
const clearAttachment = useCallback(() => {
|
||||
setPendingAttachment(null);
|
||||
setAttachmentError(null);
|
||||
}, []);
|
||||
|
||||
const clearAttachmentError = useCallback(() => setAttachmentError(null), []);
|
||||
|
||||
const retryStalled = useCallback(() => {
|
||||
const q = stalledQueryRef.current;
|
||||
if (!q) return;
|
||||
@@ -415,8 +444,11 @@ export function useChat({
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
pendingAttachment,
|
||||
attachFile,
|
||||
clearAttachment,
|
||||
attachmentError,
|
||||
clearAttachmentError,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
error,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// What can be attached to a message: images go to the vision model, text-like
|
||||
// files are extracted server-side and appended to the turn.
|
||||
|
||||
export const ATTACHMENT_ACCEPT = "image/*,.pdf,.txt,.md,.markdown,.csv";
|
||||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const IMAGE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
]);
|
||||
const FILE_EXTENSIONS = new Set(["pdf", "txt", "md", "markdown", "csv"]);
|
||||
const FILE_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/csv",
|
||||
]);
|
||||
|
||||
export type AttachmentKind = "image" | "file";
|
||||
|
||||
export const fileExtension = (name: string) =>
|
||||
name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
|
||||
|
||||
export function attachmentKind(file: File): AttachmentKind | null {
|
||||
if (IMAGE_TYPES.has(file.type)) return "image";
|
||||
if (FILE_TYPES.has(file.type) || FILE_EXTENSIONS.has(fileExtension(file.name))) {
|
||||
return "file";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns an error message if the file can't be attached, else null. */
|
||||
export function validateAttachment(file: File): string | null {
|
||||
if (!attachmentKind(file)) {
|
||||
return `Can't attach “${file.name}”. Use an image, PDF, TXT, MD or CSV file.`;
|
||||
}
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
return `“${file.name}” is larger than 10 MB.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Saved user messages record an attached file as a trailing "📎 name" line.
|
||||
const ATTACHED_FILE_RE = /(?:^|\n\n)📎 ([^\n]+)$/u;
|
||||
|
||||
export const withAttachedFile = (text: string, name: string) =>
|
||||
text ? `${text}\n\n📎 ${name}` : `📎 ${name}`;
|
||||
|
||||
export function splitAttachedFile(text: string): { body: string; fileName: string | null } {
|
||||
const match = text.match(ATTACHED_FILE_RE);
|
||||
if (!match) return { body: text, fileName: null };
|
||||
return { body: text.slice(0, match.index), fileName: match[1] };
|
||||
}
|
||||
@@ -20,8 +20,16 @@
|
||||
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
|
||||
// (unknown kind → JSON fallback)
|
||||
// anything else: two sequential calls, then a streamed answer
|
||||
// Conversations "seeded" (timeline) and "everything" (one saved tool message
|
||||
// per artifact kind) exist up front.
|
||||
// any query sent with a file_key: reads the attached file (by name)
|
||||
// Conversations "seeded" (timeline), "everything" (one saved tool message
|
||||
// per artifact kind) and "attachment" (a saved 📎 file message) exist up front.
|
||||
//
|
||||
// Uploads (POST /api/conversation/upload) follow the contract: JPEG/PNG/WebP/
|
||||
// HEIC -> {kind:"image"}, PDF/TXT/MD/CSV (by MIME or extension) ->
|
||||
// {kind:"file"}, anything else 415, files named *scan*.pdf 422 (no text
|
||||
// layer). Uploaded images are served back via /api/conversation/image/<key>.
|
||||
// GET /__mock/requests returns recorded API request bodies (multipart files
|
||||
// summarised as {filename, type, size}), newest last.
|
||||
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
|
||||
// backend tools emit. Any other *.json in this directory with `kind` and
|
||||
// `data` is loaded as an artifact of "seeded" (`id` defaults to the file name).
|
||||
@@ -62,6 +70,10 @@ const uuid = () => crypto.randomUUID();
|
||||
const artifacts = new Map();
|
||||
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
|
||||
const conversations = new Map();
|
||||
/** Uploaded objects by S3-style key: {bytes, type, name} */
|
||||
const blobs = new Map();
|
||||
/** Recorded API requests: {method, path, body} */
|
||||
const requests = [];
|
||||
|
||||
function addArtifact(conversationId, tool, a) {
|
||||
const record = {
|
||||
@@ -278,8 +290,15 @@ const LABELS = {
|
||||
};
|
||||
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
|
||||
|
||||
function seedAttachment() {
|
||||
const c = newConversation("attachment", "Vet bill questions");
|
||||
pushMessage(c, { speaker: "user", text: "How much was the dental cleaning?\n\n📎 vet-invoice-2026-09.pdf" });
|
||||
pushMessage(c, { speaker: "simba", text: "The dental cleaning on the September invoice was **$412.50**." });
|
||||
}
|
||||
|
||||
seed();
|
||||
seedEverything();
|
||||
seedAttachment();
|
||||
loadFixtureArtifacts();
|
||||
|
||||
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
|
||||
@@ -304,11 +323,20 @@ function artifactScenario(q) {
|
||||
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
|
||||
* into contract-shaped events; ctx persists tool messages and artifacts.
|
||||
*/
|
||||
function scenario(query) {
|
||||
function scenario(query, file) {
|
||||
const q = query.toLowerCase();
|
||||
const answer = (text) =>
|
||||
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
|
||||
|
||||
if (file) {
|
||||
return [
|
||||
[200, { type: "thinking_delta", delta: `Reading the attached ${file.name}…` }],
|
||||
[300, { start: "f1", tool: "simba_search", input: { query: (query || file.name).slice(0, 200) } }],
|
||||
[600, { end: "f1" }],
|
||||
...answer(`I read **${file.name}**. It looks like a vet invoice; the dental cleaning came to $412.50.`),
|
||||
];
|
||||
}
|
||||
|
||||
if (q.includes("plain")) {
|
||||
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
|
||||
}
|
||||
@@ -363,8 +391,17 @@ function scenario(query) {
|
||||
|
||||
async function streamQuery(req, res, body) {
|
||||
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
|
||||
pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null });
|
||||
if (c.name === "New Conversation") c.name = body.query.slice(0, 40);
|
||||
let saved = body.query ?? "";
|
||||
let file = null;
|
||||
if (body.file_key) {
|
||||
file = blobs.get(body.file_key);
|
||||
if (!body.file_key.startsWith(`conversations/${c.id}/files/`) || !file) {
|
||||
return json(res, 400, { error: "invalid file_key" });
|
||||
}
|
||||
saved = `${saved}\n\n📎 ${file.name}`.trim();
|
||||
}
|
||||
pushMessage(c, { speaker: "user", text: saved, image_key: body.image_key ?? null });
|
||||
if (c.name === "New Conversation") c.name = (body.query || file?.name || "Attachment").slice(0, 40);
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
@@ -377,7 +414,7 @@ async function streamQuery(req, res, body) {
|
||||
|
||||
const runs = new Map(); // key -> {run_id, tool, started}
|
||||
let text = "";
|
||||
for (const [delay, step] of scenario(body.query)) {
|
||||
for (const [delay, step] of scenario(body.query ?? "", file)) {
|
||||
await sleep(delay);
|
||||
if (closed) return;
|
||||
if (step.start) {
|
||||
@@ -428,24 +465,100 @@ const json = (res, status, body) => {
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => (raw += chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(raw ? JSON.parse(raw) : {});
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
async function readRaw(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Minimal multipart/form-data parser: {fields, files: {name: {filename, type, data}}}. */
|
||||
function parseMultipart(buf, contentType) {
|
||||
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType ?? "");
|
||||
const out = { fields: {}, files: {} };
|
||||
if (!boundary) return out;
|
||||
const delim = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
|
||||
let pos = buf.indexOf(delim);
|
||||
while (pos !== -1) {
|
||||
const start = pos + delim.length + 2; // skip CRLF
|
||||
const next = buf.indexOf(delim, start);
|
||||
if (next === -1) break;
|
||||
const part = buf.subarray(start, next - 2); // strip trailing CRLF
|
||||
const headerEnd = part.indexOf("\r\n\r\n");
|
||||
const headers = part.subarray(0, headerEnd).toString("utf8");
|
||||
const data = part.subarray(headerEnd + 4);
|
||||
const name = /name="([^"]*)"/.exec(headers)?.[1];
|
||||
const filename = /filename="([^"]*)"/.exec(headers)?.[1];
|
||||
if (name && filename !== undefined) {
|
||||
const type = /Content-Type:\s*([^\r\n]+)/i.exec(headers)?.[1]?.trim() ?? "";
|
||||
out.files[name] = { filename, type, data };
|
||||
} else if (name) {
|
||||
out.fields[name] = data.toString("utf8");
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read the body once: JSON, or multipart form as {fields, files}. */
|
||||
async function readBody(req) {
|
||||
const raw = await readRaw(req);
|
||||
const type = req.headers["content-type"] ?? "";
|
||||
if (type.startsWith("multipart/form-data")) return { form: parseMultipart(raw, type) };
|
||||
try {
|
||||
return raw.length ? JSON.parse(raw.toString("utf8")) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
|
||||
const FILE_TYPES = { "application/pdf": "pdf", "text/plain": "txt", "text/markdown": "md", "text/csv": "csv" };
|
||||
const FILE_EXTS = new Set(["pdf", "txt", "md", "csv"]);
|
||||
|
||||
function upload(res, form) {
|
||||
const file = form?.files.file;
|
||||
const conversationId = form?.fields.conversation_id;
|
||||
if (!file || !conversationId) return json(res, 400, { error: "file and conversation_id are required" });
|
||||
if (!conversations.has(conversationId)) return json(res, 404, { error: "conversation not found" });
|
||||
if (file.data.length > 10 * 1024 * 1024) return json(res, 413, { error: "File too large" });
|
||||
const type = file.type.split(";")[0].trim().toLowerCase();
|
||||
if (IMAGE_TYPES.has(type)) {
|
||||
const key = `conversations/${conversationId}/${uuid()}.${type.split("/")[1].replace("jpeg", "jpg")}`;
|
||||
blobs.set(key, { bytes: file.data, type, name: file.filename });
|
||||
return json(res, 200, { kind: "image", image_key: key });
|
||||
}
|
||||
const fromName = path.extname(file.filename).slice(1).toLowerCase();
|
||||
const ext = FILE_TYPES[type] ?? (FILE_EXTS.has(fromName) ? fromName : null);
|
||||
if (!ext) {
|
||||
return json(res, 415, { error: `Unsupported file type: ${type || file.filename}. Allowed: images, PDF, TXT, MD, CSV` });
|
||||
}
|
||||
if (ext === "pdf" && /scan/i.test(file.filename)) {
|
||||
return json(res, 422, { error: "This PDF has no text layer (scanned?), so Simba can't read it yet." });
|
||||
}
|
||||
const key = `conversations/${conversationId}/files/${uuid()}.${ext}`;
|
||||
blobs.set(key, { bytes: file.data, type, name: file.filename });
|
||||
return json(res, 200, { kind: "file", file_key: key, name: file.filename, chars: ext === "pdf" ? 1834 : file.data.length });
|
||||
}
|
||||
|
||||
const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at });
|
||||
|
||||
async function api(req, res, url) {
|
||||
const p = url.pathname.replace(/\/+$/, "") || "/";
|
||||
const m = req.method;
|
||||
const body = m === "GET" ? {} : await readBody(req);
|
||||
const { form } = body;
|
||||
requests.push({
|
||||
method: m,
|
||||
path: url.pathname + url.search,
|
||||
body: form
|
||||
? {
|
||||
...form.fields,
|
||||
files: Object.fromEntries(
|
||||
Object.entries(form.files).map(([k, f]) => [k, { filename: f.filename, type: f.type, size: f.data.length }]),
|
||||
),
|
||||
}
|
||||
: body,
|
||||
});
|
||||
|
||||
if (p === "/api/user/me") {
|
||||
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
|
||||
@@ -456,16 +569,18 @@ async function api(req, res, url) {
|
||||
if (p.startsWith("/api/scheduled")) return json(res, 200, []);
|
||||
|
||||
if (p === "/api/conversation/stream-query" && m === "POST") {
|
||||
return streamQuery(req, res, await readBody(req));
|
||||
return streamQuery(req, res, body);
|
||||
}
|
||||
if (p === "/api/conversation/query" && m === "POST") {
|
||||
return json(res, 200, { response: "meow (mock)" });
|
||||
}
|
||||
if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") {
|
||||
return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` });
|
||||
if (p === "/api/conversation/upload" && m === "POST") return upload(res, form);
|
||||
if (p === "/api/conversation/upload-image") {
|
||||
return json(res, 200, { image_key: `mock/${uuid()}.png` });
|
||||
}
|
||||
if (p.startsWith("/api/conversation/image/")) {
|
||||
return json(res, 200, { url: "/favicon.ico" });
|
||||
const key = decodeURIComponent(p.slice("/api/conversation/image/".length));
|
||||
return json(res, 200, { url: blobs.has(key) ? `/__mock/blob/${encodeURIComponent(key)}` : "/favicon.ico" });
|
||||
}
|
||||
if (p === "/api/conversation") {
|
||||
if (m === "POST") return json(res, 200, summary(newConversation()));
|
||||
@@ -490,7 +605,7 @@ async function api(req, res, url) {
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
if (m === "PATCH") {
|
||||
c.name = (await readBody(req)).name ?? c.name;
|
||||
c.name = body.name ?? c.name;
|
||||
return json(res, 200, summary(c));
|
||||
}
|
||||
return json(res, 200, {
|
||||
@@ -547,6 +662,13 @@ http
|
||||
.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
try {
|
||||
if (url.pathname === "/__mock/requests") return json(res, 200, requests);
|
||||
if (url.pathname.startsWith("/__mock/blob/")) {
|
||||
const blob = blobs.get(decodeURIComponent(url.pathname.slice("/__mock/blob/".length)));
|
||||
if (!blob) return json(res, 404, { error: "no blob" });
|
||||
res.writeHead(200, { "Content-Type": blob.type });
|
||||
return res.end(blob.bytes);
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
|
||||
return serveStatic(res, decodeURIComponent(url.pathname));
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
const { renderToStaticMarkup } = require('react-dom/server');
|
||||
|
||||
// Load the existing TSX renderer without adding a test bundler dependency.
|
||||
for (const ext of ['.ts', '.tsx']) {
|
||||
require.extensions[ext] = (mod, filename) => {
|
||||
const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
|
||||
});
|
||||
mod._compile(outputText, filename);
|
||||
};
|
||||
}
|
||||
const { YnabSpendingReport } = require('../src/components/artifacts/renderers/YnabSpendingReport.tsx');
|
||||
const base = { start_date: '2026-10-01', end_date: '2026-10-12', group_by: 'category', rows: [{ key: 'Shopping', amount: 70, count: 2 }], total: 100 };
|
||||
const render = (data) => renderToStaticMarkup(React.createElement(YnabSpendingReport, { data }));
|
||||
|
||||
test('legacy artifact keeps a neutral total label', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, />Total</);
|
||||
assert.doesNotMatch(html, /Total \(net\)|Total outflows|Inflows:/);
|
||||
});
|
||||
test('new report labels gross outflows, inflows and net separately', () => {
|
||||
const html = render({ ...base, total_basis: 'outflows', inflows: 30, net_outflows: 70 });
|
||||
assert.match(html, /Total outflows/);
|
||||
assert.match(html, /Inflows:/);
|
||||
assert.match(html, /Net outflows:/);
|
||||
assert.match(html, /Groups show gross outflows/);
|
||||
});
|
||||
test('inflow-only report remains visible with empty outflow rows', () => {
|
||||
const html = render({ ...base, total_basis: 'outflows', rows: [], total: 0, inflows: 75, net_outflows: -75 });
|
||||
assert.match(html, /No outflows in this period/);
|
||||
assert.match(html, /Inflows:/);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Regression tests for complete-turn context budgeting."""
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
from blueprints.conversation import context
|
||||
from blueprints.conversation.context import (
|
||||
IMAGE_PLACEHOLDER,
|
||||
build_context,
|
||||
message_tokens,
|
||||
trim_history,
|
||||
)
|
||||
|
||||
|
||||
def call(*ids):
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": i, "name": "search", "args": {"path": "notes/budget.md"}}
|
||||
for i in ids
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def result(i, content="found"):
|
||||
return ToolMessage(content=content, tool_call_id=i)
|
||||
|
||||
|
||||
def cost(messages):
|
||||
return sum(message_tokens(m) for m in messages)
|
||||
|
||||
|
||||
def test_long_current_turn_keeps_question_and_all_exchanges():
|
||||
question = HumanMessage(content="Compare my spending with my plan")
|
||||
messages = [question]
|
||||
for i in range(8):
|
||||
messages += [call(str(i)), result(str(i), "data " * 1000)]
|
||||
out = trim_history(messages, token_budget=3000)
|
||||
assert out[0] is question
|
||||
assert len(out) == 17
|
||||
assert cost(out) <= 3000
|
||||
assert [m.tool_call_id for m in out if isinstance(m, ToolMessage)] == [
|
||||
str(i) for i in range(8)
|
||||
]
|
||||
assert messages[2].content == "data " * 1000
|
||||
assert out[1].tool_calls == messages[1].tool_calls
|
||||
|
||||
|
||||
def test_parallel_tool_results_remain_paired():
|
||||
messages = [
|
||||
HumanMessage(content="q"),
|
||||
call("a", "b"),
|
||||
result("a", "x" * 20000),
|
||||
result("b", "y" * 20000),
|
||||
]
|
||||
out = trim_history(messages, token_budget=700)
|
||||
assert len(out) == 4
|
||||
assert out[1].tool_calls == messages[1].tool_calls
|
||||
assert cost(out) <= 700
|
||||
|
||||
|
||||
def test_drops_whole_turns_and_includes_bounded_excerpts():
|
||||
messages = []
|
||||
for i in range(12):
|
||||
messages += [
|
||||
HumanMessage(content=f"Question {i}"),
|
||||
call(str(i)),
|
||||
result(str(i), "data " * 1000),
|
||||
AIMessage(content=f"Decision {i}"),
|
||||
]
|
||||
question = HumanMessage(content="What did we decide?")
|
||||
messages.append(question)
|
||||
out = trim_history(messages, token_budget=3000)
|
||||
assert isinstance(out[0], SystemMessage)
|
||||
assert "Decision" in out[0].content
|
||||
assert "historical data" in out[0].content
|
||||
assert cost(out) <= 3000
|
||||
assert out[-1] is question
|
||||
# Every retained result still has its original call preceding it.
|
||||
seen = set()
|
||||
for m in out:
|
||||
if isinstance(m, AIMessage):
|
||||
seen.update(c["id"] for c in m.tool_calls)
|
||||
if isinstance(m, ToolMessage):
|
||||
assert m.tool_call_id in seen
|
||||
|
||||
|
||||
def test_earlier_images_replaced_current_image_preserved():
|
||||
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
|
||||
messages = [
|
||||
HumanMessage(content=[{"type": "text", "text": "old"}, image]),
|
||||
AIMessage(content="photo"),
|
||||
HumanMessage(content=[{"type": "text", "text": "new"}, image]),
|
||||
]
|
||||
out = trim_history(messages)
|
||||
assert out[0].content[1] == {"type": "text", "text": IMAGE_PLACEHOLDER}
|
||||
assert out[-1].content[1] == image
|
||||
assert messages[0].content[1] == image
|
||||
|
||||
|
||||
def test_short_history_unchanged():
|
||||
messages = [HumanMessage(content="hi"), AIMessage(content="hello")]
|
||||
assert trim_history(messages) == messages
|
||||
|
||||
|
||||
def test_question_that_cannot_fit_fails_without_truncation():
|
||||
messages = [HumanMessage(content="important request " * 1000)]
|
||||
with pytest.raises(ValueError, match="current question"):
|
||||
trim_history(messages, token_budget=100)
|
||||
assert messages[0].content == "important request " * 1000
|
||||
|
||||
|
||||
def test_system_and_tool_schemas_are_charged_to_budget(monkeypatch):
|
||||
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 2500)
|
||||
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 10000)
|
||||
schemas = [{"name": "search", "description": "tool docs " * 100}]
|
||||
messages = [HumanMessage(content="q"), call("a"), result("a", "result " * 3000)]
|
||||
out = build_context(messages, "system " * 100, schemas)
|
||||
assert cost(out) + context.estimate_tokens(schemas) <= 2500
|
||||
assert out[1] is messages[0]
|
||||
|
||||
|
||||
def test_fixed_overhead_that_cannot_fit_fails(monkeypatch):
|
||||
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 100)
|
||||
with pytest.raises(ValueError, match="tool definitions"):
|
||||
build_context([HumanMessage(content="q")], "system" * 1000, [])
|
||||
|
||||
|
||||
def test_response_and_safety_reserves_reduce_prompt_limit(monkeypatch):
|
||||
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 8000)
|
||||
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 16000)
|
||||
messages = [HumanMessage(content="q"), call("a"), result("a", "data " * 5000)]
|
||||
out = build_context(messages, "system", [])
|
||||
assert (
|
||||
cost(out) + context.estimate_tokens([])
|
||||
<= 8000 - context.RESPONSE_RESERVE_TOKENS - context.SAFETY_RESERVE_TOKENS
|
||||
)
|
||||
|
||||
|
||||
def test_cancelled_turn_does_not_replay_incomplete_exchange():
|
||||
messages = [
|
||||
HumanMessage(content="old"),
|
||||
call("a", "b"),
|
||||
result("a"),
|
||||
HumanMessage(content="new"),
|
||||
]
|
||||
out = trim_history(messages)
|
||||
assert out == [messages[0], messages[-1]]
|
||||
|
||||
|
||||
def test_orphan_results_are_removed():
|
||||
messages = [
|
||||
result("orphan"),
|
||||
HumanMessage(content="q"),
|
||||
result("another orphan"),
|
||||
AIMessage(content="answer"),
|
||||
]
|
||||
assert trim_history(messages) == [messages[1], messages[3]]
|
||||
|
||||
|
||||
def test_large_assistant_answer_is_bounded_in_earlier_turn():
|
||||
messages = [
|
||||
HumanMessage(content="old"),
|
||||
AIMessage(content="long " * 10000),
|
||||
HumanMessage(content="new"),
|
||||
]
|
||||
out = trim_history(messages, token_budget=4000)
|
||||
assert cost(out) <= 4000
|
||||
assert len(out[1].content) < len(messages[1].content)
|
||||
assert messages[1].content == "long " * 10000
|
||||
|
||||
|
||||
def test_multiblock_attachment_shares_one_cap():
|
||||
messages = [
|
||||
HumanMessage(content=[{"type": "text", "text": "x" * 10000} for _ in range(5)]),
|
||||
AIMessage(content="ok"),
|
||||
HumanMessage(content="next"),
|
||||
]
|
||||
out = trim_history(messages, token_budget=5000)
|
||||
assert cost(out) <= 5000
|
||||
assert sum(len(p["text"]) for p in out[0].content) < 5000
|
||||
|
||||
|
||||
async def test_saved_memories_are_bounded_without_deleting(monkeypatch):
|
||||
from blueprints.conversation import memory
|
||||
|
||||
saved = ["newest preference", "big " * 5000, "older preference"]
|
||||
|
||||
async def get_memories(user_id):
|
||||
return saved
|
||||
|
||||
monkeypatch.setattr(memory, "get_memories_for_user", get_memories)
|
||||
prompt = await memory.build_system_prompt_with_memories("user")
|
||||
block = prompt.split("USER MEMORIES", 1)[1]
|
||||
assert "newest preference" in block
|
||||
assert "older preference" in block
|
||||
assert "omitted" in block
|
||||
assert "big big" not in block
|
||||
assert saved[1] == "big " * 5000
|
||||
|
||||
|
||||
def test_agent_prompt_accounts_for_extra_tool_schemas(monkeypatch):
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
from blueprints.conversation import agents
|
||||
|
||||
captured = {}
|
||||
|
||||
def create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(agents, "create_react_agent", create)
|
||||
extra = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "extra",
|
||||
"description": "extra docs",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
agents.build_agent(extra_tools=[extra])
|
||||
assert extra in captured["tools"]
|
||||
question = HumanMessage(content="hello")
|
||||
actual = captured["prompt"].invoke({"messages": [question]})
|
||||
assert actual[-1] is question
|
||||
# The actual prompt closure includes this schema rather than relying on a
|
||||
# static reserve that would miss admin-only tools.
|
||||
seen = {}
|
||||
|
||||
def build(messages, system, schemas):
|
||||
seen["schemas"] = schemas
|
||||
return messages
|
||||
|
||||
monkeypatch.setattr(agents, "build_context", build)
|
||||
messages = [HumanMessage(content="hello")]
|
||||
assert captured["prompt"].invoke({"messages": messages}) == messages
|
||||
assert extra in seen["schemas"]
|
||||
|
||||
|
||||
def test_heavy_current_turn_leaves_room_for_previous_decisions():
|
||||
question = HumanMessage(content="Apply that plan to my spending")
|
||||
messages = [
|
||||
HumanMessage(content="Plan?"),
|
||||
AIMessage(content="Keep dining under $300."),
|
||||
question,
|
||||
call("a"),
|
||||
result("a", "spending " * 10000),
|
||||
]
|
||||
out = trim_history(messages, token_budget=2500)
|
||||
assert cost(out) <= 2500
|
||||
assert question in out
|
||||
assert any(
|
||||
"Keep dining under $300." in m.content
|
||||
for m in out
|
||||
if isinstance(m.content, str)
|
||||
)
|
||||
|
||||
|
||||
def test_large_tool_arguments_fail_explicitly_instead_of_breaking_pair():
|
||||
exchange = call("a")
|
||||
exchange.tool_calls[0]["args"] = {"query": "huge " * 10000}
|
||||
messages = [HumanMessage(content="q"), exchange, result("a")]
|
||||
with pytest.raises(ValueError, match="tool calls exceed"):
|
||||
trim_history(messages, token_budget=300)
|
||||
assert exchange.tool_calls[0]["args"]["query"] == "huge " * 10000
|
||||
|
||||
|
||||
def test_unicode_tool_result_fits_estimated_budget():
|
||||
messages = [HumanMessage(content="q"), call("a"), result("a", "猫😺预算" * 10000)]
|
||||
out = trim_history(messages, token_budget=600)
|
||||
assert cost(out) <= 600
|
||||
assert out[0] is messages[0]
|
||||
assert out[1].tool_calls == messages[1].tool_calls
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Tests for conversation file attachments: extraction, /upload and stream-query file_key."""
|
||||
|
||||
import io
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from quart import Quart
|
||||
from quart.datastructures import FileStorage
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
|
||||
import blueprints.conversation as conv
|
||||
from utils.file_extract import (
|
||||
MAX_ATTACHMENT_CHARS,
|
||||
TRUNCATION_MARKER,
|
||||
FileExtractionError,
|
||||
attachment_block,
|
||||
cap_text,
|
||||
extract_text,
|
||||
file_extension,
|
||||
)
|
||||
|
||||
CONV_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_ID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
def _pdf_bytes(text: str) -> bytes:
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), text)
|
||||
data = doc.tobytes()
|
||||
doc.close()
|
||||
return data
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), "orange").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestFileExtension:
|
||||
@pytest.mark.parametrize(
|
||||
"name,mime,expected",
|
||||
[
|
||||
("a.pdf", "application/pdf", "pdf"),
|
||||
("notes", "text/markdown", "md"),
|
||||
("notes.md", "application/octet-stream", "md"),
|
||||
("data.CSV", "", "csv"),
|
||||
("x.txt", "text/plain; charset=utf-8", "txt"),
|
||||
("x.markdown", None, "md"),
|
||||
("x.docx", "application/vnd.openxmlformats", None),
|
||||
("x.zip", "application/zip", None),
|
||||
],
|
||||
)
|
||||
def test_detects_supported_types(self, name, mime, expected):
|
||||
assert file_extension(name, mime) == expected
|
||||
|
||||
|
||||
class TestExtractText:
|
||||
def test_pdf(self):
|
||||
assert "Simba eats kibble" in extract_text(
|
||||
_pdf_bytes("Simba eats kibble"), "pdf"
|
||||
)
|
||||
|
||||
def test_corrupt_pdf_raises(self):
|
||||
with pytest.raises(FileExtractionError):
|
||||
extract_text(b"not a pdf", "pdf")
|
||||
|
||||
def test_text_decodes_utf8_with_replacement(self):
|
||||
assert extract_text("héllo".encode() + b"\xff", "txt") == "héllo�"
|
||||
|
||||
def test_csv_and_md_are_plain_text(self):
|
||||
assert extract_text(b"\xef\xbb\xbfa,b\n1,2", "csv") == "a,b\n1,2"
|
||||
assert extract_text(b"# Title", "md") == "# Title"
|
||||
|
||||
|
||||
class TestTruncation:
|
||||
def test_short_text_untouched(self):
|
||||
assert cap_text("abc") == "abc"
|
||||
|
||||
def test_long_text_capped_with_marker(self):
|
||||
out = cap_text("x" * (MAX_ATTACHMENT_CHARS + 50))
|
||||
assert out.endswith(TRUNCATION_MARKER)
|
||||
assert out.count("x") == MAX_ATTACHMENT_CHARS
|
||||
|
||||
def test_attachment_block_wraps_and_caps(self):
|
||||
block = attachment_block('a"b.txt', "y" * (MAX_ATTACHMENT_CHARS + 1))
|
||||
assert block.startswith('<attached file "a\'b.txt">\n')
|
||||
assert block.endswith(f"{TRUNCATION_MARKER}\n</attached>")
|
||||
|
||||
|
||||
class TestFileKeyPrefix:
|
||||
def test_own_conversation(self):
|
||||
assert conv._file_key_belongs_to(
|
||||
f"conversations/{CONV_ID}/files/x.pdf", CONV_ID
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
f"conversations/{OTHER_ID}/files/x.pdf",
|
||||
f"conversations/{CONV_ID}/x.jpg",
|
||||
f"conversations/{CONV_ID}/files/../../{OTHER_ID}/files/x.pdf",
|
||||
f"conversations/{CONV_ID}",
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_rejects_other_keys(self, key):
|
||||
assert not conv._file_key_belongs_to(key, CONV_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conv.conversation_blueprint)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(app):
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity="user-1")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _owned(conversation_id, user_id):
|
||||
if conversation_id == CONV_ID and user_id == "user-1":
|
||||
return SimpleNamespace(id=CONV_ID)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3():
|
||||
with (
|
||||
patch.object(conv, "_get_owned_conversation", AsyncMock(side_effect=_owned)),
|
||||
patch.object(conv, "s3_upload_file", AsyncMock()) as upload_file,
|
||||
patch.object(conv, "s3_upload_image", AsyncMock()) as upload_image,
|
||||
):
|
||||
yield SimpleNamespace(upload_file=upload_file, upload_image=upload_image)
|
||||
|
||||
|
||||
async def _upload(
|
||||
app, headers, data: bytes, filename: str, mime: str, conversation_id=CONV_ID
|
||||
):
|
||||
return await app.test_client().post(
|
||||
"/api/conversation/upload",
|
||||
headers=headers,
|
||||
form={"conversation_id": conversation_id},
|
||||
files={
|
||||
"file": FileStorage(io.BytesIO(data), filename=filename, content_type=mime)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestUploadRoute:
|
||||
async def test_pdf_is_stored_with_filename(self, app, headers, s3):
|
||||
resp = await _upload(
|
||||
app, headers, _pdf_bytes("hello"), "Vet bill.pdf", "application/pdf"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["kind"] == "file"
|
||||
assert body["name"] == "Vet bill.pdf"
|
||||
assert body["chars"] >= len("hello")
|
||||
assert body["file_key"].startswith(f"conversations/{CONV_ID}/files/")
|
||||
assert body["file_key"].endswith(".pdf")
|
||||
_, key, mime, metadata = s3.upload_file.await_args.args
|
||||
assert key == body["file_key"] and mime == "application/pdf"
|
||||
assert metadata == {"filename": "Vet%20bill.pdf"}
|
||||
|
||||
async def test_markdown_by_extension(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, b"# hi", "n.md", "application/octet-stream")
|
||||
body = await resp.get_json()
|
||||
assert resp.status_code == 200
|
||||
assert body["chars"] == 4 and body["file_key"].endswith(".md")
|
||||
|
||||
async def test_image_goes_through_process_image(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, _png_bytes(), "cat.png", "image/png")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["kind"] == "image"
|
||||
assert body["image_key"].startswith(f"conversations/{CONV_ID}/")
|
||||
s3.upload_image.assert_awaited_once()
|
||||
s3.upload_file.assert_not_awaited()
|
||||
|
||||
async def test_unsupported_type_is_415(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, b"PK\x03\x04", "a.zip", "application/zip")
|
||||
assert resp.status_code == 415
|
||||
s3.upload_file.assert_not_awaited()
|
||||
|
||||
async def test_unsupported_image_type_is_415(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, b"GIF89a", "a.gif", "image/gif")
|
||||
assert resp.status_code == 415
|
||||
|
||||
async def test_corrupt_pdf_is_400(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, b"nope", "a.pdf", "application/pdf")
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_pdf_without_text_layer_is_422(self, app, headers, s3):
|
||||
doc = fitz.open()
|
||||
doc.new_page() # blank page: no text, like a scan without OCR
|
||||
resp = await _upload(app, headers, doc.tobytes(), "scan.pdf", "application/pdf")
|
||||
assert resp.status_code == 422
|
||||
assert "no text layer" in (await resp.get_json())["error"]
|
||||
s3.upload_file.assert_not_awaited()
|
||||
|
||||
async def test_empty_text_file_is_422(self, app, headers, s3):
|
||||
resp = await _upload(app, headers, b" \n", "empty.txt", "text/plain")
|
||||
assert resp.status_code == 422
|
||||
s3.upload_file.assert_not_awaited()
|
||||
|
||||
async def test_extraction_runs_off_the_event_loop(self, app, headers, s3):
|
||||
calls = []
|
||||
|
||||
async def fake_to_thread(fn, *args):
|
||||
calls.append(fn)
|
||||
return fn(*args)
|
||||
|
||||
with patch.object(conv.asyncio, "to_thread", fake_to_thread):
|
||||
resp = await _upload(
|
||||
app, headers, _pdf_bytes("hi"), "a.pdf", "application/pdf"
|
||||
)
|
||||
get = AsyncMock(return_value=(b"x", {}))
|
||||
with patch.object(conv, "s3_get_file", get):
|
||||
await conv._load_attached_file(f"conversations/{CONV_ID}/files/u.txt")
|
||||
assert resp.status_code == 200
|
||||
assert calls == [conv.extract_text, conv.extract_text]
|
||||
|
||||
async def test_other_users_conversation_is_404(self, app, headers, s3):
|
||||
resp = await _upload(
|
||||
app, headers, b"hi", "a.txt", "text/plain", conversation_id=OTHER_ID
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
s3.upload_file.assert_not_awaited()
|
||||
|
||||
async def test_requires_auth(self, app, s3):
|
||||
resp = await _upload(app, {}, b"hi", "a.txt", "text/plain")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestStreamQueryFileKey:
|
||||
async def test_rejects_file_key_from_other_conversation(self, app, headers, s3):
|
||||
add = AsyncMock()
|
||||
with (
|
||||
patch.object(conv.blueprints.users.models.User, "get", AsyncMock()),
|
||||
patch.object(conv, "add_message_to_conversation", add),
|
||||
):
|
||||
resp = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
headers=headers,
|
||||
json={
|
||||
"query": "summarise",
|
||||
"conversation_id": CONV_ID,
|
||||
"file_key": f"conversations/{OTHER_ID}/files/x.pdf",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
add.assert_not_awaited()
|
||||
|
||||
async def test_load_attached_file_recovers_name_and_text(self):
|
||||
get = AsyncMock(return_value=(b"a,b\n1,2", {"filename": "M%C3%A9nage.csv"}))
|
||||
with patch.object(conv, "s3_get_file", get):
|
||||
name, text = await conv._load_attached_file(
|
||||
f"conversations/{CONV_ID}/files/u.csv"
|
||||
)
|
||||
assert name == "Ménage.csv"
|
||||
assert text == "a,b\n1,2"
|
||||
|
||||
async def test_load_attached_file_falls_back_to_key_name(self):
|
||||
get = AsyncMock(return_value=(b"x", {}))
|
||||
with patch.object(conv, "s3_get_file", get):
|
||||
name, _ = await conv._load_attached_file(
|
||||
f"conversations/{CONV_ID}/files/u.txt"
|
||||
)
|
||||
assert name == "u.txt"
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for the stream-query stall timeout and per-call model metrics."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from tortoise import Tortoise
|
||||
|
||||
import blueprints.conversation as conv
|
||||
from blueprints.conversation.models import Conversation
|
||||
from blueprints.users.models import User
|
||||
|
||||
# Shortened stall limit for the tests (seconds).
|
||||
LIMIT = 0.3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner():
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:",
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.artifacts.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
user = await User.create(username="owner", email="owner@example.com")
|
||||
conversation = await Conversation.create(name="c", user=user)
|
||||
yield user, conversation
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
class _TimedAgent:
|
||||
"""Yields (delay_seconds, event) pairs, sleeping before each event."""
|
||||
|
||||
def __init__(self, script):
|
||||
self._script = script
|
||||
|
||||
async def astream_events(self, payload, version, config):
|
||||
for delay, event in self._script:
|
||||
await asyncio.sleep(delay)
|
||||
yield event
|
||||
|
||||
|
||||
def _start(run_id):
|
||||
return {"event": "on_chat_model_start", "run_id": run_id, "name": "llm", "data": {}}
|
||||
|
||||
|
||||
def _chunk(run_id, text):
|
||||
return {
|
||||
"event": "on_chat_model_stream",
|
||||
"run_id": run_id,
|
||||
"data": {"chunk": AIMessageChunk(content=text)},
|
||||
}
|
||||
|
||||
|
||||
def _end(run_id, prompt_tokens=None):
|
||||
usage = (
|
||||
{
|
||||
"input_tokens": prompt_tokens,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": prompt_tokens + 5,
|
||||
}
|
||||
if prompt_tokens
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"event": "on_chat_model_end",
|
||||
"run_id": run_id,
|
||||
"data": {"output": AIMessage(content="", usage_metadata=usage)},
|
||||
}
|
||||
|
||||
|
||||
def _final(text):
|
||||
return {
|
||||
"event": "on_chain_end",
|
||||
"name": "agent",
|
||||
"data": {"output": {"messages": [AIMessage(content=text)]}},
|
||||
}
|
||||
|
||||
|
||||
async def _run(owner, monkeypatch, script, *, response_timeout=60):
|
||||
user, conversation = owner
|
||||
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", LIMIT)
|
||||
monkeypatch.setattr(conv, "get_agent_for", lambda u: _TimedAgent(script))
|
||||
|
||||
async def _prompt(user_id):
|
||||
return "system"
|
||||
|
||||
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["RESPONSE_TIMEOUT"] = response_timeout
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conv.conversation_blueprint)
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(user.id))
|
||||
resp = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
json={"query": "hi", "conversation_id": str(conversation.id)},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
body = (await resp.get_data()).decode()
|
||||
return [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in body.splitlines()
|
||||
if line.startswith("data: {")
|
||||
]
|
||||
|
||||
|
||||
def test_stall_timeout_only_while_a_model_call_is_in_flight(monkeypatch):
|
||||
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", 30.0)
|
||||
assert conv.stall_timeout(None, 100.0) is None
|
||||
assert conv.stall_timeout(100.0, 110.0) == 20.0
|
||||
assert conv.stall_timeout(100.0, 200.0) == 1.0 # floor
|
||||
|
||||
|
||||
async def test_active_stream_outlasts_response_timeout(owner, monkeypatch):
|
||||
script = [
|
||||
(0, _start("m1")),
|
||||
(0, _chunk("m1", "<think>")),
|
||||
(0.15, _chunk("m1", "working")),
|
||||
(0.15, _chunk("m1", "</think>Answer")),
|
||||
(0, _end("m1")),
|
||||
(0, _final("Answer")),
|
||||
]
|
||||
|
||||
frames = await _run(owner, monkeypatch, script, response_timeout=0.1)
|
||||
|
||||
assert "error" not in [f["type"] for f in frames]
|
||||
assert frames[-1] == {"type": "response", "message": "Answer"}
|
||||
|
||||
|
||||
async def test_long_thinking_is_not_a_stall(owner, monkeypatch):
|
||||
# The whole call takes ~4x the limit, but output never pauses for longer
|
||||
# than half of it; the old time-to-first-content deadline killed this.
|
||||
script = [(0, _start("m1"))]
|
||||
script += [(LIMIT / 2, _chunk("m1", "<think>hmm ")) for _ in range(8)]
|
||||
script += [
|
||||
(0, _chunk("m1", "</think>Answer")),
|
||||
(0, _end("m1")),
|
||||
(0, _final("Answer")),
|
||||
]
|
||||
|
||||
frames = await _run(owner, monkeypatch, script)
|
||||
|
||||
types = [f["type"] for f in frames]
|
||||
assert "error" not in types
|
||||
assert frames[-1] == {"type": "response", "message": "Answer"}
|
||||
|
||||
|
||||
async def test_tool_time_between_model_calls_does_not_count(owner, monkeypatch):
|
||||
tool = {"event": "on_tool_start", "name": "web_search", "run_id": "t1", "data": {}}
|
||||
tool_end = {
|
||||
"event": "on_tool_end",
|
||||
"name": "web_search",
|
||||
"run_id": "t1",
|
||||
"data": {},
|
||||
}
|
||||
script = [
|
||||
(0, _start("m1")),
|
||||
(0, _chunk("m1", "")),
|
||||
(0, _end("m1")),
|
||||
(0, tool),
|
||||
(LIMIT * 3, tool_end), # slow tool, no model call in flight
|
||||
(0, _start("m2")),
|
||||
(LIMIT / 2, _chunk("m2", "Done")),
|
||||
(0, _end("m2")),
|
||||
(0, _final("Done")),
|
||||
]
|
||||
|
||||
frames = await _run(owner, monkeypatch, script)
|
||||
|
||||
assert "error" not in [f["type"] for f in frames]
|
||||
assert frames[-1]["message"] == "Done"
|
||||
|
||||
|
||||
async def test_silent_model_call_is_a_stall(owner, monkeypatch):
|
||||
script = [
|
||||
(0, _start("m1")),
|
||||
(0, _chunk("m1", "<think>start")),
|
||||
(LIMIT * 4, _chunk("m1", "never arrives")),
|
||||
]
|
||||
|
||||
frames = await _run(owner, monkeypatch, script)
|
||||
|
||||
assert frames[-1]["type"] == "error"
|
||||
assert frames[-1]["stalled"] is True
|
||||
|
||||
|
||||
async def test_logs_per_call_prompt_tokens(owner, monkeypatch, caplog):
|
||||
script = [
|
||||
(0, _start("m1")),
|
||||
(0, _chunk("m1", "Hi")),
|
||||
(0, _end("m1", prompt_tokens=27262)),
|
||||
(0, _final("Hi")),
|
||||
]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
await _run(owner, monkeypatch, script)
|
||||
|
||||
summaries = [
|
||||
json.loads(r.getMessage())
|
||||
for r in caplog.records
|
||||
if r.getMessage().startswith('{"event": "stream_request"')
|
||||
]
|
||||
assert summaries, "stream_request summary not logged"
|
||||
summary = summaries[-1]
|
||||
assert summary["max_prompt_tokens"] == 27262
|
||||
(call,) = summary["model_calls"]
|
||||
assert call["prompt_tokens"] == 27262
|
||||
assert call["prefill_ms"] is not None and call["ms"] is not None
|
||||
@@ -589,3 +589,26 @@ def test_search_documents_artifact_empty():
|
||||
)
|
||||
def test_format_date_range(start, end, expected):
|
||||
assert format_date_range(start, end) == expected
|
||||
|
||||
|
||||
async def test_obsidian_note_line_range_retains_full_artifact(obsidian):
|
||||
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
|
||||
f.write("first\nsecond\nthird\nfourth")
|
||||
msg = await call(
|
||||
agents.obsidian_read_note,
|
||||
{"relative_path": "range.md", "start_line": 2, "max_lines": 2},
|
||||
)
|
||||
assert "Content lines 2-3 of 4:\nsecond\nthird\n\n" in msg.content
|
||||
assert "first" not in msg.content
|
||||
assert msg.artifact["data"]["markdown"] == "first\nsecond\nthird\nfourth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args", [{"start_line": 0}, {"max_lines": 0}, {"start_line": 99}]
|
||||
)
|
||||
async def test_obsidian_note_invalid_line_range(obsidian, args):
|
||||
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
|
||||
f.write("first\nsecond")
|
||||
msg = await call(agents.obsidian_read_note, {"relative_path": "range.md", **args})
|
||||
assert msg.artifact is None
|
||||
assert "positive integers" in msg.content or "beyond the end" in msg.content
|
||||
|
||||
@@ -26,7 +26,17 @@ KIND_DATA_KEYS = {
|
||||
"overspent",
|
||||
},
|
||||
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
|
||||
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
|
||||
"ynab.spending_report": {
|
||||
"start_date",
|
||||
"end_date",
|
||||
"group_by",
|
||||
"rows",
|
||||
"total",
|
||||
"total_basis",
|
||||
"inflows",
|
||||
"net_outflows",
|
||||
"filters",
|
||||
},
|
||||
"ynab.category_budget": {"month", "groups"},
|
||||
}
|
||||
|
||||
@@ -240,20 +250,31 @@ async def test_search_transactions_bad_date(monkeypatch):
|
||||
# --- ynab_spending_report ---------------------------------------------------
|
||||
|
||||
SPENDING_ROWS = [
|
||||
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
|
||||
{"key": "Groceries", "spent": Decimal("500.00"), "n": 10},
|
||||
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
|
||||
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
|
||||
]
|
||||
|
||||
|
||||
async def test_spending_report(monkeypatch):
|
||||
fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS})
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": SPENDING_ROWS,
|
||||
"AS outflows": [
|
||||
{"outflows": Decimal("710.50"), "inflows": Decimal("100.00")}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {"top": 2})
|
||||
|
||||
assert msg.content == (
|
||||
"Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n"
|
||||
"- Groceries: $400.00 (10 txns)\n"
|
||||
"Spending by category from 2026-10-01 to 2026-10-12:\n"
|
||||
"- Total spending (outflows): $710.50\n"
|
||||
"- Inflows (deposits into budget accounts, not spending): $100.00\n"
|
||||
"- Group amounts are gross outflows; refunds and other inflows are reported separately.\n"
|
||||
"- Groceries: $500.00 (10 txns)\n"
|
||||
"- Dining Out: $150.50 (5 txns)\n"
|
||||
"- 1 others: $60.00"
|
||||
)
|
||||
@@ -265,32 +286,53 @@ async def test_spending_report(monkeypatch):
|
||||
"end_date": "2026-10-12",
|
||||
"group_by": "category",
|
||||
"rows": [
|
||||
{"key": "Groceries", "amount": 400.0, "count": 10},
|
||||
{"key": "Groceries", "amount": 500.0, "count": 10},
|
||||
{"key": "Dining Out", "amount": 150.5, "count": 5},
|
||||
{"key": "Gas", "amount": 60.0, "count": 2},
|
||||
],
|
||||
"total": 610.5,
|
||||
"total": 710.5,
|
||||
"total_basis": "outflows",
|
||||
"inflows": 100.0,
|
||||
"net_outflows": 610.5,
|
||||
"filters": {"category": "", "payee": ""},
|
||||
}
|
||||
|
||||
|
||||
async def test_spending_report_by_month(monkeypatch):
|
||||
rows = [
|
||||
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
|
||||
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
|
||||
{"key": "2026-10", "spent": Decimal("300"), "n": 2},
|
||||
]
|
||||
fake_db(monkeypatch, {"GROUP BY 1": rows})
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": rows,
|
||||
"AS outflows": [
|
||||
{"outflows": Decimal("400.00"), "inflows": Decimal("100.00")}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
msg = await call(
|
||||
tools.ynab_spending_report,
|
||||
{"group_by": "month", "start_date": "2026-09-01"},
|
||||
)
|
||||
|
||||
assert msg.content.endswith("Monthly average: $150.00")
|
||||
assert msg.content.endswith(
|
||||
"Monthly average (outflows over 2 calendar months): $200.00"
|
||||
)
|
||||
assert msg.artifact["data"]["total"] == 400.0
|
||||
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
|
||||
|
||||
|
||||
async def test_spending_report_empty_and_invalid(monkeypatch):
|
||||
fake_db(monkeypatch, {"GROUP BY 1": []})
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [],
|
||||
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(0)}],
|
||||
},
|
||||
)
|
||||
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
|
||||
@@ -425,3 +467,86 @@ async def test_db_error(monkeypatch, tool):
|
||||
def test_text_only_tools_unchanged():
|
||||
assert tools.ynab_account_balances.response_format == "content"
|
||||
assert tools.ynab_recent_changes.response_format == "content"
|
||||
|
||||
|
||||
async def test_spending_report_omits_nonpositive_groups(monkeypatch):
|
||||
rows = SPENDING_ROWS + [
|
||||
{"key": "Uncategorized", "spent": Decimal("-5000"), "n": 1},
|
||||
{"key": "Fully refunded", "spent": Decimal("0"), "n": 2},
|
||||
]
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": rows,
|
||||
"AS outflows": [
|
||||
{"outflows": Decimal("710.50"), "inflows": Decimal("5100.00")}
|
||||
],
|
||||
},
|
||||
)
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert "Total spending (outflows): $710.50" in msg.content
|
||||
assert "not spending): $5,100.00" in msg.content
|
||||
assert "Uncategorized" not in msg.content
|
||||
assert "Fully refunded" not in msg.content
|
||||
assert [r["key"] for r in msg.artifact["data"]["rows"]] == [
|
||||
r["key"] for r in SPENDING_ROWS
|
||||
]
|
||||
assert msg.artifact["data"]["total"] == 710.5
|
||||
|
||||
|
||||
async def test_spending_report_fully_refunded_purchase_is_not_empty(monkeypatch):
|
||||
queries = fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "Shopping", "spent": Decimal(100), "n": 2}],
|
||||
"AS outflows": [{"outflows": Decimal(100), "inflows": Decimal(100)}],
|
||||
},
|
||||
)
|
||||
msg = await call(tools.ynab_spending_report, {"category": "Shopping"})
|
||||
assert msg.artifact["data"]["total"] == 100
|
||||
assert msg.artifact["data"]["net_outflows"] == 0
|
||||
assert msg.artifact["data"]["rows"][0]["amount"] == 100
|
||||
assert "-SUM(LEAST(l.amount, 0)) AS spent" in queries[0][0]
|
||||
assert all("l.category_name ILIKE $3" in sql for sql, _ in queries)
|
||||
assert msg.artifact["data"]["filters"] == {"category": "Shopping", "payee": ""}
|
||||
|
||||
|
||||
async def test_spending_report_inflow_only_period_retains_totals(monkeypatch):
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "Refund", "spent": Decimal(0), "n": 1}],
|
||||
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(75)}],
|
||||
},
|
||||
)
|
||||
msg = await call(tools.ynab_spending_report, {})
|
||||
assert msg.artifact["data"]["rows"] == []
|
||||
assert msg.artifact["data"]["total"] == 0
|
||||
assert msg.artifact["data"]["inflows"] == 75
|
||||
assert "not spending): $75.00" in msg.content
|
||||
|
||||
|
||||
async def test_monthly_average_includes_zero_spending_months(monkeypatch):
|
||||
fake_db(
|
||||
monkeypatch,
|
||||
{
|
||||
"GROUP BY 1": [{"key": "2026-10", "spent": Decimal(300), "n": 1}],
|
||||
"AS outflows": [{"outflows": Decimal(300), "inflows": Decimal(0)}],
|
||||
},
|
||||
)
|
||||
msg = await call(
|
||||
tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-08-01"}
|
||||
)
|
||||
assert msg.content.endswith(
|
||||
"Monthly average (outflows over 3 calendar months): $100.00"
|
||||
)
|
||||
|
||||
|
||||
async def test_spending_report_reversed_dates_are_rejected(monkeypatch):
|
||||
queries = fake_db(monkeypatch, {})
|
||||
msg = await call(
|
||||
tools.ynab_spending_report,
|
||||
{"start_date": "2026-11-01", "end_date": "2026-10-01"},
|
||||
)
|
||||
assert msg.content == "start_date must be on or before end_date."
|
||||
assert queries == []
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Text extraction for files attached to a conversation (PDF, txt, md, csv)."""
|
||||
|
||||
import os
|
||||
|
||||
import fitz
|
||||
|
||||
# Extension -> canonical MIME type for the text-like files we accept.
|
||||
FILE_TYPES = {
|
||||
"pdf": "application/pdf",
|
||||
"txt": "text/plain",
|
||||
"md": "text/markdown",
|
||||
"csv": "text/csv",
|
||||
}
|
||||
_MIME_TO_EXT = {mime: ext for ext, mime in FILE_TYPES.items()}
|
||||
_MIME_TO_EXT["text/x-markdown"] = "md"
|
||||
|
||||
MAX_ATTACHMENT_CHARS = 12_000
|
||||
TRUNCATION_MARKER = "[truncated]"
|
||||
|
||||
|
||||
class FileExtractionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def file_extension(filename: str | None, content_type: str | None) -> str | None:
|
||||
"""Return the supported extension for an upload, or None if unsupported.
|
||||
|
||||
Matches on the MIME type first, then falls back to the filename extension
|
||||
(browsers often send .md and .csv as application/octet-stream).
|
||||
"""
|
||||
mime = (content_type or "").split(";")[0].strip().lower()
|
||||
if mime in _MIME_TO_EXT:
|
||||
return _MIME_TO_EXT[mime]
|
||||
ext = os.path.splitext(filename or "")[1].lstrip(".").lower()
|
||||
if ext == "markdown":
|
||||
ext = "md"
|
||||
return ext if ext in FILE_TYPES else None
|
||||
|
||||
|
||||
def extract_text(data: bytes, ext: str) -> str:
|
||||
"""Extract plain text from file bytes. Text files decode as UTF-8 with replacement."""
|
||||
if ext == "pdf":
|
||||
try:
|
||||
with fitz.open(stream=data, filetype="pdf") as doc:
|
||||
return "\n".join(page.get_text() for page in doc).strip()
|
||||
except Exception as e:
|
||||
raise FileExtractionError(f"Could not read PDF: {e}") from e
|
||||
if ext in FILE_TYPES:
|
||||
return data.decode("utf-8", errors="replace").lstrip("")
|
||||
raise FileExtractionError(f"Unsupported file type: {ext}")
|
||||
|
||||
|
||||
def cap_text(text: str, limit: int = MAX_ATTACHMENT_CHARS) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}\n{TRUNCATION_MARKER}"
|
||||
|
||||
|
||||
def attachment_block(name: str, text: str) -> str:
|
||||
"""Wrap (capped) file text for the user turn sent to the agent."""
|
||||
safe_name = name.replace('"', "'")
|
||||
return f'<attached file "{safe_name}">\n{cap_text(text)}\n</attached>'
|
||||
@@ -61,3 +61,28 @@ async def delete_image(key: str) -> None:
|
||||
async with _get_client() as client:
|
||||
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
|
||||
logging.info(f"Deleted image from S3: {key}")
|
||||
|
||||
|
||||
async def upload_file(
|
||||
file_bytes: bytes, key: str, content_type: str, metadata: dict[str, str]
|
||||
) -> str:
|
||||
"""Store a non-image object. Metadata values must be ASCII (S3 headers)."""
|
||||
async with _get_client() as client:
|
||||
await client.put_object(
|
||||
Bucket=S3_BUCKET_NAME,
|
||||
Key=key,
|
||||
Body=file_bytes,
|
||||
ContentType=content_type,
|
||||
Metadata=metadata,
|
||||
)
|
||||
logging.info(f"Uploaded file to S3: {key}")
|
||||
return key
|
||||
|
||||
|
||||
async def get_file(key: str) -> tuple[bytes, dict[str, str]]:
|
||||
"""Return an object's bytes and its user metadata."""
|
||||
async with _get_client() as client:
|
||||
response = await client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
|
||||
body = await response["Body"].read()
|
||||
metadata = response.get("Metadata", {}) or {}
|
||||
return body, metadata
|
||||
|
||||
Reference in New Issue
Block a user