optbrew 0.1.2 → 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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +94 -0
- package/CHANGELOG.md +74 -1
- package/README.ko.md +129 -105
- package/README.md +129 -105
- package/THIRD_PARTY_NOTICES.md +88 -0
- package/docs/platforms.md +45 -8
- package/docs/release.md +37 -4
- package/lib/cli.mjs +5 -2
- package/lib/install.mjs +2 -1
- package/lib/records.mjs +2 -1
- package/lib/transaction.mjs +12 -5
- package/lib/update.mjs +3 -2
- package/package.json +29 -9
- package/plugin.json +2 -2
- package/skills/.claude-plugin/icon.png +0 -0
- package/skills/.claude-plugin/plugin.json +23 -0
- package/skills/README.md +27 -0
- package/skills/optbrew/SKILL.md +223 -8
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
- package/skills/optbrew/bundle-manifest.json +409 -49
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
- package/skills/optbrew/references/ai-crawlers.json +1 -1
- package/skills/optbrew/references/ai-crawlers.md +1 -1
- package/skills/optbrew/references/api.md +6 -4
- package/skills/optbrew/references/check-registry.json +16 -16
- package/skills/optbrew/references/chrome-paths.json +31 -0
- package/skills/optbrew/references/endpoints.json +10 -0
- package/skills/optbrew/references/harness-signals.json +5 -0
- package/skills/optbrew/references/intake.md +76 -5
- package/skills/optbrew/references/judging.md +233 -0
- package/skills/optbrew/references/limits.json +26 -0
- package/skills/optbrew/references/measurement.md +1 -1
- package/skills/optbrew/references/messages.ko.json +170 -0
- package/skills/optbrew/references/report.md +18 -6
- package/skills/optbrew/references/routing.md +2 -2
- package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
- package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
- package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
- package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
- package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
- package/skills/optbrew/references/runbooks/index.json +18 -0
- package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
- package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
- package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
- package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
- package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
- package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
- package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
- package/skills/optbrew/references/runbooks/seranking.md +38 -0
- package/skills/optbrew/references/schemas/contract.schema.json +42 -15
- package/skills/optbrew/references/scoring.md +40 -5
- package/skills/optbrew/references/source-analysis.md +40 -0
- package/skills/optbrew/references/versions.json +9 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
- package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
- package/skills/optbrew/scripts/audit.mjs +10 -96
- package/skills/optbrew/scripts/cli.mjs +5 -2
- package/skills/optbrew/scripts/core/bundle.mjs +30 -13
- package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
- package/skills/optbrew/scripts/core/cdp.mjs +105 -0
- package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
- package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
- package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
- package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
- package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
- package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
- package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
- package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
- package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
- package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
- package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
- package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
- package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
- package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
- package/skills/optbrew/scripts/core/chrome.mjs +546 -0
- package/skills/optbrew/scripts/core/collector.mjs +65 -59
- package/skills/optbrew/scripts/core/compare.mjs +18 -1
- package/skills/optbrew/scripts/core/config.mjs +31 -0
- package/skills/optbrew/scripts/core/contracts.mjs +31 -2
- package/skills/optbrew/scripts/core/cwv.mjs +100 -0
- package/skills/optbrew/scripts/core/derive.mjs +10 -161
- package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
- package/skills/optbrew/scripts/core/facts.mjs +203 -19
- package/skills/optbrew/scripts/core/findings.mjs +19 -1
- package/skills/optbrew/scripts/core/html.mjs +41 -3
- package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
- package/skills/optbrew/scripts/core/judgments.mjs +675 -0
- package/skills/optbrew/scripts/core/keys.mjs +86 -0
- package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
- package/skills/optbrew/scripts/core/messages.mjs +23 -0
- package/skills/optbrew/scripts/core/metrics.mjs +4 -2
- package/skills/optbrew/scripts/core/migration.mjs +37 -0
- package/skills/optbrew/scripts/core/page-select.mjs +136 -0
- package/skills/optbrew/scripts/core/probe.mjs +83 -0
- package/skills/optbrew/scripts/core/protocol.mjs +38 -14
- package/skills/optbrew/scripts/core/render.mjs +160 -0
- package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
- package/skills/optbrew/scripts/core/report-html.mjs +198 -0
- package/skills/optbrew/scripts/core/report-page.mjs +14 -0
- package/skills/optbrew/scripts/core/report.mjs +357 -131
- package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
- package/skills/optbrew/scripts/core/scope.mjs +23 -5
- package/skills/optbrew/scripts/core/score.mjs +12 -4
- package/skills/optbrew/scripts/core/secrets.mjs +2 -0
- package/skills/optbrew/scripts/core/stages.mjs +694 -0
- package/skills/optbrew/scripts/core/transport.mjs +157 -0
- package/skills/optbrew/scripts/core/weights.mjs +2 -1
- package/skills/optbrew/scripts/key.mjs +90 -0
- package/skills/optbrew/scripts/preflight.mjs +176 -0
- package/skills/optbrew/scripts/run.mjs +81 -0
- package/skills/optbrew/source-lock.json +1 -1
- package/source-lock.json +1 -1
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { blocks } from './checks/index.mjs';
|
|
3
|
+
import { makeObservation, problem, unitKey } from './checks/observation.mjs';
|
|
4
|
+
import { limits } from './config.mjs';
|
|
5
|
+
import { decodeEntities } from './html.mjs';
|
|
6
|
+
import { messageOf } from './messages.mjs';
|
|
7
|
+
import { sensitive } from './secrets.mjs';
|
|
8
|
+
|
|
9
|
+
const FETCH_FAMILY = 'fetch-response';
|
|
10
|
+
const MODEL_EVALUATOR = 'host_model';
|
|
11
|
+
const ENTITY_FAMILY = 'entity-identity';
|
|
12
|
+
const ANSWER_FAMILY = 'answer-clarity';
|
|
13
|
+
const QUESTION_FAMILY = 'question-coverage';
|
|
14
|
+
/**
|
|
15
|
+
* The only `source_ref` each evaluator type may carry in a replayed packet; report replay refuses any other pair. `derived` is the
|
|
16
|
+
* code evaluator's second pair: a verdict computed from the model's judgments (the question coverage), which report replay computes again.
|
|
17
|
+
*/
|
|
18
|
+
export const OBSERVATION_SOURCES = Object.freeze({ code: 'optbrew:collector', [MODEL_EVALUATOR]: 'optbrew:model-judgment', derived: 'optbrew:judgment-derived' });
|
|
19
|
+
export const QUEUE_KIND = 'judge-queue';
|
|
20
|
+
/** The two site units the model's work touches: the identity fallback it may judge, and the question coverage that code derives from its answers. */
|
|
21
|
+
export const ENTITY_UNIT = unitKey(ENTITY_FAMILY, 'site');
|
|
22
|
+
export const QUESTION_UNIT = unitKey(QUESTION_FAMILY, 'site');
|
|
23
|
+
const QUEUE_SECTION = 'judge_queue';
|
|
24
|
+
/** What the model's identity verdict rests on, by state (recorded in its measurement; report replay checks it): the name with another fact, the name alone, or no matching name. */
|
|
25
|
+
const IDENTITY_BASIS = { pass: 'name_and_fact', partial: 'name_only', fail: 'no_matching_name' };
|
|
26
|
+
const NONE_ANSWERED = problem('Question coverage: no audited page answers a question of the fixed list', 'question-coverage.none-answered');
|
|
27
|
+
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
28
|
+
const verdictMode = new Map(registry.families.map(family => [family.family_id, family.verdict_mode]));
|
|
29
|
+
const naConditions = new Map(registry.families.map(family => [family.family_id, family.na_conditions ?? []]));
|
|
30
|
+
// The not_applicable conditions that mean "the page has no X", and the families whose fail may rest on something missing from the page.
|
|
31
|
+
const absenceConditions = new Map(registry.families.map(family => [family.family_id, new Set(family.na_absence_conditions ?? [])]));
|
|
32
|
+
const absenceFamilies = new Set(registry.families.filter(family => family.absence_fail).map(family => family.family_id));
|
|
33
|
+
/** What a model marks (`basis`) on every fail of an absence family: the fail rests on something missing from the page, or on something the cited text shows. */
|
|
34
|
+
const ABSENCE_BASIS = 'absence';
|
|
35
|
+
const SHOWN_BASIS = 'shown';
|
|
36
|
+
/**
|
|
37
|
+
* What the owner reads for a failed model verdict (Task 21f, R100): the catalog key of the problem, by family and, for the two absence
|
|
38
|
+
* families, by what the fail rests on; and the text it cites, which the page shows in its own words. The model's own reason stays in the
|
|
39
|
+
* verdict as evidence. Merge and report replay build the same key and quote, so the report cannot say what the verdict did not.
|
|
40
|
+
*/
|
|
41
|
+
const modelFixKey = ({ family_id, basis }) => `model-fix.${family_id}${absenceFamilies.has(family_id) && basis ? `.${basis}` : ''}`;
|
|
42
|
+
const QUOTE_SPANS = 2;
|
|
43
|
+
const QUOTE_CHARS = 60;
|
|
44
|
+
/**
|
|
45
|
+
* The cited text as the report quotes it: the page's own lines first (site chrome only when nothing else is cited), in page order, at most
|
|
46
|
+
* two, each with its character references decoded and cut to 60 characters.
|
|
47
|
+
*/
|
|
48
|
+
const quoteOf = (spans, chrome) => {
|
|
49
|
+
const own = spans.filter(span => !chrome.has(span.text));
|
|
50
|
+
return [...(own.length ? own : spans)].sort((a, b) => a.start - b.start).slice(0, QUOTE_SPANS).map(span => {
|
|
51
|
+
const text = decodeEntities(span.text).replace(/\s+/g, ' ').trim();
|
|
52
|
+
return text.length > QUOTE_CHARS ? `${[...text].slice(0, QUOTE_CHARS).join('')}…` : text;
|
|
53
|
+
}).join(' / ');
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The `problem_messages` a failed model verdict records: one entry, the key of its family and basis with the text it cites, and for an
|
|
57
|
+
* answer-clarity fail the text of the question it fails (`question`).
|
|
58
|
+
*/
|
|
59
|
+
export const modelFailMessages = (verdict, spans, chrome, question = null) =>
|
|
60
|
+
[messageOf(modelFixKey(verdict), { quote: quoteOf(spans, chrome), ...(question ? { question } : {}) })];
|
|
61
|
+
/**
|
|
62
|
+
* A freshness fail labelled shown on a page whose own text shows no date is an absence fail in disguise (acceptance treats it as one).
|
|
63
|
+
*/
|
|
64
|
+
const disguisedFreshness = (verdict, bytes, chrome) => verdict.state === 'fail' && verdict.basis === SHOWN_BASIS && verdict.family_id === FRESHNESS_FAMILY &&
|
|
65
|
+
!textLines(bytes).some(line => !chrome.has(line.text) && hasDate(line.text, BROAD_DATE_FORMS));
|
|
66
|
+
/** The basis the owner's sentence rests on: the verdict's own, except that a disguised absence fail is told as the absence it is. */
|
|
67
|
+
export const sentenceBasis = (verdict, bytes, chrome) => disguisedFreshness(verdict, bytes, chrome) ? ABSENCE_BASIS : verdict.basis;
|
|
68
|
+
/** Why a verdict that rests on absence becomes unknown when the queue offered too little of the page. */
|
|
69
|
+
const ABSENCE_UNCOVERED = 'queue covers too little of the page to declare absence';
|
|
70
|
+
const CHROME_ONLY = 'the cited spans are all site chrome, not the page\'s own text';
|
|
71
|
+
const NO_TEXT_REASON = 'page shows no text';
|
|
72
|
+
const DATE_OUTSIDE = 'a date is visible outside the queued spans';
|
|
73
|
+
const DATE_INSIDE = 'a date is visible in the queued spans';
|
|
74
|
+
const OPERATOR_OUTSIDE = 'the operator is named outside the queued spans';
|
|
75
|
+
const OPERATOR_INSIDE = 'the operator is named in the queued spans';
|
|
76
|
+
const FRESHNESS_FAMILY = 'freshness-truth';
|
|
77
|
+
const SOURCE_FAMILY = 'source-transparency';
|
|
78
|
+
const COPYRIGHT = /©|\(c\)|copyright/i;
|
|
79
|
+
// A line that credits someone. The host label (a product's own name, as in "htmx") names an operator only on such a line.
|
|
80
|
+
const CREDIT = /©|\(c\)|copyright|\b(?:published|operated|maintained|created|made|written|run|owned)\s+by\b|운영|제공|발행|저작권|문의/i;
|
|
81
|
+
const SPACE = '[ \\t\\r\\n\\f]';
|
|
82
|
+
const LEADING = new RegExp(`^${SPACE}*`);
|
|
83
|
+
const TRAILING = new RegExp(`${SPACE}+$`);
|
|
84
|
+
// Text that is not shown to a reader: comments and script, style, template and noscript bodies.
|
|
85
|
+
const INERT = /<!--[\s\S]*?(?:-->|$)|<(script|style|template|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)/gi;
|
|
86
|
+
// An inert run is blanked to a character between its angle brackets, so it still reads as one tag. A script, style, template or
|
|
87
|
+
// noscript body (BLANK) splits the text around it. An HTML comment (COMMENT_BLANK) does not: React leaves `<!-- -->` between the
|
|
88
|
+
// text nodes of one sentence (`금액은 <!-- -->2026<!-- -->년`), and a comment is not a boundary of what a reader sees.
|
|
89
|
+
const BLANK = '\0';
|
|
90
|
+
const COMMENT_BLANK = '\x01';
|
|
91
|
+
const TAG_OPENER = /[A-Za-z/!?\0\x01]/;
|
|
92
|
+
// A hyphenated name (a custom element) is one name, so `b-card` is not the inline tag `b`.
|
|
93
|
+
const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9-]*)/;
|
|
94
|
+
const SENTENCE_END = /[.!?。!?]+["'”’」)\]]*(?=\s|$)/gu;
|
|
95
|
+
const SENTENCE_TAIL = /[.!?。!?]+["'”’」)\]]*\s*$/u;
|
|
96
|
+
// Tags inside a line of text: they do not end a span, and the text shown to the model has them taken out.
|
|
97
|
+
const INLINE_TAGS = new Set(['a', 'abbr', 'b', 'br', 'code', 'em', 'i', 'mark', 'small', 'span', 'strong', 'sub', 'sup', 'time']);
|
|
98
|
+
// A date form is a pattern plus a check of its parts: digits stand alone (no digit right before or after, so a business number such as
|
|
99
|
+
// 142-80-39228 or a phone number is not read as a date), the year is 1990-2099, the month 1-12 and the day 1-31.
|
|
100
|
+
const YEAR = value => value >= 1990 && value <= 2099;
|
|
101
|
+
const MONTH = value => value >= 1 && value <= 12;
|
|
102
|
+
const DAY = value => value >= 1 && value <= 31;
|
|
103
|
+
// A full date names a day, a month and a year. A long line that holds one is kept ahead of other long lines when the cap is full.
|
|
104
|
+
const FULL_DATE_FORMS = [
|
|
105
|
+
{ re: /(?<!\d)(\d{4})\s*[-./년]\s*(\d{1,2})\s*[-./월]\s*(\d{1,2})(?!\d)/gu, ok: (y, m, d) => YEAR(+y) && MONTH(+m) && DAY(+d) },
|
|
106
|
+
{ re: /(?<!\d)(\d{4})\s+(\d{1,2})\s*월\s*(\d{1,2})\s*일/gu, ok: (y, m, d) => YEAR(+y) && MONTH(+m) && DAY(+d) },
|
|
107
|
+
{ re: /(?<!\d)(\d{1,2})\s*[-./]\s*(\d{1,2})\s*[-./]\s*(\d{4})(?!\d)/gu, ok: (a, b, y) => YEAR(+y) && (MONTH(+a) && DAY(+b) || MONTH(+b) && DAY(+a)) },
|
|
108
|
+
{ re: /(?<!\w)(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?![a-z])\.?\s+(\d{1,2}),?\s+(\d{4})(?!\d)/giu, ok: (_, d, y) => DAY(+d) && YEAR(+y) },
|
|
109
|
+
{ re: /(?<!\d)(\d{1,2})\s+(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?![a-z])\.?,?\s+(\d{4})(?!\d)/giu, ok: (d, _, y) => DAY(+d) && YEAR(+y) },
|
|
110
|
+
];
|
|
111
|
+
// A year and month, offered when short.
|
|
112
|
+
const YEAR_MONTH_FORMS = [
|
|
113
|
+
{ re: /(?<!\d)(\d{4})\s*년\s*(\d{1,2})\s*월/gu, ok: (y, m) => YEAR(+y) && MONTH(+m) },
|
|
114
|
+
];
|
|
115
|
+
// Any text that shows a date, a month or a day of a month: a page that has one is not "without a visible date". Broad, but each form checks its parts.
|
|
116
|
+
const BROAD_DATE_FORMS = [
|
|
117
|
+
...FULL_DATE_FORMS,
|
|
118
|
+
...YEAR_MONTH_FORMS,
|
|
119
|
+
// A year with 년 (`2026년 보건복지부 고시`) shows which year the text applies to. It refutes an absence fail, but it is not offered as a short line.
|
|
120
|
+
{ re: /(?<!\d)(\d{4})\s*년/gu, ok: y => YEAR(+y) },
|
|
121
|
+
{ re: /(?<!\d)(\d{4})\s+(\d{1,2})\s*월/gu, ok: (y, m) => YEAR(+y) && MONTH(+m) },
|
|
122
|
+
{ re: /(?<![\d./-])(\d{4})\s*[-./]\s*(\d{1,2})(?![\d./-])/gu, ok: (y, m) => YEAR(+y) && MONTH(+m) },
|
|
123
|
+
{ re: /(?<!\d)(\d{1,2})\s*월\s*(\d{1,2})\s*일/gu, ok: (m, d) => MONTH(+m) && DAY(+d) },
|
|
124
|
+
{ re: /(?<!\w)(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)(?![a-z])\.?\s+(\d{1,2})(?!\d)/gu, ok: (_, d) => DAY(+d) },
|
|
125
|
+
{ re: /(?<!\w)(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)(?![a-z])\.?,?\s+(\d{4})(?!\d)/gu, ok: (_, y) => YEAR(+y) },
|
|
126
|
+
];
|
|
127
|
+
const hasDate = (text, forms) => forms.some(({ re, ok }) => [...text.matchAll(re)].some(found => ok(...found.slice(1))));
|
|
128
|
+
// Short text that is still worth offering: a full date or a year and month, a way to reach the owner (and, by element, an author line in <address>). The floor in limits.json does not drop these.
|
|
129
|
+
const CONTACT_PATTERNS = [
|
|
130
|
+
/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u,
|
|
131
|
+
/(?:\+?\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{4}/u,
|
|
132
|
+
/\b1[568]\d{2}[-\s]\d{4}\b/u,
|
|
133
|
+
];
|
|
134
|
+
// A short line that marks something as no longer current (htmx `hx-on (deprecated)`) is what a freshness verdict needs to see.
|
|
135
|
+
const DEPRECATION = /deprecated|obsolete|사용\s*중단|폐지|더\s*이상\s*지원/i;
|
|
136
|
+
const isShortException = text => hasDate(text, [...FULL_DATE_FORMS, ...YEAR_MONTH_FORMS]) || CONTACT_PATTERNS.some(pattern => pattern.test(text)) || DEPRECATION.test(text);
|
|
137
|
+
const ADDRESS_ELEMENT = /<address\b[^>]*>([\s\S]*?)<\/address\s*>/gi;
|
|
138
|
+
const BODY_ELEMENT = /<body(?=[\s>/])[^>]*>([\s\S]*)/i;
|
|
139
|
+
const HEAD_ELEMENT = /<head(?=[\s>/])[\s\S]*?<\/head\s*>/i;
|
|
140
|
+
// What is left of an element once its tags and blank entities are gone.
|
|
141
|
+
const NO_TEXT = /^(?:\s| | | )*$/i;
|
|
142
|
+
|
|
143
|
+
/** The families a model may judge: per-page families that have no code check block. Code-judged families are read from the block list. */
|
|
144
|
+
function modelFamilies() {
|
|
145
|
+
return registry.families.filter(family => family.unit_scope === 'url' && !isCodeJudged(family.family_id)).map(family => family.family_id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether a family is one the model judges through the judge queue (a per-page family with no code check block). */
|
|
149
|
+
export const isModelJudged = family_id => modelFamilies().includes(family_id);
|
|
150
|
+
|
|
151
|
+
/** Whether a family has a code check block, so only code may judge it. */
|
|
152
|
+
export const isCodeJudged = family_id => blocks.some(block => block.family_id === family_id);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether a judgment's `basis` is allowed: a fail of an absence family names `absence` (it rests on something missing from the page)
|
|
156
|
+
* or `shown` (the cited text shows it); every other verdict names none.
|
|
157
|
+
*/
|
|
158
|
+
export const basisAllowed = ({ family_id, state, basis }) =>
|
|
159
|
+
state === 'fail' && absenceFamilies.has(family_id) ? basis === ABSENCE_BASIS || basis === SHOWN_BASIS : basis === undefined;
|
|
160
|
+
|
|
161
|
+
/** Whether a judgment rests on something being absent from the page: a not_applicable under an absence condition, or a fail marked with the absence basis in a family that allows it. */
|
|
162
|
+
const restsOnAbsence = ({ family_id, state, condition, basis }) =>
|
|
163
|
+
state === 'not_applicable' ? absenceConditions.get(family_id)?.has(condition) === true : state === 'fail' && basis === ABSENCE_BASIS && absenceFamilies.has(family_id);
|
|
164
|
+
|
|
165
|
+
/** The basis the model's identity verdict must record for its state. */
|
|
166
|
+
export const identityBasisOf = state => IDENTITY_BASIS[state];
|
|
167
|
+
|
|
168
|
+
/** The one code-judged unit a model may judge: the site's identity, when code produced no verdict for it. */
|
|
169
|
+
export const isEntityFallback = (family_id, unit_key) => family_id === ENTITY_FAMILY && unit_key === ENTITY_UNIT;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The home page among fetched pages: a page at the root of the site, else the page with the shortest path; ties go to the smaller address,
|
|
173
|
+
* so the choice does not depend on the order the pages were fetched in. Queue building and report replay both choose it.
|
|
174
|
+
*/
|
|
175
|
+
export const homeOf = urls => {
|
|
176
|
+
const keyed = urls.map(url => ({ url, path: new URL(url).pathname }));
|
|
177
|
+
keyed.sort((a, b) => (a.path === '/' ? 0 : 1) - (b.path === '/' ? 0 : 1) || a.path.length - b.path.length || (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
178
|
+
return keyed[0]?.url ?? null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Throw unless `{ start, end }` is a non-empty UTF-8 byte range (end exclusive) of `bytes` that begins and ends on character
|
|
183
|
+
* boundaries and decodes as text. Report replay and mergeJudgments both use this one check.
|
|
184
|
+
*/
|
|
185
|
+
export function checkSpan(bytes, { start, end }) {
|
|
186
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start || end > bytes.length)
|
|
187
|
+
throw new Error(`span ${start}-${end} is not a byte range inside the evidence (${bytes.length} bytes)`);
|
|
188
|
+
const inside = index => index < bytes.length && (bytes[index] & 0xC0) === 0x80;
|
|
189
|
+
if (inside(start) || inside(end)) throw new Error(`span ${start}-${end} splits a multi-byte character`);
|
|
190
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end)); }
|
|
191
|
+
catch { throw new Error(`span ${start}-${end} is not valid UTF-8 text`); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The index after the `>` that ends the tag opening at `from`. A quote opens only after `=`, so a `>` inside an attribute value does not end it. */
|
|
195
|
+
function tagEnd(text, from) {
|
|
196
|
+
let quote = null;
|
|
197
|
+
let before = '';
|
|
198
|
+
for (let index = from + 1; index < text.length; index++) {
|
|
199
|
+
const char = text[index];
|
|
200
|
+
if (quote) { if (char === quote) quote = null; }
|
|
201
|
+
else if ((char === '"' || char === "'") && before === '=') quote = char;
|
|
202
|
+
else if (char === '>') return index + 1;
|
|
203
|
+
if (!quote && !/\s/.test(char)) before = char;
|
|
204
|
+
}
|
|
205
|
+
// A quote that never closed is not trusted: the tag ends at its first `>`.
|
|
206
|
+
const first = text.indexOf('>', from);
|
|
207
|
+
return first < 0 ? text.length : first + 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The ranges of `[from, to)` that lie outside every tag. */
|
|
211
|
+
function piecesOf(masked, from, to) {
|
|
212
|
+
const pieces = [];
|
|
213
|
+
let index = from;
|
|
214
|
+
let piece = from;
|
|
215
|
+
while (index < to) {
|
|
216
|
+
if (masked[index] === '<' && TAG_OPENER.test(masked[index + 1] ?? '')) {
|
|
217
|
+
if (index > piece) pieces.push([piece, index]);
|
|
218
|
+
index = Math.min(tagEnd(masked, index), to);
|
|
219
|
+
piece = index;
|
|
220
|
+
} else index++;
|
|
221
|
+
}
|
|
222
|
+
if (to > piece) pieces.push([piece, to]);
|
|
223
|
+
return pieces;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The `[start, end)` ranges of `masked` that lie between tags that break a line of text; an inline tag (a, code, em, ...) does not break it. */
|
|
227
|
+
function textRuns(masked) {
|
|
228
|
+
const runs = [];
|
|
229
|
+
let from = 0;
|
|
230
|
+
let index = 0;
|
|
231
|
+
while (index < masked.length) {
|
|
232
|
+
if (masked[index] === '<' && TAG_OPENER.test(masked[index + 1] ?? '')) {
|
|
233
|
+
const name = TAG_NAME.exec(masked.slice(index, index + 24))?.[1].toLowerCase();
|
|
234
|
+
const end = tagEnd(masked, index);
|
|
235
|
+
if (!INLINE_TAGS.has(name) && masked[index + 1] !== COMMENT_BLANK) {
|
|
236
|
+
if (index > from) runs.push([from, index]);
|
|
237
|
+
from = end;
|
|
238
|
+
}
|
|
239
|
+
index = end;
|
|
240
|
+
} else index++;
|
|
241
|
+
}
|
|
242
|
+
if (masked.length > from) runs.push([from, masked.length]);
|
|
243
|
+
return runs;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What joins two pieces of a run: a space where a line break lies between them or one element ends and the next begins; nothing where a single tag does (a word and its particle). */
|
|
247
|
+
const joinerOf = gap => /<br\b/i.test(gap) || /<\/[^>]*>\s*<[A-Za-z]/.test(gap) ? ' ' : '';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The text of a run: its pieces outside tags, with leading and trailing ASCII whitespace taken off the whole. Returns the byte range
|
|
251
|
+
* from the first to the last text byte (inline tags in between lie inside it) and the text those pieces hold, tags left out.
|
|
252
|
+
*/
|
|
253
|
+
function trimmedRun(masked, bytes, from, to) {
|
|
254
|
+
const pieces = piecesOf(masked, from, to);
|
|
255
|
+
while (pieces.length) {
|
|
256
|
+
const [start, end] = pieces[0];
|
|
257
|
+
const lead = LEADING.exec(masked.slice(start, end))[0].length;
|
|
258
|
+
if (start + lead >= end) pieces.shift(); else { pieces[0] = [start + lead, end]; break; }
|
|
259
|
+
}
|
|
260
|
+
while (pieces.length) {
|
|
261
|
+
const [start, end] = pieces.at(-1);
|
|
262
|
+
const trail = masked.slice(start, end).length - masked.slice(start, end).replace(TRAILING, '').length;
|
|
263
|
+
if (end - trail <= start) pieces.pop(); else { pieces[pieces.length - 1] = [start, end - trail]; break; }
|
|
264
|
+
}
|
|
265
|
+
if (!pieces.length) return null;
|
|
266
|
+
// Two words are not run together across a line break or the boundary of two elements.
|
|
267
|
+
const text = pieces.map(([start, end], index) => (index ? joinerOf(masked.slice(pieces[index - 1][1], start)) : '') + bytes.subarray(start, end).toString('utf8')).join('');
|
|
268
|
+
return { start: pieces[0][0], end: pieces.at(-1)[1], text, pieces };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The ranges of `text` (character indices) cut after each sentence end, and a sentence that is still longer than `max` bytes at its last space, else at a character. */
|
|
272
|
+
function sentenceRanges(text, max) {
|
|
273
|
+
const cuts = [0, ...[...text.matchAll(SENTENCE_END)].map(found => found.index + found[0].length), text.length];
|
|
274
|
+
const ranges = [];
|
|
275
|
+
for (let i = 1; i < cuts.length; i++) {
|
|
276
|
+
let from = cuts[i - 1];
|
|
277
|
+
const to = cuts[i];
|
|
278
|
+
if (to <= from) continue;
|
|
279
|
+
while (Buffer.byteLength(text.slice(from, to)) > max) {
|
|
280
|
+
let at = from;
|
|
281
|
+
let used = 0;
|
|
282
|
+
while (at < to) {
|
|
283
|
+
const width = Buffer.byteLength(String.fromCodePoint(text.codePointAt(at)));
|
|
284
|
+
if (used + width > max) break;
|
|
285
|
+
used += width;
|
|
286
|
+
at += text.codePointAt(at) > 0xFFFF ? 2 : 1;
|
|
287
|
+
}
|
|
288
|
+
const space = text.slice(from, at).search(/\s\S*$/);
|
|
289
|
+
const cut = space > 0 ? from + space + 1 : at;
|
|
290
|
+
ranges.push([from, cut]);
|
|
291
|
+
from = cut;
|
|
292
|
+
}
|
|
293
|
+
ranges.push([from, to]);
|
|
294
|
+
}
|
|
295
|
+
return ranges;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A run longer than `max` bytes as several runs of at most `max` bytes each. Its pieces are cut after sentence ends (and, for a
|
|
300
|
+
* sentence still too long, at a space or a character); the cut pieces form units that end at a sentence end or a line break, and the
|
|
301
|
+
* units are packed, in order, into the longest groups that fit (a unit longer than `max` is packed piece by piece). Each part is a byte
|
|
302
|
+
* range from its first to its last text byte, with the text of that range as `trimmedRun` builds it.
|
|
303
|
+
*/
|
|
304
|
+
function splitRun(masked, bytes, pieces, max) {
|
|
305
|
+
const units = [];
|
|
306
|
+
let unit = [];
|
|
307
|
+
pieces.forEach(([start, end], index) => {
|
|
308
|
+
const text = bytes.subarray(start, end).toString('utf8');
|
|
309
|
+
const before = index ? joinerOf(masked.slice(pieces[index - 1][1], start)) : '';
|
|
310
|
+
const lineBreak = index + 1 < pieces.length && /<br\b/i.test(masked.slice(end, pieces[index + 1][0]));
|
|
311
|
+
const ranges = sentenceRanges(text, max);
|
|
312
|
+
ranges.forEach(([from, to], n) => {
|
|
313
|
+
const atom = { start: start + Buffer.byteLength(text.slice(0, from)), end: start + Buffer.byteLength(text.slice(0, to)), text: text.slice(from, to), before: n ? '' : before };
|
|
314
|
+
unit.push(atom);
|
|
315
|
+
if (SENTENCE_TAIL.test(atom.text) || n + 1 === ranges.length && lineBreak) { units.push(unit); unit = []; }
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
if (unit.length) units.push(unit);
|
|
319
|
+
const items = units.flatMap(atoms => {
|
|
320
|
+
const joined = { start: atoms[0].start, end: atoms.at(-1).end, before: atoms[0].before,
|
|
321
|
+
text: atoms.map((atom, n) => (n ? atom.before : '') + atom.text).join('') };
|
|
322
|
+
return Buffer.byteLength(joined.text) <= max ? [joined] : atoms;
|
|
323
|
+
});
|
|
324
|
+
const parts = [];
|
|
325
|
+
let current = null;
|
|
326
|
+
for (const item of items) {
|
|
327
|
+
if (current && Buffer.byteLength(current.text + item.before + item.text) <= max) { current.text += item.before + item.text; current.end = item.end; }
|
|
328
|
+
else { if (current) parts.push(current); current = { start: item.start, end: item.end, text: item.text }; }
|
|
329
|
+
}
|
|
330
|
+
if (current) parts.push(current);
|
|
331
|
+
return parts.flatMap(part => {
|
|
332
|
+
const lead = LEADING.exec(part.text)[0];
|
|
333
|
+
const trail = TRAILING.exec(part.text)?.[0] ?? '';
|
|
334
|
+
const text = part.text.slice(lead.length, part.text.length - trail.length);
|
|
335
|
+
return text ? [{ start: part.start + Buffer.byteLength(lead), end: part.end - Buffer.byteLength(trail), text }] : [];
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The body as latin1 text with inert runs blanked, and the byte ranges of the text inside <address> elements. */
|
|
340
|
+
function maskedBody(bytes) {
|
|
341
|
+
const masked = bytes.toString('latin1').replace(INERT, found => `<${(found.startsWith('<!--') ? COMMENT_BLANK : BLANK).repeat(found.length - 2)}>`);
|
|
342
|
+
const addresses = [...masked.matchAll(ADDRESS_ELEMENT)].map(found => { const from = found.index + found[0].indexOf('>') + 1; return [from, from + found[1].length]; });
|
|
343
|
+
return { masked, addresses };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Every line of visible text of the body, as the spans are cut from it: byte range, text, and whether it stands inside an <address> element. */
|
|
347
|
+
function textLines(bytes) {
|
|
348
|
+
const { judge_span_max_bytes: maximum } = limits();
|
|
349
|
+
const { masked, addresses } = maskedBody(bytes);
|
|
350
|
+
const lines = [];
|
|
351
|
+
for (const [from, to] of textRuns(masked)) {
|
|
352
|
+
const whole = trimmedRun(masked, bytes, from, to);
|
|
353
|
+
if (!whole) continue;
|
|
354
|
+
for (const run of Buffer.byteLength(whole.text) > maximum ? splitRun(masked, bytes, whole.pieces, maximum) : [whole])
|
|
355
|
+
lines.push({ start: run.start, end: run.end, text: run.text, address: addresses.some(([first, last]) => run.start >= first && run.end <= last) });
|
|
356
|
+
}
|
|
357
|
+
return lines;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Candidate spans of one HTML body: each line of text between tags that break a line, as UTF-8 byte offsets. An inline tag (a, code,
|
|
362
|
+
* em, strong, ...) does not break the line: its span covers the tag, and its `text` is the text of the range with the tags left out.
|
|
363
|
+
* Text inside a tag (attribute values) and inside script and style is never offered. The body is scanned as bytes (latin1), so
|
|
364
|
+
* offsets are byte offsets and a span can only start or end next to an ASCII character, never inside a multi-byte one. A line shorter
|
|
365
|
+
* than the floor is dropped unless it is a date, a contact or a line inside an <address> element; those (one of each text) have an
|
|
366
|
+
* allowance of their own beside the cap. A line longer than the maximum is cut at sentence ends (or a space, or a character) into
|
|
367
|
+
* several spans of at most that size. Lines with a secret are dropped; the longest lines are kept, in document order, but a long line
|
|
368
|
+
* that holds a full date comes before the others when the cap is full.
|
|
369
|
+
*/
|
|
370
|
+
export function candidateSpans(bytes, evidence_sha256) {
|
|
371
|
+
const { judge_spans_per_unit: cap, judge_span_min_bytes: minimum, judge_short_spans_per_unit: shortCap } = limits();
|
|
372
|
+
const long = [];
|
|
373
|
+
const short = new Map();
|
|
374
|
+
for (const line of textLines(bytes)) {
|
|
375
|
+
if (sensitive.test(line.text)) continue;
|
|
376
|
+
const span = { span_id: `${line.start}-${line.end}`, evidence_sha256, start: line.start, end: line.end, text: line.text };
|
|
377
|
+
const size = Buffer.byteLength(line.text);
|
|
378
|
+
if (size >= minimum) long.push({ span, size, dated: hasDate(line.text, FULL_DATE_FORMS) });
|
|
379
|
+
else if (!short.has(line.text) && (line.address || isShortException(line.text))) short.set(line.text, span);
|
|
380
|
+
}
|
|
381
|
+
const kept = long.sort((a, b) => b.dated - a.dated || b.size - a.size || a.span.start - b.span.start).slice(0, cap).map(x => x.span);
|
|
382
|
+
return [...kept, ...[...short.values()].slice(0, shortCap)].sort((a, b) => a.start - b.start);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether the page shows no text: its <body> holds none or, with no <body>, nothing outside <head> does. Script, style and comments are not text. */
|
|
386
|
+
export function isEmptyPage(bytes) {
|
|
387
|
+
const { masked } = maskedBody(bytes);
|
|
388
|
+
const body = BODY_ELEMENT.exec(masked)?.[1] ?? masked.replace(HEAD_ELEMENT, '');
|
|
389
|
+
return NO_TEXT.test(body.replace(/<[^>]*>/g, ''));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The lines of text that repeat across most of the audited pages (menus, headers, footers): a line on more than `site_chrome.min_share`
|
|
394
|
+
* of the pages, when at least `site_chrome.min_pages` pages were audited. They belong to the site, not to a page. Queue building and
|
|
395
|
+
* report replay both derive the set from the pages' bytes.
|
|
396
|
+
*/
|
|
397
|
+
export function siteChrome(pages) {
|
|
398
|
+
const { min_share: share, min_pages: minimum } = limits().site_chrome;
|
|
399
|
+
const counts = new Map();
|
|
400
|
+
if (pages.length < minimum) return new Set();
|
|
401
|
+
for (const bytes of pages) for (const text of new Set(textLines(bytes).map(line => line.text))) counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
402
|
+
return new Set([...counts].filter(([, n]) => n / pages.length > share).map(([text]) => text));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Why an absence fail is not accepted although the queue covered the page: the page shows what the fail says is missing, in lines the
|
|
407
|
+
* model may not have read. A date: any line with a date (menus and footers included). The operator: a line of the page's own text (not
|
|
408
|
+
* site chrome) inside <address>, with a copyright mark, or naming one of `names`. Returns the reason, or null when the page shows nothing.
|
|
409
|
+
* `spans` are the spans the queue offered, to tell a line the model could read from one it could not.
|
|
410
|
+
*/
|
|
411
|
+
function absenceRefuted(family_id, bytes, spans, { chrome, names, host }) {
|
|
412
|
+
const inSpans = line => spans.some(span => span.start <= line.start && line.end <= span.end);
|
|
413
|
+
const lines = textLines(bytes);
|
|
414
|
+
if (family_id === FRESHNESS_FAMILY) {
|
|
415
|
+
const hits = lines.filter(line => !chrome.has(line.text) && hasDate(line.text, BROAD_DATE_FORMS));
|
|
416
|
+
return hits.length ? (hits.every(inSpans) ? DATE_INSIDE : DATE_OUTSIDE) : null;
|
|
417
|
+
}
|
|
418
|
+
if (family_id !== SOURCE_FAMILY) return null;
|
|
419
|
+
const lowered = names.map(name => name.toLowerCase());
|
|
420
|
+
const hits = lines.filter(line => !chrome.has(line.text) && (line.address || COPYRIGHT.test(line.text) || lowered.some(name => line.text.toLowerCase().includes(name)) ||
|
|
421
|
+
host && CREDIT.test(line.text) && line.text.toLowerCase().includes(host)));
|
|
422
|
+
return hits.length ? (hits.every(inSpans) ? OPERATOR_INSIDE : OPERATOR_OUTSIDE) : null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The names that tell a page names its operator, from every source the run has: the names the owner confirmed (`confirmed`) and the
|
|
427
|
+
* organization name the code identity check found (JSON-LD) are `names`; the site's host label (`youthdc` of youthdc.com) is `host`
|
|
428
|
+
* and counts only on a credit line. A name under two characters is not used.
|
|
429
|
+
*/
|
|
430
|
+
export function operatorNames({ confirmed = [], observations = [], url }) {
|
|
431
|
+
const entity = observations.find(observation => observation.unit_key === ENTITY_UNIT && observation.evaluator_type === 'code')?.measurement?.name_reference;
|
|
432
|
+
// A site unit has no address of its own, and an IP address has no name: no host label then.
|
|
433
|
+
const hostname = URL.canParse(url) ? new URL(url).hostname.replace(/^www\./, '') : '';
|
|
434
|
+
const host = /^[\d.]+$|^\[/.test(hostname) ? null : hostname.split('.')[0];
|
|
435
|
+
const usable = name => typeof name === 'string' && name.trim().length >= 2;
|
|
436
|
+
return { names: [...new Set([...confirmed, entity].filter(usable).map(name => name.trim()))], host: usable(host) ? host.toLowerCase() : null };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Whether a model verdict stands, from the page's bytes: null when it does, else `{ reason, code }` and the verdict becomes unknown.
|
|
441
|
+
* A page that shows no text gets no verdict but unknown. A fail of an absence family, and a freshness pass, must cite some of the page's
|
|
442
|
+
* own text (not only site chrome); a date that only site chrome shows is not the page showing a date. A verdict that rests on absence (an
|
|
443
|
+
* absence N/A, an absence fail, and a freshness fail labelled shown on a page that shows no date, which is an absence fail in
|
|
444
|
+
* disguise) needs a queue that covered the page; an absence fail also needs a page that does not show what it says is missing.
|
|
445
|
+
* `cited` are the spans the verdict cites, `offered` all spans of the queue unit. Merge and report replay both call this.
|
|
446
|
+
*/
|
|
447
|
+
export function acceptance(verdict, bytes, cited, offered, { chrome, names, host }) {
|
|
448
|
+
const { family_id, state } = verdict;
|
|
449
|
+
if (state !== 'unknown' && isEmptyPage(bytes)) return { reason: NO_TEXT_REASON, code: 'empty_page' };
|
|
450
|
+
const failOfAbsenceFamily = state === 'fail' && absenceFamilies.has(family_id);
|
|
451
|
+
const freshnessPass = state === 'pass' && family_id === FRESHNESS_FAMILY;
|
|
452
|
+
if ((failOfAbsenceFamily || freshnessPass) && cited.length && cited.every(span => chrome.has(span.text))) return { reason: CHROME_ONLY, code: 'chrome' };
|
|
453
|
+
const disguised = failOfAbsenceFamily && disguisedFreshness(verdict, bytes, chrome);
|
|
454
|
+
if (!restsOnAbsence(verdict) && !disguised) return null;
|
|
455
|
+
if (!coversPage(bytes, offered)) return { reason: ABSENCE_UNCOVERED, code: 'queue_coverage' };
|
|
456
|
+
const shown = failOfAbsenceFamily ? absenceRefuted(family_id, bytes, offered, { chrome, names, host }) : null;
|
|
457
|
+
return shown ? { reason: shown, code: 'visible' } : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Whether the queue's `spans` cover enough of the page to show that something is absent from it: the spans make up at least
|
|
462
|
+
* `judge_absence_min_coverage` of the page's visible text (bytes of text). A page with no text never gets here (acceptance refuses it first).
|
|
463
|
+
*/
|
|
464
|
+
function coversPage(bytes, spans) {
|
|
465
|
+
const total = textLines(bytes).reduce((sum, line) => sum + Buffer.byteLength(line.text), 0);
|
|
466
|
+
return total === 0 || spans.reduce((sum, span) => sum + Buffer.byteLength(span.text), 0) / total >= limits().judge_absence_min_coverage;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The model's work list: for every page that was fetched and every model-judged family with no verdict yet, the page's
|
|
471
|
+
* candidate spans. Evidence is read through the store, so a body that no longer matches its recorded hash fails here.
|
|
472
|
+
* With the run's `context`, only the families the run plans are offered. When code gave no verdict on the site's identity, the
|
|
473
|
+
* home page also carries that site unit, with the names and the other facts the start answers confirmed (`names`, `facts`) and the host.
|
|
474
|
+
* Returns `{ items: [{ family_id, unit_key, url, evidence_ref, evidence_sha256, spans }] }` and stores it in the run evidence
|
|
475
|
+
* (kind `judge-queue`, listed with its hash in the index section `judge_queue`); mergeJudgments and report replay read that copy.
|
|
476
|
+
*/
|
|
477
|
+
export async function buildJudgeQueue({ observations, store, context = null, names = [], facts = [] }) {
|
|
478
|
+
const index = await store.index();
|
|
479
|
+
const { raw } = index;
|
|
480
|
+
// Raw bodies are named by their hash, so pages with identical bytes share one evidence file; a raw entry is told apart by its address too.
|
|
481
|
+
const recorded = new Map(raw.filter(entry => entry.content_hash).map(entry => [JSON.stringify([entry.evidence_ref, entry.normalized_url]), entry]));
|
|
482
|
+
const judged = new Set(observations.filter(observation => observation.state !== 'unknown').map(observation => observation.unit_key));
|
|
483
|
+
// A page is the address its fetch-response verdict names, whatever other address answered with the same bytes.
|
|
484
|
+
const pages = new Map();
|
|
485
|
+
for (const observation of observations) {
|
|
486
|
+
if (observation.family_id !== FETCH_FAMILY || observation.state !== 'pass') continue;
|
|
487
|
+
const url = observation.unit_key.split('|').at(-1);
|
|
488
|
+
const entry = recorded.get(JSON.stringify([observation.evidence_ref, url]));
|
|
489
|
+
if (entry && !pages.has(url)) pages.set(url, entry);
|
|
490
|
+
}
|
|
491
|
+
const planned = context ? new Set(context.protocol.families.filter(family => family.applicable).map(family => family.family_id)) : null;
|
|
492
|
+
const offered = family_id => !planned || planned.has(family_id);
|
|
493
|
+
const families = modelFamilies().filter(offered);
|
|
494
|
+
const items = [];
|
|
495
|
+
const loaded = new Map();
|
|
496
|
+
for (const [url, entry] of pages) {
|
|
497
|
+
const bytes = await store.read(entry.evidence_ref, entry.content_hash);
|
|
498
|
+
loaded.set(url, { bytes, spans: candidateSpans(bytes, entry.content_hash), empty: isEmptyPage(bytes) });
|
|
499
|
+
}
|
|
500
|
+
// A line that most audited pages repeat is the site's, not the page's: the spans that hold one say so.
|
|
501
|
+
const chrome = siteChrome([...loaded.values()].map(page => page.bytes));
|
|
502
|
+
const spansOf = new Map();
|
|
503
|
+
for (const [url, page] of loaded) spansOf.set(url, page.spans.map(span => chrome.has(span.text) ? { ...span, site_chrome: true } : span));
|
|
504
|
+
for (const [url, entry] of pages) {
|
|
505
|
+
const spans = spansOf.get(url);
|
|
506
|
+
if (!spans.length) continue;
|
|
507
|
+
const empty = loaded.get(url).empty ? { empty_page: true } : {};
|
|
508
|
+
for (const family_id of families) {
|
|
509
|
+
const unit_key = unitKey(family_id, url);
|
|
510
|
+
if (!judged.has(unit_key)) items.push({ family_id, unit_key, url, evidence_ref: entry.evidence_ref, evidence_sha256: entry.content_hash, spans, ...empty });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const home = homeOf([...pages.keys()]);
|
|
514
|
+
if (home && spansOf.get(home).length && offered(ENTITY_FAMILY) && !judged.has(ENTITY_UNIT)) {
|
|
515
|
+
const entry = pages.get(home);
|
|
516
|
+
items.push({ family_id: ENTITY_FAMILY, unit_key: ENTITY_UNIT, url: home, evidence_ref: entry.evidence_ref, evidence_sha256: entry.content_hash,
|
|
517
|
+
spans: spansOf.get(home), ...(loaded.get(home).empty ? { empty_page: true } : {}), identity: { names, facts, host: new URL(home).host } });
|
|
518
|
+
}
|
|
519
|
+
const emptyPages = [...loaded].filter(([, page]) => page.empty).map(([url]) => url);
|
|
520
|
+
const queue = { items, ...(chrome.size ? { site_chrome: [...chrome].sort() } : {}), ...(emptyPages.length ? { empty_pages: emptyPages } : {}), ...(names.length ? { owner_names: names } : {}) };
|
|
521
|
+
const { ref, sha256 } = await store.write(QUEUE_KIND, `${JSON.stringify(queue, null, 2)}\n`);
|
|
522
|
+
if (!(index[QUEUE_SECTION] ?? []).some(entry => entry.sha256 === sha256)) await store.append(QUEUE_SECTION, { evidence_ref: ref, sha256 });
|
|
523
|
+
return queue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The newest queue stored in the run, read with its hash verified. */
|
|
527
|
+
async function storedQueue(store) {
|
|
528
|
+
const entry = ((await store.index())[QUEUE_SECTION] ?? []).at(-1);
|
|
529
|
+
if (!entry) throw new Error('no judge queue is stored in the run evidence; build it first');
|
|
530
|
+
return JSON.parse((await store.read(entry.evidence_ref, entry.sha256)).toString('utf8'));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Whether `span` is exactly one of the candidates the queue offered for this observation's unit and evidence. */
|
|
534
|
+
export function queuedSpan(queue, observation, span) {
|
|
535
|
+
const item = (queue.items ?? []).find(x => x.unit_key === observation.unit_key && x.family_id === observation.family_id);
|
|
536
|
+
return item?.evidence_ref === observation.evidence_ref && item.spans.some(x => x.evidence_sha256 === span.evidence_sha256 &&
|
|
537
|
+
x.start === span.start && x.end === span.end);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The other questions an answer-clarity verdict says the page answers (`also_question_ids`): none when it names none, else the list
|
|
542
|
+
* when every id is a question of the fixed list, is not the verdict's own `question_id` and is named once; null when it is not such a list.
|
|
543
|
+
*/
|
|
544
|
+
export function alsoQuestionIds({ question_id, also_question_ids }, questionIds) {
|
|
545
|
+
if (also_question_ids === undefined) return [];
|
|
546
|
+
const ok = Array.isArray(also_question_ids) && also_question_ids.length > 0 && new Set(also_question_ids).size === also_question_ids.length &&
|
|
547
|
+
also_question_ids.every(id => questionIds.has(id) && id !== question_id);
|
|
548
|
+
return ok ? also_question_ids : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A partial verdict must fit the family's verdict mode; returns the basis and the q it implies. */
|
|
552
|
+
function partialBasis(judgment) {
|
|
553
|
+
const mode = verdictMode.get(judgment.family_id);
|
|
554
|
+
if (mode === 'binary') throw new Error(`${judgment.family_id} is binary; a partial judgment is not allowed`);
|
|
555
|
+
if (mode === 'threshold_half') return { basis: { kind: 'threshold_half' }, q: 0.5 };
|
|
556
|
+
const basis = judgment.partial_basis;
|
|
557
|
+
const { numerator, denominator } = basis ?? {};
|
|
558
|
+
if (basis?.kind !== 'ratio' || !Number.isInteger(numerator) || !Number.isInteger(denominator) || numerator <= 0 || numerator >= denominator)
|
|
559
|
+
throw new Error(`a partial judgment for ${judgment.family_id} needs partial_basis { kind: "ratio", numerator, denominator } with 0 < numerator < denominator`);
|
|
560
|
+
return { basis, q: numerator / denominator };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Merge model judgments into the observations. A judgment names a queued unit and the `span_id`s it relies on. The queue is
|
|
565
|
+
* the copy stored in the run evidence (hash verified), never one passed in; each cited range is taken from it and re-checked
|
|
566
|
+
* against the stored evidence. A state other than `unknown` needs spans, and the observation records each cited range, not a
|
|
567
|
+
* range that covers them. Code-judged families are never overwritten (the site's identity is the one exception, and only when the
|
|
568
|
+
* queue offered it because code gave no verdict), a unit that already has a verdict is refused, and an `unknown` placeholder is
|
|
569
|
+
* replaced. A `not_applicable` judgment needs one of the family's registry conditions and cites spans; it is recorded with that
|
|
570
|
+
* condition as its applicability decision. An answer-clarity verdict other than unknown names the panel question it answers
|
|
571
|
+
* (`question_id`, one of `questions`) and keeps it in its measurement.
|
|
572
|
+
*/
|
|
573
|
+
export async function mergeJudgments({ observations, judgments, store, questions = [] }) {
|
|
574
|
+
const queue = await storedQueue(store);
|
|
575
|
+
const items = new Map(queue.items.map(item => [item.unit_key, item]));
|
|
576
|
+
const confirmed = [...(queue.owner_names ?? []), ...(queue.items.find(item => item.identity)?.identity.names ?? [])];
|
|
577
|
+
const chrome = new Set(queue.site_chrome ?? []);
|
|
578
|
+
const questionIds = new Set(questions.map(question => question.id));
|
|
579
|
+
const made = [];
|
|
580
|
+
for (const judgment of judgments) {
|
|
581
|
+
const { family_id, unit_key, state } = judgment;
|
|
582
|
+
const site = isEntityFallback(family_id, unit_key);
|
|
583
|
+
if (isCodeJudged(family_id) && !site) throw new Error(`${family_id} is judged by code; a model judgment cannot overwrite it`);
|
|
584
|
+
const item = items.get(unit_key);
|
|
585
|
+
if (!item || item.family_id !== family_id) throw new Error(`no queued unit for ${family_id} ${unit_key}`);
|
|
586
|
+
if (made.some(observation => observation.unit_key === unit_key)) throw new Error(`more than one judgment for ${unit_key}`);
|
|
587
|
+
if (observations.some(observation => observation.unit_key === unit_key && observation.state !== 'unknown'))
|
|
588
|
+
throw new Error(`${unit_key} already has a verdict`);
|
|
589
|
+
if (sensitive.test(judgment.reason ?? '')) throw new Error(`judgment reason for ${unit_key} contains a secret`);
|
|
590
|
+
const extra = { evaluator_type: MODEL_EVALUATOR, source_ref: OBSERVATION_SOURCES[MODEL_EVALUATOR] };
|
|
591
|
+
let evidence = null;
|
|
592
|
+
if (state === 'fail' && absenceFamilies.has(family_id) && judgment.basis === undefined)
|
|
593
|
+
throw new Error(`a fail of ${family_id} needs basis "absence" (something is missing from the page) or "shown" (the cited text shows it) (${unit_key})`);
|
|
594
|
+
if (!basisAllowed(judgment))
|
|
595
|
+
throw new Error(`basis "${judgment.basis}" is allowed only as "absence" or "shown" on a fail of ${[...absenceFamilies].join(' or ')} (${unit_key})`);
|
|
596
|
+
if (state === 'not_applicable') {
|
|
597
|
+
if (site) throw new Error(`${unit_key} is a site unit and cannot be not_applicable`);
|
|
598
|
+
if (!naConditions.get(family_id).includes(judgment.condition))
|
|
599
|
+
throw new Error(`a not_applicable judgment for ${unit_key} needs a condition of the family (${naConditions.get(family_id).join(', ')})`);
|
|
600
|
+
// A question named means the page is meant to answer it: the verdict is pass, partial or fail. Not applicable is for a page no question is about.
|
|
601
|
+
if (family_id === ANSWER_FAMILY && judgment.question_id !== undefined) throw new Error(`a not_applicable answer-clarity judgment for ${unit_key} names no question`);
|
|
602
|
+
}
|
|
603
|
+
if (family_id === ANSWER_FAMILY && judgment.also_question_ids !== undefined && !['pass', 'partial'].includes(state))
|
|
604
|
+
throw new Error(`an answer-clarity ${state} judgment for ${unit_key} names no also_question_ids`);
|
|
605
|
+
if (family_id === ANSWER_FAMILY && ['pass', 'partial', 'fail'].includes(state)) {
|
|
606
|
+
if (!questionIds.has(judgment.question_id)) throw new Error(`an answer-clarity judgment for ${unit_key} needs question_id from the question list`);
|
|
607
|
+
const also = alsoQuestionIds(judgment, questionIds);
|
|
608
|
+
if (also === null) throw new Error(`also_question_ids of ${unit_key} must be a list of other, distinct ids from the question list`);
|
|
609
|
+
extra.measurement = { question_id: judgment.question_id, ...(also.length ? { also_question_ids: also } : {}) };
|
|
610
|
+
}
|
|
611
|
+
if (state !== 'unknown') {
|
|
612
|
+
if (!judgment.span_ids?.length) throw new Error(`a ${state} judgment for ${unit_key} needs span_ids`);
|
|
613
|
+
const spans = judgment.span_ids.map(id => {
|
|
614
|
+
const queued = item.spans.find(span => span.span_id === id);
|
|
615
|
+
if (!queued) throw new Error(`unknown span_id ${id} for ${unit_key}`);
|
|
616
|
+
return queued;
|
|
617
|
+
});
|
|
618
|
+
if (new Set(judgment.span_ids).size !== spans.length) throw new Error(`span_ids repeat a span for ${unit_key}`);
|
|
619
|
+
const bytes = await store.read(item.evidence_ref, item.evidence_sha256);
|
|
620
|
+
evidence = bytes;
|
|
621
|
+
extra.evidence_span = spans.map(({ evidence_sha256, start, end }) => ({ evidence_sha256, start, end }));
|
|
622
|
+
for (const part of extra.evidence_span) {
|
|
623
|
+
if (part.evidence_sha256 !== item.evidence_sha256) throw new Error(`span of ${unit_key} is not from the unit's evidence`);
|
|
624
|
+
checkSpan(bytes, part);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// A verdict that rests on something missing from the page needs a queue that offered enough of it and a page that does not show it; otherwise it is unknown.
|
|
628
|
+
const cited = evidence ? extra.evidence_span.map(part => item.spans.find(span => span.start === part.start && span.end === part.end)) : [];
|
|
629
|
+
const notAccepted = evidence && acceptance(judgment, evidence, cited, item.spans,
|
|
630
|
+
{ chrome, ...operatorNames({ confirmed, observations, url: item.url }) });
|
|
631
|
+
if (notAccepted) {
|
|
632
|
+
made.push(makeObservation({ family_id, unit_key, state: 'unknown', reason: notAccepted.reason, evidence_ref: item.evidence_ref,
|
|
633
|
+
extra: { evaluator_type: MODEL_EVALUATOR, source_ref: extra.source_ref, measurement: { not_accepted: notAccepted.code } } }));
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (state === 'fail' && absenceFamilies.has(family_id)) extra.measurement = { ...extra.measurement, basis: judgment.basis };
|
|
637
|
+
// What the owner reads for a fail comes from the catalog (R100); the model's own reason stays as the verdict's reason.
|
|
638
|
+
if (state === 'fail') extra.problem_messages = modelFailMessages({ family_id, basis: sentenceBasis(judgment, evidence, chrome) }, cited, chrome,
|
|
639
|
+
family_id === ANSWER_FAMILY ? questions.find(question => question.id === judgment.question_id)?.question : null);
|
|
640
|
+
if (site && state !== 'unknown') extra.measurement = { ...extra.measurement, identity_basis: IDENTITY_BASIS[state] };
|
|
641
|
+
if (state === 'not_applicable') extra.applicability_decision = { condition: judgment.condition, reason: judgment.reason, evidence_ref: item.evidence_ref };
|
|
642
|
+
const partial = state === 'partial' ? partialBasis(judgment) : { basis: null, q: undefined };
|
|
643
|
+
// A q the model supplies is ignored: the state fixes it (pass 1, fail 0, unknown and not applicable none) and a partial takes the basis's q.
|
|
644
|
+
made.push(makeObservation({ family_id, unit_key, state, q: partial.q, reason: judgment.reason,
|
|
645
|
+
evidence_ref: item.evidence_ref, partial_basis: partial.basis, extra }));
|
|
646
|
+
}
|
|
647
|
+
const replaced = new Set(made.map(observation => observation.unit_key));
|
|
648
|
+
return [...observations.filter(observation => !replaced.has(observation.unit_key)), ...made];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The observations with the question coverage of the fixed panel: the questions an audited page answers (an answer-clarity
|
|
653
|
+
* verdict of pass or partial naming the question), each counted once however many pages answer it, over the panel's length. It
|
|
654
|
+
* exists once the model has judged at least one page against a question; before that the unit stays unknown. Computed by code, not
|
|
655
|
+
* judged: report replay computes it again from the answers, so a figure that does not follow from them is refused. `evidence_ref`
|
|
656
|
+
* is the run context, which holds the panel.
|
|
657
|
+
*/
|
|
658
|
+
export function deriveQuestionCoverage({ observations, panel, evidence_ref }) {
|
|
659
|
+
const others = observations.filter(observation => !(observation.unit_key === QUESTION_UNIT && (observation.state === 'unknown' || observation.source_ref === OBSERVATION_SOURCES.derived)));
|
|
660
|
+
const judged = others.filter(observation => observation.family_id === ANSWER_FAMILY && observation.evaluator_type === MODEL_EVALUATOR &&
|
|
661
|
+
['pass', 'partial', 'fail'].includes(observation.state));
|
|
662
|
+
if (!judged.length || !panel.length || others.some(observation => observation.unit_key === QUESTION_UNIT)) return others;
|
|
663
|
+
const answeredBy = new Map(panel.map(question => [question.id, []]));
|
|
664
|
+
// A page answers the question it names and every other question it says it answers; a question counts once however many pages answer it.
|
|
665
|
+
for (const observation of judged) if (observation.state !== 'fail')
|
|
666
|
+
for (const id of [observation.measurement?.question_id, ...(observation.measurement?.also_question_ids ?? [])]) answeredBy.get(id)?.push(observation.unit_key);
|
|
667
|
+
const answered = [...answeredBy.values()].filter(units => units.length).length;
|
|
668
|
+
const total = panel.length;
|
|
669
|
+
const state = answered === 0 ? 'fail' : answered === total ? 'pass' : 'partial';
|
|
670
|
+
const measurement = { answered, questions: total, list: panel.map(question => ({ question_id: question.id, answered_by: answeredBy.get(question.id) })) };
|
|
671
|
+
const extra = { measurement, evaluator_type: 'code', source_ref: OBSERVATION_SOURCES.derived };
|
|
672
|
+
const verdict = state === 'fail' ? { problems: [NONE_ANSWERED] } : { reason: 'Panel questions answered by an audited page, each counted once' };
|
|
673
|
+
return [...others, makeObservation({ family_id: QUESTION_FAMILY, unit_key: QUESTION_UNIT, state, q: state === 'partial' ? answered / total : undefined,
|
|
674
|
+
partial_basis: state === 'partial' ? { kind: 'ratio', numerator: answered, denominator: total } : null, evidence_ref, extra, ...verdict })];
|
|
675
|
+
}
|