S0-R1-C5.5-R6-R8 Deterministic Citation Identity Compiler + Author-Year Disambiguation

#96
Files changed (1) hide show
  1. core/pipeline_agent.py +301 -4
core/pipeline_agent.py CHANGED
@@ -1240,12 +1240,16 @@ class SSciPipelineAgent:
1240
  "in_text": in_text,
1241
  "ref": apa_full,
1242
  "id": title.lower().strip()[:50],
 
 
 
1243
  "evidence": (
1244
  f"Title: {title}. Venue: {paper.get('venue', 'Unknown')}. "
1245
  f"Core thesis: {core_thesis}. Abstract: {abstract}"
1246
  ),
1247
  }
1248
  rag_context.append(f"{cite_tag} [USER CORE PAPER] Title: {title}. Core Thesis: {core_thesis}. Abstract: {abstract}")
 
1249
  logging.info(f"✅ [本地解析] 成功提取 {len(papers)} 篇本地文献元数据。")
1250
  return "\n".join(rag_context)
1251
  except Exception as e:
@@ -1856,6 +1860,293 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
1856
  )
1857
  return body, tail
1858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1859
  @staticmethod
1860
  def _resolved_parenthetical_citations(text):
1861
  """Return resolved parenthetical author-year citation markers in prose.
@@ -1907,13 +2198,12 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
1907
  if len(tags) == 1:
1908
  ledger.add(tags[0])
1909
  continue
1910
- distinct_refs = {
1911
- str((self.citation_map.get(tag) or {}).get('ref') or '').strip()
1912
  for tag in tags
1913
- if str((self.citation_map.get(tag) or {}).get('ref') or '').strip()
1914
  }
1915
  previously_used = [tag for tag in tags if tag in set(self.used_citations or set())]
1916
- if len(distinct_refs) == 1:
1917
  ledger.add(sorted(tags, key=self._citation_tag_number)[0])
1918
  elif len(previously_used) == 1:
1919
  ledger.add(previously_used[0])
@@ -2099,6 +2389,7 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2099
  "evidence_lanes": [lane],
2100
  }
2101
  jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
 
2102
  logging.info("METHOD_AUTHORITY retrieval complete | accepted=%s", len(jit_rag_texts))
2103
  return "\n".join(jit_rag_texts)
2104
 
@@ -2627,6 +2918,7 @@ Return ONLY a valid JSON array of strings."""
2627
  else:
2628
  metric['status'] = 'no_results'
2629
  metric['last_safe_error'] = no_result_code
 
2630
  logging.info(
2631
  'Reference search completed: unique=%s metrics=%s',
2632
  len(self.citation_map),
@@ -3251,6 +3543,7 @@ INPUT:
3251
  selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
3252
  if len(selected) >= needed:
3253
  break
 
3254
  logging.info('Contextual citation JIT accepted: section=%s accepted=%s/%s', section_name, len(selected), needed)
3255
  return selected
3256
 
@@ -9749,6 +10042,10 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
9749
  return violations
9750
 
9751
  def resolve_citations(self, text, quarantine=False):
 
 
 
 
9752
  if quarantine:
9753
  text = re.sub(r'\s*\(?[A-Z][a-z]+ (?:& [A-Z][a-z]+ |et al\., )\d{4}\)?\s*', '', text)
9754
  text = re.sub(r'\[?CITE-\d+(?:,\s*CITE-\d+)*\]?', '', text, flags=re.IGNORECASE)
 
1240
  "in_text": in_text,
1241
  "ref": apa_full,
1242
  "id": title.lower().strip()[:50],
1243
+ "title": title,
1244
+ "year": str(year),
1245
+ "sources": ["user_core"],
1246
  "evidence": (
1247
  f"Title: {title}. Venue: {paper.get('venue', 'Unknown')}. "
1248
  f"Core thesis: {core_thesis}. Abstract: {abstract}"
1249
  ),
1250
  }
1251
  rag_context.append(f"{cite_tag} [USER CORE PAPER] Title: {title}. Core Thesis: {core_thesis}. Abstract: {abstract}")
1252
+ self.compile_citation_identities()
1253
  logging.info(f"✅ [本地解析] 成功提取 {len(papers)} 篇本地文献元数据。")
1254
  return "\n".join(rag_context)
1255
  except Exception as e:
 
1860
  )
1861
  return body, tail
1862
 
1863
+ @staticmethod
1864
+ def _citation_alpha_suffix(index):
1865
+ """Return a deterministic lowercase alphabetic suffix: 0->a, 25->z, 26->aa."""
1866
+ value = int(index)
1867
+ if value < 0:
1868
+ raise DeepSeekRequestError('Citation disambiguation suffix index must be non-negative.')
1869
+ chars = []
1870
+ while True:
1871
+ value, remainder = divmod(value, 26)
1872
+ chars.append(chr(ord('a') + remainder))
1873
+ if value == 0:
1874
+ break
1875
+ value -= 1
1876
+ return ''.join(reversed(chars))
1877
+
1878
+ @classmethod
1879
+ def _citation_source_identity(cls, metadata):
1880
+ """Return a provider-independent deterministic source identity for citation disambiguation."""
1881
+ row = dict(metadata or {})
1882
+ cluster_id = str(row.get('_citation_source_cluster_id') or '').strip()
1883
+ if cluster_id:
1884
+ return ('cluster', cluster_id)
1885
+ doi = cls._normalize_doi(row.get('doi'))
1886
+ if doi:
1887
+ return ('doi', doi)
1888
+ title = re.sub(r'[^a-z0-9]+', ' ', str(row.get('title') or '').casefold()).strip()
1889
+ year = str(row.get('year') or '').strip().casefold()
1890
+ if title:
1891
+ return ('title_year', title, year)
1892
+ ref = re.sub(r'\s+', ' ', str(row.get('_citation_base_ref') or row.get('ref') or '')).strip().casefold()
1893
+ if ref:
1894
+ return ('ref', ref)
1895
+ external_id = str(row.get('external_id') or '').strip().casefold()
1896
+ if external_id:
1897
+ return ('external', external_id)
1898
+ return ('unknown', repr(sorted((str(k), str(v)) for k, v in row.items() if k != 'evidence')))
1899
+
1900
+ @staticmethod
1901
+ def _citation_marker_with_year_suffix(marker, suffix):
1902
+ value = str(marker or '').strip()
1903
+ suffix = str(suffix or '').strip().lower()
1904
+ if not value or not suffix:
1905
+ return value
1906
+ # SmartPaper's reader-facing citation grammar is author-year. Only the
1907
+ # terminal publication year is changed; author text remains byte-stable.
1908
+ updated, count = re.subn(
1909
+ r'\b((?:19|20)\d{2})(?:[a-z]+)?\b(?=[^()]*\)$)',
1910
+ lambda m: f"{m.group(1)}{suffix}",
1911
+ value,
1912
+ count=1,
1913
+ flags=re.I,
1914
+ )
1915
+ if count != 1:
1916
+ raise DeepSeekRequestError(
1917
+ f'Citation identity compiler could not suffix author-year marker: {value}'
1918
+ )
1919
+ return updated
1920
+
1921
+ @staticmethod
1922
+ def _citation_reference_with_year_suffix(reference, year, suffix):
1923
+ value = str(reference or '').strip()
1924
+ year = str(year or '').strip()
1925
+ suffix = str(suffix or '').strip().lower()
1926
+ if not value or not suffix:
1927
+ return value
1928
+ if re.fullmatch(r'(?:19|20)\d{2}', year):
1929
+ pattern = re.compile(rf'\({re.escape(year)}(?:[a-z]+)?\)')
1930
+ else:
1931
+ # Fallback to the first conventional four-digit reference year.
1932
+ pattern = re.compile(r'\(((?:19|20)\d{2})(?:[a-z]+)?\)')
1933
+ match = pattern.search(value)
1934
+ if not match:
1935
+ raise DeepSeekRequestError(
1936
+ f'Citation identity compiler could not suffix reference year: {value[:180]}'
1937
+ )
1938
+ base_year = year if re.fullmatch(r'(?:19|20)\d{2}', year) else match.group(1)
1939
+ return value[:match.start()] + f'({base_year}{suffix})' + value[match.end():]
1940
+
1941
+ def compile_citation_identities(self):
1942
+ """Make every reader-facing author-year marker map to exactly one source.
1943
+
1944
+ R6-R8 authority rule:
1945
+ - exact duplicate source identities may share one marker;
1946
+ - distinct sources that collapse to the same author-year marker receive
1947
+ deterministic year suffixes (a/b/c/...) before citation resolution;
1948
+ - already-resolved/used markers are immutable. A late JIT source that
1949
+ collides with one receives the next free suffix instead of renaming
1950
+ prose that has already been sealed.
1951
+
1952
+ This compiler is deterministic and model-free. ZH/RU/KK inherit the
1953
+ frozen English marker as a protected citation anchor.
1954
+ """
1955
+ with self.lock:
1956
+ citation_map = self.citation_map or {}
1957
+ if not citation_map:
1958
+ return {}
1959
+
1960
+ # Preserve unsuffixed authority once so repeated compilation and
1961
+ # checkpoint resume never accumulate suffixes.
1962
+ for metadata in citation_map.values():
1963
+ if not isinstance(metadata, dict):
1964
+ continue
1965
+ metadata.setdefault('_citation_base_in_text', str(metadata.get('in_text') or '').strip())
1966
+ metadata.setdefault('_citation_base_ref', str(metadata.get('ref') or '').strip())
1967
+
1968
+ used = set(self.used_citations or set())
1969
+
1970
+ # Collapse provider/user-core aliases before author-year collision
1971
+ # handling. Two records are the same source when any authoritative
1972
+ # DOI/external/title-year key overlaps. This mirrors retrieval merge
1973
+ # semantics and prevents duplicate bibliographic records from being
1974
+ # misclassified as same-author/same-year distinct works.
1975
+ tags_in_order = sorted(citation_map, key=self._citation_tag_number)
1976
+ parent = {tag: tag for tag in tags_in_order}
1977
+
1978
+ def find(tag):
1979
+ while parent[tag] != tag:
1980
+ parent[tag] = parent[parent[tag]]
1981
+ tag = parent[tag]
1982
+ return tag
1983
+
1984
+ def union(left, right):
1985
+ a, b = find(left), find(right)
1986
+ if a != b:
1987
+ parent[b] = a if self._citation_tag_number(a) <= self._citation_tag_number(b) else b
1988
+ if parent[b] == b:
1989
+ parent[a] = b
1990
+
1991
+ source_keys = {}
1992
+ for tag in tags_in_order:
1993
+ metadata = citation_map.get(tag) or {}
1994
+ keys = set(self._reference_keys(metadata))
1995
+ if not keys:
1996
+ ref_key = re.sub(
1997
+ r'\s+', ' ', str(metadata.get('_citation_base_ref') or metadata.get('ref') or '')
1998
+ ).strip().casefold()
1999
+ if ref_key:
2000
+ keys.add(('ref', ref_key))
2001
+ source_keys[tag] = keys
2002
+ for index, left in enumerate(tags_in_order):
2003
+ if not source_keys[left]:
2004
+ continue
2005
+ for right in tags_in_order[index + 1:]:
2006
+ if source_keys[left] & source_keys[right]:
2007
+ union(left, right)
2008
+
2009
+ components = {}
2010
+ for tag in tags_in_order:
2011
+ components.setdefault(find(tag), []).append(tag)
2012
+ for component_tags in components.values():
2013
+ all_keys = sorted({key for tag in component_tags for key in source_keys.get(tag, set())}, key=repr)
2014
+ if all_keys:
2015
+ preferred = sorted(
2016
+ all_keys,
2017
+ key=lambda key: (
2018
+ {'doi': 0, 'title_year': 1, 'external': 2, 'ref': 3}.get(str(key[0]), 9),
2019
+ repr(key),
2020
+ ),
2021
+ )[0]
2022
+ cluster_id = hashlib.sha256(repr(preferred).encode('utf-8')).hexdigest()[:20]
2023
+ else:
2024
+ cluster_id = hashlib.sha256('|'.join(component_tags).encode('utf-8')).hexdigest()[:20]
2025
+ for tag in component_tags:
2026
+ (citation_map.get(tag) or {})['_citation_source_cluster_id'] = cluster_id
2027
+
2028
+ if len(component_tags) <= 1:
2029
+ continue
2030
+ used_aliases = [tag for tag in component_tags if tag in used]
2031
+ authority_tag = min(used_aliases or component_tags, key=self._citation_tag_number)
2032
+ authority = citation_map.get(authority_tag) or {}
2033
+ authority_marker = str(authority.get('in_text') or '').strip()
2034
+ authority_ref = str(authority.get('ref') or '').strip()
2035
+ authority_base_marker = str(authority.get('_citation_base_in_text') or authority_marker).strip()
2036
+ authority_base_ref = str(authority.get('_citation_base_ref') or authority_ref).strip()
2037
+ # If multiple aliases have already been rendered with different
2038
+ # reader-facing identities, there is no tag-level information in
2039
+ # sealed prose to repair safely. Preserve them for the final
2040
+ # fail-closed legacy gate rather than guessing.
2041
+ used_markers = {
2042
+ str((citation_map.get(tag) or {}).get('in_text') or '').strip()
2043
+ for tag in used_aliases
2044
+ }
2045
+ if len(used_markers) > 1:
2046
+ continue
2047
+ for tag in component_tags:
2048
+ if tag in used_aliases:
2049
+ continue
2050
+ metadata = citation_map.get(tag) or {}
2051
+ metadata['in_text'] = authority_marker
2052
+ metadata['ref'] = authority_ref
2053
+ metadata['_citation_base_in_text'] = authority_base_marker
2054
+ metadata['_citation_base_ref'] = authority_base_ref
2055
+ metadata['citation_source_alias_of'] = authority_tag
2056
+
2057
+ groups = {}
2058
+ for tag, metadata in citation_map.items():
2059
+ if not isinstance(metadata, dict):
2060
+ continue
2061
+ base_marker = str(metadata.get('_citation_base_in_text') or metadata.get('in_text') or '').strip()
2062
+ base_ref = str(metadata.get('_citation_base_ref') or metadata.get('ref') or '').strip()
2063
+ if not base_marker or not base_ref:
2064
+ continue
2065
+ groups.setdefault(base_marker, []).append(str(tag))
2066
+
2067
+ changed = {}
2068
+ for base_marker, tags in groups.items():
2069
+ # Cluster duplicate provider records by canonical source identity.
2070
+ clusters = {}
2071
+ for tag in tags:
2072
+ metadata = citation_map.get(tag) or {}
2073
+ identity = self._citation_source_identity(metadata)
2074
+ clusters.setdefault(identity, []).append(tag)
2075
+ if len(clusters) <= 1:
2076
+ continue
2077
+
2078
+ ordered = sorted(
2079
+ clusters.items(),
2080
+ key=lambda item: (
2081
+ re.sub(r'\s+', ' ', str((citation_map.get(item[1][0]) or {}).get('title') or '')).casefold(),
2082
+ repr(item[0]),
2083
+ min(self._citation_tag_number(tag) for tag in item[1]),
2084
+ ),
2085
+ )
2086
+ used_clusters = {
2087
+ identity for identity, cluster_tags in ordered if any(tag in used for tag in cluster_tags)
2088
+ }
2089
+
2090
+ reserved_markers = {
2091
+ str((citation_map.get(tag) or {}).get('in_text') or '').strip()
2092
+ for tag in used
2093
+ if tag in tags and str((citation_map.get(tag) or {}).get('in_text') or '').strip()
2094
+ }
2095
+ next_suffix_index = 0
2096
+ for identity, cluster_tags in ordered:
2097
+ cluster_is_used = identity in used_clusters
2098
+ if cluster_is_used:
2099
+ # Never rename a marker that may already exist in sealed prose.
2100
+ markers = {
2101
+ str((citation_map.get(tag) or {}).get('in_text') or '').strip()
2102
+ for tag in cluster_tags if tag in used
2103
+ }
2104
+ if len(markers) > 1:
2105
+ raise DeepSeekRequestError(
2106
+ f'Citation identity compiler found one used source with inconsistent markers: {sorted(markers)}'
2107
+ )
2108
+ reserved_markers.update(markers)
2109
+ continue
2110
+
2111
+ # Before any citation is used, assign conventional a/b/c to
2112
+ # the complete collision set. After prose has been sealed,
2113
+ # only new/unresolved sources receive a suffix, preserving
2114
+ # previously rendered text byte-for-byte.
2115
+ while True:
2116
+ suffix = self._citation_alpha_suffix(next_suffix_index)
2117
+ next_suffix_index += 1
2118
+ candidate_marker = self._citation_marker_with_year_suffix(base_marker, suffix)
2119
+ if candidate_marker not in reserved_markers:
2120
+ break
2121
+ reserved_markers.add(candidate_marker)
2122
+ for tag in cluster_tags:
2123
+ metadata = citation_map.get(tag) or {}
2124
+ base_ref = str(metadata.get('_citation_base_ref') or metadata.get('ref') or '').strip()
2125
+ year = str(metadata.get('year') or '').strip()
2126
+ candidate_ref = self._citation_reference_with_year_suffix(base_ref, year, suffix)
2127
+ old_marker = str(metadata.get('in_text') or '').strip()
2128
+ old_ref = str(metadata.get('ref') or '').strip()
2129
+ metadata['in_text'] = candidate_marker
2130
+ metadata['ref'] = candidate_ref
2131
+ metadata['citation_disambiguation_suffix'] = suffix
2132
+ metadata['citation_identity_compiler_version'] = 'R6-R8-v1'
2133
+ if candidate_marker != old_marker or candidate_ref != old_ref:
2134
+ changed[tag] = {'from': old_marker, 'to': candidate_marker, 'suffix': suffix}
2135
+
2136
+ # If no member of the collision group has been used yet, all
2137
+ # clusters must be suffixed. The loop above does that because
2138
+ # used_clusters is empty. If one or more clusters are already
2139
+ # used, unchanged used markers remain authoritative while new
2140
+ # markers are made unique against them.
2141
+
2142
+ if changed:
2143
+ logging.info(
2144
+ 'Citation identity compiler disambiguated markers: count=%s changes=%s',
2145
+ len(changed),
2146
+ {tag: row['to'] for tag, row in sorted(changed.items(), key=lambda item: self._citation_tag_number(item[0]))},
2147
+ )
2148
+ return changed
2149
+
2150
  @staticmethod
2151
  def _resolved_parenthetical_citations(text):
2152
  """Return resolved parenthetical author-year citation markers in prose.
 
2198
  if len(tags) == 1:
2199
  ledger.add(tags[0])
2200
  continue
2201
+ distinct_sources = {
2202
+ self._citation_source_identity(self.citation_map.get(tag) or {})
2203
  for tag in tags
 
2204
  }
2205
  previously_used = [tag for tag in tags if tag in set(self.used_citations or set())]
2206
+ if len(distinct_sources) == 1:
2207
  ledger.add(sorted(tags, key=self._citation_tag_number)[0])
2208
  elif len(previously_used) == 1:
2209
  ledger.add(previously_used[0])
 
2389
  "evidence_lanes": [lane],
2390
  }
2391
  jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
2392
+ self.compile_citation_identities()
2393
  logging.info("METHOD_AUTHORITY retrieval complete | accepted=%s", len(jit_rag_texts))
2394
  return "\n".join(jit_rag_texts)
2395
 
 
2918
  else:
2919
  metric['status'] = 'no_results'
2920
  metric['last_safe_error'] = no_result_code
2921
+ self.compile_citation_identities()
2922
  logging.info(
2923
  'Reference search completed: unique=%s metrics=%s',
2924
  len(self.citation_map),
 
3543
  selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
3544
  if len(selected) >= needed:
3545
  break
3546
+ self.compile_citation_identities()
3547
  logging.info('Contextual citation JIT accepted: section=%s accepted=%s/%s', section_name, len(selected), needed)
3548
  return selected
3549
 
 
10042
  return violations
10043
 
10044
  def resolve_citations(self, text, quarantine=False):
10045
+ # R6-R8 invariant: author-year identity is compiled immediately before
10046
+ # any raw [CITE-XX] tag becomes reader-facing prose. This prevents a
10047
+ # late final-ledger collision even if a new source was admitted by JIT.
10048
+ self.compile_citation_identities()
10049
  if quarantine:
10050
  text = re.sub(r'\s*\(?[A-Z][a-z]+ (?:& [A-Z][a-z]+ |et al\., )\d{4}\)?\s*', '', text)
10051
  text = re.sub(r'\[?CITE-\d+(?:,\s*CITE-\d+)*\]?', '', text, flags=re.IGNORECASE)