mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 12:31:13 +08:00
feat(cli): recover omitted and length-aware command suggestions (#37624)
This commit is contained in:
parent
d5cdb2e6f1
commit
3e606ff0dc
@ -139,3 +139,114 @@ describe('findSuggestions', () => {
|
||||
expect(suggestions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSuggestions — cross-namespace fallback', () => {
|
||||
// Mirrors the real command tree shape (auth/login, auth/devices/list, …) so the
|
||||
// omitted-namespace cases are deterministic without importing the generated tree.
|
||||
const realish: CommandTree = {
|
||||
auth: {
|
||||
subcommands: {
|
||||
login: { command: FooBarCmd, subcommands: {} },
|
||||
logout: { command: FooBarCmd, subcommands: {} },
|
||||
devices: {
|
||||
subcommands: {
|
||||
list: { command: FooBarCmd, subcommands: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
describe: { subcommands: { app: { command: FooBarCmd, subcommands: {} } } },
|
||||
get: { subcommands: { app: { command: FooBarCmd, subcommands: {} } } },
|
||||
create: { subcommands: { member: { command: FooBarCmd, subcommands: {} } } },
|
||||
set: { subcommands: { member: { command: FooBarCmd, subcommands: {} } } },
|
||||
verbose: { subcommands: { snapshot: { command: FooBarCmd, subcommands: {} } } },
|
||||
io: { subcommands: { dir: { command: FooBarCmd, subcommands: {} } } },
|
||||
}
|
||||
|
||||
it('recovers an omitted namespace for a bare leaf', () => {
|
||||
expect(findSuggestions(realish, ['login'])).toEqual(['auth login'])
|
||||
})
|
||||
|
||||
it('does not suggest a leaf outside the length-aware threshold', () => {
|
||||
// editDistance('login','logout') = 3 > threshold(2) — logout must not appear.
|
||||
expect(findSuggestions(realish, ['login'])).not.toContain('auth logout')
|
||||
})
|
||||
|
||||
it('recovers a two-level omitted namespace with one typo', () => {
|
||||
// 'list' anchors the leaf; 'device'→'devices' costs 1; 'auth' omitted → score 2.5.
|
||||
expect(findSuggestions(realish, ['device', 'list'])).toEqual(['auth devices list'])
|
||||
})
|
||||
|
||||
it('recovers a transposed leaf typo the same-level walk cannot fix', () => {
|
||||
// editDistance('descrbie','describe') = 2 > traverse's fixed 1, but within length-aware threshold(2).
|
||||
expect(findSuggestions(realish, ['descrbie', 'app'])).toEqual(['describe app'])
|
||||
})
|
||||
|
||||
it('ranks a same-level fix ahead of any omitted-namespace match', () => {
|
||||
// 'descibe' (edit distance 1 to the 'describe' namespace) is fixed in-place by the walk,
|
||||
// so the ambiguous omitted-namespace 'app' fan-out never runs.
|
||||
const suggestions = findSuggestions(realish, ['descibe', 'app'])
|
||||
expect(suggestions[0]).toBe('describe app')
|
||||
expect(suggestions).not.toContain('get app')
|
||||
})
|
||||
|
||||
it('tolerates a two-edit typo on a long leaf', () => {
|
||||
// editDistance('snpashot','snapshot') = 2, leaf length 8 → threshold 2.
|
||||
expect(findSuggestions(realish, ['verbose', 'snpashot'])).toContain('verbose snapshot')
|
||||
})
|
||||
|
||||
it('keeps short tokens strict and rejects a two-edit neighbor', () => {
|
||||
// editDistance('dxx','dir') = 2 > threshold(1) for a 3-char token.
|
||||
expect(findSuggestions(realish, ['dxx'])).not.toContain('io dir')
|
||||
// editDistance('dxr','dir') = 1 ≤ threshold(1) — the one-edit neighbor is recovered.
|
||||
expect(findSuggestions(realish, ['dxr'])).toEqual(['io dir'])
|
||||
})
|
||||
|
||||
it('suppresses ambiguous fan-out when a bare leaf lives under many namespaces', () => {
|
||||
// 'member' (create/set) and 'app' (describe/get) each tie with zero spelling cost — unroutable.
|
||||
expect(findSuggestions(realish, ['member'])).toEqual([])
|
||||
expect(findSuggestions(realish, ['app'])).toEqual([])
|
||||
})
|
||||
|
||||
it('stays silent when nothing clears the threshold', () => {
|
||||
expect(findSuggestions(realish, ['zzzzz'])).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a low-confidence two-level omission past the score cutoff', () => {
|
||||
// 'list' only reaches the depth-3 'auth devices list' (two namespaces omitted,
|
||||
// score 3.0) — beyond the cutoff, so nothing is suggested.
|
||||
expect(findSuggestions(realish, ['list'])).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a candidate when more tokens are typed than its path can hold', () => {
|
||||
// Three positional tokens cannot align to the two-segment 'describe app'.
|
||||
expect(findSuggestions(realish, ['extra', 'descrbie', 'app'])).toEqual([])
|
||||
})
|
||||
|
||||
it('produces a deterministic, stable result across runs', () => {
|
||||
expect(findSuggestions(realish, ['login'])).toEqual(findSuggestions(realish, ['login']))
|
||||
expect(findSuggestions(realish, ['device', 'list'])).toEqual(findSuggestions(realish, ['device', 'list']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSuggestions — hidden commands', () => {
|
||||
class Visible extends Command {
|
||||
async run(_argv: string[]) {}
|
||||
}
|
||||
class Hidden extends Command {
|
||||
static hidden = true
|
||||
async run(_argv: string[]) {}
|
||||
}
|
||||
const hiddenTree: CommandTree = {
|
||||
status: { command: Visible, subcommands: {} },
|
||||
secret: { command: Hidden, subcommands: {} },
|
||||
}
|
||||
|
||||
it('never surfaces a hidden command, even for a near typo', () => {
|
||||
expect(findSuggestions(hiddenTree, ['secrt'])).toEqual([])
|
||||
})
|
||||
|
||||
it('still suggests visible siblings', () => {
|
||||
expect(findSuggestions(hiddenTree, ['statuss'])).toEqual(['status'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -76,11 +76,97 @@ export function collectCommands(
|
||||
return results
|
||||
}
|
||||
|
||||
// Below MAX_SCORE a score decomposes uniquely into (integer spelling cost,
|
||||
// 1.5 × omitted namespaces), so scoreFallback's ambiguity guard can read
|
||||
// `spelling === 0` as "exact leaf, only the namespace was omitted".
|
||||
const OMIT_PENALTY = 1.5
|
||||
const MAX_SCORE = 3
|
||||
|
||||
function relThreshold(token: string): number {
|
||||
return token.length <= 3 ? 1 : 2
|
||||
}
|
||||
|
||||
function positionalTokens(argv: string[]): string[] {
|
||||
const tokens: string[] = []
|
||||
for (const token of argv) {
|
||||
if (token.startsWith('-'))
|
||||
break
|
||||
tokens.push(token)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// Minimum total edit distance to align `tokens` as an ordered subsequence of
|
||||
// `segments`, every matched pair within its length-aware threshold. Returns
|
||||
// null when no alignment exists (e.g. more tokens than segments).
|
||||
function minSubsequenceCost(tokens: string[], segments: string[]): number | null {
|
||||
const [head, ...rest] = tokens
|
||||
if (head === undefined)
|
||||
return 0
|
||||
|
||||
const threshold = relThreshold(head)
|
||||
let best: number | null = null
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const cost = editDistance(head, segment)
|
||||
if (cost > threshold)
|
||||
continue
|
||||
const tail = minSubsequenceCost(rest, segments.slice(index + 1))
|
||||
if (tail !== null && (best === null || cost + tail < best))
|
||||
best = cost + tail
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Recovers a wrong/omitted namespace by scoring the typed tokens against the
|
||||
// flat command list. The last token must align to a candidate's leaf; earlier
|
||||
// tokens align, in order, to the candidate's preceding segments (the rest are
|
||||
// namespace the user omitted). Lower score = higher confidence.
|
||||
function scoreFallback(tree: CommandTree, tokens: string[]): string[] {
|
||||
const last = tokens.length - 1
|
||||
const lastToken = tokens[last]
|
||||
if (lastToken === undefined)
|
||||
return []
|
||||
const prefix = tokens.slice(0, last)
|
||||
|
||||
const scored: Array<{ path: string, score: number, spelling: number, depth: number }> = []
|
||||
for (const { path } of collectCommands(tree)) {
|
||||
const leaf = path[path.length - 1] ?? ''
|
||||
const leafCost = editDistance(lastToken, leaf)
|
||||
if (leafCost > relThreshold(lastToken))
|
||||
continue
|
||||
|
||||
const prefixCost = minSubsequenceCost(prefix, path.slice(0, -1))
|
||||
if (prefixCost === null)
|
||||
continue
|
||||
|
||||
const spelling = leafCost + prefixCost
|
||||
const score = spelling + OMIT_PENALTY * (path.length - tokens.length)
|
||||
if (score >= MAX_SCORE)
|
||||
continue
|
||||
|
||||
scored.push({ path: buildPath(path), score, spelling, depth: path.length })
|
||||
}
|
||||
|
||||
if (scored.length === 0)
|
||||
return []
|
||||
|
||||
scored.sort((a, b) => a.score - b.score || a.depth - b.depth || a.path.localeCompare(b.path))
|
||||
|
||||
// An exact leaf living under several namespaces is unroutable — staying silent
|
||||
// beats guessing an arbitrary one.
|
||||
const best = scored[0]?.score
|
||||
const tied = scored.filter(item => item.score === best)
|
||||
if (tied.length >= 2 && tied.every(item => item.spelling === 0))
|
||||
return []
|
||||
|
||||
return scored.map(item => item.path)
|
||||
}
|
||||
|
||||
export function findSuggestions(tree: CommandTree, argv: string[]): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
function collectAll(node: CommandNode, path: string[]): void {
|
||||
if (node.command)
|
||||
if (node.command && node.command.hidden !== true)
|
||||
results.push(buildPath(path))
|
||||
for (const [key, child] of Object.entries(node.subcommands))
|
||||
collectAll(child, [...path, key])
|
||||
@ -106,6 +192,11 @@ export function findSuggestions(tree: CommandTree, argv: string[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Same-level typos and namespace listing first; only fall back to
|
||||
// cross-namespace scoring when the level-by-level walk finds nothing.
|
||||
traverse(tree, argv, [])
|
||||
return results
|
||||
if (results.length > 0)
|
||||
return results
|
||||
|
||||
return scoreFallback(tree, positionalTokens(argv))
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user