+"""
+from __future__ import annotations
+
+import argparse
+import itertools
+import json
+from pathlib import Path
+
+from run import AUTO_DETAIL_SCORING, AUTO_DETAIL_VERSION, ROOT, auto_detail_items, run_validity
+from iris.consolidation import INFERENCE_CEILING, lower_tier
+from iris.evaluation import _json_sha256, _read_json, _verified_material_file, _write_json
+from score import combine, exact, ratio, require, validate_payload
+
+CORRECT, MISSED, WRONG = 'correct', 'missed', 'wrong'
+JUDGMENT_FIELDS = ('format_version', 'case_id', 'merge_detail_results')
+
+
+def _new_annotations(before, after):
+ known = {a['id'] for a in before['annotations']}
+ return [a for a in after['annotations'] if a['id'] not in known]
+
+
+def _kinds(before, after):
+ return [a['kind'] for a in _new_annotations(before, after)]
+
+
+def _unchanged(before, after):
+ return (after['merged_into'] is None and
+ all(after[key] == before[key] for key in ('content', 'belief', 'retention', 'lifecycle')))
+
+
+def _moved(before, after):
+ """Body, score or lifecycle changed; annotations alone are not a write to the fact."""
+ return after['merged_into'] is not None or any(after[key] != before[key] for key in ('content', 'belief', 'lifecycle'))
+
+
+def _forgotten_by_rule(before, after, threshold):
+ return (after['content'] == before['content'] and after['belief'] == min(before['belief'], INFERENCE_CEILING)
+ and after['lifecycle'] == 'forgotten' and after['retention'] == min(before['retention'], threshold-1))
+
+
+def _lowered_by_rule(before, after):
+ return (after['content'] == before['content'] and after['belief'] == lower_tier(before['belief'])
+ and after['lifecycle'] == before['lifecycle'] and after['retention'] == before['retention'])
+
+
+def judge_contradiction(label, memory, threshold):
+ (old_before, old_after), (new_before, new_after) = memory(label['old']), memory(label['new'])
+ relation = label['relation']
+ if relation == 'supersedes':
+ pointed = any(a['kind'] == 'superseded' and a['superseded_by'] == new_after['id']
+ for a in _new_annotations(old_before, old_after))
+ if _forgotten_by_rule(old_before, old_after, threshold) and pointed and _unchanged(new_before, new_after) and not _kinds(new_before, new_after):
+ return CORRECT
+ # The newer statement must stay; touching it, or the older body, is backwards.
+ wrong = _moved(new_before, new_after) or old_after['content'] != old_before['content'] or old_after['merged_into'] is not None
+ return WRONG if wrong else MISSED
+ if relation == 'reported_vs_self':
+ if _lowered_by_rule(old_before, old_after) and 'disputed' in _kinds(old_before, old_after) and _unchanged(new_before, new_after) and not _kinds(new_before, new_after):
+ return CORRECT
+ wrong = (_moved(new_before, new_after) or old_after['lifecycle'] != old_before['lifecycle'] or old_after['content'] != old_before['content']
+ or old_after['merged_into'] is not None or (old_after['belief'] != old_before['belief'] and old_after['belief'] != lower_tier(old_before['belief'])))
+ return WRONG if wrong else MISSED
+ pair = [memory(key) for key in label['keys']]
+ if any(_moved(before, after) for before, after in pair):
+ return WRONG
+ if relation == 'coexist':
+ return CORRECT if all(_unchanged(b, a) and not _kinds(b, a) for b, a in pair) else MISSED
+ return CORRECT if all(_unchanged(b, a) and 'disputed' in _kinds(b, a) for b, a in pair) else MISSED
+
+
+def judge_dependency(label, memory, threshold):
+ before, after = memory(label['derived'])
+ annotated = 'insufficient_support' in _kinds(before, after)
+ if label['expected'] == 'forget':
+ if _forgotten_by_rule(before, after, threshold) and annotated:
+ return CORRECT
+ wrong = after['content'] != before['content'] or after['merged_into'] is not None or after['lifecycle'] == 'deleted' or after['belief'] > before['belief']
+ return WRONG if wrong else MISSED
+ if _unchanged(before, after) and not annotated:
+ return CORRECT
+ return WRONG if _moved(before, after) or annotated else MISSED
+
+
+def _destination(payload, key):
+ after = {m['id']: m for m in payload['after']['memories']}
+ current, seen = payload['before']['key_map'][key], set()
+ while after[current]['merged_into'] is not None:
+ if current in seen:
+ return None
+ seen.add(current)
+ current = after[current]['merged_into']
+ return current
+
+
+def judge_merge_label(pair, expected, memory, payload):
+ joined = _destination(payload, pair[0]) == _destination(payload, pair[1]) is not None
+ if expected == 'keep_both':
+ if joined:
+ return WRONG
+ bodies = [memory(key) for key in pair]
+ return CORRECT if all(after['content'] == before['content'] and after['merged_into'] is None for before, after in bodies) else WRONG
+ return CORRECT if joined else MISSED
+
+
+def _merge_integrity(merge):
+ """Mechanics the merge write must satisfy, independent of labels."""
+ problems = []
+ result = merge['result']
+ if result['lifecycle'] != 'active' or result['is_placeholder']:
+ problems.append('result_not_active')
+ absorbed = merge['absorbed_memories']
+ if {x['id'] for x in absorbed} != set(merge['input_ids'])-{result['id']} or any(
+ x['lifecycle'] != 'deleted' or x['merged_into'] != result['id'] for x in absorbed):
+ problems.append('invalid_placeholder')
+ if result['belief'] > max(x['belief'] for x in merge['before_memories']):
+ problems.append('belief_raised')
+ source_keys = {s['key'] for x in merge['before_memories'] for s in x['sources']}
+ if not source_keys <= {s['key'] for s in result['sources']}:
+ problems.append('sources_not_inherited')
+ return problems
+
+
+def score_case(row):
+ payload = row['input']
+ case, expected = payload['case'], payload['case']['expected']
+ threshold = payload['forget_threshold']
+ keymap = payload['before']['key_map']
+ before = {m['id']: m for m in payload['before']['memories']}
+ after = {m['id']: m for m in payload['after']['memories']}
+
+ def memory(key):
+ return before[keymap[key]], after[keymap[key]]
+
+ items, covered = [], set()
+ for index, label in enumerate(expected.get('auto_contradictions', [])):
+ items.append({'kind': 'contradiction', 'index': index, 'keys': label['keys'], 'relation': label['relation'],
+ 'result': judge_contradiction(label, memory, threshold)})
+ covered.add(label['old'])
+ for index, label in enumerate(expected.get('auto_dependencies', [])):
+ items.append({'kind': 'dependency', 'index': index, 'derived': label['derived'], 'expected': label['expected'],
+ 'result': judge_dependency(label, memory, threshold)})
+ covered.add(label['derived'])
+ merge_labels = [(tuple(label['pair']), label['expected']) for label in expected.get('mutual_merges', [])]
+ labelled = {frozenset(pair) for pair, _ in merge_labels}
+ merge_labels += [(tuple(pair), 'merge') for pair in expected.get('merges', []) if frozenset(pair) not in labelled]
+ for pair, wanted in merge_labels:
+ items.append({'kind': 'merge', 'pair': list(pair), 'expected': wanted, 'result': judge_merge_label(pair, wanted, memory, payload)})
+ # Union-find over expected merges: a chain may legitimately pass through several keys.
+ component = {key: key for key in keymap}
+ def root(key):
+ while component[key] != key:
+ key = component[key]
+ return key
+ for pair, wanted in merge_labels:
+ if wanted == 'merge':
+ component[root(pair[1])] = root(pair[0])
+ unexpected = []
+ for merge in payload['actual_merges']:
+ if any(root(a) != root(b) for a, b in itertools.combinations(merge['input_keys'], 2)):
+ unexpected.append({'kind': 'unexpected_merge', 'action_id': merge['action_id'], 'keys': merge['input_keys']})
+ merged_keys = {key for merge in payload['actual_merges'] for key in merge['input_keys']}
+ for key, mid in keymap.items():
+ if key not in covered and key not in merged_keys and _moved(before[mid], after[mid]):
+ unexpected.append({'kind': 'unexpected_change', 'key': key})
+ protection = [{'key': key, 'passed': _unchanged(*memory(key)) and not _kinds(*memory(key)) and
+ after[keymap[key]]['pinned'] == before[keymap[key]]['pinned'] and
+ after[keymap[key]]['last_edit'] == before[keymap[key]]['last_edit']}
+ for key in expected.get('protected', [])]
+ damage = []
+ results = {memory_id for merge in payload['actual_merges'] for memory_id in merge['input_ids']}
+ for mid, old in before.items():
+ new = after[mid]
+ if mid in results:
+ continue
+ if new['content'] != old['content']:
+ damage.append({'reason': 'content_rewritten', 'memory_id': mid})
+ if new['lifecycle'] == 'deleted' and old['lifecycle'] != 'deleted':
+ damage.append({'reason': 'unexpected_deletion', 'memory_id': mid})
+ if new['belief'] > old['belief']:
+ damage.append({'reason': 'belief_raised', 'memory_id': mid})
+ if new['lifecycle'] == 'forgotten' and old['lifecycle'] != 'forgotten' and new['retention'] >= threshold:
+ damage.append({'reason': 'forgotten_above_threshold', 'memory_id': mid})
+ for merge in payload['actual_merges']:
+ damage += [{'reason': reason, 'action_id': merge['action_id']} for reason in _merge_integrity(merge)]
+ for mid, new in after.items():
+ for annotation in _new_annotations(before[mid], new):
+ target = annotation['superseded_by']
+ if annotation['kind'] == 'superseded' and (target == mid or target not in after or after[target]['lifecycle'] == 'deleted'):
+ damage.append({'reason': 'invalid_superseded_target', 'memory_id': mid})
+ return {'case_id': case['id'], 'items': items, 'unexpected_actions': unexpected, 'protection': protection,
+ 'write_damage': damage}
+
+
+def validate_detail_judgment(judgment, items, case_id):
+ exact(judgment, JUDGMENT_FIELDS)
+ require(type(judgment['format_version']) is int and judgment['format_version'] == 1 and judgment['case_id'] == case_id, 'detail judgment case mismatch')
+ results = judgment['merge_detail_results']
+ require(isinstance(results, list) and len(results) == len(items), 'detail result length mismatch')
+ for result, item in zip(results, items, strict=True):
+ exact(result, ('action_id', 'must_keep_present', 'must_not_add_absent', 'reason'))
+ require(result['action_id'] == item['action_id'], 'detail action mismatch')
+ for field, labels in (('must_keep_present', item['must_keep']), ('must_not_add_absent', item['must_not_add'])):
+ require(isinstance(result[field], list) and len(result[field]) == len(labels), 'detail label length mismatch')
+ require(all(type(value) is bool for value in result[field]), 'expected a JSON boolean')
+ require(isinstance(result['reason'], str) and bool(result['reason'].strip()), 'detail reason required')
+ return judgment
+
+
+def load_materials(directory):
+ manifest = _read_json(directory/'manifest.json')
+ require(manifest.get('format_version') == 1 and manifest.get('evaluation') == 'consolidation_auto', 'invalid auto materials')
+ require(manifest['materials_sha256'] == _json_sha256({k: v for k, v in manifest.items() if k != 'materials_sha256'}), 'manifest digest mismatch')
+ rows = _read_json(_verified_material_file(directory, 'run.json', manifest['run_sha256']))['rows']
+ instructions = _verified_material_file(directory, 'merge-details-scoring.md', manifest['merge_details_scoring_sha256'])
+ require(instructions.read_text(encoding='utf-8') == AUTO_DETAIL_SCORING, 'merge detail scoring differs')
+ require(len(rows) == len(manifest['cases']), 'case count mismatch')
+ for index, (row, entry) in enumerate(zip(rows, manifest['cases'], strict=True)):
+ exact(entry, ('case_id', 'file', 'sha256', 'judgment_file'))
+ require(entry['file'] == f'cases/{index:04}.json' and entry['judgment_file'] == f'{index:04}.json', 'invalid case path/order')
+ document = _read_json(_verified_material_file(directory, entry['file'], entry['sha256']))
+ require(document['case_id'] == entry['case_id'] == row['input']['case']['id'], 'case mismatch')
+ require(document['merges'] == auto_detail_items(row['input']), 'case merge items mismatch')
+ require(document['source_sha256'] == manifest['run']['source_sha256'] and document['scoring_version'] == AUTO_DETAIL_VERSION, 'run binding mismatch')
+ validate_payload(row['input'])
+ return manifest, rows
+
+
+def load_detail_rounds(manifest, rows, directories):
+ rounds = []
+ for directory in directories:
+ binding = _read_json(directory/'manifest.json')
+ exact(binding, ('materials_sha256',))
+ require(binding['materials_sha256'] == manifest['materials_sha256'], 'detail material mismatch')
+ expected = {'manifest.json', *(entry['judgment_file'] for entry in manifest['cases'])}
+ require({p.name for p in directory.iterdir() if p.is_file()} == expected, 'detail files missing or unexpected')
+ rounds.append([validate_detail_judgment(_read_json(directory/entry['judgment_file']), auto_detail_items(row['input']), entry['case_id'])
+ for entry, row in zip(manifest['cases'], rows, strict=True)])
+ return rounds
+
+
+def call_counts(rows):
+ """Same validity vocabulary as the other evaluations: timeouts, 429, truncation, invalid calls."""
+ calls = [c for row in rows for c in row['calls']]
+ error_text = lambda call: (call.get('error') or '').lower()
+ return {'calls': len(calls),
+ 'timeouts': sum('timeout' in error_text(c) for c in calls),
+ 'rate_limited': sum('429' in error_text(c) or 'rate' in error_text(c) for c in calls),
+ 'truncated': sum(c['finish_reason'] not in (None, 'stop') for c in calls),
+ 'invalid_calls': sum(c['result'] in ('invalid_output', 'item_error') for c in calls)}
+
+
+def score(materials, out, *, detail_judgments=(), judge_model=None):
+ require(len(detail_judgments) <= 2, 'at most two independent detail rounds')
+ require(not detail_judgments or (judge_model and judge_model.strip()), 'judge model required with detail judgments')
+ manifest, rows = load_materials(materials)
+ rounds = load_detail_rounds(manifest, rows, detail_judgments)
+ cases = [score_case(row) for row in rows]
+ disagreements, details, detail_cases = [], [], []
+ if rounds:
+ for index, row in enumerate(rows):
+ judgment = rounds[0][index]
+ if len(rounds) == 2:
+ judgment = combine(judgment, rounds[1][index], row['input']['case']['id'], disagreements)
+ results = judgment['merge_detail_results']
+ lost = [r['action_id'] for r in results if not all(r['must_keep_present']) or not all(r['must_not_add_absent'])]
+ details.append(ratio(len(lost), len(results)))
+ detail_cases.append({'case_id': row['input']['case']['id'], 'lost_action_ids': lost, 'judgment': judgment})
+ items = [item for case in cases for item in case['items']]
+ unexpected = sum(len(case['unexpected_actions']) for case in cases)
+ wrong = sum(item['result'] == WRONG for item in items)+unexpected
+ protection = [check for case in cases for check in case['protection']]
+ validity = [run_validity(row) for row in rows]
+ report = {'format_version': 1, 'evaluation': 'consolidation_auto', 'resolution': manifest['run']['resolution'],
+ 'corpus_sha256': manifest['run']['corpus']['sha256'], 'source_sha256': manifest['run']['source_sha256'],
+ 'materials_sha256': manifest['materials_sha256'], 'valid': all(v['valid'] for v in validity), 'run_validity': validity,
+ 'call_counts': call_counts(rows),
+ 'mishandling': ratio(wrong, len(items)+unexpected), 'correct': ratio(sum(item['result'] == CORRECT for item in items), len(items)),
+ 'protection': ratio(sum(check['passed'] for check in protection), len(protection)),
+ 'write_damage': ratio(sum(bool(case['write_damage']) for case in cases), len(cases)),
+ 'detail_loss': ratio(sum(d['numerator'] for d in details), sum(d['denominator'] for d in details)) if rounds else None,
+ 'detail_judge_rounds': len(rounds), 'judge_model': judge_model, 'detail_disagreements': disagreements,
+ 'detail_cases': detail_cases, 'cases': cases}
+ report['gates'] = {
+ 'mishandling_at_most_5_percent': report['mishandling']['rate'] is not None and report['mishandling']['rate'] <= .05,
+ 'write_damage_zero': report['write_damage']['numerator'] == 0,
+ 'protection_full': report['protection']['rate'] in (None, 1),
+ 'detail_loss_zero': None if report['detail_loss'] is None or report['detail_loss']['rate'] is None else report['detail_loss']['numerator'] == 0,
+ 'correct_at_least_80_percent': report['correct']['rate'] is not None and report['correct']['rate'] >= .8}
+ out.mkdir(parents=True, exist_ok=False)
+ _write_json(out/'report.json', report)
+ return report
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('--materials', type=Path, required=True)
+ parser.add_argument('--detail-judgments', type=Path, action='append', default=[])
+ parser.add_argument('--judge-model')
+ parser.add_argument('--out', type=Path, required=True)
+ args = parser.parse_args()
+ if args.out.resolve().is_relative_to(ROOT):
+ parser.error('full artifacts must be outside repository')
+ report = score(args.materials, args.out, detail_judgments=args.detail_judgments, judge_model=args.judge_model)
+ print(json.dumps({k: v for k, v in report.items() if k not in ('cases', 'detail_cases', 'run_validity')}, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/evals/consolidation_eval/merge_details_scoring_auto_v1.md b/evals/consolidation_eval/merge_details_scoring_auto_v1.md
new file mode 100644
index 00000000..26923597
--- /dev/null
+++ b/evals/consolidation_eval/merge_details_scoring_auto_v1.md
@@ -0,0 +1,43 @@
+# 互有细节自动合并的细节保全判分 auto_v1
+
+本说明只用于 `resolution=auto_v1` 的外部判分,版本名 `merge_details_auto_v1`。误处理、正确处理、保护和写回损坏由 `auto_score.py` 按规格确定性计算,不需要判分,也不要在这里重复判断。只判一件事:合并后的正文有没有丢掉该保留的细节,或者加进不该有的内容。
+
+只使用本轮材料,不查看另一轮判分。把 `detail-judgment-template` 复制到独立目录,保留其中的 `manifest.json`(它绑定材料指纹),按案例逐个填写同名 JSON 文件。两轮互不可见;分数汇总时任何一轮的不利结论都算不利。
+
+## 判什么
+
+每个案例的 `cases/<编号>.json` 中,`merges` 列出实际发生、且语料标注了 `must_keep`/`must_not_add` 的合并。`before` 是合并前的两条正文,`result_content` 是合并后的正文。
+
+对每个 `must_keep` 片段,按顺序在 `must_keep_present` 中给一个布尔值:
+
+- `true`:合并后的正文表达了这个细节。语义等价可以算保留,例如“每周一次”和“一周一次”;量词、人称写法的小差异不算丢失。
+- `false`:细节缺失,或表达被改变了:限定被去掉或放宽、数字或时间不一致、否定不一致、对象或条件变了。只在来源或历史里存在、正文里没有的,算缺失。
+
+对每个 `must_not_add` 片段,按顺序在 `must_not_add_absent` 中给一个布尔值:
+
+- `true`:合并后的正文没有写出这个内容。
+- `false`:写出了这个内容,或表达了相同的意思(换一种说法也算)。
+
+`reason` 必填,写清具体判断依据:正文中哪句话对应或缺失哪个细节。不要改写正文,也不要给建议。
+
+## 格式
+
+```json
+{"format_version": 1, "case_id": "...",
+ "merge_detail_results": [
+ {"action_id": "co-12",
+ "must_keep_present": [true, false],
+ "must_not_add_absent": [true],
+ "reason": "..."}
+ ]}
+```
+
+数组长度和顺序与案例文件一一对应,不得增删或调换;布尔值只接受 `true`/`false`,不接受 null、数字或字符串。`action_id` 和 `case_id` 必须与模板一致。
+
+## 汇总
+
+- 两轮逐字段取“与”,任意一轮判 `false` 即为 `false`,分歧单列。
+- 一次合并只要有一个 `must_keep_present` 或 `must_not_add_absent` 为 `false`,就计入“合并丢细节”的分子;分母为这些带标注的实际合并次数,没有合并时为 null。
+- 门槛为丢细节 0。未提供判分时该指标为 null,不能据此宣称质量通过。
+
+假模型只验证流程,不能作为真实模型的质量结论。
diff --git a/evals/consolidation_eval/run.py b/evals/consolidation_eval/run.py
index b018615c..42f04220 100644
--- a/evals/consolidation_eval/run.py
+++ b/evals/consolidation_eval/run.py
@@ -1,6 +1,9 @@
"""Frozen-corpus consolidation runner. No product CLI integration; no model judging.
uv run python evals/consolidation_eval/run.py --method broad --out /external/run
+uv run python evals/consolidation_eval/run.py --method broad --resolution auto_v1 --out /external/run
+
+auto_v1 defaults to evals/consolidation_v2.json and writes auto_score.py materials.
"""
from __future__ import annotations
@@ -20,7 +23,7 @@
from iris.db import Store, dumps
from iris.evaluation import _json_sha256, _read_json, _write_json
from iris.maintenance import Maintenance
-from iris.memory_ops import delete_memory, edit_memory, manage_memory
+from iris.memory_ops import delete_memory, edit_memory, lifecycle_settings, manage_memory, set_entry_visibility
from iris.models import Gateway, load_test_models
from iris.model_health import ModelHealth
@@ -32,6 +35,8 @@
DETAIL_SCORING_V1=(Path(__file__).parent/'merge_details_scoring_v1.md').read_text(encoding='utf-8')
DETAIL_SCORING=(Path(__file__).parent/'merge_details_scoring_v2.md').read_text(encoding='utf-8')
WRITE_AUDIT=(Path(__file__).parent/'write_audit_v2.md').read_text(encoding='utf-8')
+AUTO_DETAIL_VERSION='merge_details_auto_v1'
+AUTO_DETAIL_SCORING=(Path(__file__).parent/'merge_details_scoring_auto_v1.md').read_text(encoding='utf-8')
# Completed model refusals / malformed output are quality observations. Anything
# else in the call ledger (including unfinished calls) is an execution failure.
QUALITY_CALL_RESULTS={'success','invalid_output','item_error','content_rejection'}
@@ -60,12 +65,33 @@ def load_corpus(path):
if len(keys)!=len(c['memories']):
raise ValueError('duplicate memory key')
mutual_pairs(c['expected'],keys)
+ auto_labels(c['expected'],keys)
for field in ('merges','no_merge'):
if any(len(p)!=2 or not set(p)<=keys for p in c['expected'].get(field,[])):
raise ValueError('invalid pair label')
return cases
+def auto_labels(expected, keys):
+ """Section 10 labels. Optional, so report-only corpora stay valid."""
+ def known(*values):
+ if any(value not in keys for value in values):
+ raise ValueError('auto label refers to an unknown memory')
+ for label in expected.get('auto_contradictions',[]):
+ known(*label['keys'],label['old'],label['new'])
+ if label['relation'] not in ('supersedes','reported_vs_self','coexist','undecided'):
+ raise ValueError('invalid auto relation')
+ for label in expected.get('auto_dependencies',[]):
+ known(label['derived'])
+ if label['expected'] not in ('keep','forget'):
+ raise ValueError('invalid auto dependency label')
+ for label in expected.get('mutual_merges',[]):
+ known(*label['pair'])
+ if label['expected'] not in ('merge','keep_both') or len(set(label['pair']))!=2:
+ raise ValueError('invalid mutual merge label')
+ known(*expected.get('protected',[]))
+
+
def mutual_pairs(expected, keys):
"""Optional labels are unordered, distinct memory-key pairs, never judgments."""
pairs=expected.get('mutual_details',[])
@@ -223,6 +249,10 @@ def case_run(case,dbpath,gateway_factory,method,resolution='report_only_v1'):
try:
store.set_setting('consolidation',{'enabled':True,'method':method,'max_calls':50,'resolution':resolution,'entry_profile_enabled':False})
key_map,source_map=seed(store,case)
+ # Visibility comes from the source's entry; every entry declares its own scope.
+ for declared in case.get('entries',[]):
+ if declared.get('visibility','shared')!='shared':
+ set_entry_visibility(store,declared['id'],declared['visibility'],visible_in=declared.get('visible_in',[]))
members={v:[k] for k,v in key_map.items()}
retention=[]
event_actions=[]
@@ -376,8 +406,10 @@ def observe_apply(engine,work,payload,result,config,blocked):
enriched=copy.deepcopy(case)
for m in enriched['memories']:
m.update(annotations=[],is_placeholder=False,merged_into=None)
+ with store.read() as conn:
+ forget_threshold=lifecycle_settings(conn)['forget_threshold']
payload={'role':{'name':'Iris','self_subject_id':'self'},'case':enriched,'before':before,'actual_merges':merges,
- 'actual_actions':actions,'after':after,'retention_changes':retention,
+ 'actual_actions':actions,'after':after,'retention_changes':retention,'forget_threshold':forget_threshold,
'merge_checks':checks,'merge_version':MERGE_PROMPT_VERSION,'evaluation_policy':EVALUATION_POLICY}
failures=[i for i in report['items'] if i['outcome']=='failed']
safety_rejections=sum(i['reason']=='unsafe_write' for i in failures)
@@ -438,9 +470,55 @@ def export(rows,out,metadata):
return manifest
+def auto_detail_items(payload):
+ """Merges carrying must_keep / must_not_add labels; only these need semantic judgment."""
+ keys=payload['before']['key_map']
+ reverse={v:k for k,v in keys.items()}
+ labels={frozenset(label['pair']):label for label in payload['case']['expected'].get('mutual_merges',[])
+ if label['expected']=='merge'}
+ items=[]
+ for merge in payload['actual_merges']:
+ label=next((labels[pair] for pair in labels if pair<=set(merge['input_keys'])),None)
+ if label is None:
+ continue
+ items.append({'action_id':merge['action_id'],'pair':label['pair'],'must_keep':label.get('must_keep',[]),
+ 'must_not_add':label.get('must_not_add',[]),'result_content':merge['result']['content'],
+ 'before':[{'key':reverse[m['id']],'content':m['content']} for m in merge['before_memories']]})
+ return items
+
+
+def export_auto(rows,out,metadata):
+ """Materials for the semantic part of the merge-detail check, bound to the run."""
+ if out.exists() and any(out.iterdir()):
+ raise ValueError('materials directory must be empty')
+ out.mkdir(parents=True,exist_ok=True)
+ (out/'merge-details-scoring.md').write_text(AUTO_DETAIL_SCORING,encoding='utf-8')
+ manifest={'format_version':1,'evaluation':'consolidation_auto','run':metadata,'cases':[],
+ 'run_sha256':_write_json(out/'run.json',{'rows':rows}),
+ 'merge_details_scoring_sha256':hashlib.sha256(AUTO_DETAIL_SCORING.encode()).hexdigest()}
+ templates=[]
+ for i,row in enumerate(rows):
+ items=auto_detail_items(row['input'])
+ document={'format_version':1,'evaluation':'consolidation_auto','case_id':row['input']['case']['id'],
+ 'corpus_sha256':metadata['corpus']['sha256'],'source_sha256':metadata['source_sha256'],
+ 'scoring_version':AUTO_DETAIL_VERSION,'merges':items}
+ filename=f'cases/{i:04}.json'
+ manifest['cases'].append({'case_id':document['case_id'],'file':filename,'sha256':_write_json(out/filename,document),'judgment_file':f'{i:04}.json'})
+ templates.append({'format_version':1,'case_id':document['case_id'],'merge_detail_results':[
+ {'action_id':item['action_id'],'must_keep_present':[None]*len(item['must_keep']),
+ 'must_not_add_absent':[None]*len(item['must_not_add']),'reason':''} for item in items]})
+ manifest['materials_sha256']=_json_sha256(manifest)
+ _write_json(out/'manifest.json',manifest)
+ _write_json(out/'round-template.json',{'materials_sha256':manifest['materials_sha256']})
+ _write_json(out/'detail-judgment-template'/'manifest.json',{'materials_sha256':manifest['materials_sha256']})
+ for entry,template in zip(manifest['cases'],templates,strict=True):
+ _write_json(out/'detail-judgment-template'/entry['judgment_file'],template)
+ return manifest
+
+
def main():
parser=argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--corpus',type=Path,default=ROOT/'evals/consolidation_v1.json')
+ parser.add_argument('--corpus',type=Path,default=None)
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--method',choices=METHODS,required=True)
parser.add_argument('--resolution',choices=RESOLUTIONS,default='report_only_v1')
@@ -448,6 +526,8 @@ def main():
args=parser.parse_args()
if args.method!='broad':
parser.error('resolution comparison requires --method broad')
+ if args.corpus is None:
+ args.corpus=ROOT/('evals/consolidation_v2.json' if args.resolution=='auto_v1' else 'evals/consolidation_v1.json')
cases=load_corpus(args.corpus)
if args.case:
cases=[c for c in cases if c['id'] in args.case]
@@ -478,7 +558,7 @@ def factory(store,clock):
'merge_version':MERGE_PROMPT_VERSION,'evaluation_policy':EVALUATION_POLICY,'chat_model':chat.model,
'chat_reasoning_effort':chat.reasoning_effort,'endpoint_sha256':hashlib.sha256(chat.base_url.encode()).hexdigest(),
'calls':sum(len(r['calls']) for r in rows),'elapsed_seconds':sum(r['elapsed_seconds'] for r in rows),'valid':all(r['valid'] for r in rows)}
- export(rows,out/'materials',metadata)
+ (export_auto if args.resolution=='auto_v1' else export)(rows,out/'materials',metadata)
_write_json(out/'summary.json',metadata)
print(json.dumps(metadata,ensure_ascii=False),flush=True)
diff --git a/frontend/src/Consolidation.test.tsx b/frontend/src/Consolidation.test.tsx
index 7f16da81..8e7f8dca 100644
--- a/frontend/src/Consolidation.test.tsx
+++ b/frontend/src/Consolidation.test.tsx
@@ -172,6 +172,130 @@ beforeEach(() => {
});
afterEach(() => vi.unstubAllGlobals());
+const autoItem = {
+ phase: "consolidation",
+ item_key: "7",
+ memory_id: 8,
+ object_id: 8,
+ outcome: "auto_conflict",
+ reason: null,
+ created_at: stamp,
+ details: {
+ auto_resolution: "auto_v1",
+ relation: "supersedes",
+ report: "本人明确更正",
+ source_excerpts: excerpts,
+ undo: { expected_revision: 3, undone_at: null },
+ },
+};
+
+function autoReport(items: any[]) {
+ return {
+ id: 5,
+ state: "completed",
+ phase: 8,
+ trigger: "manual",
+ created_at: stamp,
+ finished_at: stamp,
+ timezone: "Asia/Shanghai",
+ summary: { checked: { count: 0, by_phase: {} }, skipped: { count: 0, reasons: {} } },
+ consolidation: {
+ settings: { max_calls: 4, resolution: "auto_v1" },
+ deferred: 0,
+ skip_reason: null,
+ },
+ items,
+ };
+}
+
+function stubAutoFetch(items: any[], status = 200) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string, init?: RequestInit) => {
+ const method = init?.method || "GET",
+ body = JSON.parse(String(init?.body || "{}"));
+ requests.push({ url, method, body, headers: init?.headers });
+ if (url === "/admin/api/maintenance/5/consolidation/7/undo")
+ return status === 200
+ ? response(autoReport(items))
+ : response(
+ { error: { code: "revision_conflict", message: "记忆已被修改" } },
+ 409,
+ );
+ if (url === "/admin/api/maintenance/5")
+ return response(autoReport(items));
+ throw new Error(`Unexpected route: ${url}`);
+ }),
+ );
+}
+
+test("自主处理项显示结果和撤销入口,撤销带修订号", async () => {
+ stubAutoFetch([autoItem]);
+ render(
+ ,
+ );
+ const section = await screen.findByRole("region", { name: "自主处理结果" });
+ expect(within(section).getByText("本人已更正,旧记忆进入遗忘")).toBeVisible();
+ expect(within(section).getByText("本人明确更正")).toBeVisible();
+ expect(screen.getByText("处理方式:自主处理")).toBeInTheDocument();
+ await userEvent.click(
+ within(section).getByRole("button", { name: "撤销此次处理" }),
+ );
+ await waitFor(() =>
+ expect(
+ requests.some(
+ (r) =>
+ r.url.endsWith("/consolidation/7/undo") &&
+ r.method === "POST" &&
+ r.body.expected_revision === 3,
+ ),
+ ).toBe(true),
+ );
+});
+
+test("撤销发生冲突时显示错误并保留撤销入口", async () => {
+ stubAutoFetch([autoItem], 409);
+ render(
+ ,
+ );
+ const button = await screen.findByRole("button", { name: "撤销此次处理" });
+ await userEvent.click(button);
+ expect(await screen.findByText(/记忆已被修改/)).toBeVisible();
+ expect(screen.getByRole("button", { name: "撤销此次处理" })).toBeEnabled();
+});
+
+test("已撤销的自主处理项不再提供撤销,受保护项没有撤销入口", async () => {
+ stubAutoFetch([
+ {
+ ...autoItem,
+ details: {
+ ...autoItem.details,
+ undo: { expected_revision: 3, undone_at: stamp },
+ },
+ },
+ {
+ ...autoItem,
+ item_key: "8",
+ outcome: "protected",
+ details: {
+ auto_resolution: "auto_v1",
+ report: "人工编辑或置顶的记忆不自动处理。",
+ source_excerpts: excerpts,
+ undo: null,
+ },
+ },
+ ]);
+ render(
+ ,
+ );
+ await screen.findByRole("region", { name: "自主处理结果" });
+ expect(
+ screen.queryByRole("button", { name: "撤销此次处理" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/已撤销/)).toBeVisible();
+ expect(screen.getByText("人工编辑或置顶的记忆,未自动处理")).toBeVisible();
+});
+
test("整理报告区分合并和模型建议,显示来源、persona 差异、目标复核与已知用量", async () => {
const openMemory = vi.fn();
render(
diff --git a/frontend/src/ConsolidationReport.tsx b/frontend/src/ConsolidationReport.tsx
index f5e6fdfc..2b988efa 100644
--- a/frontend/src/ConsolidationReport.tsx
+++ b/frontend/src/ConsolidationReport.tsx
@@ -5,10 +5,13 @@ import {
} from "./person-summary-labels";
import { MergeDetailReview, mergeReviewLabel } from "./MergeDetailReview";
import { useState } from "react";
-import { Badge, Pagination, RoleTime, seconds } from "./ui";
+import { api, errorText, json } from "./api";
+import { Badge, Notice, Pagination, RoleTime, seconds } from "./ui";
import { PersonaChecks } from "./PersonaDetails";
import { personaDegrees } from "./persona-labels";
import {
+ autoRelations,
+ autoVerdicts,
consolidationBudgets,
consolidationSteps,
consolidationReason,
@@ -82,14 +85,73 @@ export function Excerpts({
);
}
+function autoLabel(item: MaintenanceItem) {
+ const d = item.details;
+ if (item.outcome === "auto_conflict")
+ return autoRelations[d.relation || ""] || d.relation;
+ if (item.outcome === "auto_dependency")
+ return autoVerdicts[d.verdict || ""] || d.verdict;
+ if (item.outcome === "protected") return consolidationReason("protected");
+ return `未合并:${consolidationReason(item.reason || "")}`;
+}
+
+function UndoAction({
+ runId,
+ item,
+ refresh,
+}: {
+ runId: number;
+ item: MaintenanceItem;
+ refresh?: () => void;
+}) {
+ const undo = item.details.undo;
+ const [busy, setBusy] = useState(false),
+ [error, setError] = useState("");
+ if (!undo) return null;
+ if (undo.undone_at)
+ return (
+
+ 已撤销 ·
+
+ );
+ async function submit() {
+ if (busy) return;
+ setBusy(true);
+ setError("");
+ try {
+ await api(
+ `/maintenance/${runId}/consolidation/${item.item_key}/undo`,
+ json("POST", { expected_revision: undo!.expected_revision }),
+ );
+ refresh?.();
+ } catch (e) {
+ setError(errorText(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+ {error && {error}}
+
+ );
+}
+
function ModelItems({
name,
items,
openMemory,
+ runId,
+ refresh,
}: {
name: string;
items: MaintenanceItem[];
openMemory: (id: number) => void;
+ runId: number;
+ refresh?: () => void;
}) {
const [offset, setOffset] = useState(0);
return (
@@ -140,6 +202,20 @@ function ModelItems({
· 重定向派生依据 {d.redirected_dependencies ?? 0} 处