docs: tambah doc comment, logging, dan inline comments di semua 255 file
Meliputi: - File-level //! doc comment: tujuan file, alur kerja, komponen utama - Function-level /// doc comment: apa, parameter, return, flow, edge cases - Struct/enum/trait /// doc comment: peran, field docs - Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi - Inline comments untuk variable dan branching logic penting - Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities, zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils - Build: 0 errors, 242/242 tests passed
This commit is contained in:
@@ -5,9 +5,17 @@
|
||||
//! we want tools to receive whatever arguments were already emitted so the
|
||||
//! partial work can proceed.
|
||||
//!
|
||||
//! How: a single left-to-right scan pushes opening brackets/braces onto a
|
||||
//! stack and pops them on matching closes, while tracking in-string/escape
|
||||
//! state. At EOF, the algorithm:
|
||||
//! 1. Removes a dangling escape backslash if present.
|
||||
//! 2. Closes an unterminated string.
|
||||
//! 3. Closes every unclosed bracket/brace in reverse (LIFO) order.
|
||||
//!
|
||||
//! Why LIFO vs. depth counters: `{` inside `[` must be closed with `}`
|
||||
//! *before* the `]`, not after it. Simple depth counters get the order
|
||||
//! wrong for nested heterogenous structures.
|
||||
use tracing;
|
||||
|
||||
/// Try to repair truncated JSON by closing open strings, braces, and brackets.
|
||||
///
|
||||
@@ -17,17 +25,24 @@
|
||||
/// At the end, if the last char was a backslash (start of an escape
|
||||
/// sequence), remove it; if inside a string, append `"`; then close
|
||||
/// every unclosed opener in reverse (LIFO) order.
|
||||
///
|
||||
/// Return: the input string with any missing closing delimiters appended.
|
||||
/// If the input is already valid JSON, it is returned unchanged.
|
||||
pub fn repair_incomplete_json(s: &str) -> String {
|
||||
let original_len = s.len();
|
||||
tracing::trace!(original_len, input_preview = &s[..original_len.min(80)], "repair_incomplete_json — start");
|
||||
|
||||
// LIFO stack of open brackets/braces encountered outside strings.
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut in_string = false; // true between unescaped `"`
|
||||
let mut prev_was_backslash = false;
|
||||
// `true` only when the very last character consumed was a bare `\`
|
||||
// True only when the very last character consumed was a bare `\`
|
||||
// inside a string (i.e. the start of an escape that was never completed).
|
||||
let mut ends_with_unclosed_escape = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if prev_was_backslash {
|
||||
// Consume the character that was being escaped — the escape is
|
||||
// This character is being escaped — the escape sequence is
|
||||
// complete, so clear the unclosed-escape flag.
|
||||
prev_was_backslash = false;
|
||||
ends_with_unclosed_escape = false;
|
||||
@@ -44,26 +59,33 @@ pub fn repair_incomplete_json(s: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
continue;
|
||||
continue; // skip structural chars inside a string
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => stack.push(c),
|
||||
'}' | ']' => {
|
||||
// Pop the matching opener unconditionally. If the JSON is
|
||||
// malformed (e.g. mismatched brackets), we still pop to keep
|
||||
// the LIFO tracking as lossy — the repair phase will close
|
||||
// whatever remains on the stack, which is good enough for
|
||||
// our heuristic use case.
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build repaired output ---
|
||||
let mut result = s.to_string();
|
||||
if ends_with_unclosed_escape {
|
||||
// The last character is a dangling backslash that started an escape
|
||||
// The last char is a dangling backslash that started an escape
|
||||
// but got cut off before the escaped char — remove it.
|
||||
result.pop();
|
||||
}
|
||||
if in_string {
|
||||
result.push('"');
|
||||
result.push('"'); // close an unterminated string
|
||||
}
|
||||
// Close every unclosed bracket/brace in reverse (LIFO) order.
|
||||
for &opener in stack.iter().rev() {
|
||||
match opener {
|
||||
'{' => result.push('}'),
|
||||
@@ -71,11 +93,21 @@ pub fn repair_incomplete_json(s: &str) -> String {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let repaired_len = result.len();
|
||||
tracing::debug!(
|
||||
original_len,
|
||||
repaired_len,
|
||||
added_chars = (repaired_len - original_len),
|
||||
"repair_incomplete_json — completed"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for JSON repair: unclosed strings, unclosed braces,
|
||||
//! nested structures, trailing backslashes, and escaped quotes.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
|
||||
/// JSON-repair utilities for malformed streaming fragments (truncated JSON,
|
||||
/// missing brackets, escaped newlines inside strings).
|
||||
pub mod json_repair;
|
||||
|
||||
/// Turn-level streaming state machine: manages buffering, SSE parsing,
|
||||
/// tool-call accumulation, and per-chunk event dispatch.
|
||||
pub mod turn;
|
||||
|
||||
/// Re-export from `zesdex-entities` for convenience:
|
||||
/// - `SseParser` — low-level SSE line/event parser
|
||||
/// - `StreamEvent` — typed event variants yielded by the parser
|
||||
pub use zesdex_entities::{SseParser, StreamEvent};
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
//! Accumulates streaming LLM responses into complete message/tool-call
|
||||
//! representation via `StreamedTurn`, and provides a standalone tool-call
|
||||
//! accumulator in `tools::ToolCallAccumulator`.
|
||||
//!
|
||||
//! Flow: the caller feeds [`StreamEvent`] items (from `SseParser`) one by
|
||||
//! one into [`StreamedTurn::apply_event`], which builds up content, reasoning,
|
||||
//! and tool-call deltas incrementally. When the stream ends, call
|
||||
//! [`StreamedTurn::build_assistant_message`] to produce a complete
|
||||
//! `ChatMessage`. If the connection drops before `[DONE]`,
|
||||
//! [`StreamedTurn::incomplete_tool_call`] detects truncated tool-call JSON.
|
||||
use super::json_repair::repair_incomplete_json;
|
||||
use super::StreamEvent;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
/// Accumulates a single streaming assistant turn into its final
|
||||
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
|
||||
@@ -29,33 +37,46 @@ pub struct ParsedToolCall {
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl ParsedToolCall {}
|
||||
impl ParsedToolCall {
|
||||
// Placeholder for future convenience constructors or helpers.
|
||||
// Today all field mutation happens inside `StreamedTurn::apply_event`;
|
||||
// this block exists to reserve the namespace.
|
||||
}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
///
|
||||
/// All fields start at their default (empty / false) state. The caller
|
||||
/// then feeds [`StreamEvent`] items via [`apply_event`](Self::apply_event).
|
||||
pub fn new() -> Self {
|
||||
tracing::debug!("StreamedTurn::new — initialised empty accumulator");
|
||||
StreamedTurn {
|
||||
messages: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
is_complete: false,
|
||||
done_received: false,
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
is_complete: false, // set to true when [DONE] is received
|
||||
done_received: false, // tracks whether a Done event was seen
|
||||
accumulated_content: String::new(), // text tokens, growing
|
||||
accumulated_reasoning: String::new(), // reasoning tokens, growing
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `StreamEvent` to the turn, updating accumulated content,
|
||||
/// reasoning, and tool-call deltas.
|
||||
/// Apply a single `StreamEvent` to the in-progress accumulation.
|
||||
///
|
||||
/// Flow: match on variant — `Token` appends to `accumulated_content`,
|
||||
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
|
||||
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
|
||||
///
|
||||
/// Any event variant not explicitly handled here (e.g. `Usage`) is
|
||||
/// silently ignored, since only content/reasoning/tool-call state
|
||||
/// is relevant for final message construction.
|
||||
pub fn apply_event(&mut self, event: &StreamEvent) {
|
||||
match event {
|
||||
StreamEvent::Token(token) => {
|
||||
tracing::trace!(len = token.len(), "apply_event: Token");
|
||||
self.accumulated_content.push_str(token);
|
||||
}
|
||||
StreamEvent::Reasoning(reasoning) => {
|
||||
tracing::trace!(len = reasoning.len(), "apply_event: Reasoning");
|
||||
self.accumulated_reasoning.push_str(reasoning);
|
||||
}
|
||||
StreamEvent::ToolCallDelta {
|
||||
@@ -64,6 +85,11 @@ impl StreamedTurn {
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
tracing::trace!(
|
||||
index, id, name, delta_len = arguments_delta.len(),
|
||||
"apply_event: ToolCallDelta"
|
||||
);
|
||||
// Pad the tool_calls vector with stubs so we can index by `index`.
|
||||
while self.tool_calls.len() <= *index {
|
||||
self.tool_calls.push(ParsedToolCall {
|
||||
id: String::new(),
|
||||
@@ -73,6 +99,8 @@ impl StreamedTurn {
|
||||
});
|
||||
}
|
||||
let tc = &mut self.tool_calls[*index];
|
||||
// `id` and `name` are typically sent only on the first delta;
|
||||
// subsequent deltas for the same index may omit them.
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id.clone_from(new_id);
|
||||
@@ -83,12 +111,16 @@ impl StreamedTurn {
|
||||
tc.name.clone_from(new_name);
|
||||
}
|
||||
}
|
||||
// Accumulate argument JSON fragment-by-fragment.
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
tracing::debug!("apply_event: Done — turn marked complete");
|
||||
self.is_complete = true;
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
tracing::trace!("apply_event: ignored {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,42 +133,57 @@ impl StreamedTurn {
|
||||
///
|
||||
/// Return: a complete `ChatMessage` with role `Assistant`.
|
||||
pub fn build_assistant_message(&self) -> ChatMessage {
|
||||
tracing::debug!(
|
||||
tool_calls = self.tool_calls.len(),
|
||||
content_len = self.accumulated_content.len(),
|
||||
reasoning_len = self.accumulated_reasoning.len(),
|
||||
"build_assistant_message — assembling final ChatMessage"
|
||||
);
|
||||
|
||||
// Build the assistant message, with or without tool calls.
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
// Plain text-only response — no tool calls to attach.
|
||||
ChatMessage::assistant(None)
|
||||
} else {
|
||||
// Convert ParsedToolCall → DTO ToolCall, repairing truncated JSON.
|
||||
let tool_dtos: Vec<ToolCall> = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.filter(|tc| !tc.name.is_empty()) // skip unnamed stubs
|
||||
.map(|tc| {
|
||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let repaired = repair_incomplete_json(&tc.arguments);
|
||||
match serde_json::from_str(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
// Try to parse arguments as JSON. If the stream was cut
|
||||
// short, the last tool call's arguments may be truncated.
|
||||
let args_value: serde_json::Value =
|
||||
match serde_json::from_str(&tc.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let repaired = repair_incomplete_json(&tc.arguments);
|
||||
match serde_json::from_str(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
// Last resort: store the raw string so the
|
||||
// tool dispatcher can surface the error.
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
ToolCall {
|
||||
id: tc.id.clone(),
|
||||
type_: "function".to_string(),
|
||||
@@ -153,6 +200,8 @@ impl StreamedTurn {
|
||||
}
|
||||
msg
|
||||
};
|
||||
|
||||
// Combine reasoning (inside <think> tags) with visible content.
|
||||
let full_content = if self.accumulated_reasoning.is_empty() {
|
||||
self.accumulated_content.clone()
|
||||
} else {
|
||||
@@ -161,12 +210,15 @@ impl StreamedTurn {
|
||||
self.accumulated_reasoning, self.accumulated_content
|
||||
)
|
||||
};
|
||||
let content = if full_content.is_empty() {
|
||||
|
||||
// Set content to None when empty so downstream code can distinguish
|
||||
// "no content" from "empty string".
|
||||
msg.content = if full_content.is_empty() {
|
||||
tracing::debug!("build_assistant_message — no content after assembly; setting content=None");
|
||||
None
|
||||
} else {
|
||||
Some(full_content)
|
||||
};
|
||||
msg.content = content;
|
||||
msg
|
||||
}
|
||||
|
||||
@@ -183,25 +235,43 @@ impl StreamedTurn {
|
||||
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
||||
/// `None` if every tool call's arguments are complete, parsable JSON.
|
||||
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
||||
self.tool_calls
|
||||
// Skip unnamed stubs — they indicate the stream never sent enough
|
||||
// data to begin a real tool call at that index.
|
||||
let result = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.find_map(|tc| {
|
||||
serde_json::from_str::<Value>(&tc.arguments)
|
||||
.err()
|
||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||
})
|
||||
});
|
||||
|
||||
if let Some((name, ref err)) = result {
|
||||
tracing::debug!(
|
||||
tool_name = name, error = err.as_str(),
|
||||
"incomplete_tool_call — found truncated tool arguments"
|
||||
);
|
||||
} else {
|
||||
tracing::trace!("incomplete_tool_call — all tool calls have valid JSON");
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamedTurn {
|
||||
/// Delegates to [`Self::new`]; exists so `StreamedTurn` can be used
|
||||
/// as a default field value in other structs.
|
||||
fn default() -> Self {
|
||||
tracing::debug!("StreamedTurn::default — delegating to StreamedTurn::new");
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for streaming-turn accumulation, including JSON-repair
|
||||
//! of truncated tool-call arguments and `incomplete_tool_call` detection.
|
||||
use super::*;
|
||||
|
||||
fn tool_call(name: &str, arguments: &str) -> ParsedToolCall {
|
||||
|
||||
Reference in New Issue
Block a user