trooth 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (27) hide show
  1. {trooth-0.1.1 → trooth-0.2.0}/PKG-INFO +13 -2
  2. {trooth-0.1.1 → trooth-0.2.0}/README.md +12 -1
  3. {trooth-0.1.1 → trooth-0.2.0}/pyproject.toml +1 -1
  4. {trooth-0.1.1 → trooth-0.2.0}/trooth/__init__.py +6 -2
  5. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/PKG-INFO +13 -2
  6. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/SOURCES.txt +2 -0
  7. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/__init__.py +1 -1
  8. trooth-0.2.0/trooth_semanto_verify/answer.py +172 -0
  9. trooth-0.2.0/trooth_semanto_verify/source.py +154 -0
  10. {trooth-0.1.1 → trooth-0.2.0}/LICENSE +0 -0
  11. {trooth-0.1.1 → trooth-0.2.0}/setup.cfg +0 -0
  12. {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/__init__.py +0 -0
  13. {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/langchain.py +0 -0
  14. {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/llama_index.py +0 -0
  15. {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/openai_agents.py +0 -0
  16. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/dependency_links.txt +0 -0
  17. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/entry_points.txt +0 -0
  18. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/requires.txt +0 -0
  19. {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/top_level.txt +0 -0
  20. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/__main__.py +0 -0
  21. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/canonical.py +0 -0
  22. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/cli.py +0 -0
  23. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/client.py +0 -0
  24. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/httpsig.py +0 -0
  25. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/jws.py +0 -0
  26. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/merkle.py +0 -0
  27. {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trooth
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log.
5
5
  Author: Trooth, LLC
6
6
  License-Expression: Apache-2.0
@@ -75,7 +75,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
75
75
 
76
76
  ## Tests
77
77
 
78
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
78
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
79
79
 
80
80
  ## SemantoClient
81
81
 
@@ -86,3 +86,14 @@ print(r["ok"], r["dns"], r["reason"])
86
86
  ```
87
87
 
88
88
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
89
+
90
+ ## SemantoSource
91
+
92
+ ```python
93
+ from trooth import SemantoSource
94
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
95
+ a = acme.answer(question="How much is the Starter plan?")
96
+ print(a["status"], a["text"])
97
+ ```
98
+
99
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -45,7 +45,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
45
45
 
46
46
  ## Tests
47
47
 
48
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
48
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
49
49
 
50
50
  ## SemantoClient
51
51
 
@@ -56,3 +56,14 @@ print(r["ok"], r["dns"], r["reason"])
56
56
  ```
57
57
 
58
58
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
59
+
60
+ ## SemantoSource
61
+
62
+ ```python
63
+ from trooth import SemantoSource
64
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
65
+ a = acme.answer(question="How much is the Starter plan?")
66
+ print(a["status"], a["text"])
67
+ ```
68
+
69
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
12
12
 
13
13
  [project]
14
14
  name = "trooth"
15
- version = "0.1.1"
15
+ version = "0.2.0"
16
16
  description = "Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log."
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.11"
@@ -5,10 +5,14 @@
5
5
  from trooth import SemantoClient
6
6
  r = SemantoClient().read("https://acme.com/pricing")
7
7
 
8
+ from trooth import SemantoSource
9
+ a = SemantoSource("acme.com").answer(question="What is your phone number?")
10
+
8
11
  The checks themselves live in trooth_semanto_verify (the same functions and reason codes as the JavaScript and Go
9
12
  kits). Framework integrations are optional extras: trooth[langchain], trooth[llama-index], trooth[openai-agents].
10
13
  """
11
14
  from trooth_semanto_verify.client import SemantoClient
15
+ from trooth_semanto_verify.source import SemantoSource
12
16
 
13
- __version__ = "0.1.1"
14
- __all__ = ["SemantoClient", "__version__"]
17
+ __version__ = "0.2.0"
18
+ __all__ = ["SemantoClient", "SemantoSource", "__version__"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trooth
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Read a company's Semanto signed copy and check it: signatures, keys, DNS binding, page proofs and the public log.
5
5
  Author: Trooth, LLC
6
6
  License-Expression: Apache-2.0
@@ -75,7 +75,7 @@ Canonical JSON here supports integers exactly. A non-integer float raises `Canon
75
75
 
76
76
  ## Tests
77
77
 
78
- `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
78
+ `python3 -m pytest` runs every shared vector in `verifier/test-vectors/v1` (positive and negative), the answer cases in `verifier/test-vectors/answer-v1`, the source's refresh and offline behavior, the RFC 6962 known roots, the RFC 8785 cases, the state rules and the command line.
79
79
 
80
80
  ## SemantoClient
81
81
 
@@ -86,3 +86,14 @@ print(r["ok"], r["dns"], r["reason"])
86
86
  ```
87
87
 
88
88
  It fetches with the standard library (pass `fetch=` to use your own), checks the response signature and the domain's `_trooth-keys` TXT record over DNS over HTTPS, and keeps key sets and DNS answers five minutes.
89
+
90
+ ## SemantoSource
91
+
92
+ ```python
93
+ from trooth import SemantoSource
94
+ acme = SemantoSource("acme.com", on_event=lambda e: print(e["event"], e["detail"]))
95
+ a = acme.answer(question="How much is the Starter plan?")
96
+ print(a["status"], a["text"])
97
+ ```
98
+
99
+ It loads `/.well-known/trooth.semanto` and the company's keys from the company's site, checks the file once for each new body, reads the site again at most every 15 minutes, and answers fact IDs and plain questions in process with the same matcher as Trooth's service (`trooth_semanto_verify.answer`, compared case by case with `verifier/test-vectors/answer-v1`). When Trooth's API cannot be reached it keeps answering from the last version that checked out and logs `trooth_api_unreachable`; a new version that does not check out is refused and the last good one stays in use. A fact marked changed since signing or expired returns its mark and no value.
@@ -14,10 +14,12 @@ trooth/integrations/llama_index.py
14
14
  trooth/integrations/openai_agents.py
15
15
  trooth_semanto_verify/__init__.py
16
16
  trooth_semanto_verify/__main__.py
17
+ trooth_semanto_verify/answer.py
17
18
  trooth_semanto_verify/canonical.py
18
19
  trooth_semanto_verify/cli.py
19
20
  trooth_semanto_verify/client.py
20
21
  trooth_semanto_verify/httpsig.py
21
22
  trooth_semanto_verify/jws.py
22
23
  trooth_semanto_verify/merkle.py
24
+ trooth_semanto_verify/source.py
23
25
  trooth_semanto_verify/verify.py
@@ -91,4 +91,4 @@ __all__ = [
91
91
  "version_id",
92
92
  ]
93
93
 
94
- __version__ = "0.1.1"
94
+ __version__ = "0.2.0"
@@ -0,0 +1,172 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Trooth, LLC
3
+ """The answer matcher, run in process on a company's signed copy.
4
+
5
+ A fact ID or a plain question goes in; out comes the signed fact word for word with its typed value, conditions, date
6
+ and check link, or the candidates when more than one fact fits, or the not-published reply when none does. No model and
7
+ no generated wording: the same question against the same version gives the same answer as Trooth's service. MATCHER["id"]
8
+ names the matcher and equals the `matcher` field of the service's answers; test-vectors/answer-v1 holds the service's
9
+ answers to compare. The steps, in order: fact_id, question_map, key_terms, similarity.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import unicodedata
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, Iterable, List, Optional
18
+
19
+ MATCHER: Dict[str, Any] = json.loads('{"id":"matcher-1-a75202e1","order":["fact_id","question_map","key_terms","similarity"],"SIMILARITY_THRESHOLD":0.34,"SIMILARITY_MARGIN":0.05,"KEY_TERMS":[["^contact\\\\.phone$|\\\\.telephone$",["phone","call","telephone","number"]],["^contact\\\\.(support_)?email$",["email","e-mail","mail"]],["^contact\\\\.address",["address","located","location","office","headquarters"]],["^hours\\\\.",["open","hours","close","opening","closing"]],["price|pricing|offer",["price","cost","how much","plan","pricing","fee"]],["^identity\\\\.founding_date$",["founded","started","established","since"]],["^identity\\\\.legal_name$",["legal name","legal entity","company name","registered"]],["refund|return",["refund","return","money back"]],["shipping|delivery",["shipping","delivery","ship"]]],"catalog":[["security.mfa",["multi-factor","multifactor","two-factor","2FA","MFA"]],["security.mfa_methods",["authenticator","TOTP","passkey","SMS","security key","WebAuthn","FIDO"]],["security.encryption_at_rest",["encrypted at rest","encryption at rest","at rest"]],["security.encryption_in_transit",["in transit","TLS"]],["security.soc2",["SOC 2","SOC2","SOC-2"]],["security.iso27001",["ISO 27001","ISO/IEC 27001","ISO27001"]],["security.data_residency",["data residency","stored in","hosted in","data center","region"]],["security.page_url",["security page","trust center","trust page"]],["privacy.subprocessors",["subprocessor","sub-processor"]],["privacy.data_deletion_days",["delet","purge","retention"]],["terms.uptime_commitment",["uptime","availability","SLA"]],["terms.sla_credits",["service credit","SLA credit","credits"]],["terms.minimum_term",["minimum term","contract term","commitment","annual contract","month-to-month"]],["terms.cancellation_notice",["cancel","notice"]],["support.hours",["support hours","support is available","support team","support"]],["support.channels",["email","chat","phone","ticket","portal"]],["commercial.pricing_model",["per seat","per user","usage-based","flat","tiered","pricing model","priced"]],["commercial.starting_price",["starts at","starting at","from","per month","per user","per seat"]],["commercial.free_trial",["free trial","trial"]],["commercial.returns_window_days",["return","refund"]],["commercial.authorized_sellers",["buy","sold","retailer","available at","stockist"]],["commercial.shipping_free_over",["free shipping","shipping"]],["commercial.ships_international",["international","worldwide","outside the"]],["products.{slug}.price",["price","cost","costs","priced","retails"]],["products.{slug}.sale_price",["sale","discount","off","deal"]],["products.{slug}.availability",["in stock","out of stock","available","sold out","backorder","pre-order","preorder"]],["products.{slug}.sizes",["size","sizes"]],["products.{slug}.variants",["color","colour","variant","option","finish"]],["products.{slug}.variant_differences",["difference","differs","compared","versus","vs"]],["products.{slug}.materials",["made of","made from","material","ingredient","contains"]],["products.{slug}.warranty",["warranty","guarantee"]],["products.{slug}.safety_warnings",["warning","caution","not suitable","choking","hazard"]],["products.{slug}.safety_standards",["standard","tested to","complies","compliant","EN ","ASTM","CPSC","UL "]],["products.{slug}.dimensions",["weigh","weight","dimension","measures","cm","inches","kg","lb"]],["local.hours",["open","hours","opening"]],["local.hours_sunday",["sunday"]],["local.holiday_hours",["holiday","christmas","thanksgiving","closed on"]],["local.address",["located","address","find us","at "]],["local.phone",["phone","call","telephone","reach"]],["local.email",["email","e-mail","@"]],["local.services",["service","offer","provide"]],["local.service_price",["cost","price","charge","fee"]],["local.parking",["parking","park","garage","lot"]],["local.accessibility",["wheelchair","accessible","step-free","ramp","elevator"]],["local.appointment_policy",["appointment","book","reservation"]],["local.walk_ins",["walk-in","walk in","walk-ins","without an appointment"]],["local.payment_methods",["accept","payment","cash","card","Apple Pay","Google Pay"]],["local.delivery",["deliver","delivery","pickup","pick-up","curbside"]],["local.languages",["language","speak","spoken","Spanish","French","Mandarin"]]]}')
20
+
21
+ MARK_WORDS = {
22
+ "changed_since_signing": "Changed since signing: the page this came from no longer says it the same way. Check the page before quoting a value.",
23
+ "expired": "Expired: the company said this held only until a date that has passed.",
24
+ }
25
+
26
+ _STOP = {"the", "a", "an", "is", "are", "do", "does", "what", "whats", "how", "when", "where", "who", "which", "of", "to", "for", "in", "on", "at", "your", "you", "their", "they", "its", "it", "and", "or", "can", "i", "we", "our", "my", "me", "this", "that", "page", "lists", "gives", "says"}
27
+ _KEY_TERMS = [(re.compile(src), terms) for src, terms in MATCHER["KEY_TERMS"]]
28
+ _ALNUM = re.compile(r"[^\W_]+")
29
+ _NOT_ALNUM = re.compile(r"[\W_]+")
30
+
31
+
32
+ def not_published(org: str, domain: str) -> str:
33
+ return f"{org}'s signed record does not answer this. Ask {org} at {domain}."
34
+
35
+
36
+ def _js_json(v: Any) -> str:
37
+ return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
38
+
39
+
40
+ def matcher_id(m: Optional[Dict[str, Any]] = None) -> str:
41
+ """The matcher's identity, worked out from its data (FNV-1a over the UTF-16 code units of its JSON)."""
42
+ m = m or MATCHER
43
+ src = _js_json({"order": m["order"], "SIMILARITY_THRESHOLD": m["SIMILARITY_THRESHOLD"], "SIMILARITY_MARGIN": m["SIMILARITY_MARGIN"], "KEY_TERMS": m["KEY_TERMS"], "catalog": m["catalog"]})
44
+ h = 0x811C9DC5
45
+ units = src.encode("utf-16-le")
46
+ for i in range(0, len(units), 2):
47
+ h = ((h ^ (units[i] | units[i + 1] << 8)) * 0x01000193) & 0xFFFFFFFF
48
+ return f"matcher-1-{h:08x}"
49
+
50
+
51
+ def _lower(s: str) -> str:
52
+ return unicodedata.normalize("NFKC", s.lower())
53
+
54
+
55
+ def _words(s: str) -> set:
56
+ out = set()
57
+ for w in _ALNUM.findall(_lower(s)):
58
+ if len(w) > 1 and w not in _STOP:
59
+ out.add(re.sub(r"s$", "", re.sub(r"(ies)$", "y", w)))
60
+ return out
61
+
62
+
63
+ def _norm(s: str) -> str:
64
+ return _NOT_ALNUM.sub(" ", _lower(s)).strip()
65
+
66
+
67
+ def _parse_time(s: str) -> datetime:
68
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
69
+
70
+
71
+ def fact_marks(file: dict, changed: Iterable[str] = (), now: Optional[datetime] = None) -> Dict[str, str]:
72
+ """The marks in force: "expired" past a fact's valid_to, "changed_since_signing" when the version passed its
73
+ not_after or the monitor found the sentence changed (`changed`: fact IDs for this version)."""
74
+ now = now or datetime.now(timezone.utc)
75
+ today = now.astimezone(timezone.utc).strftime("%Y-%m-%d")
76
+ na = file["header"].get("not_after")
77
+ lapsed = na is not None and _parse_time(na) < now
78
+ ch = set(changed)
79
+ out: Dict[str, str] = {}
80
+ for f in file["facts"]:
81
+ if f.get("valid_to") and f["valid_to"] < today:
82
+ out[f["id"]] = "expired"
83
+ elif lapsed or f["id"] in ch:
84
+ out[f["id"]] = "changed_since_signing"
85
+ return out
86
+
87
+
88
+ def _nz(*vals: Any) -> Any:
89
+ for v in vals:
90
+ if v is not None:
91
+ return v
92
+ return None
93
+
94
+
95
+ def _as_answered(f: dict, mark: Optional[str]) -> dict:
96
+ return {
97
+ "fact_id": f["id"], "key": f["key"], "statement": None if mark else f["statement"], "typed": None if mark else f.get("typed"),
98
+ "conditions": [] if mark else (f.get("conditions") or []), "location": f.get("location"), "valid_from": f.get("valid_from"),
99
+ "valid_to": f.get("valid_to"), "provenance": f["provenance"], "source_url": f["evidence"]["url"], "mark": mark,
100
+ }
101
+
102
+
103
+ def _distinct(facts: List[dict]) -> List[dict]:
104
+ seen: Dict[str, dict] = {}
105
+ for f in facts:
106
+ typed = f.get("typed") or {}
107
+ k = f"{_js_json(_nz(typed.get('value'), f.get('value'), f['statement']))}|{f.get('location') or ''}|{';'.join(f.get('conditions') or [])}"
108
+ seen.setdefault(k, f)
109
+ return list(seen.values())
110
+
111
+
112
+ def _match(file: dict, fact_id: Optional[str], question: Optional[str]):
113
+ facts = file["facts"]
114
+ if fact_id:
115
+ f = next((x for x in facts if x["id"] == fact_id), None)
116
+ return ("fact_id" if f else None), ([f] if f else [])
117
+ q = question or ""
118
+ nq = _norm(q)
119
+ by_id = {f["id"]: f for f in facts}
120
+ for e in file.get("question_map") or []:
121
+ if _norm(e["question"]) == nq and e.get("fact_ids"):
122
+ return "question_map", [by_id[i] for i in e["fact_ids"] if i in by_id]
123
+ lq = f" {nq} "
124
+ for rx, terms in _KEY_TERMS:
125
+ if not any(f" {t} " in lq or f" {t}s " in lq for t in terms):
126
+ continue
127
+ hits = [f for f in facts if rx.search(f["key"])]
128
+ if hits:
129
+ return "key_terms", hits
130
+ for key, terms in MATCHER["catalog"]:
131
+ if not any(f" {_norm(t)} " in lq for t in terms):
132
+ continue
133
+ hits = [f for f in facts if f["key"] == key]
134
+ if hits:
135
+ return "key_terms", hits
136
+ qw = _words(q)
137
+ if not qw:
138
+ return None, []
139
+ ranked = []
140
+ for f in facts:
141
+ fw = _words(f["statement"])
142
+ s = len(qw & fw) / len(qw | fw)
143
+ if s >= MATCHER["SIMILARITY_THRESHOLD"]:
144
+ ranked.append((s, f))
145
+ if not ranked:
146
+ return None, []
147
+ ranked.sort(key=lambda x: -x[0])
148
+ top = ranked[0][0]
149
+ return "similarity", [f for s, f in ranked if top - s <= MATCHER["SIMILARITY_MARGIN"]]
150
+
151
+
152
+ def answer(file: dict, company: dict, fact_id: Optional[str] = None, question: Optional[str] = None, marks: Optional[Dict[str, str]] = None, check_base: str = "https://api.trooth.co/public/semanto", signed_at: Optional[str] = None) -> dict:
153
+ """Answer a fact ID or a plain question from a checked signed copy, as Trooth's service does.
154
+
155
+ company: {"trooth_id", "org", "domain"}; marks: from fact_marks()."""
156
+ marks = marks or {}
157
+ h = file["header"]
158
+ check = f"{check_base.rstrip('/')}/{company['trooth_id']}/versions/{h['version']}"
159
+ base = {"object": "semanto.answer", "matcher": MATCHER["id"], "company": {"trooth_id": company["trooth_id"], "name": company["org"], "domain": company["domain"]},
160
+ "version": h["version"], "version_hash": file["root_hash"], "signed_at": _nz(signed_at, (file.get("witness") or {}).get("at")), "check": check}
161
+ by, facts = _match(file, fact_id, question)
162
+ found = _distinct(facts)
163
+ if not found:
164
+ return {**base, "status": "not_published", "matched_by": None, "facts": [], "text": not_published(company["org"], company["domain"])}
165
+ if len(found) > 1:
166
+ return {**base, "status": "candidates", "matched_by": by, "facts": [_as_answered(f, marks.get(f["id"])) for f in found[:10]],
167
+ "text": f"{len(found)} signed facts of {company['org']} could answer this; none is picked. Ask about one of them by its fact ID."}
168
+ f = found[0]
169
+ mark = marks.get(f["id"])
170
+ lines = [MARK_WORDS[mark]] if mark else [f["statement"], *([f"Conditions: {'; '.join(f['conditions'])}."] if f.get("conditions") else [])]
171
+ lines.append(f"Check: {check}")
172
+ return {**base, "status": "answer", "matched_by": by, "facts": [_as_answered(f, mark)], "text": "\n".join(lines[:3])}
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Trooth, LLC
3
+ """SemantoSource: a company's signed copy, loaded from the company's own site and answered from in process.
4
+
5
+ from trooth import SemantoSource
6
+ acme = SemantoSource("acme.com")
7
+ a = acme.answer(question="What is your phone number?")
8
+ if a["status"] == "answer":
9
+ show(a["text"])
10
+
11
+ It reads https://<domain>/.well-known/trooth.semanto and the company's keys from the same site, checks the file
12
+ (signature, witness, roots, window) once for each new body it serves, and answers value questions with the same matcher
13
+ as Trooth's service (answer.py). It reads the site again at most every refresh_seconds (900 at most, so a new version is
14
+ used within 15 minutes), and the marks (changed since signing, expired) with each refresh.
15
+
16
+ Trooth's API is used only for Trooth's witness keys and the monitor's marks. When it cannot be reached the source keeps
17
+ answering from the last version that checked out, keeps the last keys and marks, applies the date marks itself, and logs
18
+ a "trooth_api_unreachable" event. A new version is adopted only when it checks out; until then the last good one stays
19
+ in use and the failure is logged. The standard library does the HTTP; pass `fetch` to use your own.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Callable, Dict, List, Optional
29
+
30
+ from .answer import answer as _answer
31
+ from .answer import fact_marks
32
+ from .client import Fetch, urllib_fetch
33
+ from .verify import verify_semanto
34
+
35
+ TROOTH_API = "https://api.trooth.co"
36
+ MAX_REFRESH_SECONDS = 900
37
+
38
+
39
+ class SemantoSource:
40
+ def __init__(self, domain: str, fetch: Optional[Fetch] = None, api_base: str = TROOTH_API, refresh_seconds: float = MAX_REFRESH_SECONDS,
41
+ now: Optional[Callable[[], float]] = None, on_event: Optional[Callable[[dict], None]] = None):
42
+ d = domain.lower()
43
+ for p in ("https://", "http://"):
44
+ if d.startswith(p):
45
+ d = d[len(p):]
46
+ self.domain = d.split("/")[0]
47
+ self.fetch: Fetch = fetch or urllib_fetch
48
+ self.api_base = api_base.rstrip("/")
49
+ self.refresh_seconds = min(max(refresh_seconds, 1), MAX_REFRESH_SECONDS)
50
+ self.now = now or time.time
51
+ self.on_event = on_event
52
+ self.events: List[dict] = []
53
+ self.file: Optional[dict] = None
54
+ self.company: Optional[dict] = None
55
+ self.trooth_keys: Optional[dict] = None
56
+ self.changed: List[str] = []
57
+ self.checked_at: Optional[float] = None
58
+ self.checks = 0
59
+ self._body: Optional[str] = None
60
+ self._lock = threading.Lock()
61
+
62
+ def _log(self, event: str, detail: str) -> None:
63
+ e = {"at": datetime.fromtimestamp(self.now(), timezone.utc).isoformat().replace("+00:00", "Z"), "event": event, "detail": detail}
64
+ self.events.append(e)
65
+ del self.events[:-200]
66
+ if self.on_event:
67
+ try:
68
+ self.on_event(e)
69
+ except Exception: # noqa: BLE001 (the caller's logger must not stop an answer)
70
+ pass
71
+
72
+ def _json(self, url: str) -> Any:
73
+ status, _, body = self.fetch(url, {"Accept": "application/json"})
74
+ if status != 200:
75
+ raise RuntimeError(f"answered {status}")
76
+ return json.loads(body)
77
+
78
+ def _trooth_key_set(self) -> Optional[dict]:
79
+ try:
80
+ self.trooth_keys = self._json(f"{self.api_base}/public/semanto/.well-known/trooth-keys.json")
81
+ except Exception as e: # noqa: BLE001
82
+ self._log("trooth_api_unreachable", f"Trooth's keys could not be read ({e}); {'the last ones are used' if self.trooth_keys else 'none are known yet'}.")
83
+ return self.trooth_keys
84
+
85
+ def _in_use(self) -> str:
86
+ return f"version {self.file['header']['version']} stays in use" if self.file else "there is no version yet"
87
+
88
+ def refresh(self) -> None:
89
+ """Read the site again, adopt a new version that checks out, and read the marks. Never raises."""
90
+ with self._lock:
91
+ started = self.now()
92
+ try:
93
+ self._load()
94
+ finally:
95
+ self.checked_at = started
96
+
97
+ def _load(self) -> None:
98
+ try:
99
+ file = self._json(f"https://{self.domain}/.well-known/trooth.semanto")
100
+ except Exception as e: # noqa: BLE001
101
+ self._log("site_unreachable", f"https://{self.domain}/.well-known/trooth.semanto could not be read ({e}); {self._in_use()}.")
102
+ return
103
+ body = json.dumps(file, sort_keys=True, separators=(",", ":"))
104
+ if body != self._body:
105
+ try:
106
+ company = self._json(f"https://{self.domain}/.well-known/trooth-keys.json")
107
+ except Exception as e: # noqa: BLE001
108
+ self._log("site_unreachable", f"The company's keys could not be read ({e}); the new version is not adopted.")
109
+ return
110
+ trooth = self._trooth_key_set()
111
+ res = verify_semanto(file, None, {"company": company, "trooth": trooth or {"keys": []}}, now=datetime.fromtimestamp(self.now(), timezone.utc))
112
+ self.checks += 1
113
+ h = file.get("header") or {}
114
+ domains = [str(d).lower() for d in h.get("domains") or []]
115
+ if res.get("ok") and self.domain not in domains:
116
+ self._log("version_refused", f"Version {h.get('version')} is for {', '.join(domains)}, not {self.domain}.")
117
+ elif not res.get("ok"):
118
+ self._log("version_refused", f"Version {h.get('version')} did not check out ({res.get('reason')}); {self._in_use()}.")
119
+ else:
120
+ before = self.file["header"]["version"] if self.file else None
121
+ self.file, self._body = file, body
122
+ self.company = {"trooth_id": h["trooth_id"], "org": h["org"], "domain": self.domain}
123
+ self.changed = []
124
+ self._log("version_adopted", f"Version {h['version']} checked out and is in use." if before is None else f"Version {h['version']} checked out and replaces version {before}.")
125
+ if self.file:
126
+ self._read_marks()
127
+
128
+ def _read_marks(self) -> None:
129
+ tid = urllib.parse.quote(self.file["header"]["trooth_id"], safe="")
130
+ try:
131
+ m = self._json(f"{self.api_base}/public/semanto/{tid}/facts/marks")
132
+ if int(m.get("version", -1)) == int(self.file["header"]["version"]):
133
+ self.changed = [x["fact_id"] for x in m.get("marks") or [] if x.get("mark") == "changed_since_signing"]
134
+ except Exception as e: # noqa: BLE001
135
+ self._log("trooth_api_unreachable", f"The marks could not be read ({e}); the last ones and the date marks are used.")
136
+
137
+ def ensure_fresh(self) -> None:
138
+ """Read the site again when the last read is older than the refresh interval."""
139
+ if self.file is not None and self.checked_at is not None and self.now() - self.checked_at < self.refresh_seconds:
140
+ return
141
+ self.refresh()
142
+
143
+ def marks(self) -> Dict[str, str]:
144
+ """The marks in force now: the monitor's last marks for this version and the date marks."""
145
+ return fact_marks(self.file, self.changed, datetime.fromtimestamp(self.now(), timezone.utc)) if self.file else {}
146
+
147
+ def answer(self, fact_id: Optional[str] = None, question: Optional[str] = None) -> dict:
148
+ """Answer a fact ID or a plain question from the last version that checked out.
149
+ status "not_read" means no version has checked out yet; `events` says why."""
150
+ self.ensure_fresh()
151
+ if not self.file:
152
+ return {"object": "semanto.answer", "status": "not_read", "matched_by": None, "company": {"trooth_id": None, "name": None, "domain": self.domain},
153
+ "version": None, "facts": [], "text": f"No signed record of {self.domain} could be read and checked yet."}
154
+ return _answer(self.file, self.company, fact_id=fact_id, question=question, marks=self.marks(), check_base=f"{self.api_base}/public/semanto")
File without changes
File without changes