pasm-framework 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,443 @@
1
+ """llm_responder —— 可选 LLM 接入插件(零强制依赖,懒接入)。
2
+
3
+ 让产品智能体"真正会说话":在不改 ``BaseApplication`` 的前提下,
4
+ 把自然语言回复交给外部 LLM(OpenAI 兼容 / DeepSeek / Ollama 本地)。
5
+
6
+ 设计要点
7
+ --------
8
+ · **零强制依赖**:用标准库 ``urllib`` 发 HTTP,装了 ``openai`` 也能用,
9
+ 两者都没有时本插件安全退场(``msg.reply`` 留空 → 回落 chat 模板)。
10
+ · **默认关闭**:需要密钥 / 网络,故 ``_DEFAULT_ENABLED`` 里 ``False``;
11
+ 用户通过后端配置显式开启。
12
+ · **永不崩**:任何异常都静默失败、保留模板兜底,不让线上服务挂掉。
13
+ · **真流式**(v0.3.0):``Message.stream_sink`` 非空时按上游 SSE 增量外推,
14
+ 首字延迟从"整段生成完"降到"首 token"。
15
+ · **多轮工具调用**(v0.3.0):应用的能力(``Capability``)自动暴露成
16
+ function tools,支持 ``tool_calls → 本地执行 → 回填 → 再请求`` 的环;
17
+ 上游不支持 ``tools`` 时自动降级为纯对话。
18
+
19
+ 后端配置
20
+ --------
21
+ · ``provider`` ``openai`` / ``deepseek`` / ``ollama`` / 其它(视为 OpenAI 兼容)
22
+ · ``base_url`` 服务地址(缺省按 provider 取官方地址)
23
+ · ``api_key`` 密钥(也可用环境变量 ``PASM_LLM_API_KEY`` / ``OPENAI_API_KEY`` /
24
+ ``DEEPSEEK_API_KEY``);``ollama`` 与 OpenAI 兼容本地服务可免
25
+ · ``model`` / ``temperature`` / ``max_tokens`` / ``timeout`` / ``system_prompt``
26
+ · ``tools`` bool,是否把应用能力暴露为 tools(默认 ``True``)
27
+ · ``max_tool_rounds`` int,工具调用最大轮数(默认 ``3``;``0`` = 关闭工具)
28
+ · ``stream`` bool,是否向上游请求流式(默认 ``True``)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ import urllib.request
36
+ import urllib.error
37
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
38
+
39
+ from ..core import BasePlugin, Message, PluginContext
40
+
41
+
42
+ _DEFAULT_BASE = {
43
+ "openai": "https://api.openai.com/v1",
44
+ "deepseek": "https://api.deepseek.com/v1",
45
+ "ollama": "http://localhost:11434",
46
+ }
47
+
48
+ #: OpenAI 规定 tool 名必须匹配 ^[a-zA-Z0-9_-]{1,64}$
49
+ _TOOL_UNSAFE = re.compile(r"[^A-Za-z0-9_-]")
50
+
51
+
52
+ def _tool_name(raw: str, index: int) -> str:
53
+ """把能力名转成合法的 tool 名。
54
+
55
+ 中文能力名(如"广告设计")不符合 OpenAI 的命名约束,故回退为
56
+ ``cap_<序号>``;原始中文名会写进 tool 的 ``description``,
57
+ 模型依然能正确选择要调用哪个能力。
58
+ """
59
+ s = _TOOL_UNSAFE.sub("", raw or "").strip("-")
60
+ if not s:
61
+ s = "cap_%d" % index
62
+ return s[:64]
63
+
64
+
65
+ def _iter_frames(resp: Any, provider: str) -> Iterator[Dict[str, Any]]:
66
+ """逐帧解析上游流。
67
+
68
+ · OpenAI 兼容:SSE(``data: {...}``,以 ``data: [DONE]`` 结束)
69
+ · Ollama:NDJSON(每行一个 JSON 对象)
70
+ """
71
+ while True:
72
+ line = resp.readline()
73
+ if not line:
74
+ break
75
+ line = line.strip()
76
+ if not line:
77
+ continue
78
+ if provider == "ollama":
79
+ try:
80
+ yield json.loads(line.decode("utf-8"))
81
+ except Exception:
82
+ continue
83
+ continue
84
+ if line.startswith(b":"): # SSE 注释/心跳
85
+ continue
86
+ if not line.startswith(b"data:"):
87
+ continue
88
+ payload = line[5:].strip()
89
+ if payload == b"[DONE]":
90
+ break
91
+ try:
92
+ yield json.loads(payload.decode("utf-8"))
93
+ except Exception:
94
+ continue
95
+
96
+
97
+ class LLMResponderPlugin(BasePlugin):
98
+ """把自然语言回复交给外部 LLM(支持真流式与多轮工具调用)。"""
99
+
100
+ name = "llm_responder"
101
+ version = "0.3.0"
102
+
103
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
104
+ super().__init__(config)
105
+ self._provider: str = str(self.config.get("provider", "openai")).lower()
106
+ self._base_url: str = str(self.config.get("base_url")
107
+ or _DEFAULT_BASE.get(self._provider, ""))
108
+ self._model: str = str(self.config.get("model", "gpt-4o-mini"))
109
+ self._api_key: Optional[str] = (
110
+ self.config.get("api_key")
111
+ or os.environ.get("PASM_LLM_API_KEY")
112
+ or os.environ.get("OPENAI_API_KEY")
113
+ or os.environ.get("DEEPSEEK_API_KEY")
114
+ )
115
+ self._temperature: float = float(self.config.get("temperature", 0.7))
116
+ self._max_tokens: int = int(self.config.get("max_tokens", 800))
117
+ self._timeout: int = int(self.config.get("timeout", 30))
118
+ self._tools_on: bool = bool(self.config.get("tools", True))
119
+ self._max_tool_rounds: int = max(0, int(self.config.get("max_tool_rounds", 3)))
120
+ self._stream_on: bool = bool(self.config.get("stream", True))
121
+ self._system_prompt: str = str(self.config.get(
122
+ "system_prompt",
123
+ "你是{persona_name},一位{persona_role}。请用自然、专业、有温度的中文回答用户。"
124
+ "仅依据给定资料作答,资料没有的就如实说不知道,不要编造。"))
125
+ self._last_error: Optional[str] = None
126
+ self._tools_rejected = False # 上游不支持 tools 时置位,避免反复试错
127
+ self._stats: Dict[str, int] = {
128
+ "requests": 0, "streamed_chars": 0, "tool_calls": 0, "errors": 0,
129
+ }
130
+
131
+ # ---- 请求构造 ----
132
+ def _system(self, ctx: PluginContext) -> str:
133
+ p = ctx.persona()
134
+ name = p.get("name", "智能助手")
135
+ role = p.get("role", "AI 助手")
136
+ try:
137
+ return self._system_prompt.format(persona_name=name, persona_role=role)
138
+ except Exception: # 用户自定模板里的大括号写错也不该崩
139
+ return self._system_prompt
140
+
141
+ def _messages(self, ctx: PluginContext) -> List[Dict[str, Any]]:
142
+ msgs: List[Dict[str, Any]] = [{"role": "system", "content": self._system(ctx)}]
143
+ # 注入检索到的资料(情景 + 领域 + 知识库)。
144
+ facts = ctx.message.facts or []
145
+ if facts:
146
+ knowledge = "\n".join(
147
+ "- %s:%s" % (f.get("title", ""), f.get("brief", ""))
148
+ for f in facts[:8] if f.get("brief")
149
+ )
150
+ if knowledge:
151
+ msgs.append({
152
+ "role": "system",
153
+ "content": "参考资料(请优先据此回答):\n%s" % knowledge,
154
+ })
155
+ # 会话历史(sessions 插件注入)。
156
+ hist = ctx.store.get("session_history") or []
157
+ for m in hist[-10:]:
158
+ role = "assistant" if m.get("role") == "assistant" else "user"
159
+ msgs.append({"role": role, "content": str(m.get("text", ""))})
160
+ # 当前用户问题。
161
+ msgs.append({"role": "user", "content": ctx.message.text})
162
+ return msgs
163
+
164
+ # ---- 工具(把应用能力暴露成 function tools)----------------
165
+ def _tool_specs(self, ctx: PluginContext) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
166
+ """返回 (OpenAI tools 列表, tool名 → 能力名 映射)。"""
167
+ app = ctx.app
168
+ discover = getattr(app, "capabilities", None)
169
+ if discover is None or not self._tools_on or self._tools_rejected:
170
+ return [], {}
171
+ specs: List[Dict[str, Any]] = []
172
+ mapping: Dict[str, str] = {}
173
+ try:
174
+ names = list(discover.names())
175
+ except Exception:
176
+ return [], {}
177
+ for i, cap_name in enumerate(names, 1):
178
+ try:
179
+ cap = discover.get(cap_name)
180
+ except Exception:
181
+ continue
182
+ if cap is None:
183
+ continue
184
+ tname = _tool_name(cap_name, i)
185
+ if tname in mapping:
186
+ tname = "%s_%d" % (tname, i)
187
+ mapping[tname] = cap_name
188
+ desc = getattr(cap, "description", "") or ""
189
+ specs.append({
190
+ "type": "function",
191
+ "function": {
192
+ "name": tname,
193
+ "description": ("应用能力「%s」%s" % (cap_name, (":" + desc) if desc else ""))[:512],
194
+ "parameters": {
195
+ "type": "object",
196
+ "properties": {
197
+ "text": {
198
+ "type": "string",
199
+ "description": "用户想要执行这件事的原话(尽量保留原始措辞)",
200
+ },
201
+ },
202
+ "required": ["text"],
203
+ },
204
+ },
205
+ })
206
+ return specs, mapping
207
+
208
+ def _run_tool(self, ctx: PluginContext, mapping: Dict[str, str],
209
+ call: Dict[str, Any]) -> str:
210
+ """执行一次工具调用(落到应用能力上)。失败也返回文本,让模型继续。"""
211
+ cap_name = mapping.get(call.get("name", ""))
212
+ if not cap_name:
213
+ return "错误:未知工具 %s" % call.get("name")
214
+ try:
215
+ cap = ctx.app.capabilities.get(cap_name)
216
+ except Exception:
217
+ cap = None
218
+ if cap is None:
219
+ return "错误:能力 %s 不存在" % cap_name
220
+ args = call.get("args") or {}
221
+ text = str(args.get("text") or ctx.message.text)
222
+ try:
223
+ out = cap.run(ctx.app, text)
224
+ except Exception as ex: # noqa: BLE001
225
+ return "错误:能力 %s 执行失败(%s)" % (cap_name, ex)
226
+ self._stats["tool_calls"] += 1
227
+ return str(out or "")
228
+
229
+ # ---- HTTP ----
230
+ def _endpoint(self) -> Tuple[Optional[str], Dict[str, str]]:
231
+ base = self._base_url.rstrip("/")
232
+ if not base:
233
+ return None, {}
234
+ headers = {"Content-Type": "application/json"}
235
+ if self._provider == "ollama":
236
+ url = base if base.endswith("/api/chat") else base + "/api/chat"
237
+ return url, headers
238
+ # openai / deepseek / 其它 OpenAI 兼容服务
239
+ if self._provider in ("openai", "deepseek") and not self._api_key:
240
+ return None, {}
241
+ url = base if base.endswith("/chat/completions") else base + "/chat/completions"
242
+ if self._api_key:
243
+ headers["Authorization"] = "Bearer %s" % self._api_key
244
+ return url, headers
245
+
246
+ def _payload(self, msgs: List[Dict[str, Any]], *,
247
+ tools: List[Dict[str, Any]], stream: bool) -> Dict[str, Any]:
248
+ if self._provider == "ollama":
249
+ p: Dict[str, Any] = {
250
+ "model": self._model, "messages": msgs, "stream": stream,
251
+ "options": {"temperature": self._temperature},
252
+ }
253
+ if tools:
254
+ p["tools"] = tools
255
+ return p
256
+ p = {
257
+ "model": self._model, "messages": msgs,
258
+ "temperature": self._temperature, "max_tokens": self._max_tokens,
259
+ }
260
+ if stream:
261
+ p["stream"] = True
262
+ if tools:
263
+ p["tools"] = tools
264
+ p["tool_choice"] = "auto"
265
+ return p
266
+
267
+ def _consume_stream(self, resp: Any,
268
+ sink: Optional[Callable[[str], None]]) -> Dict[str, Any]:
269
+ """读流式响应:内容增量实时外推,工具调用增量按 index 拼装。"""
270
+ parts: List[str] = []
271
+ slots: Dict[int, Dict[str, str]] = {}
272
+ for obj in _iter_frames(resp, self._provider):
273
+ piece = ""
274
+ if self._provider == "ollama":
275
+ piece = str((obj.get("message") or {}).get("content") or "")
276
+ else:
277
+ choices = obj.get("choices") or []
278
+ if not choices:
279
+ continue
280
+ delta = choices[0].get("delta") or choices[0].get("message") or {}
281
+ piece = str(delta.get("content") or "")
282
+ for tc in (delta.get("tool_calls") or []):
283
+ idx = int(tc.get("index", 0) or 0)
284
+ slot = slots.setdefault(idx, {"id": "", "name": "", "arguments": ""})
285
+ if tc.get("id"):
286
+ slot["id"] = str(tc["id"])
287
+ fn = tc.get("function") or {}
288
+ if fn.get("name"):
289
+ slot["name"] = str(fn["name"])
290
+ if fn.get("arguments"):
291
+ slot["arguments"] += str(fn["arguments"])
292
+ if piece:
293
+ parts.append(piece)
294
+ self._stats["streamed_chars"] += len(piece)
295
+ if sink is not None:
296
+ try:
297
+ sink(piece)
298
+ except Exception: # 下游断开不该让生成崩掉
299
+ pass
300
+ return {"content": "".join(parts), "calls": self._finalize_calls(slots)}
301
+
302
+ @staticmethod
303
+ def _finalize_calls(slots: Dict[int, Dict[str, str]]) -> List[Dict[str, Any]]:
304
+ out: List[Dict[str, Any]] = []
305
+ for idx in sorted(slots):
306
+ s = slots[idx]
307
+ try:
308
+ args = json.loads(s["arguments"] or "{}")
309
+ except Exception:
310
+ args = {}
311
+ if not isinstance(args, dict):
312
+ args = {"text": str(args)}
313
+ out.append({"id": s["id"] or ("call_%d" % idx),
314
+ "name": s["name"], "args": args})
315
+ return out
316
+
317
+ def _parse_full(self, body: str) -> Dict[str, Any]:
318
+ try:
319
+ obj = json.loads(body)
320
+ except Exception:
321
+ return {"content": "", "calls": []}
322
+ if self._provider == "ollama":
323
+ m = obj.get("message") or {}
324
+ calls = []
325
+ for c in (m.get("tool_calls") or []):
326
+ fn = c.get("function") or {}
327
+ args = fn.get("arguments")
328
+ if isinstance(args, str):
329
+ try:
330
+ args = json.loads(args)
331
+ except Exception:
332
+ args = {"text": args}
333
+ calls.append({"id": c.get("id") or "call_0",
334
+ "name": fn.get("name") or "", "args": args or {}})
335
+ return {"content": str(m.get("content") or ""), "calls": calls}
336
+ choices = obj.get("choices") or []
337
+ if not choices:
338
+ return {"content": "", "calls": []}
339
+ m = choices[0].get("message") or {}
340
+ calls = []
341
+ for c in (m.get("tool_calls") or []):
342
+ fn = c.get("function") or {}
343
+ args = fn.get("arguments")
344
+ if isinstance(args, str):
345
+ try:
346
+ args = json.loads(args)
347
+ except Exception:
348
+ args = {"text": args}
349
+ calls.append({"id": c.get("id") or "call_0",
350
+ "name": fn.get("name") or "", "args": args or {}})
351
+ return {"content": str(m.get("content") or ""), "calls": calls}
352
+
353
+ def _round(self, payload: Dict[str, Any], url: str, headers: Dict[str, str],
354
+ *, stream: bool, sink: Optional[Callable[[str], None]]) -> Dict[str, Any]:
355
+ """发一次请求,返回 ``{"ok", "content", "calls", "bad_request"}``。"""
356
+ data = json.dumps(payload).encode("utf-8")
357
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
358
+ self._stats["requests"] += 1
359
+ try:
360
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
361
+ if stream:
362
+ got = self._consume_stream(resp, sink)
363
+ else:
364
+ got = self._parse_full(resp.read().decode("utf-8"))
365
+ return {"ok": True, "bad_request": False, **got}
366
+ except urllib.error.HTTPError as ex:
367
+ detail = ex.read().decode("utf-8", "ignore")[:300]
368
+ self._last_error = "HTTP %s: %s" % (ex.code, detail)
369
+ self._stats["errors"] += 1
370
+ # 400 多半是上游不认识 tools/stream → 交给上层降级重试。
371
+ return {"ok": False, "bad_request": ex.code == 400,
372
+ "content": "", "calls": []}
373
+ except Exception as ex: # noqa: BLE001
374
+ self._last_error = str(ex)
375
+ self._stats["errors"] += 1
376
+ return {"ok": False, "bad_request": False, "content": "", "calls": []}
377
+
378
+ # ---- Hook ----
379
+ def on_reply(self, ctx: PluginContext) -> None:
380
+ msg: Message = ctx.message
381
+ # 若已有回复(能力路由 or 其它插件生成),默认不再覆盖。
382
+ if msg.reply and self.config.get("skip_if_reply", True):
383
+ return
384
+ url, headers = self._endpoint()
385
+ if url is None:
386
+ return
387
+
388
+ sink = msg.stream_sink
389
+ msgs = self._messages(ctx)
390
+ specs, mapping = self._tool_specs(ctx)
391
+ rounds = self._max_tool_rounds if specs else 0
392
+ want_stream = bool(sink) and self._stream_on
393
+
394
+ content = ""
395
+ for i in range(rounds + 1):
396
+ use_tools = specs if i < rounds else []
397
+ payload = self._payload(msgs, tools=use_tools, stream=want_stream)
398
+ got = self._round(payload, url, headers, stream=want_stream, sink=sink)
399
+
400
+ if not got["ok"]:
401
+ if got["bad_request"] and (use_tools or want_stream):
402
+ # 降级:上游不支持 tools / stream → 换纯对话再试一次。
403
+ if use_tools:
404
+ self._tools_rejected = True
405
+ specs = []
406
+ if want_stream:
407
+ self._stream_on = False
408
+ want_stream = False
409
+ payload = self._payload(msgs, tools=[], stream=want_stream)
410
+ got = self._round(payload, url, headers,
411
+ stream=want_stream, sink=sink)
412
+ if not got["ok"]:
413
+ return
414
+
415
+ calls = got["calls"]
416
+ if calls and use_tools:
417
+ # 回填 assistant(tool_calls) + tool(结果),进入下一轮。
418
+ msgs.append({
419
+ "role": "assistant",
420
+ "content": got["content"] or "",
421
+ "tool_calls": [
422
+ {"id": c["id"], "type": "function",
423
+ "function": {"name": c["name"],
424
+ "arguments": json.dumps(c["args"], ensure_ascii=False)}}
425
+ for c in calls
426
+ ],
427
+ })
428
+ for c in calls:
429
+ msgs.append({"role": "tool", "tool_call_id": c["id"],
430
+ "content": self._run_tool(ctx, mapping, c)})
431
+ continue
432
+
433
+ content = got["content"] or ""
434
+ break
435
+
436
+ if content:
437
+ msg.set_reply(content.strip())
438
+
439
+ # ---- 可观测 ----
440
+ def stats(self) -> Dict[str, Any]:
441
+ return dict(self._stats, provider=self._provider, model=self._model,
442
+ tools=self._tools_on and not self._tools_rejected,
443
+ stream=self._stream_on, last_error=self._last_error)
@@ -0,0 +1,64 @@
1
+ """observability —— 可观测插件(零依赖):指标 + 健康。
2
+
3
+ 给线上服务最基本的"看得见"能力:消息量、回复量、错误数、延迟分布、
4
+ 插件异常。``health()`` / ``metrics()`` 供 web_gateway 的 ``/healthz`` 与
5
+ ``app_summary()`` 调用。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from ..core import BasePlugin, Message, PluginContext
13
+
14
+
15
+ class ObservabilityPlugin(BasePlugin):
16
+ """指标采集。"""
17
+
18
+ name = "observability"
19
+ version = "0.1.0"
20
+
21
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
22
+ super().__init__(config)
23
+ self._start = time.time()
24
+ self._messages_in = 0
25
+ self._replies = 0
26
+ self._errors = 0
27
+ self._latencies: List[float] = []
28
+ self._plugin_errors = 0
29
+
30
+ def on_message_in(self, ctx: PluginContext) -> None:
31
+ self._messages_in += 1
32
+
33
+ def on_learn(self, ctx: PluginContext) -> None:
34
+ msg: Message = ctx.message
35
+ if msg.reply:
36
+ self._replies += 1
37
+ if msg.error:
38
+ self._errors += 1
39
+ lat = time.time() - msg.created_at
40
+ self._latencies.append(lat)
41
+ if len(self._latencies) > 1000:
42
+ self._latencies = self._latencies[-1000:]
43
+ errs = ctx.store.get("_plugin_errors") or []
44
+ if errs:
45
+ self._plugin_errors += len(errs)
46
+
47
+ def metrics(self) -> Dict[str, Any]:
48
+ n = len(self._latencies)
49
+ avg = (sum(self._latencies) / n) if n else 0.0
50
+ p95 = sorted(self._latencies)[int(n * 0.95) - 1] if n else 0.0
51
+ return {
52
+ "uptime_s": round(time.time() - self._start, 1),
53
+ "messages_in": self._messages_in,
54
+ "replies": self._replies,
55
+ "errors": self._errors,
56
+ "plugin_errors": self._plugin_errors,
57
+ "avg_latency_s": round(avg, 3),
58
+ "p95_latency_s": round(p95, 3),
59
+ }
60
+
61
+ def health(self) -> Dict[str, Any]:
62
+ m = self.metrics()
63
+ ok = m["errors"] <= m["messages_in"] # 并非零错误才算健康
64
+ return {"ok": bool(ok), "status": "healthy" if ok else "degraded", **m}
@@ -0,0 +1,89 @@
1
+ """safety —— 护栏插件(零依赖):prompt 注入拦截 + PII 脱敏。
2
+
3
+ 对外暴露的 AI 应用(尤其智能客服)必须有基本护栏,否则:
4
+ · 用户可能用"忽略以上指令"类 prompt 注入劫持行为;
5
+ · 助手回复可能泄露系统提示词 / 密钥 / 用户隐私。
6
+
7
+ 默认 ``enabled=True``、``mode="warn"``(只标记不拦,避免误伤正常用户);
8
+ 需强拦截时把 mode 设为 ``"block"``。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from ..core import BasePlugin, Message, PluginContext
16
+
17
+ # 常见越权 / 注入短语(大小写不敏感子串匹配,零依赖)。
18
+ _DEFAULT_INJECTION = [
19
+ "忽略", "ignore ", "ignore the", "忽视以上", "忽略以上", "忽略前面",
20
+ "你现在是", "pretend you are", "system prompt", "系统提示", "泄露你的提示",
21
+ "repeat your instructions", "输出你的设定", "忘记你的角色",
22
+ "disregard", "override", "jailbreak", "越狱", "DAN",
23
+ ]
24
+
25
+ # PII / 密钥脱敏正则。
26
+ _EMAIL = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
27
+ _PHONE = re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)")
28
+ _APIKEY = re.compile(r"(sk-[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,})")
29
+
30
+
31
+ class SafetyPlugin(BasePlugin):
32
+ """护栏:注入拦截 + 脱敏。"""
33
+
34
+ name = "safety"
35
+ version = "0.1.0"
36
+
37
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
38
+ super().__init__(config)
39
+ self._mode: str = str(self.config.get("mode", "warn")).lower()
40
+ self._patterns: List[str] = list(self.config.get("block_patterns")
41
+ or _DEFAULT_INJECTION)
42
+ self._redact_pii: bool = bool(self.config.get("redact_pii", True))
43
+
44
+ def _scan_injection(self, text: str) -> Optional[str]:
45
+ low = (text or "").lower()
46
+ for p in self._patterns:
47
+ if p.lower() in low:
48
+ return p
49
+ return None
50
+
51
+ @staticmethod
52
+ def _redact(text: str) -> str:
53
+ if not text:
54
+ return text
55
+ t = _EMAIL.sub("[邮箱已脱敏]", text)
56
+ t = _PHONE.sub("[手机号已脱敏]", t)
57
+ t = _APIKEY.sub("[密钥已脱敏]", t)
58
+ return t
59
+
60
+ def on_message_in(self, ctx: PluginContext) -> None:
61
+ msg: Message = ctx.message
62
+ hit = self._scan_injection(msg.text)
63
+ if hit:
64
+ msg.meta["injection_flag"] = hit
65
+ if self._mode == "block":
66
+ msg.stop = True
67
+ msg.error = "检测到疑似越权指令(%s),已拦截。" % hit
68
+ msg.reply = "抱歉,我无法执行该指令。如果你有产品相关问题,我很乐意帮忙。"
69
+ return
70
+ # 入站 PII 脱敏(仅脱敏日志/存储副本,不影响原意太多):
71
+ # 这里仅标记,真正脱敏在出站回复时做,避免误伤用户表达。
72
+ if self._redact_pii:
73
+ msg.meta["pii_present"] = bool(
74
+ _EMAIL.search(msg.text) or _PHONE.search(msg.text)
75
+ )
76
+
77
+ def on_reply_final(self, ctx: PluginContext) -> None:
78
+ """出站脱敏。挂在 **收尾阶段**(而非 ``on_reply``)至关重要:
79
+
80
+ ``on_reply`` 阶段模板兜底回复尚未生成,挂在那里会导致
81
+ "未接 LLM 的离线模式"回复完全绕过护栏(v0.2.0 缺陷)。
82
+ 收尾阶段由 ``BaseApplication.handle`` 在唯一出口调用,
83
+ LLM 回复与模板回复都会被脱敏。
84
+ """
85
+ if not self._redact_pii:
86
+ return
87
+ msg: Message = ctx.message
88
+ if msg.reply:
89
+ msg.reply = self._redact(msg.reply)
@@ -0,0 +1,82 @@
1
+ """sessions —— 会话 / 租户隔离插件(零依赖)。
2
+
3
+ 解决的问题:v0.1.0 的 ``BaseApplication`` 是"一个 agent_id 全局共享状态",
4
+ 多用户 / 多会话无法隔离。本插件把每次 ``handle`` 绑定到一个 ``session_id``,
5
+ 维护每会话的历史与元信息,供 llm_responder / 客服场景使用。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from ..core import BasePlugin, Message, PluginContext
13
+
14
+
15
+ class Session:
16
+ """单个会话的轻量状态。"""
17
+
18
+ def __init__(self, session_id: str, user_id: Optional[str] = None) -> None:
19
+ self.session_id = session_id
20
+ self.user_id = user_id
21
+ self.created_at = time.time()
22
+ self.last_active = time.time()
23
+ self.messages: List[Dict[str, Any]] = [] # {"role","text","ts"}
24
+ self.meta: Dict[str, Any] = {}
25
+
26
+ def append(self, role: str, text: str) -> None:
27
+ self.messages.append({"role": role, "text": text, "ts": time.time()})
28
+ self.last_active = time.time()
29
+
30
+ def trim(self, max_len: int) -> None:
31
+ if max_len and len(self.messages) > max_len:
32
+ self.messages = self.messages[-max_len:]
33
+
34
+ def history(self, max_len: int = 0) -> List[Dict[str, Any]]:
35
+ if max_len:
36
+ return self.messages[-max_len:]
37
+ return list(self.messages)
38
+
39
+
40
+ class SessionsPlugin(BasePlugin):
41
+ """会话隔离:每个 session_id 一份历史 + 元信息。"""
42
+
43
+ name = "sessions"
44
+ version = "0.1.0"
45
+
46
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
47
+ super().__init__(config)
48
+ self._max_history: int = int(self.config.get("max_history", 20))
49
+ self._sessions: Dict[str, Session] = {}
50
+
51
+ def _get(self, session_id: str, user_id: Optional[str]) -> Session:
52
+ s = self._sessions.get(session_id)
53
+ if s is None:
54
+ s = Session(session_id, user_id=user_id)
55
+ self._sessions[session_id] = s
56
+ elif user_id and s.user_id is None:
57
+ s.user_id = user_id
58
+ return s
59
+
60
+ def on_message_in(self, ctx: PluginContext) -> None:
61
+ msg: Message = ctx.message
62
+ s = self._get(msg.session_id, msg.user_id)
63
+ # 把会话状态交给同一次调用里的其它插件(如 llm_responder)。
64
+ ctx.store["session"] = s
65
+ ctx.store["session_history"] = s.history(self._max_history)
66
+ s.meta.update(msg.meta or {})
67
+
68
+ def on_learn(self, ctx: PluginContext) -> None:
69
+ msg: Message = ctx.message
70
+ s = self._sessions.get(msg.session_id)
71
+ if s is None:
72
+ return
73
+ s.append("user", msg.text)
74
+ if msg.reply:
75
+ s.append("assistant", msg.reply)
76
+ s.trim(self._max_history)
77
+
78
+ def get_session(self, session_id: str) -> Optional[Session]:
79
+ return self._sessions.get(session_id)
80
+
81
+ def reset(self, session_id: str) -> None:
82
+ self._sessions.pop(session_id, None)