mirror of
https://github.com/deusyu/translate-book.git
synced 2026-09-19 08:44:23 +08:00
ba2714ea46
Phase 1 / Commit A — close the read side of the sub-agent feedback loop. Glossary v2 adds per-term aliases, gender, confidence, evidence_refs, and notes; v1 files auto-upgrade on first load (atomic write-back, one-line stderr notice). Aliases participate in count_frequencies, select_terms_for_chunk, and the term table (now 3-col: 原文 | 别名 | 译文), so an alias-only chunk still gets the canonical translation injected into its prompt. Strict v2 invariants — surface-form uniqueness across terms, no empty/duplicate/ self-referencing aliases within a term, polysemous v1 duplicate-source files rejected with disambiguation guidance. SKILL.md Rule #13 wording updated so both 原文 and 别名 columns are hard constraints. New scripts/meta.py defines the sub-agent observation schema (new_entities, alias_hypotheses, attribute_hypotheses, used_term_sources, conflicts) with chunk_id derived from filename — never from payload — to close the hallucination hole. SKILL.md Step 4 instructs sub-agents to emit one output_chunk<NNNN>.meta.json per translated chunk. Commit B will add merge_meta.py (prepare-merge / apply-merge / status) plus Step 4.5 orchestration; this commit lays the schema, validation, selection, and prompt-injection groundwork.
249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
meta.py - Per-chunk sub-agent observation file.
|
|
|
|
Each translation sub-agent emits an `output_chunk<NNNN>.meta.json` alongside
|
|
its translated chunk. The main agent reads these after each batch and merges
|
|
them into the canonical glossary (see merge_meta.py).
|
|
|
|
The schema (v1):
|
|
|
|
{
|
|
"schema_version": 1,
|
|
"new_entities": [{"source": "...", "target_proposal": "...",
|
|
"category": "...", "evidence": "..."}],
|
|
"alias_hypotheses": [{"variant": "...", "may_be_alias_of_source": "...",
|
|
"evidence": "..."}],
|
|
"attribute_hypotheses": [{"entity_source": "...", "attribute": "...",
|
|
"value": "...", "confidence": "...",
|
|
"evidence": "..."}],
|
|
"used_term_sources": ["..."],
|
|
"conflicts": [{"entity_source": "...", "field": "...",
|
|
"injected": "...", "observed_better": "...",
|
|
"evidence": "..."}]
|
|
}
|
|
|
|
`chunk_id` is intentionally NOT a field — chunk identity is derived from the
|
|
filename (`output_chunk<NNNN>.meta.json` → `chunk<NNNN>`). Letting the
|
|
sub-agent fill it would create a hallucination hole.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
|
|
|
|
META_SCHEMA_VERSION = 1
|
|
EVIDENCE_MAX_LEN = 200
|
|
|
|
VALID_TOP_LEVEL_KEYS = frozenset({
|
|
'schema_version',
|
|
'new_entities',
|
|
'alias_hypotheses',
|
|
'attribute_hypotheses',
|
|
'used_term_sources',
|
|
'conflicts',
|
|
})
|
|
|
|
VALID_ATTRIBUTE_CONFIDENCES = ('low', 'medium', 'high')
|
|
|
|
_CHUNK_ID_RE = re.compile(r'^output_(chunk\d+)\.meta\.json$')
|
|
|
|
|
|
def chunk_id_from_meta_path(path):
|
|
"""Extract the chunk identifier from a meta filename.
|
|
|
|
Filename is the authoritative source of chunk identity — never trust the
|
|
payload to provide it.
|
|
"""
|
|
basename = os.path.basename(path)
|
|
m = _CHUNK_ID_RE.match(basename)
|
|
if not m:
|
|
raise ValueError(
|
|
f"Meta filename {basename!r} does not match expected pattern "
|
|
f"'output_chunk<NNNN>.meta.json'. Cannot derive chunk_id."
|
|
)
|
|
return m.group(1)
|
|
|
|
|
|
def _canonical_json(data):
|
|
return json.dumps(data, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
|
|
|
|
|
def meta_content_hash(data):
|
|
"""SHA-256 of canonical JSON. Used by merge_meta.py to detect whether a
|
|
given meta has already been applied to the glossary."""
|
|
return hashlib.sha256(_canonical_json(data).encode('utf-8')).hexdigest()
|
|
|
|
|
|
def _check_evidence(value, where, path):
|
|
if not isinstance(value, str):
|
|
raise ValueError(
|
|
f"Meta at {path}: 'evidence' in {where} must be a string, "
|
|
f"got {type(value).__name__}"
|
|
)
|
|
if len(value) > EVIDENCE_MAX_LEN:
|
|
raise ValueError(
|
|
f"Meta at {path}: 'evidence' in {where} is {len(value)} chars; "
|
|
f"limit is {EVIDENCE_MAX_LEN}. Quote a shorter excerpt."
|
|
)
|
|
|
|
|
|
def _require_str(value, field, where, path):
|
|
if not isinstance(value, str):
|
|
raise ValueError(
|
|
f"Meta at {path}: {where} field {field!r} must be a string, "
|
|
f"got {type(value).__name__}"
|
|
)
|
|
|
|
|
|
def _validate_array(data, key, path):
|
|
val = data.get(key, [])
|
|
if not isinstance(val, list):
|
|
raise ValueError(
|
|
f"Meta at {path}: {key!r} must be a list, got {type(val).__name__}"
|
|
)
|
|
return val
|
|
|
|
|
|
def validate_meta(data, path='<meta>'):
|
|
"""Strict v1 validation. Raises ValueError with actionable messages."""
|
|
if not isinstance(data, dict):
|
|
raise ValueError(
|
|
f"Meta at {path} must be a JSON object, got {type(data).__name__}"
|
|
)
|
|
|
|
schema_version = data.get('schema_version')
|
|
if schema_version != META_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f"Meta at {path}: schema_version mismatch — expected "
|
|
f"{META_SCHEMA_VERSION}, got {schema_version!r}."
|
|
)
|
|
|
|
extras = set(data.keys()) - VALID_TOP_LEVEL_KEYS
|
|
if extras:
|
|
# 'chunk_id' is the most likely offender; call it out specifically so
|
|
# the fix is obvious.
|
|
if 'chunk_id' in extras:
|
|
raise ValueError(
|
|
f"Meta at {path}: 'chunk_id' field is not allowed in the meta "
|
|
f"payload — chunk identity is derived from the filename. Remove it."
|
|
)
|
|
raise ValueError(
|
|
f"Meta at {path}: unknown top-level key(s) {sorted(extras)!r}. "
|
|
f"Allowed keys: {sorted(VALID_TOP_LEVEL_KEYS)!r}."
|
|
)
|
|
|
|
for entity in _validate_array(data, 'new_entities', path):
|
|
if not isinstance(entity, dict):
|
|
raise ValueError(
|
|
f"Meta at {path}: each new_entities entry must be an object, "
|
|
f"got {type(entity).__name__}"
|
|
)
|
|
for required in ('source', 'target_proposal', 'evidence'):
|
|
if required not in entity:
|
|
raise ValueError(
|
|
f"Meta at {path}: new_entities entry missing required field "
|
|
f"{required!r}"
|
|
)
|
|
_require_str(entity['source'], 'source', 'new_entities', path)
|
|
_require_str(entity['target_proposal'], 'target_proposal', 'new_entities', path)
|
|
if 'category' in entity:
|
|
_require_str(entity['category'], 'category', 'new_entities', path)
|
|
_check_evidence(entity['evidence'], 'new_entities', path)
|
|
|
|
for alias in _validate_array(data, 'alias_hypotheses', path):
|
|
if not isinstance(alias, dict):
|
|
raise ValueError(
|
|
f"Meta at {path}: each alias_hypotheses entry must be an object"
|
|
)
|
|
for required in ('variant', 'may_be_alias_of_source', 'evidence'):
|
|
if required not in alias:
|
|
raise ValueError(
|
|
f"Meta at {path}: alias_hypotheses entry missing field {required!r}"
|
|
)
|
|
_require_str(alias['variant'], 'variant', 'alias_hypotheses', path)
|
|
_require_str(alias['may_be_alias_of_source'], 'may_be_alias_of_source',
|
|
'alias_hypotheses', path)
|
|
_check_evidence(alias['evidence'], 'alias_hypotheses', path)
|
|
|
|
for attr in _validate_array(data, 'attribute_hypotheses', path):
|
|
if not isinstance(attr, dict):
|
|
raise ValueError(
|
|
f"Meta at {path}: each attribute_hypotheses entry must be an object"
|
|
)
|
|
for required in ('entity_source', 'attribute', 'value', 'confidence', 'evidence'):
|
|
if required not in attr:
|
|
raise ValueError(
|
|
f"Meta at {path}: attribute_hypotheses entry missing field {required!r}"
|
|
)
|
|
_require_str(attr['entity_source'], 'entity_source', 'attribute_hypotheses', path)
|
|
_require_str(attr['attribute'], 'attribute', 'attribute_hypotheses', path)
|
|
_require_str(attr['value'], 'value', 'attribute_hypotheses', path)
|
|
if attr['confidence'] not in VALID_ATTRIBUTE_CONFIDENCES:
|
|
raise ValueError(
|
|
f"Meta at {path}: attribute_hypotheses 'confidence' must be one of "
|
|
f"{list(VALID_ATTRIBUTE_CONFIDENCES)}, got {attr['confidence']!r}"
|
|
)
|
|
_check_evidence(attr['evidence'], 'attribute_hypotheses', path)
|
|
|
|
for s_idx, src in enumerate(_validate_array(data, 'used_term_sources', path)):
|
|
if not isinstance(src, str):
|
|
raise ValueError(
|
|
f"Meta at {path}: used_term_sources #{s_idx} must be a string, "
|
|
f"got {type(src).__name__}"
|
|
)
|
|
|
|
for conflict in _validate_array(data, 'conflicts', path):
|
|
if not isinstance(conflict, dict):
|
|
raise ValueError(
|
|
f"Meta at {path}: each conflicts entry must be an object"
|
|
)
|
|
for required in ('entity_source', 'field', 'injected', 'observed_better', 'evidence'):
|
|
if required not in conflict:
|
|
raise ValueError(
|
|
f"Meta at {path}: conflicts entry missing field {required!r}"
|
|
)
|
|
_require_str(conflict['entity_source'], 'entity_source', 'conflicts', path)
|
|
_require_str(conflict['field'], 'field', 'conflicts', path)
|
|
_require_str(conflict['injected'], 'injected', 'conflicts', path)
|
|
_require_str(conflict['observed_better'], 'observed_better', 'conflicts', path)
|
|
_check_evidence(conflict['evidence'], 'conflicts', path)
|
|
|
|
|
|
def load_meta(path):
|
|
"""Load and strictly validate a meta file. Raises ValueError on any
|
|
problem. Callers that want non-blocking behavior should wrap this in a
|
|
quarantine wrapper (see merge_meta.py)."""
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"Meta not found: {path}")
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
try:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(f"Meta at {path} is not valid JSON: {e}") from e
|
|
validate_meta(data, path)
|
|
return data
|
|
|
|
|
|
def save_meta(path, data):
|
|
"""Atomically write a meta file. Validates before writing."""
|
|
validate_meta(data, path)
|
|
|
|
dirname = os.path.dirname(os.path.abspath(path)) or '.'
|
|
fd, tmp_path = tempfile.mkstemp(dir=dirname, prefix='.meta-', suffix='.tmp')
|
|
try:
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
|
f.write('\n')
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
if os.path.exists(tmp_path):
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|