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:
@@ -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![
|
||||
|
||||
@@ -44,6 +44,11 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
||||
}
|
||||
"DATE" => try_get!(chrono::NaiveDate),
|
||||
"TIME" => try_get!(chrono::NaiveTime),
|
||||
"INTERVAL" => match row.try_get::<Option<sqlx::postgres::types::PgInterval>, _>(index) {
|
||||
Ok(Some(v)) => return Value::String(format_pg_interval(&v)),
|
||||
Ok(None) => return Value::Null,
|
||||
Err(_) => {}
|
||||
},
|
||||
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||
Ok(None) => return Value::Null,
|
||||
@@ -76,6 +81,44 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a PostgreSQL INTERVAL the same way `psql` does:
|
||||
/// "1 year 2 mons 3 days 04:05:06.789012"
|
||||
/// Components are emitted only when non-zero; the time component fires
|
||||
/// whenever microseconds != 0 OR everything else is zero.
|
||||
fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String {
|
||||
let years = iv.months / 12;
|
||||
let months = iv.months % 12;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if years != 0 {
|
||||
parts.push(format!("{} year{}", years, if years.abs() == 1 { "" } else { "s" }));
|
||||
}
|
||||
if months != 0 {
|
||||
parts.push(format!("{} mon{}", months, if months.abs() == 1 { "" } else { "s" }));
|
||||
}
|
||||
if iv.days != 0 {
|
||||
parts.push(format!("{} day{}", iv.days, if iv.days.abs() == 1 { "" } else { "s" }));
|
||||
}
|
||||
if iv.microseconds != 0 || parts.is_empty() {
|
||||
let total_us = iv.microseconds.unsigned_abs();
|
||||
let total_seconds = total_us / 1_000_000;
|
||||
let micros = (total_us % 1_000_000) as u32;
|
||||
let h = total_seconds / 3600;
|
||||
let m = (total_seconds / 60) % 60;
|
||||
let s = total_seconds % 60;
|
||||
let sign = if iv.microseconds < 0 { "-" } else { "" };
|
||||
let time_part = if micros == 0 {
|
||||
format!("{}{:02}:{:02}:{:02}", sign, h, m, s)
|
||||
} else {
|
||||
format!(
|
||||
"{}{:02}:{:02}:{:02}.{:06}",
|
||||
sign, h, m, s, micros
|
||||
)
|
||||
};
|
||||
parts.push(time_part);
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub async fn execute_query_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
@@ -157,3 +200,57 @@ pub async fn execute_query(
|
||||
) -> TuskResult<QueryResult> {
|
||||
execute_query_core(&state, &connection_id, &sql).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_pg_interval;
|
||||
use sqlx::postgres::types::PgInterval;
|
||||
|
||||
#[test]
|
||||
fn interval_zero_renders_as_zero_time() {
|
||||
let iv = PgInterval { months: 0, days: 0, microseconds: 0 };
|
||||
assert_eq!(format_pg_interval(&iv), "00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_pure_time_micros() {
|
||||
// 1h 30m
|
||||
let iv = PgInterval { months: 0, days: 0, microseconds: 90 * 60 * 1_000_000 };
|
||||
assert_eq!(format_pg_interval(&iv), "01:30:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_days_only() {
|
||||
let iv = PgInterval { months: 0, days: 3, microseconds: 0 };
|
||||
assert_eq!(format_pg_interval(&iv), "3 days");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_one_day() {
|
||||
let iv = PgInterval { months: 0, days: 1, microseconds: 0 };
|
||||
assert_eq!(format_pg_interval(&iv), "1 day");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_mixed_components() {
|
||||
// 1 year 2 mons 3 days 04:05:06
|
||||
let iv = PgInterval {
|
||||
months: 14,
|
||||
days: 3,
|
||||
microseconds: ((4 * 3600) + (5 * 60) + 6) * 1_000_000,
|
||||
};
|
||||
assert_eq!(format_pg_interval(&iv), "1 year 2 mons 3 days 04:05:06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_negative_time() {
|
||||
let iv = PgInterval { months: 0, days: 0, microseconds: -3_600_000_000 };
|
||||
assert_eq!(format_pg_interval(&iv), "-01:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_with_microseconds_fraction() {
|
||||
let iv = PgInterval { months: 0, days: 0, microseconds: 1_500_000 };
|
||||
assert_eq!(format_pg_interval(&iv), "00:00:01.500000");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { chatCompact, chatSend } from "@/lib/tauri";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import type { ChatMessage } from "@/types";
|
||||
@@ -24,22 +25,40 @@ export function useChat(tabId: string, connectionId: string) {
|
||||
|
||||
const compact = useCallback(async (): Promise<boolean> => {
|
||||
const state = useAppStore.getState();
|
||||
if (state.chatPending[tabId]) return false;
|
||||
if (state.chatPending[tabId]) {
|
||||
toast.message("Wait for the agent to finish first.");
|
||||
return false;
|
||||
}
|
||||
const history = state.chatThreads[tabId] ?? [];
|
||||
if (history.length === 0) return false;
|
||||
if (history.length === 0) {
|
||||
toast.message("Nothing to compact yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const beforeCount = history.length;
|
||||
setChatPending(tabId, true);
|
||||
try {
|
||||
const turn = await chatCompact(connectionId, history);
|
||||
const afterCount = turn.messages.length;
|
||||
// Backend returns the same thread untouched when there's nothing older
|
||||
// than the last user turn; surface that instead of silently no-op.
|
||||
if (afterCount >= beforeCount) {
|
||||
toast.message("Nothing to compact (no older history beyond the last question).");
|
||||
return false;
|
||||
}
|
||||
replaceChatThread(tabId, turn.messages);
|
||||
setChatUsage(tabId, turn.usage);
|
||||
const removed = beforeCount - afterCount + 1; // +1: original older replaced by single summary
|
||||
toast.success(`Compacted ${removed} earlier message${removed === 1 ? "" : "s"}.`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const text = `Compact failed: ${String(err)}`;
|
||||
toast.error("Compact failed", { description: String(err) });
|
||||
appendChatMessages(tabId, [
|
||||
{
|
||||
id: newId("err"),
|
||||
role: "assistant",
|
||||
text: `Compact failed: ${String(err)}`,
|
||||
text,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user