Files
botpress--botpress/packages/zai/src/operations/sort.ts
T
2026-07-13 13:34:48 +08:00

812 lines
25 KiB
TypeScript

// eslint-disable consistent-type-definitions
import { z } from '@bpinternal/zui'
import pLimit from 'p-limit'
import { ZaiContext } from '../context'
import { Response } from '../response'
import { getTokenizer } from '../tokenizer'
import { fastHash, stringify } from '../utils'
import { Zai } from '../zai'
import { PROMPT_INPUT_BUFFER, PROMPT_OUTPUT_BUFFER } from './constants'
export type Options = {
/** The maximum number of tokens per item */
tokensPerItem?: number
}
const _Options = z.object({
tokensPerItem: z
.number()
.min(1)
.max(100_000)
.optional()
.describe('The maximum number of tokens per item')
.default(250),
})
// Evaluation criteria generated by LLM
type SortingCriteria = Record<
string,
{
description: string
labels: string[] // Ordered array of labels from FIRST to LAST, e.g., ['very_slow', 'slow', 'medium', 'fast', 'very_fast']
}
>
declare module '@botpress/zai' {
interface Zai {
/**
* Sorts array items based on natural language sorting criteria.
*
* This operation intelligently orders items according to your instructions, understanding
* complex sorting logic like priority, quality, chronology, or any custom criteria.
* Perfect for ranking, organizing, and prioritizing lists based on subjective or
* multi-faceted criteria.
*
* @param input - Array of items to sort
* @param instructions - Natural language description of how to sort (e.g., "by priority", "newest first")
* @param options - Configuration for tokens per item
* @returns Response resolving to the sorted array
*
* @example Sort by price
* ```typescript
* const products = [
* { name: 'Laptop', price: 999 },
* { name: 'Mouse', price: 29 },
* { name: 'Keyboard', price: 79 }
* ]
*
* const sorted = await zai.sort(products, 'from least expensive to most expensive')
* // Result: [Mouse ($29), Keyboard ($79), Laptop ($999)]
* ```
*
* @example Sort by priority/urgency
* ```typescript
* const tasks = [
* "Update documentation",
* "Fix critical security bug",
* "Add new feature",
* "System is down - all users affected"
* ]
*
* const prioritized = await zai.sort(tasks, 'by urgency and impact, most urgent first')
* // Result: ["System is down...", "Fix critical security bug", "Add new feature", "Update documentation"]
* ```
*
* @example Sort by quality/rating
* ```typescript
* const reviews = [
* "Product is okay",
* "Absolutely amazing! Best purchase ever!",
* "Terrible, broke immediately",
* "Good quality for the price"
* ]
*
* const sorted = await zai.sort(reviews, 'by sentiment, most positive first')
* // Result: [amazing review, good review, okay review, terrible review]
* ```
*
* @example Sort by complexity
* ```typescript
* const problems = [
* "Fix typo in README",
* "Redesign entire authentication system",
* "Update a dependency version",
* "Implement new microservice architecture"
* ]
*
* const sorted = await zai.sort(problems, 'by complexity, simplest first')
* ```
*
* @example Sort by relevance to query
* ```typescript
* const documents = [
* "Article about cats",
* "Article about dogs and their training",
* "Article about dog breeds",
* "Article about fish"
* ]
*
* const sorted = await zai.sort(
* documents,
* 'by relevance to "dog training", most relevant first'
* )
* // Result: [dogs and training, dog breeds, cats, fish]
* ```
*
* @example Sort candidates by fit
* ```typescript
* const candidates = [
* { name: 'Alice', experience: 5, skills: ['React', 'Node'] },
* { name: 'Bob', experience: 10, skills: ['Python', 'ML'] },
* { name: 'Charlie', experience: 3, skills: ['React', 'TypeScript'] }
* ]
*
* const sorted = await zai.sort(
* candidates,
* 'by fit for a senior React developer position, best fit first'
* )
* ```
*
* @example Sort chronologically
* ```typescript
* const events = [
* "Started the project last month",
* "Will launch next week",
* "Met with client yesterday",
* "Planning meeting tomorrow"
* ]
*
* const chronological = await zai.sort(events, 'in chronological order')
* // Understands relative time expressions
* ```
*
* @example With token limit per item
* ```typescript
* const sorted = await zai.sort(
* longDocuments,
* 'by relevance to climate change research',
* { tokensPerItem: 500 } // Allow 500 tokens per document
* )
* ```
*/
sort<T>(input: Array<T>, instructions: string, options?: Options): Response<Array<T>, Array<T>>
}
}
const END = '■END■'
const sort = async <T>(
input: Array<T>,
instructions: string,
_options: Options | undefined,
ctx: ZaiContext
): Promise<Array<T>> => {
ctx.controller.signal.throwIfAborted()
const options = _Options.parse(_options ?? {})
const tokenizer = await getTokenizer()
const model = await ctx.getModel()
const taskId = ctx.taskId
const taskType = 'zai.sort'
// Handle empty or single element arrays
if (input.length === 0) {
return []
}
if (input.length === 1) {
return input
}
const TOKENS_TOTAL_MAX = model.input.maxTokens - PROMPT_INPUT_BUFFER - PROMPT_OUTPUT_BUFFER
// Phase 1: Generate sorting criteria from instructions + sample items
const sampleSize = Math.min(5, input.length)
const sampleItems = input.slice(0, sampleSize)
const sampleItemsText = sampleItems.map((item, idx) => `■${idx}: ${stringify(item, false)}`).join('\n')
// Get examples from adapter for criteria generation
const criteriaInputStr = JSON.stringify({ instructions, sampleItems })
const criteriaExamples =
taskId && ctx.adapter
? await ctx.adapter.getExamples<string, Array<T>>({
input: criteriaInputStr.slice(0, 1000),
taskType,
taskId,
})
: []
// Format examples for few-shot learning in criteria generation
const criteriaExampleMessages: Array<{ type: 'text'; role: 'user' | 'assistant'; content: string }> = []
for (const example of criteriaExamples.slice(0, 2)) {
try {
const exampleInput = JSON.parse(example.input)
const exampleItems = Array.isArray(exampleInput) ? exampleInput : [exampleInput]
criteriaExampleMessages.push({
type: 'text',
role: 'user',
content: `Expert Example - Analyze sorting instruction: "${instructions}"\n\nSample items:\n${exampleItems
.slice(0, 3)
.map((el, i) => `■${i}: ${stringify(el, false).slice(0, 200)}`)
.join('\n')}\n\nGenerate sorting criteria.`,
})
// Try to infer criteria from the example if available
if (example.explanation) {
criteriaExampleMessages.push({
type: 'text',
role: 'assistant',
content: `${example.explanation}\n${END}`,
})
}
} catch {
// Skip malformed examples
}
}
const generateCriteriaPrompt = `Analyze this sorting instruction: "${instructions}"
Sample items to be sorted:
${sampleItemsText}
Create 1-3 sorting criteria with ordered label arrays (3-10 labels each).
**CRITICAL RULES**:
1. Labels are single words, lowercase, no spaces, use underscores
2. Labels are ordered from FIRST to LAST in sorted result
3. If instruction says "from X to Y": first label represents X, last label represents Y
4. If instruction says "prioritize" or "highest/lowest priority":
- First label = HIGHEST priority (top of todo list)
- Last label = LOWEST priority (bottom of todo list)
Examples:
"from slowest to fastest" → first=slowest, last=fastest
■speed■
very_slow;slow;medium;fast;very_fast
■END■
"from most dangerous to least dangerous" → first=most dangerous, last=least dangerous
■danger■
extremely_dangerous;very_dangerous;dangerous;moderate;slightly_dangerous;harmless
■END■
"from least urgent (spam) to most urgent (bills)" → first=spam, last=bills
■urgency■
spam;promotional;normal;important;urgent;critical
■END■
"prioritize: highest priority=open old tickets; lowest priority=closed" → first=high priority, last=low priority
■status■
open_old;open_recent;closed
■age■
oldest;old;recent;new
■END■
Output format:
■criterion_name■
label1;label2;label3;label4
■END■
Use 3-10 labels per criterion. Labels should be intuitive and match the domain.
Keep criterion names short (1-2 words, lowercase, underscores).
`
const { extracted: sortingCriteria } = await ctx.generateContent({
systemPrompt: `You are creating sorting criteria with ordered label arrays.
CRITICAL: Output ordered labels from FIRST to LAST position in sorted result.
- Labels are single words, lowercase, underscores only
- 3-10 labels per criterion
- Order matters: first label = appears first, last label = appears last`,
messages: [
...criteriaExampleMessages,
{
type: 'text',
role: 'user',
content: generateCriteriaPrompt,
},
],
transform: (text) => {
const criteria: SortingCriteria = {}
const criterionRegex = /■([^■]+)■\s*([^\n■]+)/g
let match: RegExpExecArray | null
while ((match = criterionRegex.exec(text)) !== null) {
const name = (match[1] ?? '').trim().toLowerCase()
const labelsStr = (match[2] ?? '').trim()
if (!name || name === 'end') continue
// Parse semicolon-separated labels
const labels = labelsStr
.split(';')
.map((l) => l.trim().toLowerCase().replace(/\s+/g, '_'))
.filter((l) => l.length > 0 && l.length < 50)
if (labels.length >= 3 && labels.length <= 10) {
criteria[name] = {
description: `${labels.length} ordered labels`,
labels,
}
}
}
if (Object.keys(criteria).length === 0) {
throw new Error(`Failed to parse sorting criteria. LLM output: ${text.slice(0, 500)}`)
}
return criteria
},
})
const criteriaKeys = Object.keys(sortingCriteria)
if (criteriaKeys.length === 0) {
throw new Error('No sorting criteria generated')
}
// Phase 2: Chunk items and score them in parallel
const TOKENS_CRITERIA_MAX = Math.floor(TOKENS_TOTAL_MAX * 0.2)
const TOKENS_ITEMS_MAX = TOKENS_TOTAL_MAX - TOKENS_CRITERIA_MAX
const MAX_ITEMS_PER_CHUNK = 50
// Prepare elements with indices
const elements = input.map((element, idx) => ({
element,
index: idx,
stringified: stringify(element, false),
}))
// Chunk elements
const chunks: Array<typeof elements> = []
let currentChunk: typeof elements = []
let currentTokens = 0
for (const elem of elements) {
const truncated = tokenizer.truncate(elem.stringified, options.tokensPerItem)
const elemTokens = tokenizer.count(truncated)
if (
(currentTokens + elemTokens > TOKENS_ITEMS_MAX || currentChunk.length >= MAX_ITEMS_PER_CHUNK) &&
currentChunk.length > 0
) {
chunks.push(currentChunk)
currentChunk = []
currentTokens = 0
}
currentChunk.push(elem)
currentTokens += elemTokens
}
if (currentChunk.length > 0) {
chunks.push(currentChunk)
}
// Phase 3: Score each chunk
type ItemScore = {
elementIndex: number
scores: Record<string, number>
totalScore: number
}
const scoreChunk = async (chunk: typeof elements): Promise<ItemScore[]> => {
ctx.controller.signal.throwIfAborted()
const chunkSize = chunk.length
const chunkInputStr = JSON.stringify(chunk.map((c) => c.element))
// Get examples from adapter for active learning
const examples =
taskId && ctx.adapter
? await ctx.adapter.getExamples<string, ItemScore[]>({
input: chunkInputStr.slice(0, 1000),
taskType,
taskId,
})
: []
// Check for exact match (cache hit)
const key = fastHash(
stringify({
taskId,
taskType,
input: chunkInputStr,
instructions,
})
)
const exactMatch = examples.find((x) => x.key === key)
if (exactMatch && exactMatch.output) {
return exactMatch.output
}
const elementsText = chunk
.map((elem, i) => {
const truncated = tokenizer.truncate(elem.stringified, options.tokensPerItem)
return `■${i}: ${truncated}■`
})
.join('\n')
const criteriaText = criteriaKeys
.map((key) => {
const criterion = sortingCriteria[key]
const labelsText = criterion.labels.join(';')
return `**${key}**: ${labelsText}`
})
.join('\n')
// Format examples for few-shot learning
const exampleMessages: Array<{ type: 'text'; role: 'user' | 'assistant'; content: string }> = []
for (const example of examples.slice(0, 3)) {
try {
const exampleInput = JSON.parse(example.input)
const exampleItems = Array.isArray(exampleInput) ? exampleInput : [exampleInput]
exampleMessages.push({
type: 'text',
role: 'user',
content: `Expert Example - Items to score:\n${exampleItems.map((el, i) => `■${i}: ${stringify(el, false).slice(0, 200)}■`).join('\n')}\n\nScore each item.`,
})
const exampleOutput = example.output
if (Array.isArray(exampleOutput) && exampleOutput.length > 0) {
const formattedScores = exampleOutput
.map((score) => {
const pairs = criteriaKeys.map((key) => `${key}=${score.scores[key] ?? 0}`).join(';')
return `■${score.elementIndex}:${pairs}■`
})
.join('\n')
exampleMessages.push({
type: 'text',
role: 'assistant',
content: `${formattedScores}\n${END}`,
})
if (example.explanation) {
exampleMessages.push({
type: 'text',
role: 'assistant',
content: `Reasoning: ${example.explanation}`,
})
}
}
} catch {
// Skip malformed examples
}
}
const { extracted } = await ctx.generateContent({
systemPrompt: `You are ranking items for sorting using ordered label arrays.
${criteriaText}
Instructions: "${instructions}"
SCORING RULES:
- For each item and each criterion, assign ONE label from the ordered list
- Labels are ordered: first label = appears FIRST in sorted result, last label = appears LAST
- Choose the label that best describes each item
Output format:
■0:criterion1=label;criterion2=label■
■1:criterion1=label;criterion2=label■
${END}
IMPORTANT:
- Rank every item (■0 to ■${chunkSize - 1})
- Use exact criterion names: ${criteriaKeys.join(', ')}
- Use exact labels from the lists above (lowercase, underscores)
- Use semicolons (;) between criteria
- Use equals (=) between criterion and label`,
stopSequences: [END],
messages: [
...exampleMessages,
{
type: 'text',
role: 'user',
content: `Items to rank (■0 to ■${chunkSize - 1}):\n${elementsText}\n\nRank each item using the labeled scales.\nOutput format: ■index:criterion1=label;criterion2=label■\n${END}`,
},
],
transform: (text) => {
const results: ItemScore[] = []
const regex = /■(\d+):([^■]+)■/g
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const idx = parseInt(match[1] ?? '', 10)
const labelsStr = match[2] ?? ''
if (isNaN(idx) || idx < 0 || idx >= chunkSize) continue
const scores: Record<string, number> = {}
let total = 0
const pairs = labelsStr.split(';').filter((x) => x.trim().length > 0)
for (const pair of pairs) {
const [criterion, labelStr] = pair.split('=').map((x) => x.trim().toLowerCase().replace(/\s+/g, '_'))
if (!criterion || !labelStr) continue
// Find the label index in the ordered array (index = score)
const labels = sortingCriteria[criterion]?.labels ?? []
const labelIndex = labels.findIndex((l) => l === labelStr)
if (labelIndex >= 0) {
// Use index as score (0 = first, higher = later)
scores[criterion] = labelIndex
total += labelIndex
} else {
// If label not found, use middle value
const middleIndex = labels.length > 0 ? Math.floor(labels.length / 2) : 5
scores[criterion] = middleIndex
total += middleIndex
}
}
results[idx] = {
elementIndex: chunk[idx].index,
scores,
totalScore: total,
}
}
// Fill in missing results with middle scores
for (let i = 0; i < chunkSize; i++) {
if (!results[i]) {
const scores: Record<string, number> = {}
let total = 0
for (const key of criteriaKeys) {
const labels = sortingCriteria[key]?.labels ?? []
const middleIndex = labels.length > 0 ? Math.floor(labels.length / 2) : 5
scores[key] = middleIndex
total += middleIndex
}
results[i] = {
elementIndex: chunk[i].index,
scores,
totalScore: total,
}
}
}
return results
},
})
return extracted
}
// Process all chunks in parallel
const limit = pLimit(10)
const chunkPromises = chunks.map((chunk) => limit(() => scoreChunk(chunk)))
const allScores = await Promise.all(chunkPromises)
// Phase 4: Merge scores from all chunks
// Build a map of elementIndex -> accumulated scores
const scoreMap = new Map<number, { scores: Record<string, number>; totalScore: number; tieBreakOrder?: number }>()
for (const chunkScores of allScores) {
for (const itemScore of chunkScores) {
const existing = scoreMap.get(itemScore.elementIndex)
if (existing) {
// Average the scores
for (const key of criteriaKeys) {
existing.scores[key] = (existing.scores[key] + (itemScore.scores[key] ?? 0)) / 2
}
existing.totalScore = (existing.totalScore + itemScore.totalScore) / 2
} else {
scoreMap.set(itemScore.elementIndex, {
scores: { ...itemScore.scores },
totalScore: itemScore.totalScore,
})
}
}
}
// Verify we have scores for all elements
if (scoreMap.size !== input.length) {
throw new Error(`Score map size mismatch: expected ${input.length}, got ${scoreMap.size}`)
}
// Phase 5: Identify ties
const scoreGroups = new Map<number, number[]>()
for (const [index, scoreData] of scoreMap.entries()) {
const roundedScore = Math.round(scoreData.totalScore * 100) // Round to avoid floating point issues
const group = scoreGroups.get(roundedScore) ?? []
group.push(index)
scoreGroups.set(roundedScore, group)
}
// Find groups with more than one item (ties)
const tiedGroups = Array.from(scoreGroups.values()).filter((group) => group.length > 1)
// Phase 6: Tie-breaking - process all tied groups IN PARALLEL
if (tiedGroups.length > 0) {
const tieBreakLimit = pLimit(10)
await Promise.all(
tiedGroups.map((tiedIndices) =>
tieBreakLimit(async () => {
if (tiedIndices.length <= 1) return
const tiedElements = tiedIndices.map((idx) => elements[idx])
// Re-score just these items for tie-breaking
const tieBreakText = tiedElements
.map((elem, i) => {
const truncated = tokenizer.truncate(elem.stringified, options.tokensPerItem)
return `■${i}: ${truncated}■`
})
.join('\n')
// Get examples from adapter for tie-breaking
const tieBreakInputStr = JSON.stringify(tiedElements.map((e) => e.element))
const tieBreakExamples =
taskId && ctx.adapter
? await ctx.adapter.getExamples<string, Array<T>>({
input: tieBreakInputStr.slice(0, 1000),
taskType,
taskId,
})
: []
// Format examples for few-shot learning in tie-breaking
const tieBreakExampleMessages: Array<{ type: 'text'; role: 'user' | 'assistant'; content: string }> = []
for (const example of tieBreakExamples.slice(0, 3)) {
try {
const exampleInput = JSON.parse(example.input)
const exampleItems = Array.isArray(exampleInput) ? exampleInput : [exampleInput]
tieBreakExampleMessages.push({
type: 'text',
role: 'user',
content: `Expert Example - Items to order:\n${exampleItems.map((el, i) => `■${i}: ${stringify(el, false).slice(0, 200)}■`).join('\n')}\n\nOrder them from first to last.`,
})
if (Array.isArray(example.output) && example.output.length > 0) {
const formattedOrder = example.output.map((_, i) => `■${i}■`).join('\n')
tieBreakExampleMessages.push({
type: 'text',
role: 'assistant',
content: `${formattedOrder}\n${END}`,
})
if (example.explanation) {
tieBreakExampleMessages.push({
type: 'text',
role: 'assistant',
content: `Reasoning: ${example.explanation}`,
})
}
}
} catch {
// Skip malformed examples
}
}
const { extracted: tieBreakOrder } = await ctx.generateContent({
systemPrompt: `You are breaking a tie between items with identical total scores.
Instructions: ${instructions}
Criteria:
${criteriaKeys
.map((key) => {
const labels = sortingCriteria[key].labels.join(';')
return `- ${key}: ${labels}`
})
.join('\n')}
Order these ${tiedElements.length} items from FIRST to LAST based on the instructions.
Earlier labels in each criterion should come FIRST.
Output format:
■original_index■
■original_index■
${END}
Output the indices in the order they should appear (first item at top).`,
stopSequences: [END],
messages: [
...tieBreakExampleMessages,
{
type: 'text',
role: 'user',
content: `Items with identical scores (need tie-breaking):\n${tieBreakText}\n\nOrder them from first to last.\nOutput format: ■index■ (one per line)\n${END}`,
},
],
transform: (text) => {
const order: number[] = []
const regex = /■(\d+)■/g
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const idx = parseInt(match[1] ?? '', 10)
if (!isNaN(idx) && idx >= 0 && idx < tiedElements.length) {
order.push(idx)
}
}
// If not all items were ordered, append missing ones
for (let i = 0; i < tiedElements.length; i++) {
if (!order.includes(i)) {
order.push(i)
}
}
return order
},
})
// Update scoreMap with tie-break order
for (let i = 0; i < tieBreakOrder.length; i++) {
const elementIndex = tiedElements[tieBreakOrder[i]].index
const scoreData = scoreMap.get(elementIndex)
if (scoreData) {
scoreData.tieBreakOrder = i
}
}
})
)
)
}
// Phase 7: Sort by total score, then by tie-break order
const sorted = Array.from(scoreMap.entries())
.sort((a, b) => {
// First sort by total score
const scoreDiff = a[1].totalScore - b[1].totalScore
if (scoreDiff !== 0) return scoreDiff
// If scores are equal, use tie-break order
const orderA = a[1].tieBreakOrder ?? 0
const orderB = b[1].tieBreakOrder ?? 0
return orderA - orderB
})
.map(([index]) => elements[index].element)
const result = sorted
// Save example for active learning
if (taskId && ctx.adapter && !ctx.controller.signal.aborted) {
const key = fastHash(
stringify({
taskId,
taskType,
input: JSON.stringify(input),
instructions,
})
)
await ctx.adapter.saveExample({
key,
taskType,
taskId,
input: JSON.stringify(input),
output: result,
instructions,
metadata: {
cost: { input: 0, output: 0 },
latency: 0,
model: ctx.modelId,
tokens: { input: 0, output: 0 },
},
})
}
return result
}
Zai.prototype.sort = function <T>(
this: Zai,
input: Array<T>,
instructions: string,
_options?: Options
): Response<Array<T>, Array<T>> {
const context = new ZaiContext({
client: this.client,
modelId: this.Model,
taskId: this.taskId,
taskType: 'zai.sort',
adapter: this.adapter,
memoizer: this._resolveMemoizer(),
})
return new Response<Array<T>, Array<T>>(
context,
sort(input, instructions, _options, context),
(result) => result // Simplified form is just the sorted array
)
}