fix: handle PG INTERVAL type, robust compact LLM output + feedback

INTERVAL handling
- pg_value_to_json now decodes PG INTERVAL via PgInterval and renders
  it psql-style: `1 year 2 mons 3 days 04:05:06`. Previously
  AVG(timestamp - timestamp) and similar interval-returning queries
  showed `<unsupported type: INTERVAL>` in chat results.
- 7 unit tests covering zero, days-only, mixed, negative, microsecond
  fraction, and the singular/plural unit rules.

Compact reliability
- Sharper system prompt: explicitly instructs plain text starting with
  `-`, no JSON, no fences, no field names. qwen3-coder is heavily
  trained on the agent JSON protocol and was sometimes returning
  `{"action":"final","text":"..."}` even for the compact prompt.
- New clean_summary helper strips ``` fences (with or without lang
  identifier) and extracts the underlying string from a JSON envelope
  if the model still wraps the answer (looks for text/summary/content/
  answer/output keys). 6 unit tests.
- Frontend useChat.compact: success/no-op/error toasts via sonner so
  the user sees what happened. "Nothing to compact" appears when there
  is no older history beyond the last user turn (previously silent).

Verification: cargo test --lib 66 pass (+13), tsc clean, vitest 20
pass.
This commit is contained in:
2026-05-06 20:01:50 +03:00
parent 27fed0dbf8
commit 83f204816a
3 changed files with 196 additions and 6 deletions

View File

@@ -748,6 +748,40 @@ fn render_thread_for_summary(messages: &[ChatMessage]) -> String {
out
}
/// Strip JSON envelopes, markdown fences, and known field-extraction patterns
/// that the agent-trained model tends to emit even for non-agent prompts.
/// Returns the underlying summary text.
fn clean_summary(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return trimmed.to_string();
}
// Strip ```...``` fences (with or without lang).
let unfenced = if trimmed.starts_with("```") {
let body = trimmed.trim_start_matches('`').trim_start_matches('`').trim_start_matches('`');
// Drop optional language identifier on the first line.
let after_lang = body.split_once('\n').map(|(_, rest)| rest).unwrap_or(body);
let trimmed_end = after_lang.trim_end_matches('`').trim_end_matches('`').trim_end_matches('`');
trimmed_end.trim().to_string()
} else {
trimmed.to_string()
};
// If the model returned a JSON envelope, extract a known string field.
if unfenced.starts_with('{') {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&unfenced) {
for key in ["text", "summary", "content", "answer", "output"] {
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
return s.trim().to_string();
}
}
}
}
unfenced
}
/// Find the index of the last User message; returns messages.len() if no user message.
fn last_user_turn_index(messages: &[ChatMessage]) -> usize {
for (i, m) in messages.iter().enumerate().rev() {
@@ -793,8 +827,12 @@ pub async fn chat_compact(
Produce a SHORT summary in the SAME language the user spoke. \
Use 3-6 bullet points covering: the user's goal, key tables/columns/queries used, \
numerical findings, conclusions reached, any open questions. \
Be concrete with numbers and identifiers. Total length < 800 chars. \
Output the bullets directly with no preamble, no JSON, no markdown fences.";
Be concrete with numbers and identifiers. Total length < 800 chars.\n\
\n\
OUTPUT FORMAT: PLAIN TEXT. Start each bullet with `- `. \
DO NOT output JSON. DO NOT wrap output in `{` or `}`. \
DO NOT use markdown fences. DO NOT include field names like `action`, `text`, `summary`. \
DO NOT add a preamble. The first character of your reply must be `-`.";
let llm_messages = vec![
OllamaChatMessage {
@@ -810,7 +848,7 @@ pub async fn chat_compact(
.await
.map_err(|e| TuskError::Ai(format!("Compact failed: {}", e)))?;
let cleaned = summary.trim();
let cleaned = clean_summary(&summary);
let compacted_msg = ChatMessage::Assistant {
id: new_id("asst"),
text: format!(
@@ -1033,6 +1071,42 @@ mod tests {
assert_eq!(last_user_turn_index(&msgs), msgs.len());
}
#[test]
fn clean_summary_passes_plain_text() {
let s = "- bullet one\n- bullet two";
assert_eq!(clean_summary(s), s);
}
#[test]
fn clean_summary_strips_markdown_fences() {
let s = "```\n- bullet\n```";
assert_eq!(clean_summary(s), "- bullet");
}
#[test]
fn clean_summary_strips_lang_fence() {
let s = "```text\n- bullet one\n- bullet two\n```";
assert_eq!(clean_summary(s), "- bullet one\n- bullet two");
}
#[test]
fn clean_summary_extracts_text_field_from_json_envelope() {
let s = r#"{"action":"final","text":"- bullet one\n- bullet two"}"#;
assert_eq!(clean_summary(s), "- bullet one\n- bullet two");
}
#[test]
fn clean_summary_extracts_summary_field() {
let s = r#"{"summary":"- a\n- b"}"#;
assert_eq!(clean_summary(s), "- a\n- b");
}
#[test]
fn clean_summary_returns_unchanged_for_unrecognised_json() {
let s = r#"{"weird":42}"#;
assert_eq!(clean_summary(s), s);
}
#[test]
fn render_thread_for_summary_includes_roles_and_skips_rows() {
let msgs = vec![