docfriction 0.1.0__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.
- docfriction/__init__.py +29 -0
- docfriction/_version.py +1 -0
- docfriction/checks.py +152 -0
- docfriction/cli.py +106 -0
- docfriction/evaluate.py +177 -0
- docfriction/fetch.py +124 -0
- docfriction/jev.py +196 -0
- docfriction/models.py +130 -0
- docfriction/py.typed +0 -0
- docfriction/report.py +123 -0
- docfriction/rubric.py +154 -0
- docfriction/segment.py +175 -0
- docfriction-0.1.0.dist-info/METADATA +186 -0
- docfriction-0.1.0.dist-info/RECORD +17 -0
- docfriction-0.1.0.dist-info/WHEEL +4 -0
- docfriction-0.1.0.dist-info/entry_points.txt +2 -0
- docfriction-0.1.0.dist-info/licenses/LICENSE +21 -0
docfriction/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""docfriction: automated friction logs for online documentation, scored with TypeSafe Jev."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .evaluate import EvaluateOptions, Thresholds, evaluate_document
|
|
5
|
+
from .fetch import FetchError, load_document
|
|
6
|
+
from .jev import JevClient, JevError, MissingApiKeyError
|
|
7
|
+
from .models import Document, Finding, FrictionLog, Segment, StepLog
|
|
8
|
+
from .report import render_json, render_markdown
|
|
9
|
+
from .segment import segment_markdown
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Document",
|
|
13
|
+
"EvaluateOptions",
|
|
14
|
+
"FetchError",
|
|
15
|
+
"Finding",
|
|
16
|
+
"FrictionLog",
|
|
17
|
+
"JevClient",
|
|
18
|
+
"JevError",
|
|
19
|
+
"MissingApiKeyError",
|
|
20
|
+
"Segment",
|
|
21
|
+
"StepLog",
|
|
22
|
+
"Thresholds",
|
|
23
|
+
"__version__",
|
|
24
|
+
"evaluate_document",
|
|
25
|
+
"load_document",
|
|
26
|
+
"render_json",
|
|
27
|
+
"render_markdown",
|
|
28
|
+
"segment_markdown",
|
|
29
|
+
]
|
docfriction/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
docfriction/checks.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Deterministic checks that need no model. Jev is weak at counting and pattern
|
|
2
|
+
matching, so anything a regex or an HTTP request can answer is done here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ipaddress
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .models import STATIC_SOURCE, Finding, Segment
|
|
15
|
+
|
|
16
|
+
PLACEHOLDER_RE = re.compile(
|
|
17
|
+
r"YOUR_[A-Z0-9_]+"
|
|
18
|
+
r"|\{\{[^}]+\}\}"
|
|
19
|
+
r"|\bREPLACE[_ -]?ME\b"
|
|
20
|
+
r"|\bCHANGE[_ -]?ME\b"
|
|
21
|
+
r"|\bxxx+\b"
|
|
22
|
+
r"|\bTODO\b"
|
|
23
|
+
)
|
|
24
|
+
ANGLE_PLACEHOLDER_RE = re.compile(r"<[A-Za-z][\w-]*(?:[_-][\w-]+)*>")
|
|
25
|
+
MARKUP_LANGUAGES = frozenset({"html", "xml", "jsx", "tsx", "vue", "svelte", "xhtml", "svg"})
|
|
26
|
+
MIN_PROSE_CHARS = 40
|
|
27
|
+
DEAD_LINK_RETRY_STATUSES = frozenset({400, 403, 405})
|
|
28
|
+
DEFAULT_LINK_TIMEOUT_SECONDS = 10.0
|
|
29
|
+
ALLOWED_LINK_SCHEMES = frozenset({"http", "https"})
|
|
30
|
+
USER_AGENT = f"docfriction/{__version__} (+https://github.com/Cyvid7-Darus10/docfriction)"
|
|
31
|
+
|
|
32
|
+
Resolver = Callable[[str], tuple[str, ...]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BlockedHostError(httpx.RequestError):
|
|
36
|
+
"""Raised by the link client when a URL points at a private or internal address."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_placeholders(segment: Segment) -> tuple[str, ...]:
|
|
40
|
+
found: list[str] = []
|
|
41
|
+
for block in segment.code_blocks:
|
|
42
|
+
found.extend(PLACEHOLDER_RE.findall(block.content))
|
|
43
|
+
if block.language not in MARKUP_LANGUAGES:
|
|
44
|
+
found.extend(ANGLE_PLACEHOLDER_RE.findall(block.content))
|
|
45
|
+
return tuple(sorted(set(found)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def static_findings(segment: Segment) -> tuple[Finding, ...]:
|
|
49
|
+
findings: list[Finding] = []
|
|
50
|
+
untagged = sum(1 for block in segment.code_blocks if not block.language)
|
|
51
|
+
if untagged:
|
|
52
|
+
findings.append(
|
|
53
|
+
Finding(
|
|
54
|
+
check="untagged_code_block",
|
|
55
|
+
source=STATIC_SOURCE,
|
|
56
|
+
detail=f"{untagged} code block(s) have no language tag, so readers cannot "
|
|
57
|
+
"tell shell from config or output",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
if not segment.has_code and len(segment.prose) < MIN_PROSE_CHARS:
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(
|
|
63
|
+
check="stub_section",
|
|
64
|
+
source=STATIC_SOURCE,
|
|
65
|
+
detail="The heading has almost no content under it",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return tuple(findings)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ip_literal(host: str) -> tuple[str, ...]:
|
|
72
|
+
"""The host itself if it is an IP address (IPv6 brackets stripped), else empty."""
|
|
73
|
+
try:
|
|
74
|
+
ipaddress.ip_address(host.strip("[]"))
|
|
75
|
+
except ValueError:
|
|
76
|
+
return ()
|
|
77
|
+
return (host.strip("[]"),)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_host(host: str) -> tuple[str, ...]:
|
|
81
|
+
"""All addresses a hostname resolves to via DNS."""
|
|
82
|
+
try:
|
|
83
|
+
infos = socket.getaddrinfo(host, None)
|
|
84
|
+
except socket.gaierror as exc:
|
|
85
|
+
raise BlockedHostError(f"{host} does not resolve") from exc
|
|
86
|
+
return tuple(dict.fromkeys(str(info[4][0]) for info in infos))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_public_address(address: str) -> bool:
|
|
90
|
+
parsed = ipaddress.ip_address(address)
|
|
91
|
+
return not (
|
|
92
|
+
parsed.is_private
|
|
93
|
+
or parsed.is_loopback
|
|
94
|
+
or parsed.is_link_local
|
|
95
|
+
or parsed.is_multicast
|
|
96
|
+
or parsed.is_reserved
|
|
97
|
+
or parsed.is_unspecified
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def link_client(
|
|
102
|
+
*,
|
|
103
|
+
transport: httpx.BaseTransport | None = None,
|
|
104
|
+
timeout: float = DEFAULT_LINK_TIMEOUT_SECONDS,
|
|
105
|
+
allow_private_hosts: bool = False,
|
|
106
|
+
resolver: Resolver = resolve_host,
|
|
107
|
+
) -> httpx.Client:
|
|
108
|
+
"""One shared client for link checks. The request hook runs on every redirect hop,
|
|
109
|
+
so a public URL cannot bounce the checker into a private network."""
|
|
110
|
+
|
|
111
|
+
def guard(request: httpx.Request) -> None:
|
|
112
|
+
if request.url.scheme not in ALLOWED_LINK_SCHEMES:
|
|
113
|
+
raise BlockedHostError(f"{request.url} uses an unsupported scheme")
|
|
114
|
+
if allow_private_hosts:
|
|
115
|
+
return
|
|
116
|
+
host = request.url.host
|
|
117
|
+
addresses = ip_literal(host) or resolver(host)
|
|
118
|
+
if not all(is_public_address(address) for address in addresses):
|
|
119
|
+
raise BlockedHostError(f"{request.url} points at a private or internal address")
|
|
120
|
+
|
|
121
|
+
return httpx.Client(
|
|
122
|
+
follow_redirects=True,
|
|
123
|
+
headers={"User-Agent": USER_AGENT},
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
transport=transport,
|
|
126
|
+
event_hooks={"request": [guard]},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_links(urls: tuple[str, ...], client: httpx.Client) -> tuple[Finding, ...]:
|
|
131
|
+
results = (_check_link(client, url) for url in urls)
|
|
132
|
+
return tuple(finding for finding in results if finding is not None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_link(client: httpx.Client, url: str) -> Finding | None:
|
|
136
|
+
try:
|
|
137
|
+
response = client.head(url)
|
|
138
|
+
if response.status_code in DEAD_LINK_RETRY_STATUSES:
|
|
139
|
+
# Some hosts reject HEAD; fetch headers only, never the body.
|
|
140
|
+
with client.stream("GET", url) as streamed:
|
|
141
|
+
response = streamed
|
|
142
|
+
except BlockedHostError as exc:
|
|
143
|
+
return Finding(check="blocked_link", source=STATIC_SOURCE, detail=str(exc))
|
|
144
|
+
except httpx.HTTPError as exc:
|
|
145
|
+
return _dead_link(f"{url} could not be reached ({type(exc).__name__})")
|
|
146
|
+
if response.status_code >= 400:
|
|
147
|
+
return _dead_link(f"{url} returned HTTP {response.status_code}")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _dead_link(detail: str) -> Finding:
|
|
152
|
+
return Finding(check="dead_link", source=STATIC_SOURCE, detail=detail)
|
docfriction/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Command-line entry point: docfriction <url-or-file> [options]."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .evaluate import EvaluateOptions, evaluate_document
|
|
11
|
+
from .fetch import FetchError, load_document
|
|
12
|
+
from .jev import DEFAULT_MODEL, JevClient, JevError
|
|
13
|
+
from .report import render_json, render_markdown
|
|
14
|
+
from .segment import segment_markdown
|
|
15
|
+
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_ERROR = 1
|
|
18
|
+
EXIT_THRESHOLD = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="docfriction",
|
|
24
|
+
description="Write a friction log for a documentation page, scored step by step with Jev.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("source", help="URL of a docs page, or a local .md/.html file")
|
|
27
|
+
parser.add_argument("-o", "--out", type=Path, help="write the report here instead of stdout")
|
|
28
|
+
parser.add_argument("-f", "--format", choices=("md", "json"), default="md")
|
|
29
|
+
parser.add_argument("--model", default=DEFAULT_MODEL, help="Jev model id (default: jev-latest)")
|
|
30
|
+
parser.add_argument("--check-links", action="store_true", help="also HEAD every external link")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--allow-private-links",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="let --check-links request private, loopback, and link-local addresses",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--max-sections", type=int, help="only evaluate the first N sections")
|
|
37
|
+
parser.add_argument("--concurrency", type=int, default=4, help="parallel Jev calls")
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--fail-on-severity",
|
|
40
|
+
type=float,
|
|
41
|
+
metavar="N",
|
|
42
|
+
help="exit 2 if any step's severity is at least N on the 0-3 scale",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--dry-run",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="print the detected steps and exit without calling Jev (no API key needed)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("--version", action="version", version=f"docfriction {__version__}")
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: list[str] | None = None) -> int:
|
|
54
|
+
args = build_parser().parse_args(argv)
|
|
55
|
+
try:
|
|
56
|
+
document = load_document(args.source)
|
|
57
|
+
except FetchError as exc:
|
|
58
|
+
return _fail(str(exc))
|
|
59
|
+
if args.dry_run:
|
|
60
|
+
return _dry_run(document.markdown, document.title, args.max_sections)
|
|
61
|
+
options = EvaluateOptions(
|
|
62
|
+
check_links=args.check_links,
|
|
63
|
+
allow_private_links=args.allow_private_links,
|
|
64
|
+
max_sections=args.max_sections,
|
|
65
|
+
concurrency=args.concurrency,
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
with JevClient(model=args.model) as client:
|
|
69
|
+
log = evaluate_document(document, client, options)
|
|
70
|
+
except JevError as exc:
|
|
71
|
+
return _fail(str(exc))
|
|
72
|
+
report = render_json(log) if args.format == "json" else render_markdown(log)
|
|
73
|
+
_emit(report, args.out)
|
|
74
|
+
if args.fail_on_severity is not None and log.max_severity >= args.fail_on_severity:
|
|
75
|
+
print(
|
|
76
|
+
f"docfriction: max severity {log.max_severity:.2f} >= {args.fail_on_severity}",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return EXIT_THRESHOLD
|
|
80
|
+
return EXIT_OK
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _dry_run(markdown: str, title: str, max_sections: int | None) -> int:
|
|
84
|
+
segments = segment_markdown(markdown)[:max_sections]
|
|
85
|
+
print(f"{title}: {len(segments)} step(s)")
|
|
86
|
+
for segment in segments:
|
|
87
|
+
code = f", {len(segment.code_blocks)} code block(s)" if segment.has_code else ""
|
|
88
|
+
print(f" {segment.index + 1}. {segment.title} ({len(segment.prose)} chars{code})")
|
|
89
|
+
return EXIT_OK
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _emit(report: str, out: Path | None) -> None:
|
|
93
|
+
if out is None:
|
|
94
|
+
sys.stdout.write(report)
|
|
95
|
+
return
|
|
96
|
+
out.write_text(report, encoding="utf-8")
|
|
97
|
+
print(f"docfriction: wrote {out}", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fail(message: str) -> int:
|
|
101
|
+
print(f"docfriction: {message}", file=sys.stderr)
|
|
102
|
+
return EXIT_ERROR
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main())
|
docfriction/evaluate.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Walk a document step by step, ask Jev about each step, and assemble a friction log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .checks import check_links, link_client, static_findings
|
|
13
|
+
from .jev import JevClient
|
|
14
|
+
from .models import JEV_SOURCE, Answer, Document, Finding, FrictionLog, Segment, StepLog
|
|
15
|
+
from .rubric import (
|
|
16
|
+
ACTIONABLE_ONLY,
|
|
17
|
+
FRICTION_TYPES,
|
|
18
|
+
IS_ACTIONABLE,
|
|
19
|
+
NO_FRICTION,
|
|
20
|
+
NOUL_FAILURE_DETAIL,
|
|
21
|
+
build_questions,
|
|
22
|
+
build_state,
|
|
23
|
+
)
|
|
24
|
+
from .segment import segment_markdown
|
|
25
|
+
|
|
26
|
+
SENTIMENT_BANDS: tuple[tuple[str, float], ...] = (
|
|
27
|
+
("smooth", 0.75),
|
|
28
|
+
("pause", 1.5),
|
|
29
|
+
("frustrated", 2.25),
|
|
30
|
+
)
|
|
31
|
+
BLOCKED = "blocked"
|
|
32
|
+
UNKNOWN_SENTIMENT = "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Thresholds:
|
|
37
|
+
"""Where probabilities turn into findings. Tune per docs set; these are starting points."""
|
|
38
|
+
|
|
39
|
+
friction_min_probability: float = 0.5
|
|
40
|
+
noul_fail_below: float = 0.4
|
|
41
|
+
noul_review_below: float = 0.6
|
|
42
|
+
actionable_min: float = 0.5
|
|
43
|
+
low_confidence: float = 0.4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class EvaluateOptions:
|
|
48
|
+
thresholds: Thresholds = field(default_factory=Thresholds)
|
|
49
|
+
check_links: bool = False
|
|
50
|
+
allow_private_links: bool = False
|
|
51
|
+
max_sections: int | None = None
|
|
52
|
+
concurrency: int = 4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def evaluate_document(
|
|
56
|
+
document: Document,
|
|
57
|
+
client: JevClient,
|
|
58
|
+
options: EvaluateOptions | None = None,
|
|
59
|
+
*,
|
|
60
|
+
link_transport: httpx.BaseTransport | None = None,
|
|
61
|
+
) -> FrictionLog:
|
|
62
|
+
opts = options or EvaluateOptions()
|
|
63
|
+
segments = segment_markdown(document.markdown)[: opts.max_sections]
|
|
64
|
+
links = (
|
|
65
|
+
link_client(transport=link_transport, allow_private_hosts=opts.allow_private_links)
|
|
66
|
+
if opts.check_links
|
|
67
|
+
else None
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def run(index: int) -> StepLog:
|
|
71
|
+
previous = segments[index - 1] if index else None
|
|
72
|
+
return evaluate_segment(segments[index], previous, document.title, client, opts, links)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with ThreadPoolExecutor(max_workers=max(1, opts.concurrency)) as pool:
|
|
76
|
+
steps = tuple(pool.map(run, range(len(segments))))
|
|
77
|
+
finally:
|
|
78
|
+
if links is not None:
|
|
79
|
+
links.close()
|
|
80
|
+
return FrictionLog(
|
|
81
|
+
source=document.source,
|
|
82
|
+
title=document.title,
|
|
83
|
+
model=_model_name(steps, client.model),
|
|
84
|
+
generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
85
|
+
steps=steps,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def evaluate_segment(
|
|
90
|
+
segment: Segment,
|
|
91
|
+
previous: Segment | None,
|
|
92
|
+
page_title: str,
|
|
93
|
+
client: JevClient,
|
|
94
|
+
options: EvaluateOptions,
|
|
95
|
+
links: httpx.Client | None = None,
|
|
96
|
+
) -> StepLog:
|
|
97
|
+
result = client.evaluate(build_state(segment, previous, page_title), build_questions(segment))
|
|
98
|
+
severity, jev_findings = interpret_answers(result.answers, options.thresholds)
|
|
99
|
+
link_findings = check_links(segment.links, links) if links is not None else ()
|
|
100
|
+
return StepLog(
|
|
101
|
+
segment=segment,
|
|
102
|
+
severity=severity,
|
|
103
|
+
sentiment=sentiment_for(severity),
|
|
104
|
+
findings=(*jev_findings, *static_findings(segment), *link_findings),
|
|
105
|
+
input_tokens=result.input_tokens,
|
|
106
|
+
output_tokens=result.output_tokens,
|
|
107
|
+
model=result.model,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def interpret_answers(
|
|
112
|
+
answers: Mapping[str, Answer], thresholds: Thresholds
|
|
113
|
+
) -> tuple[float | None, tuple[Finding, ...]]:
|
|
114
|
+
severity_answer = answers.get("severity")
|
|
115
|
+
severity = float(severity_answer.value) if severity_answer else None
|
|
116
|
+
actionable = answers.get(IS_ACTIONABLE)
|
|
117
|
+
is_actionable = actionable is None or float(actionable.value) >= thresholds.actionable_min
|
|
118
|
+
findings = [
|
|
119
|
+
*_friction_type_findings(answers.get("friction_type"), thresholds),
|
|
120
|
+
*(
|
|
121
|
+
finding
|
|
122
|
+
for key, answer in answers.items()
|
|
123
|
+
if answer.type == "noul" and key in NOUL_FAILURE_DETAIL
|
|
124
|
+
if is_actionable or key not in ACTIONABLE_ONLY
|
|
125
|
+
for finding in _noul_findings(key, answer, thresholds)
|
|
126
|
+
),
|
|
127
|
+
]
|
|
128
|
+
return severity, tuple(findings)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sentiment_for(severity: float | None) -> str:
|
|
132
|
+
if severity is None:
|
|
133
|
+
return UNKNOWN_SENTIMENT
|
|
134
|
+
for name, upper in SENTIMENT_BANDS:
|
|
135
|
+
if severity < upper:
|
|
136
|
+
return name
|
|
137
|
+
return BLOCKED
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _friction_type_findings(answer: Answer | None, thresholds: Thresholds) -> tuple[Finding, ...]:
|
|
141
|
+
if answer is None or answer.value == NO_FRICTION:
|
|
142
|
+
return ()
|
|
143
|
+
probability = answer.probability or 0.0
|
|
144
|
+
if probability < thresholds.friction_min_probability:
|
|
145
|
+
return ()
|
|
146
|
+
confidence = answer.confidence
|
|
147
|
+
return (
|
|
148
|
+
Finding(
|
|
149
|
+
check=str(answer.value),
|
|
150
|
+
source=JEV_SOURCE,
|
|
151
|
+
detail=FRICTION_TYPES.get(str(answer.value), str(answer.value)),
|
|
152
|
+
probability=probability,
|
|
153
|
+
confidence=confidence,
|
|
154
|
+
needs_review=confidence is not None and confidence < thresholds.low_confidence,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _noul_findings(key: str, answer: Answer, thresholds: Thresholds) -> tuple[Finding, ...]:
|
|
160
|
+
probability = float(answer.value)
|
|
161
|
+
if probability >= thresholds.noul_review_below:
|
|
162
|
+
return ()
|
|
163
|
+
return (
|
|
164
|
+
Finding(
|
|
165
|
+
check=key,
|
|
166
|
+
source=JEV_SOURCE,
|
|
167
|
+
detail=NOUL_FAILURE_DETAIL[key],
|
|
168
|
+
probability=probability,
|
|
169
|
+
confidence=answer.confidence,
|
|
170
|
+
needs_review=probability >= thresholds.noul_fail_below,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _model_name(steps: tuple[StepLog, ...], fallback: str) -> str:
|
|
176
|
+
"""Prefer the resolved model id from the API (e.g. jev-1.13.0) over the alias."""
|
|
177
|
+
return next((step.model for step in steps if step.model), fallback)
|
docfriction/fetch.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Load a documentation page from a URL or a local file and normalise it to Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from bs4 import BeautifulSoup, Tag
|
|
9
|
+
from markdownify import markdownify
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .models import Document
|
|
13
|
+
from .segment import document_title
|
|
14
|
+
|
|
15
|
+
# Most specific first: on GitHub `article.markdown-body` is the README while `main`
|
|
16
|
+
# also wraps the file browser; on Sphinx sites `[role=main]` excludes the sidebar.
|
|
17
|
+
CONTENT_SELECTORS = ("article", ".markdown-body", "[role=main]", "main", "#content", ".content")
|
|
18
|
+
STRIP_TAGS = ("script", "style", "nav", "header", "footer", "aside", "noscript", "svg", "form")
|
|
19
|
+
HTML_SUFFIXES = frozenset({".html", ".htm"})
|
|
20
|
+
LANGUAGE_CLASS_PREFIXES = ("language-", "lang-")
|
|
21
|
+
USER_AGENT = f"docfriction/{__version__} (+https://github.com/Cyvid7-Darus10/docfriction)"
|
|
22
|
+
ACCEPT = "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
|
|
23
|
+
DEFAULT_TIMEOUT_SECONDS = 20.0
|
|
24
|
+
MAX_PAGE_BYTES = 5 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FetchError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_document(
|
|
32
|
+
source: str,
|
|
33
|
+
*,
|
|
34
|
+
transport: httpx.BaseTransport | None = None,
|
|
35
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
36
|
+
) -> Document:
|
|
37
|
+
if source.startswith(("http://", "https://")):
|
|
38
|
+
return _fetch_url(source, transport=transport, timeout=timeout)
|
|
39
|
+
path = Path(source)
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
raise FetchError(f"source is neither a URL nor an existing file: {source}")
|
|
42
|
+
try:
|
|
43
|
+
text = path.read_text(encoding="utf-8")
|
|
44
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
45
|
+
raise FetchError(f"failed to read {source}: {exc}") from exc
|
|
46
|
+
if path.suffix.lower() in HTML_SUFFIXES:
|
|
47
|
+
return _from_html(source, text, fallback_title=path.name)
|
|
48
|
+
return Document(source=source, title=document_title(text) or path.name, markdown=text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fetch_url(url: str, *, transport: httpx.BaseTransport | None, timeout: float) -> Document:
|
|
52
|
+
headers = {"User-Agent": USER_AGENT, "Accept": ACCEPT}
|
|
53
|
+
try:
|
|
54
|
+
with (
|
|
55
|
+
httpx.Client(
|
|
56
|
+
follow_redirects=True, headers=headers, timeout=timeout, transport=transport
|
|
57
|
+
) as client,
|
|
58
|
+
client.stream("GET", url) as response,
|
|
59
|
+
):
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
content_type = response.headers.get("content-type", "")
|
|
62
|
+
body = _read_capped(response, url)
|
|
63
|
+
except httpx.HTTPError as exc:
|
|
64
|
+
raise FetchError(f"failed to fetch {url}: {exc}") from exc
|
|
65
|
+
if not body.strip():
|
|
66
|
+
raise FetchError(f"{url} returned an empty body")
|
|
67
|
+
if _looks_like_html(content_type, body):
|
|
68
|
+
return _from_html(url, body, fallback_title=url)
|
|
69
|
+
return Document(source=url, title=document_title(body) or url, markdown=body)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_capped(response: httpx.Response, url: str) -> str:
|
|
73
|
+
"""Read at most MAX_PAGE_BYTES so a hostile or huge page cannot exhaust memory."""
|
|
74
|
+
declared = response.headers.get("content-length")
|
|
75
|
+
if declared and declared.isdigit() and int(declared) > MAX_PAGE_BYTES:
|
|
76
|
+
raise FetchError(f"{url} is larger than {MAX_PAGE_BYTES} bytes")
|
|
77
|
+
chunks: list[bytes] = []
|
|
78
|
+
total = 0
|
|
79
|
+
for chunk in response.iter_bytes():
|
|
80
|
+
total += len(chunk)
|
|
81
|
+
if total > MAX_PAGE_BYTES:
|
|
82
|
+
raise FetchError(f"{url} is larger than {MAX_PAGE_BYTES} bytes")
|
|
83
|
+
chunks.append(chunk)
|
|
84
|
+
return b"".join(chunks).decode(response.charset_encoding or "utf-8", errors="replace")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _looks_like_html(content_type: str, body: str) -> bool:
|
|
88
|
+
head = body.lstrip()[:20].lower()
|
|
89
|
+
return "html" in content_type or head.startswith(("<!doctype", "<html"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _from_html(source: str, html: str, *, fallback_title: str) -> Document:
|
|
93
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
94
|
+
page_title = soup.title.get_text(strip=True) if soup.title else ""
|
|
95
|
+
for tag in soup(STRIP_TAGS):
|
|
96
|
+
tag.decompose()
|
|
97
|
+
root = _content_root(soup)
|
|
98
|
+
markdown = markdownify(
|
|
99
|
+
str(root), heading_style="ATX", bullets="-", code_language_callback=_code_language
|
|
100
|
+
).strip()
|
|
101
|
+
if not markdown:
|
|
102
|
+
raise FetchError(f"no readable content found in {source}")
|
|
103
|
+
title = document_title(markdown) or page_title or fallback_title
|
|
104
|
+
return Document(source=source, title=title, markdown=markdown)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _content_root(soup: BeautifulSoup) -> Tag | BeautifulSoup:
|
|
108
|
+
for selector in CONTENT_SELECTORS:
|
|
109
|
+
found = soup.select_one(selector)
|
|
110
|
+
if found is not None and found.get_text(strip=True):
|
|
111
|
+
return found
|
|
112
|
+
return soup.body or soup
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _code_language(element: Tag) -> str:
|
|
116
|
+
classes = list(element.get("class") or [])
|
|
117
|
+
inner = element.find("code")
|
|
118
|
+
if isinstance(inner, Tag):
|
|
119
|
+
classes.extend(inner.get("class") or [])
|
|
120
|
+
for css_class in classes:
|
|
121
|
+
for prefix in LANGUAGE_CLASS_PREFIXES:
|
|
122
|
+
if css_class.startswith(prefix):
|
|
123
|
+
return css_class[len(prefix) :]
|
|
124
|
+
return ""
|