memos/apps/memos-local-plugin/core/session/relation-classifier.ts

641 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Turn-relation classifier — V7 §0.1.
*
* Given the previous episode's `q_k` + `y_hat_k` and the **new** user
* message `q_{k+1}`, decide which of three categories applies:
*
* - `revision` — correcting the previous answer (same task, same
* episode). R_human back-propagates to existing
* traces.
* - `follow_up` — same session, new sub-task (new episode).
* - `new_task` — unrelated task (new session).
*
* Decision flow (improved from legacy `memos-local-openclaw` task-processor):
*
* 1. No previous context available → `new_task` @ 0.75.
* 2. Time gap > 2h → `new_task` @ 0.9.
* 3. Heuristic rule fires with confidence ≥ 0.85 → use it.
* 4. LLM available → `completeJson`.
* 5. LLM returns `new_task` with confidence < 0.65 → arbitration pass.
* 6. Heuristic fallback (any rule).
* 7. Default `follow_up` @ 0.5 (safest middle ground per V7 §0.5).
*
* The classifier is pure wrt its dependencies — only the injected
* `LlmClient` + heuristics. Unit tests stub both.
*/
import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js";
import type { LlmClient } from "../llm/index.js";
import { rootLogger } from "../logger/index.js";
import { sanitizeDerivedText } from "../safety/content.js";
import type { RelationDecision, RelationInput, TurnRelation } from "./types.js";
// ─── Heuristic rules ─────────────────────────────────────────────────────
type Tag = "neg" | "quote" | "follow_phrase" | "new_phrase" | "domain_shift" | "pronoun_ref" | "time_gap";
interface Rule {
id: string;
label: string;
kind: TurnRelation;
confidence: number;
test: (inp: RelationInput) => Tag | null;
}
// Negation patterns — match sentence-initial negations that clearly target
// the previous answer. Mid-sentence negations like "如果这个不对的话" are
// left to the LLM to disambiguate.
const NEG_PATTERNS = [
/^不对/,
/^错了/,
/^重做/,
/^改一下/,
/^再来一次/,
/^重新/,
/^而不是/,
/^\s*wrong\b/i,
/^\s*incorrect\b/i,
/^\s*not (what|quite|right|correct)\b/i,
/^\s*no[,.]?\s+(that'?s\s+)?(wrong|incorrect|not right)/i,
/^\s*redo\b/i,
/^\s*try again\b/i,
];
const FOLLOW_PATTERNS = [
/再(帮)?我/i,
/下一个/i,
/另一个类似/i,
/\bnext\b/i,
/\bthen\b/i,
/\balso\b/i,
/\banother (similar|one)\b/i,
/\bmore of (that|this)\b/i,
];
const NEW_TASK_PATTERNS = [
// Strict variant kept for backward compatibility.
/换个(话题|问题|任务|主题|场景)/i,
// Relaxed: allow 15 chars between "换个" and the topic noun, so
// natural phrasings like "换个新任务", "换个下一个话题",
// "换个完全不同的问题" all fire the strong heuristic without
// bouncing through the LLM (which often overrules them as
// follow_up when the new task shares a project / domain).
/换个[^\s。,!?]{1,6}(话题|问题|任务|主题|场景)/i,
// "换下一个 / 换下个 ..." family.
/换下(一)?个[^\s。,!?]{0,6}(话题|问题|任务|主题|场景)?/i,
// "下一个任务 / 下一个话题 ..." prefix family — matches when the
// user opens a brand-new turn with an explicit ordinal cue.
/^\s*下一?个(\S{0,5})?(话题|问题|任务|主题|场景)/i,
// Other explicit Chinese cues.
/现在(帮我)?处理另一个/i,
/先放下/i,
/忘掉之前/i,
// English cues.
/\bnew (task|question|topic|subject)\b/i,
/\bforget (that|about it)\b/i,
/\bchange (of )?(topic|subject|task)\b/i,
/\bmoving on\b/i,
/\bnext (task|topic|question)\b/i,
];
// Short message with pronoun reference — almost always a follow-up to the
// current topic. Ported from legacy `buildTopicJudgeState`.
const PRONOUN_REF_RE = /^[那这它其还哪啥]/;
const RULES: Rule[] = [
// Direct negation at start of message → almost certainly revision.
{
id: "r1_negation_keyword",
label: "negation keyword at start of turn",
kind: "revision",
confidence: 0.85,
test: (inp) => (matchesAny(inp.newUserText.trim(), NEG_PATTERNS) ? "neg" : null),
},
// Quoting / referencing prev assistant output.
{
id: "r2_quotes_prev",
label: "references previous assistant output",
kind: "revision",
confidence: 0.75,
test: (inp) => {
const prev = (inp.prevAssistantText ?? "").trim();
if (prev.length < 20) return null;
const prevWords = prev.split(/\s+/).filter((w) => w.length >= 3);
if (prevWords.length < 8) return null;
const nu = inp.newUserText.toLowerCase();
for (let i = 0; i + 8 <= prevWords.length; i++) {
const window = prevWords
.slice(i, i + 8)
.join(" ")
.toLowerCase();
if (window.length > 24 && nu.includes(window)) return "quote";
}
return null;
},
},
// Short message with Chinese pronoun reference — strong follow-up signal.
// Mirrors legacy: "那XX呢", "这个怎么办", "哪些啊" are almost always
// follow-ups pointing to the current topic.
{
id: "r3_pronoun_ref",
label: "short message with pronoun reference (那/这/它/其/还/哪/啥)",
kind: "follow_up",
confidence: 0.85,
test: (inp) => {
const text = inp.newUserText.trim();
if (text.length > 60) return null;
return PRONOUN_REF_RE.test(text) ? "pronoun_ref" : null;
},
},
// Explicit follow-up language.
{
id: "r4_follow_phrase",
label: "follow-up phrase ('再…' / 'also…' / 'next…')",
kind: "follow_up",
confidence: 0.8,
test: (inp) => (matchesAny(inp.newUserText, FOLLOW_PATTERNS) ? "follow_phrase" : null),
},
// Explicit "new task" language.
{
id: "r5_new_phrase",
label: "new-task phrase ('换个话题' / 'new topic' / 'forget that')",
kind: "new_task",
confidence: 0.85,
test: (inp) => (matchesAny(inp.newUserText, NEW_TASK_PATTERNS) ? "new_phrase" : null),
},
// Large gap → lean toward new_task but NOT strong enough to bypass LLM.
{
id: "r6_time_gap",
label: "time gap > 30min since previous episode",
kind: "new_task",
confidence: 0.6,
test: (inp) => {
const gap = inp.gapMs ?? 0;
const GAP_THRESHOLD_MS = 30 * 60 * 1000;
return gap > GAP_THRESHOLD_MS ? "time_gap" : null;
},
},
// Domain shift — no tag overlap with the previous episode.
{
id: "r7_domain_shift",
label: "no domain-tag overlap with previous episode",
kind: "new_task",
confidence: 0.55,
test: (inp) => {
if (!inp.prevTags || inp.prevTags.length === 0) return null;
const nu = inp.newUserText.toLowerCase();
const overlap = inp.prevTags.some((t) => nu.includes(t.toLowerCase()));
return overlap ? null : "domain_shift";
},
},
];
function matchesAny(text: string, patterns: readonly RegExp[]): boolean {
return patterns.some((p) => p.test(text));
}
function ruleTiePriority(rule: Rule): number {
switch (rule.id) {
case "r5_new_phrase":
return 40;
case "r1_negation_keyword":
return 30;
case "r2_quotes_prev":
return 20;
case "r3_pronoun_ref":
return 10;
default:
return 0;
}
}
// ─── Strong heuristic threshold ──────────────────────────────────────────
const STRONG_HEURISTIC_THRESHOLD = 0.85;
// ─── Idle timeout (mirrors legacy 2h hard-split) ─────────────────────────
const IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
// ─── Low-confidence new_task threshold for arbitration ───────────────────
//
// Raised from 0.65 → 0.8 so the LLM has to be quite sure (confidence ≥ 0.8)
// before its `new_task` verdict is taken at face value. Anything below that
// goes through the second-pass arbitration prompt (which is biased toward
// follow_up). Real-world observation: the primary classifier often returns
// `new_task` at 0.650.75 for messages that are actually sub-tasks of the
// same project — pulling the cut-off up reduces false topic splits at the
// cost of one extra LLM call on borderline turns.
const ARBITRATION_THRESHOLD = 0.8;
// ─── Public factory ──────────────────────────────────────────────────────
export interface RelationClassifierOptions {
llm?: LlmClient;
timeoutMs?: number;
/** Skip LLM path (tests / offline mode). */
disableLlm?: boolean;
}
export interface RelationClassifier {
classify(input: RelationInput): Promise<RelationDecision>;
}
export function createRelationClassifier(
opts: RelationClassifierOptions = {},
): RelationClassifier {
const log = rootLogger.child({ channel: "core.session.relation" });
const llmDisabled = opts.disableLlm ?? !opts.llm;
const timeoutMs = opts.timeoutMs ?? 6_000;
return {
async classify(input: RelationInput): Promise<RelationDecision> {
const newText = (input.newUserText ?? "").trim();
if (!newText) {
return mkDecision("unknown", 0, "empty message", ["empty"]);
}
// No prior context: first turn of the session.
if (!input.prevUserText) {
return mkDecision(
"new_task",
0.75,
"no previous episode in this session",
["bootstrap"],
);
}
// Hard split: idle > 2h, matching legacy taskIdleTimeoutMs.
const gap = input.gapMs ?? 0;
if (gap > IDLE_TIMEOUT_MS) {
log.info("idle_timeout", { gapMs: gap, thresholdMs: IDLE_TIMEOUT_MS });
return mkDecision(
"new_task",
0.9,
`idle ${Math.round(gap / 60_000)}min > 120min threshold`,
["idle_timeout"],
);
}
// Step 1: strongest heuristic.
const fired: { rule: Rule; tag: Tag }[] = [];
for (const rule of RULES) {
const tag = rule.test(input);
if (tag) fired.push({ rule, tag });
}
if (fired.length > 0) {
fired.sort(
(a, b) =>
b.rule.confidence - a.rule.confidence ||
ruleTiePriority(b.rule) - ruleTiePriority(a.rule),
);
const top = fired[0];
if (top.rule.confidence >= STRONG_HEURISTIC_THRESHOLD) {
log.debug("heuristic.strong", {
ruleId: top.rule.id,
kind: top.rule.kind,
confidence: top.rule.confidence,
});
return mkDecision(
top.rule.kind,
top.rule.confidence,
top.rule.label,
[top.rule.id],
);
}
}
// Step 2: LLM classification.
if (!llmDisabled && opts.llm) {
try {
const result = await withTimeout(callLlm(opts.llm, input), timeoutMs, "relation.llm.timeout");
log.debug("llm.ok", {
relation: result.relation,
confidence: result.confidence,
});
// Step 3: Two-pass arbitration for low-confidence new_task.
// Ported from legacy `arbitrateTopicSplit` — if the primary
// classifier says NEW but isn't very sure, run a second prompt
// biased toward SAME to reduce false splits.
if (
result.relation === "new_task" &&
result.confidence < ARBITRATION_THRESHOLD
) {
log.info("arbitration.triggered", {
confidence: result.confidence,
threshold: ARBITRATION_THRESHOLD,
});
try {
const arb = await withTimeout(
callArbitration(opts.llm, input),
timeoutMs,
"relation.arbitration.timeout",
);
log.info("arbitration.result", { relation: arb });
if (arb !== "new_task") {
const signals = ["llm", "arbitration_override"];
if (fired.length > 0) signals.push(...fired.map((f) => `heuristic:${f.rule.id}(weak)`));
return {
relation: "follow_up",
confidence: clamp01(0.55),
reason: "arbitration overrode low-confidence new_task → follow_up",
signals,
llmModel: result.servedBy,
};
}
} catch (err) {
log.warn("arbitration.failed", { err: summarizeErr(err) });
// Arbitration failed — downgrade to follow_up to be safe.
const signals = ["llm", "arbitration_failed_fallback"];
return {
relation: "follow_up",
confidence: clamp01(0.5),
reason: "arbitration failed; defaulting low-confidence new_task → follow_up",
signals,
llmModel: result.servedBy,
};
}
}
const signals = ["llm"];
if (fired.length > 0) signals.push(...fired.map((f) => `heuristic:${f.rule.id}(weak)`));
return {
relation: result.relation,
confidence: clamp01(result.confidence),
reason: result.reason.slice(0, 120),
signals,
llmModel: result.servedBy,
};
} catch (err) {
log.warn("llm.failed", { err: summarizeErr(err) });
}
}
// Step 4: heuristic fallback (highest-confidence rule, even if weak).
if (fired.length > 0) {
const top = fired[0];
return mkDecision(
top.rule.kind,
top.rule.confidence,
`${top.rule.label} (fallback)`,
[top.rule.id, "llm_skipped"],
);
}
// Step 5: default — safest middle ground.
return mkDecision(
"follow_up",
0.5,
"no classifier signal; defaulting to follow_up",
["default_follow_up"],
);
},
};
}
// ─── Private helpers ─────────────────────────────────────────────────────
function mkDecision(
relation: TurnRelation,
confidence: number,
reason: string,
signals: string[],
): RelationDecision {
return {
relation,
confidence: clamp01(confidence),
reason: reason.slice(0, 120),
signals,
};
}
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n > 1) return 1;
return n;
}
function summarizeErr(err: unknown): Record<string, unknown> {
if (err instanceof MemosError) return { code: err.code, message: err.message };
if (err instanceof Error) return { name: err.name, message: err.message };
return { value: String(err) };
}
// ─── LLM path ────────────────────────────────────────────────────────────
const ALLOWED: TurnRelation[] = ["revision", "follow_up", "new_task", "unknown"];
interface LlmRelationAnswer {
relation: TurnRelation;
confidence: number;
reason: string;
servedBy: string;
}
// Prompt incorporates the legacy `TOPIC_JUDGE_PROMPT`'s detailed rules and
// Chinese examples, plus the V7 revision/follow_up/new_task vocabulary.
const RELATION_SYSTEM = `You classify how a NEW user message relates to the previous conversation turn.
Return ONE of:
- "revision" — user is correcting / refining the PREVIOUS answer (same task).
- "follow_up" — continuing, following up, refining, or asking about the SAME topic/domain.
- "new_task" — COMPLETELY UNRELATED domain/topic.
- "unknown" — truly ambiguous.
Return JSON ONLY:
{
"relation": one of the four labels,
"confidence": number in [0, 1],
"reason": short justification (≤ 80 chars)
}
## follow_up (SAME topic) — the new message:
- Continues, follows up on, refines, or corrects the same subject/project/task
- Asks a clarification or next-step question about what was just discussed
- Reports a result, error, or feedback about the current task
- Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = follow_up)
- Is a short acknowledgment (ok, thanks, 好的) in response to the current flow
- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation
- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic
- Shares the same core entity (person, company, event) even if the specific detail or angle differs
## revision — the new message:
- Contains negation directed at the previous answer ("不对", "wrong", "not quite")
- Quotes or references the previous answer's specifics to correct them
- Adds a constraint that only makes sense as a correction to the previous output
## new_task — the new message:
- Introduces a subject from a COMPLETELY DIFFERENT domain (e.g., tech → cooking, work → personal life)
- Has NO logical connection to what was being discussed — no shared entities, events, or themes
- Starts a request about a different project, system, or life area
## Key principles:
- DEFAULT to follow_up unless the topic domain CLEARLY changed. When in doubt, choose follow_up.
- CRITICAL: Short messages (under ~30 chars) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow_up. Only mark them new_task if they explicitly name a completely unrelated domain.
- Different aspects of the SAME project/system are follow_up (e.g., Nginx SSL → Nginx gzip = follow_up)
- Asking about tools, systems, or methods for the current topic is follow_up
- If unsure, lean follow_up with low confidence rather than unknown.
## Examples:
- "配置Nginx" → "加gzip压缩" = follow_up
- "港股调研" → "那处理系统有哪些" = follow_up
- "部署服务器" → "那数据库怎么配" = follow_up
- "配置Nginx" → "做红烧肉" = new_task
- "部署服务器" → "年会安排" = new_task
- "不对应该用443端口" = revision
- "wrong, use port 443 instead" = revision`;
/**
* Build the user-content block for the primary LLM call.
*
* For short / pronoun-heavy messages, we append extra assistant context
* so the LLM can judge whether the user is referencing the current
* topic — mirrors legacy `buildTopicJudgeState` which appends `lastA:`
* for messages < 30 chars or starting with 那/这/它.
*/
function buildLlmUserContent(input: RelationInput): string {
const prevUser = (input.prevUserText ?? "").slice(0, 800);
const prevAssistant = (input.prevAssistantText ?? "").slice(0, 1500);
const newUser = input.newUserText.slice(0, 800);
const parts = [
`PREVIOUS_USER_MESSAGE:\n${prevUser}`,
`PREVIOUS_ASSISTANT_REPLY:\n${prevAssistant}`,
`NEW_USER_MESSAGE:\n${newUser}`,
];
// For short or pronoun-referencing messages, add extra assistant context
// so the LLM sees what the user might be referencing.
const trimmed = input.newUserText.trim();
if (trimmed.length < 30 || PRONOUN_REF_RE.test(trimmed)) {
if (prevAssistant.length > 200) {
parts.push(`NOTE: The new message is very short and may reference the assistant's previous reply above. Consider it a follow_up unless it names a completely unrelated domain.`);
}
}
return parts.join("\n\n");
}
async function callLlm(llm: LlmClient, input: RelationInput): Promise<LlmRelationAnswer> {
const userContent = buildLlmUserContent(input);
const rsp = await llm.completeJson<{ relation: unknown; confidence: unknown; reason: unknown }>(
[
{ role: "system", content: RELATION_SYSTEM },
{ role: "user", content: userContent },
],
{
op: "session.relation.classify",
phase: "session",
episodeId: input.prevEpisodeId,
schemaHint: `{"relation":"revision"|"follow_up"|"new_task"|"unknown","confidence":0..1,"reason":"..."}`,
validate: (v) => {
const o = v as Record<string, unknown>;
if (typeof o.relation !== "string" || !ALLOWED.includes(o.relation as TurnRelation)) {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"relation out of vocabulary",
{ got: o.relation },
);
}
if (typeof o.confidence !== "number") {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"confidence must be a number",
{ got: o.confidence },
);
}
if (typeof o.reason !== "string") {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"reason must be a string",
{ got: o.reason },
);
}
},
malformedRetries: 1,
temperature: 0,
},
);
return {
relation: rsp.value.relation as TurnRelation,
confidence: rsp.value.confidence as number,
reason: sanitizeDerivedText(rsp.value.reason),
servedBy: rsp.servedBy,
};
}
// ─── Arbitration pass (two-pass confirmation) ────────────────────────────
//
// Ported from legacy `TOPIC_ARBITRATION_PROMPT`. When the primary LLM
// returns new_task with low confidence (< 0.65), this second prompt
// re-evaluates with a bias toward SAME, reducing false splits.
const ARBITRATION_SYSTEM = `A classifier flagged this message as possibly a new, unrelated topic (low confidence).
Is it truly UNRELATED, or a sub-question/follow-up of the current conversation?
Tools/methods/details/sub-aspects of the current task = follow_up.
Shared entity/theme/project = follow_up.
Entirely different domain with zero connection = new_task.
When in doubt, choose follow_up.
Reply JSON ONLY: {"relation":"follow_up"|"new_task","reason":"..."}`;
async function callArbitration(llm: LlmClient, input: RelationInput): Promise<TurnRelation> {
const userContent = [
`CURRENT TASK CONTEXT:\n${(input.prevUserText ?? "").slice(0, 600)}`,
`ASSISTANT REPLY:\n${(input.prevAssistantText ?? "").slice(0, 800)}`,
`NEW MESSAGE:\n${input.newUserText.slice(0, 600)}`,
].join("\n\n");
const rsp = await llm.completeJson<{ relation: unknown; reason: unknown }>(
[
{ role: "system", content: ARBITRATION_SYSTEM },
{ role: "user", content: userContent },
],
{
op: "session.relation.arbitrate",
phase: "session",
episodeId: input.prevEpisodeId,
schemaHint: `{"relation":"follow_up"|"new_task","reason":"..."}`,
validate: (v) => {
const o = v as Record<string, unknown>;
if (typeof o.relation !== "string" || !["follow_up", "new_task"].includes(o.relation as string)) {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"arbitration relation must be follow_up or new_task",
{ got: o.relation },
);
}
},
malformedRetries: 1,
temperature: 0,
},
);
return rsp.value.relation as TurnRelation;
}
async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new MemosError(ERROR_CODES.LLM_TIMEOUT, label, { timeoutMs: ms })),
ms,
);
});
try {
return await Promise.race([p, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
/** Exposed for audit / frontend display. */
export function listRelationRules(): ReadonlyArray<{
id: string;
kind: TurnRelation;
label: string;
}> {
return RULES.map((r) => ({ id: r.id, kind: r.kind, label: r.label }));
}