@sonordev/contracts 2.7.0 → 2.8.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 +19 -0
- package/README.md +127 -0
- package/dist/business-claims.d.ts +456 -0
- package/dist/business-claims.js +1378 -0
- package/dist/business-claims.mjs +1340 -0
- package/dist/editorial-media.js +1 -0
- package/dist/editorial-media.mjs +1 -0
- package/dist/outreach.d.ts +267 -0
- package/dist/outreach.js +351 -0
- package/dist/outreach.mjs +328 -0
- package/package.json +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.8.0
|
|
4
|
+
|
|
5
|
+
A minor release: two new entries, `@sonordev/contracts/business-claims` and `@sonordev/contracts/outreach`. Every other entry is unchanged.
|
|
6
|
+
|
|
7
|
+
- **New entry `@sonordev/contracts/business-claims`**: what an outbound email or text may say about a business, checked the same way where it's written and where it's sent.
|
|
8
|
+
- `checkOutboundCopy(copy, ctx)` sorts what it finds into `blocking` (refused outright), `flags` (a person looks before it sends) and `style` (never blocks), and returns the body as the reader sees it in `plain`. It reads the copy sentence by sentence after rendering HTML to text and decoding entities once.
|
|
9
|
+
- `PLATFORM_BANNED_CLAIMS` lists the claims no business's outreach may make. A business adds its own through `ctx.rules`.
|
|
10
|
+
- `OutboundClaimReview`, `readOutboundClaimReview` and `unsettledFlags` are the stored record of a check and the rule for when it's settled. `outboundRefusalMessage` is the one wording of a refusal.
|
|
11
|
+
- `readBackVerdict(intended, read)` says whether a sent email arrived intact.
|
|
12
|
+
- The quote, price and number helpers (`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`, `numbersGrounded`), `decodeHtmlEntities`, `removeDashes`, `outboundPlainText` and `isWriterNote` are exported so nothing re-implements them.
|
|
13
|
+
- **New entry `@sonordev/contracts/outreach`**: what a segment of outreach is and who may turn one on.
|
|
14
|
+
- `outreachSegment`, `parseOutreachSegmentKey`, `isGrantableSegmentKey` and `outreachSegmentLabel` build, read and label segment keys. `prospectIndustry` and `normalizeIndustry` decide the industry, and `narrativeFingerprint` the pitch.
|
|
15
|
+
- `SegmentGrant`, `readSegmentGrants`, `activeSegmentGrant`, `segmentGrantCovers` and `grantEvidenceRefusal` read a person's standing approval of a segment and check it against the approvals it stands on. `GRADUATION_BAR` is the one bar a segment earns its own sending by.
|
|
16
|
+
- `segmentGranterEligible`, `PUBLIC_MAILBOX_DOMAINS`, `isPublicMailboxDomain` and `dripRowDisposition` round it out.
|
|
17
|
+
|
|
18
|
+
## 2.7.1
|
|
19
|
+
|
|
20
|
+
- Preserve article photo source identity during duplicate detection when a responsive image optimizer rewrites the displayed URL.
|
|
21
|
+
|
|
3
22
|
## 2.7.0
|
|
4
23
|
|
|
5
24
|
- Add `editorial-media`: shared section/takeaway text identities, article photo placement diagnostics, and source-image eligibility constants for APIs and site renderers.
|
package/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
The rules Sonor's sites, APIs and dashboard have to agree on, as one package.
|
|
4
4
|
Pure functions and types: no dependencies, no DOM, no network.
|
|
5
5
|
|
|
6
|
+
The `editorial-media` placement resolver recognizes an image's canonical
|
|
7
|
+
`data-sk-editorial-source` as well as its displayed `src`. Responsive image
|
|
8
|
+
delivery can change URLs without inserting the same approved photo twice.
|
|
9
|
+
|
|
6
10
|
```bash
|
|
7
11
|
pnpm add @sonordev/contracts
|
|
8
12
|
```
|
|
@@ -28,6 +32,8 @@ pnpm add @sonordev/contracts
|
|
|
28
32
|
| `@sonordev/contracts/schema-placeholders` | Whether stored JSON-LD carries a template's unfilled slots (`example.com` URLs, an `Organization` named "Example", a placeholder phone, `[Resident Name]`, `{plan.name}`), and the value without them | site-kit's schema output, the Sonor API's schema reads and writes |
|
|
29
33
|
| `@sonordev/contracts/sentences` | Where prose splits into sentences, without dropping a word or ending one at "p.m." (`splitSentences`) | anything that cuts an excerpt, a social slide or a summary out of an article: the Sonor API's carousels, Signal's article pipeline |
|
|
30
34
|
| `@sonordev/contracts/knowledge` | Which Sonor records Signal reads into a project's knowledge, the address each one's knowledge is stored under (`portfolio://<id>`), and the limits of the call that tells Signal a site's pages changed | the Sonor API sends those calls and Signal answers them |
|
|
35
|
+
| `@sonordev/contracts/business-claims` | What an outbound email or text may say about a business (`checkOutboundCopy`), the stored review of what it claimed and when that review is settled, and whether a sent email arrived intact (`readBackVerdict`) | the Sonor API checks again on the bytes it sends, Signal checks what its writers and agents file |
|
|
36
|
+
| `@sonordev/contracts/outreach` | What a segment of outreach is (pitch, sending mailbox, prospect industry), who may turn one on, what a turned-on segment covers, and what a drip's ledger row means for its lead | Signal writes and reads segment grants, the Sonor API checks the same grant before it sends |
|
|
31
37
|
|
|
32
38
|
## Schema placeholders
|
|
33
39
|
|
|
@@ -414,6 +420,127 @@ const batch = [...removed, ...changed].slice(0, KNOWLEDGE_SYNC_MAX_PAGES)
|
|
|
414
420
|
// removals first, so the cut drops changed pages before any removal
|
|
415
421
|
```
|
|
416
422
|
|
|
423
|
+
## The business-claims contract
|
|
424
|
+
|
|
425
|
+
An email that goes out under a business's name can't promise what the
|
|
426
|
+
business never said. Every writer (a model, an agent, a person's own
|
|
427
|
+
template) and every sender checks copy with the same function, so a
|
|
428
|
+
sentence refused where it's written is refused again where it's sent.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
import { checkOutboundCopy } from '@sonordev/contracts/business-claims'
|
|
432
|
+
|
|
433
|
+
const check = checkOutboundCopy(
|
|
434
|
+
{ subject: 'A quick idea for your site', body, format: 'text', channel: 'email' },
|
|
435
|
+
{ author: 'signal', rules, claims },
|
|
436
|
+
)
|
|
437
|
+
check.blocking // refused outright
|
|
438
|
+
check.flags // needs a person's look before it sends
|
|
439
|
+
check.style // house style, never blocks
|
|
440
|
+
check.plain // the body as the reader sees it
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
- **Who wrote it.** `author` is `'signal'` (copy a model wrote), `'agent'`
|
|
444
|
+
(an agent's own copy, the strictest set) or `'operator'` (a person's fixed
|
|
445
|
+
template). Operator copy gets the blocking set only. An author it doesn't
|
|
446
|
+
know is checked as an agent.
|
|
447
|
+
- **Blocking.** An empty subject or body, a merge tag that never rendered
|
|
448
|
+
(`{{first_name}}`, `[Company]`), a claim no business may make
|
|
449
|
+
(`PLATFORM_BANNED_CLAIMS`: guaranteed rankings, top-3 or map pack spots
|
|
450
|
+
or results, "#1 on Google",
|
|
451
|
+
"risk free", fake scarcity, implied Google endorsement, and any promise
|
|
452
|
+
that they'll pay nothing or get their money back), the business's own
|
|
453
|
+
banned claims and the pitch's forbidden phrases, a price that matches none
|
|
454
|
+
the business publishes, and a word that mixes alphabets. An agent's own copy
|
|
455
|
+
is also refused for dashes and for unsafe HTML.
|
|
456
|
+
- **Flags.** A price it can't verify, a number nothing backs, a citation that
|
|
457
|
+
doesn't match what it cites, a banned claim that a negation takes back, a
|
|
458
|
+
note the writer left to itself, and a number an agent took from its
|
|
459
|
+
research. Research about the recipient backs a number only in a sentence
|
|
460
|
+
about the recipient (`claimSubjectOf`). It never backs one about the sender.
|
|
461
|
+
A clause that names nobody ("the measured performance score is 61") counts
|
|
462
|
+
as the recipient's only when their research carries that number, and a
|
|
463
|
+
subject line may repeat a number the body backs that way.
|
|
464
|
+
- **Rules come from the caller.** `rules` carries the business's published
|
|
465
|
+
prices, banned claims, forbidden phrases, verified facts and its own names,
|
|
466
|
+
resolved before the call, so no business data lives in the package. Price
|
|
467
|
+
and banned-claim rules run only when `rules` is set.
|
|
468
|
+
- **Findings are stable.** Each finding's `id` is a hash of its code, its
|
|
469
|
+
sentence and the match (`outboundFindingId`), so a person's acknowledgement
|
|
470
|
+
of one sentence never carries over to a rewritten one.
|
|
471
|
+
- **The stored review.** `OutboundClaimReview` is what was checked, against
|
|
472
|
+
which rules, and which flags a person acknowledged.
|
|
473
|
+
`readOutboundClaimReview(value)` reads a stored one defensively, and
|
|
474
|
+
`unsettledFlags(review)` lists the flags nobody has settled yet. A sender
|
|
475
|
+
refuses while that list isn't empty.
|
|
476
|
+
- **The refusal.** `outboundRefusalMessage(findings)` is the one wording for
|
|
477
|
+
what's wrong and how to fix it, numbered and plain.
|
|
478
|
+
- **Read-back.** `readBackVerdict(intended, read)` compares the email a
|
|
479
|
+
mailbox says it sent with the one that was meant: `verified` only when the
|
|
480
|
+
whole intended text arrived (a footer added after it is fine), otherwise
|
|
481
|
+
`empty`, `mismatch` or `unreadable`.
|
|
482
|
+
- **Shared helpers.** The same normalizers the checker uses are exported for
|
|
483
|
+
anything that grounds a quote, price or number in a source
|
|
484
|
+
(`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`,
|
|
485
|
+
`numbersGrounded`), plus `decodeHtmlEntities`, `removeDashes`,
|
|
486
|
+
`outboundPlainText` and `isWriterNote`.
|
|
487
|
+
|
|
488
|
+
`BUSINESS_CLAIMS_CONTRACT_VERSION` is bumped when the stored review's shape
|
|
489
|
+
changes incompatibly.
|
|
490
|
+
|
|
491
|
+
## The outreach contract
|
|
492
|
+
|
|
493
|
+
Outreach earns the right to send on its own one segment at a time. A segment
|
|
494
|
+
is one pitch, sent from one mailbox, to one kind of business. Both the API
|
|
495
|
+
that writes outreach and the one that sends it read segments by these rules,
|
|
496
|
+
so they can't disagree about what a person turned on.
|
|
497
|
+
|
|
498
|
+
- **Segment keys.** `outreachSegment({ source, narrativeId, mailboxId,
|
|
499
|
+
industry })` builds one: `drip:<pitch id>@<mailbox id>|<industry>` for a
|
|
500
|
+
drip, or `mcp:-|<industry>` for an agent's one-off email, which is counted
|
|
501
|
+
but never turned on. `parseOutreachSegmentKey(key)` reads a key strictly
|
|
502
|
+
and returns `null` for anything else, so a caller-chosen key never reaches
|
|
503
|
+
storage. `isGrantableSegmentKey(key)` is true only for a drip segment with a
|
|
504
|
+
known industry. `outreachSegmentLabel` is the one human label.
|
|
505
|
+
- **Industry.** `prospectIndustry(prospect)` picks the prospect's industry
|
|
506
|
+
and `normalizeIndustry(value)` folds it to one key ("Roofing Contractors"
|
|
507
|
+
and "roofing" are the same segment). A prospect with none is
|
|
508
|
+
`UNSPECIFIED_INDUSTRY`, and those always wait for a person.
|
|
509
|
+
- **The pitch's fingerprint.** `narrativeFingerprint(narrative)` hashes the
|
|
510
|
+
pitch fields only (`NARRATIVE_PITCH_FIELDS`). Rewriting the pitch changes
|
|
511
|
+
it and lapses the segment. Renaming it, switching it off and on, or
|
|
512
|
+
tightening its forbidden phrases doesn't.
|
|
513
|
+
- **States.** `SEGMENT_STATES` is `ungranted`, `granted`, `paused` (it
|
|
514
|
+
stopped itself), `lapsed` (the pitch changed) and `revoked`.
|
|
515
|
+
- **Grants.** `SegmentGrant` is a person's standing approval of one segment,
|
|
516
|
+
either from approving its first email unedited (`first_email`) or by
|
|
517
|
+
accepting it once the segment earned it (`earned`).
|
|
518
|
+
`readSegmentGrants(settings)` and `activeSegmentGrant(settings, key)` read
|
|
519
|
+
them. `segmentGrantCovers(grant, row)` says whether a grant covers one
|
|
520
|
+
email: not when it's revoked or paused, names someone else, was written
|
|
521
|
+
before the grant, has a different pitch fingerprint or sends from another
|
|
522
|
+
mailbox.
|
|
523
|
+
- **Evidence.** `grantEvidenceRefusal(grant, rows, scope)` checks the
|
|
524
|
+
approvals a grant stands on and returns why it can't be trusted, or `null`.
|
|
525
|
+
A grant that fails counts as no grant, so one written into settings by hand
|
|
526
|
+
never sends anything.
|
|
527
|
+
- **The bar.** `GRADUATION_BAR` is the one track record that earns sending
|
|
528
|
+
without a click per email: at least 5 measured, at least 70% of them won,
|
|
529
|
+
and none that went wrong without being undone. An earned grant needs
|
|
530
|
+
`GRADUATION_BAR.minMeasured` approvals on record.
|
|
531
|
+
- **Who may turn one on.** `segmentGranterEligible(roles)`: never an agent;
|
|
532
|
+
otherwise an owner or admin of the project, its organization or the agency
|
|
533
|
+
that manages it.
|
|
534
|
+
- **Company domains.** `PUBLIC_MAILBOX_DOMAINS` and
|
|
535
|
+
`isPublicMailboxDomain(value)`: free mailbox providers, which never count as
|
|
536
|
+
a company's domain.
|
|
537
|
+
- **A drip's row.** `dripRowDisposition(row)` says what a drip's ledger row
|
|
538
|
+
means for its lead: `live`, `sent`, `retire`, `declined` or `refile`. A
|
|
539
|
+
status it doesn't know reads as `live`, so a lead is never written to twice.
|
|
540
|
+
|
|
541
|
+
`OUTREACH_CONTRACT_VERSION` is bumped when a grant's shape or the segment key
|
|
542
|
+
changes incompatibly.
|
|
543
|
+
|
|
417
544
|
## Why it exists
|
|
418
545
|
|
|
419
546
|
A rule that lives in several copies is how two of them end up disagreeing.
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/** Bumped when a stored shape here (OutboundClaimReview) changes incompatibly. */
|
|
2
|
+
export declare const BUSINESS_CLAIMS_CONTRACT_VERSION = 1;
|
|
3
|
+
/**
|
|
4
|
+
* Decode HTML character references in one pass: named ones (the table
|
|
5
|
+
* above), decimal (') and hex ('). One pass, so a double-encoded
|
|
6
|
+
* "&#39;" comes back as the literal "'" it was written as, never as
|
|
7
|
+
* "'". An invalid code point (zero, a surrogate, past U+10FFFF) is left as
|
|
8
|
+
* written, and   is a plain space.
|
|
9
|
+
*/
|
|
10
|
+
export declare function decodeHtmlEntities(text: string): string;
|
|
11
|
+
/** A quote shorter than this (after normalizeForQuote) grounds nothing. */
|
|
12
|
+
export declare const MIN_QUOTE_CHARS = 12;
|
|
13
|
+
/**
|
|
14
|
+
* Text as a quote is compared: entities decoded (named, decimal and hex),
|
|
15
|
+
* compatibility forms folded (… is ...), invisible characters removed, curly
|
|
16
|
+
* quotes straight, every dash a hyphen with no space around it, "$ 40" as
|
|
17
|
+
* "$40", whitespace collapsed, lowercase. Applied to both sides, so a quote
|
|
18
|
+
* copied off the page matches however the page spelled its punctuation, and
|
|
19
|
+
* nothing else does.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeForQuote(text: unknown): string;
|
|
22
|
+
/**
|
|
23
|
+
* The form every claim matcher reads: normalizeForQuote (NFKC, invisible
|
|
24
|
+
* characters removed, entities decoded, lowercase). Use it on a raw string
|
|
25
|
+
* you want to compare the way checkOutboundCopy does.
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizeMatchable(text: unknown): string;
|
|
28
|
+
/**
|
|
29
|
+
* Where `needle` occurs in `haystack` as a whole span, or -1: never the start
|
|
30
|
+
* or the tail of a longer number ("$90" inside "$900", "$4,000" inside
|
|
31
|
+
* "$4,000,000", "200" inside "$1,200") and never a word cut in half. Both
|
|
32
|
+
* sides are already normalized (normalizeForQuote).
|
|
33
|
+
*/
|
|
34
|
+
export declare function wholeSpanAt(haystack: string, needle: string): number;
|
|
35
|
+
/**
|
|
36
|
+
* Whether `quote` appears in the source text, verbatim after normalizeForQuote
|
|
37
|
+
* and as a whole span (wholeSpanAt: a quote that stops in the middle of a
|
|
38
|
+
* number or a word isn't the source's words). `normalizedSource` is already
|
|
39
|
+
* normalized (each source is normalized once). A quote shorter than
|
|
40
|
+
* `minChars` grounds nothing. A price keeps its punctuation here, so
|
|
41
|
+
* "$4,000" never matches "4 000".
|
|
42
|
+
*/
|
|
43
|
+
export declare function quoteFoundIn(quote: unknown, normalizedSource: string, minChars?: number): boolean;
|
|
44
|
+
/** Dollar amounts in normalized text: "$4,000" is 4000, "$4k" is 4000, "$12.50" is 12.5. */
|
|
45
|
+
export declare function moneyAmounts(normalized: string): number[];
|
|
46
|
+
/**
|
|
47
|
+
* Words just before a price that make it a floor, a ceiling or one end of a
|
|
48
|
+
* range. A dash or "to" counts only after a number ("$1,000-$3,000"), never
|
|
49
|
+
* after a name ("Essential - $1,000" is a price list, not a range).
|
|
50
|
+
*/
|
|
51
|
+
export declare const PRICE_RANGE_BEFORE: RegExp;
|
|
52
|
+
/** Just after a price: "+", or the other end of a range. */
|
|
53
|
+
export declare const PRICE_RANGE_AFTER: RegExp;
|
|
54
|
+
/**
|
|
55
|
+
* An offer's price checked against its quote: the price must appear in the
|
|
56
|
+
* quote exactly as written (after normalizeForQuote), as a whole span
|
|
57
|
+
* (wholeSpanAt: "$90" never passes against "$900"), and be a dollar amount or
|
|
58
|
+
* "free". A price that's one end of what the quote says ("$2,000" out of
|
|
59
|
+
* "$2,000 - $9,000", "$500" out of "starting at $500", "$25,000" out of
|
|
60
|
+
* "$25,000+") fails: the display has to carry the whole span. Returns min/max
|
|
61
|
+
* read from the amounts, or null when the price fails.
|
|
62
|
+
*/
|
|
63
|
+
export declare function checkQuotedPrice(display: unknown, quote: unknown): {
|
|
64
|
+
min?: number;
|
|
65
|
+
max?: number;
|
|
66
|
+
} | null;
|
|
67
|
+
/** The numbers written in normalized text, commas and trailing punctuation dropped: "1,200." is "1200". */
|
|
68
|
+
export declare function numbersIn(normalized: string): string[];
|
|
69
|
+
/** Every number written in `value` also appears in the quote: a claim can't carry a figure its source doesn't. */
|
|
70
|
+
export declare function numbersGrounded(value: unknown, quote: unknown): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* House style: no em or en dashes in anything a person reads. Takes them out
|
|
73
|
+
* of generated copy and stored values.
|
|
74
|
+
*
|
|
75
|
+
* - A range keeps a hyphen: a number range ("3-4 beds"), a price range
|
|
76
|
+
* ("$2,000 - $9,000"), a time range ("9:00 AM - 6:00 PM"), and an unspaced
|
|
77
|
+
* en dash between words ("Mon-Fri").
|
|
78
|
+
* - Any other em or en dash between words becomes a comma ("great light, call
|
|
79
|
+
* me").
|
|
80
|
+
*
|
|
81
|
+
* Never run a verbatim quote through this: a quote stays as it was written.
|
|
82
|
+
*/
|
|
83
|
+
export declare function removeDashes(text: string): string;
|
|
84
|
+
/** Whether a sentence reads as a note about how the copy was written (its instructions or evidence), not copy for the reader. */
|
|
85
|
+
export declare function isWriterNote(text: unknown): boolean;
|
|
86
|
+
export type OutboundFormat = 'html' | 'text';
|
|
87
|
+
export type OutboundChannel = 'email' | 'sms';
|
|
88
|
+
/**
|
|
89
|
+
* The text a reader sees in an email body, one line per block.
|
|
90
|
+
*
|
|
91
|
+
* - html: scripts, styles and comments dropped; source whitespace collapsed;
|
|
92
|
+
* a line break at br, p, li, div, headings, rows and other blocks; a link
|
|
93
|
+
* `<a href="u">t</a>` as "t (u)" unless t is the address itself; an
|
|
94
|
+
* image's alt and title text, and any other element's title, on their own
|
|
95
|
+
* line; inline tags removed without a space (so "guaran<b></b>teed" is one
|
|
96
|
+
* word); entities decoded once; spaces collapsed and empty lines dropped.
|
|
97
|
+
* - text: entities decoded once, nothing else.
|
|
98
|
+
*/
|
|
99
|
+
export declare function outboundPlainText(body: unknown, format: OutboundFormat): string;
|
|
100
|
+
/**
|
|
101
|
+
* Who wrote the copy, stamped once when the row is created
|
|
102
|
+
* (payload.claim_author) and never derived from who filed it:
|
|
103
|
+
* - signal: Signal's own generators, however they were asked;
|
|
104
|
+
* - agent: an MCP agent's own words;
|
|
105
|
+
* - operator: a fixed template or a person's own copy.
|
|
106
|
+
*/
|
|
107
|
+
export type OutboundAuthor = 'signal' | 'agent' | 'operator';
|
|
108
|
+
export declare const OUTBOUND_AUTHORS: ReadonlyArray<OutboundAuthor>;
|
|
109
|
+
/** Refused outright (400 outbound_copy_refused), at filing and again at send. */
|
|
110
|
+
export type OutboundBlockCode = 'empty_subject' | 'empty_body' | 'unrendered_token' | 'platform_banned_claim' | 'project_banned_claim' | 'forbidden_phrase' | 'price_mismatch' | 'mixed_script' | 'dash' | 'unsafe_markup' | 'prompt_control_token';
|
|
111
|
+
/** Needs a person's look (or, for an agent's own copy, is reported back): forces propose. */
|
|
112
|
+
export type OutboundFlagCode = 'price_unverifiable' | 'unsupported_number' | 'citation_invalid' | 'citation_mismatch' | 'citation_wrong_source' | 'banned_claim_negated' | 'writer_note' | 'number_from_research' | 'audit_flag' | 'audit_not_run';
|
|
113
|
+
/** House style. Never blocks and never forces propose. */
|
|
114
|
+
export type OutboundStyleCode = 'uncontracted' | 'dash' | 'first_person_firm';
|
|
115
|
+
export type OutboundFindingCode = OutboundBlockCode | OutboundFlagCode | OutboundStyleCode;
|
|
116
|
+
export interface OutboundFinding {
|
|
117
|
+
/** Stable: FNV-1a of the code, the normalized sentence and the normalized match. A new sentence is a new id. */
|
|
118
|
+
id: string;
|
|
119
|
+
code: OutboundFindingCode;
|
|
120
|
+
/** The sentence as the reader sees it (entities decoded). */
|
|
121
|
+
sentence: string;
|
|
122
|
+
/** What matched, as normalized for matching. */
|
|
123
|
+
match: string;
|
|
124
|
+
reason: string;
|
|
125
|
+
fix: string;
|
|
126
|
+
/** The Business Brief fact a citation finding is about. */
|
|
127
|
+
fact_id?: string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* FNV-1a (32-bit) of a string's UTF-16 code units, as 8 lowercase hex digits.
|
|
131
|
+
* The one hash the contracts use for finding ids and fingerprints.
|
|
132
|
+
*/
|
|
133
|
+
export declare function fnv1a(text: string): string;
|
|
134
|
+
/** A finding's id: the same code, sentence and match always give the same id, anywhere. */
|
|
135
|
+
export declare function outboundFindingId(code: string, sentence: string, match: string): string;
|
|
136
|
+
/**
|
|
137
|
+
* Build a finding with its stable id. Signal's gate uses it for the findings
|
|
138
|
+
* only it can make (prompt_control_token, audit_flag, audit_not_run), so
|
|
139
|
+
* every finding's id is computed one way.
|
|
140
|
+
*/
|
|
141
|
+
export declare function makeOutboundFinding(code: OutboundFindingCode, sentence: string, match: string, extra?: {
|
|
142
|
+
reason?: string;
|
|
143
|
+
fix?: string;
|
|
144
|
+
fact_id?: string;
|
|
145
|
+
}): OutboundFinding;
|
|
146
|
+
export type PlatformBannedClaimId = 'guaranteed_ranking' | 'number_one_on_google' | 'guaranteed_results' | 'hundred_percent_guaranteed' | 'risk_free' | 'fake_scarcity' | 'google_endorsed' | 'pay_nothing_guarantee';
|
|
147
|
+
/** Claims no outbound copy may make, for any business. Generic on purpose: a project adds its own in its Business Brief. */
|
|
148
|
+
export declare const PLATFORM_BANNED_CLAIMS: ReadonlyArray<{
|
|
149
|
+
id: PlatformBannedClaimId;
|
|
150
|
+
description: string;
|
|
151
|
+
}>;
|
|
152
|
+
export interface PlatformClaimHit {
|
|
153
|
+
id: PlatformBannedClaimId;
|
|
154
|
+
/** Offset into the normalized text. */
|
|
155
|
+
index: number;
|
|
156
|
+
match: string;
|
|
157
|
+
/** True when a negation directly governing its guarantee or promise takes it back ("We can't guarantee rankings"). */
|
|
158
|
+
negated: boolean;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Every PLATFORM_BANNED_CLAIMS hit in a text. A negation suppresses a hit
|
|
162
|
+
* only when it directly governs the guarantee or promise ("can't
|
|
163
|
+
* guarantee", "no one can promise"), or the verb a hit starts with ("we
|
|
164
|
+
* can't get you to the top of Google", "not endorsed by Google"); "not
|
|
165
|
+
* only", "no other", "don't settle", "don't wait" and "don't miss" never do.
|
|
166
|
+
* A suppressed hit comes back with `negated: true`: still worth a person's
|
|
167
|
+
* look. A #1 or top-of-Google hit needs a verb that puts someone there, so
|
|
168
|
+
* an observation ("you're near the top of Google") isn't one.
|
|
169
|
+
*/
|
|
170
|
+
export declare function findPlatformBannedClaims(text: unknown): PlatformClaimHit[];
|
|
171
|
+
export interface PhraseHit {
|
|
172
|
+
/** The phrase as you passed it. */
|
|
173
|
+
phrase: string;
|
|
174
|
+
/** Offset into the normalized text. */
|
|
175
|
+
index: number;
|
|
176
|
+
/** The phrase as normalized. */
|
|
177
|
+
match: string;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Literal phrases found as whole spans, case-insensitive, after both sides
|
|
181
|
+
* are normalized (entities, invisible characters, quotes and dashes). The one
|
|
182
|
+
* matcher for a project's banned claims and a narrative's forbidden phrases.
|
|
183
|
+
*/
|
|
184
|
+
export declare function findPhrases(text: unknown, phrases: ReadonlyArray<unknown> | null | undefined): PhraseHit[];
|
|
185
|
+
export interface SpanHit {
|
|
186
|
+
/** Offset into the text the finder read (normalized for findUnrenderedTokens, as given otherwise). */
|
|
187
|
+
index: number;
|
|
188
|
+
match: string;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Template text that never got filled in: {{x}}, {%x%}, [[x]], %TOKEN%,
|
|
192
|
+
* ${x}, {x}, unresolved {a|b} spintax, a bracketed name or company
|
|
193
|
+
* placeholder ("[First Name]"), a greeting to undefined or null, and
|
|
194
|
+
* "[object Object]". Reads the normalized text.
|
|
195
|
+
*/
|
|
196
|
+
export declare function findUnrenderedTokens(text: unknown): SpanHit[];
|
|
197
|
+
/**
|
|
198
|
+
* Dashes the house voice never uses: an em dash anywhere, and an en dash
|
|
199
|
+
* between words. An en dash inside a number, time or $ range ("3–4",
|
|
200
|
+
* "9:00 AM – 6:00 PM", "$2,000 – $9,000") or unspaced between two
|
|
201
|
+
* words ("Mon–Fri") is a range, and a hyphen is never a dash. Reads the
|
|
202
|
+
* text as given (decode entities first), so it can tell the dashes apart.
|
|
203
|
+
*/
|
|
204
|
+
export declare function findDashes(text: unknown): Array<SpanHit & {
|
|
205
|
+
kind: 'em' | 'en';
|
|
206
|
+
}>;
|
|
207
|
+
/**
|
|
208
|
+
* Words that mix Latin letters with Cyrillic or Greek ones ("Gооgle"
|
|
209
|
+
* with two Cyrillic o's), which only look like ordinary words. A word that's
|
|
210
|
+
* all Cyrillic or all Greek is left alone.
|
|
211
|
+
*/
|
|
212
|
+
export declare function findMixedScript(text: unknown): SpanHit[];
|
|
213
|
+
/**
|
|
214
|
+
* Markup an agent's HTML email may not carry: <style>, <script>, on*=
|
|
215
|
+
* event handlers, CSS content: in a style attribute, embedded frames or
|
|
216
|
+
* objects, and javascript: links. Reads the HTML as written.
|
|
217
|
+
*/
|
|
218
|
+
export declare function findUnsafeMarkup(html: unknown): SpanHit[];
|
|
219
|
+
export type PriceShape = 'exact' | 'floor' | 'ceiling' | 'range';
|
|
220
|
+
export interface PriceMention {
|
|
221
|
+
/** Offset into the normalized text. */
|
|
222
|
+
index: number;
|
|
223
|
+
/** The whole mention, floor or ceiling words and the range included ("starting at $4,000", "$4,000-$15,000"). */
|
|
224
|
+
match: string;
|
|
225
|
+
shape: PriceShape;
|
|
226
|
+
min: number;
|
|
227
|
+
max: number;
|
|
228
|
+
/** The dollar amounts in it, as written. */
|
|
229
|
+
amounts: string[];
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Every price in a text, with its shape: exact ("$4,500"), a floor
|
|
233
|
+
* ("starting at $4,000", "$25,000+"), a ceiling ("up to $900") or a range
|
|
234
|
+
* ("$4,000 - $15,000", "between $4k and $15k"). Reads the normalized text.
|
|
235
|
+
*/
|
|
236
|
+
export declare function findPriceMentions(text: unknown): PriceMention[];
|
|
237
|
+
export interface ClaimNumber {
|
|
238
|
+
/** Offset into the normalized text. */
|
|
239
|
+
index: number;
|
|
240
|
+
/** The number as written, with its % or x ("45%", "3x", "120", "$300"). */
|
|
241
|
+
match: string;
|
|
242
|
+
/** Its digits, commas dropped ("1200", "4.9"): what grounding compares. */
|
|
243
|
+
value: string;
|
|
244
|
+
kind: 'percent' | 'multiplier' | 'count' | 'money';
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The claim numbers in a text (see ClaimNumber) that none of `sources`
|
|
248
|
+
* carries. Times, dates, phones, years, addresses and the digits of a web or
|
|
249
|
+
* email address aren't claims and are skipped. Reads the normalized text.
|
|
250
|
+
*/
|
|
251
|
+
export declare function findUnsupportedNumbers(text: unknown, sources?: ReadonlyArray<unknown>): ClaimNumber[];
|
|
252
|
+
export type ClaimSubject = 'sender' | 'recipient' | 'unknown';
|
|
253
|
+
/**
|
|
254
|
+
* Whose claim the number at `index` is: the nearest pronoun or name before
|
|
255
|
+
* it in the same clause (a clause ends at , ; : or a dash, and at "and" or
|
|
256
|
+
* "but"). We, our, us (and I, me, my) and the sender's names mean the sender;
|
|
257
|
+
* you, your and the recipient's names mean the recipient. A bare I or we
|
|
258
|
+
* followed by an observation verb ("I noticed", "we measured") names neither.
|
|
259
|
+
* 'unknown' when the clause names neither: a caller treats that as the
|
|
260
|
+
* sender's unless the number is in the research about the recipient.
|
|
261
|
+
*/
|
|
262
|
+
export declare function claimSubjectOf(sentence: string, index: number, names?: {
|
|
263
|
+
senderNames?: ReadonlyArray<string> | null;
|
|
264
|
+
recipientNames?: ReadonlyArray<string> | null;
|
|
265
|
+
}): ClaimSubject;
|
|
266
|
+
/**
|
|
267
|
+
* "I" with the firm's work: build, built, design, offer, work with, put
|
|
268
|
+
* together, deliver ("I built your competitor's site"). The firm's work is
|
|
269
|
+
* "we"; "I" is for what the sender noticed, read or thinks. Quotes are left
|
|
270
|
+
* alone.
|
|
271
|
+
*/
|
|
272
|
+
export declare function findFirstPersonFirm(text: unknown): SpanHit[];
|
|
273
|
+
/** A claim the writer made and what backs it: a Business Brief fact, or the research about the recipient. */
|
|
274
|
+
export type OutboundClaim = {
|
|
275
|
+
text: string;
|
|
276
|
+
fact_id?: string | null;
|
|
277
|
+
} | {
|
|
278
|
+
text: string;
|
|
279
|
+
evidence: 'research';
|
|
280
|
+
};
|
|
281
|
+
/** A price as the Business Brief publishes it ("$4,000 - $15,000", "Starting at $500 a month"). */
|
|
282
|
+
export interface OutboundPrice {
|
|
283
|
+
display: string;
|
|
284
|
+
fact_id?: string | null;
|
|
285
|
+
}
|
|
286
|
+
/** A Business Brief fact a claim may cite by id. Only a verified one supports anything. */
|
|
287
|
+
export interface OutboundFact {
|
|
288
|
+
id: string;
|
|
289
|
+
text: string;
|
|
290
|
+
verified: boolean;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The project's rules, pre-resolved by the caller from its Business Brief and
|
|
294
|
+
* narrative, so no brain shape lives here. Frozen into a review, they're what
|
|
295
|
+
* the send side re-checks the BLOCK set against.
|
|
296
|
+
*/
|
|
297
|
+
export interface OutboundRules {
|
|
298
|
+
/** Published prices. */
|
|
299
|
+
prices: ReadonlyArray<OutboundPrice>;
|
|
300
|
+
/** True once the brief publishes a price: then a $ amount that matches none of them is refused, not flagged. */
|
|
301
|
+
prices_known: boolean;
|
|
302
|
+
/** The project's banned claims, matched literally. */
|
|
303
|
+
banned: ReadonlyArray<string>;
|
|
304
|
+
/** The narrative's forbidden phrases, matched literally. */
|
|
305
|
+
forbidden: ReadonlyArray<string>;
|
|
306
|
+
facts?: ReadonlyArray<OutboundFact>;
|
|
307
|
+
/** The business's own names, for telling a claim about the sender from one about the recipient. */
|
|
308
|
+
sender_names?: ReadonlyArray<string>;
|
|
309
|
+
}
|
|
310
|
+
export interface OutboundCopy {
|
|
311
|
+
subject?: string | null;
|
|
312
|
+
body: string;
|
|
313
|
+
format: OutboundFormat;
|
|
314
|
+
channel: OutboundChannel;
|
|
315
|
+
}
|
|
316
|
+
export interface OutboundEvidence {
|
|
317
|
+
/** Primary evidence about the recipient (their homepage text, measured scores). The only research that grounds anything. */
|
|
318
|
+
primary?: string | null;
|
|
319
|
+
/** Earlier guesses (a cached model audit). Never grounds a claim; for the model audit's fence only. */
|
|
320
|
+
hints?: string | null;
|
|
321
|
+
}
|
|
322
|
+
export interface OutboundCheckContext {
|
|
323
|
+
author: OutboundAuthor;
|
|
324
|
+
/** Price, banned and forbidden rules run only when set. */
|
|
325
|
+
rules?: OutboundRules | null;
|
|
326
|
+
claims?: ReadonlyArray<OutboundClaim> | null;
|
|
327
|
+
evidence?: OutboundEvidence | null;
|
|
328
|
+
/**
|
|
329
|
+
* cited: a number must be backed by a claim the writer cited (Signal's copy).
|
|
330
|
+
* present: a number may also be backed by a verified fact or the primary
|
|
331
|
+
* evidence that carries it (an agent's own copy). Defaults to present for
|
|
332
|
+
* an agent and cited otherwise.
|
|
333
|
+
*/
|
|
334
|
+
numbers?: 'cited' | 'present';
|
|
335
|
+
recipient?: {
|
|
336
|
+
names?: ReadonlyArray<string> | null;
|
|
337
|
+
} | null;
|
|
338
|
+
}
|
|
339
|
+
export interface OutboundCheck {
|
|
340
|
+
/** Refused at filing and again at send. */
|
|
341
|
+
blocking: OutboundFinding[];
|
|
342
|
+
/** Forces propose; a person settles them (an agent's own are reported back). Always empty for operator copy. */
|
|
343
|
+
flags: OutboundFinding[];
|
|
344
|
+
/** House style. Never blocks. */
|
|
345
|
+
style: OutboundFinding[];
|
|
346
|
+
/** The body as the reader sees it (outboundPlainText). */
|
|
347
|
+
plain: string;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The one outbound checker. Reads the copy as the reader sees it
|
|
351
|
+
* (outboundPlainText, then normalized for matching, entities decoded once),
|
|
352
|
+
* sentence by sentence, and sorts what it finds into:
|
|
353
|
+
*
|
|
354
|
+
* - blocking: empty subject (email), empty body (fewer than 8 letters in an
|
|
355
|
+
* email, none in a text), unrendered tokens, platform banned claims, the
|
|
356
|
+
* project's banned claims and the narrative's forbidden phrases, a price
|
|
357
|
+
* that matches no published one (once prices are known), mixed-script
|
|
358
|
+
* words, and for an agent's own copy dashes and unsafe HTML markup.
|
|
359
|
+
* - flags (never for operator copy): unverifiable prices, unsupported
|
|
360
|
+
* numbers, citations that are unknown, unverified, don't match or come
|
|
361
|
+
* from the wrong source, a banned claim a negation takes back, writer
|
|
362
|
+
* notes, and for an agent's own copy numbers taken from the research.
|
|
363
|
+
* - style: uncontracted forms, dashes (Signal and operator copy) and "I" for
|
|
364
|
+
* the firm's work.
|
|
365
|
+
*
|
|
366
|
+
* Research grounds a number or a price only in a sentence about the
|
|
367
|
+
* recipient (claimSubjectOf); in a sentence about the sender it's
|
|
368
|
+
* citation_wrong_source. A clause that names nobody counts as the
|
|
369
|
+
* recipient's only when the research about them carries that number, and a
|
|
370
|
+
* subject line may repeat a number the body cites that way. Price rules run
|
|
371
|
+
* only when ctx.rules is set. An author it doesn't know is checked as an
|
|
372
|
+
* agent, the strictest set.
|
|
373
|
+
*/
|
|
374
|
+
export declare function checkOutboundCopy(copy: OutboundCopy, ctx: OutboundCheckContext): OutboundCheck;
|
|
375
|
+
export declare const OUTBOUND_AUDIT_STATUSES: readonly ['clean', 'flagged', 'not_run', 'not_needed', 'stale'];
|
|
376
|
+
export type OutboundAuditStatus = (typeof OUTBOUND_AUDIT_STATUSES)[number];
|
|
377
|
+
export interface OutboundClaimAudit {
|
|
378
|
+
status: OutboundAuditStatus;
|
|
379
|
+
[key: string]: unknown;
|
|
380
|
+
}
|
|
381
|
+
/** A person said they checked these flags, at approval. */
|
|
382
|
+
export interface OutboundAcknowledgement {
|
|
383
|
+
by: string;
|
|
384
|
+
at: string;
|
|
385
|
+
/** The route it came through ('dashboard', 'drafts', ...). */
|
|
386
|
+
via: string;
|
|
387
|
+
flag_ids: string[];
|
|
388
|
+
}
|
|
389
|
+
/** An MCP agent stood behind the flags on its own copy. An assertion, never a review: it settles flags only on author agent copy. */
|
|
390
|
+
export interface OutboundAssertion {
|
|
391
|
+
by: string;
|
|
392
|
+
at: string;
|
|
393
|
+
flag_ids: string[];
|
|
394
|
+
}
|
|
395
|
+
/** payload.claim_review: what the checker and the audit found when the copy was filed, and who settled it. */
|
|
396
|
+
export interface OutboundClaimReview {
|
|
397
|
+
version: 1;
|
|
398
|
+
checked_at: string;
|
|
399
|
+
author: OutboundAuthor;
|
|
400
|
+
brain_version: number | null;
|
|
401
|
+
/** The rules the copy was checked against, frozen, so the send side re-checks the same ones. */
|
|
402
|
+
rules: OutboundRules | null;
|
|
403
|
+
flags: OutboundFinding[];
|
|
404
|
+
style: OutboundFinding[];
|
|
405
|
+
audit: OutboundClaimAudit;
|
|
406
|
+
acknowledged: OutboundAcknowledgement | null;
|
|
407
|
+
asserted: OutboundAssertion | null;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A stored claim review, read strictly. Null means not reviewed: missing,
|
|
411
|
+
* from another version, or malformed anywhere (a caller treats a malformed
|
|
412
|
+
* review exactly like a missing one, never as a clean one).
|
|
413
|
+
*/
|
|
414
|
+
export declare function readOutboundClaimReview(value: unknown): OutboundClaimReview | null;
|
|
415
|
+
/**
|
|
416
|
+
* The flags nobody has settled: in neither acknowledged.flag_ids (a person,
|
|
417
|
+
* at approval) nor, on an agent's own copy only, asserted.flag_ids (the agent
|
|
418
|
+
* standing behind them). Empty means it may go out.
|
|
419
|
+
*/
|
|
420
|
+
export declare function unsettledFlags(review: OutboundClaimReview): OutboundFinding[];
|
|
421
|
+
/**
|
|
422
|
+
* The one wording for a refusal: every fix, numbered, each with the sentence
|
|
423
|
+
* it's about. Contracted, and never an em or en dash (one in a quoted
|
|
424
|
+
* sentence is spelled out as "[em dash]").
|
|
425
|
+
*/
|
|
426
|
+
export declare function outboundRefusalMessage(findings: ReadonlyArray<Pick<OutboundFinding, 'sentence' | 'reason' | 'fix'>>): string;
|
|
427
|
+
export type ReadBackStatus = 'verified' | 'empty' | 'mismatch' | 'unreadable';
|
|
428
|
+
export interface ReadBackVerdict {
|
|
429
|
+
status: ReadBackStatus;
|
|
430
|
+
/** Length of what was read, normalized. */
|
|
431
|
+
body_chars: number;
|
|
432
|
+
/** Length of what was meant to go out, normalized. */
|
|
433
|
+
intended_chars: number;
|
|
434
|
+
/** How much of the intended text, from the first character it stops matching, isn't there. 0 when verified. */
|
|
435
|
+
chars_missing: number;
|
|
436
|
+
/** Links in the intended copy that aren't in what was read. */
|
|
437
|
+
urls_missing: string[];
|
|
438
|
+
}
|
|
439
|
+
/** A body to compare: a string (read as HTML when it has a tag), or the body with its format. */
|
|
440
|
+
export type ReadBackBody = string | {
|
|
441
|
+
body: string | null | undefined;
|
|
442
|
+
format?: OutboundFormat;
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* Whether what a mailbox says it sent is what was meant to go out. Both sides
|
|
446
|
+
* are read as the reader sees them and normalized, so markup, entities,
|
|
447
|
+
* quotes and whitespace don't count.
|
|
448
|
+
*
|
|
449
|
+
* - verified: the whole intended text is in what was read (a footer or a
|
|
450
|
+
* quoted header around it is fine), and so is every link it carries.
|
|
451
|
+
* - empty: what was read has fewer than 8 letters.
|
|
452
|
+
* - mismatch: anything else, with how much of the intended text is missing
|
|
453
|
+
* (from the first point it stops matching) and which links are.
|
|
454
|
+
* - unreadable: nothing could be read.
|
|
455
|
+
*/
|
|
456
|
+
export declare function readBackVerdict(intended: ReadBackBody, read: ReadBackBody | null | undefined): ReadBackVerdict;
|