crewhaus 0.1.8 → 0.2.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/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11601 -964
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -65
package/dist/faq.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #55 — distill repeated user questions into an auto-discovered FAQ skill.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus faq distill` clusters recurring `user_message` questions across
|
|
5
|
+
* sessions (deterministic token clustering — the same greedy-jaccard approach
|
|
6
|
+
* `graders suggest` uses, no model call), pairs each cluster with its best-rated
|
|
7
|
+
* answer (the up-rated turn's output), and emits a SKILL.md FAQ skill into
|
|
8
|
+
* `.crewhaus/skills/faq/` so `@crewhaus/skills-registry` auto-discovers it —
|
|
9
|
+
* its name + description go into the prompt and the body loads on demand when
|
|
10
|
+
* the model invokes the `Skill` tool (the registry is lazy, not eager).
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure + deterministic so it is unit-testable; the FS access
|
|
13
|
+
* and PII/secret redaction wiring live in `apps/cli/src/index.ts`. Both the
|
|
14
|
+
* harvested ANSWER and the representative QUESTION are user/model text that may
|
|
15
|
+
* carry pasted credentials, so the caller redacts BOTH (via
|
|
16
|
+
* `SYNTHESIZE_PII_DETECTORS`) before they land in the skill body (#55 F4).
|
|
17
|
+
*
|
|
18
|
+
* The emitted SKILL.md is guaranteed valid per skills-registry's `parseSkillFile`
|
|
19
|
+
* (leading `---` frontmatter with `name` + `description`, then the FAQ body).
|
|
20
|
+
*/
|
|
21
|
+
import type { FeedbackRecord, LoggedEvent, SessionTurn } from "./feedback";
|
|
22
|
+
/** One recurring-question cluster paired with its best answer. */
|
|
23
|
+
export type FaqEntry = {
|
|
24
|
+
/** The representative (most-frequent-token, then shortest) question. */
|
|
25
|
+
readonly question: string;
|
|
26
|
+
/** The best-rated answer for the cluster (already redacted by the caller). */
|
|
27
|
+
readonly answer: string;
|
|
28
|
+
/** How many distinct sessions asked a question in this cluster. */
|
|
29
|
+
readonly sessionCount: number;
|
|
30
|
+
/** How many questions total fell into this cluster. */
|
|
31
|
+
readonly occurrences: number;
|
|
32
|
+
};
|
|
33
|
+
export type ClusterOptions = {
|
|
34
|
+
/** Jaccard overlap at/above which a question joins a cluster's seed. Default 0.5. */
|
|
35
|
+
readonly threshold?: number;
|
|
36
|
+
/** Minimum number of DISTINCT sessions that must ask a clustered question
|
|
37
|
+
* for it to become an FAQ entry. Default 2 (recurring across sessions — a
|
|
38
|
+
* one-off, or a single session that repeats itself, is not an FAQ). */
|
|
39
|
+
readonly minOccurrences?: number;
|
|
40
|
+
/** Normalized rating at/above which an answer is "good". Default 0.7. */
|
|
41
|
+
readonly minScore?: number;
|
|
42
|
+
/** Redactor for answers (async). Identity by default. */
|
|
43
|
+
readonly redact?: (text: string) => Promise<string>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Build the FAQ entries from per-session turns + feedback. Deterministic:
|
|
47
|
+
* questions are clustered greedily by normalized-token Jaccard overlap in a
|
|
48
|
+
* stable visit order; each cluster keeps its best-rated answer (highest score,
|
|
49
|
+
* ties broken by session/turn order). Only clusters asked in ≥ `minOccurrences`
|
|
50
|
+
* DISTINCT sessions (#55 F9) AND with a qualifying answer (score ≥ `minScore`)
|
|
51
|
+
* become entries. Entries come back most-recurring-first.
|
|
52
|
+
*/
|
|
53
|
+
export declare function distillFaq(turnsBySession: ReadonlyArray<SessionTurn>, feedback: ReadonlyArray<FeedbackRecord>, opts?: ClusterOptions): Promise<FaqEntry[]>;
|
|
54
|
+
/** Derive `SessionTurn`s from many sessions' event logs. Convenience wrapper so
|
|
55
|
+
* the CLI and tests build the clustering input the same way. */
|
|
56
|
+
export declare function turnsFromSessions(perSession: ReadonlyArray<{
|
|
57
|
+
sessionId: string;
|
|
58
|
+
events: ReadonlyArray<LoggedEvent>;
|
|
59
|
+
}>): SessionTurn[];
|
|
60
|
+
/**
|
|
61
|
+
* Render a valid SKILL.md for the FAQ. Frontmatter carries `name` +
|
|
62
|
+
* `description` (the required pair skills-registry enforces) plus `triggers`;
|
|
63
|
+
* the body is the Q→A list. Guaranteed to parse via `parseSkillFile`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildFaqSkill(entries: ReadonlyArray<FaqEntry>, opts?: {
|
|
66
|
+
name?: string;
|
|
67
|
+
harnessName?: string;
|
|
68
|
+
}): string;
|
package/dist/faq.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #55 — distill repeated user questions into an auto-discovered FAQ skill.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus faq distill` clusters recurring `user_message` questions across
|
|
5
|
+
* sessions (deterministic token clustering — the same greedy-jaccard approach
|
|
6
|
+
* `graders suggest` uses, no model call), pairs each cluster with its best-rated
|
|
7
|
+
* answer (the up-rated turn's output), and emits a SKILL.md FAQ skill into
|
|
8
|
+
* `.crewhaus/skills/faq/` so `@crewhaus/skills-registry` auto-discovers it —
|
|
9
|
+
* its name + description go into the prompt and the body loads on demand when
|
|
10
|
+
* the model invokes the `Skill` tool (the registry is lazy, not eager).
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure + deterministic so it is unit-testable; the FS access
|
|
13
|
+
* and PII/secret redaction wiring live in `apps/cli/src/index.ts`. Both the
|
|
14
|
+
* harvested ANSWER and the representative QUESTION are user/model text that may
|
|
15
|
+
* carry pasted credentials, so the caller redacts BOTH (via
|
|
16
|
+
* `SYNTHESIZE_PII_DETECTORS`) before they land in the skill body (#55 F4).
|
|
17
|
+
*
|
|
18
|
+
* The emitted SKILL.md is guaranteed valid per skills-registry's `parseSkillFile`
|
|
19
|
+
* (leading `---` frontmatter with `name` + `description`, then the FAQ body).
|
|
20
|
+
*/
|
|
21
|
+
import { deriveTurns, mergeFeedback, normalizeRating } from "./feedback";
|
|
22
|
+
import { normalizeEvidenceTokens } from "./graders-suggest";
|
|
23
|
+
function jaccard(a, b) {
|
|
24
|
+
let intersection = 0;
|
|
25
|
+
for (const t of a)
|
|
26
|
+
if (b.has(t))
|
|
27
|
+
intersection += 1;
|
|
28
|
+
const union = a.size + b.size - intersection;
|
|
29
|
+
return union === 0 ? 0 : intersection / union;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the FAQ entries from per-session turns + feedback. Deterministic:
|
|
33
|
+
* questions are clustered greedily by normalized-token Jaccard overlap in a
|
|
34
|
+
* stable visit order; each cluster keeps its best-rated answer (highest score,
|
|
35
|
+
* ties broken by session/turn order). Only clusters asked in ≥ `minOccurrences`
|
|
36
|
+
* DISTINCT sessions (#55 F9) AND with a qualifying answer (score ≥ `minScore`)
|
|
37
|
+
* become entries. Entries come back most-recurring-first.
|
|
38
|
+
*/
|
|
39
|
+
export async function distillFaq(turnsBySession, feedback, opts = {}) {
|
|
40
|
+
const threshold = opts.threshold ?? 0.5;
|
|
41
|
+
const minOccurrences = opts.minOccurrences ?? 2;
|
|
42
|
+
const minScore = opts.minScore ?? 0.7;
|
|
43
|
+
const redact = opts.redact ?? (async (t) => t);
|
|
44
|
+
// Rating lookup keyed by session#turn.
|
|
45
|
+
const scoreByKey = new Map();
|
|
46
|
+
for (const fb of mergeFeedback(feedback)) {
|
|
47
|
+
const s = normalizeRating(fb);
|
|
48
|
+
if (s !== undefined)
|
|
49
|
+
scoreByKey.set(`${fb.sessionId}#${fb.turnNumber}`, s);
|
|
50
|
+
}
|
|
51
|
+
const records = turnsBySession
|
|
52
|
+
.filter((t) => t.input.trim() !== "")
|
|
53
|
+
.map((t) => ({
|
|
54
|
+
sessionId: t.sessionId,
|
|
55
|
+
turnNumber: t.turnNumber,
|
|
56
|
+
question: t.input.trim(),
|
|
57
|
+
answer: t.output,
|
|
58
|
+
score: scoreByKey.get(`${t.sessionId}#${t.turnNumber}`),
|
|
59
|
+
}))
|
|
60
|
+
.sort((a, b) => a.sessionId.localeCompare(b.sessionId) ||
|
|
61
|
+
a.turnNumber - b.turnNumber ||
|
|
62
|
+
a.question.localeCompare(b.question));
|
|
63
|
+
const clusters = [];
|
|
64
|
+
for (const rec of records) {
|
|
65
|
+
const tokens = new Set(normalizeEvidenceTokens(rec.question));
|
|
66
|
+
if (tokens.size === 0)
|
|
67
|
+
continue;
|
|
68
|
+
let bestIdx = -1;
|
|
69
|
+
let bestOverlap = 0;
|
|
70
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
71
|
+
const overlap = jaccard(tokens, clusters[i].seed);
|
|
72
|
+
if (overlap > bestOverlap) {
|
|
73
|
+
bestOverlap = overlap;
|
|
74
|
+
bestIdx = i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (bestIdx >= 0 && bestOverlap >= threshold) {
|
|
78
|
+
const c = clusters[bestIdx];
|
|
79
|
+
c.items.push(rec);
|
|
80
|
+
c.tokenSets.push(tokens);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
clusters.push({ seed: tokens, items: [rec], tokenSets: [tokens] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const entries = [];
|
|
87
|
+
for (const c of clusters) {
|
|
88
|
+
// #55 F9 — "recurring" means asked across DISTINCT sessions, not the same
|
|
89
|
+
// session twice. Gate on the distinct-session count so two questions from
|
|
90
|
+
// one session don't manufacture an FAQ entry at minOccurrences:2.
|
|
91
|
+
const sessionCount = new Set(c.items.map((i) => i.sessionId)).size;
|
|
92
|
+
if (sessionCount < minOccurrences)
|
|
93
|
+
continue;
|
|
94
|
+
// Best answer: highest-scored qualifying turn (stable tie-break by order).
|
|
95
|
+
const qualifying = c.items
|
|
96
|
+
.filter((i) => i.score !== undefined && i.score >= minScore && i.answer.trim() !== "")
|
|
97
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0) ||
|
|
98
|
+
a.sessionId.localeCompare(b.sessionId) ||
|
|
99
|
+
a.turnNumber - b.turnNumber);
|
|
100
|
+
const best = qualifying[0];
|
|
101
|
+
if (best === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
// Representative question: the shortest question in the cluster (most
|
|
104
|
+
// canonical phrasing), stable tie-break. #55 F4 — the representative
|
|
105
|
+
// question is a raw user turn rendered as a Markdown heading in SKILL.md,
|
|
106
|
+
// so it is redacted with the SAME redactor as the answer (a question can
|
|
107
|
+
// paste a secret/email just as an answer can).
|
|
108
|
+
const rawQuestion = [...c.items]
|
|
109
|
+
.map((i) => i.question)
|
|
110
|
+
.sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
|
|
111
|
+
const question = (await redact(rawQuestion)).trim();
|
|
112
|
+
const answer = (await redact(best.answer)).trim();
|
|
113
|
+
if (question === "" || answer === "")
|
|
114
|
+
continue;
|
|
115
|
+
entries.push({
|
|
116
|
+
question,
|
|
117
|
+
answer,
|
|
118
|
+
sessionCount,
|
|
119
|
+
occurrences: c.items.length,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return entries.sort((a, b) => b.occurrences - a.occurrences || a.question.localeCompare(b.question));
|
|
123
|
+
}
|
|
124
|
+
/** Derive `SessionTurn`s from many sessions' event logs. Convenience wrapper so
|
|
125
|
+
* the CLI and tests build the clustering input the same way. */
|
|
126
|
+
export function turnsFromSessions(perSession) {
|
|
127
|
+
const turns = [];
|
|
128
|
+
for (const { sessionId, events } of perSession) {
|
|
129
|
+
for (const t of deriveTurns(events))
|
|
130
|
+
turns.push({ ...t, sessionId });
|
|
131
|
+
}
|
|
132
|
+
return turns;
|
|
133
|
+
}
|
|
134
|
+
/** YAML-escape a scalar for the SKILL.md frontmatter (JSON is a YAML subset). */
|
|
135
|
+
function yamlScalar(s) {
|
|
136
|
+
return JSON.stringify(s);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Render a valid SKILL.md for the FAQ. Frontmatter carries `name` +
|
|
140
|
+
* `description` (the required pair skills-registry enforces) plus `triggers`;
|
|
141
|
+
* the body is the Q→A list. Guaranteed to parse via `parseSkillFile`.
|
|
142
|
+
*/
|
|
143
|
+
export function buildFaqSkill(entries, opts = {}) {
|
|
144
|
+
const name = opts.name ?? "faq";
|
|
145
|
+
const label = opts.harnessName ?? "this harness";
|
|
146
|
+
const description = `Frequently asked questions for ${label}, distilled from recurring user questions and their best-rated answers. Consult before answering common questions.`;
|
|
147
|
+
const triggers = ["faq", "frequently asked", "common question", "how do i"];
|
|
148
|
+
const bodyLines = [
|
|
149
|
+
`# FAQ — ${label}`,
|
|
150
|
+
"",
|
|
151
|
+
"These are recurring user questions paired with the answers users rated best. Prefer these answers for matching questions.",
|
|
152
|
+
"",
|
|
153
|
+
];
|
|
154
|
+
entries.forEach((e, i) => {
|
|
155
|
+
bodyLines.push(`## Q${i + 1}: ${e.question}`);
|
|
156
|
+
bodyLines.push("");
|
|
157
|
+
bodyLines.push(e.answer);
|
|
158
|
+
bodyLines.push("");
|
|
159
|
+
});
|
|
160
|
+
const frontmatter = [
|
|
161
|
+
"---",
|
|
162
|
+
`name: ${yamlScalar(name)}`,
|
|
163
|
+
`description: ${yamlScalar(description)}`,
|
|
164
|
+
`triggers: ${JSON.stringify(triggers)}`,
|
|
165
|
+
"---",
|
|
166
|
+
].join("\n");
|
|
167
|
+
return `${frontmatter}\n${bodyLines.join("\n")}`;
|
|
168
|
+
}
|
package/dist/feedback.d.ts
CHANGED
|
@@ -165,6 +165,11 @@ export type GraderSpecObject = {
|
|
|
165
165
|
name: string;
|
|
166
166
|
type: "regex";
|
|
167
167
|
pattern: string;
|
|
168
|
+
} | {
|
|
169
|
+
name: string;
|
|
170
|
+
type: "json_path";
|
|
171
|
+
path: string;
|
|
172
|
+
expected?: unknown;
|
|
168
173
|
} | {
|
|
169
174
|
name: string;
|
|
170
175
|
type: "llm_judge";
|
|
@@ -234,6 +239,8 @@ export declare function buildJudgeRubricGrader(positiveComments: ReadonlyArray<s
|
|
|
234
239
|
/** One SampleSchema-valid JSON object per line (round-trips through loadJsonl). */
|
|
235
240
|
export declare function samplesToJsonl(samples: ReadonlyArray<Sample>): string;
|
|
236
241
|
/** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
|
|
237
|
-
* arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
|
|
238
|
-
|
|
242
|
+
* arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
|
|
243
|
+
* `headerLines` replaces the distill provenance header (each line must be a
|
|
244
|
+
* full `# …` YAML comment). */
|
|
245
|
+
export declare function gradersConfigToYaml(config: GradersConfigObject, headerLines?: ReadonlyArray<string>): string;
|
|
239
246
|
export {};
|
package/dist/feedback.js
CHANGED
|
@@ -557,14 +557,19 @@ function commonToken(answers) {
|
|
|
557
557
|
export function samplesToJsonl(samples) {
|
|
558
558
|
return `${samples.map((s) => JSON.stringify(s)).join("\n")}\n`;
|
|
559
559
|
}
|
|
560
|
+
/** The default graders.yaml header — distill's provenance note. Callers with
|
|
561
|
+
* a different provenance (scaffold-evals item 13, graders suggest item 4)
|
|
562
|
+
* pass their own header lines instead. */
|
|
563
|
+
const DISTILL_GRADERS_HEADER = [
|
|
564
|
+
"# Synthesized by `crewhaus distill` from user ratings.",
|
|
565
|
+
"# Exactly one grader — stacking graders hard-ANDs their scores (see eval-grader `all`).",
|
|
566
|
+
];
|
|
560
567
|
/** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
|
|
561
|
-
* arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
"graders:",
|
|
567
|
-
];
|
|
568
|
+
* arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
|
|
569
|
+
* `headerLines` replaces the distill provenance header (each line must be a
|
|
570
|
+
* full `# …` YAML comment). */
|
|
571
|
+
export function gradersConfigToYaml(config, headerLines = DISTILL_GRADERS_HEADER) {
|
|
572
|
+
const lines = [...headerLines, "graders:"];
|
|
568
573
|
for (const g of config.graders) {
|
|
569
574
|
lines.push(` - name: ${JSON.stringify(g.name)}`);
|
|
570
575
|
lines.push(` type: ${g.type}`);
|
|
@@ -577,6 +582,11 @@ export function gradersConfigToYaml(config) {
|
|
|
577
582
|
if (g.case_insensitive !== undefined)
|
|
578
583
|
lines.push(` case_insensitive: ${g.case_insensitive}`);
|
|
579
584
|
}
|
|
585
|
+
else if (g.type === "json_path") {
|
|
586
|
+
lines.push(` path: ${JSON.stringify(g.path)}`);
|
|
587
|
+
if (g.expected !== undefined)
|
|
588
|
+
lines.push(` expected: ${JSON.stringify(g.expected)}`);
|
|
589
|
+
}
|
|
580
590
|
else if (g.type === "llm_judge") {
|
|
581
591
|
lines.push(" rubric:");
|
|
582
592
|
lines.push(" criteria:");
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #54 — harvest up-rated turns into a golden few-shot pool.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus fewshot harvest` mines POSITIVELY-rated conversation turns (the
|
|
5
|
+
* same `user_feedback` ratings `distill` consumes) into a curated pool of
|
|
6
|
+
* input→good-output few-shot examples. The pool is a versioned, provenance-
|
|
7
|
+
* tagged JSONL under `.crewhaus/fewshot/<spec>.jsonl`; `crewhaus optimize
|
|
8
|
+
* --few-shot <file>` injects the top-K examples into the agent prompt as
|
|
9
|
+
* in-context demonstrations, and `agent.instructions` can carry them at
|
|
10
|
+
* compile time via `formatFewShotForPrompt`.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure + deterministic (stable ordering, id-based dedupe)
|
|
13
|
+
* so it is unit-testable; all filesystem access + the model/redactor wiring
|
|
14
|
+
* lives in `apps/cli/src/index.ts` (mirrors `feedback.ts` / `dataset-mine.ts`).
|
|
15
|
+
* The harvested OUTPUT text is the rated assistant answer (or the user's
|
|
16
|
+
* correction), so it is PII/secret-redacted before it lands in the pool: the
|
|
17
|
+
* caller threads a redactor built from `SYNTHESIZE_PII_DETECTORS` (the shared
|
|
18
|
+
* secret/API-key + DEFAULT_PII detector set) so a pasted credential never
|
|
19
|
+
* survives into the pool or the optimizer meta-prompt.
|
|
20
|
+
*/
|
|
21
|
+
import type { FeedbackRecord, SessionTurn } from "./feedback";
|
|
22
|
+
/** Schema version of a persisted few-shot pool line. */
|
|
23
|
+
export declare const FEWSHOT_SCHEMA_VERSION: 1;
|
|
24
|
+
/** One curated few-shot example: an input paired with a known-good output. */
|
|
25
|
+
export type FewShotExample = {
|
|
26
|
+
readonly schemaVersion: 1;
|
|
27
|
+
/** Stable id: `${sessionId}_t${turnNumber}` — the join key, dedupes reruns. */
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly input: string;
|
|
30
|
+
readonly output: string;
|
|
31
|
+
/** [0,1] normalized rating that qualified this turn (or 1 for a correction). */
|
|
32
|
+
readonly score: number;
|
|
33
|
+
/** Provenance for audit + optimizer trust. */
|
|
34
|
+
readonly provenance: {
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
readonly turnNumber: number;
|
|
37
|
+
/** "rating" (up-rated answer) | "correction" (user-supplied better answer). */
|
|
38
|
+
readonly source: "rating" | "correction";
|
|
39
|
+
readonly rater?: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type HarvestOptions = {
|
|
43
|
+
/** Normalized score at/above which a rated turn qualifies. Default 0.7. */
|
|
44
|
+
readonly minScore?: number;
|
|
45
|
+
/** Async redactor applied to the harvested OUTPUT (and correction). The CLI
|
|
46
|
+
* passes a `SYNTHESIZE_PII_DETECTORS`-backed redactor; tests pass a stub. */
|
|
47
|
+
readonly redact?: (text: string) => Promise<string>;
|
|
48
|
+
};
|
|
49
|
+
export type HarvestStats = {
|
|
50
|
+
readonly totalFeedback: number;
|
|
51
|
+
readonly qualified: number;
|
|
52
|
+
readonly skippedUnmatched: number;
|
|
53
|
+
readonly skippedEmpty: number;
|
|
54
|
+
};
|
|
55
|
+
export type HarvestResult = {
|
|
56
|
+
readonly examples: FewShotExample[];
|
|
57
|
+
readonly stats: HarvestStats;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Harvest positively-rated turns into few-shot examples. A turn qualifies when
|
|
61
|
+
* its merged rating normalizes to ≥ `minScore`, OR it carries a `correction`
|
|
62
|
+
* (the user's own better answer — always gold). The example OUTPUT is the
|
|
63
|
+
* correction when present, else the assistant's answer; empty answers are
|
|
64
|
+
* skipped. Deterministic: examples are sorted by (score desc, id asc) and
|
|
65
|
+
* deduped by id, so re-harvesting the same feedback is idempotent.
|
|
66
|
+
*/
|
|
67
|
+
export declare function harvestFewShot(turns: ReadonlyArray<SessionTurn>, feedback: ReadonlyArray<FeedbackRecord>, opts?: HarvestOptions): Promise<HarvestResult>;
|
|
68
|
+
/** Narrow an arbitrary parsed JSON value to a persisted FewShotExample. */
|
|
69
|
+
export declare function isFewShotExample(value: unknown): value is FewShotExample;
|
|
70
|
+
/**
|
|
71
|
+
* Merge a freshly-harvested pool into an existing one, deduping by id (the
|
|
72
|
+
* newer example wins so re-runs refresh outputs without duplicating). Returns
|
|
73
|
+
* the deterministically-ordered union.
|
|
74
|
+
*/
|
|
75
|
+
export declare function mergePools(existing: ReadonlyArray<FewShotExample>, fresh: ReadonlyArray<FewShotExample>): FewShotExample[];
|
|
76
|
+
/** Serialize a pool to JSONL (one example per line). */
|
|
77
|
+
export declare function poolToJsonl(examples: ReadonlyArray<FewShotExample>): string;
|
|
78
|
+
/**
|
|
79
|
+
* Render the top-K examples as an in-context few-shot block for the system
|
|
80
|
+
* prompt / `agent.instructions`. Empty input → "" so callers append
|
|
81
|
+
* unconditionally. `k` caps how many examples are injected (default 5).
|
|
82
|
+
*/
|
|
83
|
+
export declare function formatFewShotForPrompt(examples: ReadonlyArray<FewShotExample>, k?: number): string;
|
package/dist/fewshot.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #54 — harvest up-rated turns into a golden few-shot pool.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus fewshot harvest` mines POSITIVELY-rated conversation turns (the
|
|
5
|
+
* same `user_feedback` ratings `distill` consumes) into a curated pool of
|
|
6
|
+
* input→good-output few-shot examples. The pool is a versioned, provenance-
|
|
7
|
+
* tagged JSONL under `.crewhaus/fewshot/<spec>.jsonl`; `crewhaus optimize
|
|
8
|
+
* --few-shot <file>` injects the top-K examples into the agent prompt as
|
|
9
|
+
* in-context demonstrations, and `agent.instructions` can carry them at
|
|
10
|
+
* compile time via `formatFewShotForPrompt`.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure + deterministic (stable ordering, id-based dedupe)
|
|
13
|
+
* so it is unit-testable; all filesystem access + the model/redactor wiring
|
|
14
|
+
* lives in `apps/cli/src/index.ts` (mirrors `feedback.ts` / `dataset-mine.ts`).
|
|
15
|
+
* The harvested OUTPUT text is the rated assistant answer (or the user's
|
|
16
|
+
* correction), so it is PII/secret-redacted before it lands in the pool: the
|
|
17
|
+
* caller threads a redactor built from `SYNTHESIZE_PII_DETECTORS` (the shared
|
|
18
|
+
* secret/API-key + DEFAULT_PII detector set) so a pasted credential never
|
|
19
|
+
* survives into the pool or the optimizer meta-prompt.
|
|
20
|
+
*/
|
|
21
|
+
import { mergeFeedback, normalizeRating } from "./feedback";
|
|
22
|
+
/** Schema version of a persisted few-shot pool line. */
|
|
23
|
+
export const FEWSHOT_SCHEMA_VERSION = 1;
|
|
24
|
+
function turnKey(sessionId, turnNumber) {
|
|
25
|
+
return `${sessionId}#${turnNumber}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Harvest positively-rated turns into few-shot examples. A turn qualifies when
|
|
29
|
+
* its merged rating normalizes to ≥ `minScore`, OR it carries a `correction`
|
|
30
|
+
* (the user's own better answer — always gold). The example OUTPUT is the
|
|
31
|
+
* correction when present, else the assistant's answer; empty answers are
|
|
32
|
+
* skipped. Deterministic: examples are sorted by (score desc, id asc) and
|
|
33
|
+
* deduped by id, so re-harvesting the same feedback is idempotent.
|
|
34
|
+
*/
|
|
35
|
+
export async function harvestFewShot(turns, feedback, opts = {}) {
|
|
36
|
+
const minScore = opts.minScore ?? 0.7;
|
|
37
|
+
const redact = opts.redact ?? (async (t) => t);
|
|
38
|
+
const turnByKey = new Map();
|
|
39
|
+
for (const t of turns)
|
|
40
|
+
turnByKey.set(turnKey(t.sessionId, t.turnNumber), t);
|
|
41
|
+
const merged = mergeFeedback(feedback);
|
|
42
|
+
const byId = new Map();
|
|
43
|
+
let qualified = 0;
|
|
44
|
+
let skippedUnmatched = 0;
|
|
45
|
+
let skippedEmpty = 0;
|
|
46
|
+
for (const fb of merged) {
|
|
47
|
+
const turn = turnByKey.get(turnKey(fb.sessionId, fb.turnNumber));
|
|
48
|
+
if (turn === undefined) {
|
|
49
|
+
skippedUnmatched += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const score = normalizeRating(fb);
|
|
53
|
+
const isCorrection = fb.correction !== undefined && fb.correction.trim() !== "";
|
|
54
|
+
const isPositive = (score !== undefined && score >= minScore) || isCorrection;
|
|
55
|
+
if (!isPositive)
|
|
56
|
+
continue;
|
|
57
|
+
const rawOutput = isCorrection ? fb.correction : turn.output;
|
|
58
|
+
if (rawOutput.trim() === "") {
|
|
59
|
+
skippedEmpty += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const input = await redact(turn.input);
|
|
63
|
+
const output = await redact(rawOutput);
|
|
64
|
+
if (output.trim() === "") {
|
|
65
|
+
skippedEmpty += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
qualified += 1;
|
|
69
|
+
const id = `${fb.sessionId}_t${fb.turnNumber}`;
|
|
70
|
+
byId.set(id, {
|
|
71
|
+
schemaVersion: FEWSHOT_SCHEMA_VERSION,
|
|
72
|
+
id,
|
|
73
|
+
input,
|
|
74
|
+
output,
|
|
75
|
+
score: isCorrection ? 1 : (score ?? 1),
|
|
76
|
+
provenance: {
|
|
77
|
+
sessionId: fb.sessionId,
|
|
78
|
+
turnNumber: fb.turnNumber,
|
|
79
|
+
source: isCorrection ? "correction" : "rating",
|
|
80
|
+
...(fb.rater !== undefined ? { rater: fb.rater } : {}),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const examples = [...byId.values()].sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
85
|
+
return {
|
|
86
|
+
examples,
|
|
87
|
+
stats: {
|
|
88
|
+
totalFeedback: merged.length,
|
|
89
|
+
qualified,
|
|
90
|
+
skippedUnmatched,
|
|
91
|
+
skippedEmpty,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Narrow an arbitrary parsed JSON value to a persisted FewShotExample. */
|
|
96
|
+
export function isFewShotExample(value) {
|
|
97
|
+
if (typeof value !== "object" || value === null)
|
|
98
|
+
return false;
|
|
99
|
+
const v = value;
|
|
100
|
+
if (v["schemaVersion"] !== 1)
|
|
101
|
+
return false;
|
|
102
|
+
if (typeof v["id"] !== "string")
|
|
103
|
+
return false;
|
|
104
|
+
if (typeof v["input"] !== "string" || typeof v["output"] !== "string")
|
|
105
|
+
return false;
|
|
106
|
+
if (typeof v["score"] !== "number" || !Number.isFinite(v["score"]))
|
|
107
|
+
return false;
|
|
108
|
+
const p = v["provenance"];
|
|
109
|
+
if (typeof p !== "object" || p === null)
|
|
110
|
+
return false;
|
|
111
|
+
const pr = p;
|
|
112
|
+
return typeof pr["sessionId"] === "string" && typeof pr["turnNumber"] === "number";
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Merge a freshly-harvested pool into an existing one, deduping by id (the
|
|
116
|
+
* newer example wins so re-runs refresh outputs without duplicating). Returns
|
|
117
|
+
* the deterministically-ordered union.
|
|
118
|
+
*/
|
|
119
|
+
export function mergePools(existing, fresh) {
|
|
120
|
+
const byId = new Map();
|
|
121
|
+
for (const e of existing)
|
|
122
|
+
byId.set(e.id, e);
|
|
123
|
+
for (const e of fresh)
|
|
124
|
+
byId.set(e.id, e);
|
|
125
|
+
return [...byId.values()].sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
126
|
+
}
|
|
127
|
+
/** Serialize a pool to JSONL (one example per line). */
|
|
128
|
+
export function poolToJsonl(examples) {
|
|
129
|
+
return `${examples.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
130
|
+
}
|
|
131
|
+
const FEWSHOT_CLOSE_TAG_RE = /<\s*\/\s*few_shot_examples\s*>/gi;
|
|
132
|
+
/**
|
|
133
|
+
* #54 F5 — delimiter safety. An example's input/output is user/model text and
|
|
134
|
+
* can be adversarially shaped, so before interpolating it into the
|
|
135
|
+
* `<few_shot_examples>` block we neutralize any embedded closing tag: a
|
|
136
|
+
* `</few_shot_examples>` inside an example would otherwise let a poisoned
|
|
137
|
+
* example terminate the block early and inject trailing instructions. The
|
|
138
|
+
* closing tag is rewritten to an inert `<\/few_shot_examples>` (content
|
|
139
|
+
* preserved for the model, but no longer parses as the real delimiter).
|
|
140
|
+
* Redaction of secrets/PII already runs upstream — this is purely structural.
|
|
141
|
+
*/
|
|
142
|
+
function escapeFewShotContent(text) {
|
|
143
|
+
return text.replace(FEWSHOT_CLOSE_TAG_RE, "<\\/few_shot_examples>");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Render the top-K examples as an in-context few-shot block for the system
|
|
147
|
+
* prompt / `agent.instructions`. Empty input → "" so callers append
|
|
148
|
+
* unconditionally. `k` caps how many examples are injected (default 5).
|
|
149
|
+
*/
|
|
150
|
+
export function formatFewShotForPrompt(examples, k = 5) {
|
|
151
|
+
const top = examples.slice(0, Math.max(0, k));
|
|
152
|
+
if (top.length === 0)
|
|
153
|
+
return "";
|
|
154
|
+
const blocks = top
|
|
155
|
+
.map((e, i) => `Example ${i + 1}:\nUser: ${escapeFewShotContent(e.input)}\nAssistant: ${escapeFewShotContent(e.output)}`)
|
|
156
|
+
.join("\n\n");
|
|
157
|
+
return `<few_shot_examples>\nThese are examples of responses users rated highly. Follow their style and quality:\n\n${blocks}\n</few_shot_examples>`;
|
|
158
|
+
}
|