sooth 0.2.1__py3-none-any.whl

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.
sooth/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Sooth — verify AI-generated text against source material."""
2
+
3
+ from sooth.claims import Claim, split_claims
4
+ from sooth.report import exit_code, log_record, render_markdown
5
+ from sooth.verify import MODEL, Verdict, VerifyError, map_verdict, verify_claims
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "MODEL",
11
+ "Claim",
12
+ "Verdict",
13
+ "VerifyError",
14
+ "__version__",
15
+ "exit_code",
16
+ "log_record",
17
+ "map_verdict",
18
+ "render_markdown",
19
+ "split_claims",
20
+ "verify_claims",
21
+ ]
sooth/claims.py ADDED
@@ -0,0 +1,59 @@
1
+ """Split draft text into claims. Pure functions, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ # ponytail: regex splitter — mis-splits abbreviations/quotes; upgrade to clause-level when real drafts demand
9
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
10
+ _HEADING = re.compile(r"^\s{0,3}#{1,6}\s")
11
+ _BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+")
12
+ _BOLD_ONLY = re.compile(r"^\*\*[^*]+\*\*:?\s*$") # **Label** or **Label:** — heading, not a claim
13
+ _MIN_WORDS = 4
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Claim:
18
+ id: str
19
+ text: str
20
+ line: int
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Segment:
25
+ """A source sentence — an evidence candidate."""
26
+ id: str
27
+ text: str
28
+ line: int
29
+ source: str
30
+
31
+
32
+ def _iter_sentences(text: str) -> list[tuple[int, str]]:
33
+ """(line_no, sentence) pairs — shared by claim and segment splitting."""
34
+ out: list[tuple[int, str]] = []
35
+ for lineno, raw in enumerate(text.splitlines(), start=1):
36
+ if _HEADING.match(raw):
37
+ continue
38
+ line = _BULLET.sub("", raw).strip()
39
+ if not line or _BOLD_ONLY.match(line):
40
+ continue
41
+ line = line.replace("**", "")
42
+ for sentence in _SENTENCE_SPLIT.split(line):
43
+ s = sentence.strip()
44
+ if not s or s.endswith("?") or len(s.split()) < _MIN_WORDS:
45
+ continue
46
+ out.append((lineno, s))
47
+ return out
48
+
49
+
50
+ def split_claims(text: str) -> list[Claim]:
51
+ """Return checkable-looking sentences as claims. Skips headings, questions, fragments."""
52
+ return [Claim(id=f"c{i + 1}", text=s, line=ln)
53
+ for i, (ln, s) in enumerate(_iter_sentences(text))]
54
+
55
+
56
+ def split_segments(text: str, source: str, start_index: int = 1) -> list[Segment]:
57
+ """Source sentences as evidence candidates. Ids continue from start_index (s{n})."""
58
+ return [Segment(id=f"s{start_index + i}", text=s, line=ln, source=source)
59
+ for i, (ln, s) in enumerate(_iter_sentences(text))]
sooth/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """CLI: sooth. File I/O, exit codes, --log writer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from sooth.claims import split_claims
12
+ from sooth.report import exit_code, log_record, render_markdown, render_plain
13
+ from sooth.verify import DEFAULT_THRESHOLD, VerifyError, verify_claims
14
+
15
+
16
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
17
+ p = argparse.ArgumentParser(
18
+ prog="sooth",
19
+ description="Verify AI-generated text against source material. "
20
+ "Each claim gets PASS / FAIL / REVIEW with calibrated probabilities.",
21
+ )
22
+ p.add_argument("--source", action="append", required=True, metavar="FILE",
23
+ help="ground-truth source file (repeatable)")
24
+ p.add_argument("--text", required=True, metavar="FILE", help="AI-generated draft to check")
25
+ p.add_argument("--confidence", type=float, default=DEFAULT_THRESHOLD, metavar="T",
26
+ help=f"REVIEW below this confidence (default {DEFAULT_THRESHOLD})")
27
+ p.add_argument("--format", choices=["md", "plain"], default="md", help="report format")
28
+ p.add_argument("-o", "--output", metavar="FILE", help="write report to file instead of stdout")
29
+ p.add_argument("--log", metavar="FILE", help="append full judgment trace as one JSONL line")
30
+ return p.parse_args(argv)
31
+
32
+
33
+ def main(argv: list[str] | None = None) -> int:
34
+ args = parse_args(argv)
35
+ try:
36
+ sources = [(path, Path(path).read_text(encoding="utf-8")) for path in args.source]
37
+ draft = Path(args.text).read_text(encoding="utf-8")
38
+ except OSError as e:
39
+ print(f"error: {e}", file=sys.stderr)
40
+ return 3
41
+ if not draft.strip():
42
+ print("error: empty draft", file=sys.stderr)
43
+ return 3
44
+ if any(not text.strip() for _, text in sources):
45
+ print("error: empty source file", file=sys.stderr)
46
+ return 3
47
+
48
+ claims = split_claims(draft)
49
+ if not claims:
50
+ print("error: no claims found in draft", file=sys.stderr)
51
+ return 3
52
+
53
+ try:
54
+ result = verify_claims(claims, sources, threshold=args.confidence)
55
+ except VerifyError as e:
56
+ print(f"error: {e}", file=sys.stderr)
57
+ return 3
58
+
59
+ report = (render_markdown if args.format == "md" else render_plain)(
60
+ result.verdicts, threshold=args.confidence
61
+ )
62
+ if args.output:
63
+ Path(args.output).write_text(report, encoding="utf-8")
64
+ else:
65
+ print(report, end="")
66
+
67
+ if args.log:
68
+ record = log_record(result, threshold=args.confidence,
69
+ sources=[path for path, _ in sources], draft=args.text)
70
+ record["ts"] = datetime.now(UTC).isoformat(timespec="seconds")
71
+ with Path(args.log).open("a", encoding="utf-8") as fh:
72
+ fh.write(json.dumps(record) + "\n")
73
+
74
+ return exit_code(result.verdicts)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
sooth/report.py ADDED
@@ -0,0 +1,125 @@
1
+ """Render verdicts to text and build the JSONL log record. Pure functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sooth.verify import FAIL, PASS, REVIEW, UNCHECKABLE, Verdict, VerifyResult
6
+
7
+ _BAR_WIDTH = 8
8
+ _MARK = {PASS: "✅ PASS", FAIL: "❌ FAIL", REVIEW: "⚠️ REVIEW", UNCHECKABLE: "➖ UNCHECKABLE"}
9
+ _PLAIN = {PASS: "PASS", FAIL: "FAIL", REVIEW: "REVIEW", UNCHECKABLE: "UNCHECKABLE"}
10
+
11
+
12
+ def bar(p: float, width: int = _BAR_WIDTH) -> str:
13
+ filled = round(p * width)
14
+ return "█" * filled + "░" * (width - filled)
15
+
16
+
17
+ def _why(v: Verdict) -> str:
18
+ if v.kind == UNCHECKABLE:
19
+ return f"P(checkable)={v.p_checkable:.2f}"
20
+ parts = []
21
+ if v.probabilities:
22
+ parts.append(" / ".join(f"{k} {p:.2f}" for k, p in v.probabilities.items()))
23
+ if v.details_p is not None and v.details_p < 1.0:
24
+ parts.append(f"details {v.details_p:.2f}")
25
+ if v.missing_numbers:
26
+ parts.append("missing #s: " + ", ".join(v.missing_numbers))
27
+ return " · ".join(parts)
28
+
29
+
30
+ def _p(v: Verdict) -> float:
31
+ return v.p_checkable if v.kind == UNCHECKABLE else (v.confidence or 0.0)
32
+
33
+
34
+ def counts(verdicts: list[Verdict]) -> dict[str, int]:
35
+ tallies = {PASS: 0, FAIL: 0, REVIEW: 0, UNCHECKABLE: 0}
36
+ for v in verdicts:
37
+ tallies[v.kind] += 1
38
+ return tallies
39
+
40
+
41
+ def _evidence(v: Verdict) -> str:
42
+ if not v.evidence_text:
43
+ return ""
44
+ snippet = v.evidence_text if len(v.evidence_text) <= 60 else v.evidence_text[:57] + "…"
45
+ return f'`{v.evidence_source}:{v.evidence_line}` "{snippet}"'
46
+
47
+
48
+ def render_markdown(verdicts: list[Verdict], threshold: float) -> str:
49
+ t = counts(verdicts)
50
+ lines = [
51
+ "# Sooth\n",
52
+ (
53
+ f"**PASS {t[PASS]} · FAIL {t[FAIL]} · REVIEW {t[REVIEW]} · UNCHECKABLE {t[UNCHECKABLE]}**"
54
+ f" — threshold {threshold:.2f}\n"
55
+ ),
56
+ "| # | Claim | Verdict | P | Why (P distribution) | Evidence |",
57
+ "|---|-------|---------|---|----------------------|----------|",
58
+ ]
59
+ for i, v in enumerate(verdicts, start=1):
60
+ claim = v.claim_text.replace("|", "\\|")
61
+ lines.append(f"| {i} | {claim} | {_MARK[v.kind]} | {bar(_p(v))} {_p(v):.2f} | {_why(v)} | {_evidence(v)} |")
62
+ needs = [v for v in verdicts if v.kind in (REVIEW, UNCHECKABLE)]
63
+ if needs:
64
+ lines += ["\n## Needs review\n"]
65
+ lines += [f"- line {v.line}: {v.claim_text}" for v in needs]
66
+ return "\n".join(lines) + "\n"
67
+
68
+
69
+ def render_plain(verdicts: list[Verdict], threshold: float) -> str:
70
+ t = counts(verdicts)
71
+ lines = [
72
+ (
73
+ f"PASS {t[PASS]} FAIL {t[FAIL]} REVIEW {t[REVIEW]} UNCHECKABLE {t[UNCHECKABLE]}"
74
+ f" (threshold {threshold:.2f})"
75
+ )
76
+ ]
77
+ for v in verdicts:
78
+ lines.append(f"{_PLAIN[v.kind]:<11} {_p(v):.2f} line {v.line} {v.claim_text}")
79
+ return "\n".join(lines) + "\n"
80
+
81
+
82
+ def exit_code(verdicts: list[Verdict]) -> int:
83
+ """0 clean, 1 any FAIL, 2 any REVIEW (no FAIL). UNCHECKABLE never fails CI."""
84
+ kinds = {v.kind for v in verdicts}
85
+ if FAIL in kinds:
86
+ return 1
87
+ if REVIEW in kinds:
88
+ return 2
89
+ return 0
90
+
91
+
92
+ def log_record(result: VerifyResult, threshold: float, sources: list[str], draft: str) -> dict:
93
+ """One JSONL line per run. Full probability distributions — the decision-ledger seed."""
94
+ return {
95
+ "model": result.model,
96
+ "threshold": threshold,
97
+ "sources": sources,
98
+ "draft": draft,
99
+ "usage": dict(result.usage),
100
+ "results": [
101
+ {
102
+ "id": v.claim_id,
103
+ "text": v.claim_text,
104
+ "line": v.line,
105
+ "kind": v.kind,
106
+ "p_checkable": v.p_checkable,
107
+ "choice": v.choice,
108
+ "probabilities": dict(v.probabilities),
109
+ "confidence": v.confidence,
110
+ "details_p": v.details_p,
111
+ "missing_numbers": list(v.missing_numbers),
112
+ "evidence": (
113
+ {
114
+ "id": v.evidence_id,
115
+ "text": v.evidence_text,
116
+ "line": v.evidence_line,
117
+ "source": v.evidence_source,
118
+ }
119
+ if v.evidence_text
120
+ else None
121
+ ),
122
+ }
123
+ for v in result.verdicts
124
+ ],
125
+ }
sooth/verify.py ADDED
@@ -0,0 +1,266 @@
1
+ """Build Jev questions, call the API, map answers to verdicts. Mapping is pure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+
9
+ from sooth.claims import Claim, Segment, split_segments
10
+
11
+ # pin: thresholds in docs are tuned against this build; bump = re-run calibration
12
+ MODEL = "jev-1.13.0"
13
+ BATCH = 30 # claims per request; 2 questions each, stays far under the 64k token cap
14
+ CHECKABLE_FLOOR = 0.5
15
+ DEFAULT_THRESHOLD = 0.7
16
+
17
+ PASS = "PASS"
18
+ FAIL = "FAIL"
19
+ REVIEW = "REVIEW"
20
+ UNCHECKABLE = "UNCHECKABLE"
21
+
22
+ DETAIL_FLOOR = 0.5 # PASS needs both supports-confidence and this detail-match probability
23
+ EVIDENCE_CANDIDATES = 6 # code-prefiltered spans offered per claim (pre-parsed cookbook)
24
+
25
+ _NUM = re.compile(r"\d+(?:[.,]\d+)*")
26
+
27
+
28
+ def extract_numbers(text: str) -> set[str]:
29
+ """Number tokens in raw and separator-stripped form ('27,5' → '27,5' + '275')."""
30
+ out: set[str] = set()
31
+ for m in _NUM.finditer(text):
32
+ out.add(m.group())
33
+ out.add(m.group().replace(".", "").replace(",", ""))
34
+ return out
35
+
36
+
37
+ def missing_numbers(claim_text: str, source_texts: list[str]) -> list[str]:
38
+ """Claim numbers absent from every source — deterministic smuggle detector."""
39
+ source_nums = extract_numbers(" ".join(source_texts))
40
+ missing = []
41
+ for m in _NUM.finditer(claim_text):
42
+ raw = m.group()
43
+ if raw not in source_nums and raw.replace(".", "").replace(",", "") not in source_nums:
44
+ missing.append(raw)
45
+ return missing
46
+
47
+
48
+ def evidence_candidates(claim_text: str, segments: list[Segment],
49
+ top: int = EVIDENCE_CANDIDATES) -> list[Segment]:
50
+ """Rank source segments by word overlap, numbers weighted. Pure pre-filter."""
51
+ nums = extract_numbers(claim_text)
52
+ words = {w.lower() for w in re.findall(r"[A-Za-zÀ-ÿ]{4,}", claim_text)}
53
+ scored = []
54
+ for seg in segments:
55
+ seg_nums = extract_numbers(seg.text)
56
+ seg_words = {w.lower() for w in re.findall(r"[A-Za-zÀ-ÿ]{4,}", seg.text)}
57
+ score = len(words & seg_words) + 3 * len(nums & seg_nums)
58
+ scored.append((score, seg))
59
+ scored.sort(key=lambda t: -t[0])
60
+ return [seg for score, seg in scored[:top] if score > 0] or segments[:top]
61
+
62
+
63
+ class VerifyError(Exception):
64
+ """Config or API failure — CLI prints one line and exits 3."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Verdict:
69
+ claim_id: str
70
+ claim_text: str
71
+ line: int
72
+ kind: str
73
+ p_checkable: float | None = None
74
+ choice: str | None = None
75
+ probabilities: dict[str, float] = field(default_factory=dict)
76
+ confidence: float | None = None
77
+ details_p: float | None = None
78
+ missing_numbers: tuple[str, ...] = ()
79
+ evidence_id: str | None = None
80
+ evidence_text: str | None = None
81
+ evidence_line: int | None = None
82
+ evidence_source: str | None = None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class VerifyResult:
87
+ verdicts: list[Verdict]
88
+ model: str
89
+ usage: dict[str, int | None]
90
+
91
+
92
+ def map_verdict(claim: Claim, p_checkable: float, choice: str, probabilities: dict[str, float],
93
+ confidence: float, threshold: float) -> Verdict:
94
+ """Verdict rules (PRD): checkable floor, then confidence gate, then choice."""
95
+ if p_checkable < CHECKABLE_FLOOR:
96
+ kind = UNCHECKABLE
97
+ elif confidence < threshold:
98
+ kind = REVIEW
99
+ elif choice == "supports":
100
+ kind = PASS
101
+ elif choice == "contradicts":
102
+ kind = FAIL
103
+ else:
104
+ kind = REVIEW # not_found
105
+ return Verdict(
106
+ claim_id=claim.id,
107
+ claim_text=claim.text,
108
+ line=claim.line,
109
+ kind=kind,
110
+ p_checkable=p_checkable,
111
+ choice=choice,
112
+ probabilities=dict(probabilities),
113
+ confidence=confidence,
114
+ )
115
+
116
+
117
+ def apply_safeguards(verdict: Verdict, details_p: float | None,
118
+ missing: list[str]) -> Verdict:
119
+ """Demote PASS when detail-gate is low or claim numbers are absent from sources."""
120
+ kind = verdict.kind
121
+ if kind == PASS and ((details_p is not None and details_p < DETAIL_FLOOR) or missing):
122
+ kind = REVIEW
123
+ return Verdict(
124
+ claim_id=verdict.claim_id,
125
+ claim_text=verdict.claim_text,
126
+ line=verdict.line,
127
+ kind=kind,
128
+ p_checkable=verdict.p_checkable,
129
+ choice=verdict.choice,
130
+ probabilities=verdict.probabilities,
131
+ confidence=verdict.confidence,
132
+ details_p=details_p,
133
+ missing_numbers=tuple(missing),
134
+ )
135
+
136
+
137
+ def attach_evidence(verdict: Verdict, segments_by_id: dict[str, Segment],
138
+ chosen: str | None) -> Verdict:
139
+ """Attach the span the model selected ('none' or unknown id → no evidence)."""
140
+ seg = segments_by_id.get(chosen or "")
141
+ return Verdict(
142
+ claim_id=verdict.claim_id,
143
+ claim_text=verdict.claim_text,
144
+ line=verdict.line,
145
+ kind=verdict.kind,
146
+ p_checkable=verdict.p_checkable,
147
+ choice=verdict.choice,
148
+ probabilities=verdict.probabilities,
149
+ confidence=verdict.confidence,
150
+ details_p=verdict.details_p,
151
+ missing_numbers=verdict.missing_numbers,
152
+ evidence_id=seg.id if seg else None,
153
+ evidence_text=seg.text if seg else None,
154
+ evidence_line=seg.line if seg else None,
155
+ evidence_source=seg.source if seg else None,
156
+ )
157
+
158
+
159
+ def build_state(claims: list[Claim], sources: list[tuple[str, str]],
160
+ segments: list[Segment]) -> dict:
161
+ return {
162
+ "sources": [{"name": name, "text": text} for name, text in sources],
163
+ "claims": [{"id": c.id, "text": c.text} for c in claims],
164
+ "segments": [{"id": s.id, "text": s.text} for s in segments],
165
+ }
166
+
167
+
168
+ def build_questions(claims: list[Claim], cands: dict[str, list[Segment]] | None = None) -> dict:
169
+ """Four questions per claim, fan-out in one call. Question dicts match the SDK's raw form."""
170
+ cands = cands or {}
171
+ questions: dict = {}
172
+ for c in claims:
173
+ questions[f"{c.id}_checkable"] = {
174
+ "type": "noul",
175
+ "instructions": (
176
+ f"Statement: {c.text}\n\n"
177
+ "Is the statement a concrete factual claim that the evidence in `sources` "
178
+ "could support or contradict? No for opinions, vague praise, questions, "
179
+ "or promises about the future."
180
+ ),
181
+ }
182
+ questions[f"{c.id}_verdict"] = {
183
+ "type": "choice",
184
+ "instructions": (
185
+ f"Statement: {c.text}\n\n"
186
+ "Does the evidence in `sources` support the statement?"
187
+ ),
188
+ "criteria": {
189
+ "supports": "The sources state or clearly imply the statement is true.",
190
+ "contradicts": "The sources state or clearly imply the statement is false.",
191
+ "not_found": (
192
+ "The sources do not address the statement either way, or the topic is absent."
193
+ ),
194
+ },
195
+ }
196
+ questions[f"{c.id}_details"] = {
197
+ "type": "noul",
198
+ "instructions": (
199
+ f"Statement: {c.text}\n\n"
200
+ "Does EVERY specific detail in the statement — names, numbers, dates, "
201
+ "quantities, and comparisons such as 'more than' or 'about' — exactly "
202
+ "match the evidence in `sources`? No if any detail is absent, slightly "
203
+ "altered, or hedged differently than the sources."
204
+ ),
205
+ }
206
+ span_criteria = {s.id: s.text[:80] for s in cands.get(c.id, [])}
207
+ span_criteria["none"] = "No segment is relevant to the statement."
208
+ questions[f"{c.id}_evidence"] = {
209
+ "type": "choice",
210
+ "instructions": (
211
+ f"Statement: {c.text}\n\n"
212
+ "Which candidate segment best supports or contradicts the statement? "
213
+ "Full text of each id is in `segments`. Choose 'none' if no segment "
214
+ "is relevant."
215
+ ),
216
+ "criteria": span_criteria,
217
+ }
218
+ return questions
219
+
220
+
221
+ def verify_claims(claims: list[Claim], sources: list[tuple[str, str]],
222
+ threshold: float = DEFAULT_THRESHOLD) -> VerifyResult:
223
+ """Batch claims through Jev and return verdicts. The only network entry point."""
224
+ if not os.environ.get("TYPESAFE_API_KEY"):
225
+ raise VerifyError("TYPESAFE_API_KEY not set (get a key at console.typesafe.ai)")
226
+ try:
227
+ from typesafe_sdk import TypeSafeClient
228
+ except ImportError as e: # pragma: no cover
229
+ raise VerifyError("typesafe-sdk not installed (pip install typesafe-sdk)") from e
230
+
231
+ verdicts: list[Verdict] = []
232
+ usage = {"input_tokens": 0, "output_tokens": 0}
233
+ model = MODEL
234
+ segments: list[Segment] = []
235
+ for name, text in sources:
236
+ segments.extend(split_segments(text, name, start_index=len(segments) + 1))
237
+ by_id = {s.id: s for s in segments}
238
+ cands = {c.id: evidence_candidates(c.text, segments) for c in claims}
239
+ try:
240
+ with TypeSafeClient(model=MODEL) as client:
241
+ for start in range(0, len(claims), BATCH):
242
+ chunk = claims[start:start + BATCH]
243
+ resp = client.system_one(
244
+ state=build_state(chunk, sources, segments),
245
+ questions=build_questions(chunk, cands),
246
+ )
247
+ model = resp.model
248
+ if resp.usage:
249
+ usage["input_tokens"] = (usage["input_tokens"] or 0) + (resp.usage.input_tokens or 0)
250
+ usage["output_tokens"] = (usage["output_tokens"] or 0) + (resp.usage.output_tokens or 0)
251
+ for c in chunk:
252
+ noul = resp.nouls[f"{c.id}_checkable"].noul
253
+ details_p = resp.nouls[f"{c.id}_details"].noul
254
+ ans = resp.choices[f"{c.id}_verdict"]
255
+ verdict = map_verdict(
256
+ c, noul, ans.choice, ans.probabilities, ans.confidence, threshold
257
+ )
258
+ missing = missing_numbers(c.text, [t for _, t in sources])
259
+ verdict = apply_safeguards(verdict, details_p, missing)
260
+ chosen = resp.choices[f"{c.id}_evidence"].choice
261
+ verdicts.append(attach_evidence(verdict, by_id, chosen))
262
+ except VerifyError:
263
+ raise
264
+ except Exception as e: # SDK error hierarchy; keep CLI free of SDK imports
265
+ raise VerifyError(f"Jev call failed: {e}") from e
266
+ return VerifyResult(verdicts=verdicts, model=model, usage=usage)
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: sooth
3
+ Version: 0.2.1
4
+ Summary: Verify AI-generated text against source material. Claim-by-claim trust reports powered by Jev (TypeSafe System One).
5
+ Project-URL: Homepage, https://github.com/naufalhilmiaji/sooth
6
+ Project-URL: Documentation, https://github.com/naufalhilmiaji/sooth/tree/main/docs
7
+ Author-email: Naufal Hilmiaji <nhilmiaji@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,jev,llm,trust,typesafe,verification
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: typesafe-sdk
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Sooth
25
+
26
+ Verify AI-generated text against source material. Claim-by-claim **PASS / FAIL / REVIEW**, with calibrated probabilities you can act on in CI.
27
+
28
+ Built on [Jev](https://docs.typesafe.ai) (TypeSafe System One) — typed judgments and probabilities instead of generated prose. Every verdict shows its probability distribution, and the combination rules are plain code you can read. No black box.
29
+
30
+ ```
31
+ sooth --source policy.md --text draft-reply.md
32
+ ```
33
+
34
+ Real output (planted errors in the draft vs a news source):
35
+
36
+ ```
37
+ # Sooth
38
+
39
+ **PASS 2 · FAIL 3 · REVIEW 1 · UNCHECKABLE 1** — threshold 0.70
40
+
41
+ | # | Claim | Verdict | P | Why (P distribution) | Evidence |
42
+ |---|-------|---------|---|----------------------|----------|
43
+ | 3 | BNBR baru menuntaskan rights issue bernilai besar di harga Rp 33. | ❌ FAIL | ████████ 1.00 | contradicts 1.00 / not_found 0.00 / supports 0.00 · details 0.01 · missing #s: 33 | `examples/news-1.md:25` "Saham ini juga baru menyelesaikan rights issue dalam juml…" |
44
+ | 5 | BUMI hanya perlu turun sekitar 10% untuk menyentuh level Rp 50. | ❌ FAIL | ████████ 1.00 | contradicts 1.00 / not_found 0.00 / supports 0.00 · details 0.01 · missing #s: 10 | `examples/news-1.md:23` "Adapun PT Bumi Resources Tbk (BUMI) di sekitar Rp192 haru…" |
45
+ | 6 | BEI juga menetapkan batas atas harga saham Rp 5.000 per saham. | ⚠️ REVIEW | ████████ 1.00 | contradicts 0.00 / not_found 1.00 / supports 0.00 · details 0.02 · missing #s: 5.000 | |
46
+ | 7 | Para investor ritel sangat senang dengan aturan baru ini. | ➖ UNCHECKABLE | ██░░░░░░ 0.24 | P(checkable)=0.24 | |
47
+ ```
48
+
49
+ ## Why
50
+
51
+ AI writes fast, nobody checks. Claims ship wrong. Sooth checks each claim against the evidence you give it — and when it is unsure, it says `REVIEW` instead of guessing.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install sooth # or: pip install -e ".[dev]" from source
57
+ export TYPESAFE_API_KEY=... # get one at console.typesafe.ai
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ sooth --source docs/policy.md --source tickets/t123.md --text draft-reply.md
64
+
65
+ # CI-friendly exit codes
66
+ sooth --source policy.md --text draft.md --format plain
67
+ # 0 = clean · 1 = any FAIL · 2 = any REVIEW · 3 = usage/config error
68
+
69
+ # Options
70
+ # --confidence T REVIEW below this confidence (default 0.7)
71
+ # --format md|plain
72
+ # -o FILE write report to file
73
+ # --log FILE append full judgment trace (one JSONL line per run)
74
+ ```
75
+
76
+ ## How it works
77
+
78
+ 1. Draft is split into claims (one sentence each).
79
+ 2. Each claim gets three questions to Jev, all fanned out in parallel batches: *is this checkable?*, *does the source support it?* (`supports` / `contradicts` / `not_found`), and *do all details match exactly?*
80
+ 3. Verdicts are mapped in code: uncheckable → `UNCHECKABLE`; low confidence → `REVIEW`; then `PASS` / `FAIL`. Safeguards demote `PASS` to `REVIEW` when details drift or claim numbers are absent from the source (checked in plain code).
81
+ 4. The report shows the full probability distribution per claim — not just a label.
82
+
83
+ The decision logic lives in [`src/sooth/verify.py`](src/sooth/verify.py) in a dozen readable lines. Change thresholds and rules there, not in prompts.
84
+
85
+ ## Known limits (alpha)
86
+
87
+ - Safeguards catch most drift and smuggling: claim numbers absent from the source demote `PASS` to `REVIEW`, and a detail-match gate flags altered hedges ("about 74%" → "over 74%"). Not perfect — read the `Why` column before trusting a verdict.
88
+ - Derived numbers (totals, values computed outside the source) look "missing" and land in `REVIEW`.
89
+ - Source + questions must fit ~64k tokens — split long documents yourself.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ pip install -e ".[dev]"
95
+ python3 tests/test_core.py # pure checks, no network
96
+ pytest # same suite
97
+ bash tests/smoke.sh # live smoke (needs TYPESAFE_API_KEY)
98
+ PYTHONPATH=src python3 tests/calibrate.py # live calibration, 30 labeled claims
99
+ ```
100
+
101
+ Docs: [PRD](docs/PRD.md) · [Design](docs/DESIGN.md) · [Testing](docs/TESTING.md)
102
+
103
+ ## Roadmap
104
+
105
+ - v0.2 shipped: source-span evidence (the exact source line behind each verdict, shown in the report)
106
+ - later: hosted web app — paste UI, history, team review queues
107
+
108
+ ## License
109
+
110
+ MIT © Naufal Hilmiaji. Powered by [TypeSafe](https://typesafe.ai) / Jev.
@@ -0,0 +1,10 @@
1
+ sooth/__init__.py,sha256=hEgypQMjarNxUCn3oVY09t_5na41QwCJMsCvb9jIaGY,492
2
+ sooth/claims.py,sha256=Utqha_gOp0HvPB0iZO667ii2YLyHtqBpGcjpUfcJ6Fo,1987
3
+ sooth/cli.py,sha256=7qgEdNVDKilkC7w7BBYJJCsBNY890psu4jw7OhijSSQ,2969
4
+ sooth/report.py,sha256=xpZp1694dDSamqgdtcrOgfUq9fcMNOyQ2ZGR-lK9pUo,4362
5
+ sooth/verify.py,sha256=FTZuhWAi34mIuDq4n5ozwLIO1xoSj3Ts5qoZmPzyvQo,10511
6
+ sooth-0.2.1.dist-info/METADATA,sha256=Vv-DulbG13G31JGWVAOc0oYmcCX1mYcsM30bL3UC-jw,5273
7
+ sooth-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ sooth-0.2.1.dist-info/entry_points.txt,sha256=Fw9Hmw3hgbtOjKZSuZFbDeQR3JGIGqoFHxjm5XJgGgc,41
9
+ sooth-0.2.1.dist-info/licenses/LICENSE,sha256=9-6ggiqSH88kkwEsXzI7bjpJ_eIO8wUNZ7o-1fejOhs,1072
10
+ sooth-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sooth = sooth.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Naufal Hilmiaji
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.