xg-cli 1.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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/__init__.py
ADDED
xg/__main__.py
ADDED
xg/adaptive/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SmartRouter 自适应反馈子系统。
|
|
2
|
+
|
|
3
|
+
负责把用户的隐式行为信号(interrupt / clarify / cmd_retry / short_high_tier)
|
|
4
|
+
落盘到 ``~/.xg/adaptive/``,并在后续步骤(阶段三 C、阶段四)聚合校准。
|
|
5
|
+
|
|
6
|
+
设计依据:XG-docs/smart-docs/states/phase-03-smart-router-adaptive-feedback.md
|
|
7
|
+
数据目录沿用项目用户级配置约定(``~/.xg``),env 变量可覆盖。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# adaptive 数据目录的 env 覆盖键(可选,不设时用默认 ~/.xg/adaptive)
|
|
16
|
+
ADAPTIVE_DIR_ENV = "XG_ADAPTIVE_DIR"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data_dir() -> Path:
|
|
20
|
+
"""解析 adaptive 数据目录:env ``XG_ADAPTIVE_DIR`` 覆盖,默认 ``~/.xg/adaptive``。
|
|
21
|
+
|
|
22
|
+
仅做路径解析,不创建目录;目录懒创建由 store 在写入时处理。
|
|
23
|
+
"""
|
|
24
|
+
override = os.environ.get(ADAPTIVE_DIR_ENV)
|
|
25
|
+
if override:
|
|
26
|
+
return Path(override)
|
|
27
|
+
return Path.home() / ".xg" / "adaptive"
|
xg/adaptive/calibrate.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""校准聚合与应用(phase-03 步骤 C)。
|
|
2
|
+
|
|
3
|
+
聚合 feedback.log 的隐式信号 → 每档偏置 per_class_bias 与全局阈值调整
|
|
4
|
+
threshold_adjust,硬夹紧(±0.15 / ±0.1),单档加权样本不足 20 不校准。
|
|
5
|
+
|
|
6
|
+
设计依据:ADAPTIVE_ROUTING §8.1(聚合公式)、§6.5(置信门应用)。
|
|
7
|
+
与文档的两处有意落地决策(文档 §6.5 示意为 ML 路由的"回落默认档",
|
|
8
|
+
本项目为纯规则路由,按方向注释落地为对称升降一档):
|
|
9
|
+
1. 偏置方向:bias[t] < 0(该档 upgrade 信号多、偏弱)→ 置信门不通过的
|
|
10
|
+
边界输入升一档;bias[t] > 0(downgrade 多、偏强)→ 降一档。
|
|
11
|
+
2. 置信门为对称双门:文档门公式只对升档方向生效(bias>0 时 gate_conf
|
|
12
|
+
恒不低于门),此处按"偏强档对称降档"补齐另一方向。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
|
|
20
|
+
from .feedback import read_feedback
|
|
21
|
+
from .store import atomic_write_json, calibration_path, read_json_safe
|
|
22
|
+
|
|
23
|
+
# 档位名(与 xg.router.model_tiers.TIER_NAMES 一致;本地定义避免包环)
|
|
24
|
+
TIER_NAMES: tuple[str, ...] = ("Basic", "Enhanced", "Superior", "Ultimate")
|
|
25
|
+
|
|
26
|
+
MIN_SAMPLES_PER_TIER = 20 # 单档加权样本不足时不校准
|
|
27
|
+
MAX_BIAS = 0.15 # per_class_bias 硬夹紧
|
|
28
|
+
MAX_THRESHOLD_ADJUST = 0.1 # threshold_adjust 硬夹紧
|
|
29
|
+
CONFIDENCE_BASE = 0.5 # 置信门基础阈值
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Calibration:
|
|
34
|
+
"""一次校准的聚合结果。bias/sample 按档位索引 0..3 排列。"""
|
|
35
|
+
|
|
36
|
+
bias: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
|
|
37
|
+
threshold_adjust: float = 0.0
|
|
38
|
+
samples: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) # 加权样本量
|
|
39
|
+
total: float = 0.0
|
|
40
|
+
|
|
41
|
+
def bias_of(self, tier_idx: int) -> float:
|
|
42
|
+
return self.bias[tier_idx] if 0 <= tier_idx < 4 else 0.0
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"bias": dict(zip(TIER_NAMES, self.bias)),
|
|
47
|
+
"threshold_adjust": self.threshold_adjust,
|
|
48
|
+
"samples": dict(zip(TIER_NAMES, self.samples)),
|
|
49
|
+
"total": self.total,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def from_dict(data: dict[str, Any] | None) -> Calibration:
|
|
54
|
+
"""从持久化 JSON 恢复;结构异常时回退空校准(不抛错)。"""
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
return Calibration()
|
|
57
|
+
try:
|
|
58
|
+
bias_map = data.get("bias") or {}
|
|
59
|
+
samples_map = data.get("samples") or {}
|
|
60
|
+
bias = tuple(float(bias_map.get(name, 0.0)) for name in TIER_NAMES)
|
|
61
|
+
samples = tuple(float(samples_map.get(name, 0.0)) for name in TIER_NAMES)
|
|
62
|
+
return Calibration(
|
|
63
|
+
bias=bias, # type: ignore[arg-type]
|
|
64
|
+
threshold_adjust=float(data.get("threshold_adjust", 0.0)),
|
|
65
|
+
samples=samples, # type: ignore[arg-type]
|
|
66
|
+
total=float(data.get("total", 0.0)),
|
|
67
|
+
)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return Calibration()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _clamp(v: float, lo: float, hi: float) -> float:
|
|
73
|
+
return max(lo, min(hi, v))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def aggregate(
|
|
77
|
+
records: Sequence[dict[str, Any]],
|
|
78
|
+
tier_names: Sequence[str] = TIER_NAMES,
|
|
79
|
+
min_samples: float = MIN_SAMPLES_PER_TIER,
|
|
80
|
+
) -> Calibration:
|
|
81
|
+
"""按 §8.1 公式聚合反馈记录。
|
|
82
|
+
|
|
83
|
+
up[t]/down[t] 为该档 upgrade/downgrade 信号的加权和(weight 求和);
|
|
84
|
+
n[t] = up+down;n < min_samples → bias=0;否则
|
|
85
|
+
r[t]=(up-down)/n,bias[t]=clamp(-r[t]*0.15, ±0.15)。
|
|
86
|
+
全局 threshold_adjust=clamp(r_global*0.1, ±0.1)。
|
|
87
|
+
未知档位/信号方向的记录跳过不计。
|
|
88
|
+
"""
|
|
89
|
+
names = list(tier_names)
|
|
90
|
+
up = [0.0] * len(names)
|
|
91
|
+
down = [0.0] * len(names)
|
|
92
|
+
for rec in records:
|
|
93
|
+
try:
|
|
94
|
+
tier = rec.get("model_tier")
|
|
95
|
+
if tier not in names:
|
|
96
|
+
continue
|
|
97
|
+
idx = names.index(tier)
|
|
98
|
+
weight = float(rec.get("weight", 0.0))
|
|
99
|
+
direction = rec.get("signal")
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
continue
|
|
102
|
+
if direction == "upgrade":
|
|
103
|
+
up[idx] += weight
|
|
104
|
+
elif direction == "downgrade":
|
|
105
|
+
down[idx] += weight
|
|
106
|
+
|
|
107
|
+
bias: list[float] = []
|
|
108
|
+
samples: list[float] = []
|
|
109
|
+
for i in range(len(names)):
|
|
110
|
+
n = up[i] + down[i]
|
|
111
|
+
samples.append(n)
|
|
112
|
+
if n < min_samples:
|
|
113
|
+
bias.append(0.0)
|
|
114
|
+
else:
|
|
115
|
+
r = (up[i] - down[i]) / n
|
|
116
|
+
bias.append(_clamp(-r * MAX_BIAS, -MAX_BIAS, MAX_BIAS))
|
|
117
|
+
|
|
118
|
+
total = sum(up) + sum(down)
|
|
119
|
+
if total > 0:
|
|
120
|
+
r_global = (sum(up) - sum(down)) / total
|
|
121
|
+
threshold_adjust = _clamp(r_global * MAX_THRESHOLD_ADJUST,
|
|
122
|
+
-MAX_THRESHOLD_ADJUST, MAX_THRESHOLD_ADJUST)
|
|
123
|
+
else:
|
|
124
|
+
threshold_adjust = 0.0
|
|
125
|
+
return Calibration(
|
|
126
|
+
bias=tuple(bias), # type: ignore[arg-type]
|
|
127
|
+
threshold_adjust=threshold_adjust,
|
|
128
|
+
samples=tuple(samples), # type: ignore[arg-type]
|
|
129
|
+
total=total,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_calibration(calibration: Calibration, path=None) -> None:
|
|
134
|
+
"""原子写 calibration.json(tmp+os.replace)。"""
|
|
135
|
+
from .store import ensure_dir
|
|
136
|
+
|
|
137
|
+
p = path or calibration_path()
|
|
138
|
+
if path is None:
|
|
139
|
+
ensure_dir()
|
|
140
|
+
atomic_write_json(p, calibration.to_dict())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_calibration(path=None) -> Calibration:
|
|
144
|
+
"""读取 calibration.json;缺失/损坏回退空校准,绝不抛错。"""
|
|
145
|
+
return Calibration.from_dict(read_json_safe(path or calibration_path()))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def recalibrate(log_path=None, cal_path=None) -> Calibration:
|
|
149
|
+
"""读 feedback.log → 聚合 → 落盘 calibration.json → 返回结果。
|
|
150
|
+
|
|
151
|
+
无记录时返回空校准且不写盘(保证"删掉 ~/.xg/adaptive/ 即回到
|
|
152
|
+
第 1 期行为"的验收:不重建目录)。
|
|
153
|
+
"""
|
|
154
|
+
records = read_feedback(log_path)
|
|
155
|
+
if not records:
|
|
156
|
+
return Calibration()
|
|
157
|
+
calibration = aggregate(records)
|
|
158
|
+
save_calibration(calibration, cal_path)
|
|
159
|
+
return calibration
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def apply_calibration(
|
|
163
|
+
tier_idx: int,
|
|
164
|
+
confidence: float,
|
|
165
|
+
hard_rule: bool,
|
|
166
|
+
calibration: Calibration,
|
|
167
|
+
) -> int:
|
|
168
|
+
"""置信门 + 档位偏置:返回校准后的档位索引。
|
|
169
|
+
|
|
170
|
+
只读纯函数,供 route() 在规则打分后、安全后处理前调用:
|
|
171
|
+
- 硬规则决策(confidence=1.0 的安全兜底)不受校准影响;
|
|
172
|
+
- bias == 0(样本不足或恰好均衡)不动;
|
|
173
|
+
- 对称双置信门(§6.5 的门公式按方向注释落地为双向):
|
|
174
|
+
偏弱档(bias<0):confidence + bias < 0.5 + threshold_adjust 时升一档;
|
|
175
|
+
偏强档(bias>0):confidence - bias < 0.5 + threshold_adjust 时降一档。
|
|
176
|
+
即 |bias| 把置信门的适用窗口向两侧对称放宽,threshold_adjust 整体平移。
|
|
177
|
+
单次最多移动一档,夹在 0..3,不会跳档。
|
|
178
|
+
"""
|
|
179
|
+
if hard_rule:
|
|
180
|
+
return tier_idx
|
|
181
|
+
bias = calibration.bias_of(tier_idx)
|
|
182
|
+
if bias == 0.0:
|
|
183
|
+
return tier_idx
|
|
184
|
+
gate = CONFIDENCE_BASE + calibration.threshold_adjust
|
|
185
|
+
if bias < 0:
|
|
186
|
+
if confidence + bias < gate:
|
|
187
|
+
return min(tier_idx + 1, 3)
|
|
188
|
+
else:
|
|
189
|
+
if confidence - bias < gate:
|
|
190
|
+
return max(tier_idx - 1, 0)
|
|
191
|
+
return tier_idx
|
xg/adaptive/feedback.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""隐式反馈信号:枚举、事件结构、内存缓冲 + 定时 flush 到 feedback.log(JSONL)。
|
|
2
|
+
|
|
3
|
+
设计依据:phase-03 步骤 A/B。
|
|
4
|
+
- A 步骤交付骨架:SignalType 枚举、FeedbackEvent、FeedbackRecorder 的
|
|
5
|
+
内存缓冲与 flush、read_feedback 读取。
|
|
6
|
+
- B 步骤才接入真实采集挂点;在此之前的 recorder 不会产生任何写入
|
|
7
|
+
(缓冲区为空时 flush 是 no-op)。
|
|
8
|
+
|
|
9
|
+
信号清单(四类可采集 + file_revert 预留位,本期不采集 file_revert):
|
|
10
|
+
- interrupt 用户在回答中途 Ctrl+C(upgrade,weight 0.9)
|
|
11
|
+
- clarify 上一轮后紧接追问/否定,且上轮档 ≤Superior(upgrade,weight 1.0)
|
|
12
|
+
- cmd_retry 紧接重试类输入(upgrade,weight 0.6)
|
|
13
|
+
- short_high_tier 简短闲聊却落到中高档(downgrade,weight 0.3)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import enum
|
|
19
|
+
import hashlib
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Iterable
|
|
24
|
+
|
|
25
|
+
from .store import append_jsonl, ensure_dir, feedback_log_path
|
|
26
|
+
|
|
27
|
+
DEFAULT_FLUSH_INTERVAL = 10.0 # 秒,缓冲不足或未到周期时不下盘
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SignalType(str, enum.Enum):
|
|
31
|
+
INTERRUPT = "interrupt"
|
|
32
|
+
CLARIFY = "clarify"
|
|
33
|
+
CMD_RETRY = "cmd_retry"
|
|
34
|
+
SHORT_HIGH_TIER = "short_high_tier"
|
|
35
|
+
FILE_REVERT = "file_revert" # 预留占位:本期不采集
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# 各信号的 vector:upgrade=True 表示该升档,False 表示该降档;weight 表示可信度
|
|
39
|
+
SIGNAL_META: dict[SignalType, dict[str, Any]] = {
|
|
40
|
+
SignalType.INTERRUPT: {"upgrade": True, "weight": 0.9},
|
|
41
|
+
SignalType.CLARIFY: {"upgrade": True, "weight": 1.0},
|
|
42
|
+
SignalType.CMD_RETRY: {"upgrade": True, "weight": 0.6},
|
|
43
|
+
SignalType.SHORT_HIGH_TIER: {"upgrade": False, "weight": 0.3},
|
|
44
|
+
SignalType.FILE_REVERT: {"upgrade": False, "weight": 0.5}, # 预留,未实现
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def text_hash(text: str) -> str:
|
|
49
|
+
"""对输入文本取短哈希,用于对账/去重,不存原文。"""
|
|
50
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class FeedbackEvent:
|
|
55
|
+
"""一条反馈记录,对应 feedback.log 的一行 JSON。"""
|
|
56
|
+
|
|
57
|
+
source: SignalType
|
|
58
|
+
model_tier: str # 触发信号时路出的档位名(如 "Superior")
|
|
59
|
+
session: str # 当前项目目录标识(可空,如无项目)
|
|
60
|
+
text_hash_val: str = ""
|
|
61
|
+
signal: str = "" # 冗余命中类型名,聚合时用
|
|
62
|
+
weight: float = 0.0
|
|
63
|
+
ts: float = field(default_factory=time.time)
|
|
64
|
+
features: dict | None = None # 当轮特征快照(第 4 期 A1 learned_rules 聚合用)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
d: dict[str, Any] = {
|
|
68
|
+
"ts": self.ts,
|
|
69
|
+
"session": self.session,
|
|
70
|
+
"source": self.source.value,
|
|
71
|
+
"signal": "upgrade" if self.signal == "upgrade" else
|
|
72
|
+
("downgrade" if self.signal == "downgrade" else ""),
|
|
73
|
+
"text_hash": self.text_hash_val,
|
|
74
|
+
"model_tier": self.model_tier,
|
|
75
|
+
"weight": self.weight,
|
|
76
|
+
}
|
|
77
|
+
if self.features: # 仅当存在时落盘,向后兼容(旧记录无此字段)
|
|
78
|
+
d["features"] = self.features
|
|
79
|
+
return d
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FeedbackRecorder:
|
|
83
|
+
"""内存缓冲 + 定时 flush 的反馈记录器。
|
|
84
|
+
|
|
85
|
+
用法:
|
|
86
|
+
rec = FeedbackRecorder(session="proj-abc")
|
|
87
|
+
rec.capture(SignalType.CLARIFY, model_tier="Superior")
|
|
88
|
+
# 定期调用 rec.flush(),或依赖 __del__ / 显式 flush
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
session: str = "",
|
|
94
|
+
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
|
|
95
|
+
log_path: Path | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.session = session
|
|
98
|
+
self.flush_interval = flush_interval
|
|
99
|
+
self._buffer: list[FeedbackEvent] = []
|
|
100
|
+
self._path = log_path or feedback_log_path()
|
|
101
|
+
|
|
102
|
+
def capture(
|
|
103
|
+
self,
|
|
104
|
+
source: SignalType,
|
|
105
|
+
model_tier: str,
|
|
106
|
+
text: str = "",
|
|
107
|
+
ts: float | None = None,
|
|
108
|
+
features: dict | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""入内存缓冲(不立即写盘)。"""
|
|
111
|
+
meta = SIGNAL_META[source]
|
|
112
|
+
ev = FeedbackEvent(
|
|
113
|
+
source=source,
|
|
114
|
+
model_tier=model_tier,
|
|
115
|
+
session=self.session,
|
|
116
|
+
text_hash_val=text_hash(text) if text else "",
|
|
117
|
+
signal="upgrade" if meta["upgrade"] else "downgrade",
|
|
118
|
+
weight=meta["weight"],
|
|
119
|
+
ts=ts if ts is not None else time.time(),
|
|
120
|
+
features=features,
|
|
121
|
+
)
|
|
122
|
+
self._buffer.append(ev)
|
|
123
|
+
|
|
124
|
+
def flush(self, force: bool = False) -> int:
|
|
125
|
+
"""把缓冲逐行追加写入 feedback.log,返回写入条数。
|
|
126
|
+
|
|
127
|
+
仅当 buffer 非空时写盘;返回值为本次追加的行数。
|
|
128
|
+
force 参数保留(语义:即便 buffered 条数不足也写),A 步骤无定时器,
|
|
129
|
+
调用方负责按 flush_interval 控制节奏。
|
|
130
|
+
"""
|
|
131
|
+
if not self._buffer:
|
|
132
|
+
return 0
|
|
133
|
+
ensure_dir()
|
|
134
|
+
n = 0
|
|
135
|
+
for ev in self._buffer:
|
|
136
|
+
append_jsonl(self._path, ev.to_dict())
|
|
137
|
+
n += 1
|
|
138
|
+
self._buffer.clear()
|
|
139
|
+
return n
|
|
140
|
+
|
|
141
|
+
def count(self) -> int:
|
|
142
|
+
"""当前缓冲区未 flush 的条数。"""
|
|
143
|
+
return len(self._buffer)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_feedback(path: Path | None = None) -> list[dict[str, Any]]:
|
|
147
|
+
"""读取 feedback.log 全部记录;文件不存在返回空列表,单行损坏跳过不抛错。"""
|
|
148
|
+
path = path or feedback_log_path()
|
|
149
|
+
if not path.exists():
|
|
150
|
+
return []
|
|
151
|
+
records: list[dict[str, Any]] = []
|
|
152
|
+
try:
|
|
153
|
+
with open(path, encoding="utf-8") as fh:
|
|
154
|
+
for line in fh:
|
|
155
|
+
line = line.strip()
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
records.append(_json_loads(line))
|
|
160
|
+
except Exception:
|
|
161
|
+
continue # 单行损坏跳过,不拖垮整体
|
|
162
|
+
except OSError:
|
|
163
|
+
return []
|
|
164
|
+
return records
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _json_loads(line: str) -> dict[str, Any]:
|
|
168
|
+
import json
|
|
169
|
+
|
|
170
|
+
return json.loads(line)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""自学习局部规则(phase-04 步骤 A1)。
|
|
2
|
+
|
|
3
|
+
从 feedback.log 聚合"特征谓词 → 信号方向"的高频规律,生成受限规则
|
|
4
|
+
(±1 档微调、置信度上限、支持度门槛),原子写 learned_rules.json。
|
|
5
|
+
|
|
6
|
+
与 calibration(第 3 期)的区别:calibration 是**全局**档位偏置,
|
|
7
|
+
对同一档位的所有输入一视同仁;learned_rules 是**局部**规则,只影响
|
|
8
|
+
命中某个特征谓词(如"含 debug 词""文本 ≤60 字")的输入。二者叠加生效,
|
|
9
|
+
learned_rules 注入点在 postprocess 的 6 条规则之后、防降级之后。
|
|
10
|
+
|
|
11
|
+
关键约(验收,见 phase-04 §7):
|
|
12
|
+
- 规则 action 恒为 ±1,不跳档;
|
|
13
|
+
- 永不覆盖风险/闲聊/长上下文硬规则(由 postprocess 的 forced 标志保证);
|
|
14
|
+
- 单条规则 support < MIN_SUPPORT 不生成;
|
|
15
|
+
- 多数派占比 < MIN_PRECISION 不生成(避免弱规则);
|
|
16
|
+
- 删除 learned_rules.json 即回到第 3 期行为。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Sequence
|
|
23
|
+
|
|
24
|
+
from .feedback import read_feedback
|
|
25
|
+
from .store import atomic_write_json, learned_rules_path, read_json_safe
|
|
26
|
+
|
|
27
|
+
MIN_SUPPORT = 20 # 单候选谓词加权样本量下限(与校准样本门槛一致)
|
|
28
|
+
MIN_PRECISION = 0.6 # 多数派占比下限(低于则不生成,避免弱规则)
|
|
29
|
+
MAX_CONFIDENCE = 0.9 # 规则置信度上限(受限规则不允许满置信)
|
|
30
|
+
MAX_RULES = 20 # 规则条数上限(按 support 降序保留,防爆炸)
|
|
31
|
+
|
|
32
|
+
# 特殊数值特征集合:非关键词类别计数,单独判定谓词
|
|
33
|
+
_SPECIAL_INT = ("has_attachment", "is_chatty", "question_mark", "num_code_blocks")
|
|
34
|
+
|
|
35
|
+
# 简短文本长度上限(len_chars 谓词阈值,与 signals 的 SHORT_TEXT_CHARS 一致)
|
|
36
|
+
_SHORT_CHARS = 60
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _predicate_key(feature: str, op: str, value: float) -> tuple[str, str, float]:
|
|
40
|
+
"""谓词的三元唯一标识(用于聚合去重)。"""
|
|
41
|
+
return (feature, op, value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _candidates(f: dict) -> list[tuple[str, str, float]]:
|
|
45
|
+
"""从一条特征的 features dict 提取其命中的候选谓词列表。
|
|
46
|
+
|
|
47
|
+
只描述"这一条满足什么条件":
|
|
48
|
+
- num_<cat>_kw 关键词类别计数 >0 → num_<cat>_kw >= 1
|
|
49
|
+
- 特殊整型特征 ==1 → <feature> >= 1
|
|
50
|
+
- len_chars <= _SHORT_CHARS 且 >0 → len_chars <= 60
|
|
51
|
+
|
|
52
|
+
不 import router 类别名(避免 adaptive→router 包环),谓词由
|
|
53
|
+
features dict 里实际存在的键动态推导。
|
|
54
|
+
"""
|
|
55
|
+
preds: list[tuple[str, str, float]] = []
|
|
56
|
+
for k, v in f.items():
|
|
57
|
+
if k == "len_chars":
|
|
58
|
+
if isinstance(v, (int, float)) and 0 < v <= _SHORT_CHARS:
|
|
59
|
+
preds.append((k, "<=", float(_SHORT_CHARS)))
|
|
60
|
+
elif k in _SPECIAL_INT:
|
|
61
|
+
if v == 1:
|
|
62
|
+
preds.append((k, ">=", 1.0))
|
|
63
|
+
elif k.startswith("num_") and k.endswith("_kw"):
|
|
64
|
+
if isinstance(v, (int, float)) and v >= 1:
|
|
65
|
+
preds.append((k, ">=", 1.0))
|
|
66
|
+
return preds
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class LearnedRule:
|
|
71
|
+
"""一条自学习规则。predicate 为单个特征谓词。"""
|
|
72
|
+
|
|
73
|
+
feature: str
|
|
74
|
+
op: str # ">=" 或 "<="
|
|
75
|
+
value: float
|
|
76
|
+
action: int # +1(相关输入升档)或 -1(降档)
|
|
77
|
+
confidence: float # 多数派占比,上限 MAX_CONFIDENCE
|
|
78
|
+
support: float # 加权样本量
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def predicate(self) -> dict[str, float]:
|
|
82
|
+
return {f"{self.feature}{self.op}": self.value}
|
|
83
|
+
|
|
84
|
+
def matches(self, f: dict) -> bool:
|
|
85
|
+
"""该记录的特征是否命中本条规则谓词。"""
|
|
86
|
+
v = f.get(self.feature)
|
|
87
|
+
if not isinstance(v, (int, float)):
|
|
88
|
+
return False
|
|
89
|
+
return (v >= self.value) if self.op == ">=" else (v <= self.value)
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"predicate": self.predicate,
|
|
94
|
+
"action": self.action,
|
|
95
|
+
"confidence": self.confidence,
|
|
96
|
+
"support": self.support,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class LearnedRules:
|
|
102
|
+
"""规则集。rules 按 support 降序排列,apply 取第一条命中的 action。"""
|
|
103
|
+
|
|
104
|
+
rules: tuple[LearnedRule, ...] = ()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def count(self) -> int:
|
|
108
|
+
return len(self.rules)
|
|
109
|
+
|
|
110
|
+
def apply(self, f: dict) -> int:
|
|
111
|
+
"""返回命中规则的 action(+1/-1/0=无命中)。只取第一条(最确信)。
|
|
112
|
+
|
|
113
|
+
同一 features 可能命中多条规则,取 support 最高的一条,不叠加,
|
|
114
|
+
保证单次最多偏移一档。
|
|
115
|
+
"""
|
|
116
|
+
for r in self.rules:
|
|
117
|
+
if r.matches(f):
|
|
118
|
+
return r.action
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict[str, Any]:
|
|
122
|
+
return {"rules": [r.to_dict() for r in self.rules]}
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def from_dict(data: dict[str, Any] | None) -> LearnedRules:
|
|
126
|
+
"""从持久化 JSON 恢复;结构异常回退空规则集(不抛错)。"""
|
|
127
|
+
if not isinstance(data, dict):
|
|
128
|
+
return LearnedRules()
|
|
129
|
+
try:
|
|
130
|
+
rules: list[LearnedRule] = []
|
|
131
|
+
for item in data.get("rules") or []:
|
|
132
|
+
pred = item.get("predicate") or {}
|
|
133
|
+
# predicate 格式 {"feature>=1": 1} 或 {"feature<=60": 60}
|
|
134
|
+
key = next(iter(pred)) if pred else None
|
|
135
|
+
value = float(pred.get(key, 0.0)) if key else 0.0
|
|
136
|
+
if not key:
|
|
137
|
+
continue
|
|
138
|
+
op = "<=" if "<=" in key else (">=" if ">=" in key else "")
|
|
139
|
+
feature = key.split(op)[0] if op else ""
|
|
140
|
+
if not feature or op not in (">=", "<="):
|
|
141
|
+
continue
|
|
142
|
+
# 归一化 value 为谓词里声明的值
|
|
143
|
+
value = float(pred.get(key, value))
|
|
144
|
+
rules.append(LearnedRule(
|
|
145
|
+
feature=feature, op=op, value=value,
|
|
146
|
+
action=int(item.get("action", 0)),
|
|
147
|
+
confidence=float(item.get("confidence", 0.0)),
|
|
148
|
+
support=float(item.get("support", 0.0)),
|
|
149
|
+
))
|
|
150
|
+
rules.sort(key=lambda r: (-r.support, r.feature))
|
|
151
|
+
return LearnedRules(tuple(rules)) # type: ignore[arg-type]
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
return LearnedRules()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def aggregate(
|
|
157
|
+
records: Sequence[dict[str, Any]],
|
|
158
|
+
min_support: float = MIN_SUPPORT,
|
|
159
|
+
min_precision: float = MIN_PRECISION,
|
|
160
|
+
max_confidence: float = MAX_CONFIDENCE,
|
|
161
|
+
max_rules: int = MAX_RULES,
|
|
162
|
+
) -> LearnedRules:
|
|
163
|
+
"""聚合 feedback.log → 规则集。
|
|
164
|
+
|
|
165
|
+
对每条带 features 的记录,生成其命中的候选谓词,按"upgrade/downgrade
|
|
166
|
+
加权和"累计每个谓词的支持度;支持度过门槛且多数派占比清晰才生成规则。
|
|
167
|
+
旧记录(无 features 字段)跳过不计,保证向后兼容。
|
|
168
|
+
"""
|
|
169
|
+
# 谓词唯一键 -> [up, down]
|
|
170
|
+
stats: dict[tuple[str, str, float], list[float]] = {}
|
|
171
|
+
for rec in records:
|
|
172
|
+
feats = rec.get("features")
|
|
173
|
+
if not isinstance(feats, dict) or not feats:
|
|
174
|
+
continue
|
|
175
|
+
direction = rec.get("signal")
|
|
176
|
+
if direction not in ("upgrade", "downgrade"):
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
weight = float(rec.get("weight", 0.0))
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
continue
|
|
182
|
+
for pred in _candidates(feats):
|
|
183
|
+
bucket = stats.setdefault(pred, [0.0, 0.0])
|
|
184
|
+
if direction == "upgrade":
|
|
185
|
+
bucket[0] += weight
|
|
186
|
+
else:
|
|
187
|
+
bucket[1] += weight
|
|
188
|
+
|
|
189
|
+
rules: list[LearnedRule] = []
|
|
190
|
+
for (feature, op, value), (up, down) in stats.items():
|
|
191
|
+
n = up + down
|
|
192
|
+
if n < min_support:
|
|
193
|
+
continue
|
|
194
|
+
# 多数派占比;up > down -> 升规则,down > up -> 降规则
|
|
195
|
+
if up > down:
|
|
196
|
+
prec = up / n
|
|
197
|
+
action = 1
|
|
198
|
+
elif down > up:
|
|
199
|
+
prec = down / n
|
|
200
|
+
action = -1
|
|
201
|
+
else:
|
|
202
|
+
continue # 完全对半,无方向
|
|
203
|
+
if prec < min_precision:
|
|
204
|
+
continue
|
|
205
|
+
rules.append(LearnedRule(
|
|
206
|
+
feature=feature, op=op, value=value, action=action,
|
|
207
|
+
confidence=min(prec, max_confidence), support=n,
|
|
208
|
+
))
|
|
209
|
+
rules.sort(key=lambda r: (-r.support, r.feature))
|
|
210
|
+
return LearnedRules(tuple(rules[:max_rules])) # type: ignore[arg-type]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def save_learned_rules(rules: LearnedRules, path=None) -> None:
|
|
214
|
+
"""原子写 learned_rules.json。空规则集也写(清空语义)。"""
|
|
215
|
+
from .store import ensure_dir
|
|
216
|
+
|
|
217
|
+
p = path or learned_rules_path()
|
|
218
|
+
if path is None:
|
|
219
|
+
ensure_dir()
|
|
220
|
+
atomic_write_json(p, rules.to_dict())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_learned_rules(path=None) -> LearnedRules:
|
|
224
|
+
"""读取 learned_rules.json;缺失/损坏回退空规则集,绝不抛错。"""
|
|
225
|
+
return LearnedRules.from_dict(read_json_safe(path or learned_rules_path()))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def re_learn(log_path=None, rules_path=None) -> LearnedRules:
|
|
229
|
+
"""读 feedback.log → 聚合 → 落盘 learned_rules.json → 返回结果。
|
|
230
|
+
|
|
231
|
+
无记录时返回空规则集且不写盘(沿用第 3 期"删掉 ~/.xg/adaptive/ 即
|
|
232
|
+
回到纯规则行为"的验收:不重建目录)。
|
|
233
|
+
"""
|
|
234
|
+
records = read_feedback(log_path)
|
|
235
|
+
if not records:
|
|
236
|
+
return LearnedRules()
|
|
237
|
+
rules = aggregate(records)
|
|
238
|
+
save_learned_rules(rules, rules_path)
|
|
239
|
+
return rules
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def rule_hit_stats(
|
|
243
|
+
records: Sequence[dict[str, Any]], rules: LearnedRules,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""统计规则集在 feedback.log 上的命中情况,供 `/smartRouter status` 展示。
|
|
246
|
+
|
|
247
|
+
返回 {"rule_count", "sample_records", "hit_records", "per_rule"}:
|
|
248
|
+
- rule_count:规则条数;
|
|
249
|
+
- sample_records:带 features 的可命中样本记录数;
|
|
250
|
+
- hit_records:命中至少一条规则的样本记录数;
|
|
251
|
+
- per_rule:每条规则的 predicate/action/confidence/support 及命中次数
|
|
252
|
+
(一条记录可能命中多条,各自累计)。
|
|
253
|
+
"""
|
|
254
|
+
per: dict[int, int] = {id(r): 0 for r in rules.rules}
|
|
255
|
+
sample_records = 0
|
|
256
|
+
hit_records = 0
|
|
257
|
+
for rec in records:
|
|
258
|
+
feats = rec.get("features")
|
|
259
|
+
if not isinstance(feats, dict) or not feats:
|
|
260
|
+
continue
|
|
261
|
+
sample_records += 1
|
|
262
|
+
hit_any = False
|
|
263
|
+
for r in rules.rules:
|
|
264
|
+
if r.matches(feats):
|
|
265
|
+
per[id(r)] += 1
|
|
266
|
+
hit_any = True
|
|
267
|
+
if hit_any:
|
|
268
|
+
hit_records += 1
|
|
269
|
+
per_rule = [
|
|
270
|
+
{
|
|
271
|
+
"predicate": r.predicate,
|
|
272
|
+
"action": r.action,
|
|
273
|
+
"confidence": r.confidence,
|
|
274
|
+
"support": r.support,
|
|
275
|
+
"hits": per[id(r)],
|
|
276
|
+
}
|
|
277
|
+
for r in rules.rules
|
|
278
|
+
]
|
|
279
|
+
return {
|
|
280
|
+
"rule_count": rules.count,
|
|
281
|
+
"sample_records": sample_records,
|
|
282
|
+
"hit_records": hit_records,
|
|
283
|
+
"per_rule": per_rule,
|
|
284
|
+
}
|