Files
writing-agent/src/blackboard/table-side-effects.ts

276 lines
8.4 KiB
TypeScript
Raw 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.
/**
* 表边沿副作用prev 不满足且 now 满足才触发once 规则记 fired。
* 规则来自 设计.worker集.tables.side_effects。
*/
import type { Blackboard } from "./blackboard.js";
import type { TableDoc } from "./table-cells.js";
export type SideEffectOp = "eq" | "neq" | "gte" | "lte" | "gt" | "lt" | "truthy" | "changed";
export type SideEffectMode = "once" | "edge" | "every_edge";
export type SideEffectAction =
| { type: "write_tag"; tag: string; content: string }
| { type: "replace_tag"; tag: string; content: string }
| { type: "queue_worker"; workerId: string; note?: string };
export type SideEffectRule = {
id: string;
field: string;
op: SideEffectOp;
value?: unknown;
/** once = 边沿 + 记 firededge/every_edge = 仅边沿,可反复跨边沿 */
mode: SideEffectMode;
action: SideEffectAction;
};
export type FiredRegistry = Record<string, { at: string; round?: number }>;
export const SIDE_EFFECT_FIRED_TAG = "运行.表副作用.fired";
export function parseSideEffectRules(tables: unknown): SideEffectRule[] {
if (!tables || typeof tables !== "object" || Array.isArray(tables)) return [];
const raw = (tables as { side_effects?: unknown }).side_effects;
if (!Array.isArray(raw)) return [];
const out: SideEffectRule[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const id = typeof row.id === "string" ? row.id.trim() : "";
const field = typeof row.field === "string" ? row.field.trim() : "";
if (!id || !field) continue;
const op = normalizeOp(row.op);
const mode = normalizeMode(row.mode);
const action = parseAction(row.action);
if (!action) continue;
out.push({ id, field, op, value: row.value, mode, action });
}
return out;
}
function normalizeOp(raw: unknown): SideEffectOp {
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "eq";
const allowed: SideEffectOp[] = [
"eq",
"neq",
"gte",
"lte",
"gt",
"lt",
"truthy",
"changed",
];
return (allowed.includes(s as SideEffectOp) ? s : "eq") as SideEffectOp;
}
function normalizeMode(raw: unknown): SideEffectMode {
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "once";
if (s === "edge" || s === "every_edge") return "every_edge";
return "once";
}
function parseAction(raw: unknown): SideEffectAction | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const a = raw as Record<string, unknown>;
const type = typeof a.type === "string" ? a.type.trim() : "";
if (type === "write_tag" || type === "replace_tag") {
const tag = typeof a.tag === "string" ? a.tag.trim() : "";
const content = typeof a.content === "string" ? a.content : "";
if (!tag) return null;
return { type, tag, content };
}
if (type === "queue_worker") {
const workerId =
typeof a.workerId === "string"
? a.workerId.trim()
: typeof a.worker_id === "string"
? a.worker_id.trim()
: "";
if (!workerId) return null;
return {
type: "queue_worker",
workerId,
note: typeof a.note === "string" ? a.note : undefined,
};
}
return null;
}
export function tableValueMap(doc: TableDoc | null | undefined): Map<string, unknown> {
const map = new Map<string, unknown>();
for (const row of doc?.rows ?? []) {
map.set(row.key, row.value);
}
return map;
}
export function conditionSatisfied(
values: Map<string, unknown>,
rule: SideEffectRule,
prevValues?: Map<string, unknown>,
): boolean {
const now = values.get(rule.field);
if (rule.op === "changed") {
if (!prevValues) return false;
return !sameValue(prevValues.get(rule.field), now);
}
return compareOp(now, rule.op, rule.value);
}
function compareOp(actual: unknown, op: SideEffectOp, expected: unknown): boolean {
switch (op) {
case "eq":
return sameValue(actual, expected);
case "neq":
return !sameValue(actual, expected);
case "truthy":
return Boolean(actual);
case "gte":
case "lte":
case "gt":
case "lt": {
const a = toNumber(actual);
const b = toNumber(expected);
if (a == null || b == null) return false;
if (op === "gte") return a >= b;
if (op === "lte") return a <= b;
if (op === "gt") return a > b;
return a < b;
}
case "changed":
return false;
default:
return false;
}
}
function toNumber(v: unknown): number | null {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) {
return Number(v);
}
return null;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a == null && b == null;
if (typeof a === "number" || typeof b === "number") {
const na = toNumber(a);
const nb = toNumber(b);
return na != null && nb != null && na === nb;
}
return String(a) === String(b);
}
export type SideEffectTrigger = {
rule: SideEffectRule;
reason: "edge";
};
/**
* 对本轮 prev→now 快照算边沿;同 round 同 ruleId 最多一次。
* once已 fired 则跳过;触发后写入 nextFired。
* every_edge每次边沿都触发不记 fired。
*/
export function evaluateSideEffects(params: {
prev: TableDoc | null;
next: TableDoc;
rules: SideEffectRule[];
fired: FiredRegistry;
round?: number;
}): {
triggers: SideEffectTrigger[];
nextFired: FiredRegistry;
queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }>;
} {
const prevMap = tableValueMap(params.prev);
const nextMap = tableValueMap(params.next);
const nextFired: FiredRegistry = { ...params.fired };
const triggers: SideEffectTrigger[] = [];
const seen = new Set<string>();
const queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }> =
[];
for (const rule of params.rules) {
if (seen.has(rule.id)) continue;
if (rule.mode === "once" && nextFired[rule.id]) continue;
const was = conditionSatisfied(prevMap, rule, undefined);
const now = conditionSatisfied(nextMap, rule, prevMap);
// 边沿prev 不满足、now 满足changed 用 prev/now 值差)
const edge =
rule.op === "changed"
? conditionSatisfied(nextMap, rule, prevMap)
: !was && now;
if (!edge) continue;
seen.add(rule.id);
triggers.push({ rule, reason: "edge" });
if (rule.mode === "once") {
nextFired[rule.id] = {
at: new Date().toISOString(),
round: params.round,
};
}
if (rule.action.type === "queue_worker") {
queuedWorkers.push({
workerId: rule.action.workerId,
ruleId: rule.id,
note: rule.action.note,
});
}
}
return { triggers, nextFired, queuedWorkers };
}
export function parseFiredRegistry(raw: string | undefined | null): FiredRegistry {
if (!raw?.trim()) return {};
try {
const doc = JSON.parse(raw) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {};
const out: FiredRegistry = {};
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
if (!k.trim()) continue;
if (v && typeof v === "object" && !Array.isArray(v)) {
const row = v as Record<string, unknown>;
out[k] = {
at: typeof row.at === "string" ? row.at : new Date().toISOString(),
round: typeof row.round === "number" ? row.round : undefined,
};
} else if (v === true) {
out[k] = { at: new Date().toISOString() };
}
}
return out;
} catch {
return {};
}
}
export function stringifyFiredRegistry(fired: FiredRegistry): string {
return JSON.stringify(fired, null, 2);
}
/** 执行 write_tag / replace_tagqueue_worker 只返回,由上层调度 */
export function applySideEffectTagActions(params: {
blackboard: Blackboard;
triggers: SideEffectTrigger[];
source?: string;
}): { writtenTags: string[] } {
const writtenTags: string[] = [];
const source = params.source ?? "system:table-side-effect";
for (const { rule } of params.triggers) {
const action = rule.action;
if (action.type !== "write_tag" && action.type !== "replace_tag") continue;
params.blackboard.write({
tag: action.tag,
content: action.content,
source,
});
writtenTags.push(action.tag);
}
return { writtenTags };
}