LCOV - code coverage report
Current view: top level - dashql-app/src/agent - input_mode.ts Coverage Total Hit
Test: DashQL 915828b Lines: 97.9 % 95 93
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as core from '@ankoh/dashql-core';
       2              : 
       3            1 : const TokenType = core.buffers.parser.ScannerTokenType;
       4            1 : const DiagnosticSource = core.buffers.editor.EditorDiagnosticSource;
       5            1 : const DiagnosticSeverity = core.buffers.editor.EditorDiagnosticSeverity;
       6              : 
       7              : /// AI -> SQL: a viable prefix with this many settled tokens is SQL
       8            1 : const ENTER_MIN_TOKENS = 3;
       9              : /// AI -> SQL: a shorter viable prefix is SQL if it has a token that is not an identifier after the leading keyword
      10            1 : const ENTER_MIN_TOKENS_WITH_SQL_SYNTAX = 2;
      11              : /// SQL -> AI: the invalid tail needs at least this many tokens
      12            1 : const EXIT_MIN_TAIL = 2;
      13              : /// SQL -> AI: the invalid tail needs at least this share of the valid prefix, long SQL holds on longer
      14            1 : const EXIT_TAIL_RATIO = 0.5;
      15              : /// SQL -> AI: the invalid tail reads as natural language below this share of SQL-shaped tokens
      16            1 : const NL_TAIL_MAX_SQL_SHARE = 0.35;
      17              : 
      18              : /// The input mode of the composer
      19            1 : export enum ComposerInputMode {
      20              :     /// The draft is a SQL script
      21            1 :     Sql,
      22              :     /// The draft is a question in natural language
      23            1 :     Ai,
      24            1 : }
      25              : 
      26              : /// How much a draft looks like SQL
      27              : export interface SqlEvidence {
      28              :     /// Is the first settled token a keyword?
      29              :     leadingKeyword: boolean;
      30              :     /// The number of settled tokens before the first scanner or parser error
      31              :     validTokens: number;
      32              :     /// The number of valid tokens after the leading one that are not identifiers
      33              :     validSqlTokens: number;
      34              :     /// The number of settled tokens from the first scanner or parser error on
      35              :     tailTokens: number;
      36              :     /// The summed SQL weight of the tail tokens, see tokenSqlWeight
      37              :     tailSqlWeight: number;
      38              :     /// Is the text free of any scanner or parser error, including an error at the end of the input?
      39              :     complete: boolean;
      40              : }
      41              : 
      42              : /// How strongly a token suggests SQL rather than natural language.
      43              : /// English is mostly identifiers with a few keywords (by, for, all), SQL brings operators, literals and punctuation.
      44            1 : function tokenSqlWeight(tokenType: core.buffers.parser.ScannerTokenType): number {
      45            1 :     switch (tokenType) {
      46            1 :         case TokenType.IDENTIFIER:
      47            1 :             return 0;
      48            1 :         case TokenType.KEYWORD:
      49            1 :             return 0.5;
      50            1 :         default:
      51            1 :             return 1;
      52            1 :     }
      53            1 : }
      54              : 
      55              : /// Can the token still grow while the cursor sits at its end?
      56            1 : function isGrowingToken(tokenType: core.buffers.parser.ScannerTokenType): boolean {
      57            1 :     switch (tokenType) {
      58            1 :         case TokenType.IDENTIFIER:
      59            1 :         case TokenType.KEYWORD:
      60            1 :         case TokenType.LITERAL_INTEGER:
      61            1 :         case TokenType.LITERAL_FLOAT:
      62            1 :             return true;
      63            1 :         default:
      64            1 :             return false;
      65            1 :     }
      66            1 : }
      67              : 
      68              : /// Collect the evidence of an editor update.
      69              : ///
      70              : /// The token the user is still typing (cursor at its end) is not settled and is ignored, partial words must not switch the mode.
      71              : /// Pass a null cursor to settle every token, e.g. when the draft is sent.
      72            1 : export function assessSql(update: core.buffers.editor.EditorUpdateT, cursor: number | null): SqlEvidence {
      73              :     // The first scanner or parser error, diagnostics are sorted by offset
      74            1 :     let errorOffset: number | null = null;
      75            1 :     const scannerErrors: [number, number][] = [];
      76            1 :     for (const diagnostic of update.diagnostics) {
      77            1 :         if (diagnostic.severity !== DiagnosticSeverity.ERROR || diagnostic.source === DiagnosticSource.ANALYZER) {
      78            0 :             continue;
      79              :         }
      80            1 :         const offset = Number(diagnostic.textSpan?.offset ?? 0);
      81            1 :         const length = Number(diagnostic.textSpan?.length ?? 0);
      82            1 :         if (errorOffset == null) {
      83            1 :             errorOffset = offset;
      84              :         }
      85            1 :         if (diagnostic.source === DiagnosticSource.SCANNER) {
      86            0 :             scannerErrors.push([offset, offset + length]);
      87              :         }
      88            1 :     }
      89              : 
      90              :     // The settled tokens
      91            1 :     const tokens: { type: core.buffers.parser.ScannerTokenType, begin: number, end: number }[] = [];
      92            1 :     for (const span of update.syntaxSpans) {
      93            1 :         if (span.tokenType === TokenType.COMMENT) {
      94            1 :             continue;
      95              :         }
      96            1 :         const begin = Number(span.textSpan?.offset ?? 0);
      97            1 :         tokens.push({ type: span.tokenType, begin, end: begin + Number(span.textSpan?.length ?? 0) });
      98            1 :     }
      99            1 :     const last = tokens[tokens.length - 1];
     100            1 :     if (cursor != null && last != null && last.end === cursor && isGrowingToken(last.type)) {
     101            1 :         tokens.pop();
     102              :     }
     103              : 
     104            1 :     const evidence: SqlEvidence = {
     105            1 :         leadingKeyword: tokens.length > 0 && tokens[0].type === TokenType.KEYWORD,
     106            1 :         validTokens: 0,
     107            1 :         validSqlTokens: 0,
     108            1 :         tailTokens: 0,
     109            1 :         tailSqlWeight: 0,
     110            1 :         complete: errorOffset == null,
     111            1 :     };
     112            1 :     for (let i = 0; i < tokens.length; ++i) {
     113            1 :         const token = tokens[i];
     114            1 :         if (errorOffset == null || token.begin < errorOffset) {
     115            1 :             evidence.validTokens += 1;
     116            1 :             if (i > 0 && token.type !== TokenType.IDENTIFIER) {
     117            1 :                 evidence.validSqlTokens += 1;
     118              :             }
     119            1 :         } else {
     120            1 :             evidence.tailTokens += 1;
     121              :             // Text swallowed by a scanner error (e.g. an unterminated string after an apostrophe) is no evidence of SQL
     122            1 :             const garbage = scannerErrors.some(([begin, end]) => token.begin < end && begin < token.end);
     123            1 :             evidence.tailSqlWeight += garbage ? 0 : tokenSqlWeight(token.type);
     124            1 :         }
     125            1 :     }
     126            1 :     return evidence;
     127            1 : }
     128              : 
     129              : /// Decide the next input mode.
     130              : ///
     131              : /// The switch is asymmetric to avoid flicker: entering SQL needs a viable prefix with some substance, leaving SQL
     132              : /// needs an invalid tail that reads as natural language and that grows with the length of the valid SQL.
     133              : /// A final decision (when the draft is sent) also accepts any complete statement that starts with a keyword.
     134            1 : export function decideMode(current: ComposerInputMode, evidence: SqlEvidence, final: boolean = false): ComposerInputMode {
     135            1 :     if (current === ComposerInputMode.Ai) {
     136            1 :         if (!evidence.leadingKeyword || evidence.tailTokens > 0) {
     137            1 :             return current;
     138              :         }
     139            1 :         if (final && evidence.complete) {
     140            1 :             return ComposerInputMode.Sql;
     141              :         }
     142            1 :         const substantial = evidence.validTokens >= ENTER_MIN_TOKENS
     143            1 :             || (evidence.validTokens >= ENTER_MIN_TOKENS_WITH_SQL_SYNTAX && evidence.validSqlTokens > 0);
     144            1 :         return substantial ? ComposerInputMode.Sql : current;
     145            1 :     } else {
     146            1 :         const minTail = Math.max(EXIT_MIN_TAIL, Math.ceil(evidence.validTokens * EXIT_TAIL_RATIO));
     147            1 :         if (evidence.tailTokens < minTail) {
     148            1 :             return current;
     149              :         }
     150            1 :         const naturalTail = evidence.tailSqlWeight / evidence.tailTokens < NL_TAIL_MAX_SQL_SHARE;
     151            1 :         return naturalTail ? ComposerInputMode.Ai : current;
     152            1 :     }
     153            1 : }
        

Generated by: LCOV version 2.0-1