Spaces:
Sleeping
Sleeping
P3-A4 minimal citation authority repair on live main
#152
by GuXiaopeng - opened
- core/generation_runner.py +104 -8
- core/pipeline_agent.py +244 -6
core/generation_runner.py
CHANGED
|
@@ -1902,6 +1902,21 @@ class GenerationRunner:
|
|
| 1902 |
rows.append(' | '.join(parts))
|
| 1903 |
return '\n'.join(f'- {row}' for row in rows)
|
| 1904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1905 |
@staticmethod
|
| 1906 |
def _section_violation_severity(row, *, section_name: str = "", mode: str = "", exhausted: bool = False) -> str:
|
| 1907 |
"""Map one validation issue to REPAIRABLE/FATAL/ADVISORY.
|
|
@@ -1929,7 +1944,12 @@ class GenerationRunner:
|
|
| 1929 |
"unsupported_study_procedure", "enumeration_contract_invalid",
|
| 1930 |
"hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
|
| 1931 |
"hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
|
| 1932 |
-
"citation_outside_frozen_section_whitelist",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
"study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
|
| 1934 |
"unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
|
| 1935 |
"unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
|
|
@@ -1972,7 +1992,17 @@ class GenerationRunner:
|
|
| 1972 |
item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
|
| 1973 |
kind = str(item.get("type") or "unknown_validation_issue")
|
| 1974 |
groups = {
|
| 1975 |
-
"citation_scope": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1976 |
"citation_ownership": {"citation_evidence_ownership_mismatch"},
|
| 1977 |
"citation_support": {"citation_unsupported_claim", "citation_support_unverified"},
|
| 1978 |
"citation_compound": {"citation_claim_split_required"},
|
|
@@ -2110,6 +2140,20 @@ class GenerationRunner:
|
|
| 2110 |
blocking.append(row)
|
| 2111 |
return blocking, advisory
|
| 2112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2113 |
@staticmethod
|
| 2114 |
def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
|
| 2115 |
"""Append reader-facing, nonfatal notes without exposing control vocabulary."""
|
|
@@ -2177,6 +2221,7 @@ class GenerationRunner:
|
|
| 2177 |
self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
|
| 2178 |
section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
|
| 2179 |
hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
|
|
|
|
| 2180 |
):
|
| 2181 |
"""A2 last-mile semantic repair after lineage retry exhaustion.
|
| 2182 |
|
|
@@ -2215,10 +2260,22 @@ class GenerationRunner:
|
|
| 2215 |
repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
|
| 2216 |
repaired, section_name=section_name
|
| 2217 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2218 |
rows = self._section_validation_bundle(
|
| 2219 |
mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
|
| 2220 |
section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
|
| 2221 |
-
hypothesis_ledger=hypothesis_ledger,
|
| 2222 |
)
|
| 2223 |
blocking, advisory = self._partition_section_violations(
|
| 2224 |
rows, section_name=section_name, mode=mode, exhausted=True
|
|
@@ -5600,11 +5657,8 @@ class GenerationRunner:
|
|
| 5600 |
)
|
| 5601 |
sec_title = sec["section"]
|
| 5602 |
subsection = sec.get("subsection")
|
| 5603 |
-
is_quarantined = any(
|
| 5604 |
-
word in sec_title.lower()
|
| 5605 |
-
for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
|
| 5606 |
-
)
|
| 5607 |
section_id = str(sec.get("section_id") or "")
|
|
|
|
| 5608 |
section_logical_node = self._section_logical_node(
|
| 5609 |
mode, section_id, sec.get("subsection_id")
|
| 5610 |
)
|
|
@@ -5630,7 +5684,7 @@ class GenerationRunner:
|
|
| 5630 |
elif section_id == "conclusion":
|
| 5631 |
mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
|
| 5632 |
|
| 5633 |
-
if is_quarantined
|
| 5634 |
citation_rule = (
|
| 5635 |
"NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
|
| 5636 |
"Report only documented empirical/qualitative evidence and the interpretation permitted by the section role."
|
|
@@ -6097,6 +6151,29 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 6097 |
if adaptive_advisory:
|
| 6098 |
self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
|
| 6099 |
if adaptive_blocking:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6100 |
self._record_adaptive_completion_residual(
|
| 6101 |
quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
|
| 6102 |
)
|
|
@@ -6276,7 +6353,26 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 6276 |
study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
|
| 6277 |
hypothesis_ledger=hypothesis_ledger, task=sec,
|
| 6278 |
quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6279 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6280 |
logging.warning(
|
| 6281 |
"Adaptive completion accepted section | unit=%s residual=%s",
|
| 6282 |
display_name, len(residual_rows),
|
|
|
|
| 1902 |
rows.append(' | '.join(parts))
|
| 1903 |
return '\n'.join(f'- {row}' for row in rows)
|
| 1904 |
|
| 1905 |
+
@staticmethod
|
| 1906 |
+
def _section_forbids_external_citations(section_title: str = "", section_id: str = "") -> bool:
|
| 1907 |
+
"""One authority for manuscript units that must remain citation-free.
|
| 1908 |
+
|
| 1909 |
+
P3-A4 closes a historic split where Results/Findings were declared
|
| 1910 |
+
citation-free in the Writer prompt but did not enter the same quarantine
|
| 1911 |
+
validation path as Conclusion/Appendix-style units.
|
| 1912 |
+
"""
|
| 1913 |
+
title = str(section_title or '').casefold()
|
| 1914 |
+
sid = str(section_id or '').strip().casefold()
|
| 1915 |
+
return (
|
| 1916 |
+
any(word in title for word in ("abstract", "keyword", "conclusion", "references", "appendix"))
|
| 1917 |
+
or sid in {"results", "findings"}
|
| 1918 |
+
)
|
| 1919 |
+
|
| 1920 |
@staticmethod
|
| 1921 |
def _section_violation_severity(row, *, section_name: str = "", mode: str = "", exhausted: bool = False) -> str:
|
| 1922 |
"""Map one validation issue to REPAIRABLE/FATAL/ADVISORY.
|
|
|
|
| 1944 |
"unsupported_study_procedure", "enumeration_contract_invalid",
|
| 1945 |
"hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
|
| 1946 |
"hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
|
| 1947 |
+
"citation_outside_frozen_section_whitelist",
|
| 1948 |
+
"citation_resolved_marker_outside_frozen_section_whitelist",
|
| 1949 |
+
"citation_unknown_tag", "citation_resolved_marker_without_authority",
|
| 1950 |
+
"citation_resolved_marker_ambiguous", "citation_noncanonical_resolved_marker",
|
| 1951 |
+
"citation_forbidden_in_quarantined_unit",
|
| 1952 |
+
"authorial_ai_meta_trace",
|
| 1953 |
"study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
|
| 1954 |
"unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
|
| 1955 |
"unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
|
|
|
|
| 1992 |
item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
|
| 1993 |
kind = str(item.get("type") or "unknown_validation_issue")
|
| 1994 |
groups = {
|
| 1995 |
+
"citation_scope": {
|
| 1996 |
+
"citation_outside_frozen_section_whitelist",
|
| 1997 |
+
"citation_resolved_marker_outside_frozen_section_whitelist",
|
| 1998 |
+
},
|
| 1999 |
+
"citation_authority": {
|
| 2000 |
+
"citation_unknown_tag",
|
| 2001 |
+
"citation_resolved_marker_without_authority",
|
| 2002 |
+
"citation_resolved_marker_ambiguous",
|
| 2003 |
+
"citation_noncanonical_resolved_marker",
|
| 2004 |
+
"citation_forbidden_in_quarantined_unit",
|
| 2005 |
+
},
|
| 2006 |
"citation_ownership": {"citation_evidence_ownership_mismatch"},
|
| 2007 |
"citation_support": {"citation_unsupported_claim", "citation_support_unverified"},
|
| 2008 |
"citation_compound": {"citation_claim_split_required"},
|
|
|
|
| 2140 |
blocking.append(row)
|
| 2141 |
return blocking, advisory
|
| 2142 |
|
| 2143 |
+
@classmethod
|
| 2144 |
+
def _persistent_citation_authority_rows(cls, rows) -> list[dict]:
|
| 2145 |
+
"""Return citation identity/scope defects that may never cross a Section seal.
|
| 2146 |
+
|
| 2147 |
+
P3-A4 intentionally keeps this narrower than general validation failure.
|
| 2148 |
+
Citation support/style advisories retain their existing adaptive-completion
|
| 2149 |
+
behavior; only source identity and unit-scope authority stay fail-closed.
|
| 2150 |
+
"""
|
| 2151 |
+
return [
|
| 2152 |
+
row for row in list(rows or [])
|
| 2153 |
+
if isinstance(row, dict)
|
| 2154 |
+
and cls._section_violation_lineage(row) in {"citation_scope", "citation_authority"}
|
| 2155 |
+
]
|
| 2156 |
+
|
| 2157 |
@staticmethod
|
| 2158 |
def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
|
| 2159 |
"""Append reader-facing, nonfatal notes without exposing control vocabulary."""
|
|
|
|
| 2221 |
self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
|
| 2222 |
section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
|
| 2223 |
hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
|
| 2224 |
+
allowed_citation_tags=None, quarantine=False,
|
| 2225 |
):
|
| 2226 |
"""A2 last-mile semantic repair after lineage retry exhaustion.
|
| 2227 |
|
|
|
|
| 2260 |
repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
|
| 2261 |
repaired, section_name=section_name
|
| 2262 |
)
|
| 2263 |
+
# P3-A4: every model-owned last-mile rewrite must re-enter the same
|
| 2264 |
+
# citation authority + claim-specific semantic Gate before it can be
|
| 2265 |
+
# considered sealable. Otherwise an unrelated adaptive repair could
|
| 2266 |
+
# reintroduce a pre-resolved/unknown/wrong-scope citation after the
|
| 2267 |
+
# earlier Section citation audit had already passed.
|
| 2268 |
+
repaired = agent.audit_and_resolve_citations(
|
| 2269 |
+
repaired, section_name=section_name, section_id=section_id,
|
| 2270 |
+
quarantine=quarantine, allowed_tags=allowed_citation_tags,
|
| 2271 |
+
)
|
| 2272 |
+
adaptive_citation_rows = copy.deepcopy(
|
| 2273 |
+
list(getattr(agent, "_last_citation_semantic_violations", []) or [])
|
| 2274 |
+
)
|
| 2275 |
rows = self._section_validation_bundle(
|
| 2276 |
mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
|
| 2277 |
section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
|
| 2278 |
+
citation_violations=adaptive_citation_rows, hypothesis_ledger=hypothesis_ledger,
|
| 2279 |
)
|
| 2280 |
blocking, advisory = self._partition_section_violations(
|
| 2281 |
rows, section_name=section_name, mode=mode, exhausted=True
|
|
|
|
| 5657 |
)
|
| 5658 |
sec_title = sec["section"]
|
| 5659 |
subsection = sec.get("subsection")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5660 |
section_id = str(sec.get("section_id") or "")
|
| 5661 |
+
is_quarantined = self._section_forbids_external_citations(sec_title, section_id)
|
| 5662 |
section_logical_node = self._section_logical_node(
|
| 5663 |
mode, section_id, sec.get("subsection_id")
|
| 5664 |
)
|
|
|
|
| 5684 |
elif section_id == "conclusion":
|
| 5685 |
mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
|
| 5686 |
|
| 5687 |
+
if is_quarantined:
|
| 5688 |
citation_rule = (
|
| 5689 |
"NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
|
| 5690 |
"Report only documented empirical/qualitative evidence and the interpretation permitted by the section role."
|
|
|
|
| 6151 |
if adaptive_advisory:
|
| 6152 |
self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
|
| 6153 |
if adaptive_blocking:
|
| 6154 |
+
# P3-A4: citation identity/scope authority is a hard
|
| 6155 |
+
# pre-seal invariant. Adaptive completion may retain
|
| 6156 |
+
# reader-facing advisories for unrelated quality
|
| 6157 |
+
# defects, but it may never seal a Section that still
|
| 6158 |
+
# contains an unauthorized/ambiguous citation identity
|
| 6159 |
+
# or a citation outside the frozen unit whitelist.
|
| 6160 |
+
persistent_citation_authority = self._persistent_citation_authority_rows(
|
| 6161 |
+
adaptive_blocking
|
| 6162 |
+
)
|
| 6163 |
+
if persistent_citation_authority:
|
| 6164 |
+
self._checkpoint_section_retry_exhausted(
|
| 6165 |
+
state, section_id=section_id, section_name=display_name,
|
| 6166 |
+
violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
|
| 6167 |
+
)
|
| 6168 |
+
raise RuntimeError(
|
| 6169 |
+
"citation_authority_repair_exhausted:"
|
| 6170 |
+
+ str(display_name)
|
| 6171 |
+
+ ":"
|
| 6172 |
+
+ ",".join(sorted({
|
| 6173 |
+
str((row or {}).get("type") or "citation_authority")
|
| 6174 |
+
for row in persistent_citation_authority if isinstance(row, dict)
|
| 6175 |
+
}))
|
| 6176 |
+
)
|
| 6177 |
self._record_adaptive_completion_residual(
|
| 6178 |
quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
|
| 6179 |
)
|
|
|
|
| 6353 |
study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
|
| 6354 |
hypothesis_ledger=hypothesis_ledger, task=sec,
|
| 6355 |
quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
|
| 6356 |
+
allowed_citation_tags=allowed_section_citation_tags,
|
| 6357 |
+
quarantine=is_quarantined,
|
| 6358 |
+
)
|
| 6359 |
+
persistent_citation_authority = self._persistent_citation_authority_rows(
|
| 6360 |
+
residual_rows
|
| 6361 |
)
|
| 6362 |
+
if persistent_citation_authority:
|
| 6363 |
+
self._checkpoint_section_retry_exhausted(
|
| 6364 |
+
state, section_id=section_id, section_name=display_name,
|
| 6365 |
+
violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
|
| 6366 |
+
)
|
| 6367 |
+
raise RuntimeError(
|
| 6368 |
+
"citation_authority_repair_exhausted:"
|
| 6369 |
+
+ str(display_name)
|
| 6370 |
+
+ ":"
|
| 6371 |
+
+ ",".join(sorted({
|
| 6372 |
+
str((row or {}).get("type") or "citation_authority")
|
| 6373 |
+
for row in persistent_citation_authority if isinstance(row, dict)
|
| 6374 |
+
}))
|
| 6375 |
+
)
|
| 6376 |
logging.warning(
|
| 6377 |
"Adaptive completion accepted section | unit=%s residual=%s",
|
| 6378 |
display_name, len(residual_rows),
|
core/pipeline_agent.py
CHANGED
|
@@ -6349,6 +6349,196 @@ Return exactly:
|
|
| 6349 |
)
|
| 6350 |
return revised
|
| 6351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6352 |
def audit_and_resolve_citations(
|
| 6353 |
self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
|
| 6354 |
):
|
|
@@ -6358,12 +6548,41 @@ Return exactly:
|
|
| 6358 |
)
|
| 6359 |
value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
|
| 6360 |
if quarantine:
|
| 6361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6362 |
unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
|
| 6363 |
if unknown:
|
| 6364 |
for tag in unknown:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6365 |
value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
|
| 6366 |
-
self._append_citation_audit(section_name, '
|
|
|
|
| 6367 |
allowed = None if allowed_tags is None else {
|
| 6368 |
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 6369 |
}
|
|
@@ -6383,6 +6602,16 @@ Return exactly:
|
|
| 6383 |
self._append_citation_audit(
|
| 6384 |
section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
|
| 6385 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6386 |
has_citations = bool(re.search(r'CITE-\d+', value, re.I))
|
| 6387 |
violations = self.find_multi_citation_violations(value)
|
| 6388 |
if has_citations and self.citation_semantic_audit.get('enabled', True):
|
|
@@ -6413,12 +6642,21 @@ Return exactly:
|
|
| 6413 |
value = self._local_citation_recovery(value, section_name, retain_lexical=False)
|
| 6414 |
elif violations:
|
| 6415 |
value = self._local_citation_recovery(value, section_name)
|
| 6416 |
-
|
|
|
|
|
|
|
| 6417 |
self._last_citation_semantic_violations = (
|
| 6418 |
-
list(
|
| 6419 |
-
+ list(self._last_citation_semantic_violations or [])
|
| 6420 |
-
+ list(outside_rows)
|
| 6421 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6422 |
if self.find_multi_citation_violations(value):
|
| 6423 |
value = self._local_citation_recovery(value, section_name)
|
| 6424 |
if self.find_multi_citation_violations(value):
|
|
|
|
| 6349 |
)
|
| 6350 |
return revised
|
| 6351 |
|
| 6352 |
+
@staticmethod
|
| 6353 |
+
def _high_confidence_noncanonical_citation_spans(text):
|
| 6354 |
+
"""Return high-confidence direct author-year citation forms outside canonical parentheses.
|
| 6355 |
+
|
| 6356 |
+
These forms are never rebound heuristically. They are surfaced as typed
|
| 6357 |
+
section violations so the owning writer can retry with its frozen CITE
|
| 6358 |
+
capability or narrow the unsupported claim. Patterns intentionally avoid
|
| 6359 |
+
bare years and tokens containing digits to reduce false positives for
|
| 6360 |
+
model/version/statistical expressions.
|
| 6361 |
+
"""
|
| 6362 |
+
value = str(text or '')
|
| 6363 |
+
surname = r"[A-ZÀ-ÖØ-Þ][A-Za-zÀ-ÖØ-öø-ÿ'’.-]{1,39}"
|
| 6364 |
+
conjunction = r"(?:&|and|AND)"
|
| 6365 |
+
et_al = r"(?:et|Et|ET)\s+(?:al|Al|AL)\."
|
| 6366 |
+
author_group = rf"(?:{surname}(?:\s+{conjunction}\s+{surname})?|{surname}\s+{et_al})"
|
| 6367 |
+
year = r"(?:19|20)\d{2}[a-z]?"
|
| 6368 |
+
patterns = (
|
| 6369 |
+
re.compile(rf"\[(?P<cite>{author_group}\s*,\s*{year})\]"),
|
| 6370 |
+
re.compile(rf"(?<![\w\[])\b(?P<cite>{author_group})\s*\(\s*(?P<year>{year})\s*\)"),
|
| 6371 |
+
)
|
| 6372 |
+
spans = []
|
| 6373 |
+
for pattern in patterns:
|
| 6374 |
+
for match in pattern.finditer(value):
|
| 6375 |
+
candidate = match.group(0).strip()
|
| 6376 |
+
# Canonical SmartPaper markers are handled separately and always
|
| 6377 |
+
# contain the author and year inside the same parentheses.
|
| 6378 |
+
if candidate.startswith('(') and candidate.endswith(')'):
|
| 6379 |
+
continue
|
| 6380 |
+
spans.append((match.start(), match.end(), candidate))
|
| 6381 |
+
# Stable de-duplication for overlapping patterns.
|
| 6382 |
+
unique = []
|
| 6383 |
+
seen = set()
|
| 6384 |
+
for row in sorted(spans, key=lambda item: (item[0], item[1], item[2])):
|
| 6385 |
+
key = (row[0], row[1], row[2])
|
| 6386 |
+
if key not in seen:
|
| 6387 |
+
seen.add(key)
|
| 6388 |
+
unique.append(row)
|
| 6389 |
+
return unique
|
| 6390 |
+
def _normalize_pre_resolved_citation_authority(self, text, *, allowed_tags=None, section_name=''):
|
| 6391 |
+
"""Normalize only exact, authoritative pre-resolved markers back to CITE tags.
|
| 6392 |
+
|
| 6393 |
+
The Section Writer is supposed to emit frozen ``[CITE-XX]`` capabilities.
|
| 6394 |
+
A model-emitted reader-facing author-year marker therefore has no provenance
|
| 6395 |
+
until it is matched exactly against the *compiled* citation authority.
|
| 6396 |
+
|
| 6397 |
+
Safe normalization requires all of the following:
|
| 6398 |
+
- exact marker equality after ``compile_citation_identities()``;
|
| 6399 |
+
- authoritative reference metadata;
|
| 6400 |
+
- one distinct source identity for the visible marker;
|
| 6401 |
+
- at least one matching tag inside this manuscript unit's whitelist.
|
| 6402 |
+
|
| 6403 |
+
Anything else becomes a typed violation. No fuzzy author/year matching,
|
| 6404 |
+
metadata synthesis, or silent citation deletion is permitted here.
|
| 6405 |
+
"""
|
| 6406 |
+
value = str(text or '')
|
| 6407 |
+
allowed = None if allowed_tags is None else {
|
| 6408 |
+
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 6409 |
+
}
|
| 6410 |
+
self.compile_citation_identities()
|
| 6411 |
+
|
| 6412 |
+
authoritative = {}
|
| 6413 |
+
visible_any = {}
|
| 6414 |
+
for tag, metadata in (self.citation_map or {}).items():
|
| 6415 |
+
meta = metadata or {}
|
| 6416 |
+
marker = str(meta.get('in_text') or '').strip()
|
| 6417 |
+
if not marker:
|
| 6418 |
+
continue
|
| 6419 |
+
visible_any.setdefault(marker, []).append(str(tag))
|
| 6420 |
+
if str(meta.get('ref') or '').strip():
|
| 6421 |
+
authoritative.setdefault(marker, []).append(str(tag))
|
| 6422 |
+
|
| 6423 |
+
violations = []
|
| 6424 |
+
replacements = []
|
| 6425 |
+
for marker in list(dict.fromkeys(self._resolved_parenthetical_citations(value))):
|
| 6426 |
+
tags = list(authoritative.get(marker) or [])
|
| 6427 |
+
if not tags:
|
| 6428 |
+
visible_tags = sorted(visible_any.get(marker) or [], key=self._citation_tag_number)
|
| 6429 |
+
violations.append({
|
| 6430 |
+
'type': 'citation_resolved_marker_without_authority',
|
| 6431 |
+
'citation': marker,
|
| 6432 |
+
'tag': visible_tags[0] if len(visible_tags) == 1 else '',
|
| 6433 |
+
'sentence': marker,
|
| 6434 |
+
'reason': 'A reader-facing author-year citation was emitted without authoritative reference metadata.',
|
| 6435 |
+
'repair_instruction': (
|
| 6436 |
+
'Do not emit author-year citations directly. Use only an authorized CITE tag from this manuscript unit '
|
| 6437 |
+
'when its evidence directly supports the claim, or narrow/rewrite the claim so no unsupported citation is needed.'
|
| 6438 |
+
),
|
| 6439 |
+
})
|
| 6440 |
+
continue
|
| 6441 |
+
|
| 6442 |
+
distinct_sources = {
|
| 6443 |
+
self._citation_source_identity(self.citation_map.get(tag) or {})
|
| 6444 |
+
for tag in tags
|
| 6445 |
+
}
|
| 6446 |
+
if len(distinct_sources) != 1:
|
| 6447 |
+
violations.append({
|
| 6448 |
+
'type': 'citation_resolved_marker_ambiguous',
|
| 6449 |
+
'citation': marker,
|
| 6450 |
+
'sentence': marker,
|
| 6451 |
+
'reason': 'The reader-facing author-year marker maps to multiple distinct authoritative sources.',
|
| 6452 |
+
'repair_instruction': (
|
| 6453 |
+
'Do not guess which source was intended. Rewrite this claim using exactly one authorized CITE tag '
|
| 6454 |
+
'from the frozen evidence plan, or remove/narrow the unsupported citation-dependent claim.'
|
| 6455 |
+
),
|
| 6456 |
+
})
|
| 6457 |
+
continue
|
| 6458 |
+
|
| 6459 |
+
eligible = tags if allowed is None else [tag for tag in tags if tag in allowed]
|
| 6460 |
+
if not eligible:
|
| 6461 |
+
violations.append({
|
| 6462 |
+
'type': 'citation_resolved_marker_outside_frozen_section_whitelist',
|
| 6463 |
+
'citation': marker,
|
| 6464 |
+
'tag': sorted(tags, key=self._citation_tag_number)[0] if tags else '',
|
| 6465 |
+
'sentence': marker,
|
| 6466 |
+
'reason': 'The citation is authoritative globally but is not authorized for this manuscript unit.',
|
| 6467 |
+
'repair_instruction': (
|
| 6468 |
+
'Use only citation tags frozen for this manuscript unit. If no eligible source directly supports the claim, '
|
| 6469 |
+
'narrow or rewrite the claim without external citation support.'
|
| 6470 |
+
),
|
| 6471 |
+
})
|
| 6472 |
+
continue
|
| 6473 |
+
|
| 6474 |
+
selected = sorted(eligible, key=self._citation_tag_number)[0]
|
| 6475 |
+
replacements.append((marker, selected))
|
| 6476 |
+
self._append_citation_audit(
|
| 6477 |
+
section_name, 'normalize_exact_pre_resolved_marker_to_authoritative_tag',
|
| 6478 |
+
marker, selected, 'Exact compiled marker + authoritative reference + current-unit whitelist.'
|
| 6479 |
+
)
|
| 6480 |
+
|
| 6481 |
+
# Reject other high-confidence author-year forms rather than attempting a
|
| 6482 |
+
# lossy conversion to canonical identity. The bounded Section retry owns
|
| 6483 |
+
# prose repair and claim narrowing.
|
| 6484 |
+
for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
|
| 6485 |
+
violations.append({
|
| 6486 |
+
'type': 'citation_noncanonical_resolved_marker',
|
| 6487 |
+
'citation': citation,
|
| 6488 |
+
'sentence': citation,
|
| 6489 |
+
'reason': 'A direct author-year citation form bypasses the frozen CITE-tag authority path.',
|
| 6490 |
+
'repair_instruction': (
|
| 6491 |
+
'Do not emit narrative or bracketed author-year citations directly. Use one authorized CITE tag '
|
| 6492 |
+
'from this manuscript unit when directly supported, or rewrite the claim without that citation.'
|
| 6493 |
+
),
|
| 6494 |
+
})
|
| 6495 |
+
|
| 6496 |
+
for marker, tag in replacements:
|
| 6497 |
+
value = value.replace(marker, tag)
|
| 6498 |
+
return value, violations
|
| 6499 |
+
def _quarantined_unit_citation_violations(self, text):
|
| 6500 |
+
"""Return typed citations found in a manuscript unit that forbids literature.
|
| 6501 |
+
|
| 6502 |
+
Conclusion/Appendix-style units are explicitly writer-quarantined from
|
| 6503 |
+
external literature. P3-A4 therefore treats any high-confidence raw or
|
| 6504 |
+
reader-facing citation form as a retry-owned scope violation instead of
|
| 6505 |
+
silently deleting the marker and leaving the surrounding claim intact.
|
| 6506 |
+
Bare years and ordinary numeric/model-version expressions are excluded by
|
| 6507 |
+
the same conservative scanners used by the normal citation path.
|
| 6508 |
+
"""
|
| 6509 |
+
value = str(text or '')
|
| 6510 |
+
found = []
|
| 6511 |
+
seen = set()
|
| 6512 |
+
|
| 6513 |
+
for raw in re.findall(r'\[?CITE-\d+\]?', value, flags=re.I):
|
| 6514 |
+
token = raw.upper()
|
| 6515 |
+
key = ('raw', token)
|
| 6516 |
+
if key not in seen:
|
| 6517 |
+
seen.add(key)
|
| 6518 |
+
found.append(token)
|
| 6519 |
+
|
| 6520 |
+
for marker in self._resolved_parenthetical_citations(value):
|
| 6521 |
+
key = ('resolved', marker)
|
| 6522 |
+
if key not in seen:
|
| 6523 |
+
seen.add(key)
|
| 6524 |
+
found.append(marker)
|
| 6525 |
+
|
| 6526 |
+
for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
|
| 6527 |
+
key = ('noncanonical', citation)
|
| 6528 |
+
if key not in seen:
|
| 6529 |
+
seen.add(key)
|
| 6530 |
+
found.append(citation)
|
| 6531 |
+
|
| 6532 |
+
return [{
|
| 6533 |
+
'type': 'citation_forbidden_in_quarantined_unit',
|
| 6534 |
+
'citation': citation,
|
| 6535 |
+
'sentence': citation,
|
| 6536 |
+
'reason': 'This manuscript unit forbids external literature citations, but a citation form was emitted.',
|
| 6537 |
+
'repair_instruction': (
|
| 6538 |
+
'Rewrite this unit without external literature citations. Preserve only documented study evidence, '
|
| 6539 |
+
'study-owned interpretation, and the section role; do not replace the citation with another source.'
|
| 6540 |
+
),
|
| 6541 |
+
} for citation in found]
|
| 6542 |
def audit_and_resolve_citations(
|
| 6543 |
self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
|
| 6544 |
):
|
|
|
|
| 6548 |
)
|
| 6549 |
value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
|
| 6550 |
if quarantine:
|
| 6551 |
+
quarantine_rows = self._quarantined_unit_citation_violations(value)
|
| 6552 |
+
if ownership_rows or quarantine_rows:
|
| 6553 |
+
# Do not silently delete a citation-dependent claim. The owning
|
| 6554 |
+
# Section retry/adaptive-repair path receives a typed violation
|
| 6555 |
+
# and must rewrite the unit without external literature.
|
| 6556 |
+
self._last_citation_semantic_violations = (
|
| 6557 |
+
list(ownership_rows) + list(quarantine_rows)
|
| 6558 |
+
)
|
| 6559 |
+
for row in quarantine_rows:
|
| 6560 |
+
self._append_citation_audit(
|
| 6561 |
+
section_name, 'reject_citation_in_quarantined_unit',
|
| 6562 |
+
str(row.get('citation') or ''), '', str(row.get('reason') or '')
|
| 6563 |
+
)
|
| 6564 |
+
return value
|
| 6565 |
+
return value
|
| 6566 |
+
|
| 6567 |
+
# P3-A4: collect deterministic citation-authority violations separately.
|
| 6568 |
+
# semantic_audit_claim_specific_citations() owns its own last-violation
|
| 6569 |
+
# buffer, so these rows are merged back only after that audit completes.
|
| 6570 |
+
authority_rows = []
|
| 6571 |
unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
|
| 6572 |
if unknown:
|
| 6573 |
for tag in unknown:
|
| 6574 |
+
authority_rows.append({
|
| 6575 |
+
'type': 'citation_unknown_tag',
|
| 6576 |
+
'tag': tag,
|
| 6577 |
+
'sentence': tag,
|
| 6578 |
+
'reason': 'The manuscript unit emitted a citation capability that does not exist in authoritative citation_map.',
|
| 6579 |
+
'repair_instruction': (
|
| 6580 |
+
'Use only citation tags frozen for this manuscript unit, or narrow/rewrite the claim so no unsupported citation is needed.'
|
| 6581 |
+
),
|
| 6582 |
+
})
|
| 6583 |
value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
|
| 6584 |
+
self._append_citation_audit(section_name, 'reject_unknown_tags_for_section_retry', text, value, ','.join(unknown))
|
| 6585 |
+
|
| 6586 |
allowed = None if allowed_tags is None else {
|
| 6587 |
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 6588 |
}
|
|
|
|
| 6602 |
self._append_citation_audit(
|
| 6603 |
section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
|
| 6604 |
)
|
| 6605 |
+
|
| 6606 |
+
# P3-A4 closes the resolved-marker bypass before section sealing. Exact,
|
| 6607 |
+
# unambiguous, current-unit-authorized markers are normalized back to raw
|
| 6608 |
+
# capabilities so the existing claim-specific semantic audit still owns
|
| 6609 |
+
# claim/evidence adjudication. All other forms become typed violations.
|
| 6610 |
+
value, resolved_authority_rows = self._normalize_pre_resolved_citation_authority(
|
| 6611 |
+
value, allowed_tags=allowed, section_name=section_name
|
| 6612 |
+
)
|
| 6613 |
+
authority_rows.extend(resolved_authority_rows)
|
| 6614 |
+
|
| 6615 |
has_citations = bool(re.search(r'CITE-\d+', value, re.I))
|
| 6616 |
violations = self.find_multi_citation_violations(value)
|
| 6617 |
if has_citations and self.citation_semantic_audit.get('enabled', True):
|
|
|
|
| 6642 |
value = self._local_citation_recovery(value, section_name, retain_lexical=False)
|
| 6643 |
elif violations:
|
| 6644 |
value = self._local_citation_recovery(value, section_name)
|
| 6645 |
+
|
| 6646 |
+
pre_semantic_rows = list(ownership_rows) + list(outside_rows) + list(authority_rows)
|
| 6647 |
+
if pre_semantic_rows:
|
| 6648 |
self._last_citation_semantic_violations = (
|
| 6649 |
+
pre_semantic_rows + list(self._last_citation_semantic_violations or [])
|
|
|
|
|
|
|
| 6650 |
)
|
| 6651 |
+
|
| 6652 |
+
# P3-A4 reliability rule: once deterministic identity/scope authority has
|
| 6653 |
+
# already rejected this attempt, return it to the owning bounded Section
|
| 6654 |
+
# retry instead of letting the legacy multi-source hard exception preempt
|
| 6655 |
+
# typed recovery. The rejected bytes can never be sealed because runner
|
| 6656 |
+
# treats these rows as a hard pre-seal invariant.
|
| 6657 |
+
if outside_rows or authority_rows:
|
| 6658 |
+
return value
|
| 6659 |
+
|
| 6660 |
if self.find_multi_citation_violations(value):
|
| 6661 |
value = self._local_citation_recovery(value, section_name)
|
| 6662 |
if self.find_multi_citation_violations(value):
|