@sanity/ailf 0.1.34 → 0.3.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/config/airbyte/ai_literacy_framework.connector.yaml +6 -0
  3. package/config/bigquery/views/reports.sql +1 -0
  4. package/dist/_vendor/ailf-core/examples/index.d.ts +10 -20
  5. package/dist/_vendor/ailf-core/examples/index.js +10 -20
  6. package/dist/_vendor/ailf-core/ports/task-source.d.ts +2 -0
  7. package/dist/_vendor/ailf-core/types/index.d.ts +65 -0
  8. package/dist/_vendor/ailf-tasks/schemas.d.ts +12 -0
  9. package/dist/_vendor/ailf-tasks/schemas.js +4 -0
  10. package/dist/adapters/task-sources/content-lake-task-source.js +9 -1
  11. package/dist/adapters/task-sources/repo-task-source.js +19 -4
  12. package/dist/commands/calculate-scores.js +5 -1
  13. package/dist/commands/publish.js +3 -0
  14. package/dist/composition-root.js +7 -2
  15. package/dist/orchestration/pipeline-orchestrator.js +27 -2
  16. package/dist/orchestration/step-runner.js +8 -0
  17. package/dist/orchestration/steps/calculate-scores-step.js +22 -19
  18. package/dist/orchestration/steps/generate-configs-step.js +1 -0
  19. package/dist/orchestration/steps/grader-consistency-step.js +1 -0
  20. package/dist/orchestration/steps/mirror-repo-tasks-step.js +2 -1
  21. package/dist/orchestration/steps/publish-report-step.js +3 -0
  22. package/dist/pipeline/calculate-scores.d.ts +11 -1
  23. package/dist/pipeline/calculate-scores.js +222 -157
  24. package/dist/pipeline/coverage-audit.d.ts +2 -1
  25. package/dist/pipeline/coverage-audit.js +5 -3
  26. package/dist/pipeline/expand-tasks.d.ts +2 -1
  27. package/dist/pipeline/expand-tasks.js +33 -2
  28. package/dist/pipeline/generate-configs.d.ts +3 -1
  29. package/dist/pipeline/generate-configs.js +51 -37
  30. package/dist/pipeline/grader-api.d.ts +2 -1
  31. package/dist/pipeline/grader-api.js +11 -9
  32. package/dist/pipeline/grader-compare-runner.d.ts +3 -0
  33. package/dist/pipeline/grader-compare-runner.js +21 -19
  34. package/dist/pipeline/grader-consistency-runner.d.ts +3 -0
  35. package/dist/pipeline/grader-consistency-runner.js +16 -14
  36. package/dist/pipeline/grader-sensitivity-runner.d.ts +3 -0
  37. package/dist/pipeline/grader-sensitivity-runner.js +18 -16
  38. package/dist/pipeline/grader-validate-runner.d.ts +3 -0
  39. package/dist/pipeline/grader-validate-runner.js +16 -14
  40. package/dist/pipeline/mirror-repo-tasks.d.ts +80 -1
  41. package/dist/pipeline/mirror-repo-tasks.js +148 -32
  42. package/dist/pipeline/provenance.d.ts +3 -0
  43. package/dist/pipeline/provenance.js +25 -3
  44. package/dist/pipeline/report-title.d.ts +66 -0
  45. package/dist/pipeline/report-title.js +118 -0
  46. package/dist/report-store.js +2 -0
  47. package/dist/sinks/bigquery/index.d.ts +1 -0
  48. package/dist/sinks/bigquery/index.js +1 -0
  49. package/dist/sources.d.ts +2 -1
  50. package/dist/sources.js +28 -1
  51. package/package.json +23 -23
@@ -13,7 +13,9 @@
13
13
  * @see docs/exec-plans/tasks-as-content/phase-5-content-lake-mirroring.md
14
14
  */
15
15
  import { createHash } from "crypto";
16
- import { isSlugRef, } from "../_vendor/ailf-core/index.js";
16
+ import { readFileSync } from "fs";
17
+ import { isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../_vendor/ailf-core/index.js";
18
+ import { ConsoleLogger } from "../adapters/loggers/index.js";
17
19
  // ---------------------------------------------------------------------------
18
20
  // Public API
19
21
  // ---------------------------------------------------------------------------
@@ -29,7 +31,8 @@ import { isSlugRef, } from "../_vendor/ailf-core/index.js";
29
31
  * 6. Upsert the ailf.task document with origin block
30
32
  */
31
33
  export async function mirrorRepoTasks(options) {
32
- const { client, tasks, git, dryRun = false } = options;
34
+ const { client, tasks, git, dryRun = false, logger } = options;
35
+ const log = logger ?? new ConsoleLogger();
33
36
  const result = {
34
37
  total: tasks.length,
35
38
  upserted: 0,
@@ -54,29 +57,37 @@ export async function mirrorRepoTasks(options) {
54
57
  }
55
58
  // Ensure all feature areas exist
56
59
  const areas = [...new Set(tasks.map((t) => t.featureArea))];
57
- const createdAreas = await ensureFeatureAreas(client, areas, dryRun);
60
+ const createdAreas = await ensureFeatureAreas(client, areas, dryRun, log);
58
61
  result.areasCreated = createdAreas;
59
- // Fetch existing mirror document content hashes for change detection
62
+ // Fetch existing mirror document state for change detection + ownership check
60
63
  const mirrorIds = tasks.map((t) => mirrorDocId(git.owner, git.name, t.id));
61
- const existingHashes = await fetchExistingHashes(client, mirrorIds);
64
+ const existingDocState = await fetchExistingDocState(client, mirrorIds);
62
65
  // Mirror each task
63
66
  for (const task of tasks) {
64
67
  try {
65
68
  const docId = mirrorDocId(git.owner, git.name, task.id);
69
+ const existing = existingDocState.get(docId);
70
+ // Skip graduated tasks — ownership was changed to "studio"
71
+ if (existing?.ownership === "studio") {
72
+ log.info(` ℹ️ Skipping "${task.id}" — graduated to Studio ownership`);
73
+ result.skipped++;
74
+ continue;
75
+ }
66
76
  const contentHash = computeTaskHash(task);
67
77
  // Skip unchanged
68
- if (existingHashes.get(docId) === contentHash) {
78
+ if (existing?.hash === contentHash) {
69
79
  result.skipped++;
70
80
  continue;
71
81
  }
72
82
  const doc = buildMirrorDocument(task, {
73
83
  contentHash,
74
84
  docId,
85
+ existingAuthor: existing?.existingAuthor,
75
86
  git,
76
87
  slugToDocId,
77
88
  });
78
89
  if (dryRun) {
79
- console.log(` [dry-run] Would upsert: ${docId}`);
90
+ log.info(` [dry-run] Would upsert: ${docId}`);
80
91
  result.upserted++;
81
92
  continue;
82
93
  }
@@ -106,12 +117,15 @@ export async function detectGitContext(repoTasksPath) {
106
117
  if (ghRepo) {
107
118
  const [owner, name] = ghRepo.split("/");
108
119
  const branch = ghHeadRef || ghRef.replace("refs/heads/", "").replace("refs/tags/", "");
120
+ // Extract author from GitHub Actions environment
121
+ const author = detectGitHubActionsAuthor();
109
122
  return {
110
123
  repo: ghRepo,
111
124
  owner: owner ?? "unknown",
112
125
  name: name ?? "unknown",
113
126
  branch: branch || "unknown",
114
127
  commitSha: ghSha || "unknown",
128
+ author,
115
129
  };
116
130
  }
117
131
  // Fallback: try git CLI
@@ -134,12 +148,15 @@ export async function detectGitContext(repoTasksPath) {
134
148
  remote.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/);
135
149
  const owner = match?.[1] ?? "unknown";
136
150
  const name = match?.[2] ?? "unknown";
151
+ // Extract author from git log
152
+ const author = detectGitCliAuthor(repoTasksPath, execSync);
137
153
  return {
138
154
  repo: `${owner}/${name}`,
139
155
  owner,
140
156
  name,
141
157
  branch,
142
158
  commitSha,
159
+ author,
143
160
  };
144
161
  }
145
162
  catch {
@@ -149,10 +166,63 @@ export async function detectGitContext(repoTasksPath) {
149
166
  name: "unknown",
150
167
  branch: "unknown",
151
168
  commitSha: "unknown",
169
+ author: {},
152
170
  };
153
171
  }
154
172
  }
155
173
  // ---------------------------------------------------------------------------
174
+ // Author detection helpers
175
+ // ---------------------------------------------------------------------------
176
+ /**
177
+ * Extract author info from GitHub Actions environment variables and
178
+ * the webhook event payload (GITHUB_EVENT_PATH).
179
+ */
180
+ function detectGitHubActionsAuthor() {
181
+ const ghActor = process.env.GITHUB_ACTOR ?? undefined;
182
+ const author = { githubUsername: ghActor };
183
+ // Try to read richer author info from the event payload
184
+ const eventPath = process.env.GITHUB_EVENT_PATH;
185
+ if (eventPath) {
186
+ try {
187
+ const event = JSON.parse(readFileSync(eventPath, "utf-8"));
188
+ // Push event: head_commit.author has name + email
189
+ if (event.head_commit?.author) {
190
+ author.gitName = event.head_commit.author.name ?? undefined;
191
+ author.gitEmail = event.head_commit.author.email ?? undefined;
192
+ }
193
+ // PR event: pull_request.user.login is the PR author
194
+ if (event.pull_request?.user?.login) {
195
+ author.githubUsername = event.pull_request.user.login;
196
+ }
197
+ }
198
+ catch {
199
+ // Event payload parsing is best-effort — fall through with GITHUB_ACTOR only
200
+ }
201
+ }
202
+ return author;
203
+ }
204
+ /**
205
+ * Extract author info from git CLI (local fallback when not in GitHub Actions).
206
+ */
207
+ function detectGitCliAuthor(cwd, execSyncFn) {
208
+ try {
209
+ const authorLine = execSyncFn('git log -1 --format="%an|%ae"', {
210
+ encoding: "utf-8",
211
+ cwd,
212
+ })
213
+ .toString()
214
+ .trim();
215
+ const [gitName, gitEmail] = authorLine.split("|");
216
+ return {
217
+ gitName: gitName || undefined,
218
+ gitEmail: gitEmail || undefined,
219
+ };
220
+ }
221
+ catch {
222
+ return {};
223
+ }
224
+ }
225
+ // ---------------------------------------------------------------------------
156
226
  // Document ID scheme
157
227
  // ---------------------------------------------------------------------------
158
228
  /**
@@ -219,7 +289,7 @@ async function batchResolveDocSlugs(client, slugs) {
219
289
  * Ensure ailf.featureArea documents exist for all referenced areas.
220
290
  * Returns the list of newly created area IDs.
221
291
  */
222
- async function ensureFeatureAreas(client, areas, dryRun) {
292
+ async function ensureFeatureAreas(client, areas, dryRun, log) {
223
293
  if (areas.length === 0)
224
294
  return [];
225
295
  // Check which areas already exist
@@ -230,7 +300,7 @@ async function ensureFeatureAreas(client, areas, dryRun) {
230
300
  return [];
231
301
  if (dryRun) {
232
302
  for (const area of missing) {
233
- console.log(` [dry-run] Would create feature area: ${area}`);
303
+ log.info(` [dry-run] Would create feature area: ${area}`);
234
304
  }
235
305
  return missing;
236
306
  }
@@ -247,42 +317,82 @@ async function ensureFeatureAreas(client, areas, dryRun) {
247
317
  await transaction.commit();
248
318
  return missing;
249
319
  }
250
- // ---------------------------------------------------------------------------
251
- // Fetch existing content hashes
252
- // ---------------------------------------------------------------------------
253
320
  /**
254
- * Fetch existing mirror documents' content hashes for change detection.
255
- * The hash is stored in origin.contentHash on the document.
321
+ * Fetch existing mirror documents' ownership and content hashes.
322
+ *
323
+ * The ownership field determines whether the mirror step is allowed to
324
+ * update the document:
325
+ * - `"repo"` or absent → safe to update (active mirror)
326
+ * - `"studio"` → skip (graduated to Studio ownership)
327
+ *
328
+ * The hash is stored in origin.contentHash for change detection.
256
329
  */
257
- async function fetchExistingHashes(client, docIds) {
330
+ async function fetchExistingDocState(client, docIds) {
258
331
  if (docIds.length === 0)
259
332
  return new Map();
260
- const query = `*[_id in $ids] { _id, "hash": origin.contentHash }`;
261
- const results = await client.fetch(query, {
262
- ids: docIds,
263
- });
333
+ const query = `*[_id in $ids] {
334
+ _id,
335
+ "hash": origin.contentHash,
336
+ ownership,
337
+ "existingAuthor": origin.author
338
+ }`;
339
+ const results = await client.fetch(query, { ids: docIds });
264
340
  const map = new Map();
265
341
  for (const r of results) {
266
- if (r.hash)
267
- map.set(r._id, r.hash);
342
+ map.set(r._id, {
343
+ hash: r.hash ?? undefined,
344
+ ownership: r.ownership ?? undefined,
345
+ existingAuthor: r.existingAuthor ?? undefined,
346
+ });
268
347
  }
269
348
  return map;
270
349
  }
271
350
  // ---------------------------------------------------------------------------
272
351
  // Build mirror document
273
352
  // ---------------------------------------------------------------------------
274
- function buildMirrorDocument(task, opts) {
275
- const { contentHash, docId, git, slugToDocId } = opts;
276
- // Build canonical docs with resolved references.
277
- // Only slug refs can be resolved to article references today.
278
- // Other ref types (path, id, perspective) are stored with reason only.
353
+ /** @internal Exported for testing — not part of the public API. */
354
+ export function buildMirrorDocument(task, opts) {
355
+ const { contentHash, docId, existingAuthor, git, slugToDocId } = opts;
356
+ // Build canonical docs with resolved references and correct refType.
357
+ // Each ref type gets the appropriate resolution fields set on the
358
+ // mirror document so Studio can display them correctly.
279
359
  const canonicalDocs = task.canonicalDocs.map((ref, i) => {
280
- const resolvedId = isSlugRef(ref) ? slugToDocId.get(ref.slug) : undefined;
281
- return {
282
- _key: `cd${i}`,
283
- ...(resolvedId ? { doc: { _ref: resolvedId, _type: "reference" } } : {}),
284
- reason: ref.reason ?? "",
285
- };
360
+ const base = { _key: `cd${i}`, reason: ref.reason ?? "" };
361
+ if (isSlugRef(ref)) {
362
+ const resolvedId = slugToDocId.get(ref.slug);
363
+ // When a slug resolves to a document, store as "id" ref with
364
+ // the resolved article reference. When unresolved, store as
365
+ // "slug" so Studio knows the resolution strategy even if the
366
+ // article doesn't exist yet.
367
+ return {
368
+ ...base,
369
+ refType: resolvedId ? "id" : "slug",
370
+ ...(resolvedId
371
+ ? { doc: { _ref: resolvedId, _type: "reference" } }
372
+ : {}),
373
+ };
374
+ }
375
+ if (isPathRef(ref)) {
376
+ return { ...base, refType: "path", path: ref.path };
377
+ }
378
+ if (isIdRef(ref)) {
379
+ return {
380
+ ...base,
381
+ refType: "id",
382
+ ...(ref.id
383
+ ? { doc: { _ref: ref.id, _type: "reference" }, docId: ref.id }
384
+ : {}),
385
+ };
386
+ }
387
+ if (isPerspectiveRef(ref)) {
388
+ return {
389
+ ...base,
390
+ refType: "perspective",
391
+ perspective: ref.perspective,
392
+ };
393
+ }
394
+ // Unknown ref type — store with reason only
395
+ return base;
286
396
  });
287
397
  // Build assertions
288
398
  const assertArray = task.assertions.map((a, i) => {
@@ -315,6 +425,8 @@ function buildMirrorDocument(task, opts) {
315
425
  return {
316
426
  _id: docId,
317
427
  _type: "ailf.task",
428
+ ownership: "repo",
429
+ status: task.status ?? "active",
318
430
  assert: assertArray,
319
431
  canonicalDocs,
320
432
  description: task.description,
@@ -325,6 +437,7 @@ function buildMirrorDocument(task, opts) {
325
437
  },
326
438
  id: { _type: "slug", current: task.id },
327
439
  origin: {
440
+ // Existing provenance fields
328
441
  branch: git.branch,
329
442
  commitSha: git.commitSha,
330
443
  contentHash,
@@ -334,6 +447,9 @@ function buildMirrorDocument(task, opts) {
334
447
  repoName: git.name,
335
448
  repoOwner: git.owner,
336
449
  type: "repo",
450
+ // Authorship: author is write-once (preserve existing), lastEditor always updates
451
+ author: existingAuthor ?? git.author,
452
+ lastEditor: git.author,
337
453
  },
338
454
  taskPrompt: task.taskPrompt,
339
455
  ...(task.baseline
@@ -11,11 +11,14 @@
11
11
  * @see docs/design-docs/report-store/domain-model.md
12
12
  * @see docs/design-docs/report-store/architecture.md — Provenance collection
13
13
  */
14
+ import type { Logger } from "../_vendor/ailf-core/index.d.ts";
14
15
  import type { ResolvedSourceConfig } from "../sources.js";
15
16
  import type { EvalMode, PromptfooUrlEntry, ReportAutoScope, ReportProvenance } from "./types.js";
16
17
  export interface ProvenanceInput {
17
18
  /** Feature areas that were evaluated */
18
19
  areas: string[];
20
+ /** Logger instance (defaults to ConsoleLogger) */
21
+ logger?: Logger;
19
22
  /** Release auto-scope metadata (when perspective evaluation was scoped) */
20
23
  autoScope?: ReportAutoScope;
21
24
  /**
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from "fs";
15
15
  import { resolve } from "path";
16
16
  import { load } from "js-yaml";
17
+ import { ConsoleLogger } from "../adapters/loggers/index.js";
17
18
  /**
18
19
  * Build a ReportProvenance object from pipeline context.
19
20
  *
@@ -24,7 +25,20 @@ import { load } from "js-yaml";
24
25
  * - Optional metadata (context hash, Promptfoo URL)
25
26
  */
26
27
  export function buildProvenance(input) {
27
- const models = loadModelsConfig(input.rootDir);
28
+ const log = input.logger ?? new ConsoleLogger();
29
+ const models = loadModelsConfig(input.rootDir, log);
30
+ log.debug("Assembling provenance input", {
31
+ mode: input.mode,
32
+ sourceName: input.source.name,
33
+ sourceBaseUrl: input.source.baseUrl,
34
+ areas: input.areas,
35
+ taskIds: input.taskIds,
36
+ hasContextHash: Boolean(input.contextHash),
37
+ hasEvalFingerprint: Boolean(input.evalFingerprint),
38
+ hasCallerGit: Boolean(input.callerGit),
39
+ hasSourceReportId: Boolean(input.sourceReportId),
40
+ modelCount: models.models.length,
41
+ });
28
42
  // Cross-repo evaluations: prefer explicit caller git metadata over
29
43
  // CI env vars (which always reflect the AILF core repo).
30
44
  const git = input.callerGit
@@ -39,6 +53,14 @@ export function buildProvenance(input) {
39
53
  const lineage = input.sourceReportId
40
54
  ? { rerunOf: input.sourceReportId }
41
55
  : undefined;
56
+ const trigger = detectTrigger();
57
+ log.debug("Provenance computed", {
58
+ triggerType: trigger.type,
59
+ gitRepo: git?.repo,
60
+ gitBranch: git?.branch,
61
+ evalFingerprint: input.evalFingerprint,
62
+ hasLineage: Boolean(lineage),
63
+ });
42
64
  return {
43
65
  areas: input.areas,
44
66
  autoScope: input.autoScope,
@@ -149,13 +171,13 @@ function detectTrigger() {
149
171
  * Load config/models.yaml to extract model list and grader info.
150
172
  * Falls back to a minimal config if the file can't be read.
151
173
  */
152
- function loadModelsConfig(rootDir) {
174
+ function loadModelsConfig(rootDir, log) {
153
175
  try {
154
176
  const content = readFileSync(resolve(rootDir, "config", "models.yaml"), "utf-8");
155
177
  return load(content);
156
178
  }
157
179
  catch {
158
- console.warn(" ⚠️ Could not read config/models.yaml for provenance");
180
+ log.warn("Could not read config/models.yaml for provenance");
159
181
  return {
160
182
  defaults: {},
161
183
  grader: { id: "unknown" },
@@ -0,0 +1,66 @@
1
+ /**
2
+ * pipeline/report-title.ts
3
+ *
4
+ * Pure function that generates descriptive report titles from provenance
5
+ * metadata. The title is the primary display string shown in dashboards,
6
+ * Slack digests, and Studio views — it conveys trigger context, evaluated
7
+ * areas, source/perspective, and document scope at a glance.
8
+ *
9
+ * Score is intentionally omitted from the title since it is surfaced
10
+ * heavily elsewhere in the UI. The `tag` field (on Report) is preserved
11
+ * as a secondary label; the title is the primary display string.
12
+ *
13
+ * Segments are joined with ` · ` (middle dot with spaces).
14
+ *
15
+ * @see docs/design-docs/report-store/domain-model.md
16
+ * @see packages/eval/src/pipeline/provenance.ts — builds the provenance input
17
+ */
18
+ import type { EvalMode, ReportTrigger } from "./types.js";
19
+ /** Input required to generate a human-readable report title. */
20
+ export interface ReportTitleInput {
21
+ provenance: {
22
+ /** Feature areas that were evaluated */
23
+ areas: string[];
24
+ /** Evaluation mode */
25
+ mode: EvalMode;
26
+ /** Resolved documentation source */
27
+ source: {
28
+ name: string;
29
+ perspective?: string;
30
+ };
31
+ /** Sanity document IDs targeted (when scoped to specific documents) */
32
+ targetDocuments?: string[];
33
+ /** What triggered the evaluation */
34
+ trigger: ReportTrigger;
35
+ };
36
+ /**
37
+ * Total number of known feature areas in the system.
38
+ * Used to determine whether to show "All areas" vs "N areas"
39
+ * when more than 3 areas are evaluated.
40
+ */
41
+ totalAreaCount?: number;
42
+ }
43
+ /**
44
+ * Generate a descriptive report title from provenance metadata.
45
+ *
46
+ * The title is composed of up to four segments separated by ` · `:
47
+ *
48
+ * 1. **Trigger context** — what initiated the evaluation (always present)
49
+ * 2. **Areas** — which feature areas were evaluated (omitted if empty)
50
+ * 3. **Source context** — non-default source or perspective (omitted if default)
51
+ * 4. **Target documents** — scoped document IDs (omitted if not scoped)
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * generateReportTitle({
56
+ * provenance: {
57
+ * areas: ["GROQ", "Mutations"],
58
+ * mode: "baseline",
59
+ * source: { name: "production" },
60
+ * trigger: { type: "manual" },
61
+ * },
62
+ * })
63
+ * // → "Manual eval · GROQ, Mutations"
64
+ * ```
65
+ */
66
+ export declare function generateReportTitle(input: ReportTitleInput): string;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * pipeline/report-title.ts
3
+ *
4
+ * Pure function that generates descriptive report titles from provenance
5
+ * metadata. The title is the primary display string shown in dashboards,
6
+ * Slack digests, and Studio views — it conveys trigger context, evaluated
7
+ * areas, source/perspective, and document scope at a glance.
8
+ *
9
+ * Score is intentionally omitted from the title since it is surfaced
10
+ * heavily elsewhere in the UI. The `tag` field (on Report) is preserved
11
+ * as a secondary label; the title is the primary display string.
12
+ *
13
+ * Segments are joined with ` · ` (middle dot with spaces).
14
+ *
15
+ * @see docs/design-docs/report-store/domain-model.md
16
+ * @see packages/eval/src/pipeline/provenance.ts — builds the provenance input
17
+ */
18
+ // ---------------------------------------------------------------------------
19
+ // Segment builders
20
+ // ---------------------------------------------------------------------------
21
+ const SEPARATOR = " · ";
22
+ /** Segment 1 — human-readable trigger context */
23
+ function triggerSegment(trigger) {
24
+ switch (trigger.type) {
25
+ case "scheduled": {
26
+ const name = trigger.schedule.replace(/-/g, " ");
27
+ return name.charAt(0).toUpperCase() + name.slice(1);
28
+ }
29
+ case "ci":
30
+ return "CI eval";
31
+ case "webhook":
32
+ return "Content change";
33
+ case "cross-repo": {
34
+ // Only show the repo name if callerRepo looks like "owner/repo".
35
+ // Numeric IDs (e.g. GITHUB_REPOSITORY_OWNER_ID fallback) are not useful.
36
+ const repo = trigger.callerRepo;
37
+ if (repo.includes("/")) {
38
+ const shortName = repo.split("/").pop() ?? repo;
39
+ return `Cross-repo (${shortName})`;
40
+ }
41
+ return "Cross-repo";
42
+ }
43
+ case "manual":
44
+ return "Manual eval";
45
+ }
46
+ }
47
+ /** Segment 2 — areas evaluated (omitted when empty) */
48
+ function areasSegment(areas, totalAreaCount) {
49
+ if (areas.length === 0)
50
+ return undefined;
51
+ if (areas.length <= 3) {
52
+ return areas.join(", ");
53
+ }
54
+ if (totalAreaCount !== undefined && areas.length === totalAreaCount) {
55
+ return "All areas";
56
+ }
57
+ return `${areas.length} areas`;
58
+ }
59
+ /** Segment 3 — source context (omitted when default production, no perspective) */
60
+ function sourceSegment(source) {
61
+ const parts = [];
62
+ if (source.perspective) {
63
+ parts.push(`perspective: ${source.perspective}`);
64
+ }
65
+ if (source.name !== "production") {
66
+ parts.push(source.name);
67
+ }
68
+ return parts.length > 0 ? parts.join(", ") : undefined;
69
+ }
70
+ /** Segment 4 — target documents (omitted when not scoped) */
71
+ function targetDocumentsSegment(targetDocuments) {
72
+ if (!targetDocuments || targetDocuments.length === 0)
73
+ return undefined;
74
+ if (targetDocuments.length === 1) {
75
+ return targetDocuments[0];
76
+ }
77
+ return `${targetDocuments.length} documents`;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Public API
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Generate a descriptive report title from provenance metadata.
84
+ *
85
+ * The title is composed of up to four segments separated by ` · `:
86
+ *
87
+ * 1. **Trigger context** — what initiated the evaluation (always present)
88
+ * 2. **Areas** — which feature areas were evaluated (omitted if empty)
89
+ * 3. **Source context** — non-default source or perspective (omitted if default)
90
+ * 4. **Target documents** — scoped document IDs (omitted if not scoped)
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * generateReportTitle({
95
+ * provenance: {
96
+ * areas: ["GROQ", "Mutations"],
97
+ * mode: "baseline",
98
+ * source: { name: "production" },
99
+ * trigger: { type: "manual" },
100
+ * },
101
+ * })
102
+ * // → "Manual eval · GROQ, Mutations"
103
+ * ```
104
+ */
105
+ export function generateReportTitle(input) {
106
+ const { provenance, totalAreaCount } = input;
107
+ const segments = [triggerSegment(provenance.trigger)];
108
+ const areas = areasSegment(provenance.areas, totalAreaCount);
109
+ if (areas)
110
+ segments.push(areas);
111
+ const source = sourceSegment(provenance.source);
112
+ if (source)
113
+ segments.push(source);
114
+ const docs = targetDocumentsSegment(provenance.targetDocuments);
115
+ if (docs)
116
+ segments.push(docs);
117
+ return segments.join(SEPARATOR);
118
+ }
@@ -203,6 +203,7 @@ export class ReportStore {
203
203
  reportId: report.id,
204
204
  summary: report.summary,
205
205
  tag: report.tag ?? null,
206
+ title: report.title ?? null,
206
207
  });
207
208
  return report.id;
208
209
  }
@@ -255,5 +256,6 @@ function toReport(doc) {
255
256
  provenance: doc.provenance,
256
257
  summary: doc.summary,
257
258
  tag: doc.tag,
259
+ title: doc.title,
258
260
  };
259
261
  }
@@ -71,6 +71,7 @@ export interface ReportRow {
71
71
  source_name: string;
72
72
  source_perspective: null | string;
73
73
  tag: null | string;
74
+ title: null | string;
74
75
  total_cost: null | number;
75
76
  trigger_caller_repo: null | string;
76
77
  trigger_type: string;
@@ -213,6 +213,7 @@ export function flattenReportRow(report) {
213
213
  source_name: provenance.source.name,
214
214
  source_perspective: provenance.source.perspective ?? null,
215
215
  tag: report.tag ?? null,
216
+ title: report.title ?? null,
216
217
  total_cost: summary.overall.cost?.total ?? null,
217
218
  trigger_caller_repo: provenance.trigger.type === "cross-repo"
218
219
  ? provenance.trigger.callerRepo
package/dist/sources.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * Environment variables in config/sources.yaml are resolved via ${{ VAR | default }}
13
13
  * interpolation at load time.
14
14
  */
15
+ import type { Logger } from "./_vendor/ailf-core/index.d.ts";
15
16
  /**
16
17
  * @deprecated Use {@link ResolvedSourceConfig} instead.
17
18
  * Kept as a type alias for backward compatibility during the transition.
@@ -89,7 +90,7 @@ export interface SourceOverrides {
89
90
  * @param name - Source name from config/sources.yaml. If omitted, uses DOC_SOURCE
90
91
  * env var or the first source defined in config/sources.yaml.
91
92
  */
92
- export declare function loadSource(name?: string, overrides?: SourceOverrides): ResolvedSourceConfig;
93
+ export declare function loadSource(name?: string, overrides?: SourceOverrides, logger?: Logger): ResolvedSourceConfig;
93
94
  /**
94
95
  * Match a hostname against an origin pattern that may contain `*` wildcards.
95
96
  *