329 lines
9.6 KiB
TypeScript
329 lines
9.6 KiB
TypeScript
/**
|
||
* V7 §2.4.3 + §2.4.6 — classify a raw user feedback string into one of
|
||
* eight shapes. The output drives whether the repair orchestrator needs
|
||
* to run and what fields it can extract.
|
||
*
|
||
* We keep this deterministic + rule-based first. The feedback pipeline
|
||
* must run in degraded mode (no LLM, no network) and tests must stay
|
||
* trivially stable.
|
||
*
|
||
* Priority order (first hit wins):
|
||
*
|
||
* 1. `preference` — explicit "use X instead of Y" / 用 X 代替 Y / ...
|
||
* 2. `correction` — "it should be X, not Y" / "应该是 X 不是 Y"
|
||
* 3. `constraint` — "also make sure N" / "还要 …" / "must keep …"
|
||
* 4. `negative` — blanket rejection ("wrong", "不对", "no")
|
||
* 5. `positive` — clear approval
|
||
* 6. `confusion` — user didn't understand ("what do you mean?" / "???")
|
||
* 7. `instruction` — imperative next step
|
||
* 8. `unknown` — no signal
|
||
*/
|
||
|
||
import type { ClassifiedFeedback, UserFeedbackShape } from "./types.js";
|
||
|
||
export interface ClassifierOptions {
|
||
/** Language hints are advisory; the classifier handles mixed text. */
|
||
locales?: readonly ("en" | "zh")[];
|
||
}
|
||
|
||
export function classifyFeedback(
|
||
raw: string,
|
||
opts: ClassifierOptions = {},
|
||
): ClassifiedFeedback {
|
||
const text = (raw ?? "").trim();
|
||
if (!text) {
|
||
return {
|
||
shape: "unknown",
|
||
confidence: 0,
|
||
text: "",
|
||
};
|
||
}
|
||
|
||
const normalized = text.toLowerCase();
|
||
|
||
const preference = detectPreference(text, normalized);
|
||
if (preference) return { text, ...preference };
|
||
|
||
const correction = detectCorrection(text, normalized);
|
||
if (correction) return { text, ...correction };
|
||
|
||
const constraint = detectConstraint(text, normalized);
|
||
if (constraint) return { text, ...constraint };
|
||
|
||
if (matchesAny(normalized, NEGATIVE_PATTERNS)) {
|
||
return {
|
||
shape: "negative",
|
||
confidence: 0.75,
|
||
text,
|
||
};
|
||
}
|
||
|
||
if (matchesAny(normalized, POSITIVE_PATTERNS)) {
|
||
return {
|
||
shape: "positive",
|
||
confidence: 0.75,
|
||
text,
|
||
};
|
||
}
|
||
|
||
if (matchesAny(normalized, CONFUSION_PATTERNS)) {
|
||
return {
|
||
shape: "confusion",
|
||
confidence: 0.7,
|
||
text,
|
||
};
|
||
}
|
||
|
||
if (looksLikeInstruction(text, normalized)) {
|
||
return {
|
||
shape: "instruction",
|
||
confidence: 0.55,
|
||
text,
|
||
};
|
||
}
|
||
|
||
return { shape: "unknown", confidence: 0.3, text };
|
||
}
|
||
|
||
// ─── Preference extraction ────────────────────────────────────────────────
|
||
|
||
function detectPreference(
|
||
raw: string,
|
||
normalized: string,
|
||
): Omit<ClassifiedFeedback, "text"> | null {
|
||
for (const pattern of PREFERENCE_PATTERNS) {
|
||
const match = raw.match(pattern.regex);
|
||
if (!match) continue;
|
||
const prefer = pattern.prefer ? clean(match[pattern.prefer]) : undefined;
|
||
const avoid = pattern.avoid ? clean(match[pattern.avoid]) : undefined;
|
||
if (!prefer && !avoid) continue;
|
||
return {
|
||
shape: "preference",
|
||
confidence: 0.8,
|
||
prefer,
|
||
avoid,
|
||
};
|
||
}
|
||
// No capture groups, but still a soft preference signal
|
||
if (/(prefer|instead|should use|下次用|改用|而不是)/.test(normalized)) {
|
||
return { shape: "preference", confidence: 0.55 };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function clean(s: string | undefined): string | undefined {
|
||
if (!s) return undefined;
|
||
return s.trim().replace(/^["'`]|["'`]$/g, "").trim() || undefined;
|
||
}
|
||
|
||
// ─── Patterns ─────────────────────────────────────────────────────────────
|
||
|
||
const PREFERENCE_PATTERNS: readonly {
|
||
regex: RegExp;
|
||
prefer?: number;
|
||
avoid?: number;
|
||
}[] = [
|
||
// "use X instead of Y"
|
||
{
|
||
regex: /use\s+(?<prefer>.+?)\s+instead\s+of\s+(?<avoid>.+?)([.。!?\n]|$)/i,
|
||
prefer: 1,
|
||
avoid: 2,
|
||
},
|
||
// "prefer X over Y"
|
||
{
|
||
regex: /prefer\s+(?<prefer>.+?)\s+over\s+(?<avoid>.+?)([.。!?\n]|$)/i,
|
||
prefer: 1,
|
||
avoid: 2,
|
||
},
|
||
// "X instead of Y" (no leading verb)
|
||
{
|
||
regex: /([^,.!?\n]+)\s+instead\s+of\s+([^,.!?\n]+)/i,
|
||
prefer: 1,
|
||
avoid: 2,
|
||
},
|
||
// Chinese: "用 X 代替 Y" / "用 X 而不是 Y"
|
||
{
|
||
regex: /用\s*(.+?)\s*(代替|而不是)\s*(.+?)([。!?\n]|$)/,
|
||
prefer: 1,
|
||
avoid: 3,
|
||
},
|
||
// Chinese: "别/不要 Y,用 X"
|
||
{
|
||
regex: /(别|不要|不能)\s*(.+?)[,,]\s*(要)?\s*(用|改用)\s*(.+?)([。!?\n]|$)/,
|
||
prefer: 5,
|
||
avoid: 2,
|
||
},
|
||
// "next time: do X"
|
||
{
|
||
regex: /next time\s*[::]?\s*(.+)/i,
|
||
prefer: 1,
|
||
},
|
||
];
|
||
|
||
const NEGATIVE_PATTERNS: readonly RegExp[] = [
|
||
/\bwrong\b/,
|
||
/\bnot\s+(right|correct|what|that)\b/,
|
||
/\bdon't\s+do\b/,
|
||
/\bdo\s+not\s+do\b/,
|
||
/\bstop\s+that\b/,
|
||
/\bno[,.!? ]/,
|
||
/^(no|nope|nah)$/,
|
||
/不对/,
|
||
/错(了)?/,
|
||
/不要这样/,
|
||
/别这样/,
|
||
];
|
||
|
||
const POSITIVE_PATTERNS: readonly RegExp[] = [
|
||
/\b(great|perfect|awesome|nice work|well done|works|fixed)\b/,
|
||
/\bthanks?\b/,
|
||
/^(yes|ok|okay|sure)[.!?]?$/,
|
||
/好的|太棒了|不错|完美|搞定/,
|
||
];
|
||
|
||
/**
|
||
* V7 §2.4.3 — user expresses confusion or asks why the agent did
|
||
* something. Drives a UI "explain-further" path, not decision repair.
|
||
*/
|
||
const CONFUSION_PATTERNS: readonly RegExp[] = [
|
||
/\bwhat\s+(do\s+you\s+mean|are\s+you\s+(doing|saying))\b/,
|
||
/\bwhy\s+(did|are)\s+you\b/,
|
||
/\bi\s+don'?t\s+(understand|get|follow)\b/,
|
||
/\bnot\s+sure\s+what\b/,
|
||
/\bconfus(ed|ing)\b/,
|
||
/什么意思/,
|
||
/没(看|搞)懂/,
|
||
/为什么(这样|要)/,
|
||
/\?{2,}\s*$/,
|
||
];
|
||
|
||
/**
|
||
* V7 §2.4.3 — user corrects a specific part of the answer ("it should be X,
|
||
* not Y"). Captured separately from `preference` because corrections
|
||
* target the PREVIOUS answer whereas preferences target FUTURE behavior.
|
||
*/
|
||
const CORRECTION_PATTERNS: readonly {
|
||
regex: RegExp;
|
||
/** 1-based index of the "should be" clause in the match. */
|
||
should: number;
|
||
}[] = [
|
||
// "it should be X" / "should be X" / "actually X"
|
||
{ regex: /\b(?:it\s+should\s+be|should\s+be|it\s*'?s?\s+actually)\s+(?<should>.{3,120})/i, should: 1 },
|
||
// "not X, (it's) Y" — take Y as correction
|
||
{ regex: /\bnot\s+.{2,80}[,,]\s*(?:it'?s\s+|its\s+|actually\s+)?(?<should>.{3,120})/i, should: 1 },
|
||
// "the answer is X" right after negation
|
||
{ regex: /\b(?:answer|result|value|output)\s+(?:is|=)\s+(?<should>.{2,120})/i, should: 1 },
|
||
// Chinese: "应该是 X"
|
||
{ regex: /应该是\s*(?<should>.{2,80})/, should: 1 },
|
||
// Chinese: "不是 X 是 Y" → take Y as correction
|
||
{ regex: /不是\s*.{1,40}\s*[,,]?\s*是\s*(?<should>.{2,80})/, should: 1 },
|
||
];
|
||
|
||
/**
|
||
* V7 §2.4.3 — user keeps the direction but tightens scope ("also add N",
|
||
* "keep X but also Y", "but it must …"). Constraints flow into L2/L3
|
||
* scope refinement, so we need to capture them separately from
|
||
* preferences (which are about *which tool* to pick).
|
||
*/
|
||
const CONSTRAINT_PATTERNS: readonly {
|
||
regex: RegExp;
|
||
/** 1-based index of the constraint text. */
|
||
constraint: number;
|
||
}[] = [
|
||
{ regex: /\b(?:also|additionally|on top of that)\s+(?<c>.{3,120})/i, constraint: 1 },
|
||
{ regex: /\b(?:must|has to|needs to)\s+(?<c>.{3,120})/i, constraint: 1 },
|
||
{ regex: /\bmake sure (?:to |that )?(?<c>.{3,120})/i, constraint: 1 },
|
||
{ regex: /\bbut\s+(?:make sure|don'?t forget|keep)\s+(?<c>.{3,120})/i, constraint: 1 },
|
||
// Chinese
|
||
{ regex: /还要\s*(?<c>.{2,80})/, constraint: 1 },
|
||
{ regex: /别忘了\s*(?<c>.{2,80})/, constraint: 1 },
|
||
{ regex: /加(一个|个)?\s*(?<c>.{2,80}(条件|限制|要求|约束))/, constraint: 1 },
|
||
{ regex: /必须\s*(?<c>.{2,80})/, constraint: 1 },
|
||
];
|
||
|
||
function detectCorrection(
|
||
raw: string,
|
||
normalized: string,
|
||
): Omit<ClassifiedFeedback, "text"> | null {
|
||
for (const pat of CORRECTION_PATTERNS) {
|
||
const m = raw.match(pat.regex);
|
||
if (!m) continue;
|
||
const correction = clean(m[pat.should]);
|
||
if (!correction) continue;
|
||
return {
|
||
shape: "correction",
|
||
confidence: 0.75,
|
||
correction,
|
||
};
|
||
}
|
||
// Softer signal: "not quite" / "close but" — correction intent without
|
||
// an explicit "should be X".
|
||
if (/\b(?:not quite|close but|almost|kind of)\b/.test(normalized)) {
|
||
return { shape: "correction", confidence: 0.5 };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function detectConstraint(
|
||
raw: string,
|
||
_normalized: string,
|
||
): Omit<ClassifiedFeedback, "text"> | null {
|
||
for (const pat of CONSTRAINT_PATTERNS) {
|
||
const m = raw.match(pat.regex);
|
||
if (!m) continue;
|
||
const c = clean(m[pat.constraint]);
|
||
if (!c) continue;
|
||
return {
|
||
shape: "constraint",
|
||
confidence: 0.7,
|
||
constraint: c,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
function matchesAny(s: string, patterns: readonly RegExp[]): boolean {
|
||
for (const p of patterns) if (p.test(s)) return true;
|
||
return false;
|
||
}
|
||
|
||
function looksLikeInstruction(raw: string, normalized: string): boolean {
|
||
// Heuristic: starts with an imperative verb OR contains a "then/also"
|
||
// connective with a verb.
|
||
const firstWord = (raw.match(/[A-Za-z\u4e00-\u9fff]+/)?.[0] ?? "").toLowerCase();
|
||
if (IMPERATIVE_VERBS.has(firstWord)) return true;
|
||
return /\b(then|also|next)\s+(run|delete|create|install|try|use|call)\b/.test(
|
||
normalized,
|
||
);
|
||
}
|
||
|
||
const IMPERATIVE_VERBS = new Set([
|
||
"run",
|
||
"delete",
|
||
"create",
|
||
"install",
|
||
"try",
|
||
"use",
|
||
"call",
|
||
"build",
|
||
"deploy",
|
||
"test",
|
||
"add",
|
||
"remove",
|
||
"restart",
|
||
"停止",
|
||
"启动",
|
||
"运行",
|
||
"删除",
|
||
"创建",
|
||
"安装",
|
||
"试试",
|
||
"改成",
|
||
]);
|
||
|
||
// ─── Export for re-use ────────────────────────────────────────────────────
|
||
|
||
export type { UserFeedbackShape };
|