运营丢来一句「上周上海未支付,金额大于 1000」。旧筛选把正好 1000 元的 A1001 留在表格里。改完之后,大于 1000 只剩 A1002、A1010;说成大于等于,A1001 才出现。基准日 2026-10-10,上周锁定为 9 月 28 日到 10 月 4 日。本地 10 条用例 10/10。
前言
我们后台的订单页是 Vue3 + Element Plus。筛选一直是五个控件:城市、状态、最小金额、最大金额、日期。运营不爱填,习惯在微信里丢一句人话。
我加过一版「生成筛选」。模型返回 JSON,页面直接铺进表单。演示的时候挺顺,拿真实句子去对单,三个错叠在一起:1000 元那单不该在,上周和最近 7 天被算成同一段,请求参数里还出现了模型自己加的 sql。
这篇按对单的顺序写。先给 10 张订单和改之前的结果,再规定大于、上周、城市别名怎么收,然后是能跑的代码、10 条脏数据,以及 10 句要打到模型上的人话。
一、10 张订单,改之前错在哪
基准日定在 2026-10-10,周六。页面上的日期框可以改,文里的对单结果都锁在这一天,不然「上周」每天都会变,用例对不上。
| 单号 | 城市 | 金额 | 状态 | 日期 | 备注 |
|---|---|---|---|---|---|
| A1001 | 上海 | 1000 | 未支付 | 2026-09-29 | 黄底,专门卡边界 |
| A1002 | 上海 | 1280 | 未支付 | 2026-09-30 | |
| A1003 | 上海 | 860 | 未支付 | 2026-10-04 | 落在上周的最后一天 |
| A1004 | 北京 | 2200 | 未支付 | 2026-10-02 | |
| A1005 | 上海 | 1688 | 已支付 | 2026-09-28 | 金额够,状态不对 |
| A1006 | 杭州 | 980 | 已支付 | 2026-10-08 | |
| A1007 | 深圳 | 640 | 已关闭 | 2026-10-06 | |
| A1008 | 北京 | 800 | 已关闭 | 2026-10-08 | |
| A1009 | 广州 | 1500 | 已支付 | 2026-10-03 | |
| A1010 | 上海 | 2560 | 未支付 | 2026-10-03 |
句子是:「上周上海未支付,金额大于 1000」。
旧逻辑把「大于」写成 amount >= 1000,又让模型自己填日期。模型有时写成 10 月 3 日到 10 月 9 日,有时把「上海市」原样塞进下拉框。下拉框的值是「上海」,对不上就空,空又被当成「不限城市」。A1004 这种北京单会漏进来。
按后来定死的规则,这句只该命中两张单。
| 单号 | 大于 1000 | 原因 |
|---|---|---|
| A1001 | 不中 | 正好 1000,大于不含 |
| A1002 | 中 | 上海、未支付、1280、在上周内 |
| A1003 | 不中 | 日期在上周,金额 860 不够 |
| A1004 | 不中 | 北京 |
| A1005 | 不中 | 已支付 |
| A1010 | 中 | 上海、未支付、2560、10 月 3 日 |
同一句改成「大于等于 1000」,只多出 A1001。我就用这一张黄底单判断代码有没有把大于写成大于等于。
二、上周和最近 7 天不是同一段
2026 年 10 月 10 日是周六。周从周一起算:
text
一 二 三 四 五 六 日
上周 09-28 09-29 09-30 10-01 10-02 10-03 10-04
本周 10-05 10-06 10-07 10-08 10-09 10-10 10-11
最近 7 天含基准日当天,往前数 6 天:10 月 4 日到 10 月 10 日。
10 月 4 日卡在两边。它是上周日,也是最近 7 天的第一天。A1003 就在这一天。只看「有没有这张单」分不清两段日期,还得看金额和另外几天。所以上周的用例期望写成完整起止日期,不写成「大概是上周」。
周日的 getDay() 是 0。若写成「减一天就是周一」,周日会跳到周六,整周错位。代码里周日退 6 天。
本周是 10 月 5 日到 10 月 11 日,周日在基准日后面,表格里可以没有单,日期仍然要铺满这一周。本月是 10 月 1 日到 10 月 31 日。
这些日期由前端算。模型只许返回 last_week、last_7_days、this_week、this_month、absolute、none。它若在 last_week 旁边又写了 10 月 3 日到 10 月 9 日,那段日期丢弃,警告里写「已忽略模型自算的日期」。
三、城市、状态、金额各自怎么收
| 运营怎么说 | 收成 |
|---|---|
| 上海市、沪 | 上海 |
| 北京市、京 | 北京 |
| 广州市、深圳市、杭州市 | 去掉「市」 |
| 成都 | 城市清空,页面警告,别的条件保留 |
| 未支付、未付款、待支付、待付款 | unpaid |
| 已支付、已付款、支付成功 | paid |
| 已关闭、已取消、取消 | closed |
| 退款中 | 状态回到全部,并警告 |
| 大于 1000 | minAmount=1000,minInclusive=false |
| 大于等于、不低于、以上 | minInclusive=true |
| 小于 1000 | maxInclusive=false |
| 不超过、以下 | maxInclusive=true |
| 500 到 2000 | 两侧都含 |
| 大于 1000 且小于等于 3000 | 左侧不含,右侧含 |
「已取消」并进 closed 是我们订单状态只有三种的决定。别的项目有单独取消状态,改别名表,同时改第 8 条和第 10 条用例。
金额必须是对象,而且带布尔值 inclusive:
json
{ "min": { "value": 1000, "inclusive": false }, "max": null }
只有数字、没有 inclusive,这一侧扔掉。一个 op: "gte" 也扔掉。大于 1000 且小于等于 3000 无法塞进一个 op,所以两侧分开存。
城市按整词匹配。「上海周边」不会收成上海,避免短别名误伤。
发给接口的参数只留这些键:city、status、minAmount、minInclusive、maxAmount、maxInclusive、startDate、endDate。没有城市就不传 city,状态是全部就不传 status。模型附带的 sql、顶层裸的 minAmount,都进警告,不进参数。
大于 1000、上周、上海、未支付,最终参数是:
json
{
"city": "上海",
"status": "unpaid",
"minAmount": 1000,
"minInclusive": false,
"startDate": "2026-09-28",
"endDate": "2026-10-04"
}
这里没有 maxAmount,也没有 sql。
四、收口函数
新建 src/filters/interpret.ts。这个文件不引用 Vue。本地 10 条直接喂脏 JSON,不请求模型。
ts
export const BASELINE_DAY = '2026-10-10'
export const QUERY_KEYS = [
'city',
'status',
'minAmount',
'minInclusive',
'maxAmount',
'maxInclusive',
'startDate',
'endDate',
] as const
export const ORDER_STATUSES = ['all', 'unpaid', 'paid', 'closed'] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number]
export interface OrderFilter {
city: string
status: OrderStatus
minAmount: number | null
minInclusive: boolean
maxAmount: number | null
maxInclusive: boolean
startDate: string
endDate: string
}
export interface Order {
id: string
city: string
amount: number
status: Exclude<OrderStatus, 'all'>
date: string
}
export interface InterpretResult {
filter: OrderFilter
warnings: string[]
query: Record<string, string | number | boolean>
}
interface AmountBound {
value: number
inclusive: boolean
}
const CITY_ALIASES: Record<string, string> = {
北京: '北京',
北京市: '北京',
京: '北京',
上海: '上海',
上海市: '上海',
沪: '上海',
广州: '广州',
广州市: '广州',
深圳: '深圳',
深圳市: '深圳',
杭州: '杭州',
杭州市: '杭州',
}
const STATUS_ALIASES: Record<string, OrderStatus> = {
all: 'all',
全部: 'all',
所有: 'all',
不限: 'all',
unpaid: 'unpaid',
未支付: 'unpaid',
未付款: 'unpaid',
待支付: 'unpaid',
待付款: 'unpaid',
paid: 'paid',
已支付: 'paid',
已付款: 'paid',
支付成功: 'paid',
closed: 'closed',
已关闭: 'closed',
已取消: 'closed',
取消: 'closed',
}
const ALLOWED_FIELDS = new Set(['city', 'status', 'amount', 'datePreset', 'startDate', 'endDate'])
export function emptyFilter(): OrderFilter {
return {
city: '',
status: 'all',
minAmount: null,
minInclusive: true,
maxAmount: null,
maxInclusive: true,
startDate: '',
endDate: '',
}
}
export function expectFilter(partial: Partial<OrderFilter>): OrderFilter {
return { ...emptyFilter(), ...partial }
}
function parseISODate(value: string) {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
function formatDate(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function addDays(date: Date, days: number) {
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate())
next.setDate(next.getDate() + days)
return next
}
function startOfWeekMonday(date: Date) {
const day = date.getDay()
const delta = day === 0 ? -6 : 1 - day
return addDays(date, delta)
}
function isISODate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
return formatDate(parseISODate(value)) === value
}
function asNumber(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
return null
}
function resolvePreset(preset: string, today: Date) {
if (preset === 'last_week') {
const start = addDays(startOfWeekMonday(today), -7)
return { start: formatDate(start), end: formatDate(addDays(start, 6)) }
}
if (preset === 'last_7_days') {
return { start: formatDate(addDays(today, -6)), end: formatDate(today) }
}
if (preset === 'this_week') {
const start = startOfWeekMonday(today)
return { start: formatDate(start), end: formatDate(addDays(start, 6)) }
}
if (preset === 'this_month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1)
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
return { start: formatDate(start), end: formatDate(end) }
}
return null
}
function applyCity(value: unknown, filter: OrderFilter, warnings: string[]) {
if (value === '' || value === null || value === undefined) return
if (typeof value !== 'string') {
warnings.push('城市不是字符串,已忽略')
return
}
const text = value.trim()
if (!text) return
const canonical = CITY_ALIASES[text]
if (!canonical) {
warnings.push(`城市「${text}」不在可筛范围,已忽略`)
return
}
filter.city = canonical
if (text !== canonical) warnings.push(`城市已从「${text}」收成「${canonical}」`)
}
function applyStatus(value: unknown, filter: OrderFilter, warnings: string[]) {
if (value === '' || value === null || value === undefined) return
if (typeof value !== 'string') {
warnings.push('状态不是字符串,已按全部处理')
return
}
const text = value.trim()
if (!text) return
const canonical = STATUS_ALIASES[text]
if (!canonical) {
warnings.push(`状态「${text}」不在可筛范围,已按全部处理`)
return
}
filter.status = canonical
if (text !== canonical) warnings.push(`状态已从「${text}」收成「${canonical}」`)
}
function readBound(value: unknown, warnings: string[], side: string): AmountBound | null {
if (value === null || value === undefined || value === '') return null
if (typeof value !== 'object' || Array.isArray(value)) {
warnings.push(`${side}只有数字没有 inclusive,已忽略`)
return null
}
const bound = value as Record<string, unknown>
const number = asNumber(bound.value)
if (number === null || typeof bound.inclusive !== 'boolean') {
warnings.push(`${side}缺少 value 或 inclusive,已忽略`)
return null
}
return { value: number, inclusive: bound.inclusive }
}
function applyAmount(value: unknown, filter: OrderFilter, warnings: string[]) {
if (value === undefined || value === null) return
if (typeof value !== 'object' || Array.isArray(value)) {
warnings.push('金额不是对象,已忽略')
return
}
const amount = value as Record<string, unknown>
for (const key of Object.keys(amount)) {
if (key !== 'min' && key !== 'max') warnings.push(`已丢弃金额字段 ${key}`)
}
const min = readBound(amount.min, warnings, '最小金额')
const max = readBound(amount.max, warnings, '最大金额')
if (min && max && min.value > max.value) {
warnings.push('最小金额大于最大金额,金额条件已忽略')
return
}
if (min) {
filter.minAmount = min.value
filter.minInclusive = min.inclusive
}
if (max) {
filter.maxAmount = max.value
filter.maxInclusive = max.inclusive
}
}
function applyDate(data: Record<string, unknown>, filter: OrderFilter, warnings: string[], today: Date) {
const preset = typeof data.datePreset === 'string' ? data.datePreset : 'none'
const relative = resolvePreset(preset, today)
if (relative) {
filter.startDate = relative.start
filter.endDate = relative.end
if (data.startDate || data.endDate) warnings.push('已忽略模型自算的日期')
return
}
if (preset !== 'none' && preset !== 'absolute' && preset !== '') {
warnings.push(`日期预设「${preset}」无法识别,已忽略`)
return
}
const start = typeof data.startDate === 'string' ? data.startDate : ''
const end = typeof data.endDate === 'string' ? data.endDate : ''
if (!start && !end) return
if (!isISODate(start) || !isISODate(end) || start > end) {
warnings.push('日期格式无法识别,已忽略')
return
}
filter.startDate = start
filter.endDate = end
}
export function toQuery(filter: OrderFilter) {
const query: Record<string, string | number | boolean> = {}
if (filter.city) query.city = filter.city
if (filter.status !== 'all') query.status = filter.status
if (filter.minAmount !== null) {
query.minAmount = filter.minAmount
query.minInclusive = filter.minInclusive
}
if (filter.maxAmount !== null) {
query.maxAmount = filter.maxAmount
query.maxInclusive = filter.maxInclusive
}
if (filter.startDate) query.startDate = filter.startDate
if (filter.endDate) query.endDate = filter.endDate
return query
}
export function interpret(raw: unknown, todayInput: string): InterpretResult {
const warnings: string[] = []
const filter = emptyFilter()
const today = parseISODate(todayInput)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
warnings.push('模型没有返回对象,筛选保持为空')
return { filter, warnings, query: toQuery(filter) }
}
const data = raw as Record<string, unknown>
for (const key of Object.keys(data)) {
if (!ALLOWED_FIELDS.has(key)) warnings.push(`已丢弃字段 ${key}`)
}
applyCity(data.city, filter, warnings)
applyStatus(data.status, filter, warnings)
applyAmount(data.amount, filter, warnings)
applyDate(data, filter, warnings, today)
return { filter, warnings, query: toQuery(filter) }
}
export function sameFilter(left: OrderFilter, right: OrderFilter) {
const sameBound = (
amount: number | null,
inclusive: boolean,
otherAmount: number | null,
otherInclusive: boolean,
) => {
if (amount === null && otherAmount === null) return true
return amount === otherAmount && inclusive === otherInclusive
}
return (
left.city === right.city &&
left.status === right.status &&
left.startDate === right.startDate &&
left.endDate === right.endDate &&
sameBound(left.minAmount, left.minInclusive, right.minAmount, right.minInclusive) &&
sameBound(left.maxAmount, left.maxInclusive, right.maxAmount, right.maxInclusive)
)
}
export function matchOrder(order: Order, filter: OrderFilter) {
if (filter.city && order.city !== filter.city) return false
if (filter.status !== 'all' && order.status !== filter.status) return false
if (filter.minAmount !== null) {
const pass = filter.minInclusive ? order.amount >= filter.minAmount : order.amount > filter.minAmount
if (!pass) return false
}
if (filter.maxAmount !== null) {
const pass = filter.maxInclusive ? order.amount <= filter.maxAmount : order.amount < filter.maxAmount
if (!pass) return false
}
if (filter.startDate && order.date < filter.startDate) return false
if (filter.endDate && order.date > filter.endDate) return false
return true
}
export function amountLabel(filter: OrderFilter) {
const parts: string[] = []
if (filter.minAmount !== null) parts.push(`${filter.minInclusive ? '≥' : '>'} ${filter.minAmount}`)
if (filter.maxAmount !== null) parts.push(`${filter.maxInclusive ? '≤' : '<'} ${filter.maxAmount}`)
return parts.length ? parts.join(' 且 ') : '不限'
}
2026-02-31 会被 isISODate 拒绝。Date 会把这一天进位到 3 月,格式化之后对不上原字符串,整段日期丢掉,不会留下「只有开始、没有结束」。斜杠日期同样整段丢掉,金额条件可以单独留下。
sameFilter 在两侧金额都是 null 时,不再比较 inclusive。空筛选的默认值是 true,和「这一侧不存在」是一回事。
五、10 条脏数据,每条锁一个错
这 10 条不调用模型。输入是我在对单时见过的脏 JSON,外加几个故意加错的字段。改别名、改周一、改大于含不含,先跑 runLocalCases()。
继续写在 interpret.ts 底部:
ts
interface LocalCase {
name: string
input: unknown
expected: OrderFilter
warningIncludes: string[]
queryExcludes?: string[]
}
export const LOCAL_CASES: LocalCase[] = [
{
name: '大于不含边界,上周按周一,丢掉 sql',
input: {
city: '上海市',
status: '未支付',
amount: { min: { value: 1000, inclusive: false }, max: null },
datePreset: 'last_week',
startDate: '2026-10-03',
endDate: '2026-10-09',
sql: 'drop table orders',
},
expected: expectFilter({
city: '上海',
status: 'unpaid',
minAmount: 1000,
minInclusive: false,
startDate: '2026-09-28',
endDate: '2026-10-04',
}),
warningIncludes: ['上海市', '已忽略模型自算的日期', 'sql'],
queryExcludes: ['sql'],
},
{
name: '大于等于含边界,不认 op',
input: {
city: '上海',
status: 'unpaid',
amount: { op: 'gte', min: { value: 1000, inclusive: true }, max: null },
datePreset: 'none',
},
expected: expectFilter({
city: '上海',
status: 'unpaid',
minAmount: 1000,
minInclusive: true,
}),
warningIncludes: ['op'],
},
{
name: '区间两端包含,最近7天',
input: {
city: '杭州',
status: '已支付',
amount: {
min: { value: 500, inclusive: true },
max: { value: 2000, inclusive: true },
},
datePreset: 'last_7_days',
},
expected: expectFilter({
city: '杭州',
status: 'paid',
minAmount: 500,
minInclusive: true,
maxAmount: 2000,
maxInclusive: true,
startDate: '2026-10-04',
endDate: '2026-10-10',
}),
warningIncludes: ['已支付'],
},
{
name: '城市不在白名单',
input: { city: '成都', status: '', amount: { min: null, max: null }, datePreset: 'this_month' },
expected: expectFilter({ startDate: '2026-10-01', endDate: '2026-10-31' }),
warningIncludes: ['成都'],
},
{
name: '状态不在白名单',
input: { city: '深圳', status: '退款中', datePreset: 'none' },
expected: expectFilter({ city: '深圳', status: 'all' }),
warningIncludes: ['退款中'],
},
{
name: '最近7天不是上周',
input: { city: '', status: '全部', datePreset: 'last_7_days' },
expected: expectFilter({ startDate: '2026-10-04', endDate: '2026-10-10' }),
warningIncludes: ['全部'],
},
{
name: '斜杠日期整段丢掉',
input: {
city: '北京',
status: '已关闭',
amount: { max: { value: 1000, inclusive: false }, min: null },
datePreset: 'absolute',
startDate: '2026/10/01',
endDate: '2026-10-10',
},
expected: expectFilter({
city: '北京',
status: 'closed',
maxAmount: 1000,
maxInclusive: false,
}),
warningIncludes: ['日期格式无法识别'],
},
{
name: '已取消并进关闭,裸金额丢掉',
input: { city: '广州', status: '已取消', minAmount: 1000, datePreset: 'none' },
expected: expectFilter({ city: '广州', status: 'closed' }),
warningIncludes: ['minAmount', '已取消'],
},
{
name: '沪收成上海,不超过含边界,本月',
input: {
city: '沪',
status: '已付款',
amount: { max: { value: 2000, inclusive: true }, min: null },
datePreset: 'this_month',
},
expected: expectFilter({
city: '上海',
status: 'paid',
maxAmount: 2000,
maxInclusive: true,
startDate: '2026-10-01',
endDate: '2026-10-31',
}),
warningIncludes: ['沪'],
},
{
name: '两侧边界不同,绝对日期',
input: {
city: '广州',
status: '已取消',
amount: {
min: { value: 1000, inclusive: false },
max: { value: 3000, inclusive: true },
},
datePreset: 'absolute',
startDate: '2026-10-01',
endDate: '2026-10-10',
},
expected: expectFilter({
city: '广州',
status: 'closed',
minAmount: 1000,
minInclusive: false,
maxAmount: 3000,
maxInclusive: true,
startDate: '2026-10-01',
endDate: '2026-10-10',
}),
warningIncludes: ['已取消'],
},
]
export function runLocalCases(today = BASELINE_DAY) {
return LOCAL_CASES.map((item) => {
const result = interpret(item.input, today)
const keyOk = Object.keys(result.query).every((key) =>
(QUERY_KEYS as readonly string[]).includes(key),
)
const excludedOk = (item.queryExcludes ?? []).every((key) => !(key in result.query))
const warningOk = item.warningIncludes.every((text) =>
result.warnings.some((warning) => warning.includes(text)),
)
return {
name: item.name,
ok: sameFilter(result.filter, item.expected) && keyOk && excludedOk && warningOk,
warnings: result.warnings,
query: result.query,
}
})
}
逐条对一下表格,避免用例和故事各说各的。
第 1 条。 输入把城市写成上海市,状态写成未支付,日期预设是上周,同时又塞了 10 月 3 日到 10 月 9 日和一段 sql。收成上海、unpaid、不含 1000、9 月 28 日到 10 月 4 日。警告里要同时出现「上海市」「已忽略模型自算的日期」「sql」。参数对象里不能有 sql。用这张筛选去套 10 张订单,命中 A1002、A1010。
第 2 条。 金额对象里故意留了 op: "gte"。判断只看 inclusive: true,所以 1000 含在里面。警告里要出现 op。这张没有日期,上海未支付且金额大于等于 1000 的单是 A1001、A1002、A1010。A1001 就是和第 1 条的差别。
第 3 条。 杭州、已支付、500 到 2000 两端包含、最近 7 天。日期必须是 10 月 4 日到 10 月 10 日,不能是上周。A1006 是杭州、已支付、980、10 月 8 日,会中。A1009 是广州,不会中。
第 4 条。 成都不在城市表里。城市清空,本月 10 月 1 日到 10 月 31 日保留。不能因为城市失败把日期也清掉,运营还要看到「月份是对的,城市没接住」。
第 5 条。 「退款中」没有别名。深圳留下,状态回到全部。若把未知状态直接写进下拉框,el-select 会显示一个不在选项里的值,下次手改也容易乱。
第 6 条。 只给「全部 + 最近 7 天」。日期锁死 10 月 4 日到 10 月 10 日。这条专门防止有人把两个预设收成同一个函数。
第 7 条。 开始日期是 2026/10/01,结束日期格式是对的。仍然整段丢掉,北京和「小于 1000」保留。半截日期比没有日期更危险,会把 10 月以前的单全部卷进来。
第 8 条。 「已取消」收成 closed。minAmount: 1000 写在顶层,不在 amount 里,必须丢掉。我第一版见过模型省事,把数字写在外面,代码如果顺手读了,大于和大于等于又分不清。
第 9 条。 「沪」收成上海,「已付款」收成 paid,「不超过 2000」含边界,本月。A1005 是上海已支付 1688,日期 9 月 28 日,不在本月,不会中。用来确认「不超过」没有把日期条件冲掉。
第 10 条。 大于 1000 且小于等于 3000,绝对日期 10 月 1 日到 10 月 10 日,已取消并进 closed。两侧边界必须同时在。A1009 是广州、已支付、1500、10 月 3 日,状态对不上,不会中。
10 条全绿之后,再用第 1 条和第 2 条去筛那 10 张订单。大于命中 2 张,大于等于命中 3 张。这一步我写在页面加载时,打开就能看见 10/10。
六、10 句人话,专门拿来打模型
本地用例证明收口函数没坏。模型会不会把人话拆成这个结构,是另一件事。期望和本地用例共用 expectFilter,避免两套日期。
ts
export const SENTENCE_CASES: Array<{ sentence: string; expected: OrderFilter }> = [
{
sentence: '上周上海未支付、金额大于 1000 的订单',
expected: expectFilter({
city: '上海',
status: 'unpaid',
minAmount: 1000,
minInclusive: false,
startDate: '2026-09-28',
endDate: '2026-10-04',
}),
},
{
sentence: '上周上海未支付、金额大于等于 1000 的订单',
expected: expectFilter({
city: '上海',
status: 'unpaid',
minAmount: 1000,
minInclusive: true,
startDate: '2026-09-28',
endDate: '2026-10-04',
}),
},
{
sentence: '最近 7 天杭州已支付、金额 500 到 2000',
expected: expectFilter({
city: '杭州',
status: 'paid',
minAmount: 500,
minInclusive: true,
maxAmount: 2000,
maxInclusive: true,
startDate: '2026-10-04',
endDate: '2026-10-10',
}),
},
{
sentence: '本月成都的订单',
expected: expectFilter({ startDate: '2026-10-01', endDate: '2026-10-31' }),
},
{
sentence: '退款中的深圳订单',
expected: expectFilter({ city: '深圳' }),
},
{
sentence: '10 月 8 日北京已关闭、金额小于 1000',
expected: expectFilter({
city: '北京',
status: 'closed',
maxAmount: 1000,
maxInclusive: false,
startDate: '2026-10-08',
endDate: '2026-10-08',
}),
},
{
sentence: '上海市待付款、1000 以上',
expected: expectFilter({
city: '上海',
status: 'unpaid',
minAmount: 1000,
minInclusive: true,
}),
},
{
sentence: '本周全部订单',
expected: expectFilter({ startDate: '2026-10-05', endDate: '2026-10-11' }),
},
{
sentence: '沪,已付款,不超过 2000,上周',
expected: expectFilter({
city: '上海',
status: 'paid',
maxAmount: 2000,
maxInclusive: true,
startDate: '2026-09-28',
endDate: '2026-10-04',
}),
},
{
sentence: '广州 2026 年 10 月 1 日到 10 月 10 日,金额大于 1000 且小于等于 3000',
expected: expectFilter({
city: '广州',
minAmount: 1000,
minInclusive: false,
maxAmount: 3000,
maxInclusive: true,
startDate: '2026-10-01',
endDate: '2026-10-10',
}),
},
]
export function systemPrompt(today: string) {
return [
'你是订单筛选解析器。只输出一个 JSON 对象,不要 Markdown,不要解释。',
'字段只允许 city、status、amount、datePreset、startDate、endDate。',
'city、status 按用户原文摘出,不要自己删「市」,也不要翻译成英文。没有就返回空字符串。',
'amount 形如 {"min":{"value":1000,"inclusive":false},"max":null}。',
'大于:min.inclusive 为 false。大于等于、不低于、以上:min.inclusive 为 true。',
'小于:max.inclusive 为 false。小于等于、不超过、以下:max.inclusive 为 true。',
'「到」「至」两侧 inclusive 都为 true。没有的一侧写 null。',
'inclusive 必须是布尔值。不要输出 op 字段。',
'不要自己计算日历。datePreset 只能是 last_week、last_7_days、this_week、this_month、absolute、none。',
'上周用 last_week,最近 7 天用 last_7_days,本周用 this_week,本月用 this_month。',
'用户给出具体日期时用 absolute,startDate 和 endDate 用 YYYY-MM-DD。',
`没写年份时按 ${today.slice(0, 4)} 年。基准日是 ${today}。`,
'相对日期时 startDate 和 endDate 返回空字符串。',
].join('\n')
}
提示词要求模型保留「上海市」,由别名表收成上海。这样失败时能看出是提示词把城市翻译乱了,还是别名表没收住。基准日写进提示词,和 interpret 用同一个字符串。
「1000 以上」我按大于等于。业务若要按大于,改提示词里那一句,同时把第 7 句的 minInclusive 改成 false。只改一边,按钮打出来就是红的。
打模型时基准日仍固定 2026-10-10。手输那一句可以用日期框里的基准日,10 句回归不用,否则期望日期每天都要改。
七、请求一次,只要 JSON
新建 src/composables/useAiFilter.ts。stream 固定 false。返回正文之后仍然进 interpret。
ts
import { ref } from 'vue'
import { interpret, systemPrompt, type InterpretResult } from '../filters/interpret'
function extractJson(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/)
return JSON.parse((fenced?.[1] ?? text).trim())
}
export function useAiFilter() {
const loading = ref(false)
const error = ref('')
async function parseSentence(sentence: string, today: string): Promise<InterpretResult & { error: string }> {
const empty = interpret({}, today)
const text = sentence.trim()
if (!text) return { ...empty, error: '' }
loading.value = true
error.value = ''
try {
const response = await fetch('/ai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: import.meta.env.VITE_AI_MODEL || 'deepseek-chat',
temperature: 0,
stream: false,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt(today) },
{ role: 'user', content: text },
],
}),
})
if (!response.ok) throw new Error(`请求失败:${response.status}`)
const payload = await response.json()
const content = payload.choices?.[0]?.message?.content
if (typeof content !== 'string') throw new Error('模型没有返回内容')
return { ...interpret(extractJson(content), today), error: '' }
} catch (err) {
const message = err instanceof Error ? err.message : '解析失败'
error.value = message
return { ...empty, error: message }
} finally {
loading.value = false
}
}
return { loading, error, parseSentence }
}
temperature 设为 0,同一句话两次点,结构尽量不变。模型若把 JSON 包在 Markdown 代码块里,extractJson 先剥掉再解析。解析失败就保持空筛选,页面上留错误文案,不把半截字符串写进表单。
八、订单页
新建 src/views/OrderFilter.vue。main.ts 里如果已经 app.use(ElementPlus),下面的组件可以直接用。
黄底行是金额正好 1000 的单。生成之后,警告、金额说法、请求 JSON、命中行数放在同一张卡片里,对单时不用翻控制台。
html
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import {
BASELINE_DAY,
SENTENCE_CASES,
amountLabel,
emptyFilter,
matchOrder,
runLocalCases,
sameFilter,
type Order,
type OrderFilter,
} from '../filters/interpret'
import { useAiFilter } from '../composables/useAiFilter'
const sentence = ref('上周上海未支付、金额大于 1000 的订单')
const baseline = ref(BASELINE_DAY)
const query = reactive<OrderFilter>(emptyFilter())
const warnings = ref<string[]>([])
const queryText = ref('{}')
const localReport = runLocalCases()
const localPassed = localReport.filter((item) => item.ok).length
const modelReport = ref<Array<{ name: string; ok: boolean; detail: string }>>([])
const { loading, error, parseSentence } = useAiFilter()
const orders: Order[] = [
{ id: 'A1001', city: '上海', amount: 1000, status: 'unpaid', date: '2026-09-29' },
{ id: 'A1002', city: '上海', amount: 1280, status: 'unpaid', date: '2026-09-30' },
{ id: 'A1003', city: '上海', amount: 860, status: 'unpaid', date: '2026-10-04' },
{ id: 'A1004', city: '北京', amount: 2200, status: 'unpaid', date: '2026-10-02' },
{ id: 'A1005', city: '上海', amount: 1688, status: 'paid', date: '2026-09-28' },
{ id: 'A1006', city: '杭州', amount: 980, status: 'paid', date: '2026-10-08' },
{ id: 'A1007', city: '深圳', amount: 640, status: 'closed', date: '2026-10-06' },
{ id: 'A1008', city: '北京', amount: 800, status: 'closed', date: '2026-10-08' },
{ id: 'A1009', city: '广州', amount: 1500, status: 'paid', date: '2026-10-03' },
{ id: 'A1010', city: '上海', amount: 2560, status: 'unpaid', date: '2026-10-03' },
]
const statusText: Record<Order['status'], string> = {
unpaid: '未支付',
paid: '已支付',
closed: '已关闭',
}
const filteredOrders = computed(() => orders.filter((order) => matchOrder(order, query)))
function applyResult(filter: OrderFilter, nextWarnings: string[], nextQuery: Record<string, string | number | boolean>) {
Object.assign(query, filter)
warnings.value = nextWarnings
queryText.value = JSON.stringify(nextQuery, null, 2)
}
async function applySentence() {
const today = baseline.value || BASELINE_DAY
const result = await parseSentence(sentence.value, today)
applyResult(result.filter, result.warnings, result.query)
}
async function runModelCases() {
modelReport.value = []
for (const item of SENTENCE_CASES) {
const result = await parseSentence(item.sentence, BASELINE_DAY)
const ok = result.error === '' && sameFilter(result.filter, item.expected)
modelReport.value.push({
name: item.sentence,
ok,
detail: ok
? ''
: result.error ||
`${result.filter.city || '全部城市'} / ${result.filter.status} / ${amountLabel(result.filter)} / ${result.filter.startDate || '不限'}~${result.filter.endDate || '不限'}`,
})
}
}
function resetFilter() {
Object.assign(query, emptyFilter())
warnings.value = []
queryText.value = '{}'
sentence.value = ''
}
</script>
<template>
<div class="page">
<el-card shadow="never">
<div class="sentence">
<el-date-picker v-model="baseline" type="date" value-format="YYYY-MM-DD" placeholder="基准日" />
<el-input v-model="sentence" clearable @keyup.enter="applySentence" />
<el-button type="primary" :loading="loading" @click="applySentence">生成筛选</el-button>
<el-button @click="resetFilter">重置</el-button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<el-alert v-for="item in warnings" :key="item" :title="item" type="warning" :closable="false" class="warn" />
<el-form :model="query" inline class="query">
<el-form-item label="城市">
<el-select v-model="query.city" clearable placeholder="全部" style="width: 120px">
<el-option label="北京" value="北京" />
<el-option label="上海" value="上海" />
<el-option label="广州" value="广州" />
<el-option label="深圳" value="深圳" />
<el-option label="杭州" value="杭州" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" style="width: 120px">
<el-option label="全部" value="all" />
<el-option label="未支付" value="unpaid" />
<el-option label="已支付" value="paid" />
<el-option label="已关闭" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="最小金额">
<el-input-number v-model="query.minAmount" :min="0" :controls="false" :value-on-clear="null" />
<el-checkbox v-model="query.minInclusive" :disabled="query.minAmount === null">包含等于</el-checkbox>
</el-form-item>
<el-form-item label="最大金额">
<el-input-number v-model="query.maxAmount" :min="0" :controls="false" :value-on-clear="null" />
<el-checkbox v-model="query.maxInclusive" :disabled="query.maxAmount === null">包含等于</el-checkbox>
</el-form-item>
<el-form-item label="开始">
<el-date-picker v-model="query.startDate" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="结束">
<el-date-picker v-model="query.endDate" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
</el-form>
<p class="summary">金额:{{ amountLabel(query) }},当前 {{ filteredOrders.length }} 条</p>
<pre class="query-text">{{ queryText }}</pre>
</el-card>
<el-table :data="filteredOrders" border class="table" :row-class-name="({ row }) => (row.amount === 1000 ? 'boundary' : '')">
<el-table-column prop="id" label="订单号" width="120" />
<el-table-column prop="city" label="城市" width="100" />
<el-table-column prop="amount" label="金额" width="120" />
<el-table-column label="状态" width="120">
<template #default="{ row }">{{ statusText[row.status] }}</template>
</el-table-column>
<el-table-column prop="date" label="下单日期" />
</el-table>
<el-card shadow="never" class="report">
<template #header>本地回归 {{ localPassed }}/{{ localReport.length }}</template>
<p>锁基准日 2026-10-10,不请求模型。大于 1000 应命中 A1002、A1010;大于等于再多 A1001。</p>
<ul>
<li v-for="item in localReport" :key="item.name" :class="item.ok ? 'ok' : 'bad'">
{{ item.ok ? '通过' : '失败' }} {{ item.name }}
</li>
</ul>
<el-button :loading="loading" @click="runModelCases">用 10 句人话打模型</el-button>
<ul>
<li v-for="item in modelReport" :key="item.name" :class="item.ok ? 'ok' : 'bad'">
{{ item.ok ? '通过' : '失败' }} {{ item.name }}
<span v-if="item.detail"> --- {{ item.detail }}</span>
</li>
</ul>
</el-card>
</div>
</template>
<style scoped>
.page { padding: 24px; }
.sentence { display: flex; gap: 12px; margin-bottom: 12px; }
.query { margin-top: 12px; }
.summary { margin: 0; color: #334155; }
.query-text {
margin: 8px 0 0;
padding: 12px;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
overflow: auto;
}
.table, .report { margin-top: 16px; }
.warn { margin-bottom: 8px; }
.error { color: #dc2626; }
.ok { color: #15803d; }
.bad { color: #b91c1c; }
:deep(.boundary) { --el-table-tr-bg-color: #fef9c3; }
</style>
App.vue 先挂这个页面:
html
<script setup lang="ts">
import OrderFilter from './views/OrderFilter.vue'
</script>
<template>
<OrderFilter />
</template>
打开页面,本地回归应是 10/10。这一步没有 Key 也能过。输入默认句子,点生成,表格应剩 A1002、A1010。把句子里的「大于」改成「大于等于」再生成,黄底 A1001 出现。请求 JSON 里 minInclusive 从 false 变成 true。
手改「包含等于」时,表格跟着变。模型填错边界,运营可以自己勾,不必再发一句。
接真实接口时,把 queryText 对应的对象交给现有的订单查询。分页、排序仍走原来的接口,这次只换筛选参数从哪来。
九、Key 放哪
.env.local 不要提交:
bash
AI_API_KEY=sk-你的密钥
AI_BASE_URL=https://api.deepseek.com
VITE_AI_MODEL=deepseek-chat
vite.config.ts:
ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
server: {
proxy: {
'/ai': {
target: env.AI_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ai/, ''),
headers: { Authorization: `Bearer ${env.AI_API_KEY}` },
},
},
},
}
})
改完重启 npm run dev。AI_API_KEY 没有 VITE_ 前缀,打包进页面的只有模型名。
换模型只改这两个变量。
| 服务 | AI_BASE_URL | VITE_AI_MODEL |
|---|---|---|
| DeepSeek | https://api.deepseek.com |
deepseek-chat |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-plus |
| 智谱 | https://open.bigmodel.cn/api/paas/v4 |
glm-4-flash |
| Kimi | https://api.moonshot.cn/v1 |
moonshot-v1-8k |
某个模型不认 response_format,删掉这个字段即可。extractJson 仍能从代码块里取 JSON。
打包之后没有 Vite 代理。上线把 /ai/chat/completions 换成自己的后端,Key 留在服务器,返回的 JSON 继续交给 interpret。10 条本地用例跟后端无关,改收口逻辑时照样先跑。
十、对单时另外碰到的问题
本地回归不是 10/10。 看失败那条的名字。日期差一天,查周日是不是退了 6 天。金额差在 1000 这一单上,查 minInclusive,不要把大于写成 >=。
模型 10 句里只有「以上」失败。 提示词和 SENTENCE_CASES 对这个词的定义不一致。两处改成同一个含义再打。
警告写了收成上海,下拉框却是空的。 别名是整词。「上海市」可以,「上海站」不行。要做包含匹配,先加一条用例,写明「北京」不能误伤别的词。
清空金额变成 0。 el-input-number 写了 :value-on-clear="null"。旧版本没有这个属性时,清空后把 undefined 写回 null。留下 0 会变成大于等于 0,10 张订单全部进来。
日期框差一天。 el-date-picker 要 value-format="YYYY-MM-DD"。值若是 Date 对象,和订单字符串 "2026-09-29" 比,结果会空。
401。 Key 没进当前终端,或者改完 .env.local 没有重启。本地 10/10 仍然该是绿的,红的只是模型那一排。
写在后面
这句话值不值得接进后台,看运营是不是还在用微信发筛选。五个固定下拉框就能覆盖的页面,不必加模型。
接了之后,模型只拆句子。大于含不含、上周从周几起、城市叫什么,以这 10 张订单和 10 条脏数据为准。本地 10 条挂了先改代码,模型 10 句挂了先改提示词。期望都写在 expectFilter 里。