@nebulacomponents/citable 1.14.0 → 1.15.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/CHANGELOG.md +66 -0
- package/README.md +1 -1
- package/dist/universal/.agents/skills/citable/SKILL.md +11 -6
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +4 -1
- package/dist/universal/.agents/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.agents/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.agents/skills/citable/manifest.json +10 -9
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +11 -6
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +4 -1
- package/dist/universal/.claude/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.claude/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.claude/skills/citable/manifest.json +10 -9
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/SKILL.md +11 -6
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +4 -1
- package/dist/universal/.cursor/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +10 -9
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/SKILL.md +11 -6
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +4 -1
- package/dist/universal/.gemini/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +10 -9
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.github/skills/citable/SKILL.md +11 -6
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +4 -1
- package/dist/universal/.github/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.github/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.github/skills/citable/manifest.json +10 -9
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/SKILL.md +11 -6
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +4 -1
- package/dist/universal/.kiro/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +10 -9
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/SKILL.md +11 -6
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +4 -1
- package/dist/universal/.opencode/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +10 -9
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -6
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +4 -1
- package/dist/universal/.pi/agent/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +10 -9
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/SKILL.md +11 -6
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +4 -1
- package/dist/universal/.qoder/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +10 -9
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -6
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +4 -1
- package/dist/universal/.rovodev/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +10 -9
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -6
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +4 -1
- package/dist/universal/.trae-cn/skills/citable/commands/observe-and-act.md +12 -5
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +57 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +10 -9
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/schedule.schema.json +1 -1
- package/dist/universal/manifest.json +124 -112
- package/package.json +1 -1
- package/schemas/observation.schema.json +1 -1
- package/schemas/schedule.schema.json +1 -1
- package/skill/SKILL.md +11 -6
- package/skill/commands/README.md +4 -1
- package/skill/commands/observe-and-act.md +12 -5
- package/skill/commands/reporting.md +57 -0
- package/src/cli/index.js +78 -11
- package/src/commands/connect.js +29 -0
- package/src/commands/context.js +15 -0
- package/src/commands/delivery.js +86 -2
- package/src/commands/monitor.js +35 -0
- package/src/commands/observe.js +3 -1
- package/src/commands/reportDashboard.js +21 -0
- package/src/commands/reportShareOfVoice.js +60 -0
- package/src/connectors/http.js +6 -3
- package/src/connectors/index.js +3 -1
- package/src/connectors/webflow.js +224 -0
- package/src/connectors/wordpress.js +226 -0
- package/src/detectors/geoReco.js +101 -0
- package/src/monitoring/alertDelivery.js +98 -0
- package/src/observations/common.js +10 -0
- package/src/observations/stance.js +261 -0
- package/src/reporting/dashboard.js +254 -0
- package/src/reporting/shareOfVoice.js +362 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { validatePublicUrl } from '../crawler/fetch.js';
|
|
5
|
+
import { nowIso, sha256, writeJson } from '../shared/io.js';
|
|
6
|
+
|
|
7
|
+
export const SEVERITY_ORDER = {
|
|
8
|
+
informational: 1,
|
|
9
|
+
medium: 2,
|
|
10
|
+
high: 3,
|
|
11
|
+
critical: 4,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function filterAlerts(alerts = [], minSeverity = 'informational') {
|
|
15
|
+
const minRank = SEVERITY_ORDER[minSeverity] ?? SEVERITY_ORDER.informational;
|
|
16
|
+
return alerts.filter((item) => (SEVERITY_ORDER[item.severity] ?? 0) >= minRank);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildAlertPayload({ runA, runB, source = 'monitor', summary = {}, alerts = [], timestamp = nowIso() } = {}) {
|
|
20
|
+
const critOrHigh = alerts.filter((a) => ['critical', 'high'].includes(a.severity)).length;
|
|
21
|
+
return {
|
|
22
|
+
event: 'citable.regression_alert',
|
|
23
|
+
source,
|
|
24
|
+
generated_at: timestamp,
|
|
25
|
+
run_a: runA ?? null,
|
|
26
|
+
run_b: runB,
|
|
27
|
+
summary: {
|
|
28
|
+
total_alerts: alerts.length,
|
|
29
|
+
critical_or_high: critOrHigh,
|
|
30
|
+
...summary,
|
|
31
|
+
},
|
|
32
|
+
alerts,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function dispatchAlertWebhook(root, payload, {
|
|
37
|
+
webhookUrl,
|
|
38
|
+
headers = {},
|
|
39
|
+
timeoutMs = 10000,
|
|
40
|
+
fetchImpl = globalThis.fetch,
|
|
41
|
+
lookup,
|
|
42
|
+
allowPrivateForTest = false,
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (!webhookUrl) throw new Error('webhookUrl is required for alert dispatch');
|
|
45
|
+
if (!allowPrivateForTest) {
|
|
46
|
+
await validatePublicUrl(webhookUrl, { lookup });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deliveryId = `DELIVERY-${nowIso().replace(/[:.]/g, '')}-${crypto.randomBytes(4).toString('hex')}`;
|
|
50
|
+
const payloadString = JSON.stringify(payload);
|
|
51
|
+
const payloadHash = sha256(Buffer.from(payloadString));
|
|
52
|
+
const deliveriesDir = path.join(root, '.citable', 'monitoring', 'deliveries');
|
|
53
|
+
const receiptFile = path.join(deliveriesDir, `${deliveryId}.json`);
|
|
54
|
+
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
57
|
+
|
|
58
|
+
let statusCode = null;
|
|
59
|
+
let success = false;
|
|
60
|
+
let errorMessage = null;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(webhookUrl, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'content-type': 'application/json',
|
|
67
|
+
'x-citable-event': 'citable.regression_alert',
|
|
68
|
+
'x-citable-delivery': deliveryId,
|
|
69
|
+
...headers,
|
|
70
|
+
},
|
|
71
|
+
body: payloadString,
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
statusCode = res.status;
|
|
76
|
+
success = res.ok;
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
errorMessage = `HTTP ${res.status} ${res.statusText || 'Error'}`;
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
errorMessage = err.name === 'AbortError' ? `Request timed out after ${timeoutMs}ms` : err.message;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const receipt = {
|
|
86
|
+
delivery_id: deliveryId,
|
|
87
|
+
target_url: webhookUrl,
|
|
88
|
+
delivered_at: nowIso(),
|
|
89
|
+
status_code: statusCode,
|
|
90
|
+
success,
|
|
91
|
+
payload_hash: payloadHash,
|
|
92
|
+
error: errorMessage,
|
|
93
|
+
summary: payload.summary,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
writeJson(receiptFile, receipt);
|
|
97
|
+
return { ...receipt, receipt_file: receiptFile };
|
|
98
|
+
}
|
|
@@ -62,6 +62,7 @@ export function summarizeObservations(items) {
|
|
|
62
62
|
}
|
|
63
63
|
competitiveSourceMap.set(domain, current);
|
|
64
64
|
}
|
|
65
|
+
const stances = items.filter((item) => item.kind === 'stance');
|
|
65
66
|
return {
|
|
66
67
|
total: items.length, by_kind: byKind, by_state: byState,
|
|
67
68
|
citation_metrics: citations.length ? {
|
|
@@ -74,5 +75,14 @@ export function summarizeObservations(items) {
|
|
|
74
75
|
competitive_domains: [...new Set(reviews.filter((x) => !x.data.first_party).map((x) => { try { return new URL(x.data.canonical_url).hostname; } catch { return null; } }).filter(Boolean))].sort(),
|
|
75
76
|
competitive_sources: [...competitiveSourceMap.values()].sort((a, b) => a.domain.localeCompare(b.domain)),
|
|
76
77
|
} : null,
|
|
78
|
+
stance_metrics: stances.length ? {
|
|
79
|
+
total: stances.length,
|
|
80
|
+
favorable: stances.filter((x) => x.data.stance === 'favorable').length,
|
|
81
|
+
neutral: stances.filter((x) => x.data.stance === 'neutral').length,
|
|
82
|
+
unfavorable: stances.filter((x) => x.data.stance === 'unfavorable').length,
|
|
83
|
+
mixed: stances.filter((x) => x.data.stance === 'mixed').length,
|
|
84
|
+
ambiguous: stances.filter((x) => x.data.stance === 'ambiguous').length,
|
|
85
|
+
review_required: stances.filter((x) => x.state === 'review_required').length,
|
|
86
|
+
} : null,
|
|
77
87
|
};
|
|
78
88
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { envelope, observationRun, readInput } from './common.js';
|
|
2
|
+
import { loadRegistries } from '../registries/index.js';
|
|
3
|
+
import { sha256 } from '../shared/io.js';
|
|
4
|
+
|
|
5
|
+
const FAVORABLE_MARKERS = [
|
|
6
|
+
/\b(?:highly\s+)?recommended\b/i,
|
|
7
|
+
/\bindustry\s+standard\b/i,
|
|
8
|
+
/\bmarket\s+leader\b/i,
|
|
9
|
+
/\bbest(?:-in-class)?\b/i,
|
|
10
|
+
/\btop(?:\s+choice|\s+tier|\s+rated)?\b/i,
|
|
11
|
+
/\bpreferred\s+(?:solution|option|tool|platform)\b/i,
|
|
12
|
+
/\breliable\b/i,
|
|
13
|
+
/\brobust\b/i,
|
|
14
|
+
/\bsecure\b/i,
|
|
15
|
+
/\bperformant\b/i,
|
|
16
|
+
/\befficient\b/i,
|
|
17
|
+
/\bcomprehensive\b/i,
|
|
18
|
+
/\btrusted\b/i,
|
|
19
|
+
/\bintuitive\b/i,
|
|
20
|
+
/\bexcellent\b/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const UNFAVORABLE_MARKERS = [
|
|
24
|
+
/\bcriticized\s+for\b/i,
|
|
25
|
+
/\bexpensive\b/i,
|
|
26
|
+
/\boverpriced\b/i,
|
|
27
|
+
/\bunreliable\b/i,
|
|
28
|
+
/\boutdated\b/i,
|
|
29
|
+
/\blegacy\b/i,
|
|
30
|
+
/\bslow\b/i,
|
|
31
|
+
/\bbuggy\b/i,
|
|
32
|
+
/\bunstable\b/i,
|
|
33
|
+
/\bvulnerabilit(?:y|ies)\b/i,
|
|
34
|
+
/\bsecurity\s+flaw[s]?\b/i,
|
|
35
|
+
/\bsecurity\s+issue[s]?\b/i,
|
|
36
|
+
/\bbreach(?:es)?\b/i,
|
|
37
|
+
/\blacks?\s+(?:critical|essential|basic|key)\b/i,
|
|
38
|
+
/\bmissing\s+(?:critical|essential|key)\b/i,
|
|
39
|
+
/\bpoor\s+(?:performance|support|usability|documentation)\b/i,
|
|
40
|
+
/\bavoid\b/i,
|
|
41
|
+
/\bnot\s+recommended\b/i,
|
|
42
|
+
/\binferior\b/i,
|
|
43
|
+
/\bworst\b/i,
|
|
44
|
+
/\bsteep\s+learning\s+curve\b/i,
|
|
45
|
+
/\bfrustrating\b/i,
|
|
46
|
+
/\bfrequent\s+(?:downtime|crashes|errors|failures)\b/i,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function escapeRegex(text) {
|
|
50
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract sentences from text.
|
|
55
|
+
*/
|
|
56
|
+
function splitSentences(text) {
|
|
57
|
+
if (!text) return [];
|
|
58
|
+
return String(text)
|
|
59
|
+
.replace(/([.?!])\s+/g, '$1|§|')
|
|
60
|
+
.split('|§|')
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Classify tone / stance towards a specific entity within an answer text.
|
|
67
|
+
* Stance categories: 'favorable', 'neutral', 'unfavorable', 'mixed', 'ambiguous'.
|
|
68
|
+
*/
|
|
69
|
+
export function classifyAnswerStance(answerText, entity, { reviewer = null, manualStance = null } = {}) {
|
|
70
|
+
const entityId = entity?.entity_id || 'unknown';
|
|
71
|
+
const canonicalName = entity?.canonical_name || entityId;
|
|
72
|
+
const names = [canonicalName, ...(entity?.aliases || [])].filter(Boolean);
|
|
73
|
+
|
|
74
|
+
if (!names.length || !answerText) {
|
|
75
|
+
return {
|
|
76
|
+
entity_id: entityId,
|
|
77
|
+
canonical_name: canonicalName,
|
|
78
|
+
mentioned: false,
|
|
79
|
+
stance: 'not_mentioned',
|
|
80
|
+
confidence: 'confirmed',
|
|
81
|
+
evidence_passages: [],
|
|
82
|
+
favorable_markers: [],
|
|
83
|
+
unfavorable_markers: [],
|
|
84
|
+
tone_summary: 'Entity not mentioned in text',
|
|
85
|
+
reviewer,
|
|
86
|
+
review_required: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if any alias / canonical name is mentioned
|
|
91
|
+
const namePatterns = names.map((n) => new RegExp(`\\b${escapeRegex(n)}\\b`, 'i'));
|
|
92
|
+
const isMentioned = namePatterns.some((pattern) => pattern.test(answerText));
|
|
93
|
+
|
|
94
|
+
if (!isMentioned) {
|
|
95
|
+
return {
|
|
96
|
+
entity_id: entityId,
|
|
97
|
+
canonical_name: canonicalName,
|
|
98
|
+
mentioned: false,
|
|
99
|
+
stance: 'not_mentioned',
|
|
100
|
+
confidence: 'confirmed',
|
|
101
|
+
evidence_passages: [],
|
|
102
|
+
favorable_markers: [],
|
|
103
|
+
unfavorable_markers: [],
|
|
104
|
+
tone_summary: 'Entity not mentioned in text',
|
|
105
|
+
reviewer,
|
|
106
|
+
review_required: false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If a manual stance from a human reviewer is already recorded, honor it directly
|
|
111
|
+
if (manualStance && ['favorable', 'neutral', 'unfavorable', 'mixed', 'ambiguous'].includes(manualStance)) {
|
|
112
|
+
return {
|
|
113
|
+
entity_id: entityId,
|
|
114
|
+
canonical_name: canonicalName,
|
|
115
|
+
mentioned: true,
|
|
116
|
+
stance: manualStance,
|
|
117
|
+
confidence: reviewer ? 'confirmed' : 'high',
|
|
118
|
+
evidence_passages: [answerText.slice(0, 300)],
|
|
119
|
+
favorable_markers: [],
|
|
120
|
+
unfavorable_markers: [],
|
|
121
|
+
tone_summary: `Human-adjudicated stance: ${manualStance}`,
|
|
122
|
+
reviewer,
|
|
123
|
+
review_required: !reviewer,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Find sentences mentioning the entity
|
|
128
|
+
const sentences = splitSentences(answerText);
|
|
129
|
+
const matchedIndices = new Set();
|
|
130
|
+
sentences.forEach((sentence, idx) => {
|
|
131
|
+
if (namePatterns.some((p) => p.test(sentence))) {
|
|
132
|
+
matchedIndices.add(idx);
|
|
133
|
+
// include adjacent sentence context if available
|
|
134
|
+
if (idx > 0) matchedIndices.add(idx - 1);
|
|
135
|
+
if (idx < sentences.length - 1) matchedIndices.add(idx + 1);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const contextSentences = [...matchedIndices].sort((a, b) => a - b).map((idx) => sentences[idx]);
|
|
140
|
+
const contextText = contextSentences.join(' ');
|
|
141
|
+
|
|
142
|
+
const favorableHits = [];
|
|
143
|
+
for (const rx of FAVORABLE_MARKERS) {
|
|
144
|
+
const m = contextText.match(rx);
|
|
145
|
+
if (m) favorableHits.push(m[0].toLowerCase());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const unfavorableHits = [];
|
|
149
|
+
for (const rx of UNFAVORABLE_MARKERS) {
|
|
150
|
+
const m = contextText.match(rx);
|
|
151
|
+
if (m) unfavorableHits.push(m[0].toLowerCase());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let stance = 'neutral';
|
|
155
|
+
if (unfavorableHits.length > 0 && favorableHits.length > 0) {
|
|
156
|
+
stance = 'mixed';
|
|
157
|
+
} else if (unfavorableHits.length > 0) {
|
|
158
|
+
stance = 'unfavorable';
|
|
159
|
+
} else if (favorableHits.length > 0) {
|
|
160
|
+
stance = 'favorable';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const toneSummary = stance === 'unfavorable'
|
|
164
|
+
? `Unfavorable tone detected with markers: ${unfavorableHits.join(', ')}`
|
|
165
|
+
: stance === 'favorable'
|
|
166
|
+
? `Favorable tone detected with markers: ${favorableHits.join(', ')}`
|
|
167
|
+
: stance === 'mixed'
|
|
168
|
+
? `Mixed tone detected (favorable: ${favorableHits.join(', ')}; unfavorable: ${unfavorableHits.join(', ')})`
|
|
169
|
+
: 'Neutral / descriptive tone without evaluative polarity';
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
entity_id: entityId,
|
|
173
|
+
canonical_name: canonicalName,
|
|
174
|
+
mentioned: true,
|
|
175
|
+
stance,
|
|
176
|
+
confidence: reviewer ? 'confirmed' : 'medium',
|
|
177
|
+
evidence_passages: contextSentences.slice(0, 3),
|
|
178
|
+
favorable_markers: [...new Set(favorableHits)],
|
|
179
|
+
unfavorable_markers: [...new Set(unfavorableHits)],
|
|
180
|
+
tone_summary: toneSummary,
|
|
181
|
+
reviewer,
|
|
182
|
+
review_required: !reviewer,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute `citable observe stance`.
|
|
188
|
+
*/
|
|
189
|
+
export async function observeStance(root, options = {}) {
|
|
190
|
+
const input = readInput(options.input);
|
|
191
|
+
const { registries, problems } = loadRegistries(root);
|
|
192
|
+
if (problems.length) throw new Error(`registry validation failed: ${problems.join('; ')}`);
|
|
193
|
+
|
|
194
|
+
const entities = (registries.entities?.entries || []).filter((e) => e.status !== 'retired');
|
|
195
|
+
if (!entities.length) throw new Error('no active entities found in entity registry');
|
|
196
|
+
|
|
197
|
+
const targetEntities = options.entity
|
|
198
|
+
? entities.filter((e) => e.entity_id === options.entity || e.canonical_name.toLowerCase() === options.entity.toLowerCase())
|
|
199
|
+
: entities;
|
|
200
|
+
|
|
201
|
+
if (options.entity && !targetEntities.length) {
|
|
202
|
+
throw new Error(`entity not found: ${options.entity}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Parse candidate items from input
|
|
206
|
+
const doc = input.value;
|
|
207
|
+
const items = Array.isArray(doc)
|
|
208
|
+
? doc
|
|
209
|
+
: doc.observations || doc.items || doc.answers || doc.prompts || [doc];
|
|
210
|
+
|
|
211
|
+
const observations = [];
|
|
212
|
+
for (const item of items) {
|
|
213
|
+
const answerText = item.answer_text || item.answer || item.text || item.data?.answer_text || '';
|
|
214
|
+
if (!answerText) continue;
|
|
215
|
+
|
|
216
|
+
const promptId = item.prompt_id || item.data?.prompt_id || 'unknown';
|
|
217
|
+
const promptText = item.prompt_text || item.data?.prompt_text || null;
|
|
218
|
+
const engine = item.provider || item.engine || item.data?.provider || item.data?.engine || 'unknown';
|
|
219
|
+
const reviewer = options.reviewer || item.reviewer || item.evaluator || null;
|
|
220
|
+
|
|
221
|
+
for (const entity of targetEntities) {
|
|
222
|
+
const classification = classifyAnswerStance(answerText, entity, {
|
|
223
|
+
reviewer,
|
|
224
|
+
manualStance: item.stance || item.sentiment,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!classification.mentioned) continue;
|
|
228
|
+
|
|
229
|
+
const data = {
|
|
230
|
+
prompt_id: promptId,
|
|
231
|
+
prompt_text: promptText,
|
|
232
|
+
engine,
|
|
233
|
+
entity_id: entity.entity_id,
|
|
234
|
+
canonical_name: entity.canonical_name,
|
|
235
|
+
stance: classification.stance,
|
|
236
|
+
evidence_passages: classification.evidence_passages,
|
|
237
|
+
favorable_markers: classification.favorable_markers,
|
|
238
|
+
unfavorable_markers: classification.unfavorable_markers,
|
|
239
|
+
tone_summary: classification.tone_summary,
|
|
240
|
+
reviewer: classification.reviewer,
|
|
241
|
+
review_required: classification.review_required,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const raw = JSON.stringify({ item, classification });
|
|
245
|
+
observations.push(envelope('stance', data, {
|
|
246
|
+
method: reviewer ? 'human_review' : 'static_analysis',
|
|
247
|
+
source: input.file,
|
|
248
|
+
state: classification.review_required ? 'review_required' : 'observed',
|
|
249
|
+
confidence: classification.confidence,
|
|
250
|
+
raw,
|
|
251
|
+
limitations: classification.review_required
|
|
252
|
+
? ['Automated stance classification is an evidence-backed semantic finding; authoritative determination requires a named human reviewer per the narrative-accuracy rubric.']
|
|
253
|
+
: [],
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return observationRun(root, 'observe stance', input.file, observations, {
|
|
259
|
+
rawInputs: { stance_input: input.raw },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson } from '../shared/io.js';
|
|
4
|
+
|
|
5
|
+
const POSTURE_CLASS = {
|
|
6
|
+
pass: 'posture-pass',
|
|
7
|
+
partial: 'posture-partial',
|
|
8
|
+
fail: 'posture-fail',
|
|
9
|
+
not_established: 'posture-unknown',
|
|
10
|
+
observed: 'posture-pass',
|
|
11
|
+
not_evidenced: 'posture-unknown',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const NO_GUARANTEE = 'This dashboard renders values already recorded by each audit run. It derives no new findings, combines nothing into an AI visibility score, and guarantees no crawling, indexing, ranking, citation, recommendation, or conversion outcome.';
|
|
15
|
+
|
|
16
|
+
function isAuditSummary(summary) {
|
|
17
|
+
return Boolean(summary) && typeof summary === 'object' && Boolean(summary.by_severity) && Boolean(summary.posture);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load every audit run that recorded a summary.json, oldest first. Run IDs are
|
|
22
|
+
* `<ISO-timestamp>-<command>-<rand>`, so a lexicographic sort is chronological.
|
|
23
|
+
* A run whose evidence cannot be read is reported in `skipped`, never dropped
|
|
24
|
+
* silently and never thrown.
|
|
25
|
+
*/
|
|
26
|
+
export function loadRunHistory(root, { since, last } = {}) {
|
|
27
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
28
|
+
const included = [];
|
|
29
|
+
const skipped = [];
|
|
30
|
+
if (!fs.existsSync(runsDir)) return { included, skipped };
|
|
31
|
+
let runIds = fs.readdirSync(runsDir)
|
|
32
|
+
.filter((runId) => fs.existsSync(path.join(runsDir, runId, 'summary.json')))
|
|
33
|
+
.sort();
|
|
34
|
+
if (since) runIds = runIds.filter((runId) => runId >= since);
|
|
35
|
+
for (const runId of runIds) {
|
|
36
|
+
const dir = path.join(runsDir, runId);
|
|
37
|
+
try {
|
|
38
|
+
const summary = readJson(path.join(dir, 'summary.json'));
|
|
39
|
+
if (!isAuditSummary(summary)) {
|
|
40
|
+
skipped.push({ run_id: runId, reason: 'summary.json records no by_severity or posture; observation runs are not part of the audit trend' });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
included.push({ run_id: runId, manifest: readJson(path.join(dir, 'manifest.json')), summary });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
skipped.push({ run_id: runId, reason: err.message });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// `last` counts audit runs that carry usable evidence, so an unreadable or
|
|
49
|
+
// observation-only run cannot silently consume the requested window. Skipped
|
|
50
|
+
// runs are reported unless `last` moved the window past them; `since` has
|
|
51
|
+
// already bounded them above.
|
|
52
|
+
const windowed = Number.isInteger(last) && last > 0 ? included.slice(-last) : included;
|
|
53
|
+
const truncated = windowed.length < included.length && windowed.length > 0;
|
|
54
|
+
return {
|
|
55
|
+
included: windowed,
|
|
56
|
+
skipped: truncated ? skipped.filter((item) => item.run_id >= windowed[0].run_id) : skipped,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Project the loaded runs into parallel, run-aligned series. */
|
|
61
|
+
export function buildTimeSeries(included) {
|
|
62
|
+
return {
|
|
63
|
+
run_ids: included.map((run) => run.run_id),
|
|
64
|
+
timestamps: included.map((run) => run.manifest?.timestamp ?? null),
|
|
65
|
+
by_severity_series: included.map((run) => run.summary.by_severity ?? {}),
|
|
66
|
+
retrieval_eligibility_series: included.map((run) => run.summary.posture?.retrieval_eligibility?.result ?? null),
|
|
67
|
+
source_extraction_series: included.map((run) => run.summary.posture?.source_extraction_and_support?.result ?? null),
|
|
68
|
+
citation_presence_rate_series: included.map((run) => run.summary.posture?.observed_citation_behavior?.citation_presence_rate ?? null),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function severityCount(bySeverity, severity) {
|
|
73
|
+
return bySeverity?.[severity] ?? 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatRate(rate) {
|
|
77
|
+
return rate == null ? 'not evidenced' : `${(rate * 100).toFixed(1)}%`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatPosture(result) {
|
|
81
|
+
return result ?? 'not recorded';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatTimestamp(timestamp) {
|
|
85
|
+
return timestamp ?? 'not recorded';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderDashboardMarkdown(series, skipped = []) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
const runCount = series.run_ids.length;
|
|
91
|
+
lines.push(`# Citable evidence dashboard`);
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(`- Audit runs included: ${runCount}`);
|
|
94
|
+
lines.push(`- Runs skipped: ${skipped.length}`);
|
|
95
|
+
if (runCount) lines.push(`- Window: \`${series.run_ids[0]}\` → \`${series.run_ids[runCount - 1]}\``);
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push(`> ${NO_GUARANTEE}`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
if (runCount < 2) {
|
|
100
|
+
lines.push('## Insufficient history');
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push(runCount === 0
|
|
103
|
+
? 'No audit run in `.citable/runs/` recorded a summary. No trend is established. Run `citable audit` to record comparable evidence.'
|
|
104
|
+
: 'Only one audit run is available. A single point establishes no trend, no direction, and no rate of change. Record at least two comparable runs before reading movement into these values.');
|
|
105
|
+
lines.push('');
|
|
106
|
+
}
|
|
107
|
+
if (runCount) {
|
|
108
|
+
lines.push('## Run history');
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push('| Run | Timestamp | Critical | High | Medium | Retrieval eligibility | Source extraction | Citation presence rate |');
|
|
111
|
+
lines.push('| --- | --- | --- | --- | --- | --- | --- | --- |');
|
|
112
|
+
for (let i = 0; i < runCount; i++) {
|
|
113
|
+
const sev = series.by_severity_series[i];
|
|
114
|
+
lines.push(`| \`${series.run_ids[i]}\` | ${formatTimestamp(series.timestamps[i])} | ${severityCount(sev, 'critical')} | ${severityCount(sev, 'high')} | ${severityCount(sev, 'medium')} | ${formatPosture(series.retrieval_eligibility_series[i])} | ${formatPosture(series.source_extraction_series[i])} | ${formatRate(series.citation_presence_rate_series[i])} |`);
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push('Counts come from each run\'s own recorded summary. A lower count is an observed difference between runs, not proof that a condition was fixed — see `citable compare-snapshots` for run comparability.');
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
if (skipped.length) {
|
|
121
|
+
lines.push('## Skipped runs');
|
|
122
|
+
lines.push('');
|
|
123
|
+
for (const item of skipped) lines.push(`- \`${item.run_id}\`: ${item.reason}`);
|
|
124
|
+
lines.push('');
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function escapeHtml(value) {
|
|
130
|
+
return String(value)
|
|
131
|
+
.replace(/&/g, '&')
|
|
132
|
+
.replace(/</g, '<')
|
|
133
|
+
.replace(/>/g, '>')
|
|
134
|
+
.replace(/"/g, '"')
|
|
135
|
+
.replace(/'/g, ''');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Map run-aligned values to `<polyline points>` coordinates. Non-numeric entries
|
|
140
|
+
* (a run with no evidenced value) are omitted rather than interpolated, and
|
|
141
|
+
* fewer than two numeric points yields no line at all.
|
|
142
|
+
*/
|
|
143
|
+
function sparklinePath(values, { width = 480, height = 80 } = {}) {
|
|
144
|
+
const pad = 3;
|
|
145
|
+
const points = values.map((value, index) => ({ value, index })).filter((point) => Number.isFinite(point.value));
|
|
146
|
+
if (points.length < 2) return '';
|
|
147
|
+
const min = Math.min(...points.map((point) => point.value));
|
|
148
|
+
const max = Math.max(...points.map((point) => point.value));
|
|
149
|
+
const span = max - min;
|
|
150
|
+
const steps = Math.max(values.length - 1, 1);
|
|
151
|
+
return points.map((point) => {
|
|
152
|
+
const x = pad + (point.index / steps) * (width - pad * 2);
|
|
153
|
+
const y = span === 0 ? height / 2 : (height - pad) - ((point.value - min) / span) * (height - pad * 2);
|
|
154
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
155
|
+
}).join(' ');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderSparkline(title, values, format) {
|
|
159
|
+
const points = sparklinePath(values);
|
|
160
|
+
const numeric = values.filter((value) => Number.isFinite(value));
|
|
161
|
+
const html = [];
|
|
162
|
+
html.push('<section class="chart">');
|
|
163
|
+
html.push(`<h3>${escapeHtml(title)}</h3>`);
|
|
164
|
+
if (!points) {
|
|
165
|
+
html.push(`<p class="note">Fewer than two runs recorded a numeric value for this series (${numeric.length} of ${values.length}). No trend line is drawn.</p>`);
|
|
166
|
+
} else {
|
|
167
|
+
html.push(`<svg viewBox="0 0 480 80" width="480" height="80" role="img" aria-label="${escapeHtml(title)}">`);
|
|
168
|
+
html.push(`<polyline points="${points}" fill="none" stroke="#2f6f4f" stroke-width="2" />`);
|
|
169
|
+
html.push('</svg>');
|
|
170
|
+
html.push(`<p class="note">Range across ${numeric.length} run(s) with a recorded value: ${escapeHtml(format(Math.min(...numeric)))} to ${escapeHtml(format(Math.max(...numeric)))}. The vertical axis is scaled to this range only; runs without a recorded value are omitted, not interpolated.</p>`);
|
|
171
|
+
}
|
|
172
|
+
html.push('</section>');
|
|
173
|
+
return html.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderPostureRow(label, results, runIds) {
|
|
177
|
+
const cells = results.map((result, index) => `<span class="posture ${POSTURE_CLASS[result] ?? 'posture-unknown'}" title="${escapeHtml(runIds[index])}">${escapeHtml(formatPosture(result))}</span>`).join('');
|
|
178
|
+
return `<section class="chart"><h3>${escapeHtml(label)}</h3><div class="posture-row">${cells}</div><p class="note">Categorical states in run order. These are not plotted as numbers because they have no defined numeric distance.</p></section>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function renderDashboardHtml(series, skipped = []) {
|
|
182
|
+
const runCount = series.run_ids.length;
|
|
183
|
+
const criticalHigh = series.by_severity_series.map((sev) => severityCount(sev, 'critical') + severityCount(sev, 'high'));
|
|
184
|
+
const html = [];
|
|
185
|
+
html.push('<!doctype html>');
|
|
186
|
+
html.push('<html lang="en">');
|
|
187
|
+
html.push('<head>');
|
|
188
|
+
html.push('<meta charset="utf-8" />');
|
|
189
|
+
html.push('<meta name="viewport" content="width=device-width, initial-scale=1" />');
|
|
190
|
+
html.push('<title>Citable evidence dashboard</title>');
|
|
191
|
+
html.push('<style>');
|
|
192
|
+
html.push('body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #1c1c1c; }');
|
|
193
|
+
html.push('table { border-collapse: collapse; width: 100%; font-size: 0.875rem; }');
|
|
194
|
+
html.push('th, td { border: 1px solid #d4d4d4; padding: 0.375rem 0.5rem; text-align: left; }');
|
|
195
|
+
html.push('th { background: #f3f3f3; }');
|
|
196
|
+
html.push('code { font-size: 0.8125rem; }');
|
|
197
|
+
html.push('blockquote { border-left: 4px solid #999; margin: 0 0 1.5rem; padding: 0.25rem 1rem; color: #333; }');
|
|
198
|
+
html.push('.note { color: #555; font-size: 0.8125rem; }');
|
|
199
|
+
html.push('.chart { margin: 1.5rem 0; }');
|
|
200
|
+
html.push('.chart svg { background: #fafafa; border: 1px solid #e4e4e4; }');
|
|
201
|
+
html.push('.posture-row { display: flex; flex-wrap: wrap; gap: 0.25rem; }');
|
|
202
|
+
html.push('.posture { border-radius: 3px; font-size: 0.75rem; padding: 0.125rem 0.375rem; }');
|
|
203
|
+
html.push('.posture-pass { background: #e2f2e6; color: #1d5c33; }');
|
|
204
|
+
html.push('.posture-partial { background: #fbf1d8; color: #7a5510; }');
|
|
205
|
+
html.push('.posture-fail { background: #fbe0e0; color: #8c1f1f; }');
|
|
206
|
+
html.push('.posture-unknown { background: #ececec; color: #4a4a4a; }');
|
|
207
|
+
html.push('.insufficient { background: #fbf1d8; border: 1px solid #e2c98a; padding: 0.75rem 1rem; }');
|
|
208
|
+
html.push('</style>');
|
|
209
|
+
html.push('</head>');
|
|
210
|
+
html.push('<body>');
|
|
211
|
+
html.push('<h1>Citable evidence dashboard</h1>');
|
|
212
|
+
html.push('<ul>');
|
|
213
|
+
html.push(`<li>Audit runs included: ${runCount}</li>`);
|
|
214
|
+
html.push(`<li>Runs skipped: ${skipped.length}</li>`);
|
|
215
|
+
if (runCount) html.push(`<li>Window: <code>${escapeHtml(series.run_ids[0])}</code> to <code>${escapeHtml(series.run_ids[runCount - 1])}</code></li>`);
|
|
216
|
+
html.push('</ul>');
|
|
217
|
+
html.push(`<blockquote>${escapeHtml(NO_GUARANTEE)}</blockquote>`);
|
|
218
|
+
if (runCount < 2) {
|
|
219
|
+
html.push('<section class="insufficient">');
|
|
220
|
+
html.push('<h2>Insufficient history</h2>');
|
|
221
|
+
html.push(`<p>${runCount === 0
|
|
222
|
+
? 'No audit run in <code>.citable/runs/</code> recorded a summary. No trend is established. Run <code>citable audit</code> to record comparable evidence.'
|
|
223
|
+
: 'Only one audit run is available. A single point establishes no trend, no direction, and no rate of change. Record at least two comparable runs before reading movement into these values.'}</p>`);
|
|
224
|
+
html.push('</section>');
|
|
225
|
+
}
|
|
226
|
+
if (runCount) {
|
|
227
|
+
html.push('<h2>Trends</h2>');
|
|
228
|
+
html.push(renderSparkline('Critical and high findings per run', criticalHigh, (value) => String(value)));
|
|
229
|
+
html.push(renderSparkline('Observed citation presence rate per run', series.citation_presence_rate_series, formatRate));
|
|
230
|
+
html.push(renderPostureRow('Retrieval eligibility per run', series.retrieval_eligibility_series, series.run_ids));
|
|
231
|
+
html.push(renderPostureRow('Source extraction and support per run', series.source_extraction_series, series.run_ids));
|
|
232
|
+
html.push('<h2>Run history</h2>');
|
|
233
|
+
html.push('<table>');
|
|
234
|
+
html.push('<thead><tr><th>Run</th><th>Timestamp</th><th>Critical</th><th>High</th><th>Medium</th><th>Retrieval eligibility</th><th>Source extraction</th><th>Citation presence rate</th></tr></thead>');
|
|
235
|
+
html.push('<tbody>');
|
|
236
|
+
for (let i = 0; i < runCount; i++) {
|
|
237
|
+
const sev = series.by_severity_series[i];
|
|
238
|
+
html.push(`<tr><td><code>${escapeHtml(series.run_ids[i])}</code></td><td>${escapeHtml(formatTimestamp(series.timestamps[i]))}</td><td>${severityCount(sev, 'critical')}</td><td>${severityCount(sev, 'high')}</td><td>${severityCount(sev, 'medium')}</td><td>${escapeHtml(formatPosture(series.retrieval_eligibility_series[i]))}</td><td>${escapeHtml(formatPosture(series.source_extraction_series[i]))}</td><td>${escapeHtml(formatRate(series.citation_presence_rate_series[i]))}</td></tr>`);
|
|
239
|
+
}
|
|
240
|
+
html.push('</tbody>');
|
|
241
|
+
html.push('</table>');
|
|
242
|
+
html.push('<p class="note">Counts come from each run's own recorded summary. A lower count is an observed difference between runs, not proof that a condition was fixed.</p>');
|
|
243
|
+
}
|
|
244
|
+
if (skipped.length) {
|
|
245
|
+
html.push('<h2>Skipped runs</h2>');
|
|
246
|
+
html.push('<ul>');
|
|
247
|
+
for (const item of skipped) html.push(`<li><code>${escapeHtml(item.run_id)}</code>: ${escapeHtml(item.reason)}</li>`);
|
|
248
|
+
html.push('</ul>');
|
|
249
|
+
}
|
|
250
|
+
html.push('</body>');
|
|
251
|
+
html.push('</html>');
|
|
252
|
+
html.push('');
|
|
253
|
+
return html.join('\n');
|
|
254
|
+
}
|