spl-agent-engine 0.2.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (20) hide show
  1. {spl_agent_engine-0.2.0/spl_agent_engine.egg-info → spl_agent_engine-0.4.0}/PKG-INFO +1 -1
  2. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/pyproject.toml +1 -1
  3. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/__init__.py +19 -3
  4. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/core.py +243 -11
  5. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0/spl_agent_engine.egg-info}/PKG-INFO +1 -1
  6. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/LICENSE +0 -0
  7. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/README.md +0 -0
  8. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/setup.cfg +0 -0
  9. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/__init__.py +0 -0
  10. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/bias.py +0 -0
  11. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/goal.py +0 -0
  12. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/identity.py +0 -0
  13. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/language_style.py +0 -0
  14. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/value.py +0 -0
  15. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/feature/world.py +0 -0
  16. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine/py.typed +0 -0
  17. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine.egg-info/SOURCES.txt +0 -0
  18. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine.egg-info/dependency_links.txt +0 -0
  19. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine.egg-info/requires.txt +0 -0
  20. {spl_agent_engine-0.2.0 → spl_agent_engine-0.4.0}/spl_agent_engine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spl-agent-engine
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Anthropomorphic psychology engine built on SPL Pure Core V8.0 — deterministic emotion, trauma, memory, trust, and defense mechanisms.
5
5
  Author-email: NOHN-AI <ai@nohnlins.com>
6
6
  Maintainer-email: NOHN-AI <ai@nohnlins.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spl-agent-engine"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Anthropomorphic psychology engine built on SPL Pure Core V8.0 — deterministic emotion, trauma, memory, trust, and defense mechanisms."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.8"
@@ -19,10 +19,26 @@ Usage:
19
19
  print(core.fluid)
20
20
  """
21
21
 
22
- from .core import AuditLogger, SPLPureCoreV7_3, NarrativeMapper
22
+ from .core import (
23
+ AuditLogger,
24
+ SPLPureCoreV7_3,
25
+ NarrativeMapper,
26
+ TokenUsage,
27
+ TokenStats,
28
+ LLMAdapter,
29
+ OpenAIAdapter,
30
+ ClaudeAdapter,
31
+ ChainAdapter,
32
+ generate_line_with_llm,
33
+ )
23
34
 
24
35
  # Canonical name for V8.0 (class name kept for backward compatibility)
25
36
  SPLPureCore = SPLPureCoreV7_3
26
37
 
27
- __version__ = "0.2.0"
28
- __all__ = ["SPLPureCore", "SPLPureCoreV7_3", "NarrativeMapper", "AuditLogger"]
38
+ __version__ = "0.4.0"
39
+ __all__ = [
40
+ "SPLPureCore", "SPLPureCoreV7_3", "NarrativeMapper", "AuditLogger",
41
+ "TokenUsage", "TokenStats",
42
+ "LLMAdapter", "OpenAIAdapter", "ClaudeAdapter", "ChainAdapter",
43
+ "generate_line_with_llm",
44
+ ]
@@ -4,7 +4,7 @@ import json
4
4
  import os
5
5
  import datetime
6
6
  from dataclasses import dataclass, field
7
- from typing import Dict, Any, List, Optional
7
+ from typing import Dict, Any, List, Optional, Tuple, Union
8
8
 
9
9
 
10
10
  # ================================================================
@@ -75,10 +75,162 @@ class AuditLogger:
75
75
  "memory_count", "expected_count")
76
76
  return {k: snap.get(k) for k in keys if k in snap}
77
77
 
78
+ def log_llm_call(self, model: str, prompt_preview: str,
79
+ usage: Optional["TokenUsage"] = None,
80
+ duration_ms: float = 0.0,
81
+ success: bool = True,
82
+ error: str = "") -> None:
83
+ """记录一次 LLM 调用(token 用量、耗时、成功/失败)。
84
+
85
+ 写入失败静默跳过,绝不阻断引擎核心。
86
+ """
87
+ if not self.enabled:
88
+ return
89
+ try:
90
+ entry = {
91
+ "seq": self._entry_count,
92
+ "ts": datetime.datetime.now().isoformat(timespec="milliseconds"),
93
+ "engine": self.ENGINE_VERSION,
94
+ "session": self.session_id,
95
+ "event": "llm_call",
96
+ "model": model,
97
+ "prompt_preview": prompt_preview[:200],
98
+ "success": success,
99
+ "duration_ms": round(duration_ms, 2),
100
+ "usage": {
101
+ "prompt_tokens": usage.prompt_tokens if usage else 0,
102
+ "completion_tokens": usage.completion_tokens if usage else 0,
103
+ "total_tokens": usage.total_tokens if usage else 0,
104
+ } if usage else None,
105
+ "error": error or None,
106
+ }
107
+ with open(self.log_file, "a", encoding="utf-8") as f:
108
+ f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
109
+ self._entry_count += 1
110
+ except Exception:
111
+ pass
112
+
78
113
  def export_path(self) -> Optional[str]:
79
114
  return self.log_file if os.path.exists(self.log_file) else None
80
115
 
81
116
 
117
+ @dataclass
118
+ class TokenUsage:
119
+ """LLM 调用 token 用量统计。"""
120
+ prompt_tokens: int = 0
121
+ completion_tokens: int = 0
122
+ total_tokens: int = 0
123
+ model: str = ""
124
+
125
+
126
+ # ================================================================
127
+ # Token 累计统计器 — 跨调用汇总 token 用量
128
+ # ================================================================
129
+ class TokenStats:
130
+ """跨多次 LLM 调用累计统计 token 用量。
131
+
132
+ 功能:
133
+ - 累计 prompt / completion / total tokens
134
+ - 按模型分别统计
135
+ - 记录每次调用明细
136
+ - 导出 JSON 汇总报告
137
+
138
+ 用法:
139
+ stats = TokenStats()
140
+ stats.record(usage) # 传入 TokenUsage
141
+ stats.record(usage2)
142
+ stats.summary() # 汇总 dict
143
+ stats.export_json("stats.json") # 导出文件
144
+ """
145
+
146
+ def __init__(self):
147
+ self._total_prompt: int = 0
148
+ self._total_completion: int = 0
149
+ self._total_tokens: int = 0
150
+ self._call_count: int = 0
151
+ self._by_model: Dict[str, Dict[str, int]] = {}
152
+ self._history: List[Dict[str, Any]] = []
153
+
154
+ def record(self, usage: TokenUsage) -> None:
155
+ """记录一次 LLM 调用的 token 用量。"""
156
+ if usage is None:
157
+ return
158
+ self._total_prompt += usage.prompt_tokens
159
+ self._total_completion += usage.completion_tokens
160
+ self._total_tokens += usage.total_tokens
161
+ self._call_count += 1
162
+
163
+ model = usage.model or "unknown"
164
+ if model not in self._by_model:
165
+ self._by_model[model] = {
166
+ "prompt_tokens": 0,
167
+ "completion_tokens": 0,
168
+ "total_tokens": 0,
169
+ "call_count": 0,
170
+ }
171
+ m = self._by_model[model]
172
+ m["prompt_tokens"] += usage.prompt_tokens
173
+ m["completion_tokens"] += usage.completion_tokens
174
+ m["total_tokens"] += usage.total_tokens
175
+ m["call_count"] += 1
176
+
177
+ self._history.append({
178
+ "seq": self._call_count,
179
+ "model": model,
180
+ "prompt_tokens": usage.prompt_tokens,
181
+ "completion_tokens": usage.completion_tokens,
182
+ "total_tokens": usage.total_tokens,
183
+ })
184
+
185
+ def summary(self) -> Dict[str, Any]:
186
+ """返回累计统计汇总。"""
187
+ return {
188
+ "call_count": self._call_count,
189
+ "total_prompt_tokens": self._total_prompt,
190
+ "total_completion_tokens": self._total_completion,
191
+ "total_tokens": self._total_tokens,
192
+ "by_model": dict(self._by_model),
193
+ }
194
+
195
+ def history(self) -> List[Dict[str, Any]]:
196
+ """返回每次调用的明细记录。"""
197
+ return list(self._history)
198
+
199
+ def reset(self) -> None:
200
+ """清空所有统计。"""
201
+ self._total_prompt = 0
202
+ self._total_completion = 0
203
+ self._total_tokens = 0
204
+ self._call_count = 0
205
+ self._by_model.clear()
206
+ self._history.clear()
207
+
208
+ def export_json(self, path: str) -> None:
209
+ """将统计汇总 + 明细导出为 JSON 文件。"""
210
+ data = {
211
+ "summary": self.summary(),
212
+ "history": self.history(),
213
+ }
214
+ with open(path, "w", encoding="utf-8") as f:
215
+ json.dump(data, f, ensure_ascii=False, indent=2)
216
+
217
+ @property
218
+ def total_tokens(self) -> int:
219
+ return self._total_tokens
220
+
221
+ @property
222
+ def total_prompt_tokens(self) -> int:
223
+ return self._total_prompt
224
+
225
+ @property
226
+ def total_completion_tokens(self) -> int:
227
+ return self._total_completion
228
+
229
+ @property
230
+ def call_count(self) -> int:
231
+ return self._call_count
232
+
233
+
82
234
  # ================================================================
83
235
  # 1. 外部叙事映射器("自设:偏见/世界观/价值观"——纯外部策略)
84
236
  # 用户可随意替换,无需修改核心代码。
@@ -1309,7 +1461,9 @@ class LLMAdapter(ABC):
1309
1461
  style_example: str = "",
1310
1462
  system_prompt: str = "",
1311
1463
  temperature: float = 0.7,
1312
- max_tokens: int = 500) -> Optional[str]:
1464
+ max_tokens: int = 500,
1465
+ return_usage: bool = False
1466
+ ) -> Union[Optional[str], Tuple[Optional[str], Optional["TokenUsage"]]]:
1313
1467
  """
1314
1468
  根据 prompt_injection 生成台词。
1315
1469
 
@@ -1319,9 +1473,11 @@ class LLMAdapter(ABC):
1319
1473
  system_prompt: 可选系统提示词,覆盖默认
1320
1474
  temperature: 采样温度
1321
1475
  max_tokens: 最大生成 token 数
1476
+ return_usage: True 时返回 (台词, TokenUsage) 元组
1322
1477
 
1323
1478
  Returns:
1324
- 生成的台词字符串,失败返回 None
1479
+ return_usage=False: 生成的台词字符串,失败返回 None
1480
+ return_usage=True: (台词, TokenUsage) 元组
1325
1481
  """
1326
1482
  ...
1327
1483
 
@@ -1350,7 +1506,9 @@ class OpenAIAdapter(LLMAdapter):
1350
1506
  style_example: str = "",
1351
1507
  system_prompt: str = "",
1352
1508
  temperature: float = 0.7,
1353
- max_tokens: int = 500) -> Optional[str]:
1509
+ max_tokens: int = 500,
1510
+ return_usage: bool = False
1511
+ ) -> Union[Optional[str], Tuple[Optional[str], Optional[TokenUsage]]]:
1354
1512
  import urllib.request
1355
1513
  import urllib.error
1356
1514
 
@@ -1383,8 +1541,22 @@ class OpenAIAdapter(LLMAdapter):
1383
1541
  body = json.loads(resp.read().decode("utf-8"))
1384
1542
  choice = body.get("choices", [{}])[0]
1385
1543
  content = choice.get("message", {}).get("content", "")
1386
- return content.strip() if content else None
1544
+ result = content.strip() if content else None
1545
+
1546
+ usage_data = body.get("usage", {}) or {}
1547
+ usage = TokenUsage(
1548
+ prompt_tokens=usage_data.get("prompt_tokens", 0),
1549
+ completion_tokens=usage_data.get("completion_tokens", 0),
1550
+ total_tokens=usage_data.get("total_tokens", 0),
1551
+ model=body.get("model", self.model),
1552
+ ) if usage_data else None
1553
+
1554
+ if return_usage:
1555
+ return result, usage
1556
+ return result
1387
1557
  except (urllib.error.URLError, json.JSONDecodeError, KeyError) as e:
1558
+ if return_usage:
1559
+ return None, None
1388
1560
  return None
1389
1561
 
1390
1562
 
@@ -1412,7 +1584,9 @@ class ClaudeAdapter(LLMAdapter):
1412
1584
  style_example: str = "",
1413
1585
  system_prompt: str = "",
1414
1586
  temperature: float = 0.7,
1415
- max_tokens: int = 500) -> Optional[str]:
1587
+ max_tokens: int = 500,
1588
+ return_usage: bool = False
1589
+ ) -> Union[Optional[str], Tuple[Optional[str], Optional[TokenUsage]]]:
1416
1590
  import urllib.request
1417
1591
  import urllib.error
1418
1592
 
@@ -1445,8 +1619,24 @@ class ClaudeAdapter(LLMAdapter):
1445
1619
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
1446
1620
  body = json.loads(resp.read().decode("utf-8"))
1447
1621
  content = body.get("content", [{}])[0].get("text", "")
1448
- return content.strip() if content else None
1622
+ result = content.strip() if content else None
1623
+
1624
+ usage_data = body.get("usage", {}) or {}
1625
+ prompt_tok = usage_data.get("input_tokens", 0)
1626
+ completion_tok = usage_data.get("output_tokens", 0)
1627
+ usage = TokenUsage(
1628
+ prompt_tokens=prompt_tok,
1629
+ completion_tokens=completion_tok,
1630
+ total_tokens=prompt_tok + completion_tok,
1631
+ model=body.get("model", self.model),
1632
+ ) if usage_data else None
1633
+
1634
+ if return_usage:
1635
+ return result, usage
1636
+ return result
1449
1637
  except (urllib.error.URLError, json.JSONDecodeError, KeyError) as e:
1638
+ if return_usage:
1639
+ return None, None
1450
1640
  return None
1451
1641
 
1452
1642
 
@@ -1472,24 +1662,45 @@ class ChainAdapter(LLMAdapter):
1472
1662
  style_example: str = "",
1473
1663
  system_prompt: str = "",
1474
1664
  temperature: float = 0.7,
1475
- max_tokens: int = 500) -> Optional[str]:
1476
- result = self.primary.generate(
1665
+ max_tokens: int = 500,
1666
+ return_usage: bool = False
1667
+ ) -> Union[Optional[str], Tuple[Optional[str], Optional[TokenUsage]]]:
1668
+ result, usage = self.primary.generate(
1477
1669
  prompt_injection=prompt_injection,
1478
1670
  style_example=style_example,
1479
1671
  system_prompt=system_prompt,
1480
1672
  temperature=temperature,
1481
1673
  max_tokens=max_tokens,
1674
+ return_usage=True,
1482
1675
  )
1483
1676
  if result is not None:
1677
+ if return_usage:
1678
+ return result, usage
1484
1679
  return result
1485
1680
  if self.fallback is not None:
1486
- return self.fallback.generate(
1681
+ result2, usage2 = self.fallback.generate(
1487
1682
  prompt_injection=prompt_injection,
1488
1683
  style_example=style_example,
1489
1684
  system_prompt=system_prompt,
1490
1685
  temperature=temperature,
1491
1686
  max_tokens=max_tokens,
1687
+ return_usage=True,
1492
1688
  )
1689
+ if return_usage:
1690
+ # 合并两次调用的 token 用量
1691
+ if usage and usage2:
1692
+ merged = TokenUsage(
1693
+ prompt_tokens=usage.prompt_tokens + usage2.prompt_tokens,
1694
+ completion_tokens=usage.completion_tokens + usage2.completion_tokens,
1695
+ total_tokens=usage.total_tokens + usage2.total_tokens,
1696
+ model=f"{usage.model}->{usage2.model}",
1697
+ )
1698
+ else:
1699
+ merged = usage2 or usage
1700
+ return result2, merged
1701
+ return result2
1702
+ if return_usage:
1703
+ return None, usage
1493
1704
  return None
1494
1705
 
1495
1706
 
@@ -1501,6 +1712,7 @@ def generate_line_with_llm(
1501
1712
  core_snapshot: dict,
1502
1713
  expression,
1503
1714
  adapter: LLMAdapter,
1715
+ audit_logger: Optional["AuditLogger"] = None,
1504
1716
  ) -> str:
1505
1717
  """
1506
1718
  便利函数:引擎渲染风格 → LLM 生成台词 → 失败回退到确定性生成。
@@ -1510,18 +1722,38 @@ def generate_line_with_llm(
1510
1722
  core_snapshot: SPL 核心快照(含 fluid/mood/self_esteem 等)
1511
1723
  expression: 表达意图对象(含 expression_mode / emotion_hidden / silence 等)
1512
1724
  adapter: LLMAdapter 实例
1725
+ audit_logger: 可选 AuditLogger,传入则记录 LLM 调用与 token 用量
1513
1726
 
1514
1727
  Returns:
1515
1728
  台词字符串(LLM 成功=LLM 产出,失败=引擎确定性产出)
1516
1729
  """
1730
+ import time as _time
1731
+
1517
1732
  rendered = engine.render_style(core_snapshot, expression)
1518
1733
  if rendered.silence_hint:
1519
1734
  return rendered.silence_hint
1520
1735
 
1521
- line = adapter.generate(
1736
+ t0 = _time.perf_counter()
1737
+ line, usage = adapter.generate(
1522
1738
  prompt_injection=rendered.prompt_injection,
1523
1739
  style_example=rendered.style_example,
1740
+ return_usage=True,
1524
1741
  )
1742
+ duration_ms = (_time.perf_counter() - t0) * 1000
1743
+
1744
+ if audit_logger is not None:
1745
+ model = getattr(adapter, "model", "") or (
1746
+ usage.model if usage else ""
1747
+ )
1748
+ audit_logger.log_llm_call(
1749
+ model=model,
1750
+ prompt_preview=rendered.prompt_injection,
1751
+ usage=usage,
1752
+ duration_ms=duration_ms,
1753
+ success=line is not None,
1754
+ error="" if line is not None else "llm_failed",
1755
+ )
1756
+
1525
1757
  if line is not None:
1526
1758
  return line
1527
1759
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spl-agent-engine
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Anthropomorphic psychology engine built on SPL Pure Core V8.0 — deterministic emotion, trauma, memory, trust, and defense mechanisms.
5
5
  Author-email: NOHN-AI <ai@nohnlins.com>
6
6
  Maintainer-email: NOHN-AI <ai@nohnlins.com>