dsh-biocore 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -167,6 +167,7 @@ export function readFullConfig(dataDir) {
167
167
  embedding: storedEmbedding(source),
168
168
  rerank: normalizeRerank(source.rerank),
169
169
  expression: normalizeExpression(source.expression),
170
+ sanitize_reply: source.sanitize_reply !== false,
170
171
  }
171
172
  }
172
173
 
@@ -176,6 +177,7 @@ function writeFullConfig(file, config) {
176
177
  embedding: config.embedding,
177
178
  rerank: config.rerank,
178
179
  expression: config.expression,
180
+ sanitize_reply: config.sanitize_reply !== false,
179
181
  }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
180
182
  try { chmodSync(file, 0o600) } catch { /* Windows may ignore POSIX modes */ }
181
183
  }
@@ -191,6 +193,7 @@ export async function handleSettings(req, res, dataDir) {
191
193
  embedding: maskedEmbedding(config.embedding),
192
194
  rerank: maskedRerank(config.rerank),
193
195
  expression: config.expression,
196
+ sanitize_reply: config.sanitize_reply !== false,
194
197
  restart_required: false,
195
198
  })
196
199
  }
@@ -213,7 +216,8 @@ export async function handleSettings(req, res, dataDir) {
213
216
  const hasEmbedding = body.embedding && typeof body.embedding === 'object'
214
217
  const hasRerank = body.rerank && typeof body.rerank === 'object'
215
218
  const hasExpression = body.expression && typeof body.expression === 'object'
216
- if (!hasEmbedding && !hasRerank && !hasExpression) {
219
+ const hasSanitize = typeof body.sanitize_reply === 'boolean'
220
+ if (!hasEmbedding && !hasRerank && !hasExpression && !hasSanitize) {
217
221
  return sendJson(res, 400, { ok: false, reason: 'no_settings' })
218
222
  }
219
223
  if (hasEmbedding) {
@@ -236,12 +240,14 @@ export async function handleSettings(req, res, dataDir) {
236
240
  config.rerank = rerank
237
241
  }
238
242
  if (hasExpression) config.expression = normalizeExpression(body.expression)
243
+ if (hasSanitize) config.sanitize_reply = body.sanitize_reply === true
239
244
  writeFullConfig(paths.file, config)
240
245
  return sendJson(res, 200, {
241
246
  ok: true,
242
247
  embedding: maskedEmbedding(config.embedding),
243
248
  rerank: maskedRerank(config.rerank),
244
249
  expression: config.expression,
250
+ sanitize_reply: config.sanitize_reply !== false,
245
251
  restart_required: true,
246
252
  hint: '重启 BioCore 生效',
247
253
  })
@@ -26,6 +26,11 @@ import { applyGateText, assistantTextOf } from './safety-gate.js'
26
26
  import { defaultStoreDir } from './trace.js'
27
27
  import { runtimeAction, runTurn } from '../py-bridge.js'
28
28
  import { EXPRESSION_DEFAULTS, readExpressionSettings } from './expression-config.js'
29
+ import {
30
+ readSanitizeReplySetting,
31
+ sanitizeReply,
32
+ sanitizeReplyEnabled,
33
+ } from './sanitize-reply.js'
29
34
 
30
35
  export const TURN_CONTEXT_NAME = 'biocore-expression-turn'
31
36
  export const TURN_CONTEXT_ORDER = -190
@@ -89,6 +94,17 @@ export function diagnosticsFromState(state, userText) {
89
94
  }
90
95
  }
91
96
 
97
+ /** Final assistant text: core clean_text wins, JS sanitizer is the fallback.
98
+ * sanitizeEnabled=false keeps the original (user disabled the switch). */
99
+ export function chooseCleanReply(original, coreCleanText, options) {
100
+ const opts = options || {}
101
+ const source = String(original == null ? '' : original)
102
+ if (opts.sanitizeEnabled === false) return source
103
+ if (typeof coreCleanText === 'string') return coreCleanText
104
+ const sanitize = typeof opts.sanitize === 'function' ? opts.sanitize : sanitizeReply
105
+ return String(sanitize(source))
106
+ }
107
+
92
108
  export function turnStartPayload(options) {
93
109
  const opts = options || {}
94
110
  const expression = opts.expression && typeof opts.expression === 'object'
@@ -118,6 +134,7 @@ export function turnEndPayload(options) {
118
134
  store: opts.store || defaultStoreDir(),
119
135
  capture: false,
120
136
  memoryEnabled: false,
137
+ sanitize_reply: opts.sanitizeReply !== false,
121
138
  diagnostics,
122
139
  }, diagnostics, {
123
140
  modules_trace: diagnostics.modules,
@@ -135,6 +152,9 @@ export function makeCoreSessionListener(options) {
135
152
  const getExpression = typeof opts.getExpression === 'function'
136
153
  ? opts.getExpression
137
154
  : () => readExpressionSettings(stateDir)
155
+ const getSanitizeReply = typeof opts.getSanitizeReply === 'function'
156
+ ? opts.getSanitizeReply
157
+ : () => readSanitizeReplySetting(stateDir)
138
158
  const store = opts.store || defaultStoreDir()
139
159
  const log = typeof opts.log === 'function' ? opts.log : (line) => {
140
160
  try { console.error(line) } catch { /* noop */ }
@@ -186,9 +206,11 @@ export function makeCoreSessionListener(options) {
186
206
  }).catch(() => null)
187
207
  return
188
208
  }
189
- // assistant/message: gate + trace via one turn_end call.
209
+ // assistant/message: sanitize -> gate + trace via one turn_end call.
190
210
  const original = assistantTextOf(event)
191
211
  if (!original) return
212
+ const sanitizeEnabled = sanitizeReplyEnabled(getSanitizeReply())
213
+ const cleaned = sanitizeEnabled ? sanitizeReply(original) : original
192
214
  const userText = lastUser.get(sessionId) || ''
193
215
  void (async () => {
194
216
  try {
@@ -198,14 +220,23 @@ export function makeCoreSessionListener(options) {
198
220
  const state = read && !read.error && !read.missing ? read.state : {}
199
221
  const payload = turnEndPayload({
200
222
  sessionId,
201
- assistantText: original,
223
+ assistantText: cleaned,
202
224
  userText,
203
225
  switches,
204
226
  store,
205
227
  state,
228
+ sanitizeReply: sanitizeEnabled,
206
229
  })
207
230
  const result = await runAction('turn_end', payload)
208
231
  if (!result || result.ok !== true) return
232
+ const reply = result.reply && typeof result.reply === 'object' ? result.reply : {}
233
+ const coreCleanText = typeof reply.clean_text === 'string'
234
+ ? reply.clean_text
235
+ : (typeof result.clean_text === 'string' ? result.clean_text : null)
236
+ const finalText = chooseCleanReply(original, coreCleanText, {
237
+ sanitizeEnabled,
238
+ sanitize: sanitizeReply,
239
+ })
209
240
  const gate = result.gate && typeof result.gate === 'object' ? result.gate : {}
210
241
  const action = stringId(gate.action || 'pass')
211
242
  if ((action === 'replace' || action === 'truncate')
@@ -216,6 +247,9 @@ export function makeCoreSessionListener(options) {
216
247
  + ' blocked=' + (gate.blocked === true ? '1' : '0')
217
248
  + ' rule=' + stringId(gate.rule || '')
218
249
  + ' chars=' + original.length + '->' + gate.text.length)
250
+ } else if (finalText !== original) {
251
+ // No gate rewrite: write the core-cleaned / JS-fallback text back.
252
+ applyGateText(event, finalText)
219
253
  }
220
254
  if (result.trace_file) {
221
255
  logLine(log, CORE_TRACE_PREFIX + ' session=' + sessionId
@@ -0,0 +1,60 @@
1
+ /** Strip BioCore output markers from assistant replies (task-223).
2
+ *
3
+ * Removes:
4
+ * - <bc>...</bc> and <biocore>...</biocore> blocks (also unclosed to EOL/EOS)
5
+ * - bare compact JSON objects that only carry e/i[/n] keys
6
+ * - extra whitespace; empty result stays empty (never falls back to input)
7
+ */
8
+ import { readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export const DEFAULT_SANITIZE_REPLY = true
12
+
13
+ const TAG_RE = /<(bc|biocore)\b[^>]*>/gi
14
+ const BARE_JSON_RE = /\{\s*"e"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*,\s*"i"\s*:\s*-?\d+(?:\.\d+)?(?:\s*,\s*"n"\s*:\s*-?\d+(?:\.\d+)?)?\s*\}/g
15
+
16
+ function stripClosedTags(text) {
17
+ return String(text)
18
+ .replace(/<bc\b[^>]*>[\s\S]*?<\/bc\s*>/gi, '')
19
+ .replace(/<biocore\b[^>]*>[\s\S]*?<\/biocore\s*>/gi, '')
20
+ }
21
+
22
+ function stripUnclosedTags(text) {
23
+ // Until line end or a Chinese/ASCII sentence terminator; the terminator stays.
24
+ return String(text)
25
+ .replace(/<bc\b[^>]*>[\s\S]*?(?:[\n。!?!?]|$)/gi, '')
26
+ .replace(/<biocore\b[^>]*>[\s\S]*?(?:[\n。!?!?]|$)/gi, '')
27
+ }
28
+
29
+ function normalizeWhitespace(text) {
30
+ return String(text)
31
+ .replace(/[ \t]{2,}/g, ' ')
32
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
33
+ .replace(/\n{3,}/g, '\n\n')
34
+ .trim()
35
+ }
36
+
37
+ /** Main entry: returns the cleaned reply (may be ''); never reverts to input. */
38
+ export function sanitizeReply(text, options) {
39
+ const opts = options || {}
40
+ if (opts.enabled === false) return String(text == null ? '' : text)
41
+ const source = String(text == null ? '' : text)
42
+ let out = stripClosedTags(source)
43
+ out = stripUnclosedTags(out)
44
+ out = out.replace(BARE_JSON_RE, '')
45
+ return normalizeWhitespace(out)
46
+ }
47
+
48
+ /** Config.json top-level flag: only an explicit false disables cleaning. */
49
+ export function readSanitizeReplySetting(dataDir) {
50
+ try {
51
+ const doc = JSON.parse(readFileSync(join(String(dataDir || '').trim(), 'config.json'), 'utf8'))
52
+ if (doc && typeof doc === 'object' && doc.sanitize_reply === false) return false
53
+ } catch { /* missing/bad config -> default on */ }
54
+ return DEFAULT_SANITIZE_REPLY
55
+ }
56
+
57
+ /** Payload helper: sanitize_reply defaults to true unless explicitly false. */
58
+ export function sanitizeReplyEnabled(value) {
59
+ return value !== false
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-biocore",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "BioCore DSH UI: per-session state tab, standalone instrument panel, /api/biocore/state, session/event one_turn updates, /bio sync.",
6
6
  "main": "lib/index.js",
@@ -96,6 +96,11 @@ try:
96
96
  except Exception: # pragma: no cover
97
97
  _rerank_mod = None
98
98
 
99
+ try:
100
+ from biocore_model import sanitize as _sanitize
101
+ except Exception: # pragma: no cover
102
+ _sanitize = None
103
+
99
104
  try: # self-layer modules (all pure; optional so a stripped checkout still runs)
100
105
  from biocore_model import x_directives as _x_directives
101
106
  from biocore_model import self_intent as _self_intent
@@ -1794,10 +1799,35 @@ def dispatch(action, payload=None):
1794
1799
  return bad("missing session_id")
1795
1800
  if "assistant_text" not in data:
1796
1801
  return bad("missing assistant_text")
1797
- assistant_text = clean_text(data.get("assistant_text"))
1802
+ raw_assistant = clean_text(data.get("assistant_text"))
1803
+ do_sanitize = _as_bool(data.get("sanitize_reply"), True)
1804
+ cleaned = raw_assistant
1805
+ sanitized = False
1806
+ removed_tags = 0
1807
+ if do_sanitize and _sanitize is not None:
1808
+ stats = getattr(_sanitize, "STATS", {}) or {}
1809
+ before = (int(stats.get("tag_stripped", 0) or 0),
1810
+ int(stats.get("json_stripped", 0) or 0),
1811
+ int(stats.get("fenced_stripped", 0) or 0))
1812
+ try:
1813
+ cleaned = _sanitize.clean(raw_assistant)
1814
+ except Exception:
1815
+ cleaned = raw_assistant
1816
+ after = (int(stats.get("tag_stripped", 0) or 0),
1817
+ int(stats.get("json_stripped", 0) or 0),
1818
+ int(stats.get("fenced_stripped", 0) or 0))
1819
+ removed_tags = max(0, sum(after) - sum(before))
1820
+ sanitized = cleaned != raw_assistant or removed_tags > 0
1821
+ assistant_text = cleaned
1798
1822
  switches = normalize_switches(data.get("switches"))
1799
1823
  gate = gate_reply(assistant_text, {
1800
1824
  "switches": switches, "user_text": data.get("user_text", "")})
1825
+ if do_sanitize and _sanitize is not None:
1826
+ try:
1827
+ gate = dict(gate)
1828
+ gate["text"] = _sanitize.clean(gate.get("text") or "")
1829
+ except Exception:
1830
+ pass
1801
1831
  captured = capture(session_id, "assistant", assistant_text, store, {
1802
1832
  "enabled": _as_bool(data.get("memoryEnabled",
1803
1833
  switches.get("memoryEnabled", True)),
@@ -1818,6 +1848,9 @@ def dispatch(action, payload=None):
1818
1848
  "captured": {"captured": bool(decision.get("capture")),
1819
1849
  "entry_id": str(entry.get("id") or ""),
1820
1850
  "reason": str(decision.get("reason") or "")},
1851
+ "clean_text": cleaned,
1852
+ "sanitized": bool(sanitized),
1853
+ "removed_tags": int(removed_tags),
1821
1854
  "trace": trace,
1822
1855
  "trace_file": trace_file,
1823
1856
  }
package/ui/panel.html CHANGED
@@ -247,6 +247,7 @@
247
247
  <dl class="advanced-info">
248
248
  <div><dt>数据目录</dt><dd id="dataDirText">宿主托管</dd></div>
249
249
  <div><dt>插件版本</dt><dd id="pluginVersion">0.1.0</dd></div>
250
+ <div><dt>出口清洗</dt><dd id="sanitizeReplyText">开</dd></div>
250
251
  </dl>
251
252
  <div class="advanced-actions">
252
253
  <button id="resetLayout" class="advanced-button" type="button">恢复默认布局</button>
package/ui/panel.js CHANGED
@@ -1050,6 +1050,7 @@
1050
1050
  fillModel(embedding)
1051
1051
  fillRerank(body.rerank)
1052
1052
  fillExpression(body.expression)
1053
+ setText('sanitizeReplyText', body.sanitize_reply === false ? '关' : '开')
1053
1054
  setModelStatus('已载入')
1054
1055
  return
1055
1056
  }