GuXiaopeng commited on
Commit
8191bb2
·
verified ·
1 Parent(s): fd85ed5

S0-R1-C5.5-R5 Qualitative Appendix Deterministic Target Binding

Browse files
core/generation_runner.py CHANGED
@@ -2090,6 +2090,108 @@ class GenerationRunner:
2090
  )
2091
  return updated_sections, updated_glossary
2092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2093
  @staticmethod
2094
  def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
2095
  base = max(80, int(base_target))
@@ -3038,6 +3140,12 @@ class GenerationRunner:
3038
  citation_unit_section_map = self._citation_unit_section_map(adaptive_structure)
3039
  translation_sections = dict(state.get('translation_sections') or {})
3040
  translation_glossary = dict(state.get('translation_glossary') or {})
 
 
 
 
 
 
3041
  canonical_concept_ledger = state.get('canonical_concept_ledger')
3042
  if not isinstance(canonical_concept_ledger, dict) or not canonical_concept_ledger.get('items'):
3043
  canonical_concept_ledger = self._build_canonical_concept_ledger(
@@ -4137,12 +4245,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4137
  translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4138
  frozen_section
4139
  )
4140
- self._enqueue_translation_unit(
4141
- translation_agent,
4142
- unit_key=section_key, unit_name=sec_title, source_text=translation_source,
4143
- translation_sections=translation_sections,
4144
- english_commit_text=full_draft_en,
4145
- )
 
 
 
 
 
4146
  # Collect only already-finished target jobs. This call never waits,
4147
  # so English Section N+1 can start while target Section N runs.
4148
  translation_sections, translation_glossary = self._collect_translation_results(
@@ -4506,13 +4619,20 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4506
  translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4507
  source_text
4508
  )
4509
- translation_sections, translation_glossary = self._translate_frozen_unit(
4510
- translation_agent, unit_key=unit_key, unit_name=unit_name, source_text=translation_source,
4511
- target_lang=target_lang, translation_sections=translation_sections,
4512
- translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
4513
- include_front=True, defer_failures=False,
4514
- english_commit_text=full_draft_en, final_gate=True,
4515
- )
 
 
 
 
 
 
 
4516
  # R2: quantitative tables are manifest-owned artifacts, not translation-model output.
4517
  # Rebind them only after the frozen Results translation has passed its language/semantic QA.
4518
  # Re-running this block is idempotent because the engine first removes any prior/model table block.
@@ -4584,6 +4704,11 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4584
  full_draft_translated = _normalize_target_manuscript_typography(
4585
  full_draft_translated, target_lang
4586
  )
 
 
 
 
 
4587
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
4588
  agent.validate_reference_preservation(full_draft_en, full_draft_translated, target_lang)
4589
  self._publish_target_preview(
 
2090
  )
2091
  return updated_sections, updated_glossary
2092
 
2093
+ def _translate_qualitative_appendix_unit(
2094
+ self, agent, qual_engine, *, unit_key: str, source_text: str, target_lang: str,
2095
+ translation_sections: dict, translation_glossary: dict, data_metadata: dict,
2096
+ ):
2097
+ """Translate Appendix cells, then bind the six-column table deterministically."""
2098
+ source = str(source_text or '').strip()
2099
+ manifest = qual_engine.build_appendix_manifest(data_metadata)
2100
+ if not qual_engine._validate_appendix_manifest(manifest):
2101
+ raise RuntimeError('qualitative_appendix_manifest_invalid')
2102
+ manifest_hash = str(manifest.get('manifest_hash') or '')
2103
+ identity = self._translation_identity_bundle(agent, source)
2104
+ entity_hash_method = getattr(agent, 'translation_protected_entity_ledger_hash', None)
2105
+ protected_entity_ledger_hash = str(
2106
+ entity_hash_method(identity['compiled_source'], translation_glossary)
2107
+ if callable(entity_hash_method) else ''
2108
+ )
2109
+ protected_entity_ledger_version = str(
2110
+ getattr(agent, 'PROTECTED_ENTITY_LEDGER_VERSION', 'protected-entity-ledger-v1')
2111
+ or 'protected-entity-ledger-v1'
2112
+ )
2113
+ sections = dict(translation_sections or {})
2114
+ existing = dict(sections.get(unit_key) or {})
2115
+ if (
2116
+ existing.get('qa_status') in {'passed', 'passed_with_warning'}
2117
+ and existing.get('source_hash') == identity['source_hash']
2118
+ and existing.get('target_lang') == target_lang
2119
+ and existing.get('qualitative_appendix_binding_version') == qual_engine.TARGET_APPENDIX_BINDING_VERSION
2120
+ and existing.get('qualitative_appendix_manifest_hash') == manifest_hash
2121
+ and existing.get('translated_text')
2122
+ ):
2123
+ qual_engine.validate_bound_target_appendix(existing['translated_text'], manifest, target_lang)
2124
+ return sections, translation_glossary
2125
+
2126
+ quote_record = sections.get('__quote_ledger__')
2127
+ quote_map = dict((quote_record or {}).get('translations') or {}) if isinstance(quote_record, dict) else {}
2128
+ required_quotes = {str(row.get('quote_id') or '') for row in manifest.get('rows') or []}
2129
+ if any(not str(quote_map.get(tag) or '').strip() for tag in required_quotes):
2130
+ missing = sorted(tag for tag in required_quotes if not str(quote_map.get(tag) or '').strip())
2131
+ raise RuntimeError(f'qualitative_appendix_quote_ledger_incomplete:{missing[:6]}')
2132
+
2133
+ translate_cells = getattr(agent, 'translate_qualitative_appendix_cells', None)
2134
+ if not callable(translate_cells):
2135
+ raise RuntimeError('Typed qualitative Appendix cell translation entry point is unavailable.')
2136
+ try:
2137
+ result = translate_cells(
2138
+ qual_engine.appendix_translation_cells(manifest), target_lang,
2139
+ glossary=translation_glossary, max_attempts=2,
2140
+ claim_states=(existing.get('appendix_cell_claim_states') if isinstance(existing.get('appendix_cell_claim_states'), dict) else None),
2141
+ )
2142
+ cell_translations = dict((result or {}).get('translations') or {})
2143
+ updated_glossary = dict((result or {}).get('glossary') or translation_glossary or {})
2144
+ translated_text = qual_engine.bind_target_language_appendix(
2145
+ manifest, target_lang, cell_translations, quote_map
2146
+ )
2147
+ qual_engine.validate_bound_target_appendix(translated_text, manifest, target_lang)
2148
+ except PipelineCancelledError:
2149
+ raise
2150
+ except Exception as error:
2151
+ code_method = getattr(agent, 'translation_error_code', None)
2152
+ error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
2153
+ sections[unit_key] = {
2154
+ 'source_hash': identity['source_hash'],
2155
+ 'compiled_source_hash': identity['compiled_source_hash'],
2156
+ 'target_lang': target_lang,
2157
+ 'translation_contract_version': TRANSLATION_CONTRACT_VERSION,
2158
+ 'fact_ledger_hash': identity['fact_ledger_hash'],
2159
+ 'protected_entity_ledger_version': protected_entity_ledger_version,
2160
+ 'protected_entity_ledger_hash': protected_entity_ledger_hash,
2161
+ 'translated_text': '',
2162
+ 'qa_status': 'repair_pending',
2163
+ 'attempts': int(existing.get('attempts') or 0) + 1,
2164
+ 'error_code': error_code,
2165
+ 'qualitative_appendix_binding_version': qual_engine.TARGET_APPENDIX_BINDING_VERSION,
2166
+ 'qualitative_appendix_manifest_hash': manifest_hash,
2167
+ 'appendix_cell_claim_states': dict(getattr(agent, '_last_translation_claim_states', {}) or {}),
2168
+ 'updated_at': datetime.now(timezone.utc).isoformat(),
2169
+ }
2170
+ logging.warning(
2171
+ 'Qualitative Appendix typed translation repair pending | unit=%s code=%s error=%s',
2172
+ unit_key, error_code, type(error).__name__,
2173
+ )
2174
+ return sections, translation_glossary
2175
+ sections[unit_key] = {
2176
+ 'source_hash': identity['source_hash'],
2177
+ 'compiled_source_hash': identity['compiled_source_hash'],
2178
+ 'target_lang': target_lang,
2179
+ 'glossary_version': self._glossary_version(updated_glossary),
2180
+ 'translation_contract_version': TRANSLATION_CONTRACT_VERSION,
2181
+ 'fact_ledger_hash': identity['fact_ledger_hash'],
2182
+ 'protected_entity_ledger_version': protected_entity_ledger_version,
2183
+ 'protected_entity_ledger_hash': protected_entity_ledger_hash,
2184
+ 'translated_text': translated_text,
2185
+ 'qa_status': str((result or {}).get('qa_status') or 'passed'),
2186
+ 'attempts': int((result or {}).get('attempts') or 1),
2187
+ 'qualitative_appendix_binding_version': qual_engine.TARGET_APPENDIX_BINDING_VERSION,
2188
+ 'qualitative_appendix_manifest_hash': manifest_hash,
2189
+ 'appendix_cell_contract_version': str((result or {}).get('appendix_cell_contract_version') or ''),
2190
+ 'appendix_cell_claim_states': dict((result or {}).get('claim_states') or {}),
2191
+ 'updated_at': datetime.now(timezone.utc).isoformat(),
2192
+ }
2193
+ return sections, updated_glossary
2194
+
2195
  @staticmethod
2196
  def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
2197
  base = max(80, int(base_target))
 
3140
  citation_unit_section_map = self._citation_unit_section_map(adaptive_structure)
3141
  translation_sections = dict(state.get('translation_sections') or {})
3142
  translation_glossary = dict(state.get('translation_glossary') or {})
3143
+ if mode == "asq":
3144
+ appendix_record = dict(translation_sections.get("appendix") or {})
3145
+ if appendix_record and appendix_record.get("qualitative_appendix_binding_version") != qual_engine.TARGET_APPENDIX_BINDING_VERSION:
3146
+ logging.info("Invalidating pre-R5 qualitative Appendix translation record; deterministic binding required.")
3147
+ translation_sections.pop("appendix", None)
3148
+ state["translation_sections"] = dict(translation_sections)
3149
  canonical_concept_ledger = state.get('canonical_concept_ledger')
3150
  if not isinstance(canonical_concept_ledger, dict) or not canonical_concept_ledger.get('items'):
3151
  canonical_concept_ledger = self._build_canonical_concept_ledger(
 
4245
  translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4246
  frozen_section
4247
  )
4248
+ if mode == "asq" and str(section_key).strip().casefold() == "appendix":
4249
+ # R5: the qualitative Appendix is not model-owned Markdown.
4250
+ # It is translated/bound at the strict final gate from typed cells.
4251
+ pass
4252
+ else:
4253
+ self._enqueue_translation_unit(
4254
+ translation_agent,
4255
+ unit_key=section_key, unit_name=sec_title, source_text=translation_source,
4256
+ translation_sections=translation_sections,
4257
+ english_commit_text=full_draft_en,
4258
+ )
4259
  # Collect only already-finished target jobs. This call never waits,
4260
  # so English Section N+1 can start while target Section N runs.
4261
  translation_sections, translation_glossary = self._collect_translation_results(
 
4619
  translation_source = quant_engine._strip_target_quantitative_markdown_tables(
4620
  source_text
4621
  )
4622
+ if mode == "asq" and str(unit_key).strip().casefold() == "appendix":
4623
+ translation_sections, translation_glossary = self._translate_qualitative_appendix_unit(
4624
+ translation_agent, qual_engine, unit_key=unit_key, source_text=source_text,
4625
+ target_lang=target_lang, translation_sections=translation_sections,
4626
+ translation_glossary=translation_glossary, data_metadata=data_metadata,
4627
+ )
4628
+ else:
4629
+ translation_sections, translation_glossary = self._translate_frozen_unit(
4630
+ translation_agent, unit_key=unit_key, unit_name=unit_name, source_text=translation_source,
4631
+ target_lang=target_lang, translation_sections=translation_sections,
4632
+ translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
4633
+ include_front=True, defer_failures=False,
4634
+ english_commit_text=full_draft_en, final_gate=True,
4635
+ )
4636
  # R2: quantitative tables are manifest-owned artifacts, not translation-model output.
4637
  # Rebind them only after the frozen Results translation has passed its language/semantic QA.
4638
  # Re-running this block is idempotent because the engine first removes any prior/model table block.
 
4704
  full_draft_translated = _normalize_target_manuscript_typography(
4705
  full_draft_translated, target_lang
4706
  )
4707
+ if mode == "asq":
4708
+ appendix_manifest = qual_engine.build_appendix_manifest(data_metadata)
4709
+ qual_engine.validate_bound_target_appendix(
4710
+ full_draft_translated, appendix_manifest, target_lang
4711
+ )
4712
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
4713
  agent.validate_reference_preservation(full_draft_en, full_draft_translated, target_lang)
4714
  self._publish_target_preview(
core/pipeline_agent.py CHANGED
@@ -8255,6 +8255,59 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8255
  )
8256
  return {}
8257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8258
  def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None, claim_states=None):
8259
  """Translate one frozen English unit and publish only after all QA passes."""
8260
  value = str(section_text or '').strip()
 
8255
  )
8256
  return {}
8257
 
8258
+ def translate_qualitative_appendix_cells(
8259
+ self, cell_objects, target_lang, glossary=None, max_attempts=2,
8260
+ stage_callback=None, claim_states=None,
8261
+ ):
8262
+ """Translate typed Appendix cells without giving the model table ownership.
8263
+
8264
+ Cell IDs never enter model-visible text. Unique source surfaces are
8265
+ translated as independent SP-P blocks through the existing frozen-section
8266
+ compiler/QA stack, then rebound to the original server-side cell IDs.
8267
+ """
8268
+ if not isinstance(cell_objects, dict) or not cell_objects:
8269
+ raise DeepSeekRequestError('Qualitative Appendix cells must be a non-empty mapping.')
8270
+ ordered = []
8271
+ source_to_ids = {}
8272
+ for cell_id, raw_source in cell_objects.items():
8273
+ cell_id = str(cell_id or '').strip()
8274
+ source = re.sub(r'\s+', ' ', str(raw_source or '')).strip()
8275
+ if not cell_id or not source:
8276
+ raise DeepSeekRequestError('Qualitative Appendix cell identity/source is empty.')
8277
+ if source not in source_to_ids:
8278
+ source_to_ids[source] = []
8279
+ ordered.append(source)
8280
+ source_to_ids[source].append(cell_id)
8281
+ payload = '\n\n'.join(ordered)
8282
+ result = self.translate_frozen_section(
8283
+ payload, target_lang, glossary=glossary,
8284
+ section_name='Qualitative Appendix semantic cells', max_attempts=max_attempts,
8285
+ stage_callback=stage_callback, claim_states=claim_states,
8286
+ )
8287
+ body_units = [
8288
+ unit['text'] for unit in self._translation_body_units(result.get('text') or '')
8289
+ if unit.get('kind') == 'body'
8290
+ ]
8291
+ if len(body_units) != len(ordered):
8292
+ raise DeepSeekRequestError(
8293
+ 'Qualitative Appendix cell identity mismatch after translation: '
8294
+ f'expected={len(ordered)} got={len(body_units)}.'
8295
+ )
8296
+ by_source = dict(zip(ordered, body_units))
8297
+ translations = {}
8298
+ for source, cell_ids in source_to_ids.items():
8299
+ target = re.sub(r'\s+', ' ', str(by_source.get(source) or '')).strip()
8300
+ if not target:
8301
+ raise DeepSeekRequestError('Qualitative Appendix translated cell is empty.')
8302
+ for cell_id in cell_ids:
8303
+ translations[cell_id] = target
8304
+ return {
8305
+ **dict(result or {}),
8306
+ 'translations': translations,
8307
+ 'cell_ids': list(cell_objects.keys()),
8308
+ 'appendix_cell_contract_version': 'typed-qualitative-appendix-cells-v1',
8309
+ }
8310
+
8311
  def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None, claim_states=None):
8312
  """Translate one frozen English unit and publish only after all QA passes."""
8313
  value = str(section_text or '').strip()
core/qualitative_engine.py CHANGED
@@ -4,6 +4,7 @@
4
  from __future__ import annotations
5
 
6
  import hashlib
 
7
  import logging
8
  import re
9
 
@@ -11,6 +12,32 @@ from config import APP_CONFIG
11
 
12
 
13
  class QualitativeEngine:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  ALLOWED_ANALYTIC_DESIGNS = {
15
  "Process analysis",
16
  "Comparative case analysis",
@@ -227,38 +254,197 @@ class QualitativeEngine:
227
 
228
  return "\n".join(lines).strip()
229
 
 
 
 
 
 
 
 
 
 
 
 
230
  @classmethod
231
- def build_appendix_evidence_md(cls, metadata):
232
- """Render the reader-facing qualitative evidence appendix deterministically.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
- Internal QUOTE identifiers are deliberately omitted. The same frozen
235
- quotation literals are reused by the translation ledger, so Findings
236
- and Appendix share one target-language rendering per quote.
237
- """
238
- metadata = metadata or {}
 
239
 
240
- def cell(value, default="Not specified"):
241
- return cls._clean_cell(value, default).replace("|", r"\|")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  lines = [
244
- "| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |",
245
  "| :--- | :--- | :--- | :--- | :--- | :--- |",
246
  ]
247
- for item in metadata.get("evidence") or []:
248
- if not isinstance(item, dict):
249
- continue
250
- quote = cls._clean_cell(item.get("quote"), "").replace('"', "'")
251
- if not quote:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  lines.append(
254
- "| {theme} | {concept} | {stage} | {case_label} | {role} | \"{quote}\" |".format(
255
- theme=cell(item.get("theme"), "Emergent pattern"),
256
- concept=cell(item.get("concept"), "Empirical observation"),
257
- stage=cell(item.get("stage")),
258
- case_label=cell(item.get("case_label")),
259
- role=cell(str(item.get("evidence_role") or "typical").strip().lower(), "typical"),
260
- quote=quote.replace("|", r"\|"),
261
- )
262
  )
263
  return "\n".join(lines).strip()
264
 
 
4
  from __future__ import annotations
5
 
6
  import hashlib
7
+ import json
8
  import logging
9
  import re
10
 
 
12
 
13
 
14
  class QualitativeEngine:
15
+ QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
+ TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v1"
17
+ APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
18
+ APPENDIX_HEADERS = (
19
+ "Higher-order Theme", "Empirical Concept", "Process Stage",
20
+ "Case/Actor", "Evidence Role", "Representative Quote",
21
+ )
22
+ _TARGET_APPENDIX_LOCALIZATION = {
23
+ "en": {
24
+ "heading": "Appendix: Data Structure and Evidence",
25
+ "headers": APPENDIX_HEADERS,
26
+ },
27
+ "zh": {
28
+ "heading": "附录:数据结构与证据",
29
+ "headers": ("高阶主题", "经验概念", "过程阶段", "案例/参与者", "证据角色", "代表性引文"),
30
+ },
31
+ "ru": {
32
+ "heading": "Приложение: Структура данных и доказательства",
33
+ "headers": ("Тема высшего порядка", "Эмпирическое понятие", "Этап процесса", "Случай/участник", "Роль доказательства", "Репрезентативная цитата"),
34
+ },
35
+ "kk": {
36
+ "heading": "Қосымша: Деректер құрылымы және дәлелдер",
37
+ "headers": ("Жоғары деңгейлі тақырып", "Эмпирикалық ұғым", "Процесс кезеңі", "Кейс/қатысушы", "Дәлел рөлі", "Репрезентативті дәйексөз"),
38
+ },
39
+ }
40
+
41
  ALLOWED_ANALYTIC_DESIGNS = {
42
  "Process analysis",
43
  "Comparative case analysis",
 
254
 
255
  return "\n".join(lines).strip()
256
 
257
+ @staticmethod
258
+ def _appendix_target_language_code(target_lang):
259
+ value = str(target_lang or "").strip().casefold().replace("_", "-")
260
+ if value.startswith("zh") or "chinese" in value or "中文" in value or "简体" in value:
261
+ return "zh"
262
+ if value.startswith("ru") or "russian" in value or "рус" in value:
263
+ return "ru"
264
+ if value.startswith("kk") or value.startswith("kz") or "kazakh" in value or "қазақ" in value:
265
+ return "kk"
266
+ return "en"
267
+
268
  @classmethod
269
+ def build_appendix_manifest(cls, metadata):
270
+ """Freeze Appendix row/cell identity before any target-language operation."""
271
+ rows = []
272
+ seen = set()
273
+ for index, item in enumerate((metadata or {}).get("evidence") or [], start=1):
274
+ if not isinstance(item, dict):
275
+ continue
276
+ quote = cls._clean_cell(item.get("quote"), "")
277
+ if not quote:
278
+ continue
279
+ quote_id = str(item.get("tag") or "").strip().upper()
280
+ if not re.fullmatch(r"QUOTE-\d+", quote_id) or quote_id in seen:
281
+ return {}
282
+ seen.add(quote_id)
283
+ row = {
284
+ "row_id": quote_id,
285
+ "quote_id": quote_id,
286
+ "quote_hash": hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest(),
287
+ "theme": cls._clean_cell(item.get("theme"), "Emergent pattern"),
288
+ "concept": cls._clean_cell(item.get("concept"), "Empirical observation"),
289
+ "stage": cls._clean_cell(item.get("stage")),
290
+ "case_label": cls._clean_cell(item.get("case_label")),
291
+ "evidence_role": cls._clean_cell(
292
+ str(item.get("evidence_role") or "typical").strip().lower(), "typical"
293
+ ),
294
+ "quote": quote,
295
+ }
296
+ rows.append(row)
297
+ if not rows:
298
+ return {}
299
+ manifest = {
300
+ "version": cls.QUALITATIVE_APPENDIX_MANIFEST_VERSION,
301
+ "columns": list(cls.APPENDIX_HEADERS),
302
+ "rows": rows,
303
+ }
304
+ manifest["manifest_hash"] = cls.appendix_manifest_hash(manifest)
305
+ return manifest
306
 
307
+ @classmethod
308
+ def appendix_manifest_hash(cls, manifest):
309
+ record = dict(manifest or {})
310
+ record.pop("manifest_hash", None)
311
+ payload = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
312
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
313
 
314
+ @classmethod
315
+ def appendix_translation_cells(cls, manifest):
316
+ """Return typed non-quote Appendix cells; IDs stay server-side."""
317
+ cells = {}
318
+ for row in (manifest or {}).get("rows") or []:
319
+ if not isinstance(row, dict):
320
+ continue
321
+ row_id = str(row.get("row_id") or "").strip()
322
+ if not row_id:
323
+ continue
324
+ for field in cls.APPENDIX_FIELDS:
325
+ value = cls._clean_cell(row.get(field), "Not specified")
326
+ cells[f"{row_id}:{field}"] = value
327
+ return cells
328
+
329
+ @staticmethod
330
+ def _escape_appendix_markdown_cell(value):
331
+ return re.sub(r"\s+", " ", str(value or "").strip()).replace("|", r"\|")
332
+
333
+ @classmethod
334
+ def _validate_appendix_manifest(cls, manifest):
335
+ record = dict(manifest or {})
336
+ if record.get("version") != cls.QUALITATIVE_APPENDIX_MANIFEST_VERSION:
337
+ return False
338
+ if list(record.get("columns") or []) != list(cls.APPENDIX_HEADERS):
339
+ return False
340
+ rows = [row for row in (record.get("rows") or []) if isinstance(row, dict)]
341
+ ids = [str(row.get("row_id") or "") for row in rows]
342
+ quote_ids = [str(row.get("quote_id") or "") for row in rows]
343
+ if not rows or len(ids) != len(set(ids)) or ids != quote_ids:
344
+ return False
345
+ if any(not re.fullmatch(r"QUOTE-\d+", item) for item in ids):
346
+ return False
347
+ expected_hash = str(record.get("manifest_hash") or "")
348
+ return bool(expected_hash) and expected_hash == cls.appendix_manifest_hash(record)
349
 
350
+ @classmethod
351
+ def bind_target_language_appendix(cls, manifest, target_lang, cell_translations, quote_translations):
352
+ """Render one six-column target Appendix from frozen semantic objects.
353
+
354
+ Markdown structure, row identity, column identity, and quote identity are
355
+ program-owned. The translation model may supply only ordinary cell
356
+ surfaces through ``cell_translations``.
357
+ """
358
+ if not cls._validate_appendix_manifest(manifest):
359
+ raise RuntimeError("target_qualitative_appendix_manifest_invalid")
360
+ expected_cells = cls.appendix_translation_cells(manifest)
361
+ translations = dict(cell_translations or {})
362
+ if set(translations) != set(expected_cells):
363
+ missing = sorted(set(expected_cells) - set(translations))
364
+ extra = sorted(set(translations) - set(expected_cells))
365
+ raise RuntimeError(
366
+ f"target_qualitative_appendix_cell_identity_mismatch:missing={missing[:4]} extra={extra[:4]}"
367
+ )
368
+ quote_map = dict(quote_translations or {})
369
+ required_quotes = {str(row.get("quote_id") or "") for row in manifest.get("rows") or []}
370
+ missing_quotes = sorted(tag for tag in required_quotes if not str(quote_map.get(tag) or "").strip())
371
+ if missing_quotes:
372
+ raise RuntimeError(f"target_qualitative_appendix_quote_identity_missing:{missing_quotes[:6]}")
373
+
374
+ code = cls._appendix_target_language_code(target_lang)
375
+ profile = dict(cls._TARGET_APPENDIX_LOCALIZATION.get(code) or cls._TARGET_APPENDIX_LOCALIZATION["en"])
376
+ headers = list(profile.get("headers") or cls.APPENDIX_HEADERS)
377
+ heading = str(profile.get("heading") or cls._TARGET_APPENDIX_LOCALIZATION["en"]["heading"])
378
  lines = [
379
+ "| " + " | ".join(cls._escape_appendix_markdown_cell(item) for item in headers) + " |",
380
  "| :--- | :--- | :--- | :--- | :--- | :--- |",
381
  ]
382
+ for row in manifest.get("rows") or []:
383
+ row_id = str(row.get("row_id") or "")
384
+ values = [translations[f"{row_id}:{field}"] for field in cls.APPENDIX_FIELDS]
385
+ values.append(quote_map[str(row.get("quote_id") or "")])
386
+ lines.append(
387
+ "| " + " | ".join(cls._escape_appendix_markdown_cell(value) for value in values) + " |"
388
+ )
389
+ rendered = f"# {heading}\n\n" + "\n".join(lines)
390
+ cls.validate_bound_target_appendix(rendered, manifest, target_lang)
391
+ return rendered.strip()
392
+
393
+ @classmethod
394
+ def validate_bound_target_appendix(cls, text, manifest, target_lang):
395
+ """Fail before DOCX packaging if semantic Appendix identity drifted."""
396
+ if not cls._validate_appendix_manifest(manifest):
397
+ raise RuntimeError("target_qualitative_appendix_manifest_invalid")
398
+ code = cls._appendix_target_language_code(target_lang)
399
+ profile = dict(cls._TARGET_APPENDIX_LOCALIZATION.get(code) or cls._TARGET_APPENDIX_LOCALIZATION["en"])
400
+ heading = str(profile.get("heading") or "")
401
+ headers = list(profile.get("headers") or cls.APPENDIX_HEADERS)
402
+ value = str(text or "")
403
+ heading_pattern = re.compile(rf"(?m)^#\s+{re.escape(heading)}\s*$")
404
+ matches = list(heading_pattern.finditer(value))
405
+ if len(matches) != 1:
406
+ raise RuntimeError("target_qualitative_appendix_heading_identity_missing_or_ambiguous")
407
+ tail = value[matches[0].end():]
408
+ table_blocks = re.findall(r"(?m)(?:^[ \t]*\|[^\n]+\|[ \t]*$\n?)+", tail)
409
+ valid_blocks = []
410
+ for block in table_blocks:
411
+ lines = [line.strip() for line in block.splitlines() if line.strip().startswith("|")]
412
+ if not lines:
413
  continue
414
+ if all(len(re.findall(r"(?<!\\)\|", line)) == 7 for line in lines):
415
+ valid_blocks.append(lines)
416
+ if len(valid_blocks) != 1:
417
+ raise RuntimeError("target_qualitative_appendix_semantic_table_identity_missing_or_ambiguous")
418
+ lines = valid_blocks[0]
419
+ expected_rows = len(manifest.get("rows") or []) + 2
420
+ if len(lines) != expected_rows:
421
+ raise RuntimeError(
422
+ f"target_qualitative_appendix_row_identity_mismatch:expected={expected_rows} got={len(lines)}"
423
+ )
424
+ expected_header = "| " + " | ".join(cls._escape_appendix_markdown_cell(item) for item in headers) + " |"
425
+ if lines[0] != expected_header or not re.fullmatch(
426
+ r"\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|",
427
+ lines[1],
428
+ ):
429
+ raise RuntimeError("target_qualitative_appendix_column_identity_mismatch")
430
+ if re.search(r"\bQUOTE-\d+\b", "\n".join(lines), flags=re.I):
431
+ raise RuntimeError("target_qualitative_appendix_internal_quote_id_leak")
432
+ return True
433
+
434
+ @classmethod
435
+ def build_appendix_evidence_md(cls, metadata):
436
+ """Render the reader-facing English Appendix from the same frozen manifest."""
437
+ manifest = cls.build_appendix_manifest(metadata)
438
+ if not cls._validate_appendix_manifest(manifest):
439
+ raise RuntimeError("qualitative_appendix_manifest_invalid")
440
+ lines = [
441
+ "| " + " | ".join(cls.APPENDIX_HEADERS) + " |",
442
+ "| :--- | :--- | :--- | :--- | :--- | :--- |",
443
+ ]
444
+ for row in manifest.get("rows") or []:
445
+ values = [row.get(field) for field in cls.APPENDIX_FIELDS] + [f'"{row.get("quote")}"']
446
  lines.append(
447
+ "| " + " | ".join(cls._escape_appendix_markdown_cell(value) for value in values) + " |"
 
 
 
 
 
 
 
448
  )
449
  return "\n".join(lines).strip()
450