@dzhechkov/p-replicator 1.5.14 → 1.5.15
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/.dz-manifest.json +31 -7
- package/package.json +1 -1
- package/sbom.json +66 -6
- package/templates/.claude/skills/explore/SKILL.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +141 -11
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +363 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +297 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +277 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v1_fact.json +21 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +462 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py +170 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +784 -0
- package/templates/.claude/skills/problem-solver-enhanced/SKILL.md +1 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/SKILL.md +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Self-learning bridge — health-advisor learns from its own retractions, via dz.
|
|
4
|
+
|
|
5
|
+
WHY A BRIDGE AND NOT AN ENGINE. The learning loop (`dz recall` / `dz teach`) is
|
|
6
|
+
SQLite FTS5 + a vector tier + embeddings. Vendoring that into a content skill pack
|
|
7
|
+
would not just be heavy — it would create a SECOND store, and a loop only compounds
|
|
8
|
+
when recall and teach hit ONE. So harness-cli is an OPTIONAL, DETECTED dependency:
|
|
9
|
+
absent, this package behaves exactly as before and says so once; present, research
|
|
10
|
+
sessions start by recalling the traps already caught and end by teaching new ones.
|
|
11
|
+
|
|
12
|
+
WHAT IS WORTH TEACHING HERE (the loop points, by analogy with the pipeline's own
|
|
13
|
+
QE step — the moment a conclusion was WRONG is the most valuable signal there is):
|
|
14
|
+
* a retraction — a conclusion that had to be withdrawn;
|
|
15
|
+
* a population check that flipped a conclusion;
|
|
16
|
+
* a preanalytical finding that explained an alarming value;
|
|
17
|
+
* closing an open question.
|
|
18
|
+
|
|
19
|
+
THE PRIVACY RULE IS AN INVARIANT, NOT A WISH.
|
|
20
|
+
A lesson describes a METHOD, never a PATIENT:
|
|
21
|
+
good — "total testosterone is uninterpretable without SHBG"
|
|
22
|
+
bad — "the patient has fasted 3 days weekly for 7 years, testosterone 8.04"
|
|
23
|
+
Without this the learned store quietly becomes a medical record: it is a plain file
|
|
24
|
+
on disk, it is shared across projects, and nobody consented to that. `check_lesson`
|
|
25
|
+
REFUSES the second shape with a reason, so the caller must rewrite it as a method.
|
|
26
|
+
|
|
27
|
+
HONEST SCOPE of that guard, stated at its real width: it enforces the SHAPE of a
|
|
28
|
+
method lesson — no digits (spelled-out values included), no identifiers, lower case
|
|
29
|
+
after the first word, and never a person beside a number. It is a shape detector, not
|
|
30
|
+
a de-identifier. A sentence that names nobody, quotes no figure and is written in
|
|
31
|
+
lower case can still describe one person; the guard cannot see that, and neither can
|
|
32
|
+
any regular expression. What keeps the store clean is the shape rule PLUS the four
|
|
33
|
+
teach moments, which are moments of method. The guard fails toward refusal.
|
|
34
|
+
|
|
35
|
+
Usage (from a skill or by hand):
|
|
36
|
+
python3 learning_bridge.py status
|
|
37
|
+
python3 learning_bridge.py recall "transferrin saturation" [--limit 5]
|
|
38
|
+
python3 learning_bridge.py teach "<method lesson>" [--reward 0.8]
|
|
39
|
+
python3 learning_bridge.py check "<candidate lesson>" # privacy guard only
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import re
|
|
46
|
+
import shutil
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import List, Optional, Sequence, Tuple
|
|
51
|
+
|
|
52
|
+
# The domain every lesson from this package is tagged with, and the one recall
|
|
53
|
+
# boosts. One shared store, one namespace inside it.
|
|
54
|
+
LEARNING_DOMAIN = "health-research"
|
|
55
|
+
|
|
56
|
+
# THE WIRE CONTRACT between this bridge and `dz recall --domain`.
|
|
57
|
+
#
|
|
58
|
+
# The CURRENT dz ends its domain run with one of two note lines; their ABSENCE is how
|
|
59
|
+
# an older CLI is detected (see recall() for why an exit code cannot do that job).
|
|
60
|
+
#
|
|
61
|
+
# Round 2 broke the first version of this: the marker was the bare substring `domain "`,
|
|
62
|
+
# which any RECALLED LESSON could contain — `remember domain "ownership" before
|
|
63
|
+
# reporting` would have certified an old CLI as boost-capable. A capability probe that
|
|
64
|
+
# the payload can forge is not a probe. So the check is now anchored to the START of a
|
|
65
|
+
# line AND requires one of the two full tails the renderer emits.
|
|
66
|
+
#
|
|
67
|
+
# These strings are pinned on the other side too: a harness-core test asserts
|
|
68
|
+
# renderDomainBoostNote() still emits them, so changing the wording turns that test red
|
|
69
|
+
# instead of silently switching this loop into permanent degraded mode.
|
|
70
|
+
BOOST_NOTE_TAILS = (
|
|
71
|
+
"foreign-domain lessons kept (a boost, not a filter)",
|
|
72
|
+
"order unchanged, nothing was hidden",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _boost_note_present(out: str, domain: str = LEARNING_DOMAIN) -> bool:
|
|
77
|
+
"""True when THIS dz printed a real domain-boost note (not a lesson quoting one)."""
|
|
78
|
+
head = f'domain "{domain}":'
|
|
79
|
+
for line in out.splitlines():
|
|
80
|
+
stripped = line.strip()
|
|
81
|
+
if stripped.startswith(head) and any(tail in stripped for tail in BOOST_NOTE_TAILS):
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
DZ_MISSING_NOTE = (
|
|
86
|
+
"self-learning is OFF: `dz` (@dzhechkov/harness-cli) is not installed. "
|
|
87
|
+
"The package works exactly as before; install it to let research sessions recall "
|
|
88
|
+
"the traps already caught and record new ones: npm i -g @dzhechkov/harness-cli"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------- privacy guard
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# THE GUARD IS AN ALLOWLIST, NOT A BLOCKLIST — and that is a deliberate rewrite.
|
|
95
|
+
#
|
|
96
|
+
# The first design listed personal-data SHAPES and refused those. A cross-model
|
|
97
|
+
# review broke it in minutes, and the breakage was not a missing pattern but the
|
|
98
|
+
# wrong SHAPE OF DESIGN:
|
|
99
|
+
# * one stray word laundered anything — "TSH was 8.04 mIU/L WHEN fasting" passed,
|
|
100
|
+
# because a threshold-marker exemption trusted the presence of a word to prove
|
|
101
|
+
# the number was a guideline rule;
|
|
102
|
+
# * every unit, date format, script and identifier outside the list walked
|
|
103
|
+
# through: `MRN 84729163`, `Patient John Smith has HIV`, `130 µmol/L`,
|
|
104
|
+
# `8 мая 2026`, an email, a phone number.
|
|
105
|
+
# Enumerating what personal data LOOKS LIKE is an arms race whose every gap is a
|
|
106
|
+
# leaked medical fact. So the rule is inverted:
|
|
107
|
+
#
|
|
108
|
+
# A METHOD LESSON CARRIES NO DIGITS, NO IDENTIFIERS,
|
|
109
|
+
# AND IS WRITTEN IN LOWER CASE (acronyms excepted).
|
|
110
|
+
#
|
|
111
|
+
# All three clauses are properties of the ACCEPTED form. That matters: "no names" was
|
|
112
|
+
# still a blocklist in disguise, because a name is any word and no list of them exists
|
|
113
|
+
# — which is precisely how `John Smith has HIV` walked through round 2. "Lower case"
|
|
114
|
+
# is checkable in one line and has a one-keystroke fix.
|
|
115
|
+
#
|
|
116
|
+
# It is almost free: "a fast lowers total testosterone by roughly a third" is a fine
|
|
117
|
+
# lesson without the figure. When a number genuinely IS the knowledge (a guideline
|
|
118
|
+
# threshold), the author passes --allow-numbers and takes responsibility explicitly —
|
|
119
|
+
# a decision by a human, not an inference from a word.
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
# Any digit. Yes, any: a threshold, a dose, a year and a lab value are the same
|
|
123
|
+
# character class, and no amount of context-sniffing reliably tells them apart.
|
|
124
|
+
_DIGIT_RE = r"\d"
|
|
125
|
+
|
|
126
|
+
# A value SPELLED OUT is still a value. Round 2 walked `John's TSH was eight point
|
|
127
|
+
# zero four` through the digit rule untouched. Requiring two number-words in a row
|
|
128
|
+
# (or a number-word beside "point") keeps the false-positive cost near zero: method
|
|
129
|
+
# prose says "a third", "one marker", never "eight point zero four".
|
|
130
|
+
_NUMBER_WORDS = (
|
|
131
|
+
"zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
|
|
132
|
+
"fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
|
|
133
|
+
"sixty|seventy|eighty|ninety|hundred|thousand|"
|
|
134
|
+
"ноль|один|одна|два|две|три|четыре|пять|шесть|семь|восемь|девять|десять|"
|
|
135
|
+
"двадцать|тридцать|сорок|пятьдесят|шестьдесят|семьдесят|восемьдесят|девяносто|сто|тысяч\\w*"
|
|
136
|
+
)
|
|
137
|
+
_SPELLED_NUMBER_RE = (
|
|
138
|
+
rf"\b(?:{_NUMBER_WORDS})\b[\s,-]+\b(?:point|целых|запятая|{_NUMBER_WORDS})\b"
|
|
139
|
+
rf"|\bpoint\b[\s,-]+\b(?:{_NUMBER_WORDS})\b"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Direct identifiers — refused regardless of --allow-numbers, because no method
|
|
143
|
+
# lesson needs them and every one of them is a person.
|
|
144
|
+
# NOTE the per-pattern case flag. The identifier scan originally ran WITHOUT
|
|
145
|
+
# IGNORECASE, so `MRN 84729163` walked straight through while the lowercase spelling
|
|
146
|
+
# was caught — but the NAME pattern must stay case-SENSITIVE, since capitalisation is
|
|
147
|
+
# the whole signal. One shared flag could not serve both; a test caught the gap.
|
|
148
|
+
_IDENTIFIER_PATTERNS: Sequence[Tuple[str, str, bool]] = (
|
|
149
|
+
# (pattern, why, ignore_case)
|
|
150
|
+
(r"[\w.+-]+@[\w-]+\.[\w.]+", "an email address", True),
|
|
151
|
+
(r"\+?\d[\d\s().-]{7,}\d", "something shaped like a phone number", True),
|
|
152
|
+
(r"\b(mrn|снилс|полис|истори\w* болезни|medical record (no|number)|record no)\b[:\s#]*\w*",
|
|
153
|
+
"a medical-record identifier", True),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# THE CAPITALISATION RULE — round 2 forced this rewrite.
|
|
157
|
+
#
|
|
158
|
+
# The previous pattern hunted a NAME: two capitalised words in a row, exempting the
|
|
159
|
+
# start of a sentence. Both halves were wrong, and both were measured:
|
|
160
|
+
# * `John Smith has HIV` passed — the exemption fired on the sentence start;
|
|
161
|
+
# * `Patient John has HIV` passed — only one capitalised word followed "Patient".
|
|
162
|
+
# Hunting names is a blocklist wearing an allowlist's clothes: a name is any word,
|
|
163
|
+
# and there is no list of them. So stop hunting names and state a property of the
|
|
164
|
+
# ACCEPTED form instead:
|
|
165
|
+
#
|
|
166
|
+
# A METHOD LESSON IS WRITTEN IN LOWER CASE (acronyms excepted).
|
|
167
|
+
#
|
|
168
|
+
# Any capitalised word after the first token is refused, whatever it is. This does
|
|
169
|
+
# refuse `compare Testosterone Replacement Therapy against placebo` — deliberately.
|
|
170
|
+
# The fix is one keystroke ("lower-case it"), the rule is one sentence, and the
|
|
171
|
+
# false-negative it closes is a named person beside a diagnosis.
|
|
172
|
+
#
|
|
173
|
+
# ROUND 3 forced this one step further, and the finding was the DESIGN again, not the
|
|
174
|
+
# pattern. `\b[A-ZА-Я][a-zа-я]{2,}\b` is still a blocklist: it enumerates which
|
|
175
|
+
# capitalised words look like names, so `Li` (too short), `Élodie` (Latin-1 letter)
|
|
176
|
+
# and `method—John` (em dash, no \s before it) all walked through. Every round of
|
|
177
|
+
# widening the pattern buys one more round.
|
|
178
|
+
#
|
|
179
|
+
# So the check moves to the TOKEN and states what is ACCEPTED, in a form with no
|
|
180
|
+
# residue: split on whitespace, strip surrounding punctuation, and accept a token only
|
|
181
|
+
# if it is one of
|
|
182
|
+
# * all lower case (letters, any script — `.islower()` answers for every alphabet);
|
|
183
|
+
# * an ACRONYM — two or more upper-case letters in it (SHBG, HIV, apoB, HbA1c);
|
|
184
|
+
# * pure punctuation, or the FIRST token of the lesson (an ordinary sentence start);
|
|
185
|
+
# * a NUMBER, judged by the number rules below.
|
|
186
|
+
# `John`, `Li`, `Élodie`, `Testosterone` each carry exactly one capital and are refused
|
|
187
|
+
# without the guard ever asking what they mean — which is the point, since names cannot
|
|
188
|
+
# be enumerated and lower-casing costs one keystroke.
|
|
189
|
+
_TOKEN_EDGE_PUNCT = " \t\n\r.,;:!?()[]{}\"'«»„“”‘’—–-/\\|*_`"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_acronym(token: str) -> bool:
|
|
193
|
+
"""Two or more capitals in one token: SHBG, HIV, apoB, HbA1c. A name has one."""
|
|
194
|
+
return sum(1 for ch in token if ch.isupper()) >= 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Splitting on whitespace ALONE was the last hole round 3 found: `method—John has HIV`
|
|
198
|
+
# passed because the em dash kept `method—John` as ONE token, and that token was first,
|
|
199
|
+
# so it took the sentence-start exemption. Any punctuation that can JOIN two words is a
|
|
200
|
+
# token boundary. A period is a boundary only between non-digits, so `8.04` survives.
|
|
201
|
+
_TOKEN_SPLIT_RE = r"(?:[\s—–/\\|()\[\]{}<>\"«»„“”‘’\',;:!?*_`]|(?<!\d)[.](?!\d))+"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tokens(raw: str) -> List[str]:
|
|
205
|
+
return [t.strip(_TOKEN_EDGE_PUNCT) for t in re.split(_TOKEN_SPLIT_RE, raw) if t.strip(_TOKEN_EDGE_PUNCT)]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# A token that mixes letters and digits is an IDENTIFIER SHAPE — `ab1234567`,
|
|
209
|
+
# `NCT04368728`, an accession, a record number. It is refused even under
|
|
210
|
+
# --allow-numbers, because that flag exists for a THRESHOLD (`8.04`, `56h`), and a
|
|
211
|
+
# threshold is a number with at most a short unit after it. Letters BEFORE digits is
|
|
212
|
+
# not a measurement in any unit system; it is a label for one thing.
|
|
213
|
+
_NUMBER_TOKEN_RE = r"^[<>≥≤~±]?\d+([.,]\d+)?[a-zа-яё%°]{0,4}$"
|
|
214
|
+
|
|
215
|
+
# A reference to ONE person. Alone it is fine — a method sentence may discuss patients
|
|
216
|
+
# in the abstract. Beside a value it is load-bearing: a person plus a reading is a
|
|
217
|
+
# record, and no --allow-numbers waves that through.
|
|
218
|
+
#
|
|
219
|
+
# SINGULAR ONLY, and that is the whole design. Round 3 was right that `the woman has
|
|
220
|
+
# tsh 8.04` must be refused, but the first fix simply added `woman|man|adult|child` and
|
|
221
|
+
# immediately refused `transferrin saturation above 45% warrants attention in men` — a
|
|
222
|
+
# textbook population lesson. Plural person nouns ARE the vocabulary of method lessons
|
|
223
|
+
# about populations; singular ones are the vocabulary of case notes. Number, not word
|
|
224
|
+
# list, is what separates a cohort from a patient, and grammar is checkable.
|
|
225
|
+
#
|
|
226
|
+
# Honest scope: `the woman` is caught, `a 54 year old` is not, and this clause only ever
|
|
227
|
+
# runs when --allow-numbers was passed (digits are refused outright otherwise). It is
|
|
228
|
+
# the narrowest of the three clauses and the only one that is still an enumeration.
|
|
229
|
+
_PERSON_RE = (
|
|
230
|
+
r"\b(patient|subject|participant|client|donor|volunteer|"
|
|
231
|
+
r"woman|man|individual|child|infant|adult|"
|
|
232
|
+
r"he|she|him|his|her|hers|"
|
|
233
|
+
r"пациент(?!ы|ов|ам|ами|ах)\w*|больн(ой|ого|ому|ым|ом)|испытуем(ый|ого|ому|ым|ом)|"
|
|
234
|
+
r"мужчина|мужчины\b|женщина|женщины\b|ребёнок|ребенок|доброволец|"
|
|
235
|
+
r"у него|у неё)\b"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class LessonVerdict:
|
|
240
|
+
ok: bool
|
|
241
|
+
reasons: List[str]
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def note(self) -> str:
|
|
245
|
+
if self.ok:
|
|
246
|
+
# NOT "safe": a shape detector cannot certify safety, and saying so would
|
|
247
|
+
# transfer responsibility it does not have (Codex QE #2). It reports the
|
|
248
|
+
# absence of a detected shape — the author still owns the content.
|
|
249
|
+
return (
|
|
250
|
+
"no personal-data SHAPE detected (no digits, no identifiers, lower case). This is "
|
|
251
|
+
"not a safety certificate: the guard checks shapes, not meaning — a lower-case "
|
|
252
|
+
"sentence with no figures can still describe one person, and you remain "
|
|
253
|
+
"responsible for what it says."
|
|
254
|
+
)
|
|
255
|
+
return "REFUSED — this reads like a record about a person, not a method:\n - " + "\n - ".join(self.reasons)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def check_lesson(text: str, allow_numbers: bool = False) -> LessonVerdict:
|
|
259
|
+
"""Refuse anything not shaped like a METHOD lesson. Fails toward refusal.
|
|
260
|
+
|
|
261
|
+
`allow_numbers` is an EXPLICIT human decision for the case where a number IS the
|
|
262
|
+
knowledge (a guideline threshold). It is a flag a person passes, never something
|
|
263
|
+
inferred from the presence of a word — inferring it is exactly how the first
|
|
264
|
+
design was laundered ("TSH was 8.04 mIU/L WHEN fasting" passed because "when"
|
|
265
|
+
was read as a threshold marker).
|
|
266
|
+
|
|
267
|
+
The right response to a refusal is to rewrite the lesson as the rule it taught,
|
|
268
|
+
which is also the more useful form: a reading helps once, a rule helps every time.
|
|
269
|
+
"""
|
|
270
|
+
raw = text or ""
|
|
271
|
+
if not raw.strip():
|
|
272
|
+
return LessonVerdict(False, ["the lesson is empty"])
|
|
273
|
+
reasons: List[str] = []
|
|
274
|
+
|
|
275
|
+
# Identifiers with a fixed shape (email, phone, medical-record vocabulary) are
|
|
276
|
+
# refused ALWAYS — --allow-numbers must not reach them. This list is genuinely a
|
|
277
|
+
# blocklist and is NOT the guard's backbone: the token allowlist below is what
|
|
278
|
+
# catches identifier shapes the list never heard of.
|
|
279
|
+
for pattern, why, ignore_case in _IDENTIFIER_PATTERNS:
|
|
280
|
+
flags = re.UNICODE | (re.IGNORECASE if ignore_case else 0)
|
|
281
|
+
if re.search(pattern, raw, flags=flags):
|
|
282
|
+
reasons.append(why)
|
|
283
|
+
|
|
284
|
+
tokens = _tokens(raw)
|
|
285
|
+
for index, token in enumerate(tokens):
|
|
286
|
+
has_digit = any(ch.isdigit() for ch in token)
|
|
287
|
+
has_alpha = any(ch.isalpha() for ch in token)
|
|
288
|
+
if has_digit and has_alpha and not re.match(_NUMBER_TOKEN_RE, token):
|
|
289
|
+
reasons.append(
|
|
290
|
+
f"{token!r} mixes letters and digits — that is the shape of an identifier "
|
|
291
|
+
"(a record number, an accession, a passport), not of a measurement, and no "
|
|
292
|
+
"flag accepts it"
|
|
293
|
+
)
|
|
294
|
+
break
|
|
295
|
+
if has_digit or not has_alpha:
|
|
296
|
+
continue # numbers are judged below; punctuation-only tokens carry nothing
|
|
297
|
+
if index == 0:
|
|
298
|
+
continue # an ordinary sentence start
|
|
299
|
+
if token.islower() or _is_acronym(token):
|
|
300
|
+
continue
|
|
301
|
+
reasons.append(
|
|
302
|
+
f"a capitalised word ({token!r}) after the first one — a method lesson is written "
|
|
303
|
+
"in lower case, because one capital is the shape of a name and names cannot be "
|
|
304
|
+
"listed. Lower-case it (acronyms like SHBG, TSH or apoB carry two capitals and pass)"
|
|
305
|
+
)
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
spelled = re.search(_SPELLED_NUMBER_RE, raw, flags=re.IGNORECASE | re.UNICODE)
|
|
309
|
+
has_digits = re.search(_DIGIT_RE, raw) is not None or spelled is not None
|
|
310
|
+
if has_digits and not allow_numbers:
|
|
311
|
+
reasons.append(
|
|
312
|
+
"the lesson contains digits — a method lesson rarely needs them, and a number is where "
|
|
313
|
+
"readings hide. Rewrite it without the figure, or pass --allow-numbers if the number IS "
|
|
314
|
+
"the knowledge (a guideline threshold) and you take responsibility for it"
|
|
315
|
+
+ (f" (spelled out, but still a value: {spelled.group(0)!r})" if spelled else "")
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# A person beside a value is a RECORD — even under an explicit --allow-numbers.
|
|
319
|
+
# Note the widened trigger: ANY single number word counts here, not just the
|
|
320
|
+
# two-word sequences that read as a value on their own. `one marker` alone is
|
|
321
|
+
# ordinary method prose; `his tsh was twelve` is a reading about a human being,
|
|
322
|
+
# and round 3 walked it through because a lone number word was not a "number".
|
|
323
|
+
lone_number_word = re.search(rf"\b(?:{_NUMBER_WORDS})\b", raw, flags=re.IGNORECASE | re.UNICODE)
|
|
324
|
+
carries_value = has_digits or lone_number_word is not None
|
|
325
|
+
if carries_value and re.search(_PERSON_RE, raw, flags=re.IGNORECASE | re.UNICODE):
|
|
326
|
+
reasons.append(
|
|
327
|
+
"a person is referred to alongside a number — that is a record about someone, "
|
|
328
|
+
"and no flag makes it a method"
|
|
329
|
+
)
|
|
330
|
+
return LessonVerdict(not reasons, reasons)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------- dz detection
|
|
334
|
+
|
|
335
|
+
def dz_path() -> Optional[str]:
|
|
336
|
+
"""Absolute path to `dz`, or None. Detection only — never installs anything."""
|
|
337
|
+
return shutil.which("dz")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _run_dz(args: Sequence[str], timeout: int = 60) -> Tuple[int, str, str]:
|
|
341
|
+
exe = dz_path()
|
|
342
|
+
if exe is None:
|
|
343
|
+
return 127, "", DZ_MISSING_NOTE
|
|
344
|
+
try:
|
|
345
|
+
proc = subprocess.run([exe, *args], capture_output=True, text=True, timeout=timeout, check=False)
|
|
346
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
347
|
+
return 1, "", f"dz call failed: {exc}"
|
|
348
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def status() -> str:
|
|
352
|
+
exe = dz_path()
|
|
353
|
+
if exe is None:
|
|
354
|
+
return DZ_MISSING_NOTE
|
|
355
|
+
code, out, _ = _run_dz(["recall", "--all", "--stats"], timeout=90)
|
|
356
|
+
if code != 0:
|
|
357
|
+
return f"dz found at {exe}, but the learned store is not readable yet — teach the first lesson to create it."
|
|
358
|
+
total = out.splitlines()[0] if out else ""
|
|
359
|
+
return f"self-learning ON via {exe}\n {total.strip()}\n lessons from this package are tagged domain={LEARNING_DOMAIN}"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def recall(query: str, limit: int = 5) -> str:
|
|
363
|
+
"""Recall traps already caught. Absent dz is a NOTE, never a failure — a research
|
|
364
|
+
session must not depend on an optional dependency.
|
|
365
|
+
|
|
366
|
+
There is exactly ONE call, and no retry — an earlier version of this docstring
|
|
367
|
+
promised a fallback call that the code never made (round 2 caught the prose, not
|
|
368
|
+
the code, lying). No retry is needed: an older `dz` does not REJECT `--domain`, it
|
|
369
|
+
ignores the flag and exits 0 with unranked results, so the single call already
|
|
370
|
+
returns everything an unflagged one would. What the older CLI cannot do is print
|
|
371
|
+
the boost note — which is why detection reads the OUTPUT, not the exit code.
|
|
372
|
+
|
|
373
|
+
A non-zero exit is a different thing entirely and is reported as itself: recall is
|
|
374
|
+
unavailable and the session proceeds WITHOUT prior lessons. It is never reported as
|
|
375
|
+
"your CLI is old", because that would be a guess about the cause.
|
|
376
|
+
"""
|
|
377
|
+
if query.startswith("--"):
|
|
378
|
+
# ARGUMENT injection, not shell injection: the query lands in argv, and a leading
|
|
379
|
+
# `--` makes the child parser read it as an option — recall("--all") would dump
|
|
380
|
+
# the entire learned store. Shell quoting does not help; this does.
|
|
381
|
+
#
|
|
382
|
+
# Only `--` is refused, not a single dash. Round 2 measured that `dz` treats
|
|
383
|
+
# `-contrast` as an ordinary positional argument, so refusing it blocked
|
|
384
|
+
# legitimate text ("-contrast", "-negative findings") to defend against nothing.
|
|
385
|
+
# A guard that refuses safe input teaches people to work around the guard.
|
|
386
|
+
return "refusing a query that starts with '--': it would be read as an option by dz, not as text"
|
|
387
|
+
|
|
388
|
+
code, out, err = _run_dz(["recall", query, "--domain", LEARNING_DOMAIN, "--limit", str(limit)])
|
|
389
|
+
if code == 127:
|
|
390
|
+
return err
|
|
391
|
+
if code != 0:
|
|
392
|
+
return f"recall unavailable ({err.strip() or 'unknown error'}) — proceeding WITHOUT prior lessons"
|
|
393
|
+
|
|
394
|
+
# CAPABILITY, not exit code (Codex QE #3 — the sharpest finding of the round).
|
|
395
|
+
# The PREVIOUS dz did not reject `--domain`: its parser accepted any `--key value`
|
|
396
|
+
# and cmdRecall simply ignored it, exiting 0 with UNFILTERED results. So an
|
|
397
|
+
# error-code test could never fire, and my own test had FABRICATED the failure it
|
|
398
|
+
# was checking — modelling a version that never existed. The observable difference
|
|
399
|
+
# is the boost note the new CLI prints; its absence is what "too old" looks like.
|
|
400
|
+
if not _boost_note_present(out):
|
|
401
|
+
return out.rstrip() + (
|
|
402
|
+
"\n note: this dz ranked WITHOUT the domain boost (the installed CLI predates "
|
|
403
|
+
"`dz recall --domain`, which ignores the flag silently rather than failing) — results "
|
|
404
|
+
"may mix other domains. Upgrade: npm i -g @dzhechkov/harness-cli"
|
|
405
|
+
)
|
|
406
|
+
return out.rstrip() or "no prior lessons matched — this is new ground"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def teach(lesson: str, reward: float = 0.8, allow_numbers: bool = False) -> Tuple[int, str]:
|
|
410
|
+
"""Record a METHOD lesson. The privacy guard runs FIRST and can refuse."""
|
|
411
|
+
if lesson.startswith("--"):
|
|
412
|
+
return 1, "refusing a lesson that starts with '--': dz would read it as an option, not as text"
|
|
413
|
+
verdict = check_lesson(lesson, allow_numbers=allow_numbers)
|
|
414
|
+
if not verdict.ok:
|
|
415
|
+
return 1, verdict.note + (
|
|
416
|
+
"\n\nRewrite it as the general rule it taught. The method form is also the more useful "
|
|
417
|
+
"lesson: a reading helps once, a rule helps every time."
|
|
418
|
+
)
|
|
419
|
+
code, out, err = _run_dz([
|
|
420
|
+
"teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN, "--type", "lesson-learned",
|
|
421
|
+
])
|
|
422
|
+
if code == 127:
|
|
423
|
+
return 0, err # not an error: the package works without dz
|
|
424
|
+
if code != 0:
|
|
425
|
+
return 1, f"teach failed: {err.strip() or out.strip()}"
|
|
426
|
+
return 0, f"recorded (domain={LEARNING_DOMAIN})"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
430
|
+
parser = argparse.ArgumentParser(description="health-advisor ⇄ dz self-learning bridge")
|
|
431
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
432
|
+
sub.add_parser("status")
|
|
433
|
+
p_recall = sub.add_parser("recall")
|
|
434
|
+
p_recall.add_argument("query")
|
|
435
|
+
p_recall.add_argument("--limit", type=int, default=5)
|
|
436
|
+
p_teach = sub.add_parser("teach")
|
|
437
|
+
p_teach.add_argument("lesson")
|
|
438
|
+
p_teach.add_argument("--reward", type=float, default=0.8)
|
|
439
|
+
p_teach.add_argument("--allow-numbers", action="store_true",
|
|
440
|
+
help="the number IS the knowledge (a guideline threshold) — an explicit human decision")
|
|
441
|
+
p_check = sub.add_parser("check")
|
|
442
|
+
p_check.add_argument("lesson")
|
|
443
|
+
p_check.add_argument("--allow-numbers", action="store_true")
|
|
444
|
+
args = parser.parse_args(argv)
|
|
445
|
+
|
|
446
|
+
if args.cmd == "status":
|
|
447
|
+
print(status())
|
|
448
|
+
return 0
|
|
449
|
+
if args.cmd == "recall":
|
|
450
|
+
print(recall(args.query, args.limit))
|
|
451
|
+
return 0
|
|
452
|
+
if args.cmd == "check":
|
|
453
|
+
verdict = check_lesson(args.lesson, allow_numbers=args.allow_numbers)
|
|
454
|
+
print(verdict.note)
|
|
455
|
+
return 0 if verdict.ok else 1
|
|
456
|
+
code, message = teach(args.lesson, args.reward, allow_numbers=args.allow_numbers)
|
|
457
|
+
print(message)
|
|
458
|
+
return code
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Source-class tiers and staleness — the THIRD ceiling (FR-5, FR-6).
|
|
4
|
+
|
|
5
|
+
Before this, every fact carried trust_class=SELF_ATTESTED with a flat 0.60 cap,
|
|
6
|
+
so a Cochrane meta-analysis and a forum post were indistinguishable by that field.
|
|
7
|
+
Tiering by source CLASS is imperfect but incomparably more useful than flat.
|
|
8
|
+
|
|
9
|
+
What a tier is NOT: it is not a cryptographic statement about the issuer, and it
|
|
10
|
+
must never be confused with issuer key pinning (the `whitelist_available` defect
|
|
11
|
+
fused exactly these two ideas and made high-stakes modes unreachable). A tier is
|
|
12
|
+
a claim about the class a domain belongs to. Nothing more.
|
|
13
|
+
|
|
14
|
+
Ceilings compose as a MINIMUM with the other two axes — the weakest link decides:
|
|
15
|
+
confidence = min(trust_ceiling, evidence_ceiling, tier_ceiling)
|
|
16
|
+
|
|
17
|
+
Data lives in TIER_DOMAINS below rather than in a config file on purpose: this is
|
|
18
|
+
a small, reviewable, version-controlled list, and a medical package should not
|
|
19
|
+
silently pick up trust rules from an unversioned file on disk.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Dict, Optional, Tuple
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
TIER_A = "A" # guideline bodies, national registries, systematic-review orgs
|
|
30
|
+
TIER_B = "B" # peer-reviewed literature
|
|
31
|
+
TIER_C = "C" # preprints, trial registries
|
|
32
|
+
TIER_D = "D" # secondary reviews, media, forums, unknown
|
|
33
|
+
|
|
34
|
+
TIER_CEILINGS: Dict[str, float] = {TIER_A: 0.90, TIER_B: 0.80, TIER_C: 0.60, TIER_D: 0.40}
|
|
35
|
+
|
|
36
|
+
# Suffix match on the registrable host: "www.cochrane.org" and "x.cochrane.org"
|
|
37
|
+
# both match "cochrane.org", while "cochrane.org.evil.com" does NOT (see _host_matches).
|
|
38
|
+
TIER_DOMAINS: Dict[str, str] = {
|
|
39
|
+
# A — guidelines, registries, HTA/【systematic review】 bodies
|
|
40
|
+
"cochrane.org": TIER_A,
|
|
41
|
+
"cochranelibrary.com": TIER_A,
|
|
42
|
+
"who.int": TIER_A,
|
|
43
|
+
"nice.org.uk": TIER_A,
|
|
44
|
+
"uspreventiveservicestaskforce.org": TIER_A,
|
|
45
|
+
"escardio.org": TIER_A,
|
|
46
|
+
"diabetes.org": TIER_A,
|
|
47
|
+
"acc.org": TIER_A,
|
|
48
|
+
"ahajournals.org": TIER_A,
|
|
49
|
+
"cdc.gov": TIER_A,
|
|
50
|
+
"nih.gov": TIER_A,
|
|
51
|
+
"fda.gov": TIER_A,
|
|
52
|
+
"ema.europa.eu": TIER_A,
|
|
53
|
+
# B — peer-reviewed literature
|
|
54
|
+
"pubmed.ncbi.nlm.nih.gov": TIER_B,
|
|
55
|
+
"ncbi.nlm.nih.gov": TIER_B,
|
|
56
|
+
"doi.org": TIER_B,
|
|
57
|
+
"nejm.org": TIER_B,
|
|
58
|
+
"thelancet.com": TIER_B,
|
|
59
|
+
"bmj.com": TIER_B,
|
|
60
|
+
"jamanetwork.com": TIER_B,
|
|
61
|
+
"sciencedirect.com": TIER_B,
|
|
62
|
+
"springer.com": TIER_B,
|
|
63
|
+
"wiley.com": TIER_B,
|
|
64
|
+
"nature.com": TIER_B,
|
|
65
|
+
# C — preprints and registries (registered ≠ peer-reviewed ≠ completed)
|
|
66
|
+
"medrxiv.org": TIER_C,
|
|
67
|
+
"biorxiv.org": TIER_C,
|
|
68
|
+
"clinicaltrials.gov": TIER_C,
|
|
69
|
+
"osf.io": TIER_C,
|
|
70
|
+
}
|
|
71
|
+
# NOTE: keys are HOSTS only. A path-scoped rule ("who.int/trialsearch": C) is DEAD
|
|
72
|
+
# here — classify_source matches on hostname, so such a key can never fire and the
|
|
73
|
+
# URL silently inherits its host's tier (Codex QE #13). Path-scoped tiering needs a
|
|
74
|
+
# different matcher; until it exists, we do not pretend to have it.
|
|
75
|
+
|
|
76
|
+
# Freshness TTLs in days, by topic kind. A source older than its TTL is flagged
|
|
77
|
+
# `may_be_stale` — a flag, never a silent drop: an old guideline is still the
|
|
78
|
+
# guideline until a newer one is found.
|
|
79
|
+
TTL_DAYS: Dict[str, int] = {
|
|
80
|
+
"guideline": 365 * 2,
|
|
81
|
+
"registry": 180,
|
|
82
|
+
"meta_analysis": 365 * 2,
|
|
83
|
+
"trial": 365 * 3,
|
|
84
|
+
"price": 7,
|
|
85
|
+
"news": 30,
|
|
86
|
+
"default": 365,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class TierVerdict:
|
|
92
|
+
tier: str
|
|
93
|
+
ceiling: float
|
|
94
|
+
matched_domain: Optional[str]
|
|
95
|
+
known: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _registrable_candidates(host: str) -> Tuple[str, ...]:
|
|
99
|
+
"""Progressive suffixes of the host, longest first: a.b.c → (a.b.c, b.c, c).
|
|
100
|
+
|
|
101
|
+
Matching on suffix LABELS (not raw string endswith) is what stops
|
|
102
|
+
`cochrane.org.evil.com` from inheriting tier A.
|
|
103
|
+
"""
|
|
104
|
+
labels = [label for label in host.split(".") if label]
|
|
105
|
+
return tuple(".".join(labels[i:]) for i in range(len(labels)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _host_matches(host: str) -> Optional[str]:
|
|
109
|
+
for candidate in _registrable_candidates(host):
|
|
110
|
+
if candidate in TIER_DOMAINS:
|
|
111
|
+
return candidate
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def classify_source(url: str) -> TierVerdict:
|
|
116
|
+
"""Tier for a URL. An UNKNOWN domain gets tier D — the most cautious class,
|
|
117
|
+
not an exception and not a free pass. Unknown is a state, not an error."""
|
|
118
|
+
try:
|
|
119
|
+
parsed = urlparse(url)
|
|
120
|
+
host = (parsed.hostname or "").lower()
|
|
121
|
+
scheme = (parsed.scheme or "").lower()
|
|
122
|
+
except Exception:
|
|
123
|
+
host, scheme = "", ""
|
|
124
|
+
# file://who.int/... must not read as tier A: a tier is a claim about a WEB
|
|
125
|
+
# source class, and a local path is not one (Codex QE #13).
|
|
126
|
+
if scheme not in ("http", "https"):
|
|
127
|
+
return TierVerdict(tier=TIER_D, ceiling=TIER_CEILINGS[TIER_D], matched_domain=None, known=False)
|
|
128
|
+
if not host:
|
|
129
|
+
return TierVerdict(tier=TIER_D, ceiling=TIER_CEILINGS[TIER_D], matched_domain=None, known=False)
|
|
130
|
+
matched = _host_matches(host)
|
|
131
|
+
if matched is None:
|
|
132
|
+
return TierVerdict(tier=TIER_D, ceiling=TIER_CEILINGS[TIER_D], matched_domain=None, known=False)
|
|
133
|
+
tier = TIER_DOMAINS[matched]
|
|
134
|
+
return TierVerdict(tier=tier, ceiling=TIER_CEILINGS[tier], matched_domain=matched, known=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_date(value: str) -> Optional[datetime]:
|
|
138
|
+
raw = value.strip().replace("Z", "+00:00")
|
|
139
|
+
for parse in (
|
|
140
|
+
lambda v: datetime.fromisoformat(v),
|
|
141
|
+
lambda v: datetime.strptime(v, "%Y-%m-%d"),
|
|
142
|
+
lambda v: datetime.strptime(v, "%Y"),
|
|
143
|
+
):
|
|
144
|
+
try:
|
|
145
|
+
parsed = parse(raw)
|
|
146
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
147
|
+
except (ValueError, TypeError):
|
|
148
|
+
continue
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_stale(source_date: Optional[str], kind: str = "default", now: Optional[datetime] = None) -> Tuple[bool, str]:
|
|
153
|
+
"""(stale?, reason). An ABSENT date is reported as unknown-and-therefore-flagged:
|
|
154
|
+
the report author must not be able to dodge the freshness question by omitting
|
|
155
|
+
the field — that is how "is the Turkish ban still in force?" went wrong."""
|
|
156
|
+
ttl = TTL_DAYS.get(kind, TTL_DAYS["default"])
|
|
157
|
+
if not source_date:
|
|
158
|
+
return True, "source_date missing — freshness cannot be established"
|
|
159
|
+
parsed = _parse_date(source_date)
|
|
160
|
+
if parsed is None:
|
|
161
|
+
return True, f"source_date {source_date!r} is unparseable — freshness cannot be established"
|
|
162
|
+
reference = now or datetime.now(timezone.utc)
|
|
163
|
+
age_days = (reference - parsed).days
|
|
164
|
+
if age_days < 0:
|
|
165
|
+
# A source dated in the future is not "fresh forever" — it is a data error,
|
|
166
|
+
# and treating it as fresh made 2099 an eternal pass (Codex QE #14).
|
|
167
|
+
return True, f"source_date {source_date!r} is in the future ({-age_days}d ahead) — implausible"
|
|
168
|
+
if age_days > ttl:
|
|
169
|
+
return True, f"source is {age_days}d old, past the {ttl}d TTL for kind={kind}"
|
|
170
|
+
return False, f"source is {age_days}d old, within the {ttl}d TTL for kind={kind}"
|