R3.2.8.6-R1.3B Context-Aware AI-Writing Trace Guard

#30
Files changed (3) hide show
  1. config.py +1 -2
  2. core/pipeline_agent.py +69 -9
  3. 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", "as an ai",
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
- patterns = [
3186
- r'(?im)^.*\bas an ai(?: language model)?\b.*(?:\n|$)',
3187
- r'(?im)^.*\bai[- ]generated\b.*(?:\n|$)',
3188
- r'(?im)^.*\bthis response\b.*(?:\n|$)',
3189
- r'(?im)^.*\bi cannot (?:browse|access|verify)\b.*(?:\n|$)',
3190
- ]
 
 
3191
  value = str(text or '')
3192
- for pattern in patterns:
3193
- value = re.sub(pattern, '', value)
3194
- return re.sub(r'\n{3,}', '\n\n', value).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if re.search(r"\[?CITE-\d+\]?", value, flags=re.IGNORECASE):
 
 
 
 
 
 
62
  raise OutputValidationError(f"{language_name} manuscript still contains unresolved citation tags.")
63
- if re.search(r"(?i)\bas an ai(?: language model)?\b|\bai[- ]generated\b", value):
 
 
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.")