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 +21 -0
- sooth/claims.py +59 -0
- sooth/cli.py +78 -0
- sooth/report.py +125 -0
- sooth/verify.py +266 -0
- sooth-0.2.1.dist-info/METADATA +110 -0
- sooth-0.2.1.dist-info/RECORD +10 -0
- sooth-0.2.1.dist-info/WHEEL +4 -0
- sooth-0.2.1.dist-info/entry_points.txt +2 -0
- sooth-0.2.1.dist-info/licenses/LICENSE +21 -0
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,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.
|