ai-consultants 2.19.2 → 2.20.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.
- package/.claude/commands/ai-consultants:help.md +1 -0
- package/.claude/commands/ai-consultants:roster-audit.md +38 -0
- package/CLAUDE.md +20 -1
- package/README.md +2 -2
- package/SKILL.md +2 -2
- package/docs/COST_RATES.md +1 -1
- package/docs/SMART_ROUTING.md +115 -1
- package/docs/cost_rates.json +1 -1
- package/docs/releases/v2.20.0.md +65 -0
- package/package.json +1 -1
- package/references/affinity.json +31 -1
- package/references/calibration_benchmark.json +65 -0
- package/references/configuration.md +17 -0
- package/scripts/config.sh +13 -1
- package/scripts/consult_all.sh +8 -0
- package/scripts/doctor.sh +15 -0
- package/scripts/lib/routing.sh +75 -2
- package/scripts/lib/voting.sh +58 -7
- package/scripts/roster_audit.sh +157 -0
- package/scripts/roster_calibrate.sh +217 -0
- package/scripts/run_calibration.sh +76 -0
- package/scripts/taste_elo.sh +171 -0
- package/scripts/test_capability_weighting.sh +135 -0
- package/scripts/test_roster_audit.sh +65 -0
- package/scripts/test_roster_calibrate.sh +76 -0
- package/scripts/test_taste_elo.sh +61 -0
package/scripts/lib/routing.sh
CHANGED
|
@@ -35,6 +35,10 @@ _AFFINITY_DATA=""
|
|
|
35
35
|
# portable workaround. At ~135 entries x ~25 chars = ~3KB, memory is a
|
|
36
36
|
# non-issue.
|
|
37
37
|
_AFFINITY_RESULT_CACHE=" "
|
|
38
|
+
# Parallel per-key cache for capability lookups: " CONSULTANT|AXIS=score".
|
|
39
|
+
# Same leading-space bracketing trick as _AFFINITY_RESULT_CACHE to avoid
|
|
40
|
+
# substring collisions. Reset alongside it when the affinity file changes.
|
|
41
|
+
_CAPABILITY_RESULT_CACHE=" "
|
|
38
42
|
|
|
39
43
|
# Resolve the affinity file path using the same search precedence as user
|
|
40
44
|
# config (v2.12+): explicit AFFINITY_FILE > user config dir > bundled default.
|
|
@@ -66,6 +70,7 @@ _load_affinity_data() {
|
|
|
66
70
|
fi
|
|
67
71
|
_AFFINITY_LOADED_FILE="$file"
|
|
68
72
|
_AFFINITY_RESULT_CACHE=" "
|
|
73
|
+
_CAPABILITY_RESULT_CACHE=" "
|
|
69
74
|
fi
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -116,6 +121,60 @@ get_affinity() {
|
|
|
116
121
|
echo "$score"
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
# Get a consultant's capability score on a quality/efficiency axis.
|
|
125
|
+
# Usage: get_capability <consultant> <axis> (axis: intelligence|taste|cost)
|
|
126
|
+
# Scores are 1-10 (higher = better). A missing consultant/axis, missing
|
|
127
|
+
# 'capabilities' block, or missing jq falls back to capability_default (5).
|
|
128
|
+
# Cached per (consultant, axis), mirroring get_affinity.
|
|
129
|
+
get_capability() {
|
|
130
|
+
local consultant="$1"
|
|
131
|
+
local axis="$2"
|
|
132
|
+
|
|
133
|
+
_load_affinity_data
|
|
134
|
+
|
|
135
|
+
local key=" ${consultant}|${axis}="
|
|
136
|
+
case "$_CAPABILITY_RESULT_CACHE" in
|
|
137
|
+
*"${key}"*)
|
|
138
|
+
local cached="${_CAPABILITY_RESULT_CACHE#*"${key}"}"
|
|
139
|
+
echo "${cached%% *}"
|
|
140
|
+
return 0
|
|
141
|
+
;;
|
|
142
|
+
esac
|
|
143
|
+
|
|
144
|
+
if [[ -z "$_AFFINITY_DATA" ]] || ! command -v jq >/dev/null 2>&1; then
|
|
145
|
+
echo "${CAPABILITY_DEFAULT:-5}"
|
|
146
|
+
return 0
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
local score
|
|
150
|
+
score=$(jq -r --arg c "$consultant" --arg ax "$axis" '
|
|
151
|
+
(.capabilities[$c][$ax]) // (.capability_default // 5)
|
|
152
|
+
' <<<"$_AFFINITY_DATA" 2>/dev/null)
|
|
153
|
+
|
|
154
|
+
[[ -z "$score" || "$score" == "null" ]] && score="${CAPABILITY_DEFAULT:-5}"
|
|
155
|
+
_CAPABILITY_RESULT_CACHE+="${key#" "}${score} "
|
|
156
|
+
echo "$score"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Get the quality axis a question category stresses (intelligence|taste).
|
|
160
|
+
# Usage: get_category_axis <category>
|
|
161
|
+
# Unmapped categories (and missing JSON/jq) default to "intelligence".
|
|
162
|
+
get_category_axis() {
|
|
163
|
+
local category="$1"
|
|
164
|
+
|
|
165
|
+
_load_affinity_data
|
|
166
|
+
|
|
167
|
+
if [[ -z "$_AFFINITY_DATA" ]] || ! command -v jq >/dev/null 2>&1; then
|
|
168
|
+
echo "intelligence"
|
|
169
|
+
return 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
local axis
|
|
173
|
+
axis=$(jq -r --arg cat "$category" '(.category_axis[$cat]) // "intelligence"' <<<"$_AFFINITY_DATA" 2>/dev/null)
|
|
174
|
+
[[ -z "$axis" || "$axis" == "null" ]] && axis="intelligence"
|
|
175
|
+
echo "$axis"
|
|
176
|
+
}
|
|
177
|
+
|
|
119
178
|
# Select the best consultants for a category
|
|
120
179
|
# Usage: select_consultants <category> [min_affinity] [max_consultants]
|
|
121
180
|
# Returns: list of consultants sorted by affinity
|
|
@@ -129,12 +188,26 @@ select_consultants() {
|
|
|
129
188
|
local selected=()
|
|
130
189
|
local scores=()
|
|
131
190
|
|
|
132
|
-
# Collect affinities
|
|
191
|
+
# Collect affinities. When capability-aware routing is on, the ELIGIBILITY
|
|
192
|
+
# filter still uses raw category affinity (same panel as before), but the
|
|
193
|
+
# SORT rank is nudged by the consultant's capability on the axis this
|
|
194
|
+
# category stresses — so under a max/limit the quality-axis reorders which
|
|
195
|
+
# eligible consultants make the cut. Default (flag off): rank == affinity.
|
|
196
|
+
local _cap_axis=""
|
|
197
|
+
if [[ "${ENABLE_CAPABILITY_ROUTING:-false}" == "true" ]]; then
|
|
198
|
+
_cap_axis=$(get_category_axis "$category")
|
|
199
|
+
fi
|
|
133
200
|
for c in "${consultants[@]}"; do
|
|
134
201
|
local score=$(get_affinity "$category" "$c")
|
|
135
202
|
if [[ $score -ge $min_affinity ]]; then
|
|
203
|
+
local rank=$score
|
|
204
|
+
if [[ -n "$_cap_axis" ]]; then
|
|
205
|
+
local cap
|
|
206
|
+
cap=$(get_capability "$c" "$_cap_axis")
|
|
207
|
+
rank=$(( score + cap - ${CAPABILITY_DEFAULT:-5} ))
|
|
208
|
+
fi
|
|
136
209
|
selected+=("$c")
|
|
137
|
-
scores+=("$
|
|
210
|
+
scores+=("$rank")
|
|
138
211
|
fi
|
|
139
212
|
done
|
|
140
213
|
|
package/scripts/lib/voting.sh
CHANGED
|
@@ -8,6 +8,44 @@
|
|
|
8
8
|
# CONFIDENCE-WEIGHTED VOTING
|
|
9
9
|
# =============================================================================
|
|
10
10
|
|
|
11
|
+
# Resolve the capability-weighting axis for the current run, or "" when
|
|
12
|
+
# capability weighting is off or the routing helpers are unavailable (e.g.
|
|
13
|
+
# voting.sh sourced standalone). Reads the run category from QUESTION_CATEGORY.
|
|
14
|
+
# Centralized so each voting function computes the axis once, not per response.
|
|
15
|
+
_voting_capability_axis() {
|
|
16
|
+
if [[ "${ENABLE_CAPABILITY_WEIGHTING:-false}" != "true" ]]; then
|
|
17
|
+
echo ""
|
|
18
|
+
return 0
|
|
19
|
+
fi
|
|
20
|
+
if ! declare -f get_capability >/dev/null 2>&1 || ! declare -f get_category_axis >/dev/null 2>&1; then
|
|
21
|
+
echo ""
|
|
22
|
+
return 0
|
|
23
|
+
fi
|
|
24
|
+
get_category_axis "${QUESTION_CATEGORY:-GENERAL}"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Effective vote weight for a consultant: its self-reported confidence,
|
|
28
|
+
# optionally modulated by how capable it is on the axis this question's
|
|
29
|
+
# category stresses (capability-aware voting). Degrades to plain confidence
|
|
30
|
+
# when no axis is supplied (feature off / helpers unavailable).
|
|
31
|
+
# Usage: _effective_vote_weight <consultant> <confidence> <axis-or-empty>
|
|
32
|
+
# weight = confidence * (STRENGTH + capability) / STRENGTH (integer math)
|
|
33
|
+
# STRENGTH = CAPABILITY_WEIGHT_STRENGTH (default 10): higher = gentler nudge.
|
|
34
|
+
_effective_vote_weight() {
|
|
35
|
+
local consultant="$1" confidence="$2" axis="$3"
|
|
36
|
+
if [[ -z "$axis" ]]; then
|
|
37
|
+
echo "$confidence"
|
|
38
|
+
return 0
|
|
39
|
+
fi
|
|
40
|
+
local cap strength
|
|
41
|
+
cap=$(get_capability "$consultant" "$axis")
|
|
42
|
+
[[ "$cap" =~ ^[0-9]+$ ]] || cap="${CAPABILITY_DEFAULT:-5}"
|
|
43
|
+
strength="${CAPABILITY_WEIGHT_STRENGTH:-10}"
|
|
44
|
+
[[ "$strength" =~ ^[0-9]+$ ]] || strength=10
|
|
45
|
+
[[ "$strength" -le 0 ]] && strength=10
|
|
46
|
+
echo $(( confidence * (strength + cap) / strength ))
|
|
47
|
+
}
|
|
48
|
+
|
|
11
49
|
# Calculate the weighted average of confidence scores
|
|
12
50
|
# Usage: calculate_weighted_average <json_responses_dir>
|
|
13
51
|
calculate_weighted_average() {
|
|
@@ -178,17 +216,22 @@ calculate_weighted_recommendation() {
|
|
|
178
216
|
map_clear "APPROACH_WEIGHTS"
|
|
179
217
|
map_clear "APPROACH_SUPPORTERS"
|
|
180
218
|
|
|
219
|
+
# Resolve the capability-weighting axis once (empty when weighting is off)
|
|
220
|
+
local _cap_axis
|
|
221
|
+
_cap_axis=$(_voting_capability_axis)
|
|
222
|
+
|
|
181
223
|
# Collect weights for each approach
|
|
182
224
|
for f in "$responses_dir"/*.json; do
|
|
183
225
|
if [[ -f "$f" && -s "$f" ]]; then
|
|
184
|
-
local consultant approach confidence current_weight current_supporters
|
|
226
|
+
local consultant approach confidence current_weight current_supporters weight
|
|
185
227
|
consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
|
|
186
228
|
approach=$(jq -r '.response.approach // "unknown"' "$f" 2>/dev/null)
|
|
187
229
|
confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
|
|
188
230
|
|
|
189
|
-
# Add weight
|
|
231
|
+
# Add weight (capability-modulated when ENABLE_CAPABILITY_WEIGHTING)
|
|
232
|
+
weight=$(_effective_vote_weight "$consultant" "$confidence" "$_cap_axis")
|
|
190
233
|
current_weight=$(map_get "APPROACH_WEIGHTS" "$approach")
|
|
191
|
-
map_set "APPROACH_WEIGHTS" "$approach" "$((${current_weight:-0} +
|
|
234
|
+
map_set "APPROACH_WEIGHTS" "$approach" "$((${current_weight:-0} + weight))"
|
|
192
235
|
|
|
193
236
|
# Add supporter
|
|
194
237
|
current_supporters=$(map_get "APPROACH_SUPPORTERS" "$approach")
|
|
@@ -257,19 +300,27 @@ calculate_final_score() {
|
|
|
257
300
|
|
|
258
301
|
local weighted_sum=0
|
|
259
302
|
local total_confidence=0
|
|
303
|
+
local _cap_axis
|
|
304
|
+
_cap_axis=$(_voting_capability_axis)
|
|
260
305
|
|
|
261
306
|
for f in "$responses_dir"/*.json; do
|
|
262
307
|
if [[ -f "$f" && -s "$f" ]]; then
|
|
308
|
+
local consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
|
|
263
309
|
local approach=$(jq -r '.response.approach // "unknown"' "$f" 2>/dev/null)
|
|
264
310
|
local confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
|
|
311
|
+
local weight
|
|
312
|
+
weight=$(_effective_vote_weight "$consultant" "$confidence" "$_cap_axis")
|
|
265
313
|
|
|
266
|
-
|
|
314
|
+
# Normalize by the same weight used in the numerator so the score
|
|
315
|
+
# stays on the 1-10 scale. weight == confidence unless capability
|
|
316
|
+
# weighting is enabled.
|
|
317
|
+
total_confidence=$((total_confidence + weight))
|
|
267
318
|
|
|
268
|
-
# Full weight if supports,
|
|
319
|
+
# Full weight if supports, reduced if dissents (still considered)
|
|
269
320
|
if [[ "$approach" == "$winning_approach" ]]; then
|
|
270
|
-
weighted_sum=$((weighted_sum +
|
|
321
|
+
weighted_sum=$((weighted_sum + weight * 10)) # Full support
|
|
271
322
|
else
|
|
272
|
-
weighted_sum=$((weighted_sum +
|
|
323
|
+
weighted_sum=$((weighted_sum + weight * 2)) # Dissent but consider
|
|
273
324
|
fi
|
|
274
325
|
fi
|
|
275
326
|
done
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# roster_audit.sh — "uncorrelated value" audit for the consultant roster.
|
|
3
|
+
#
|
|
4
|
+
# A consultant earns its seat only if it contributes signal the others DON'T —
|
|
5
|
+
# a distinct approach, not an echo of the panel. This is the diversity-not-
|
|
6
|
+
# redundancy bar from the model-routing "before a new model earns a row" test
|
|
7
|
+
# (catch >=1 thing both incumbents miss), applied to a consultation panel
|
|
8
|
+
# instead of a review lane.
|
|
9
|
+
#
|
|
10
|
+
# For each past consultation, a consultant's approach is DISTINCT if its keyword
|
|
11
|
+
# set has low Jaccard overlap (< threshold) with EVERY other consultant that
|
|
12
|
+
# round. Aggregated across consultations, a consultant that is rarely distinct
|
|
13
|
+
# is correlated with the rest — a candidate to drop or down-weight; one that is
|
|
14
|
+
# often distinct is pulling its weight on diversity.
|
|
15
|
+
#
|
|
16
|
+
# Reuses the voting.sh keyword/Jaccard machinery. Read-only; no live API calls.
|
|
17
|
+
#
|
|
18
|
+
# Usage: roster_audit.sh [--recent N] [--threshold PCT] [--json] [dir ...]
|
|
19
|
+
# dir... consultation output dirs (each holds <consultant>.json files)
|
|
20
|
+
# --recent N if no dirs given, audit the N most recent under the base (20)
|
|
21
|
+
# --threshold P Jaccard %% at/above which two approaches count as correlated (20)
|
|
22
|
+
# --json emit JSON instead of a table
|
|
23
|
+
# -h, --help show this header
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
# shellcheck source=lib/voting.sh
|
|
28
|
+
source "$SCRIPT_DIR/lib/voting.sh"
|
|
29
|
+
|
|
30
|
+
MIN_SAMPLE=3 # participations below which a verdict is "insufficient-data"
|
|
31
|
+
acc="" # temp accumulator file (cleaned on EXIT)
|
|
32
|
+
trap '[[ -n "$acc" ]] && rm -f "$acc"' EXIT
|
|
33
|
+
|
|
34
|
+
# Verdict from participation count + distinctiveness percentage.
|
|
35
|
+
# Usage: _verdict <participated> <pct>
|
|
36
|
+
_verdict() {
|
|
37
|
+
local p="$1" pct="$2"
|
|
38
|
+
if [[ "$p" -lt "$MIN_SAMPLE" ]]; then echo "insufficient-data"; return; fi
|
|
39
|
+
if [[ "$pct" -ge 50 ]]; then echo "unique-value"
|
|
40
|
+
elif [[ "$pct" -ge 20 ]]; then echo "some-value"
|
|
41
|
+
else echo "redundant?"; fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_render_table() { # <audited> <threshold> <rows>
|
|
45
|
+
local audited="$1" threshold="$2" rows="$3" redundant="" c p d pct v
|
|
46
|
+
printf 'Roster uncorrelated-value audit (%d consultations, correlated >= %d%%)\n\n' "$audited" "$threshold"
|
|
47
|
+
printf '%-12s %12s %8s %9s %s\n' CONSULTANT PARTICIPATED DISTINCT "DISTINCT%" VERDICT
|
|
48
|
+
while read -r c p d pct; do
|
|
49
|
+
[[ -z "$c" ]] && continue
|
|
50
|
+
v=$(_verdict "$p" "$pct")
|
|
51
|
+
printf '%-12s %12d %8d %8d%% %s\n' "$c" "$p" "$d" "$pct" "$v"
|
|
52
|
+
[[ "$v" == "redundant?" ]] && redundant="${redundant:+$redundant, }$c"
|
|
53
|
+
done <<< "$rows"
|
|
54
|
+
echo
|
|
55
|
+
if [[ -n "$redundant" ]]; then
|
|
56
|
+
printf 'Possibly redundant (>=%d samples, <20%% distinct): %s\n' "$MIN_SAMPLE" "$redundant"
|
|
57
|
+
echo ' -> consider dropping or down-weighting these in references/affinity.json.'
|
|
58
|
+
else
|
|
59
|
+
echo 'No consultant flagged redundant on this sample.'
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_render_json() { # <audited> <threshold> <rows>
|
|
64
|
+
local audited="$1" threshold="$2" rows="$3" items="[]" c p d pct v
|
|
65
|
+
while read -r c p d pct; do
|
|
66
|
+
[[ -z "$c" ]] && continue
|
|
67
|
+
v=$(_verdict "$p" "$pct")
|
|
68
|
+
items=$(jq -c --arg c "$c" --argjson p "$p" --argjson d "$d" --argjson pct "$pct" --arg v "$v" \
|
|
69
|
+
'. + [{consultant:$c, participated:$p, distinct:$d, distinct_pct:$pct, verdict:$v}]' <<<"$items")
|
|
70
|
+
done <<< "$rows"
|
|
71
|
+
jq -n --argjson audited "$audited" --argjson threshold "$threshold" --argjson roster "$items" \
|
|
72
|
+
'{consultations_audited:$audited, correlated_threshold_pct:$threshold, roster:$roster}'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main() {
|
|
76
|
+
local recent=20 threshold=20 as_json=0
|
|
77
|
+
local dirs=()
|
|
78
|
+
while [[ $# -gt 0 ]]; do
|
|
79
|
+
case "$1" in
|
|
80
|
+
--recent) recent="${2:-20}"; shift 2 ;;
|
|
81
|
+
--threshold) threshold="${2:-20}"; shift 2 ;;
|
|
82
|
+
--json) as_json=1; shift ;;
|
|
83
|
+
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
84
|
+
-*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
85
|
+
*) dirs+=("$1"); shift ;;
|
|
86
|
+
esac
|
|
87
|
+
done
|
|
88
|
+
|
|
89
|
+
command -v jq >/dev/null 2>&1 || { echo "roster_audit: jq is required" >&2; exit 2; }
|
|
90
|
+
|
|
91
|
+
# No dirs given -> the N most recent consultation dirs under the base.
|
|
92
|
+
if [[ ${#dirs[@]} -eq 0 ]]; then
|
|
93
|
+
# shellcheck source=config.sh
|
|
94
|
+
source "$SCRIPT_DIR/config.sh" >/dev/null 2>&1 || true
|
|
95
|
+
local base="${DEFAULT_OUTPUT_DIR_BASE:-/tmp/ai_consultations}"
|
|
96
|
+
if [[ ! -d "$base" ]]; then
|
|
97
|
+
echo "roster_audit: no dirs given and base '$base' not found" >&2
|
|
98
|
+
exit 1
|
|
99
|
+
fi
|
|
100
|
+
while IFS= read -r d; do dirs+=("$d"); done < <(
|
|
101
|
+
find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -r | head -n "$recent"
|
|
102
|
+
)
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
acc="$(mktemp "${TMPDIR:-/tmp}/roster_audit.XXXXXX")"
|
|
106
|
+
|
|
107
|
+
local audited=0 dir f
|
|
108
|
+
for dir in ${dirs[@]+"${dirs[@]}"}; do
|
|
109
|
+
[[ -d "$dir" ]] || continue
|
|
110
|
+
local names=() kw=()
|
|
111
|
+
for f in "$dir"/*.json; do
|
|
112
|
+
[[ -f "$f" && -s "$f" ]] || continue
|
|
113
|
+
local c a al
|
|
114
|
+
c=$(jq -r '.consultant // empty' "$f" 2>/dev/null)
|
|
115
|
+
[[ -z "$c" ]] && continue # not a consultant response file
|
|
116
|
+
a=$(jq -r '.response.approach? // "unknown"' "$f" 2>/dev/null)
|
|
117
|
+
al=$(printf '%s' "$a" | tr '[:upper:]' '[:lower:]')
|
|
118
|
+
case "$al" in unknown|""|n/a|none|"not available") continue ;; esac
|
|
119
|
+
names+=("$c"); kw+=("$(_extract_keywords "$a")")
|
|
120
|
+
done
|
|
121
|
+
local n=${#names[@]}
|
|
122
|
+
[[ $n -lt 2 ]] && continue # need >=2 to assess correlation
|
|
123
|
+
audited=$((audited+1))
|
|
124
|
+
local i j
|
|
125
|
+
for ((i=0; i<n; i++)); do
|
|
126
|
+
local maxsim=0 s
|
|
127
|
+
for ((j=0; j<n; j++)); do
|
|
128
|
+
[[ $i -eq $j ]] && continue
|
|
129
|
+
s=$(_jaccard_similarity "${kw[$i]}" "${kw[$j]}")
|
|
130
|
+
[[ $s -gt $maxsim ]] && maxsim=$s
|
|
131
|
+
done
|
|
132
|
+
local distinct=0
|
|
133
|
+
[[ $maxsim -lt $threshold ]] && distinct=1
|
|
134
|
+
printf '%s %d\n' "${names[$i]}" "$distinct" >> "$acc"
|
|
135
|
+
done
|
|
136
|
+
done
|
|
137
|
+
|
|
138
|
+
if [[ $audited -eq 0 ]]; then
|
|
139
|
+
echo "roster_audit: no consultations with >=2 responders found (nothing to audit)" >&2
|
|
140
|
+
exit 1
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Aggregate: "consultant participated distinct pct", sorted by pct then distinct desc.
|
|
144
|
+
local rows
|
|
145
|
+
rows=$(awk '
|
|
146
|
+
{ part[$1]++; dist[$1]+=$2 }
|
|
147
|
+
END { for (c in part){ p=part[c]; d=dist[c]; pct=(p>0)?int(d*100/p):0; printf "%s %d %d %d\n", c, p, d, pct } }
|
|
148
|
+
' "$acc" | sort -k4,4nr -k3,3nr)
|
|
149
|
+
|
|
150
|
+
if [[ "$as_json" -eq 1 ]]; then
|
|
151
|
+
_render_json "$audited" "$threshold" "$rows"
|
|
152
|
+
else
|
|
153
|
+
_render_table "$audited" "$threshold" "$rows"
|
|
154
|
+
fi
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
main "$@"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# roster_calibrate.sh — MEASURE capability scores from consultation history,
|
|
3
|
+
# replacing the heuristic seeds in references/affinity.json (Tier A).
|
|
4
|
+
#
|
|
5
|
+
# The three axes are derived, not guessed — each from a signal the tool already
|
|
6
|
+
# records, and sliced by the SAME category_axis that consumes them:
|
|
7
|
+
#
|
|
8
|
+
# intelligence = mean blind PEER-REVIEW score on intelligence-axis categories
|
|
9
|
+
# taste = mean blind PEER-REVIEW score on taste-axis categories
|
|
10
|
+
# cost = mean observed $/response (tokens_used x catalog rate),
|
|
11
|
+
# rank-normalized (cheapest = 10). This is cost-per-*task*
|
|
12
|
+
# (verbosity + completion), not per-token sticker price.
|
|
13
|
+
#
|
|
14
|
+
# Requires consultations run with ENABLE_PEER_REVIEW=true (peer scores) and the
|
|
15
|
+
# default cost tracking (tokens_used in metadata). Read-only unless --write.
|
|
16
|
+
#
|
|
17
|
+
# Usage: roster_calibrate.sh [--recent N] [--json] [--write] [dir ...]
|
|
18
|
+
# dir... consultation output dirs (each: <consultant>.json + peer_review/
|
|
19
|
+
# + optimization_metrics.json). Default: N most recent under base.
|
|
20
|
+
# --recent N number of recent consultations to use (default 30)
|
|
21
|
+
# --json emit {measured_capabilities, samples} JSON
|
|
22
|
+
# --write merge measured values into references/affinity.json (backup first)
|
|
23
|
+
# -h, --help show this header
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
# shellcheck source=lib/routing.sh
|
|
28
|
+
source "$SCRIPT_DIR/lib/routing.sh" # get_category_axis
|
|
29
|
+
COST_RATES="$SCRIPT_DIR/../docs/cost_rates.json"
|
|
30
|
+
AFFINITY_JSON="$SCRIPT_DIR/../references/affinity.json"
|
|
31
|
+
|
|
32
|
+
# Input/output token split convention (mirrors lib/costs.sh's 60/40 estimate)
|
|
33
|
+
# and the fallback rate for a model absent from the catalog (per-1K).
|
|
34
|
+
SPLIT_IN=0.6; SPLIT_OUT=0.4
|
|
35
|
+
FALLBACK_IN=0.005; FALLBACK_OUT=0.015
|
|
36
|
+
|
|
37
|
+
qacc=""; cacc=""
|
|
38
|
+
trap '[[ -n "$qacc" ]] && rm -f "$qacc"; [[ -n "$cacc" ]] && rm -f "$cacc"' EXIT
|
|
39
|
+
|
|
40
|
+
# Per-response cost in dollars from a model + total token count. Rate looked up
|
|
41
|
+
# case-insensitively (callers/consultants vary case); falls back if absent.
|
|
42
|
+
_response_cost() {
|
|
43
|
+
local model="$1" tokens="$2" rin rout
|
|
44
|
+
read -r rin rout < <(jq -r --arg m "$model" '
|
|
45
|
+
(.models | to_entries
|
|
46
|
+
| map(select(.key | ascii_downcase == ($m | ascii_downcase)))
|
|
47
|
+
| .[0].value) as $r
|
|
48
|
+
| if $r then "\($r.input) \($r.output)" else "NA NA" end
|
|
49
|
+
' "$COST_RATES" 2>/dev/null)
|
|
50
|
+
if [[ "$rin" == "NA" || -z "${rin:-}" ]]; then rin=$FALLBACK_IN; rout=$FALLBACK_OUT; fi
|
|
51
|
+
awk -v t="$tokens" -v i="$rin" -v o="$rout" -v si="$SPLIT_IN" -v so="$SPLIT_OUT" \
|
|
52
|
+
'BEGIN{ printf "%.8f", (t/1000)*(si*i + so*o) }'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Locate a peer-review aggregate file in <dir>/peer_review by SHAPE (an array of
|
|
56
|
+
# {consultant, average_peer_score}) — robust to its exact filename.
|
|
57
|
+
_find_peer_file() {
|
|
58
|
+
local pdir="$1/peer_review" pf
|
|
59
|
+
[[ -d "$pdir" ]] || return 1
|
|
60
|
+
for pf in "$pdir"/*.json; do
|
|
61
|
+
[[ -f "$pf" ]] || continue
|
|
62
|
+
if jq -e 'type=="array" and length>0 and (.[0]|has("average_peer_score") and has("consultant"))' \
|
|
63
|
+
"$pf" >/dev/null 2>&1; then
|
|
64
|
+
echo "$pf"; return 0
|
|
65
|
+
fi
|
|
66
|
+
done
|
|
67
|
+
return 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main() {
|
|
71
|
+
local recent=30 as_json=0 do_write=0
|
|
72
|
+
local dirs=()
|
|
73
|
+
while [[ $# -gt 0 ]]; do
|
|
74
|
+
case "$1" in
|
|
75
|
+
--recent) recent="${2:-30}"; shift 2 ;;
|
|
76
|
+
--json) as_json=1; shift ;;
|
|
77
|
+
--write) do_write=1; shift ;;
|
|
78
|
+
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
79
|
+
-*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
80
|
+
*) dirs+=("$1"); shift ;;
|
|
81
|
+
esac
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
command -v jq >/dev/null 2>&1 || { echo "roster_calibrate: jq is required" >&2; exit 2; }
|
|
85
|
+
command -v awk >/dev/null 2>&1 || { echo "roster_calibrate: awk is required" >&2; exit 2; }
|
|
86
|
+
|
|
87
|
+
if [[ ${#dirs[@]} -eq 0 ]]; then
|
|
88
|
+
# shellcheck source=config.sh
|
|
89
|
+
source "$SCRIPT_DIR/config.sh" >/dev/null 2>&1 || true
|
|
90
|
+
local base="${DEFAULT_OUTPUT_DIR_BASE:-/tmp/ai_consultations}"
|
|
91
|
+
[[ -d "$base" ]] || { echo "roster_calibrate: no dirs given and base '$base' not found" >&2; exit 1; }
|
|
92
|
+
while IFS= read -r d; do dirs+=("$d"); done < <(
|
|
93
|
+
find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -r | head -n "$recent"
|
|
94
|
+
)
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
qacc="$(mktemp "${TMPDIR:-/tmp}/rc_quality.XXXXXX")" # lines: "consultant axis score"
|
|
98
|
+
cacc="$(mktemp "${TMPDIR:-/tmp}/rc_cost.XXXXXX")" # lines: "consultant cost"
|
|
99
|
+
|
|
100
|
+
local used=0 dir
|
|
101
|
+
for dir in ${dirs[@]+"${dirs[@]}"}; do
|
|
102
|
+
[[ -d "$dir" ]] || continue
|
|
103
|
+
|
|
104
|
+
# Category -> quality axis (intelligence|taste) for this consultation.
|
|
105
|
+
local cat axis
|
|
106
|
+
cat=$(jq -r '.quality_metrics.category // empty' "$dir/optimization_metrics.json" 2>/dev/null)
|
|
107
|
+
[[ -z "$cat" ]] && cat="GENERAL"
|
|
108
|
+
axis=$(get_category_axis "$cat")
|
|
109
|
+
|
|
110
|
+
# Peer-review quality scores (blind) -> quality accumulator.
|
|
111
|
+
local peer_file
|
|
112
|
+
if peer_file=$(_find_peer_file "$dir"); then
|
|
113
|
+
while read -r c s; do
|
|
114
|
+
[[ -z "$c" || "$c" == "unknown" ]] && continue
|
|
115
|
+
echo "$c $axis $s" >> "$qacc"
|
|
116
|
+
done < <(jq -r '.[] | "\(.consultant) \(.average_peer_score)"' "$peer_file" 2>/dev/null)
|
|
117
|
+
used=$((used+1))
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Observed cost per response -> cost accumulator.
|
|
121
|
+
local f
|
|
122
|
+
for f in "$dir"/*.json; do
|
|
123
|
+
[[ -f "$f" && -s "$f" ]] || continue
|
|
124
|
+
local c model tokens
|
|
125
|
+
c=$(jq -r '.consultant // empty' "$f" 2>/dev/null)
|
|
126
|
+
[[ -z "$c" ]] && continue
|
|
127
|
+
model=$(jq -r '.model // "unknown"' "$f" 2>/dev/null)
|
|
128
|
+
tokens=$(jq -r '.metadata.tokens_used // empty' "$f" 2>/dev/null)
|
|
129
|
+
[[ -z "$tokens" || ! "$tokens" =~ ^[0-9]+$ ]] && continue
|
|
130
|
+
echo "$c $(_response_cost "$model" "$tokens")" >> "$cacc"
|
|
131
|
+
done
|
|
132
|
+
done
|
|
133
|
+
|
|
134
|
+
if [[ $used -eq 0 ]]; then
|
|
135
|
+
echo "roster_calibrate: no consultations with peer-review data found (run with ENABLE_PEER_REVIEW=true)" >&2
|
|
136
|
+
exit 1
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# --- Aggregate quality per (consultant, axis): mean, rounded, clamped 1-10 ---
|
|
140
|
+
# Emits: "consultant intelligence <score|-> <n> taste <score|-> <n>"
|
|
141
|
+
local quality_rows
|
|
142
|
+
quality_rows=$(awk '
|
|
143
|
+
{ key=$1 SUBSEP $2; sum[key]+=$3; n[key]++; seen[$1]=1 }
|
|
144
|
+
END {
|
|
145
|
+
for (c in seen) {
|
|
146
|
+
ik=c SUBSEP "intelligence"; tk=c SUBSEP "taste"
|
|
147
|
+
iv=(ik in sum)?int(sum[ik]/n[ik]+0.5):-1; ic=(ik in n)?n[ik]:0
|
|
148
|
+
tv=(tk in sum)?int(sum[tk]/n[tk]+0.5):-1; tc=(tk in n)?n[tk]:0
|
|
149
|
+
if (iv>10) iv=10; if (iv!=-1 && iv<1) iv=1
|
|
150
|
+
if (tv>10) tv=10; if (tv!=-1 && tv<1) tv=1
|
|
151
|
+
printf "%s %s %d %s %d\n", c, (iv==-1?"-":iv), ic, (tv==-1?"-":tv), tc
|
|
152
|
+
}
|
|
153
|
+
}' "$qacc" | sort)
|
|
154
|
+
|
|
155
|
+
# --- Aggregate cost per consultant: mean, then rank-normalize (cheapest=10) ---
|
|
156
|
+
# Emits: "consultant <costscore> <n>"
|
|
157
|
+
local cost_rows
|
|
158
|
+
cost_rows=$(awk '
|
|
159
|
+
{ sum[$1]+=$2; n[$1]++ }
|
|
160
|
+
END { for (c in sum) printf "%s %.10f %d\n", c, sum[c]/n[c], n[c] }
|
|
161
|
+
' "$cacc" | sort -k2,2g | awk '
|
|
162
|
+
{ c[NR]=$1; mean[NR]=$2; cnt[NR]=$3 }
|
|
163
|
+
END {
|
|
164
|
+
N=NR
|
|
165
|
+
for (r=1; r<=N; r++) {
|
|
166
|
+
if (N==1) score=5
|
|
167
|
+
else score=int(1 + 9*(N-r)/(N-1) + 0.5) # rank 1 (cheapest) -> 10
|
|
168
|
+
print c[r], score, cnt[r]
|
|
169
|
+
}
|
|
170
|
+
}' | sort)
|
|
171
|
+
|
|
172
|
+
# --- Assemble measured_capabilities + samples JSON ---
|
|
173
|
+
local caps='{}' samples='{}' c iv ic tv tc
|
|
174
|
+
while read -r c iv ic tv tc; do
|
|
175
|
+
[[ -z "$c" ]] && continue
|
|
176
|
+
caps=$(jq -c --arg c "$c" \
|
|
177
|
+
--argjson iv "$([[ "$iv" == "-" ]] && echo null || echo "$iv")" \
|
|
178
|
+
--argjson tv "$([[ "$tv" == "-" ]] && echo null || echo "$tv")" \
|
|
179
|
+
'.[$c] = ((.[$c] // {}) + (if $iv==null then {} else {intelligence:$iv} end)
|
|
180
|
+
+ (if $tv==null then {} else {taste:$tv} end))' <<<"$caps")
|
|
181
|
+
samples=$(jq -c --arg c "$c" --argjson ic "$ic" --argjson tc "$tc" \
|
|
182
|
+
'.[$c] = ((.[$c] // {}) + {intelligence_n:$ic, taste_n:$tc})' <<<"$samples")
|
|
183
|
+
done <<< "$quality_rows"
|
|
184
|
+
|
|
185
|
+
while read -r c score cnt; do
|
|
186
|
+
[[ -z "$c" ]] && continue
|
|
187
|
+
caps=$(jq -c --arg c "$c" --argjson v "$score" '.[$c] = ((.[$c] // {}) + {cost:$v})' <<<"$caps")
|
|
188
|
+
samples=$(jq -c --arg c "$c" --argjson n "$cnt" '.[$c] = ((.[$c] // {}) + {cost_n:$n})' <<<"$samples")
|
|
189
|
+
done <<< "$cost_rows"
|
|
190
|
+
|
|
191
|
+
local result
|
|
192
|
+
result=$(jq -n --argjson caps "$caps" --argjson samples "$samples" \
|
|
193
|
+
'{measured_capabilities:$caps, samples:$samples}')
|
|
194
|
+
|
|
195
|
+
if [[ "$do_write" -eq 1 ]]; then
|
|
196
|
+
cp "$AFFINITY_JSON" "${AFFINITY_JSON}.bak"
|
|
197
|
+
jq --argjson m "$caps" '.capabilities = ((.capabilities // {}) * $m)' "$AFFINITY_JSON" \
|
|
198
|
+
> "${AFFINITY_JSON}.tmp" && mv "${AFFINITY_JSON}.tmp" "$AFFINITY_JSON"
|
|
199
|
+
echo "Merged measured capabilities into $AFFINITY_JSON (backup: ${AFFINITY_JSON}.bak)"
|
|
200
|
+
echo "Measured $(echo "$caps" | jq 'length') consultant(s) from $used consultation(s)."
|
|
201
|
+
elif [[ "$as_json" -eq 1 ]]; then
|
|
202
|
+
echo "$result"
|
|
203
|
+
else
|
|
204
|
+
echo "Measured capabilities from $used consultation(s) with peer-review data:"
|
|
205
|
+
echo "(intelligence/taste = mean blind peer score on that axis's categories; cost = rank, cheapest=10)"
|
|
206
|
+
echo
|
|
207
|
+
echo "$result" | jq -r '
|
|
208
|
+
.measured_capabilities as $c | .samples as $s
|
|
209
|
+
| ($c | keys[]) as $k
|
|
210
|
+
| " \($k): intelligence=\($c[$k].intelligence // "n/a") taste=\($c[$k].taste // "n/a") cost=\($c[$k].cost // "n/a")"
|
|
211
|
+
+ " (n: int=\($s[$k].intelligence_n // 0) taste=\($s[$k].taste_n // 0) cost=\($s[$k].cost_n // 0))"'
|
|
212
|
+
echo
|
|
213
|
+
echo "Apply with --write (merges into affinity.json, keeping unmeasured cells), or --json for raw."
|
|
214
|
+
fi
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main "$@"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run_calibration.sh — data-collection harness for roster_calibrate.sh.
|
|
3
|
+
#
|
|
4
|
+
# Runs each benchmark question (references/calibration_benchmark.json) through
|
|
5
|
+
# the full panel with ENABLE_PEER_REVIEW=true, collects the output dirs, then
|
|
6
|
+
# calibrates measured intelligence/taste/cost scores.
|
|
7
|
+
#
|
|
8
|
+
# WARNING: this makes real consultant calls for EVERY question — it costs money
|
|
9
|
+
# and takes time (dozens of consultations). Use --limit to sample first and
|
|
10
|
+
# --dry-run to preview without running anything.
|
|
11
|
+
#
|
|
12
|
+
# Usage: run_calibration.sh [--limit N] [--dry-run] [--write]
|
|
13
|
+
# --limit N only run the first N benchmark questions
|
|
14
|
+
# --dry-run print the plan; execute nothing
|
|
15
|
+
# --write after collecting, merge measured scores into affinity.json
|
|
16
|
+
# -h, --help show this header
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
BENCH="$SCRIPT_DIR/../references/calibration_benchmark.json"
|
|
21
|
+
|
|
22
|
+
main() {
|
|
23
|
+
local limit=0 dry=0 do_write=0
|
|
24
|
+
while [[ $# -gt 0 ]]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--limit) limit="${2:-0}"; shift 2 ;;
|
|
27
|
+
--dry-run) dry=1; shift ;;
|
|
28
|
+
--write) do_write=1; shift ;;
|
|
29
|
+
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
30
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
command -v jq >/dev/null 2>&1 || { echo "run_calibration: jq is required" >&2; exit 2; }
|
|
35
|
+
[[ -f "$BENCH" ]] || { echo "run_calibration: benchmark not found: $BENCH" >&2; exit 1; }
|
|
36
|
+
|
|
37
|
+
local total
|
|
38
|
+
total=$(jq '.questions | length' "$BENCH")
|
|
39
|
+
[[ "$limit" -gt 0 && "$limit" -lt "$total" ]] && total="$limit"
|
|
40
|
+
|
|
41
|
+
echo "Calibration run: $total question(s) x full panel, ENABLE_PEER_REVIEW=true."
|
|
42
|
+
[[ "$dry" -eq 1 ]] && echo "(dry run — nothing will be executed)"
|
|
43
|
+
echo
|
|
44
|
+
|
|
45
|
+
local dirs=() i=0 q qcat outdir
|
|
46
|
+
while IFS= read -r q; do
|
|
47
|
+
[[ "$limit" -gt 0 && "$i" -ge "$limit" ]] && break
|
|
48
|
+
i=$((i+1))
|
|
49
|
+
qcat=$(jq -r ".questions[$((i-1))].category" "$BENCH")
|
|
50
|
+
printf '[%d/%d] %-12s %.70s\n' "$i" "$total" "$qcat" "$q"
|
|
51
|
+
[[ "$dry" -eq 1 ]] && continue
|
|
52
|
+
outdir=$(ENABLE_PEER_REVIEW=true "$SCRIPT_DIR/consult_all.sh" "$q" 2>/dev/null | tail -1)
|
|
53
|
+
if [[ -n "$outdir" && -d "$outdir" ]]; then
|
|
54
|
+
dirs+=("$outdir")
|
|
55
|
+
else
|
|
56
|
+
echo " ! consultation produced no output dir — skipped"
|
|
57
|
+
fi
|
|
58
|
+
done < <(jq -r '.questions[].q' "$BENCH")
|
|
59
|
+
|
|
60
|
+
if [[ "$dry" -eq 1 ]]; then
|
|
61
|
+
echo; echo "Dry run complete. Re-run without --dry-run to collect data."
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if [[ ${#dirs[@]} -eq 0 ]]; then
|
|
66
|
+
echo "No consultations succeeded — nothing to calibrate. Check ./scripts/doctor.sh --live." >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
echo; echo "Collected ${#dirs[@]} consultation(s). Calibrating..."
|
|
71
|
+
local calib_args=()
|
|
72
|
+
[[ "$do_write" -eq 1 ]] && calib_args+=(--write)
|
|
73
|
+
"$SCRIPT_DIR/roster_calibrate.sh" ${calib_args[@]+"${calib_args[@]}"} "${dirs[@]}"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main "$@"
|