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.
- {trooth-0.1.1 → trooth-0.2.0}/PKG-INFO +13 -2
- {trooth-0.1.1 → trooth-0.2.0}/README.md +12 -1
- {trooth-0.1.1 → trooth-0.2.0}/pyproject.toml +1 -1
- {trooth-0.1.1 → trooth-0.2.0}/trooth/__init__.py +6 -2
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/PKG-INFO +13 -2
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/SOURCES.txt +2 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/__init__.py +1 -1
- trooth-0.2.0/trooth_semanto_verify/answer.py +172 -0
- trooth-0.2.0/trooth_semanto_verify/source.py +154 -0
- {trooth-0.1.1 → trooth-0.2.0}/LICENSE +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/setup.cfg +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/__init__.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/langchain.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/llama_index.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth/integrations/openai_agents.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/dependency_links.txt +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/entry_points.txt +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/requires.txt +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth.egg-info/top_level.txt +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/__main__.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/canonical.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/cli.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/client.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/httpsig.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/jws.py +0 -0
- {trooth-0.1.1 → trooth-0.2.0}/trooth_semanto_verify/merkle.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|