Ctrl K
ComunidadGuías…

src/response-classifier.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Response-content classifier (Thinking-core item 2, applied in item 1).
//
// Purpose: decide which parts of a streamed answer are *reasoning* vs *actionable
// text*, so misrouted content can be corrected at the egress and self-reinforcing
// loops can be broken.
//
// Degradation addressed: the model sometimes emits its reasoning through the CONTENT
// channel wrapped in its native markers. The client stores that as visible assistant
// text and resends it next turn, re-priming more reasoning-as-text — the loop.
//
// This module detects the markers in the content stream and lets the egress reroute
// the marked spans to the thinking channel (which clients do not resend), breaking the
// loop. Scope: a LEADING reasoning block only (marker at the very start, optional
// whitespace before it). No synthetic signatures, no router reliance, no guesses about
// unmarked or mid-answer content. Handles markers split across stream deltas.

const THINK_OPEN = '<' + 'think' + '>';
const THINK_CLOSE = '<' + '/' + 'think' + '>';

const MAX_PENDING = 32000; // hold ceiling for an unterminated think span
const MAX_LEAD = 8192;     // undecided-hold ceiling before committing to text


export class ThinkTextClassifier {
  constructor() {
    this.pending = '';
    this.mode = 'undecided'; // undecided | text | think
  }

  // Feed a content delta; returns { text, thinking } — slices to emit on each channel
  // right now ('' when nothing is due).
  //
  // think/undecided share one explicit loop so a chain of blocks inside a single delta
  // is processed iteratively. The original mutual recursion (_feedThink -> _feedUndecided
  // -> _feedThink -> ...) grew one stack frame per stacked block and threw a RangeError
  // at a few thousand of them; MAX_PENDING does not bound that, since it only caps a
  // single unterminated span, not a chain of closed ones.
  feed(delta) {
    if (!delta) return { text: '', thinking: '' };
    if (this.mode === 'text') return { text: delta, thinking: '' };

    let rest = delta;
    let text = '';
    let thinking = '';

    while (true) {
      // Each helper scans `this.pending`, so the remainder of a closed block (or the
      // original delta) is folded in at the top of every iteration.
      this.pending += rest;
      rest = '';

      if (this.mode === 'think') {
        const s = this._scanThink();
        if (s.text !== undefined) return { text: s.text, thinking }; // MAX_PENDING dump
        if (s.hold) return { text, thinking };  // buffer until the close marker proves it
        thinking += s.span;                     // closed block -> reroute its reasoning
        rest = s.rest;                          // re-scan the remainder this same delta
        continue;
      }

      const u = this._scanUndecided();
      if (u.toThink) continue;                  // leading marker -> think mode, same delta
      if (u.text !== undefined) {
        return { text: text + u.text, thinking }; // committed as text (inline / overflow)
      }
      return { text, thinking };                // hold: may still grow into a marker
    }
  }

  // Scan an undecided buffer for a decision. Returns either a completed result (the
  // caller returns it), `{ toThink: true }` to hand the remainder after a leading
  // marker to think mode, or `{ toThink: false }` to hold. Never recurses.
  _scanUndecided() {
    const oi = this.pending.indexOf(THINK_OPEN);
    if (oi >= 0) {
      if (this.pending.slice(0, oi).trim() === '') {
        // Leading marker -> enter think mode; drop whitespace-only prefix.
        this.mode = 'think';
        this.pending = this.pending.slice(oi + THINK_OPEN.length);
        return { toThink: true }; // process the remainder of this same delta
      }
      // Marker present but real text precedes it -> inline, not a leak.
      return this._commitText();
    }

    // Enough held without a decision -> it is plain text; commit. This check
    // MUST run after the marker scan (not before): MAX_LEAD is a cap on undecided
    // *content* that proves it is text, not a budget that lets an early overflow
    // dump a leading think block to the text channel before the scan ever runs.
    if (this.pending.length > MAX_LEAD) return this._commitText();

    // No full marker yet. Could the pending still grow into one?
    const core = this.pending.replace(/^\s+/, '');
    if (core.length === 0) return { toThink: false }; // only whitespace so far
    if (THINK_OPEN.startsWith(core)) return { toThink: false }; // partial marker
    return this._commitText(); // definitively not a leading marker
  }

  _commitText() {
    const out = this.pending;
    this.pending = '';
    this.mode = 'text';
    return { text: out, thinking: '' };
  }

  // Scan a think-mode buffer for a closing marker. Returns `{ hold: true }` to keep
  // buffering, `{ text, thinking }` for the MAX_PENDING dump-as-text escape, or
  // `{ span, rest }` for a closed block — the caller re-scans `rest` in the same
  // iteration, so a chain of blocks never nests another call.
  _scanThink() {
    const ci = this.pending.indexOf(THINK_CLOSE);
    if (ci >= 0) {
      const span = this.pending.slice(0, ci);
      const rest = this.pending.slice(ci + THINK_CLOSE.length);
      this.pending = '';
      this.mode = 'undecided'; // may catch a following block; normal text commits
      return { span, rest };
    }

    if (this.pending.length > MAX_PENDING) {
      // Pathological unterminated span -> deliver as text (visible beats dropped,
      // and loop-break value is gone at this size anyway).
      const dump = this.pending;
      this.pending = '';
      this.mode = 'text';
      return { text: dump, thinking: '' };
    }

    return { hold: true }; // buffer until the close marker proves it
  }

  // Stream end: flush whatever is held. Undecided content is text; an unterminated
  // think span is NOT rerouted (it never proved it was reasoning) — delivered as text.
  flush() {
    const out = this.pending;
    this.pending = '';
    this.mode = 'text';
    return out;
  }
}

export const THINK_MARKERS = { open: THINK_OPEN, close: THINK_CLOSE };