synthesis_input.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. """Lossless source-local reuse of repeated synthesis text and list entries."""
  2. from collections import Counter
  3. from copy import deepcopy
  4. import json
  5. import re
  6. TEXT_REUSE_RULE = '''
  7. 输入压缩规则:每条来源的text_pool只存本来源的原文字串。字段为{"text_parts":[...]}时,字符串直接读取,{"ref":"t1"}取同条text_pool对应全文,按顺序无缝拼接即原字段。所有部分必须完整读取,不遗漏末尾的条件、否定、例外和版本,不跨来源取字串。字段、原路径和状态各自有效,文字相同不表示资格满足。此编码不改变原文;输出仍用原source_id和JSON协议,不展示内部ref/text_pool。'''
  8. TEXT_REUSE_MIN_BYTES = 96
  9. TEXT_REUSE_MAX_FRAGMENTS = 256
  10. TEXT_REUSE_MAX_PATTERN_CHARS = 32768
  11. TEXT_REUSE_MIN_NET_BYTES = 1024
  12. TEXT_REUSE_MIN_NET_PERCENT = 5
  13. def text_reuse_minimum(payload_bytes):
  14. """Avoid encoding complexity for byte-level wins that cannot justify it."""
  15. return max(TEXT_REUSE_MIN_NET_BYTES, (payload_bytes * TEXT_REUSE_MIN_NET_PERCENT + 99) // 100)
  16. def _bytes(value):
  17. return len(json.dumps(value, ensure_ascii=False).encode('utf-8'))
  18. def _text_locations(entry):
  19. """Only prose fields are encoded; IDs, paths, names and markers stay literal."""
  20. locations = []
  21. def visit(value, path, root):
  22. if isinstance(value, dict):
  23. for key, child in value.items():
  24. visit(child, (*path, key), root)
  25. elif isinstance(value, list):
  26. for index, child in enumerate(value):
  27. visit(child, (*path, index), root)
  28. elif isinstance(value, str) and len(value.encode('utf-8')) >= TEXT_REUSE_MIN_BYTES:
  29. if root == 'citations' and path[-1] != 'quote':
  30. return
  31. if root == 'card' and len(path) > 1 and path[1] == 'name':
  32. return
  33. if root == 'conditions' and path[-1] not in ('quote', 'requirement', 'user_evidence', 'question'):
  34. return
  35. if root == 'match_basis' and path[-1] not in ('fact_quote', 'explanation'):
  36. return
  37. locations.append((path, value))
  38. for root in ('answer', 'citations', 'card', 'conditions', 'match_basis', 'pending_questions'):
  39. if root in entry:
  40. visit(entry[root], (root,), root)
  41. return locations
  42. def _assign(entry, path, value):
  43. target = entry
  44. for key in path[:-1]:
  45. target = target[key]
  46. target[path[-1]] = value
  47. def expand_text_entry(entry):
  48. """Restore the exact source-local representation for validation and tests."""
  49. pool = entry.get('text_pool')
  50. if not isinstance(pool, dict):
  51. return deepcopy(entry)
  52. def expand(value):
  53. if isinstance(value, dict):
  54. if set(value) == {'text_parts'}:
  55. parts = value['text_parts']
  56. if not isinstance(parts, list):
  57. raise ValueError('invalid_synthesis_text_parts')
  58. result = []
  59. for part in parts:
  60. if isinstance(part, str):
  61. result.append(part)
  62. elif (isinstance(part, dict) and set(part) == {'ref'} and isinstance(part['ref'], str)
  63. and isinstance(pool.get(part['ref']), str)):
  64. result.append(pool[part['ref']])
  65. else:
  66. raise ValueError('invalid_synthesis_text_reference')
  67. return ''.join(result)
  68. return {k: expand(v) for k, v in value.items()}
  69. if isinstance(value, list):
  70. return [expand(v) for v in value]
  71. return value
  72. return expand({k: v for k, v in entry.items() if k != 'text_pool'})
  73. def _reuse_entry(entry):
  74. if not isinstance(entry, dict) or not isinstance(entry.get('source_id'), str) or 'text_pool' in entry:
  75. return entry
  76. locations = _text_locations(entry)
  77. candidates = Counter()
  78. for _, text in locations:
  79. sentences = re.split(r'(?<=[。!?;;!?\n])', text)
  80. candidates.update(s for s in sentences if len(s.encode('utf-8')) >= TEXT_REUSE_MIN_BYTES)
  81. if text not in sentences:
  82. candidates[text] += 1
  83. # Bound indexing work; unselected or unique prose remains inline in full.
  84. ordered = sorted((s for s, count in candidates.items() if count >= 2),
  85. key=lambda s: (len(s.encode('utf-8')) * (candidates[s] - 1), len(s)), reverse=True)
  86. selected, remaining = [], TEXT_REUSE_MAX_PATTERN_CHARS
  87. for text in ordered:
  88. if len(text) <= remaining:
  89. selected.append(text)
  90. remaining -= len(text)
  91. if len(selected) >= TEXT_REUSE_MAX_FRAGMENTS:
  92. break
  93. if not selected:
  94. return entry
  95. for _ in range(3):
  96. pattern = re.compile('|'.join(re.escape(s) for s in sorted(selected, key=len, reverse=True)))
  97. uses = Counter(m.group() for _, text in locations for m in pattern.finditer(text))
  98. kept = [s for s in selected if uses[s] >= 2]
  99. if kept == selected:
  100. break
  101. selected = kept
  102. if not selected:
  103. return entry
  104. pattern = re.compile('|'.join(re.escape(s) for s in sorted(selected, key=len, reverse=True)))
  105. ids = {text: f't{index}' for index, text in enumerate(selected)}
  106. result = deepcopy(entry)
  107. count = 0
  108. for path, text in locations:
  109. parts, start = [], 0
  110. for match in pattern.finditer(text):
  111. if match.group() not in ids:
  112. continue
  113. if match.start() > start:
  114. parts.append(text[start:match.start()])
  115. parts.append({'ref': ids[match.group()]})
  116. start = match.end()
  117. count += 1
  118. if parts:
  119. if start < len(text):
  120. parts.append(text[start:])
  121. _assign(result, path, {'text_parts': parts})
  122. if not count:
  123. return entry
  124. result['text_pool'] = {identity: text for text, identity in ids.items()}
  125. # No semantic selection or truncation: every field, path and condition must
  126. # reconstruct exactly. Skip any representation whose JSON overhead grows.
  127. try:
  128. valid = expand_text_entry(result) == entry
  129. except (ValueError, TypeError, KeyError):
  130. valid = False
  131. return result if valid and _bytes(result) < _bytes(entry) else entry
  132. def text_reuse_prompt(prompt, payload):
  133. entries = payload.get('evidence', payload.get('cards', []))
  134. return prompt + TEXT_REUSE_RULE if any(isinstance(e, dict) and 'text_pool' in e for e in entries) else prompt
  135. def final_model_budget(model, prompt, payload, diagnostics):
  136. """Choose a call-local output limit; never mutate shared models or evidence."""
  137. maximum = getattr(model, 'MAX_OUTPUT_TOKENS', None)
  138. minimum = getattr(model, 'MIN_OUTPUT_TOKENS', None)
  139. config = getattr(model, 'config', None)
  140. context = getattr(config, 'context_tokens', None)
  141. margin = getattr(config, 'margin', None)
  142. if not all(type(v) is int for v in (maximum, minimum, context, margin)):
  143. return model
  144. margin = max(904, margin)
  145. size = len((prompt + json.dumps(payload, ensure_ascii=False)).encode('utf-8'))
  146. available = context - margin - size
  147. # Below the floor, keep the normal budget and let hierarchical reduction run.
  148. reserve = min(maximum, available) if available >= minimum else maximum
  149. diagnostics.update(configured_output_token_reserve=maximum, min_output_token_reserve=minimum,
  150. output_token_reserve=reserve, output_reserve_reduction=maximum - reserve,
  151. final_call_input_bytes=size, context_limit=context, margin_reserve=margin,
  152. reserved_total=size + reserve + margin)
  153. return model.with_output_tokens(reserve) if reserve != maximum else model
  154. def compact_payload(payload, diagnostics=None):
  155. result = deepcopy(payload)
  156. removed = 0
  157. def unique(rows):
  158. nonlocal removed
  159. seen, kept = set(), []
  160. for row in rows:
  161. key = json.dumps(row, ensure_ascii=False, sort_keys=True)
  162. if key in seen:
  163. removed += 1
  164. else:
  165. seen.add(key)
  166. kept.append(row)
  167. return kept
  168. # Do not merge sources, rewrite prose, or normalize quotes and qualifiers.
  169. for entry in result.get('evidence', result.get('cards', [])):
  170. for field in ('citations', 'conditions', 'match_basis'):
  171. if isinstance(entry.get(field), list):
  172. entry[field] = unique(entry[field])
  173. if isinstance(result.get('known_subject_facts'), list):
  174. result['known_subject_facts'] = unique(result['known_subject_facts'])
  175. key = 'evidence' if 'evidence' in result else 'cards' if 'cards' in result else None
  176. if key and isinstance(result[key], list):
  177. packed = {**result, key: [_reuse_entry(entry) for entry in result[key]]}
  178. savings = _bytes(result) - _bytes(packed)
  179. already_packed = any(isinstance(e, dict) and 'text_pool' in e for e in result[key])
  180. net = savings - (0 if already_packed else len(TEXT_REUSE_RULE.encode('utf-8')))
  181. minimum = text_reuse_minimum(_bytes(result))
  182. if savings > 0 and diagnostics is not None:
  183. diagnostics['text_reuse_candidate_net_bytes'] = max(0, net)
  184. diagnostics['text_reuse_minimum_net_bytes'] = minimum
  185. diagnostics['text_reuse_applied'] = int(net >= minimum)
  186. if net >= minimum:
  187. if diagnostics is not None:
  188. diagnostics['reused_text_bytes_saved'] = savings
  189. diagnostics['pooled_texts'] = sum(len(e.get('text_pool', {})) for e in packed[key] if isinstance(e, dict))
  190. result = packed
  191. return result, removed