autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
340 lines (339 loc) • 13.2 kB
JavaScript
/**
* ContentPatcher — Proposal suggestedChanges 消费引擎
*
* 核心职责:
* 1. 从 Proposal.evidence 提取 suggestedChanges
* 2. 解析为结构化 Patch(JSON 或降级为纯文本)
* 3. 创建 Recipe 内容快照(before)
* 4. 应用 patch 到 Recipe 字段
* 5. 创建快照(after)
* 6. 持久化更新
*
* 安全边界:
* - 只修改 Patch 指定的字段,不擅自变更其他内容
* - suggestedChanges 缺失或格式不合规时降级跳过(不阻塞状态转移)
* - 所有变更在 before/after 快照中可追溯
*
* @module service/evolution/ContentPatcher
*/
import Logger from '../../infrastructure/logging/Logger.js';
/* ────────────────────── Patchable Fields ────────────────────── */
/** 允许 patch 的顶层字段白名单 */
const PATCHABLE_FIELDS = new Set([
'coreCode',
'doClause',
'dontClause',
'whenClause',
'content.markdown',
'content.rationale',
'sourceRefs',
'headers',
]);
/* ────────────────────── Class ────────────────────── */
export class ContentPatcher {
#knowledgeRepo;
#sourceRefRepo;
#logger = Logger.getInstance();
constructor(knowledgeRepo, sourceRefRepo) {
this.#knowledgeRepo = knowledgeRepo;
this.#sourceRefRepo = sourceRefRepo;
}
/**
* 从 Proposal evidence 提取 suggestedChanges 并应用到 Recipe
*
* @returns ContentPatchResult — success: 是否成功应用了至少一个 patch
*/
async applyProposal(proposal, patchSource = 'agent-suggestion') {
const recipeId = proposal.targetRecipeId;
// 1. 获取 Recipe 当前内容
const recipe = await this.#getRecipe(recipeId);
if (!recipe) {
return this.#skipResult(recipeId, patchSource, 'Recipe not found');
}
// 2. 提取 suggestedChanges
const rawChanges = this.#extractSuggestedChanges(proposal.evidence);
if (!rawChanges) {
return this.#skipResult(recipeId, patchSource, 'No suggestedChanges in proposal evidence');
}
// 3. 解析为结构化 Patch
const patch = this.#parsePatch(rawChanges);
if (!patch || patch.changes.length === 0) {
return this.#skipResult(recipeId, patchSource, 'suggestedChanges could not be parsed or empty');
}
// 4. 创建 before 快照
const beforeSnapshot = this.#createSnapshot(recipe);
// 5. 应用 patch
const fieldsPatched = this.#applyPatch(recipe, patch.changes);
if (fieldsPatched.length === 0) {
return this.#skipResult(recipeId, patchSource, 'No valid fields to patch');
}
// 6. 持久化
await this.#persistRecipe(recipe);
// 7. 创建 after 快照
const afterSnapshot = this.#createSnapshot(recipe);
this.#logger.info(`[ContentPatcher] Applied ${fieldsPatched.length} patches to recipe ${recipeId}: ${fieldsPatched.join(', ')}`);
return {
success: true,
recipeId,
fieldsPatched,
beforeSnapshot,
afterSnapshot,
patchSource,
skipped: false,
};
}
/* ═══════════════════ Extract ═══════════════════ */
#extractSuggestedChanges(evidence) {
for (const ev of evidence) {
const cast = ev;
if (cast.suggestedChanges &&
typeof cast.suggestedChanges === 'string' &&
cast.suggestedChanges.trim().length > 0) {
return cast.suggestedChanges;
}
}
return null;
}
/* ═══════════════════ Parse ═══════════════════ */
/**
* 解析 suggestedChanges — 优先 JSON,降级为纯文本全量替换
*/
#parsePatch(raw) {
// 尝试 JSON 解析
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed.changes && Array.isArray(parsed.changes)) {
// JSON 有效但 changes 为空 → 视为无 patch
if (parsed.changes.length === 0) {
return null;
}
return {
patchVersion: parsed.patchVersion ?? 1,
changes: parsed.changes,
reasoning: parsed.reasoning ?? '',
};
}
}
catch {
// JSON 解析失败,降级到纯文本
}
}
// 降级:纯文本视为 content.markdown 全量替换(适用于旧格式 Agent 输出)
if (trimmed.length >= 20) {
return {
patchVersion: 1,
changes: [
{
field: 'content.markdown',
action: 'replace',
newValue: trimmed,
},
],
reasoning: 'Fallback: raw text treated as content.markdown replacement',
};
}
return null;
}
/* ═══════════════════ Apply ═══════════════════ */
#applyPatch(recipe, changes) {
const patched = [];
for (const change of changes) {
if (!PATCHABLE_FIELDS.has(change.field)) {
this.#logger.warn(`[ContentPatcher] Skipping non-patchable field: ${change.field}`);
continue;
}
const success = this.#applyOneChange(recipe, change);
if (success) {
patched.push(change.field);
}
}
return patched;
}
#applyOneChange(recipe, change) {
const { field, action } = change;
// content.* 嵌套字段
if (field.startsWith('content.')) {
return this.#applyContentChange(recipe, change);
}
// 顶层字段
if (field === 'sourceRefs' || field === 'headers') {
return this.#applyArrayChange(recipe, field, change);
}
// 简单字符串字段
const key = field;
if (action === 'replace' && change.newValue !== undefined) {
recipe[key] = change.newValue;
return true;
}
if (action === 'append' && change.newValue !== undefined) {
recipe[key] = `${recipe[key]}\n${change.newValue}`;
return true;
}
return false;
}
#applyContentChange(recipe, change) {
const contentObj = safeJsonParse(recipe.content, {});
const subField = change.field.split('.')[1]; // 'markdown' | 'rationale'
if (!subField) {
return false;
}
if (change.action === 'replace' && change.newValue !== undefined) {
contentObj[subField] = change.newValue;
recipe.content = JSON.stringify(contentObj);
return true;
}
if (change.action === 'replace-section' && change.section && change.newContent) {
const current = contentObj[subField] ?? '';
const updated = this.#replaceSection(current, change.section, change.newContent);
if (updated !== current) {
contentObj[subField] = updated;
recipe.content = JSON.stringify(contentObj);
return true;
}
return false;
}
if (change.action === 'append' && change.newValue !== undefined) {
const current = contentObj[subField] ?? '';
contentObj[subField] = `${current}\n${change.newValue}`;
recipe.content = JSON.stringify(contentObj);
return true;
}
return false;
}
#applyArrayChange(recipe, field, change) {
if (change.action === 'replace' && change.newValue !== undefined) {
try {
const newArr = JSON.parse(change.newValue);
if (Array.isArray(newArr)) {
recipe[field] = JSON.stringify(newArr);
return true;
}
}
catch {
// invalid JSON array
}
return false;
}
return false;
}
/**
* 替换 Markdown 中指定 section(基于标题行匹配)
*/
#replaceSection(markdown, sectionTitle, newContent) {
const lines = markdown.split('\n');
const titleLine = lines.findIndex((line) => line.trim() === sectionTitle.trim());
if (titleLine === -1) {
// Section 不存在 → 追加
return `${markdown}\n\n${newContent}`;
}
// 找到 section 结尾(下一个同级或更高级标题)
const headingLevel = (sectionTitle.match(/^#+/) ?? [''])[0].length;
let endLine = lines.length;
for (let i = titleLine + 1; i < lines.length; i++) {
const match = lines[i].match(/^(#+)\s/);
if (match && match[1].length <= headingLevel) {
endLine = i;
break;
}
}
const before = lines.slice(0, titleLine);
const after = lines.slice(endLine);
return [...before, newContent, ...after].join('\n');
}
/* ═══════════════════ Snapshot ═══════════════════ */
#createSnapshot(recipe) {
const contentObj = safeJsonParse(recipe.content, {});
return {
coreCode: recipe.coreCode,
doClause: recipe.doClause,
dontClause: recipe.dontClause,
whenClause: recipe.whenClause,
content: {
markdown: contentObj.markdown ?? undefined,
rationale: contentObj.rationale ?? undefined,
},
sourceRefs: safeJsonParse(recipe.sourceRefs, []),
headers: safeJsonParse(recipe.headers, []),
};
}
/* ═══════════════════ DB ═══════════════════ */
async #getRecipe(recipeId) {
const entry = await this.#knowledgeRepo.findById(recipeId);
if (!entry) {
return null;
}
// 从 recipe_source_refs 表读取关联的源引用路径
const refs = this.#sourceRefRepo.findByRecipeId(entry.id);
const sourcePaths = refs.map((r) => r.sourcePath);
return {
id: entry.id,
title: entry.title,
coreCode: entry.coreCode || '',
doClause: entry.doClause || '',
dontClause: entry.dontClause || '',
whenClause: entry.whenClause || '',
content: JSON.stringify(entry.content || {}),
sourceRefs: JSON.stringify(sourcePaths),
headers: JSON.stringify(entry.headers || []),
};
}
async #persistRecipe(recipe) {
await this.#knowledgeRepo.update(recipe.id, {
coreCode: recipe.coreCode,
doClause: recipe.doClause,
dontClause: recipe.dontClause,
whenClause: recipe.whenClause,
content: safeJsonParse(recipe.content, {}),
headers: safeJsonParse(recipe.headers, []),
});
// 同步 sourceRefs 到 recipe_source_refs 表
const newPaths = safeJsonParse(recipe.sourceRefs, []);
const now = Math.floor(Date.now() / 1000);
this.#sourceRefRepo.deleteByRecipeId(recipe.id);
for (const sourcePath of newPaths) {
this.#sourceRefRepo.upsert({
recipeId: recipe.id,
sourcePath,
status: 'active',
verifiedAt: now,
});
}
}
/* ═══════════════════ Helpers ═══════════════════ */
#skipResult(recipeId, patchSource, reason) {
this.#logger.info(`[ContentPatcher] Skipped for ${recipeId}: ${reason}`);
const emptySnapshot = {
coreCode: '',
doClause: '',
dontClause: '',
whenClause: '',
content: {},
sourceRefs: [],
headers: [],
};
return {
success: false,
recipeId,
fieldsPatched: [],
beforeSnapshot: emptySnapshot,
afterSnapshot: emptySnapshot,
patchSource,
skipped: true,
skipReason: reason,
};
}
}
/* ────────────────────── Util ────────────────────── */
function safeJsonParse(json, fallback) {
if (!json) {
return fallback;
}
try {
return JSON.parse(json);
}
catch {
return fallback;
}
}