Просмотр исходного кода

feat(core): add structured logger, streaming heartbeat, and partial response recovery

- New logger module (utils/logger.ts) with StderrSink (ANSI colors),
  JsonLineSink, nullSink, and child() context inheritance
- StreamProgress/OnStreamProgress types + createStreamMonitor() factory
  with 30s interval heartbeat and CJK char counting
- All 3 streaming functions (OpenAI Chat, OpenAI Responses, Anthropic)
  wired with monitor via try/finally
- PartialResponseError: stream interruption with >=500 chars received
  returns truncated response instead of throwing
- AgentContext gains logger? and onStreamProgress?, BaseAgent.chat()
  forwards onStreamProgress to chatCompletion()
Ma 7 месяцев назад
Родитель
Сommit
340a57e82d

+ 9 - 2
packages/core/src/agents/base.ts

@@ -1,11 +1,14 @@
-import type { LLMClient, LLMMessage, LLMResponse } from "../llm/provider.js";
+import type { LLMClient, LLMMessage, LLMResponse, OnStreamProgress } from "../llm/provider.js";
 import { chatCompletion } from "../llm/provider.js";
+import type { Logger } from "../utils/logger.js";
 
 export interface AgentContext {
   readonly client: LLMClient;
   readonly model: string;
   readonly projectRoot: string;
   readonly bookId?: string;
+  readonly logger?: Logger;
+  readonly onStreamProgress?: OnStreamProgress;
 }
 
 export abstract class BaseAgent {
@@ -19,7 +22,10 @@ export abstract class BaseAgent {
     messages: ReadonlyArray<LLMMessage>,
     options?: { readonly temperature?: number; readonly maxTokens?: number },
   ): Promise<LLMResponse> {
-    return chatCompletion(this.ctx.client, this.ctx.model, messages, options);
+    return chatCompletion(this.ctx.client, this.ctx.model, messages, {
+      ...options,
+      onStreamProgress: this.ctx.onStreamProgress,
+    });
   }
 
   /**
@@ -35,6 +41,7 @@ export abstract class BaseAgent {
     return chatCompletion(this.ctx.client, this.ctx.model, messages, {
       ...options,
       webSearch: true,
+      onStreamProgress: this.ctx.onStreamProgress,
     });
   }
 

+ 2 - 1
packages/core/src/index.ts

@@ -9,7 +9,7 @@ export { type DetectionHistoryEntry, type DetectionStats } from "./models/detect
 export { type StyleProfile } from "./models/style-profile.js";
 
 // LLM
-export { createLLMClient, chatCompletion, chatWithTools, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult } from "./llm/provider.js";
+export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
 
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";
@@ -35,6 +35,7 @@ export { parseSettlementOutput, type SettlementOutput } from "./agents/settler-p
 // Utils
 export { fetchUrl } from "./utils/web-search.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
+export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";

+ 141 - 23
packages/core/src/llm/provider.ts

@@ -2,6 +2,57 @@ import OpenAI from "openai";
 import Anthropic from "@anthropic-ai/sdk";
 import type { LLMConfig } from "../models/project.js";
 
+// === Streaming Monitor Types ===
+
+export interface StreamProgress {
+  readonly elapsedMs: number;
+  readonly totalChars: number;
+  readonly chineseChars: number;
+  readonly status: "streaming" | "done";
+}
+
+export type OnStreamProgress = (progress: StreamProgress) => void;
+
+export function createStreamMonitor(
+  onProgress?: OnStreamProgress,
+  intervalMs: number = 30000,
+): { readonly onChunk: (text: string) => void; readonly stop: () => void } {
+  let totalChars = 0;
+  let chineseChars = 0;
+  const startTime = Date.now();
+  let timer: ReturnType<typeof setInterval> | undefined;
+
+  if (onProgress) {
+    timer = setInterval(() => {
+      onProgress({
+        elapsedMs: Date.now() - startTime,
+        totalChars,
+        chineseChars,
+        status: "streaming",
+      });
+    }, intervalMs);
+  }
+
+  return {
+    onChunk(text: string): void {
+      totalChars += text.length;
+      chineseChars += (text.match(/[\u4e00-\u9fff]/g) || []).length;
+    },
+    stop(): void {
+      if (timer !== undefined) {
+        clearInterval(timer);
+        timer = undefined;
+      }
+      onProgress?.({
+        elapsedMs: Date.now() - startTime,
+        totalChars,
+        chineseChars,
+        status: "done",
+      });
+    },
+  };
+}
+
 // === Shared Types ===
 
 export interface LLMResponse {
@@ -89,6 +140,20 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   };
 }
 
+// === Partial Response (stream interrupted but usable content received) ===
+
+export class PartialResponseError extends Error {
+  readonly partialContent: string;
+  constructor(partialContent: string, cause: unknown) {
+    super(`Stream interrupted after ${partialContent.length} chars: ${String(cause)}`);
+    this.name = "PartialResponseError";
+    this.partialContent = partialContent;
+  }
+}
+
+/** Minimum chars to consider a partial response salvageable (Chinese ~2 chars/word → 500 chars ≈ 250 words) */
+const MIN_SALVAGEABLE_CHARS = 500;
+
 // === Error Wrapping ===
 
 function wrapLLMError(error: unknown): Error {
@@ -125,6 +190,7 @@ export async function chatCompletion(
     readonly temperature?: number;
     readonly maxTokens?: number;
     readonly webSearch?: boolean;
+    readonly onStreamProgress?: OnStreamProgress;
   },
 ): Promise<LLMResponse> {
   try {
@@ -132,20 +198,28 @@ export async function chatCompletion(
       temperature: options?.temperature ?? client.defaults.temperature,
       maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     };
+    const onStreamProgress = options?.onStreamProgress;
     if (client.provider === "anthropic") {
       return client.stream
-        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget)
+        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress)
         : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
     }
     if (client.apiFormat === "responses") {
       return client.stream
-        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch)
+        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
         : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
     }
     return client.stream
-      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch)
+      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
       : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
   } catch (error) {
+    // Stream interrupted but partial content is usable — return truncated response
+    if (error instanceof PartialResponseError) {
+      return {
+        content: error.partialContent,
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      };
+    }
     throw wrapLLMError(error);
   }
 }
@@ -188,6 +262,7 @@ async function chatCompletionOpenAIChat(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   webSearch?: boolean,
+  onStreamProgress?: OnStreamProgress,
 ): Promise<LLMResponse> {
   const stream = await client.chat.completions.create({
     model,
@@ -204,14 +279,29 @@ async function chatCompletionOpenAIChat(
   const chunks: string[] = [];
   let inputTokens = 0;
   let outputTokens = 0;
+  const monitor = createStreamMonitor(onStreamProgress);
 
-  for await (const chunk of stream) {
-    const delta = chunk.choices[0]?.delta?.content;
-    if (delta) chunks.push(delta);
-    if (chunk.usage) {
-      inputTokens = chunk.usage.prompt_tokens ?? 0;
-      outputTokens = chunk.usage.completion_tokens ?? 0;
+  try {
+    for await (const chunk of stream) {
+      const delta = chunk.choices[0]?.delta?.content;
+      if (delta) {
+        chunks.push(delta);
+        monitor.onChunk(delta);
+      }
+      if (chunk.usage) {
+        inputTokens = chunk.usage.prompt_tokens ?? 0;
+        outputTokens = chunk.usage.completion_tokens ?? 0;
+      }
+    }
+  } catch (streamError) {
+    monitor.stop();
+    const partial = chunks.join("");
+    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
+      throw new PartialResponseError(partial, streamError);
     }
+    throw streamError;
+  } finally {
+    monitor.stop();
   }
 
   const content = chunks.join("");
@@ -356,6 +446,7 @@ async function chatCompletionOpenAIResponses(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   webSearch?: boolean,
+  onStreamProgress?: OnStreamProgress,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
@@ -378,15 +469,28 @@ async function chatCompletionOpenAIResponses(
   const chunks: string[] = [];
   let inputTokens = 0;
   let outputTokens = 0;
+  const monitor = createStreamMonitor(onStreamProgress);
 
-  for await (const event of stream) {
-    if (event.type === "response.output_text.delta") {
-      chunks.push(event.delta);
+  try {
+    for await (const event of stream) {
+      if (event.type === "response.output_text.delta") {
+        chunks.push(event.delta);
+        monitor.onChunk(event.delta);
+      }
+      if (event.type === "response.completed") {
+        inputTokens = event.response.usage?.input_tokens ?? 0;
+        outputTokens = event.response.usage?.output_tokens ?? 0;
+      }
     }
-    if (event.type === "response.completed") {
-      inputTokens = event.response.usage?.input_tokens ?? 0;
-      outputTokens = event.response.usage?.output_tokens ?? 0;
+  } catch (streamError) {
+    monitor.stop();
+    const partial = chunks.join("");
+    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
+      throw new PartialResponseError(partial, streamError);
     }
+    throw streamError;
+  } finally {
+    monitor.stop();
   }
 
   const content = chunks.join("");
@@ -535,6 +639,7 @@ async function chatCompletionAnthropic(
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
   thinkingBudget: number = 0,
+  onStreamProgress?: OnStreamProgress,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -559,17 +664,30 @@ async function chatCompletionAnthropic(
   const chunks: string[] = [];
   let inputTokens = 0;
   let outputTokens = 0;
+  const monitor = createStreamMonitor(onStreamProgress);
 
-  for await (const event of stream) {
-    if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
-      chunks.push(event.delta.text);
-    }
-    if (event.type === "message_start") {
-      inputTokens = event.message.usage?.input_tokens ?? 0;
+  try {
+    for await (const event of stream) {
+      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
+        chunks.push(event.delta.text);
+        monitor.onChunk(event.delta.text);
+      }
+      if (event.type === "message_start") {
+        inputTokens = event.message.usage?.input_tokens ?? 0;
+      }
+      if (event.type === "message_delta") {
+        outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
+      }
     }
-    if (event.type === "message_delta") {
-      outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
+  } catch (streamError) {
+    monitor.stop();
+    const partial = chunks.join("");
+    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
+      throw new PartialResponseError(partial, streamError);
     }
+    throw streamError;
+  } finally {
+    monitor.stop();
   }
 
   const content = chunks.join("");

+ 123 - 0
packages/core/src/utils/logger.ts

@@ -0,0 +1,123 @@
+// === Types ===
+
+export type LogLevel = "debug" | "info" | "warn" | "error";
+
+export interface LogEntry {
+  readonly level: LogLevel;
+  readonly tag: string;
+  readonly message: string;
+  readonly timestamp: string;
+  readonly ctx?: Record<string, unknown>;
+}
+
+export interface LogSink {
+  readonly write: (entry: LogEntry) => void;
+}
+
+export interface Logger {
+  readonly debug: (msg: string, ctx?: Record<string, unknown>) => void;
+  readonly info: (msg: string, ctx?: Record<string, unknown>) => void;
+  readonly warn: (msg: string, ctx?: Record<string, unknown>) => void;
+  readonly error: (msg: string, ctx?: Record<string, unknown>) => void;
+  readonly child: (tag: string, extraCtx?: Record<string, unknown>) => Logger;
+}
+
+// === Level Ordering ===
+
+const LEVEL_ORDER: Record<LogLevel, number> = {
+  debug: 0,
+  info: 1,
+  warn: 2,
+  error: 3,
+};
+
+// === ANSI Colors ===
+
+const COLORS: Record<LogLevel, string> = {
+  debug: "\x1b[90m", // gray
+  info: "\x1b[36m",  // cyan
+  warn: "\x1b[33m",  // yellow
+  error: "\x1b[31m", // red
+};
+const RESET = "\x1b[0m";
+
+// === Built-in Sinks ===
+
+export function createStderrSink(options: {
+  readonly minLevel?: LogLevel;
+  readonly enableColors?: boolean;
+}): LogSink {
+  const minLevel = options.minLevel ?? "info";
+  const enableColors = options.enableColors ?? (process.stderr.isTTY ?? false);
+  const minOrder = LEVEL_ORDER[minLevel];
+
+  return {
+    write(entry: LogEntry): void {
+      if (LEVEL_ORDER[entry.level] < minOrder) return;
+
+      const levelTag = entry.level.toUpperCase().padEnd(5);
+      const prefix = `[${entry.tag}]`;
+
+      if (enableColors) {
+        const color = COLORS[entry.level];
+        process.stderr.write(
+          `${color}${levelTag}${RESET} ${prefix} ${entry.message}\n`,
+        );
+      } else {
+        process.stderr.write(`${levelTag} ${prefix} ${entry.message}\n`);
+      }
+    },
+  };
+}
+
+export function createJsonLineSink(writable: NodeJS.WritableStream): LogSink {
+  return {
+    write(entry: LogEntry): void {
+      writable.write(JSON.stringify(entry) + "\n");
+    },
+  };
+}
+
+export const nullSink: LogSink = {
+  write(): void {},
+};
+
+// === Factory ===
+
+export function createLogger(options: {
+  readonly tag: string;
+  readonly sinks: ReadonlyArray<LogSink>;
+  readonly minLevel?: LogLevel;
+  readonly baseCtx?: Record<string, unknown>;
+}): Logger {
+  const { tag, sinks, baseCtx } = options;
+
+  function emit(level: LogLevel, msg: string, ctx?: Record<string, unknown>): void {
+    const entry: LogEntry = {
+      level,
+      tag,
+      message: msg,
+      timestamp: new Date().toISOString(),
+      ...(ctx || baseCtx
+        ? { ctx: { ...baseCtx, ...ctx } }
+        : {}),
+    };
+    for (const sink of sinks) {
+      sink.write(entry);
+    }
+  }
+
+  return {
+    debug: (msg, ctx) => emit("debug", msg, ctx),
+    info: (msg, ctx) => emit("info", msg, ctx),
+    warn: (msg, ctx) => emit("warn", msg, ctx),
+    error: (msg, ctx) => emit("error", msg, ctx),
+    child(childTag, extraCtx) {
+      return createLogger({
+        tag: childTag,
+        sinks,
+        baseCtx: { ...baseCtx, ...extraCtx },
+      });
+    },
+  };
+}