@heytherevibin/skillforge 0.7.0 → 0.10.0

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,312 @@
1
+ /**
2
+ * Detect MCP-friendly editors and install global slash-command files (Cursor, Claude Code).
3
+ */
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const MANAGED_SUBSTRING = 'skillforge-managed';
10
+
11
+ /** @returns {string} */
12
+ function homeDir() {
13
+ return os.homedir();
14
+ }
15
+
16
+ /** @returns {string} */
17
+ function claudeConfigBase() {
18
+ const override = process.env.CLAUDE_CONFIG_DIR;
19
+ if (override && String(override).trim()) {
20
+ return String(override).trim();
21
+ }
22
+ return path.join(homeDir(), '.claude');
23
+ }
24
+
25
+ function claudeCliOnPath() {
26
+ try {
27
+ const isWin = process.platform === 'win32';
28
+ const bin = isWin ? 'where' : 'which';
29
+ const r = spawnSync(bin, ['claude'], { encoding: 'utf8', stdio: 'pipe', timeout: 4000 });
30
+ return r.status === 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @returns {{ id: string, label: string, detail: string }[]}
38
+ */
39
+ function detectMcpFriendlyHosts() {
40
+ const h = homeDir();
41
+ /** @type {{ id: string, label: string, detail: string }[]} */
42
+ const out = [];
43
+
44
+ const cursorDir = path.join(h, '.cursor');
45
+ if (fs.existsSync(cursorDir)) {
46
+ out.push({
47
+ id: 'cursor',
48
+ label: 'Cursor',
49
+ detail: `~/.cursor (mcp.json, commands/)`,
50
+ });
51
+ }
52
+
53
+ const claudeJson = path.join(h, '.claude.json');
54
+ const ccSettings = path.join(claudeConfigBase(), 'settings.json');
55
+ if (fs.existsSync(claudeJson) || fs.existsSync(ccSettings) || claudeCliOnPath()) {
56
+ out.push({
57
+ id: 'claude-code',
58
+ label: 'Claude Code',
59
+ detail: fs.existsSync(claudeJson)
60
+ ? '~/.claude.json + ~/.claude/'
61
+ : '~/.claude/ (add MCP via claude mcp or .mcp.json)',
62
+ });
63
+ }
64
+
65
+ if (process.platform === 'darwin') {
66
+ const claude = path.join(h, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
67
+ if (fs.existsSync(claude)) {
68
+ out.push({
69
+ id: 'claude-desktop',
70
+ label: 'Claude Desktop',
71
+ detail: '~/Library/Application Support/Claude/',
72
+ });
73
+ }
74
+ const cursorApp = path.join('/Applications', 'Cursor.app');
75
+ const cursorAppUser = path.join(h, 'Applications', 'Cursor.app');
76
+ if (fs.existsSync(cursorApp) || fs.existsSync(cursorAppUser)) {
77
+ if (!out.some(x => x.id === 'cursor' || x.id === 'cursor-app')) {
78
+ out.push({
79
+ id: 'cursor-app',
80
+ label: 'Cursor (app)',
81
+ detail: 'Cursor.app installed — use Cursor MCP settings if ~/.cursor is missing',
82
+ });
83
+ }
84
+ }
85
+ } else if (process.platform === 'win32') {
86
+ const appData = process.env.APPDATA || path.join(h, 'AppData', 'Roaming');
87
+ const claudeWin = path.join(appData, 'Claude', 'claude_desktop_config.json');
88
+ if (fs.existsSync(claudeWin)) {
89
+ out.push({
90
+ id: 'claude-desktop',
91
+ label: 'Claude Desktop',
92
+ detail: path.join(appData, 'Claude'),
93
+ });
94
+ }
95
+ }
96
+
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * @returns {boolean}
102
+ */
103
+ function looksLikeCursorEnvironment() {
104
+ const h = homeDir();
105
+ if (fs.existsSync(path.join(h, '.cursor'))) return true;
106
+ if (process.platform === 'darwin') {
107
+ if (fs.existsSync(path.join('/Applications', 'Cursor.app'))) return true;
108
+ if (fs.existsSync(path.join(h, 'Applications', 'Cursor.app'))) return true;
109
+ }
110
+ if (process.env.SKILLFORGE_CURSOR_GLOBAL_COMMAND === '1' || process.env.SKILLFORGE_CURSOR_GLOBAL_COMMAND === 'true') {
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * @returns {boolean}
118
+ */
119
+ function looksLikeClaudeCodeEnvironment() {
120
+ const h = homeDir();
121
+ if (fs.existsSync(path.join(h, '.claude.json'))) return true;
122
+ const base = claudeConfigBase();
123
+ if (fs.existsSync(path.join(base, 'settings.json'))) return true;
124
+ if (claudeCliOnPath()) return true;
125
+ if (process.env.SKILLFORGE_CLAUDE_CODE_GLOBAL_COMMAND === '1' || process.env.SKILLFORGE_CLAUDE_CODE_GLOBAL_COMMAND === 'true') {
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * @param {{ force?: boolean, log?: (msg: string) => void, err?: (msg: string) => void, pkgRoot: string, pkgVersion: string }} opts
133
+ * @returns {{ wrote: boolean, path: string | null, skippedReason: string | null }}
134
+ */
135
+ function installGlobalCursorSkillforgeCommand(opts) {
136
+ const log = opts.log || (() => {});
137
+ const errFn = opts.err || log;
138
+ const force = !!opts.force;
139
+ const skip = process.env.SKILLFORGE_SKIP_CURSOR_SETUP === '1' || process.env.SKILLFORGE_SKIP_CURSOR_SETUP === 'true';
140
+ if (skip) {
141
+ return { wrote: false, path: null, skippedReason: 'SKILLFORGE_SKIP_CURSOR_SETUP' };
142
+ }
143
+
144
+ if (!looksLikeCursorEnvironment()) {
145
+ return { wrote: false, path: null, skippedReason: 'cursor_not_detected' };
146
+ }
147
+
148
+ const cmdDir = path.join(homeDir(), '.cursor', 'commands');
149
+ const cmdFile = path.join(cmdDir, 'skillforge.md');
150
+
151
+ try {
152
+ if (fs.existsSync(cmdFile) && !force) {
153
+ const prev = fs.readFileSync(cmdFile, 'utf8');
154
+ if (!prev.includes(MANAGED_SUBSTRING)) {
155
+ log(
156
+ `Cursor: left ${cmdFile} unchanged (custom file; not skillforge-managed). Replace with: skillforge hosts init --force`
157
+ );
158
+ return { wrote: false, path: cmdFile, skippedReason: 'custom_file' };
159
+ }
160
+ }
161
+
162
+ const tplPath = path.join(opts.pkgRoot, 'lib', 'templates', 'cursor-skillforge-global.md');
163
+ if (!fs.existsSync(tplPath)) {
164
+ errFn(`Cursor: template missing at ${tplPath}`);
165
+ return { wrote: false, path: null, skippedReason: 'no_template' };
166
+ }
167
+ let body = fs.readFileSync(tplPath, 'utf8');
168
+ body = body.replace(/PACKAGE_VERSION/g, opts.pkgVersion);
169
+
170
+ fs.mkdirSync(cmdDir, { recursive: true });
171
+ fs.writeFileSync(cmdFile, body, 'utf8');
172
+ return { wrote: true, path: cmdFile, skippedReason: null };
173
+ } catch (e) {
174
+ errFn(`Cursor: could not write ${cmdFile}: ${/** @type {Error} */ (e).message}`);
175
+ return { wrote: false, path: cmdFile, skippedReason: 'write_error' };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * @param {{ force?: boolean, log?: (msg: string) => void, err?: (msg: string) => void, pkgRoot: string, pkgVersion: string }} opts
181
+ * @returns {{ wrote: boolean, path: string | null, skippedReason: string | null }}
182
+ */
183
+ function installGlobalClaudeCodeSkillforgeCommand(opts) {
184
+ const log = opts.log || (() => {});
185
+ const errFn = opts.err || log;
186
+ const force = !!opts.force;
187
+ const skip = process.env.SKILLFORGE_SKIP_CLAUDE_CODE_SETUP === '1' || process.env.SKILLFORGE_SKIP_CLAUDE_CODE_SETUP === 'true';
188
+ if (skip) {
189
+ return { wrote: false, path: null, skippedReason: 'SKILLFORGE_SKIP_CLAUDE_CODE_SETUP' };
190
+ }
191
+
192
+ if (!looksLikeClaudeCodeEnvironment()) {
193
+ return { wrote: false, path: null, skippedReason: 'claude_code_not_detected' };
194
+ }
195
+
196
+ const base = claudeConfigBase();
197
+ const cmdDir = path.join(base, 'commands');
198
+ const cmdFile = path.join(cmdDir, 'skillforge.md');
199
+
200
+ try {
201
+ if (fs.existsSync(cmdFile) && !force) {
202
+ const prev = fs.readFileSync(cmdFile, 'utf8');
203
+ if (!prev.includes(MANAGED_SUBSTRING)) {
204
+ log(
205
+ `Claude Code: left ${cmdFile} unchanged (custom file; not skillforge-managed). Replace with: skillforge hosts init --force`
206
+ );
207
+ return { wrote: false, path: cmdFile, skippedReason: 'custom_file' };
208
+ }
209
+ }
210
+
211
+ const tplPath = path.join(opts.pkgRoot, 'lib', 'templates', 'claude-code-skillforge-global.md');
212
+ if (!fs.existsSync(tplPath)) {
213
+ errFn(`Claude Code: template missing at ${tplPath}`);
214
+ return { wrote: false, path: null, skippedReason: 'no_template' };
215
+ }
216
+ let body = fs.readFileSync(tplPath, 'utf8');
217
+ body = body.replace(/PACKAGE_VERSION/g, opts.pkgVersion);
218
+
219
+ fs.mkdirSync(cmdDir, { recursive: true });
220
+ fs.writeFileSync(cmdFile, body, 'utf8');
221
+ return { wrote: true, path: cmdFile, skippedReason: null };
222
+ } catch (e) {
223
+ errFn(`Claude Code: could not write ${cmdFile}: ${/** @type {Error} */ (e).message}`);
224
+ return { wrote: false, path: cmdFile, skippedReason: 'write_error' };
225
+ }
226
+ }
227
+
228
+ /**
229
+ * @param {{ force?: boolean, log?: typeof console.error, ok?: typeof console.error, err?: typeof console.error, dim?: (s: string) => void, pkgRoot: string, pkgVersion: string }} opts
230
+ */
231
+ function reportHostsAndInstallAgentCommands(opts) {
232
+ const log = opts.log || console.error;
233
+ const ok = opts.ok || console.error;
234
+ const errLine = opts.err || log;
235
+ const dim =
236
+ opts.dim ||
237
+ ((s) => {
238
+ console.error(s);
239
+ });
240
+
241
+ const hosts = detectMcpFriendlyHosts();
242
+ if (hosts.length > 0) {
243
+ log('');
244
+ ok(`Detected MCP-friendly environment(s): ${hosts.map(h => h.label).join(', ')}`);
245
+ hosts.forEach(h => dim(` • ${h.label}: ${h.detail}`));
246
+ }
247
+ if (hosts.some(h => h.id === 'claude-desktop')) {
248
+ dim(' Tip (Claude Desktop): merge `skillforge mcp config` into claude_desktop_config.json');
249
+ }
250
+ if (hosts.some(h => h.id === 'claude-code')) {
251
+ dim(
252
+ ' Tip (Claude Code): add skillforge MCP (`skillforge mcp config`, project `.mcp.json`, or `claude mcp`). Global `/skillforge` → ~/.claude/commands/skillforge.md'
253
+ );
254
+ }
255
+
256
+ const rCur = installGlobalCursorSkillforgeCommand({
257
+ force: opts.force,
258
+ log,
259
+ err: errLine,
260
+ pkgRoot: opts.pkgRoot,
261
+ pkgVersion: opts.pkgVersion,
262
+ });
263
+
264
+ const rCc = installGlobalClaudeCodeSkillforgeCommand({
265
+ force: opts.force,
266
+ log,
267
+ err: errLine,
268
+ pkgRoot: opts.pkgRoot,
269
+ pkgVersion: opts.pkgVersion,
270
+ });
271
+
272
+ if (rCur.skippedReason === 'cursor_not_detected') {
273
+ dim('');
274
+ dim(
275
+ 'Cursor: ~/.cursor not found. For global /skillforge: SKILLFORGE_CURSOR_GLOBAL_COMMAND=1 skillforge install (or skillforge hosts init)'
276
+ );
277
+ } else if (rCur.skippedReason === 'SKILLFORGE_SKIP_CURSOR_SETUP') {
278
+ dim('Cursor integration skipped (SKILLFORGE_SKIP_CURSOR_SETUP).');
279
+ }
280
+ if (rCur.wrote && rCur.path) {
281
+ ok(`Wrote Cursor command: ${rCur.path} (use /skillforge in chat)`);
282
+ }
283
+
284
+ if (rCc.skippedReason === 'claude_code_not_detected') {
285
+ dim('');
286
+ dim(
287
+ 'Claude Code not detected (~/.claude.json, ~/.claude/settings.json, or `claude` on PATH). For global /skillforge: SKILLFORGE_CLAUDE_CODE_GLOBAL_COMMAND=1 skillforge install (or skillforge hosts init)'
288
+ );
289
+ } else if (rCc.skippedReason === 'SKILLFORGE_SKIP_CLAUDE_CODE_SETUP') {
290
+ dim('Claude Code integration skipped (SKILLFORGE_SKIP_CLAUDE_CODE_SETUP).');
291
+ }
292
+ if (rCc.wrote && rCc.path) {
293
+ ok(`Wrote Claude Code command/skill: ${rCc.path} (use /skillforge in Terminal/IDE)`);
294
+ }
295
+ }
296
+
297
+ /** @deprecated use reportHostsAndInstallAgentCommands */
298
+ function reportHostsAndInstallCursorCommand(opts) {
299
+ reportHostsAndInstallAgentCommands(opts);
300
+ }
301
+
302
+ module.exports = {
303
+ detectMcpFriendlyHosts,
304
+ claudeConfigBase,
305
+ looksLikeCursorEnvironment,
306
+ looksLikeClaudeCodeEnvironment,
307
+ installGlobalCursorSkillforgeCommand,
308
+ installGlobalClaudeCodeSkillforgeCommand,
309
+ reportHostsAndInstallAgentCommands,
310
+ reportHostsAndInstallCursorCommand,
311
+ MANAGED_SUBSTRING,
312
+ };
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Run Skillforge MCP (route_skills) to load routed SKILL.md context. Use when the user invokes /skillforge, asks for Skillforge, or needs catalog skills for the repo.
3
+ ---
4
+
5
+ <!-- skillforge-managed vPACKAGE_VERSION — remove this line to stop auto-updates from `skillforge install` -->
6
+
7
+ # Skillforge — route SKILL.md context (MCP)
8
+
9
+ Global **`/skillforge`** for **Claude Code** (same mechanism as `.claude/commands/*.md`). Configure the **skillforge** MCP server (see **`skillforge mcp config`**, project **`.mcp.json`**, or `claude mcp`). Optional: **`skillforge health`**, **`skillforge route-eval`**, **`skillforge weights export|import`**. MCP **`_meta.feedback_effect`** explains learned-ranking bias for picked skills when present.
10
+
11
+ ## Do this
12
+
13
+ 1. **`route_skills`** (MCP): pass **`project_root`** as the **current project root** (absolute path) so SQLite lives in **`<project>/.skillforge/`**. Pass the **user's task** as **`prompt`**. Reuse **`session_id`** across turns when the MCP returns it.
14
+
15
+ - **`SKILLFORGE_ROUTER_MODE=host`**: first call **without** **`picked_names`** (shortlist only); second call **with** **`picked_names`**.
16
+
17
+ 2. **Use the returned skill text** in your answer.
18
+
19
+ 3. **Per-repo list:** run **`materialize_project`** after **`route_skills`** to refresh **`.claude/commands/skillforge.md`** and **`CLAUDE.md`**.
@@ -0,0 +1,16 @@
1
+ <!-- skillforge-managed vPACKAGE_VERSION — remove this line to stop auto-updates from `skillforge install` -->
2
+
3
+ # Skillforge — route SKILL.md context (MCP)
4
+
5
+ Global **`/skillforge`** command. Use the **skillforge** MCP server (configure in **`~/.cursor/mcp.json`** or your host; run **`skillforge mcp config`** for a JSON snippet). For local checks: **`skillforge health`** (preflight), **`skillforge route-eval`** (embedding routing smoke tests), and **`skillforge weights export|import`** (backup learned weights). **`route_skills`** responses can include **`_meta.feedback_effect`** (how thumbs / uses bias ranking for picked skills).
6
+
7
+ ## Do this
8
+
9
+ 1. **`route_skills`** (MCP): pass **`project_root`** as the **current workspace root** (absolute path) so SQLite lives in **`<workspace>/.skillforge/`**. Pass the **user's task** as **`prompt`**. Reuse **`session_id`** across turns when the tool returns one.
10
+
11
+ - **`SKILLFORGE_ROUTER_MODE=host`**: call once **without** **`picked_names`** (shortlist only); then call again with **`picked_names`** (exact catalog ids) to load skill context.
12
+ - Optional: **`conversation`** when recent turns should influence routing.
13
+
14
+ 2. **Use the returned skill text** in your answer.
15
+
16
+ 3. **Project-specific lists:** run **`materialize_project`** in a repo (after **`route_skills`**) to write **`.cursor/commands/skillforge.md`**, **`.cursor/rules/skillforge.mdc`**, and **`docs/SKILLFORGE-PRD.md`** with the latest **`skill_names`**.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heytherevibin/skillforge",
3
- "version": "0.7.0",
4
- "description": "Skill orchestration for Claude: hybrid embedding and router-based routing, MCP stdio server, per-user learning, and a large bundled SKILL.md catalog.",
3
+ "version": "0.10.0",
4
+ "description": "SKILL.md orchestration for AI agents: MCP-first routing with local embeddings, optional LLM stages, project RAG, policy overlays, portable learning state, and auditable telemetry.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "skills",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "bin/",
20
+ "ci/",
20
21
  "lib/",
21
22
  "python/",
22
23
  "skills/",
@@ -0,0 +1,133 @@
1
+ """Run route quality eval fixtures (deterministic embedding routing)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
14
+ p = argparse.ArgumentParser(
15
+ description=(
16
+ "Evaluate route_skills-style routing against a JSON fixture "
17
+ "(defaults to SKILLFORGE_ROUTER_MODE=embedding for stable retrieval)."
18
+ )
19
+ )
20
+ p.add_argument(
21
+ "--fixture",
22
+ "-f",
23
+ type=Path,
24
+ required=True,
25
+ help="Path to JSON fixture (cases[].prompt, expect_in_candidates, …).",
26
+ )
27
+ p.add_argument(
28
+ "--router-mode",
29
+ default="embedding",
30
+ help="Router mode for this process (set before loading app.main). Default: embedding",
31
+ )
32
+ p.add_argument(
33
+ "--json",
34
+ action="store_true",
35
+ help="Print one JSON object per line to stdout (cases + errors); stderr stays human.",
36
+ )
37
+ return p.parse_args(argv)
38
+
39
+
40
+ async def _run_case(
41
+ *,
42
+ con,
43
+ router,
44
+ case: dict,
45
+ defaults: dict,
46
+ ) -> list[str]:
47
+ from app.main import run_route_turn
48
+
49
+ prompt = (case.get("prompt") or "").strip()
50
+ if not prompt:
51
+ return [f"{case.get('id', '?')}: empty prompt"]
52
+
53
+ result = await run_route_turn(
54
+ con,
55
+ router,
56
+ prompt,
57
+ conversation=[],
58
+ user_id="__eval__",
59
+ session_id=None,
60
+ project_root=None,
61
+ include_project_rag=False,
62
+ picked_names_from_host=None,
63
+ picked_names_from_host_supplied=False,
64
+ )
65
+
66
+ from app.route_eval_harness import evaluate_case_result
67
+
68
+ return evaluate_case_result(result, case, defaults=defaults)
69
+
70
+
71
+ async def _async_main(args: argparse.Namespace) -> int:
72
+ fixture_path = args.fixture.expanduser().resolve()
73
+ if not fixture_path.is_file():
74
+ print(f"skillforge route-eval: fixture not found: {fixture_path}", file=sys.stderr)
75
+ return 2
76
+
77
+ os.environ["SKILLFORGE_ROUTER_MODE"] = args.router_mode.strip().lower()
78
+
79
+ from app.route_eval_harness import load_eval_fixture
80
+ from app.main import build_router_and_skills, init_db
81
+
82
+ data = load_eval_fixture(fixture_path)
83
+ defaults = data["defaults"] if isinstance(data.get("defaults"), dict) else {}
84
+ cases = data["cases"]
85
+
86
+ fd, tmp_name = tempfile.mkstemp(suffix=".sqlite", prefix="skillforge-eval-")
87
+ os.close(fd)
88
+ db_path = Path(tmp_name)
89
+ try:
90
+ con = init_db(db_path)
91
+ try:
92
+ router, _skills = await asyncio.to_thread(
93
+ build_router_and_skills,
94
+ log=not args.json,
95
+ log_prefix="[skillforge-eval]",
96
+ )
97
+ all_errs: list[str] = []
98
+ summaries: list[dict] = []
99
+ for case in cases:
100
+ if not isinstance(case, dict):
101
+ all_errs.append("non-dict case entry")
102
+ continue
103
+ errs = await _run_case(con=con, router=router, case=case, defaults=defaults)
104
+ cid = case.get("id") or case.get("name") or "?"
105
+ summaries.append({"id": cid, "ok": not errs, "errors": errs})
106
+ all_errs.extend(errs)
107
+ if errs and not args.json:
108
+ for e in errs:
109
+ print(e, file=sys.stderr)
110
+ elif not errs and not args.json:
111
+ print(f"ok {cid}", file=sys.stderr)
112
+
113
+ if args.json:
114
+ out = {"fixture": str(fixture_path), "cases": summaries, "failed": len(all_errs)}
115
+ print(json.dumps(out, indent=2))
116
+
117
+ return 1 if all_errs else 0
118
+ finally:
119
+ con.close()
120
+ finally:
121
+ try:
122
+ db_path.unlink(missing_ok=True)
123
+ except OSError:
124
+ pass
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> None:
128
+ args = _parse_args(argv)
129
+ raise SystemExit(asyncio.run(_async_main(args)))
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
@@ -0,0 +1,96 @@
1
+ """Transparency for per-user learning: how feedback stats affect routing scores."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from typing import Any
6
+
7
+
8
+ def get_skill_weight_detail(con: sqlite3.Connection, skill_name: str, user_id: str = "") -> dict[str, Any] | None:
9
+ cur = con.execute(
10
+ """
11
+ SELECT weight, uses, referenced, thumbs_up, thumbs_down, disabled, updated_at
12
+ FROM skill_weights WHERE user_id = ? AND skill_name = ?
13
+ """,
14
+ (user_id, skill_name),
15
+ )
16
+ row = cur.fetchone()
17
+ if not row:
18
+ return None
19
+ w, uses, ref, up, down, dis, ts = row
20
+ uses_i, ref_i = int(uses), int(ref)
21
+ up_i, down_i = int(up), int(down)
22
+ ref_rate = (ref_i / uses_i) if uses_i > 0 else 0.0
23
+ return {
24
+ "learned_weight": round(float(w), 4),
25
+ "uses": uses_i,
26
+ "referenced": ref_i,
27
+ "thumbs_up": up_i,
28
+ "thumbs_down": down_i,
29
+ "net_thumbs": up_i - down_i,
30
+ "reference_rate": round(float(ref_rate), 4),
31
+ "disabled": bool(dis),
32
+ "updated_at": float(ts) if ts is not None else None,
33
+ }
34
+
35
+
36
+ def build_feedback_effect(
37
+ con: sqlite3.Connection,
38
+ picked_names: list[str],
39
+ user_id: str = "",
40
+ ) -> dict[str, Any]:
41
+ """JSON-serializable snapshot of learning stats for picked skills (after this route's use counts)."""
42
+ seen: set[str] = set()
43
+ ordered: list[str] = []
44
+ for n in picked_names:
45
+ if n not in seen:
46
+ seen.add(n)
47
+ ordered.append(n)
48
+
49
+ picked_out: list[dict[str, Any]] = []
50
+ nonzero = 0
51
+ max_abs = 0.0
52
+
53
+ for name in ordered:
54
+ row = get_skill_weight_detail(con, name, user_id=user_id)
55
+ if row is None:
56
+ picked_out.append({
57
+ "skill": name,
58
+ "has_db_row": False,
59
+ "learned_weight": 0.0,
60
+ "uses": 0,
61
+ "referenced": 0,
62
+ "thumbs_up": 0,
63
+ "thumbs_down": 0,
64
+ "net_thumbs": 0,
65
+ "reference_rate": None,
66
+ "disabled": False,
67
+ })
68
+ continue
69
+ lw = float(row["learned_weight"])
70
+ if abs(lw) > 1e-9:
71
+ nonzero += 1
72
+ max_abs = max(max_abs, abs(lw))
73
+ picked_out.append({
74
+ "skill": name,
75
+ "has_db_row": True,
76
+ "learned_weight": row["learned_weight"],
77
+ "uses": row["uses"],
78
+ "referenced": row["referenced"],
79
+ "thumbs_up": row["thumbs_up"],
80
+ "thumbs_down": row["thumbs_down"],
81
+ "net_thumbs": row["net_thumbs"],
82
+ "reference_rate": row["reference_rate"],
83
+ "disabled": row["disabled"],
84
+ })
85
+
86
+ return {
87
+ "schema": "feedback_effect/1",
88
+ "weight_formula": "weight = (referenced/uses - 0.5) * 0.3 + (thumbs_up - thumbs_down) * 0.1; reference_rate=referenced/uses if uses>0 else 0",
89
+ "routing_applies": "rank_score += learned_weight (disabled skills get large negative score)",
90
+ "picked": picked_out,
91
+ "summary": {
92
+ "picked_count": len(ordered),
93
+ "picked_with_nonzero_learned_weight": nonzero,
94
+ "max_abs_learned_weight": round(float(max_abs), 4),
95
+ },
96
+ }