memos/apps/memos-local-plugin/core/feedback/feedback.ts

404 lines
12 KiB
TypeScript

/**
* V7 §2.4.6 — Decision Repair orchestrator.
*
* Entry points:
*
* - `runRepair(input, deps)` — imperative API. Called by the subscriber
* whenever a failure burst is detected or a user message is classified
* as `negative` / `preference`.
*
* - `attachRepairToPolicies(draft, deps)` — append the draft's
* preference / anti-pattern onto the `decisionGuidance` field of each
* referenced policy. Only runs when `config.attachToPolicy === true`.
*
* The orchestrator never mutates state on its own unless it decides to
* persist: every write is a single atomic insert into `decision_repairs`
* plus, optionally, per-policy `update`s. Failures are always scoped:
* logged, emitted as `repair.skipped`, and the orchestrator returns a
* skip result.
*/
import type { Embedder } from "../embedding/types.js";
import type { LlmClient } from "../llm/types.js";
import type { Logger } from "../logger/types.js";
import type { Repos } from "../storage/repos/index.js";
import { ids } from "../id.js";
import { now as nowMs } from "../time.js";
import type {
DecisionRepairRow,
EpochMs,
PolicyId,
PolicyRow,
SessionId,
TraceRow,
} from "../types.js";
import { classifyFeedback } from "./classifier.js";
import { gatherRepairEvidence } from "./evidence.js";
import { synthesizeDraft } from "./synthesize.js";
import type {
ClassifiedFeedback,
DecisionRepairDraft,
FeedbackConfig,
FeedbackEventBus,
RepairInput,
RepairResult,
RepairTrigger,
} from "./types.js";
export interface RepairDeps {
repos: Repos;
llm: LlmClient | null;
embedder: Embedder | null;
bus: FeedbackEventBus;
log: Logger;
config: FeedbackConfig;
}
// ─── Main orchestrator ─────────────────────────────────────────────────────
export async function runRepair(
input: RepairInput,
deps: RepairDeps,
): Promise<RepairResult> {
const startedAt = nowMs() as EpochMs;
const log = deps.log;
const { bus, repos, config } = deps;
log.info("repair.run.start", {
trigger: input.trigger,
contextHash: input.contextHash,
toolId: input.toolId,
});
// Cooldown guard — same context, quickly reissued triggers are squelched.
if (isOnCooldown(repos, input.contextHash, config, startedAt)) {
log.info("repair.cooldown", { contextHash: input.contextHash });
bus.emit({
kind: "repair.skipped",
at: startedAt,
contextHash: input.contextHash,
trigger: input.trigger,
reason: "cooldown",
});
return skip(input, startedAt, "cooldown");
}
// Classify the user text if the caller provided one.
const classified = input.userText
? classifyFeedback(input.userText)
: undefined;
if (classified) {
bus.emit({
kind: "feedback.classified",
at: startedAt,
shape: classified.shape,
confidence: classified.confidence,
});
}
if (!sessionKnown(input)) {
bus.emit({
kind: "repair.skipped",
at: startedAt,
contextHash: input.contextHash,
trigger: input.trigger,
reason: "no-session",
});
return skip(input, startedAt, "no-session");
}
// Gather evidence from recent traces in the same session.
const evidence = gatherRepairEvidence(
{
sessionId: input.sessionId as SessionId,
keyword: input.toolId ?? classified?.prefer ?? classified?.avoid,
limit: config.evidenceLimit,
},
{ repos, config, log: log.child({ channel: "core.feedback.evidence" }) },
);
const valueDiff = computeValueDiff(evidence.highValue, evidence.lowValue);
if (valueDiff < config.valueDelta && !classified) {
// Without an explicit user signal, small deltas aren't worth persisting.
log.info("repair.valueDiff.below_threshold", {
contextHash: input.contextHash,
valueDiff,
threshold: config.valueDelta,
});
bus.emit({
kind: "repair.skipped",
at: startedAt,
contextHash: input.contextHash,
trigger: input.trigger,
reason: "value-delta-low",
});
return skip(input, startedAt, "value-delta-low");
}
// Look up any policies that both high- and low-value evidence point to
// so we can attach the draft to them.
const candidatePolicies = collectCandidatePolicies(
[...evidence.highValue, ...evidence.lowValue],
repos,
);
bus.emit({
kind: "repair.triggered",
at: startedAt,
contextHash: input.contextHash,
trigger: input.trigger,
failureCount: input.failures?.length,
});
const synth = await synthesizeDraft(
{
trigger: input.trigger,
contextHash: input.contextHash,
highValue: evidence.highValue,
lowValue: evidence.lowValue,
classifiedFeedback: classified,
toolId: input.toolId,
candidatePolicies,
},
{
llm: deps.llm,
log: log.child({ channel: "core.feedback.synthesize" }),
config,
},
);
if (!synth.ok) {
log.info("repair.skipped", {
contextHash: input.contextHash,
reason: synth.reason,
highValue: evidence.highValue.length,
lowValue: evidence.lowValue.length,
});
bus.emit({
kind: "repair.skipped",
at: startedAt,
contextHash: input.contextHash,
trigger: input.trigger,
reason: synth.reason,
});
return skip(input, startedAt, synth.reason);
}
const row = persistRepair(repos, synth.draft, startedAt);
log.info("repair.persisted", {
id: row.id,
contextHash: row.contextHash,
confidence: synth.draft.confidence,
severity: synth.draft.severity,
});
bus.emit({
kind: "repair.persisted",
at: nowMs() as EpochMs,
contextHash: row.contextHash,
repairId: row.id,
confidence: synth.draft.confidence,
severity: synth.draft.severity,
});
if (config.attachToPolicy && synth.draft.attachToPolicyIds.length > 0) {
const attached = attachRepairToPolicies(synth.draft, deps);
if (attached.length > 0) {
bus.emit({
kind: "repair.attached",
at: nowMs() as EpochMs,
repairId: row.id,
policyIds: attached,
});
}
}
return {
trigger: input.trigger,
contextHash: input.contextHash,
repairId: row.id,
draft: synth.draft,
skipped: false,
startedAt,
completedAt: nowMs() as EpochMs,
};
}
// ─── Helpers ──────────────────────────────────────────────────────────────
function sessionKnown(input: RepairInput): boolean {
if (!input.sessionId) return false;
return Boolean(input.sessionId);
}
function computeValueDiff(high: TraceRow[], low: TraceRow[]): number {
if (high.length === 0 || low.length === 0) {
// Any single-side signal is already noteworthy — return a neutral
// value that still allows the user-triggered branch to fire.
return Infinity;
}
const meanHigh = mean(high.map((t) => t.value));
const meanLow = mean(low.map((t) => t.value));
return Math.abs(meanHigh - meanLow);
}
function mean(xs: number[]): number {
if (xs.length === 0) return 0;
let sum = 0;
for (const x of xs) sum += x;
return sum / xs.length;
}
function collectCandidatePolicies(traces: TraceRow[], repos: Repos): PolicyRow[] {
// Policies whose sourceEpisodeIds intersect the evidence trace episodes
// are the policies we want to tag. Fetch the small window of active
// policies and filter.
const episodeIds = new Set<string>();
for (const t of traces) episodeIds.add(t.episodeId as string);
if (episodeIds.size === 0) return [];
const policies = repos.policies.list({ status: "active", limit: 200 });
return policies.filter((p) =>
p.sourceEpisodeIds.some((eid) => episodeIds.has(eid as string)),
);
}
function isOnCooldown(
repos: Repos,
contextHash: string,
cfg: FeedbackConfig,
now: EpochMs,
): boolean {
if (cfg.cooldownMs <= 0) return false;
const recent = repos.decisionRepairs.recentForContext(contextHash);
if (recent.length === 0) return false;
const last = recent[0]!;
return now - last.ts < cfg.cooldownMs;
}
function persistRepair(
repos: Repos,
draft: DecisionRepairDraft,
ts: EpochMs,
): DecisionRepairRow {
const owner = ownerFromRepairEvidence(repos, draft);
const row: DecisionRepairRow = {
id: ids.decisionRepair(),
...owner,
ts,
contextHash: draft.contextHash,
preference: draft.preference,
antiPattern: draft.antiPattern,
highValueTraceIds: [...draft.highValueTraceIds],
lowValueTraceIds: [...draft.lowValueTraceIds],
validated: false,
};
repos.decisionRepairs.insert(row);
return row;
}
function ownerFromRepairEvidence(
repos: Repos,
draft: DecisionRepairDraft,
): { ownerAgentKind: string; ownerProfileId: string; ownerWorkspaceId: string | null } {
const traceId = draft.highValueTraceIds[0] ?? draft.lowValueTraceIds[0];
const trace = traceId ? repos.traces.getById(traceId) : null;
return {
ownerAgentKind: trace?.ownerAgentKind ?? "unknown",
ownerProfileId: trace?.ownerProfileId ?? "default",
ownerWorkspaceId: trace?.ownerWorkspaceId ?? null,
};
}
function skip(
input: RepairInput,
startedAt: EpochMs,
reason: string,
): RepairResult {
return {
trigger: input.trigger,
contextHash: input.contextHash,
repairId: null,
draft: null,
skipped: true,
skippedReason: reason,
startedAt,
completedAt: nowMs() as EpochMs,
};
}
// ─── Policy attachment ────────────────────────────────────────────────────
export interface AttachDeps {
repos: Repos;
log: Logger;
}
/**
* Append the repair draft's preference / anti-pattern onto the candidate
* policies' decision_guidance metadata. Returns the list of policy IDs
* that were actually updated.
*/
export function attachRepairToPolicies(
draft: DecisionRepairDraft,
deps: AttachDeps,
): PolicyId[] {
const updated: PolicyId[] = [];
for (const policyId of draft.attachToPolicyIds) {
const policy = deps.repos.policies.getById(policyId);
if (!policy) continue;
const next = mergePolicyGuidance(policy, draft);
if (!next) continue;
deps.repos.policies.upsert(next);
updated.push(policyId);
deps.log.debug("repair.attached.policy", { policyId });
}
return updated;
}
/**
* Update a policy's structured `decisionGuidance` column with the new
* preference / anti-pattern lines from a repair draft. Returns `null`
* when the merge would be a no-op (every line already present), which
* lets the caller skip the write entirely.
*
* Stored in `policies.decision_guidance_json` (migration 001) — no more
* regex-parsing the boundary text.
*/
function mergePolicyGuidance(
policy: PolicyRow,
draft: DecisionRepairDraft,
): PolicyRow | null {
const current = policy.decisionGuidance;
const nextPref = dedupeKeep(current.preference.concat(draft.preference));
const nextAvoid = dedupeKeep(current.antiPattern.concat(draft.antiPattern));
if (
arraysEqual(nextPref, current.preference) &&
arraysEqual(nextAvoid, current.antiPattern)
) {
return null;
}
return {
...policy,
decisionGuidance: { preference: nextPref, antiPattern: nextAvoid },
updatedAt: nowMs() as PolicyRow["updatedAt"],
};
}
function dedupeKeep(xs: readonly string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const x of xs) {
const key = x.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(key);
}
return out;
}
function arraysEqual<T>(a: readonly T[], b: readonly T[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
return true;
}