Spaces:
Running
Running
R3.2.8.6-R1.3B Context-Aware AI-Writing Trace Guard
#30
by GuXiaopeng - opened
- config.py +1 -2
- core/pipeline_agent.py +69 -9
- core/production_guard.py +300 -2
config.py
CHANGED
|
@@ -161,8 +161,7 @@ def load_config():
|
|
| 161 |
"pivotal", "foster", "seamlessly", "multifaceted",
|
| 162 |
"beacon", "landscape", "underscore", "robust",
|
| 163 |
"firstly", "secondly", "moreover", "furthermore",
|
| 164 |
-
"in conclusion", "to summarize",
|
| 165 |
-
"ai-generated", "this response",
|
| 166 |
],
|
| 167 |
},
|
| 168 |
"openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
|
|
|
|
| 161 |
"pivotal", "foster", "seamlessly", "multifaceted",
|
| 162 |
"beacon", "landscape", "underscore", "robust",
|
| 163 |
"firstly", "secondly", "moreover", "furthermore",
|
| 164 |
+
"in conclusion", "to summarize",
|
|
|
|
| 165 |
],
|
| 166 |
},
|
| 167 |
"openalex_api_url": os.environ.get("OPENALEX_API_URL", "https://api.openalex.org/works"),
|
core/pipeline_agent.py
CHANGED
|
@@ -26,6 +26,7 @@ except ImportError:
|
|
| 26 |
LANGDETECT_AVAILABLE = False
|
| 27 |
|
| 28 |
from config import APP_CONFIG
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
class DeepSeekAPIError(RuntimeError):
|
|
@@ -3182,16 +3183,75 @@ Return exactly:
|
|
| 3182 |
|
| 3183 |
@staticmethod
|
| 3184 |
def _remove_ai_meta_traces(text):
|
| 3185 |
-
|
| 3186 |
-
|
| 3187 |
-
|
| 3188 |
-
|
| 3189 |
-
|
| 3190 |
-
|
|
|
|
|
|
|
| 3191 |
value = str(text or '')
|
| 3192 |
-
|
| 3193 |
-
|
| 3194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3195 |
|
| 3196 |
def polish_academic_section(self, text, section_name='', abstract=False, mode='amj'):
|
| 3197 |
value = self._remove_ai_meta_traces(text)
|
|
|
|
| 26 |
LANGDETECT_AVAILABLE = False
|
| 27 |
|
| 28 |
from config import APP_CONFIG
|
| 29 |
+
from core.production_guard import find_authorial_ai_meta_spans
|
| 30 |
|
| 31 |
|
| 32 |
class DeepSeekAPIError(RuntimeError):
|
|
|
|
| 3183 |
|
| 3184 |
@staticmethod
|
| 3185 |
def _remove_ai_meta_traces(text):
|
| 3186 |
+
"""Bounded deterministic repair for high-confidence authorial meta only.
|
| 3187 |
+
|
| 3188 |
+
R1.3B deliberately does not treat AI-related words as traces. Protected
|
| 3189 |
+
quotations/references are excluded by the shared classifier, and a
|
| 3190 |
+
finding removes only its affected sentence/clause rather than a whole
|
| 3191 |
+
Markdown line/paragraph. Headings are never rewritten here; unresolved
|
| 3192 |
+
heading-level traces are left for the final validating guard.
|
| 3193 |
+
"""
|
| 3194 |
value = str(text or '')
|
| 3195 |
+
findings = find_authorial_ai_meta_spans(value)
|
| 3196 |
+
if not findings:
|
| 3197 |
+
return value.strip()
|
| 3198 |
+
|
| 3199 |
+
spans = []
|
| 3200 |
+
sentence_breaks = '.!?。!?;;\n'
|
| 3201 |
+
for finding in findings:
|
| 3202 |
+
start = int(finding.get('start', 0))
|
| 3203 |
+
end = int(finding.get('end', start))
|
| 3204 |
+
|
| 3205 |
+
left_positions = [value.rfind(mark, 0, start) for mark in sentence_breaks]
|
| 3206 |
+
left_boundary = max(left_positions)
|
| 3207 |
+
left = left_boundary + 1 if left_boundary >= 0 else 0
|
| 3208 |
+
|
| 3209 |
+
right_candidates = [value.find(mark, end) for mark in sentence_breaks]
|
| 3210 |
+
right_candidates = [pos for pos in right_candidates if pos >= 0]
|
| 3211 |
+
if right_candidates:
|
| 3212 |
+
right_mark = min(right_candidates)
|
| 3213 |
+
right = right_mark + 1
|
| 3214 |
+
else:
|
| 3215 |
+
right = len(value)
|
| 3216 |
+
|
| 3217 |
+
# Never damage a Markdown heading or a mixed sentence carrying
|
| 3218 |
+
# protected academic objects. Unsafe-to-repair traces remain in
|
| 3219 |
+
# place so the final validating guard can fail closed rather than
|
| 3220 |
+
# silently deleting hypotheses, numbers, citations, or quotations.
|
| 3221 |
+
line_start = value.rfind('\n', 0, start) + 1
|
| 3222 |
+
candidate = value[left:right]
|
| 3223 |
+
if value[line_start:start].lstrip().startswith('#'):
|
| 3224 |
+
continue
|
| 3225 |
+
if (
|
| 3226 |
+
re.search(r'\bH\d+[A-Za-z]?\b', candidate)
|
| 3227 |
+
or re.search(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?', candidate)
|
| 3228 |
+
or re.search(r'\[\[SP-(?:P|C|T)', candidate, flags=re.I)
|
| 3229 |
+
or re.search(r'\[?CITE-\d+', candidate, flags=re.I)
|
| 3230 |
+
or re.search(r'["“”«»「」『』]', candidate)
|
| 3231 |
+
):
|
| 3232 |
+
continue
|
| 3233 |
+
spans.append((left, right))
|
| 3234 |
+
|
| 3235 |
+
if not spans:
|
| 3236 |
+
return value.strip()
|
| 3237 |
+
|
| 3238 |
+
merged = []
|
| 3239 |
+
for start, end in sorted(spans):
|
| 3240 |
+
if merged and start <= merged[-1][1]:
|
| 3241 |
+
merged[-1][1] = max(merged[-1][1], end)
|
| 3242 |
+
else:
|
| 3243 |
+
merged.append([start, end])
|
| 3244 |
+
|
| 3245 |
+
pieces = []
|
| 3246 |
+
cursor = 0
|
| 3247 |
+
for start, end in merged:
|
| 3248 |
+
pieces.append(value[cursor:start])
|
| 3249 |
+
cursor = end
|
| 3250 |
+
pieces.append(value[cursor:])
|
| 3251 |
+
repaired = ''.join(pieces)
|
| 3252 |
+
repaired = re.sub(r'[ \t]{2,}', ' ', repaired)
|
| 3253 |
+
repaired = re.sub(r'\n{3,}', '\n\n', repaired)
|
| 3254 |
+
return repaired.strip()
|
| 3255 |
|
| 3256 |
def polish_academic_section(self, text, section_name='', abstract=False, mode='amj'):
|
| 3257 |
value = self._remove_ai_meta_traces(text)
|
core/production_guard.py
CHANGED
|
@@ -54,13 +54,311 @@ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
|
| 58 |
value = str(text or "").strip()
|
| 59 |
if len(value) < 1000:
|
| 60 |
raise OutputValidationError(f"{language_name} manuscript is incomplete.")
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.")
|
| 63 |
-
if
|
|
|
|
|
|
|
| 64 |
raise OutputValidationError(f"{language_name} manuscript contains AI-writing meta-commentary.")
|
| 65 |
if not re.search(r"(?m)^#\s+", value):
|
| 66 |
raise OutputValidationError(f"{language_name} manuscript has no title/section headings.")
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
| 57 |
+
|
| 58 |
+
_REFERENCE_HEADING_RE = re.compile(
|
| 59 |
+
r"(?im)^#\s+(?:References|参考文献|Литература|Пайдаланылған әдебиеттер)\s*$"
|
| 60 |
+
)
|
| 61 |
+
_HARD_SP_MARKER_RE = re.compile(
|
| 62 |
+
r"\[\[SP-(?:P|C|T)(?:\d{6}|x{6})\]\]",
|
| 63 |
+
flags=re.IGNORECASE,
|
| 64 |
+
)
|
| 65 |
+
_UNRESOLVED_CITE_RE = re.compile(
|
| 66 |
+
r"(?<![A-Za-z0-9_])\[?CITE-\d+\]?(?![A-Za-z0-9_])",
|
| 67 |
+
flags=re.IGNORECASE,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# R1.3B uses one semantic taxonomy with bounded language profiles. These
|
| 71 |
+
# patterns intentionally require relations (role + first person, capability
|
| 72 |
+
# disclaimer, current artifact + AI actor + writing action, or current-response
|
| 73 |
+
# frame + first-person answer action). AI-domain vocabulary alone is never a
|
| 74 |
+
# finding.
|
| 75 |
+
_AUTHORIAL_META_PATTERNS = (
|
| 76 |
+
# English
|
| 77 |
+
("en", "assistant_self_identity", re.compile(
|
| 78 |
+
r"\bas an\s+(?:ai|artificial intelligence)(?:\s+language)?"
|
| 79 |
+
r"(?:\s+(?:model|assistant))?\s*,?\s*"
|
| 80 |
+
r"(?:i|we)\s+(?:am|are|can(?:not|'t)?|will|would|shall|"
|
| 81 |
+
r"do|don't|have|lack|provide|help|summarize|write|draft|generate)\b",
|
| 82 |
+
re.IGNORECASE,
|
| 83 |
+
)),
|
| 84 |
+
("en", "capability_disclaimer", re.compile(
|
| 85 |
+
r"\bi\s+(?:cannot|can't|am\s+unable\s+to)\s+"
|
| 86 |
+
r"(?:browse|access|verify|open|retrieve|check|confirm)\b",
|
| 87 |
+
re.IGNORECASE,
|
| 88 |
+
)),
|
| 89 |
+
("en", "current_artifact_ai_writing", re.compile(
|
| 90 |
+
r"\b(?:this|the\s+current|the)\s+(?:manuscript|paper|article|section)\s+"
|
| 91 |
+
r"(?:was|is|has\s+been)\s+"
|
| 92 |
+
r"(?:generated|written|drafted|authored|composed|prepared)\s+"
|
| 93 |
+
r"(?:by|using)\s+"
|
| 94 |
+
r"(?:an?\s+)?(?:ai(?:\s+(?:assistant|model))?|artificial intelligence|"
|
| 95 |
+
r"generative ai|llm|language model|chatgpt)\b",
|
| 96 |
+
re.IGNORECASE,
|
| 97 |
+
)),
|
| 98 |
+
("en", "current_artifact_ai_writing", re.compile(
|
| 99 |
+
r"\b(?:ai(?:\s+(?:assistant|model))?|artificial intelligence|"
|
| 100 |
+
r"generative ai|llm|language model|chatgpt)\s+"
|
| 101 |
+
r"(?:was|is|has\s+been)\s+used\s+to\s+"
|
| 102 |
+
r"(?:draft|write|generate|author|compose|prepare)\s+"
|
| 103 |
+
r"(?:this|the\s+current|the)\s+(?:manuscript|paper|article|section)\b",
|
| 104 |
+
re.IGNORECASE,
|
| 105 |
+
)),
|
| 106 |
+
("en", "current_response_frame", re.compile(
|
| 107 |
+
r"\b(?:in\s+(?:this|the\s+current)\s+(?:response|answer)|below)\s*,?\s*"
|
| 108 |
+
r"(?:i|we)\s+(?:(?:will|can|shall|would)\s+)?"
|
| 109 |
+
r"(?:summarize|provide|present|discuss|explain|outline|draft|write|answer)\b",
|
| 110 |
+
re.IGNORECASE,
|
| 111 |
+
)),
|
| 112 |
+
# Chinese
|
| 113 |
+
("zh", "assistant_self_identity", re.compile(
|
| 114 |
+
r"作为(?:人工智能|AI)(?:语言)?(?:模型|助手)[,,]?\s*"
|
| 115 |
+
r"我(?:无法|不能|可以|能|将|会|能够|可)"
|
| 116 |
+
)),
|
| 117 |
+
("zh", "capability_disclaimer", re.compile(
|
| 118 |
+
r"我(?:无法|不能)(?:浏览|访问|核实|验证|查证|获取|打开)"
|
| 119 |
+
)),
|
| 120 |
+
("zh", "current_artifact_ai_writing", re.compile(
|
| 121 |
+
r"(?:本文|本论文|本稿|本手稿|本节|本章节|这篇论文|这篇文章|这个章节|这一节)"
|
| 122 |
+
r"\s*(?:由|是由)\s*(?:ChatGPT|人工智能|AI|大语言模型|语言模型|AI助手|人工智能助手)"
|
| 123 |
+
r".{0,20}?(?:生成|撰写|写成|起草|编写|创作|准备)"
|
| 124 |
+
)),
|
| 125 |
+
("zh", "current_artifact_ai_writing", re.compile(
|
| 126 |
+
r"(?:ChatGPT|人工智能|AI|大语言模型|语言模型|AI助手|人工智能助手)"
|
| 127 |
+
r"\s*(?:被)?用于(?:生成|撰写|写作|起草|编写|准备)"
|
| 128 |
+
r"\s*(?:本文|本论文|本稿|本手稿|本节|本章节|这篇论文|这篇文章)"
|
| 129 |
+
)),
|
| 130 |
+
("zh", "current_response_frame", re.compile(
|
| 131 |
+
r"(?:在)?(?:本回答|此回答|本回复|此回复)中?[,,]?\s*"
|
| 132 |
+
r"我(?:将|会|可以)?(?:总结|概述|提供|说明|解释|介绍|撰写|回答)"
|
| 133 |
+
)),
|
| 134 |
+
# Russian
|
| 135 |
+
("ru", "assistant_self_identity", re.compile(
|
| 136 |
+
r"Как\s+(?:модель\s+искусственного\s+интеллекта|"
|
| 137 |
+
r"ИИ[- ]?(?:модель|ассистент)|языковая\s+модель|"
|
| 138 |
+
r"AI[- ]?(?:модель|ассистент))\s*,?\s*"
|
| 139 |
+
r"я\s+(?:не\s+могу|могу|буду|готов|способен|не\s+способен)",
|
| 140 |
+
re.IGNORECASE,
|
| 141 |
+
)),
|
| 142 |
+
("ru", "capability_disclaimer", re.compile(
|
| 143 |
+
r"\bя\s+(?:не\s+могу|не\s+способен|не\s+имею\s+возможности)\s+"
|
| 144 |
+
r"(?:просмотр\w*|получить\s+доступ|провер\w*|подтверд\w*|откры\w*)",
|
| 145 |
+
re.IGNORECASE,
|
| 146 |
+
)),
|
| 147 |
+
("ru", "current_artifact_ai_writing", re.compile(
|
| 148 |
+
r"\b(?:(?:эта|данная)\s+(?:рукопись|статья)|(?:этот|данный)\s+раздел)"
|
| 149 |
+
r"\s+(?:была|был|было|является|был\s+бы)?\s*"
|
| 150 |
+
r"(?:сгенерирован\w*|написан\w*|подготовлен\w*|создан\w*)"
|
| 151 |
+
r".{0,40}?(?:ChatGPT|ИИ|искусственн\w+\s+интеллект\w+|"
|
| 152 |
+
r"языков\w+\s+модел\w+|ИИ[- ]?(?:ассистент|модель))\b",
|
| 153 |
+
re.IGNORECASE,
|
| 154 |
+
)),
|
| 155 |
+
("ru", "current_response_frame", re.compile(
|
| 156 |
+
r"\bВ\s+(?:этом|данном)\s+(?:ответе|отклике)\s+я\s+"
|
| 157 |
+
r"(?:(?:буду|могу)\s+)?"
|
| 158 |
+
r"(?:резюмир\w*|представ\w*|объясн\w*|опиш\w*|напиш\w*)",
|
| 159 |
+
re.IGNORECASE,
|
| 160 |
+
)),
|
| 161 |
+
# Kazakh
|
| 162 |
+
("kk", "assistant_self_identity", re.compile(
|
| 163 |
+
r"(?:Жасанды\s+интеллект(?:\s+тіл)?\s+моделі|ЖИ[- ]?(?:моделі|көмекшісі))"
|
| 164 |
+
r"\s+ретінде\s*,?\s*мен\b.{0,120}?"
|
| 165 |
+
r"(?:алмаймын|аламын|боламын|көмектесе\s+аламын|ұсына\s+аламын)",
|
| 166 |
+
re.IGNORECASE,
|
| 167 |
+
)),
|
| 168 |
+
("kk", "capability_disclaimer", re.compile(
|
| 169 |
+
r"\bмен\b.{0,80}?(?:тексере|қарай|қол\s+жеткізе|растай|аша)"
|
| 170 |
+
r".{0,30}?\b(?:алмаймын)\b",
|
| 171 |
+
re.IGNORECASE,
|
| 172 |
+
)),
|
| 173 |
+
("kk", "current_artifact_ai_writing", re.compile(
|
| 174 |
+
r"\b(?:(?:Бұл|Осы)\s+(?:қолжазба|мақала)|(?:Бұл|Осы)\s+бөлім)"
|
| 175 |
+
r".{0,40}?(?:ChatGPT|ЖИ|жасанды\s+интеллект|тілдік\s+модель)"
|
| 176 |
+
r".{0,40}?(?:арқылы\s+)?(?:жасал\w*|жазыл\w*|дайындал\w*|құрастырыл\w*)",
|
| 177 |
+
re.IGNORECASE,
|
| 178 |
+
)),
|
| 179 |
+
("kk", "current_response_frame", re.compile(
|
| 180 |
+
r"\b(?:Бұл|Осы)\s+жауапта\s+мен\b.{0,40}?"
|
| 181 |
+
r"(?:қорытындылаймын|ұсынамын|түсіндіремін|сипаттаймын|жазамын)",
|
| 182 |
+
re.IGNORECASE,
|
| 183 |
+
)),
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
| 188 |
+
merged: list[list[int]] = []
|
| 189 |
+
for start, end in sorted((max(0, int(a)), max(0, int(b))) for a, b in ranges if int(b) > int(a)):
|
| 190 |
+
if merged and start <= merged[-1][1]:
|
| 191 |
+
merged[-1][1] = max(merged[-1][1], end)
|
| 192 |
+
else:
|
| 193 |
+
merged.append([start, end])
|
| 194 |
+
return [(start, end) for start, end in merged]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _protected_natural_language_ranges(value: str) -> list[tuple[int, int]]:
|
| 198 |
+
"""Ranges exempt from natural-language authorship detection, never from hard markers."""
|
| 199 |
+
ranges: list[tuple[int, int]] = []
|
| 200 |
+
reference_match = _REFERENCE_HEADING_RE.search(value)
|
| 201 |
+
if reference_match:
|
| 202 |
+
ranges.append((reference_match.start(), len(value)))
|
| 203 |
+
|
| 204 |
+
# Markdown blockquotes are explicit evidence/transcript zones.
|
| 205 |
+
ranges.extend((match.start(), match.end()) for match in re.finditer(r"(?m)^[ \t]*>.*(?:\n|$)", value))
|
| 206 |
+
|
| 207 |
+
# Fenced excerpts are treated as explicitly delimited evidence/prompt/stimulus
|
| 208 |
+
# spans for natural-language meta only. Hard SmartPaper markers still fail globally.
|
| 209 |
+
for fence in ("```", "~~~"):
|
| 210 |
+
fence_re = re.compile(
|
| 211 |
+
rf"(?ms)^[ \t]*{re.escape(fence)}[^\n]*\n.*?^[ \t]*{re.escape(fence)}[ \t]*(?:\n|$)"
|
| 212 |
+
)
|
| 213 |
+
ranges.extend((match.start(), match.end()) for match in fence_re.finditer(value))
|
| 214 |
+
|
| 215 |
+
# Explicit prompt/stimulus/transcript delimiters are protected evidence
|
| 216 |
+
# zones. Labels alone are insufficient; both opening and closing delimiters
|
| 217 |
+
# are required so ordinary authorial prose cannot opt itself out.
|
| 218 |
+
delimited_patterns = (
|
| 219 |
+
r"(?is)<(?:prompt|stimulus|transcript)>.*?</(?:prompt|stimulus|transcript)>",
|
| 220 |
+
r"(?is)\[(?:PROMPT|STIMULUS|TRANSCRIPT)\].*?\[/(?:PROMPT|STIMULUS|TRANSCRIPT)\]",
|
| 221 |
+
r"(?ims)^BEGIN\s+(?:PROMPT|STIMULUS|TRANSCRIPT)\s*$.*?^END\s+(?:PROMPT|STIMULUS|TRANSCRIPT)\s*$",
|
| 222 |
+
)
|
| 223 |
+
for pattern in delimited_patterns:
|
| 224 |
+
ranges.extend((match.start(), match.end()) for match in re.finditer(pattern, value))
|
| 225 |
+
|
| 226 |
+
# Well-formed quoted spans used for chatbot transcripts, participant evidence,
|
| 227 |
+
# paper titles, and experimental stimuli.
|
| 228 |
+
quote_patterns = (
|
| 229 |
+
r'"(?:\\.|[^"\\])*"',
|
| 230 |
+
r"“[^”]*”",
|
| 231 |
+
r"«[^»]*»",
|
| 232 |
+
r"「[^」]*」",
|
| 233 |
+
r"『[^』]*』",
|
| 234 |
+
)
|
| 235 |
+
for pattern in quote_patterns:
|
| 236 |
+
ranges.extend((match.start(), match.end()) for match in re.finditer(pattern, value, flags=re.DOTALL))
|
| 237 |
+
return _merge_ranges(ranges)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _mask_ranges(value: str, ranges: list[tuple[int, int]]) -> str:
|
| 241 |
+
"""Mask protected text while retaining offsets/newlines for deterministic matching."""
|
| 242 |
+
if not ranges:
|
| 243 |
+
return value
|
| 244 |
+
chunks: list[str] = []
|
| 245 |
+
cursor = 0
|
| 246 |
+
for start, end in _merge_ranges(ranges):
|
| 247 |
+
if start > cursor:
|
| 248 |
+
chunks.append(value[cursor:start])
|
| 249 |
+
segment = value[start:end]
|
| 250 |
+
chunks.append("".join("\n" if char == "\n" else " " for char in segment))
|
| 251 |
+
cursor = end
|
| 252 |
+
chunks.append(value[cursor:])
|
| 253 |
+
return "".join(chunks)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def find_authorial_ai_meta_spans(text: str) -> tuple[dict[str, Any], ...]:
|
| 257 |
+
"""Return high-confidence natural-language authorial/process relations only."""
|
| 258 |
+
value = str(text or "")
|
| 259 |
+
masked = _mask_ranges(value, _protected_natural_language_ranges(value))
|
| 260 |
+
findings: list[dict[str, Any]] = []
|
| 261 |
+
seen: set[tuple[int, int, str]] = set()
|
| 262 |
+
for language, relation, pattern in _AUTHORIAL_META_PATTERNS:
|
| 263 |
+
for match in pattern.finditer(masked):
|
| 264 |
+
key = (match.start(), match.end(), relation)
|
| 265 |
+
if key in seen:
|
| 266 |
+
continue
|
| 267 |
+
seen.add(key)
|
| 268 |
+
findings.append(
|
| 269 |
+
{
|
| 270 |
+
"kind": "meta",
|
| 271 |
+
"language": language,
|
| 272 |
+
"relation": relation,
|
| 273 |
+
"start": match.start(),
|
| 274 |
+
"end": match.end(),
|
| 275 |
+
"text": value[match.start():match.end()],
|
| 276 |
+
}
|
| 277 |
+
)
|
| 278 |
+
findings.sort(key=lambda item: (int(item["start"]), int(item["end"]), str(item["relation"])))
|
| 279 |
+
return tuple(findings)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def find_ai_writing_trace_findings(text: str) -> tuple[dict[str, Any], ...]:
|
| 283 |
+
"""Classify fatal SmartPaper leaks and high-confidence authorial AI meta traces."""
|
| 284 |
+
value = str(text or "")
|
| 285 |
+
findings: list[dict[str, Any]] = []
|
| 286 |
+
|
| 287 |
+
# Hard SmartPaper markers and unresolved internal citation tags are global,
|
| 288 |
+
# including References and quotations.
|
| 289 |
+
for match in _HARD_SP_MARKER_RE.finditer(value):
|
| 290 |
+
findings.append(
|
| 291 |
+
{
|
| 292 |
+
"kind": "prompt_leak",
|
| 293 |
+
"relation": "smartpaper_marker",
|
| 294 |
+
"start": match.start(),
|
| 295 |
+
"end": match.end(),
|
| 296 |
+
"text": match.group(0),
|
| 297 |
+
}
|
| 298 |
+
)
|
| 299 |
+
for match in _UNRESOLVED_CITE_RE.finditer(value):
|
| 300 |
+
findings.append(
|
| 301 |
+
{
|
| 302 |
+
"kind": "prompt_leak",
|
| 303 |
+
"relation": "unresolved_citation",
|
| 304 |
+
"start": match.start(),
|
| 305 |
+
"end": match.end(),
|
| 306 |
+
"text": match.group(0),
|
| 307 |
+
}
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
protected = _protected_natural_language_ranges(value)
|
| 311 |
+
masked = _mask_ranges(value, protected)
|
| 312 |
+
|
| 313 |
+
# Soft internal prompt language is only fatal when its structured conjunction
|
| 314 |
+
# occurs together in an unprotected authorial block.
|
| 315 |
+
for block_match in re.finditer(r"(?ms)(?:^|\n\s*\n)(.*?)(?=\n\s*\n|\Z)", masked):
|
| 316 |
+
block = block_match.group(1)
|
| 317 |
+
if (
|
| 318 |
+
re.search(r"(?im)^\s*JOURNAL\s+VOICE\s*:", block)
|
| 319 |
+
and re.search(r"(?im)^\s*SECTION\s*:", block)
|
| 320 |
+
and re.search(r"(?im)^\s*TEXT\s*:", block)
|
| 321 |
+
):
|
| 322 |
+
findings.append(
|
| 323 |
+
{
|
| 324 |
+
"kind": "prompt_leak",
|
| 325 |
+
"relation": "internal_prompt_envelope",
|
| 326 |
+
"start": block_match.start(1),
|
| 327 |
+
"end": block_match.end(1),
|
| 328 |
+
"text": value[block_match.start(1):block_match.end(1)],
|
| 329 |
+
}
|
| 330 |
+
)
|
| 331 |
+
for match in re.finditer(r"(?im)^\s*\[SELF-REFLECTION\]\s*:", masked):
|
| 332 |
+
findings.append(
|
| 333 |
+
{
|
| 334 |
+
"kind": "prompt_leak",
|
| 335 |
+
"relation": "internal_control_heading",
|
| 336 |
+
"start": match.start(),
|
| 337 |
+
"end": match.end(),
|
| 338 |
+
"text": value[match.start():match.end()],
|
| 339 |
+
}
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
findings.extend(find_authorial_ai_meta_spans(value))
|
| 343 |
+
findings.sort(key=lambda item: (int(item["start"]), int(item["end"]), str(item.get("kind", ""))))
|
| 344 |
+
return tuple(findings)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
|
| 348 |
value = str(text or "").strip()
|
| 349 |
if len(value) < 1000:
|
| 350 |
raise OutputValidationError(f"{language_name} manuscript is incomplete.")
|
| 351 |
+
|
| 352 |
+
findings = find_ai_writing_trace_findings(value)
|
| 353 |
+
unresolved = next(
|
| 354 |
+
(item for item in findings if item.get("relation") == "unresolved_citation"),
|
| 355 |
+
None,
|
| 356 |
+
)
|
| 357 |
+
if unresolved is not None:
|
| 358 |
raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.")
|
| 359 |
+
if any(item.get("kind") == "prompt_leak" for item in findings):
|
| 360 |
+
raise OutputValidationError(f"{language_name} manuscript contains internal prompt/pipeline leakage.")
|
| 361 |
+
if any(item.get("kind") == "meta" for item in findings):
|
| 362 |
raise OutputValidationError(f"{language_name} manuscript contains AI-writing meta-commentary.")
|
| 363 |
if not re.search(r"(?m)^#\s+", value):
|
| 364 |
raise OutputValidationError(f"{language_name} manuscript has no title/section headings.")
|