@meddevkit/crisis-signals
v0.2.0
Published
Offline keyword-and-context screening that flags first-person crisis language for human follow-up (unreviewed; not a diagnostic or risk-assessment tool)
Maintainers
Readme
@meddevkit/crisis-signals
A keyword-and-context screen for first-person crisis language. It is not a diagnostic or risk-assessment tool, it will miss some expressions of risk, and it must never be the only safeguard. Route any flagged text to a person according to your own escalation policy.
Status: in development, unreviewed. No clinician has reviewed the categories, the level mapping, the patterns or the test vectors yet. Do not describe this screen as validated. The measured limits below come from synthetic test vectors, not from clinical review or real-world use.
Measured limits
On text it was not written against, it caught about half of crisis statements. Use it as an explainable, fast, offline first-pass signal, never as the only safeguard.
Patterns version 0.2.0. All vectors are synthetic and unreviewed; intervals are Wilson 95% confidence intervals.
| Set | Crisis recall | Any-flag recall | Grief false alarms (→ crisis) | Idiom and everyday false alarms |
|---|---|---|---|---|
| Held-out v2, written blind (167 vectors). The honest number. | 15/31 = 48.4% (32.0–65.2%) | 36/56 = 64.3% (51.2–75.5%) | 0/14 (0–21.5%) | 2/34 = 5.9% (1.6–19.1%) |
| Dev (534 vectors; patterns written against it) | 172/172 | 245/245 | 0/28 | 0/134 |
The dev numbers show only that the patterns match the text they were written for. The held-out numbers are the ones to plan around, and the small sample gives them wide intervals.
What it misses. Mostly oblique language: preparation described without any crisis words, imagery of a method or of an outcome, and wishes expressed by their meaning rather than by recognisable wording. Keyword-and-context rules plateau on this kind of text; more rounds of patterns mostly fit the dev set.
What it does well.
- Explicit first-person statements of suicidal thoughts, self-harm or feeling unsafe with oneself, including slang and texting spellings.
- Context: negation, past and resolved distress, hypotheticals, quotations, third parties and idioms change a finding's level, and the finding is kept.
- Explainability: every flag carries a rule id, matched span, offsets and modifiers, and the full pattern list is public.
- Speed and offline use: deterministic, no network calls, no dependencies, about 25 ms for 50,000 characters.
Recommendation. Combine it with a second opinion (see
Second opinion (opt-in); the
@meddevkit/crisis-adapter-nope adapter is in development) and with a human
review process. A none result does not mean the text is safe.
What it does
screenText looks for first-person language suggesting the writer may be in
acute distress or at risk of harming themselves, and returns a level
(none, concern or crisis), an immediacy flag, and every finding with
its rule id, matched span, offsets and context modifiers (negated, past,
resolved, hypothetical, quoted, idiom, third party). Context changes a
finding's level; it never hides it. It also flags a person threatening the
writer (threatened_by_other, at concern).
The library does not show crisis resources and does not decide what happens
next. Escalation is your job, because the right resources depend on locale and
setting. For example, a US consumer might surface 988 (Suicide & Crisis
Lifeline) on crisis.
Properties
- Offline. No network calls and no model inference. Deterministic.
- No logging. It does not log, store or send text, and errors never contain text. Findings carry offsets and spans of at most 120 characters.
- Zero runtime dependencies. Works in Node >= 18 and the browser.
- Linear time. A 50,000-character input screens in about 25 ms on a laptop (under 45 ms for the worst adversarial input in the test suite); every repetition in the patterns is bounded, and the test suite includes adversarial inputs to guard against regex backtracking.
- English only (v1). Risk to others is out of scope (v1); a threat
against the writer is flagged as
threatened_by_other. - Not HIPAA-anything. This package makes no HIPAA or other regulatory compliance claim.
Usage
import { screenText } from '@meddevkit/crisis-signals';
const r = screenText('I keep thinking about ending it. Maybe tonight.');
// r.level -> 'crisis'
// r.immediacy -> true
// r.findings -> [
// { ruleId: 'si_active.ending_it', category: 'suicidal_ideation_active',
// baseLevel: 'crisis', level: 'crisis', startOffset: 7, endOffset: 31,
// text: 'thinking about ending it',
// modifiers: { negated: false, past: false, resolved: false,
// hypothetical: false, quoted: false, idiom: false },
// subject: 'self' },
// { ruleId: 'immediacy.timing', category: 'immediacy', baseLevel: 'none',
// level: 'crisis', text: 'tonight', ... }
// ]
// r.patternsVersion -> '0.2.0'
screenText('This commute is killing me.').level; // 'none' (idiom, finding kept)
screenText("I'm not going to hurt myself.").level; // 'concern' (negated, never 'none')
screenText('My friend told me she wanted to die.').level; // 'none' (subject 'other', finding kept)
screenText('Years ago I tried to kill myself. I am doing better now.').level; // 'concern' (history)
// Audits: report idioms without suppressing them.
screenText('This commute is killing me.', { suppressIdioms: false }).level; // 'concern'Offsets are UTF-16 code units (JavaScript string indices). findings are in
text order and include findings that context softened to none.
screenText throws a TypeError (with no text in the message) if text is
not a string or the options are malformed.
How levels are decided
| Level | Meaning |
|---|---|
| crisis | Present, first-person thoughts of suicide or self-harm, or feeling unsafe with oneself |
| concern | Acute distress without self-harm language, or crisis language that context softened |
| none | Nothing found, or only idioms, third parties, or clearly resolved past distress |
Categories: suicidal_ideation_passive, suicidal_ideation_active,
self_harm, unsafe_with_self (base crisis); acute_distress, history,
threatened_by_other (base concern); immediacy (flag only).
threatened_by_other flags a person threatening or hurting the writer ("my
boyfriend said he'd kill me if I left, and he keeps a knife by the bed"). Its
subject is always other, and the third-party rule does not lower it. The
same shape is often figurative ("my mom is going to kill me when she sees my
grades"), so idiom cues still suppress it, unless means, violence or fear
appear in the same or the next sentence; then it stays concern.
One modifier sets each finding's level, by precedence:
idiom→none("dying to know", "this job is killing me").- third party →
none,subject: 'other'("my friend said she wanted to die"). past+resolved→ acute distressnone; crisis languageconcernwith categoryhistory.resolvedmay come from the next sentence ("I was falling apart. I'm not there anymore.").negated,hypothetical,quoted, orpastalone →concern(negated acute distress →none). If several apply, the higher level wins.
Context rules worth knowing:
- Recent past is present. "Last night", "yesterday", "this week" and a
present-tense verb in the same clause keep a finding current.
pastneeds a distant anchor ("years ago", "back in college", "when I was nineteen", "used to", "last year"). - A first-person conditional is not hypothetical. "If things don't get
better I might end my life" stays
crisis. Questions about others or about procedure ("how do people know if…", "would you have to report it?") arehypothetical. - Negation is scoped to the clause after the cue, and pseudo-negations
("not sure", "don't know", "can't stop") do not count. It does not reach
into a later subordinate clause with its own first-person subject ("so
she's not stuck after I'm gone"). A denial can also follow the crisis
wording ("wanting to die isn't what this is", "hurting myself has never
been part of it"). A denial of self-harm is still
concern, because it is worth a human look. - Vague wording counts only in context. "I'm going to finish it", "how
I'd do it", "for when they find me" or "this is it" is crisis language
only when crisis language, lethal means, a plan, preparation or a high
place appears in the same, previous or next sentence (see
Context-gated patterns). Final-sounding
statements that are alarming on their own ("this is the last time you'll
hear from me", "nobody can talk me out of it") are
concern. immediacy(plan, means, timing, preparation) is set only when a non-negatedcrisisfinding is present in the same text. It never lowers a level.- The text's level is the maximum over its findings, so the writer's own statement wins over an idiom or a third-party finding elsewhere.
Second opinion (opt-in)
screenTextWithSecondOpinion combines the offline screen with one model or
API classifier that you configure. Text leaves the device only if you
install and configure an adapter; screenText alone never sends anything,
and this package makes no network calls itself.
import { screenTextWithSecondOpinion } from '@meddevkit/crisis-signals';
const r = await screenTextWithSecondOpinion(text, adapter, { timeoutMs: 10000 });
// r.level, r.immediacy combined
// r.local exactly what screenText(text) returns
// r.secondOpinion { name, version, status: 'ok'|'error'|'timeout'|'invalid', result?, reason?, durationMs }
// r.degraded true when the adapter did not return a valid result
// r.raisedBy 'local' | 'secondOpinion' | 'both' | 'none'Combination rules:
- The offline result is computed first and returned unchanged as
local. - The final level is the maximum of the two: a model can raise a level,
never lower it.
raisedBy: 'secondOpinion'means the flag has no rule, span or modifiers behind it; route it to a person like any other flag. - An opinion about someone else (
subject: 'other') raises at most toconcern. Adapters for providers that cannot tell the subject raise at most toconcern. immediacyis the local flag, or the model's when the final level iscrisis.- If the adapter throws, times out (default 10 s) or returns an invalid
result, you get the local level with
degraded: true. The outcome never contains input text or the provider's error message. - Aborting your
signalrejects with anAbortErrorand aborts the adapter's request.
Adapters implement the CrisisSecondOpinion interface and are separate
packages. The first, @meddevkit/crisis-adapter-nope (self-hosted
nope-edge), is in development.
Patterns are public
The full pattern list, modifier cues and clause boundaries are open source, so
you can see exactly what the screen does and does not match:
data/crisis.patterns.json.
The raw file is also shipped in the package as
@meddevkit/crisis-signals/patterns.json for vendoring into other runtimes.
Pattern file format
{
"version": "0.2.0",
"fragments": [ // named regex pieces, referenced as {{ID}}
{ "id": "SELF", "pattern": "\\b(?:i|im|i['’]m|i\\s+am|ive|i['’]ve|…)\\b", "note": "…", "sources": [], "relation": "informed-by" }
],
"patterns": [ // detection patterns
{ "id": "si_active.ending_it", "category": "suicidal_ideation_active", "level": "crisis",
"pattern": "…", "flags": "i", "note": "…", "sources": ["srl-v1.0", "nimh-asq"], "relation": "informed-by" },
{ "id": "si_active.vague_act_in_context", "category": "suicidal_ideation_active", "level": "crisis",
"pattern": "…", "flags": "i", "note": "…", "sources": ["srl-v1.0"], "relation": "informed-by",
"context": { // context-gated: a match counts only with this context nearby
"scope": "adjacent",
"categories": ["suicidal_ideation_passive", "suicidal_ideation_active", "self_harm", "unsafe_with_self", "history"],
"rules": ["immediacy.plan", "immediacy.means", "immediacy.preparation", "immediacy.location"],
"pattern": "{{DROP}}|{{LETHAL}}|…", "flags": "i" } }
],
"modifiers": [ // context cues
{ "id": "negated.not", "modifier": "negated", "scope": "forward", "maxGap": 50, "pattern": "…", … },
{ "id": "past.block_recent", "modifier": "past", "scope": "clause", "effect": "block", "pattern": "…", … }
],
"boundaries": [ // clause boundaries for modifier scope
{ "id": "boundary.contrast", "strength": "strong", "pattern": "…", … }
]
}Fragments. A pattern, cue, boundary or later fragment may reference a
fragment as {{ID}}; the loader replaces each reference with
(?:<fragment>) before compiling, and a fragment may reference only fragments
listed before it. Fragments hold the shared frames and vocabulary slots: a
first-person subject (SELF), bounded fillers (FILL, hedges, intensifiers,
auxiliaries and negations), a clause start for subjectless texting (START),
imagery and urge frames (IMAGINE), and slots such as IDEATE, INTEND,
CANT, PILLS, MEANS, LETHAL, DROP (high places and tracks), BODY,
SH_MARK, TIMING, PERMANENT and NUISANCE (everyday annoyances). Another runtime that vendors the file must apply the
same substitution (one regex replace).
Finding span. When a capturing group takes part in a match, the first such group is the finding's span and the rest of the match is required context. A frame such as "I've never" can then precede the core "cut" without being part of the span, so a negation in the frame scopes over the finding as a cue. An empty group is a veto: the match consumes its text and yields no finding (for example "reading about self-harm"). Without a participating group, the whole match is the span.
Cue scope values: forward (the finding starts within maxGap characters
after the cue, in the same clause), sentence (same sentence, unless a
strong boundary lies between), sentence-or-next (same or next sentence),
within (the finding lies inside the cue's match, e.g. a quotation),
overlap (the cue overlaps the finding, e.g. an idiom). Cues with
"effect": "block" cancel a modifier: clause and sentence blocks cancel it
for findings in that clause or segment, a sentence-or-next block for
findings in the same or the previous sentence, and a between block cancels a
forward cue when it lies after that cue and before the end of the finding.
A cue with "categories": [...] acts only on findings of those categories
(for example the literal-threat evidence that keeps a threatened_by_other
finding from being read as an idiom).
Context-gated patterns
A detection pattern with a context object is context-gated. It is for vague
or anaphoric wording that is crisis language only in context: "I've thought
about it a lot and I'm going to finish it. I'm done being alive", but not
"I'm going to finish it this weekend, the deck ships Monday". A gated match
yields a finding only when, within scope (sentence: the same sentence;
adjacent: the same sentence, the one before it, or the one after it), at
least one of these is present and does not overlap the match itself:
- a finding of an ungated detection pattern whose category is listed in
categories, or of an ungated detection or immediacy pattern whose id is listed inrules, that is neither negated nor suppressed as an idiom (past, quoted or third-party context still counts: "I tried to end my life in 2019. This time I'm going to finish what I started"); or - a match of
pattern(withflags; fragments allowed, no capturing groups), for vocabulary that is not a finding of its own ("I keep thinking about how I'd do it. The bridge, mostly").
Context comes only from ungated patterns, so gates never depend on each other
and the order of evaluation does not matter. Passing gated matches then
compete with the ungated matches for overlaps as usual (the higher base level
wins), and modifiers apply to them like any other finding. A gate never lowers
anything: without its context, a gated match is simply not a finding, and an
ungated pattern can cover the same wording at a lower level
(distress.vague_finality). Another runtime implements a gate in a few lines:
collect the ungated findings and their modifiers, then keep each gated match
whose sentence window holds qualifying context.
Every regex stays within a portable subset (no named groups, \p{…}, inline
flags, atomic or possessive groups, variable-width lookbehind, \Z/\z or
$), so ECMAScript (u flag) and Python re give the same matches, before
and after fragment expansion. Patterns run over the whole text; sentence
splitting is structural and lives in code.
Metrics
pnpm --filter @meddevkit/crisis-signals build
pnpm --filter @meddevkit/crisis-signals metrics # SPEC §8 metrics on data/vectors.jsonl
node packages/crisis-signals/scripts/metrics.mjs <file.jsonl>Published metrics: see Measured limits. Each release reports the dev set and every held-out set separately (SPEC §8).
Design and evaluation plan: SPEC.md. Third-party attributions: NOTICE.
License
MIT. Some patterns are informed by third-party sources; see NOTICE.
