@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.
@@ -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}"