@dzhechkov/p-replicator 1.5.14 → 1.5.16

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.
@@ -0,0 +1,502 @@
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
+ # split("\n"), NOT splitlines(). Python's splitlines() also breaks on U+2028,
80
+ # U+0085 and friends, which the CLI does not treat as line breaks — so a lesson
81
+ # containing one produced a "line" the renderer never emitted, and that forged line
82
+ # could satisfy this probe. Splitting exactly the way the producer joins keeps a
83
+ # forged tail stuck on a line that starts with the hit prefix, where the anchor
84
+ # below rejects it.
85
+ for line in out.split("\n"):
86
+ stripped = line.strip()
87
+ if stripped.startswith(head) and any(tail in stripped for tail in BOOST_NOTE_TAILS):
88
+ return True
89
+ return False
90
+
91
+ DZ_MISSING_NOTE = (
92
+ "self-learning is OFF: `dz` (@dzhechkov/harness-cli) is not installed. "
93
+ "The package works exactly as before; install it to let research sessions recall "
94
+ "the traps already caught and record new ones: npm i -g @dzhechkov/harness-cli"
95
+ )
96
+
97
+ # ---------------------------------------------------------------- privacy guard
98
+
99
+ # =============================================================================
100
+ # THE GUARD IS AN ALLOWLIST, NOT A BLOCKLIST — and that is a deliberate rewrite.
101
+ #
102
+ # The first design listed personal-data SHAPES and refused those. A cross-model
103
+ # review broke it in minutes, and the breakage was not a missing pattern but the
104
+ # wrong SHAPE OF DESIGN:
105
+ # * one stray word laundered anything — "TSH was 8.04 mIU/L WHEN fasting" passed,
106
+ # because a threshold-marker exemption trusted the presence of a word to prove
107
+ # the number was a guideline rule;
108
+ # * every unit, date format, script and identifier outside the list walked
109
+ # through: `MRN 84729163`, `Patient John Smith has HIV`, `130 µmol/L`,
110
+ # `8 мая 2026`, an email, a phone number.
111
+ # Enumerating what personal data LOOKS LIKE is an arms race whose every gap is a
112
+ # leaked medical fact. So the rule is inverted:
113
+ #
114
+ # A METHOD LESSON CARRIES NO DIGITS, NO IDENTIFIERS,
115
+ # AND IS WRITTEN IN LOWER CASE (acronyms excepted).
116
+ #
117
+ # All three clauses are properties of the ACCEPTED form. That matters: "no names" was
118
+ # still a blocklist in disguise, because a name is any word and no list of them exists
119
+ # — which is precisely how `John Smith has HIV` walked through round 2. "Lower case"
120
+ # is checkable in one line and has a one-keystroke fix.
121
+ #
122
+ # It is almost free: "a fast lowers total testosterone by roughly a third" is a fine
123
+ # lesson without the figure. When a number genuinely IS the knowledge (a guideline
124
+ # threshold), the author passes --allow-numbers and takes responsibility explicitly —
125
+ # a decision by a human, not an inference from a word.
126
+ # =============================================================================
127
+
128
+ # Any digit. Yes, any: a threshold, a dose, a year and a lab value are the same
129
+ # character class, and no amount of context-sniffing reliably tells them apart.
130
+ _DIGIT_RE = r"\d"
131
+
132
+ # A value SPELLED OUT is still a value. Round 2 walked `John's TSH was eight point
133
+ # zero four` through the digit rule untouched. Requiring two number-words in a row
134
+ # (or a number-word beside "point") keeps the false-positive cost near zero: method
135
+ # prose says "a third", "one marker", never "eight point zero four".
136
+ _NUMBER_WORDS = (
137
+ "zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
138
+ "fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
139
+ "sixty|seventy|eighty|ninety|hundred|thousand|"
140
+ "ноль|один|одна|два|две|три|четыре|пять|шесть|семь|восемь|девять|десять|"
141
+ "двадцать|тридцать|сорок|пятьдесят|шестьдесят|семьдесят|восемьдесят|девяносто|сто|тысяч\\w*"
142
+ )
143
+ _SPELLED_NUMBER_RE = (
144
+ rf"\b(?:{_NUMBER_WORDS})\b[\s,-]+\b(?:point|целых|запятая|{_NUMBER_WORDS})\b"
145
+ rf"|\bpoint\b[\s,-]+\b(?:{_NUMBER_WORDS})\b"
146
+ )
147
+
148
+ # Direct identifiers — refused regardless of --allow-numbers, because no method
149
+ # lesson needs them and every one of them is a person.
150
+ # NOTE the per-pattern case flag. The identifier scan originally ran WITHOUT
151
+ # IGNORECASE, so `MRN 84729163` walked straight through while the lowercase spelling
152
+ # was caught — but the NAME pattern must stay case-SENSITIVE, since capitalisation is
153
+ # the whole signal. One shared flag could not serve both; a test caught the gap.
154
+ _IDENTIFIER_PATTERNS: Sequence[Tuple[str, str, bool]] = (
155
+ # (pattern, why, ignore_case)
156
+ (r"[\w.+-]+@[\w-]+\.[\w.]+", "an email address", True),
157
+ (r"\+?\d[\d\s().-]{7,}\d", "something shaped like a phone number", True),
158
+ (r"\b(mrn|снилс|полис|истори\w* болезни|medical record (no|number)|record no)\b[:\s#]*\w*",
159
+ "a medical-record identifier", True),
160
+ # Round 4: the TOKEN check refused `ab1234567`, so the same identifier written
161
+ # `ab—1234567` or `ab/1234567` was split by the tokenizer into a lower-case word
162
+ # and a permitted number — laundered by punctuation. This reads the RAW text, where
163
+ # the joiner is still visible. A SPACE is deliberately not a joiner here, or every
164
+ # "the 2019 guideline" would be refused.
165
+ (r"\b[A-Za-zА-Яа-я]{2,}[—–/._-]?\d{4,}\b",
166
+ "letters sitting against a long run of digits — that is an identifier, however it is punctuated", False),
167
+ )
168
+
169
+ # THE CAPITALISATION RULE — round 2 forced this rewrite.
170
+ #
171
+ # The previous pattern hunted a NAME: two capitalised words in a row, exempting the
172
+ # start of a sentence. Both halves were wrong, and both were measured:
173
+ # * `John Smith has HIV` passed — the exemption fired on the sentence start;
174
+ # * `Patient John has HIV` passed — only one capitalised word followed "Patient".
175
+ # Hunting names is a blocklist wearing an allowlist's clothes: a name is any word,
176
+ # and there is no list of them. So stop hunting names and state a property of the
177
+ # ACCEPTED form instead:
178
+ #
179
+ # A METHOD LESSON IS WRITTEN IN LOWER CASE (acronyms excepted).
180
+ #
181
+ # Any capitalised word after the first token is refused, whatever it is. This does
182
+ # refuse `compare Testosterone Replacement Therapy against placebo` — deliberately.
183
+ # The fix is one keystroke ("lower-case it"), the rule is one sentence, and the
184
+ # false-negative it closes is a named person beside a diagnosis.
185
+ #
186
+ # ROUND 3 forced this one step further, and the finding was the DESIGN again, not the
187
+ # pattern. `\b[A-ZА-Я][a-zа-я]{2,}\b` is still a blocklist: it enumerates which
188
+ # capitalised words look like names, so `Li` (too short), `Élodie` (Latin-1 letter)
189
+ # and `method—John` (em dash, no \s before it) all walked through. Every round of
190
+ # widening the pattern buys one more round.
191
+ #
192
+ # So the check moves to the TOKEN and states what is ACCEPTED, in a form with no
193
+ # residue: split on whitespace, strip surrounding punctuation, and accept a token only
194
+ # if it is one of
195
+ # * all lower case (letters, any script — `.islower()` answers for every alphabet);
196
+ # * an ACRONYM — two or more upper-case letters in it (SHBG, HIV, apoB, HbA1c);
197
+ # * pure punctuation, or the FIRST token of the lesson (an ordinary sentence start);
198
+ # * a NUMBER, judged by the number rules below.
199
+ # `John`, `Li`, `Élodie`, `Testosterone` each carry exactly one capital and are refused
200
+ # without the guard ever asking what they mean — which is the point, since names cannot
201
+ # be enumerated and lower-casing costs one keystroke.
202
+ _TOKEN_EDGE_PUNCT = " \t\n\r.,;:!?()[]{}\"'«»„“”‘’—–-/\\|*_`"
203
+
204
+
205
+ def _is_acronym(token: str) -> bool:
206
+ """An acronym, as opposed to a name.
207
+
208
+ Round 4 killed the previous rule ("two or more capitals"), which was wrong in BOTH
209
+ directions at once: `Anne-Marie` and `McDonald` carry two capitals and sailed
210
+ through as acronyms, while `apoB` carries one and was refused — contradicting the
211
+ documentation in the same repository that offered it as the example.
212
+
213
+ What actually separates the two is WHERE the first capital sits. A name is Title
214
+ Case: capital first, lower case after. An acronym is either fully upper (SHBG, HIV,
215
+ TSH) or starts lower and capitalises later (apoB, mmHg). So: accept a token whose
216
+ first letter is lower case, or one whose letters are ALL upper case. Refuse
217
+ anything that opens with a capital and then drops to lower case, whatever it is.
218
+ """
219
+ letters = [ch for ch in token if ch.isalpha()]
220
+ if not letters:
221
+ return False
222
+ if not letters[0].isupper():
223
+ return True # apoB, mmHg — lower-case opening is never a name
224
+ return all(ch.isupper() for ch in letters) # SHBG, HIV, D
225
+
226
+
227
+ # Splitting on whitespace ALONE was the last hole round 3 found: `method—John has HIV`
228
+ # passed because the em dash kept `method—John` as ONE token, and that token was first,
229
+ # so it took the sentence-start exemption. Any punctuation that can JOIN two words is a
230
+ # token boundary. A period is a boundary only between non-digits, so `8.04` survives.
231
+ _TOKEN_SPLIT_RE = r"(?:[\s—–/\\|()\[\]{}<>\"«»„“”‘’\',;:!?*_`]|(?<!\d)[.](?!\d))+"
232
+
233
+
234
+ def _tokens(raw: str) -> List[str]:
235
+ return [t.strip(_TOKEN_EDGE_PUNCT) for t in re.split(_TOKEN_SPLIT_RE, raw) if t.strip(_TOKEN_EDGE_PUNCT)]
236
+
237
+
238
+ # A token that mixes letters and digits is an IDENTIFIER SHAPE — `ab1234567`,
239
+ # `NCT04368728`, an accession, a record number. It is refused even under
240
+ # --allow-numbers, because that flag exists for a THRESHOLD (`8.04`, `56h`), and a
241
+ # threshold is a number with at most a short unit after it. Letters BEFORE digits is
242
+ # not a measurement in any unit system; it is a label for one thing.
243
+ _NUMBER_TOKEN_RE = r"^[<>≥≤~±]?\d+([.,]\d+)?[a-zа-яё%°]{0,4}$"
244
+
245
+ # …but a few real lab acronyms carry a digit (`HbA1c`, `CYP2D6`). What separates them
246
+ # from an accession is SIZE: an identifier has to be long enough to be unique. So a
247
+ # mixed token is an identifier when it is long or digit-heavy, and a lab acronym
248
+ # otherwise. The bound is a judgement call and is stated rather than hidden.
249
+ _IDENTIFIER_MIN_LEN = 7
250
+ _IDENTIFIER_MIN_DIGITS = 4
251
+
252
+ # A reference to ONE person. Alone it is fine — a method sentence may discuss patients
253
+ # in the abstract. Beside a value it is load-bearing: a person plus a reading is a
254
+ # record, and no --allow-numbers waves that through.
255
+ #
256
+ # SINGULAR ONLY, and that is the whole design. Round 3 was right that `the woman has
257
+ # tsh 8.04` must be refused, but the first fix simply added `woman|man|adult|child` and
258
+ # immediately refused `transferrin saturation above 45% warrants attention in men` — a
259
+ # textbook population lesson. Plural person nouns ARE the vocabulary of method lessons
260
+ # about populations; singular ones are the vocabulary of case notes. Number, not word
261
+ # list, is what separates a cohort from a patient, and grammar is checkable.
262
+ #
263
+ # Honest scope: `the woman` is caught, `a 54 year old` is not, and this clause only ever
264
+ # runs when --allow-numbers was passed (digits are refused outright otherwise). It is
265
+ # the narrowest of the three clauses and the only one that is still an enumeration.
266
+ _PERSON_RE = (
267
+ r"\b(patient|subject|participant|client|donor|volunteer|"
268
+ r"woman|man|individual|child|infant|adult|"
269
+ r"he|she|him|his|her|hers|"
270
+ r"пациент(?!ы|ов|ам|ами|ах)\w*|больн(ой|ого|ому|ым|ом)|испытуем(ый|ого|ому|ым|ом)|"
271
+ r"мужчина|мужчины\b|женщина|женщины\b|ребёнок|ребенок|доброволец|"
272
+ r"у него|у неё)\b"
273
+ )
274
+
275
+ @dataclass(frozen=True)
276
+ class LessonVerdict:
277
+ ok: bool
278
+ reasons: List[str]
279
+
280
+ @property
281
+ def note(self) -> str:
282
+ if self.ok:
283
+ # NOT "safe": a shape detector cannot certify safety, and saying so would
284
+ # transfer responsibility it does not have (Codex QE #2). It reports the
285
+ # absence of a detected shape — the author still owns the content.
286
+ return (
287
+ "no personal-data SHAPE detected (no digits, no identifiers, lower case). This is "
288
+ "not a safety certificate: the guard checks shapes, not meaning — a lower-case "
289
+ "sentence with no figures can still describe one person, and you remain "
290
+ "responsible for what it says."
291
+ )
292
+ return "REFUSED — this reads like a record about a person, not a method:\n - " + "\n - ".join(self.reasons)
293
+
294
+
295
+ def check_lesson(text: str, allow_numbers: bool = False) -> LessonVerdict:
296
+ """Refuse anything not shaped like a METHOD lesson. Fails toward refusal.
297
+
298
+ `allow_numbers` is an EXPLICIT human decision for the case where a number IS the
299
+ knowledge (a guideline threshold). It is a flag a person passes, never something
300
+ inferred from the presence of a word — inferring it is exactly how the first
301
+ design was laundered ("TSH was 8.04 mIU/L WHEN fasting" passed because "when"
302
+ was read as a threshold marker).
303
+
304
+ The right response to a refusal is to rewrite the lesson as the rule it taught,
305
+ which is also the more useful form: a reading helps once, a rule helps every time.
306
+ """
307
+ raw = text or ""
308
+ if not raw.strip():
309
+ return LessonVerdict(False, ["the lesson is empty"])
310
+ reasons: List[str] = []
311
+
312
+ # Identifiers with a fixed shape (email, phone, medical-record vocabulary) are
313
+ # refused ALWAYS — --allow-numbers must not reach them. This list is genuinely a
314
+ # blocklist and is NOT the guard's backbone: the token allowlist below is what
315
+ # catches identifier shapes the list never heard of.
316
+ for pattern, why, ignore_case in _IDENTIFIER_PATTERNS:
317
+ flags = re.UNICODE | (re.IGNORECASE if ignore_case else 0)
318
+ if re.search(pattern, raw, flags=flags):
319
+ reasons.append(why)
320
+
321
+ tokens = _tokens(raw)
322
+ for index, token in enumerate(tokens):
323
+ has_digit = any(ch.isdigit() for ch in token)
324
+ has_alpha = any(ch.isalpha() for ch in token)
325
+ digit_count = sum(1 for ch in token if ch.isdigit())
326
+ looks_like_id = len(token) >= _IDENTIFIER_MIN_LEN or digit_count >= _IDENTIFIER_MIN_DIGITS
327
+ if has_digit and has_alpha and looks_like_id and not re.match(_NUMBER_TOKEN_RE, token):
328
+ reasons.append(
329
+ f"{token!r} mixes letters and digits — that is the shape of an identifier "
330
+ "(a record number, an accession, a passport), not of a measurement, and no "
331
+ "flag accepts it"
332
+ )
333
+ break
334
+ if has_digit or not has_alpha:
335
+ continue # numbers are judged below; punctuation-only tokens carry nothing
336
+ if index == 0:
337
+ continue # an ordinary sentence start
338
+ if token.islower() or _is_acronym(token):
339
+ continue
340
+ reasons.append(
341
+ f"a capitalised word ({token!r}) after the first one — a method lesson is written "
342
+ "in lower case, because one capital is the shape of a name and names cannot be "
343
+ "listed. Lower-case it — an acronym passes either fully upper (SHBG, TSH) or opening "
344
+ "lower (apoB); only Title Case is refused"
345
+ )
346
+ break
347
+
348
+ spelled = re.search(_SPELLED_NUMBER_RE, raw, flags=re.IGNORECASE | re.UNICODE)
349
+ has_digits = re.search(_DIGIT_RE, raw) is not None or spelled is not None
350
+ if has_digits and not allow_numbers:
351
+ reasons.append(
352
+ "the lesson contains digits — a method lesson rarely needs them, and a number is where "
353
+ "readings hide. Rewrite it without the figure, or pass --allow-numbers if the number IS "
354
+ "the knowledge (a guideline threshold) and you take responsibility for it"
355
+ + (f" (spelled out, but still a value: {spelled.group(0)!r})" if spelled else "")
356
+ )
357
+
358
+ # A person beside a value is a RECORD — even under an explicit --allow-numbers.
359
+ # Note the widened trigger: ANY single number word counts here, not just the
360
+ # two-word sequences that read as a value on their own. `one marker` alone is
361
+ # ordinary method prose; `his tsh was twelve` is a reading about a human being,
362
+ # and round 3 walked it through because a lone number word was not a "number".
363
+ lone_number_word = re.search(rf"\b(?:{_NUMBER_WORDS})\b", raw, flags=re.IGNORECASE | re.UNICODE)
364
+ carries_value = has_digits or lone_number_word is not None
365
+ if carries_value and re.search(_PERSON_RE, raw, flags=re.IGNORECASE | re.UNICODE):
366
+ reasons.append(
367
+ "a person is referred to alongside a number — that is a record about someone, "
368
+ "and no flag makes it a method"
369
+ )
370
+ return LessonVerdict(not reasons, reasons)
371
+
372
+
373
+ # ---------------------------------------------------------------- dz detection
374
+
375
+ def dz_path() -> Optional[str]:
376
+ """Absolute path to `dz`, or None. Detection only — never installs anything."""
377
+ return shutil.which("dz")
378
+
379
+
380
+ def _run_dz(args: Sequence[str], timeout: int = 60) -> Tuple[int, str, str]:
381
+ exe = dz_path()
382
+ if exe is None:
383
+ return 127, "", DZ_MISSING_NOTE
384
+ try:
385
+ proc = subprocess.run([exe, *args], capture_output=True, text=True, timeout=timeout, check=False)
386
+ except (OSError, subprocess.SubprocessError) as exc:
387
+ return 1, "", f"dz call failed: {exc}"
388
+ return proc.returncode, proc.stdout, proc.stderr
389
+
390
+
391
+ def status() -> str:
392
+ exe = dz_path()
393
+ if exe is None:
394
+ return DZ_MISSING_NOTE
395
+ code, out, _ = _run_dz(["recall", "--all", "--stats"], timeout=90)
396
+ if code != 0:
397
+ return f"dz found at {exe}, but the learned store is not readable yet — teach the first lesson to create it."
398
+ total = out.splitlines()[0] if out else ""
399
+ return f"self-learning ON via {exe}\n {total.strip()}\n lessons from this package are tagged domain={LEARNING_DOMAIN}"
400
+
401
+
402
+ def recall(query: str, limit: int = 5) -> str:
403
+ """Recall traps already caught. Absent dz is a NOTE, never a failure — a research
404
+ session must not depend on an optional dependency.
405
+
406
+ There is exactly ONE call, and no retry — an earlier version of this docstring
407
+ promised a fallback call that the code never made (round 2 caught the prose, not
408
+ the code, lying). No retry is needed: an older `dz` does not REJECT `--domain`, it
409
+ ignores the flag and exits 0 with unranked results, so the single call already
410
+ returns everything an unflagged one would. What the older CLI cannot do is print
411
+ the boost note — which is why detection reads the OUTPUT, not the exit code.
412
+
413
+ A non-zero exit is a different thing entirely and is reported as itself: recall is
414
+ unavailable and the session proceeds WITHOUT prior lessons. It is never reported as
415
+ "your CLI is old", because that would be a guess about the cause.
416
+ """
417
+ if query.startswith("--"):
418
+ # ARGUMENT injection, not shell injection: the query lands in argv, and a leading
419
+ # `--` makes the child parser read it as an option — recall("--all") would dump
420
+ # the entire learned store. Shell quoting does not help; this does.
421
+ #
422
+ # Only `--` is refused, not a single dash. Round 2 measured that `dz` treats
423
+ # `-contrast` as an ordinary positional argument, so refusing it blocked
424
+ # legitimate text ("-contrast", "-negative findings") to defend against nothing.
425
+ # A guard that refuses safe input teaches people to work around the guard.
426
+ return "refusing a query that starts with '--': it would be read as an option by dz, not as text"
427
+
428
+ code, out, err = _run_dz(["recall", query, "--domain", LEARNING_DOMAIN, "--limit", str(limit)])
429
+ if code == 127:
430
+ return err
431
+ if code != 0:
432
+ return f"recall unavailable ({err.strip() or 'unknown error'}) — proceeding WITHOUT prior lessons"
433
+
434
+ # CAPABILITY, not exit code (Codex QE #3 — the sharpest finding of the round).
435
+ # The PREVIOUS dz did not reject `--domain`: its parser accepted any `--key value`
436
+ # and cmdRecall simply ignored it, exiting 0 with UNFILTERED results. So an
437
+ # error-code test could never fire, and my own test had FABRICATED the failure it
438
+ # was checking — modelling a version that never existed. The observable difference
439
+ # is the boost note the new CLI prints; its absence is what "too old" looks like.
440
+ if not _boost_note_present(out):
441
+ return out.rstrip() + (
442
+ "\n note: this dz ranked WITHOUT the domain boost (the installed CLI predates "
443
+ "`dz recall --domain`, which ignores the flag silently rather than failing) — results "
444
+ "may mix other domains. Upgrade: npm i -g @dzhechkov/harness-cli"
445
+ )
446
+ return out.rstrip() or "no prior lessons matched — this is new ground"
447
+
448
+
449
+ def teach(lesson: str, reward: float = 0.8, allow_numbers: bool = False) -> Tuple[int, str]:
450
+ """Record a METHOD lesson. The privacy guard runs FIRST and can refuse."""
451
+ if lesson.startswith("--"):
452
+ return 1, "refusing a lesson that starts with '--': dz would read it as an option, not as text"
453
+ verdict = check_lesson(lesson, allow_numbers=allow_numbers)
454
+ if not verdict.ok:
455
+ return 1, verdict.note + (
456
+ "\n\nRewrite it as the general rule it taught. The method form is also the more useful "
457
+ "lesson: a reading helps once, a rule helps every time."
458
+ )
459
+ code, out, err = _run_dz([
460
+ "teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN, "--type", "lesson-learned",
461
+ ])
462
+ if code == 127:
463
+ return 0, err # not an error: the package works without dz
464
+ if code != 0:
465
+ return 1, f"teach failed: {err.strip() or out.strip()}"
466
+ return 0, f"recorded (domain={LEARNING_DOMAIN})"
467
+
468
+
469
+ def main(argv: Optional[Sequence[str]] = None) -> int:
470
+ parser = argparse.ArgumentParser(description="health-advisor ⇄ dz self-learning bridge")
471
+ sub = parser.add_subparsers(dest="cmd", required=True)
472
+ sub.add_parser("status")
473
+ p_recall = sub.add_parser("recall")
474
+ p_recall.add_argument("query")
475
+ p_recall.add_argument("--limit", type=int, default=5)
476
+ p_teach = sub.add_parser("teach")
477
+ p_teach.add_argument("lesson")
478
+ p_teach.add_argument("--reward", type=float, default=0.8)
479
+ p_teach.add_argument("--allow-numbers", action="store_true",
480
+ help="the number IS the knowledge (a guideline threshold) — an explicit human decision")
481
+ p_check = sub.add_parser("check")
482
+ p_check.add_argument("lesson")
483
+ p_check.add_argument("--allow-numbers", action="store_true")
484
+ args = parser.parse_args(argv)
485
+
486
+ if args.cmd == "status":
487
+ print(status())
488
+ return 0
489
+ if args.cmd == "recall":
490
+ print(recall(args.query, args.limit))
491
+ return 0
492
+ if args.cmd == "check":
493
+ verdict = check_lesson(args.lesson, allow_numbers=args.allow_numbers)
494
+ print(verdict.note)
495
+ return 0 if verdict.ok else 1
496
+ code, message = teach(args.lesson, args.reward, allow_numbers=args.allow_numbers)
497
+ print(message)
498
+ return code
499
+
500
+
501
+ if __name__ == "__main__":
502
+ 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}"