agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/outlines.py
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Bounded outline rendering through Agent2Learn's dedicated local CDP profile.
|
|
2
|
+
|
|
3
|
+
The production factory may acquire or launch only the persistent Agent2Learn browser profile; it
|
|
4
|
+
never reads or attaches to an everyday profile. Every outline gets a fresh page target and CDP
|
|
5
|
+
connection. Top-level and subresource URLs are checked at the request boundary, and a blocked
|
|
6
|
+
dependency makes the outline unavailable instead of producing a false "no policy" conclusion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from hashlib import sha256
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Protocol, cast
|
|
21
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
22
|
+
|
|
23
|
+
from agent2learn import aipolicy, clock, paths, transactions
|
|
24
|
+
from agent2learn import index as course_index
|
|
25
|
+
from agent2learn.errors import AuthenticationError
|
|
26
|
+
from agent2learn.ingest import (
|
|
27
|
+
CourseMetadata,
|
|
28
|
+
MetadataReport,
|
|
29
|
+
OutlineReport,
|
|
30
|
+
TopicRecord,
|
|
31
|
+
_content_directory,
|
|
32
|
+
_sanitize_richtext,
|
|
33
|
+
_topic_from_row,
|
|
34
|
+
_write_content_map,
|
|
35
|
+
_write_index,
|
|
36
|
+
)
|
|
37
|
+
from agent2learn.schools import School
|
|
38
|
+
from agent2learn.vault import DerivedArtifact, ManifestEntry, Vault
|
|
39
|
+
|
|
40
|
+
OUTLINE_TIMEOUT_SECONDS = 30.0
|
|
41
|
+
OUTLINE_TOOL_VERSION = "1"
|
|
42
|
+
_OUTLINE_WORDS = re.compile(r"\b(?:outline|syllabus|course\s+schedule)\b", re.IGNORECASE)
|
|
43
|
+
_LOGIN_MARKERS = (
|
|
44
|
+
re.compile(r"<title[^>]*>\s*(?:sign\s*in|log\s*in|login)", re.IGNORECASE),
|
|
45
|
+
re.compile(r"<form[^>]+(?:action|id|class)=[^>]*(?:login|signin|d2l)", re.IGNORECASE),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class OutlinePage:
|
|
51
|
+
"""A rendered DOM and the request audit returned by a CDP transport."""
|
|
52
|
+
|
|
53
|
+
html: str
|
|
54
|
+
canonical_url: str
|
|
55
|
+
pdf: bytes | None = None
|
|
56
|
+
subresources: tuple[str, ...] = ()
|
|
57
|
+
top_level_requests: tuple[str, ...] = ()
|
|
58
|
+
popups: tuple[str, ...] = ()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OutlineBrowser(Protocol):
|
|
62
|
+
"""The narrow one-target browser capability required by :func:`ingest_outlines`."""
|
|
63
|
+
|
|
64
|
+
def render_outline(
|
|
65
|
+
self,
|
|
66
|
+
url: str,
|
|
67
|
+
*,
|
|
68
|
+
allowed_hosts: Sequence[str],
|
|
69
|
+
timeout: float,
|
|
70
|
+
) -> OutlinePage | Mapping[str, object]:
|
|
71
|
+
"""Render one target and return the final DOM plus its request audit."""
|
|
72
|
+
|
|
73
|
+
def close_target(self) -> None:
|
|
74
|
+
"""Close this target and its page connection before another target is opened."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OutlineBrowserFactory(Protocol):
|
|
78
|
+
"""Create a new one-target browser connection for every discovered outline."""
|
|
79
|
+
|
|
80
|
+
def open_browser(self) -> OutlineBrowser:
|
|
81
|
+
"""Return a browser backed by a newly-created page target."""
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
"""Release factory-owned dedicated-profile resources after all targets are closed."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ingest_outlines(
|
|
88
|
+
factory: OutlineBrowserFactory,
|
|
89
|
+
vault: Vault,
|
|
90
|
+
school: School,
|
|
91
|
+
metadata: MetadataReport,
|
|
92
|
+
) -> OutlineReport:
|
|
93
|
+
"""Render discovered outlines after metadata, using a fresh target for every attempt."""
|
|
94
|
+
|
|
95
|
+
if not callable(getattr(factory, "open_browser", None)) or not callable(
|
|
96
|
+
getattr(factory, "close", None)
|
|
97
|
+
):
|
|
98
|
+
raise TypeError("ingest_outlines requires an OutlineBrowserFactory")
|
|
99
|
+
rendered = 0
|
|
100
|
+
unavailable = 0
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
stop_after_cleanup_failure = False
|
|
103
|
+
try:
|
|
104
|
+
for course_metadata in metadata.courses:
|
|
105
|
+
status_rows: list[dict[str, object]] = []
|
|
106
|
+
for topic in _outline_topics(course_metadata.topics):
|
|
107
|
+
if stop_after_cleanup_failure:
|
|
108
|
+
unavailable += 1
|
|
109
|
+
status_rows.append(
|
|
110
|
+
_status(
|
|
111
|
+
topic,
|
|
112
|
+
"outline_unavailable",
|
|
113
|
+
"previous target cleanup failed",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
url = _topic_outline_url(topic, school)
|
|
119
|
+
if url is None:
|
|
120
|
+
unavailable += 1
|
|
121
|
+
status_rows.append(_status(topic, "outline_unavailable", "unsafe URL"))
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
target: OutlineBrowser = factory.open_browser()
|
|
126
|
+
except AuthenticationError as exc:
|
|
127
|
+
# The dedicated profile is deliberately not enabled; that is a declined
|
|
128
|
+
# capability, not a broken renderer.
|
|
129
|
+
unavailable += 1
|
|
130
|
+
status_rows.append(_status(topic, "outline_unavailable", type(exc).__name__))
|
|
131
|
+
continue
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
unavailable += 1
|
|
134
|
+
errors.append(f"outline: browser target failed ({type(exc).__name__})")
|
|
135
|
+
status_rows.append(_status(topic, "outline_unavailable", type(exc).__name__))
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
status: dict[str, object]
|
|
139
|
+
install_error: str | None = None
|
|
140
|
+
try:
|
|
141
|
+
try:
|
|
142
|
+
page = _render(target, url, school)
|
|
143
|
+
page = _validate_page(page, school)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
# A remote render or validation failure is a per-topic coverage gap,
|
|
146
|
+
# not evidence that the local vault or renderer is broken.
|
|
147
|
+
unavailable += 1
|
|
148
|
+
status = _status(topic, "outline_unavailable", type(exc).__name__)
|
|
149
|
+
else:
|
|
150
|
+
try:
|
|
151
|
+
source_path, markdown_path = _install_outline(
|
|
152
|
+
vault, school, course_metadata, topic, page
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
# The outline rendered but could not be persisted locally; the
|
|
156
|
+
# vault write failure must fail the sync, not hide as a gap.
|
|
157
|
+
unavailable += 1
|
|
158
|
+
install_error = type(exc).__name__
|
|
159
|
+
status = _status(topic, "outline_unavailable", type(exc).__name__)
|
|
160
|
+
else:
|
|
161
|
+
rendered += 1
|
|
162
|
+
status = {
|
|
163
|
+
"source_key": topic.source_key,
|
|
164
|
+
"url": page.canonical_url,
|
|
165
|
+
"status": "rendered",
|
|
166
|
+
"source_path": source_path,
|
|
167
|
+
"path": markdown_path,
|
|
168
|
+
}
|
|
169
|
+
finally:
|
|
170
|
+
close_error = _close_target(target)
|
|
171
|
+
|
|
172
|
+
if install_error is not None:
|
|
173
|
+
errors.append(f"outline: local install failed ({install_error})")
|
|
174
|
+
|
|
175
|
+
if close_error is not None:
|
|
176
|
+
errors.append(f"outline: target cleanup failed ({close_error})")
|
|
177
|
+
status["cleanup_error"] = "target could not be closed"
|
|
178
|
+
stop_after_cleanup_failure = True
|
|
179
|
+
status_rows.append(status)
|
|
180
|
+
|
|
181
|
+
_write_json(
|
|
182
|
+
course_metadata.directory / "_meta" / "outlines.json",
|
|
183
|
+
status_rows,
|
|
184
|
+
root=vault.root,
|
|
185
|
+
)
|
|
186
|
+
rendered_paths = [
|
|
187
|
+
vault.root / path
|
|
188
|
+
for row in status_rows
|
|
189
|
+
if row.get("status") == "rendered" and isinstance((path := row.get("path")), str)
|
|
190
|
+
]
|
|
191
|
+
aipolicy.surface_course_ai_policy(
|
|
192
|
+
course_metadata.directory, rendered_paths, root=vault.root
|
|
193
|
+
)
|
|
194
|
+
finally:
|
|
195
|
+
factory_error = _close_factory(factory)
|
|
196
|
+
|
|
197
|
+
if factory_error is not None:
|
|
198
|
+
errors.append(f"outline: browser cleanup failed ({factory_error})")
|
|
199
|
+
return OutlineReport(rendered=rendered, unavailable=unavailable, errors=tuple(errors))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _close_factory(factory: OutlineBrowserFactory) -> str | None:
|
|
203
|
+
try:
|
|
204
|
+
factory.close()
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
return type(exc).__name__
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _outline_topics(topics: Sequence[TopicRecord]) -> list[TopicRecord]:
|
|
211
|
+
return sorted(
|
|
212
|
+
(
|
|
213
|
+
topic
|
|
214
|
+
for topic in topics
|
|
215
|
+
if topic.availability != "external_link"
|
|
216
|
+
and (_OUTLINE_WORDS.search(topic.title) or topic.outline_url)
|
|
217
|
+
),
|
|
218
|
+
key=lambda topic: topic.source_key,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _topic_outline_url(topic: TopicRecord, school: School) -> str | None:
|
|
223
|
+
if topic.outline_url:
|
|
224
|
+
return _normalize_allowed_url(topic.outline_url, school)
|
|
225
|
+
if topic.url_path:
|
|
226
|
+
return _normalize_allowed_url(
|
|
227
|
+
f"{school.base_url.rstrip('/')}/{topic.url_path.lstrip('/')}", school
|
|
228
|
+
)
|
|
229
|
+
return _normalize_allowed_url(topic.view_url, school)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _normalize_allowed_url(value: str, school: School) -> str | None:
|
|
233
|
+
try:
|
|
234
|
+
parsed = urlsplit(value)
|
|
235
|
+
if (
|
|
236
|
+
parsed.scheme.casefold() != "https"
|
|
237
|
+
or parsed.username is not None
|
|
238
|
+
or parsed.password is not None
|
|
239
|
+
or parsed.hostname is None
|
|
240
|
+
):
|
|
241
|
+
return None
|
|
242
|
+
if not _host_allowed(value, school):
|
|
243
|
+
return None
|
|
244
|
+
host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
|
|
245
|
+
port = parsed.port
|
|
246
|
+
netloc = host if port is None else f"{host}:{port}"
|
|
247
|
+
return urlunsplit(("https", netloc, parsed.path or "/", "", ""))
|
|
248
|
+
except (TypeError, UnicodeError, ValueError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _host_allowed(value: str, school: School) -> bool:
|
|
253
|
+
return _allowed_host_from_list(value, (school.base_url, *school.outline_hosts()))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _render(browser: OutlineBrowser, url: str, school: School) -> OutlinePage:
|
|
257
|
+
result = browser.render_outline(
|
|
258
|
+
url,
|
|
259
|
+
allowed_hosts=(school.base_url, *school.outline_hosts()),
|
|
260
|
+
timeout=OUTLINE_TIMEOUT_SECONDS,
|
|
261
|
+
)
|
|
262
|
+
return _coerce_page(result)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _coerce_page(value: OutlinePage | Mapping[str, object]) -> OutlinePage:
|
|
266
|
+
if isinstance(value, OutlinePage):
|
|
267
|
+
return value
|
|
268
|
+
if not isinstance(value, Mapping):
|
|
269
|
+
raise ValueError("browser returned an invalid outline page")
|
|
270
|
+
body = value.get("html")
|
|
271
|
+
canonical_url = value.get("canonical_url", value.get("url"))
|
|
272
|
+
if not isinstance(body, str) or not isinstance(canonical_url, str):
|
|
273
|
+
raise ValueError("browser returned an incomplete outline page")
|
|
274
|
+
pdf = value.get("pdf")
|
|
275
|
+
if pdf is not None and not isinstance(pdf, bytes):
|
|
276
|
+
raise ValueError("browser returned an invalid outline PDF")
|
|
277
|
+
return OutlinePage(
|
|
278
|
+
html=body,
|
|
279
|
+
canonical_url=canonical_url,
|
|
280
|
+
pdf=pdf,
|
|
281
|
+
subresources=_string_tuple(value.get("subresources"), "subresources"),
|
|
282
|
+
top_level_requests=_string_tuple(value.get("top_level_requests"), "top_level_requests"),
|
|
283
|
+
popups=_string_tuple(value.get("popups"), "popups"),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _string_tuple(value: object, label: str) -> tuple[str, ...]:
|
|
288
|
+
if value is None:
|
|
289
|
+
return ()
|
|
290
|
+
if not isinstance(value, (list, tuple)) or any(not isinstance(item, str) for item in value):
|
|
291
|
+
raise ValueError(f"browser returned invalid {label}")
|
|
292
|
+
return tuple(value)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _validate_page(page: OutlinePage, school: School) -> OutlinePage:
|
|
296
|
+
canonical_url = _normalize_allowed_url(page.canonical_url, school)
|
|
297
|
+
if canonical_url is None:
|
|
298
|
+
raise ValueError("outline redirected to an undeclared origin")
|
|
299
|
+
requests = (*page.subresources, *page.top_level_requests)
|
|
300
|
+
if any(_normalize_allowed_url(value, school) is None for value in requests):
|
|
301
|
+
raise ValueError("outline requested an undeclared dependency")
|
|
302
|
+
if page.popups:
|
|
303
|
+
raise ValueError("outline opened a popup")
|
|
304
|
+
if any(marker.search(page.html) for marker in _LOGIN_MARKERS):
|
|
305
|
+
raise ValueError("outline reached the sign-in wall")
|
|
306
|
+
if not page.html.strip() and page.pdf is None:
|
|
307
|
+
raise ValueError("outline render was empty")
|
|
308
|
+
# Persist the normalized URL, not the browser's raw location, because a same-origin redirect
|
|
309
|
+
# can still carry signed query strings or fragments that are not part of the citation.
|
|
310
|
+
return replace(page, canonical_url=canonical_url)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _install_outline(
|
|
314
|
+
vault: Vault,
|
|
315
|
+
school: School,
|
|
316
|
+
metadata: CourseMetadata,
|
|
317
|
+
topic: TopicRecord,
|
|
318
|
+
page: OutlinePage,
|
|
319
|
+
) -> tuple[str, str]:
|
|
320
|
+
transactions.recover_generated(vault, topic.source_key)
|
|
321
|
+
prior = vault.entry(topic.source_key)
|
|
322
|
+
if page.pdf is None:
|
|
323
|
+
# A rendered outline is untrusted course HTML. Persist the inert canonical form, not
|
|
324
|
+
# scripts, event handlers, remote-image loads, credentials, or signed query strings.
|
|
325
|
+
page = replace(page, html=_sanitize_richtext(page.html, school.base_url))
|
|
326
|
+
source_bytes = page.pdf if page.pdf is not None else page.html.encode("utf-8")
|
|
327
|
+
source_hash = sha256(source_bytes).hexdigest()
|
|
328
|
+
source_destination = _source_destination(vault, metadata, topic, prior, page)
|
|
329
|
+
markdown_destination = vault.derived_destination(
|
|
330
|
+
topic.source_key, _markdown_destination(vault, source_destination, prior)
|
|
331
|
+
)
|
|
332
|
+
markdown_bytes = _outline_markdown(topic.title, page)
|
|
333
|
+
markdown_hash = sha256(markdown_bytes).hexdigest()
|
|
334
|
+
if prior is not None and _outline_is_current(
|
|
335
|
+
vault,
|
|
336
|
+
prior,
|
|
337
|
+
source_destination=source_destination,
|
|
338
|
+
source_hash=source_hash,
|
|
339
|
+
markdown_destination=markdown_destination,
|
|
340
|
+
markdown_hash=markdown_hash,
|
|
341
|
+
):
|
|
342
|
+
_update_topic_map(vault, metadata, topic, prior, school)
|
|
343
|
+
artifact = prior.derived["markdown"]
|
|
344
|
+
return prior.path, artifact.path
|
|
345
|
+
|
|
346
|
+
preserve = prior is not None and _outline_needs_preservation(
|
|
347
|
+
vault, prior, source_hash, markdown_hash
|
|
348
|
+
)
|
|
349
|
+
derived = DerivedArtifact(
|
|
350
|
+
path=paths.rel_posix(markdown_destination, vault.root),
|
|
351
|
+
sha256=markdown_hash,
|
|
352
|
+
source_sha256=source_hash,
|
|
353
|
+
tool="outline-renderer",
|
|
354
|
+
tool_version=OUTLINE_TOOL_VERSION,
|
|
355
|
+
created_at=_now(),
|
|
356
|
+
)
|
|
357
|
+
entry = ManifestEntry(
|
|
358
|
+
path=paths.rel_posix(source_destination, vault.root),
|
|
359
|
+
sha256=source_hash,
|
|
360
|
+
source_id=topic.source_id,
|
|
361
|
+
etag=topic.etag,
|
|
362
|
+
last_modified=topic.last_modified,
|
|
363
|
+
size=len(source_bytes),
|
|
364
|
+
fetched_at=_now(),
|
|
365
|
+
derived={"markdown": derived},
|
|
366
|
+
)
|
|
367
|
+
transactions.install_generated(
|
|
368
|
+
vault,
|
|
369
|
+
topic.source_key,
|
|
370
|
+
entry,
|
|
371
|
+
source_bytes,
|
|
372
|
+
{"markdown": markdown_bytes},
|
|
373
|
+
preserve=preserve,
|
|
374
|
+
)
|
|
375
|
+
_update_topic_map(vault, metadata, topic, entry, school)
|
|
376
|
+
return entry.path, derived.path
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _outline_is_current(
|
|
380
|
+
vault: Vault,
|
|
381
|
+
prior: ManifestEntry,
|
|
382
|
+
*,
|
|
383
|
+
source_destination: Path,
|
|
384
|
+
source_hash: str,
|
|
385
|
+
markdown_destination: Path,
|
|
386
|
+
markdown_hash: str,
|
|
387
|
+
) -> bool:
|
|
388
|
+
"""Require both generated files and their manifest provenance to match exactly."""
|
|
389
|
+
|
|
390
|
+
artifact = prior.derived.get("markdown")
|
|
391
|
+
if artifact is None:
|
|
392
|
+
return False
|
|
393
|
+
if (
|
|
394
|
+
prior.path != paths.rel_posix(source_destination, vault.root)
|
|
395
|
+
or prior.sha256 != source_hash
|
|
396
|
+
or artifact.path != paths.rel_posix(markdown_destination, vault.root)
|
|
397
|
+
or artifact.sha256 != markdown_hash
|
|
398
|
+
or artifact.source_sha256 != source_hash
|
|
399
|
+
or artifact.tool != "outline-renderer"
|
|
400
|
+
or artifact.tool_version != OUTLINE_TOOL_VERSION
|
|
401
|
+
):
|
|
402
|
+
return False
|
|
403
|
+
source_fingerprint = _file_fingerprint(source_destination)
|
|
404
|
+
markdown_fingerprint = _file_fingerprint(markdown_destination)
|
|
405
|
+
return source_fingerprint == (prior.sha256, prior.size) and (
|
|
406
|
+
markdown_fingerprint is not None and markdown_fingerprint[0] == artifact.sha256
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _outline_needs_preservation(
|
|
411
|
+
vault: Vault,
|
|
412
|
+
prior: ManifestEntry,
|
|
413
|
+
source_hash: str,
|
|
414
|
+
markdown_hash: str,
|
|
415
|
+
) -> bool:
|
|
416
|
+
"""Preserve changed generated bytes or a locally modified current artifact before overwrite."""
|
|
417
|
+
|
|
418
|
+
if source_hash != prior.sha256:
|
|
419
|
+
return True
|
|
420
|
+
source = vault.materialized(prior)
|
|
421
|
+
actual_source = _file_fingerprint(source)
|
|
422
|
+
if actual_source is not None and actual_source != (prior.sha256, prior.size):
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
artifact = prior.derived.get("markdown")
|
|
426
|
+
if artifact is None:
|
|
427
|
+
return False
|
|
428
|
+
if markdown_hash != artifact.sha256:
|
|
429
|
+
return True
|
|
430
|
+
markdown = _markdown_destination(vault, source, prior)
|
|
431
|
+
actual_markdown = _file_fingerprint(markdown)
|
|
432
|
+
return actual_markdown is not None and actual_markdown[0] != artifact.sha256
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _file_fingerprint(source: Path) -> tuple[str, int] | None:
|
|
436
|
+
if paths.is_link(source):
|
|
437
|
+
raise ValueError("outline artifact must not be a symlink")
|
|
438
|
+
digest = sha256()
|
|
439
|
+
size = 0
|
|
440
|
+
try:
|
|
441
|
+
with open(os.fspath(paths.long_path(source)), "rb") as handle:
|
|
442
|
+
while chunk := handle.read(1024 * 1024):
|
|
443
|
+
digest.update(chunk)
|
|
444
|
+
size += len(chunk)
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
return None
|
|
447
|
+
except IsADirectoryError as exc:
|
|
448
|
+
raise ValueError("outline artifact must be a regular file") from exc
|
|
449
|
+
return digest.hexdigest(), size
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _source_destination(
|
|
453
|
+
vault: Vault,
|
|
454
|
+
metadata: CourseMetadata,
|
|
455
|
+
topic: TopicRecord,
|
|
456
|
+
prior: ManifestEntry | None,
|
|
457
|
+
page: OutlinePage,
|
|
458
|
+
) -> Path:
|
|
459
|
+
if prior is not None:
|
|
460
|
+
return vault.materialized(prior)
|
|
461
|
+
suffix = ".pdf" if page.pdf is not None else ".html"
|
|
462
|
+
candidate = _content_directory(metadata.directory, ("Outlines",)) / (
|
|
463
|
+
f"{paths.safe_name(topic.title)}{suffix}"
|
|
464
|
+
)
|
|
465
|
+
return paths.unique_path(candidate, reserved=vault.claimed_paths())
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _markdown_destination(vault: Vault, source: Path, prior: ManifestEntry | None) -> Path:
|
|
469
|
+
if prior is not None:
|
|
470
|
+
artifact = prior.derived.get("markdown")
|
|
471
|
+
if artifact is not None:
|
|
472
|
+
# Resolve the already-validated manifest artifact through the vault boundary instead
|
|
473
|
+
# of treating a persisted relative path as an untrusted filesystem path.
|
|
474
|
+
return vault.materialized(
|
|
475
|
+
ManifestEntry(
|
|
476
|
+
path=artifact.path,
|
|
477
|
+
sha256=artifact.sha256,
|
|
478
|
+
source_id="derived",
|
|
479
|
+
etag=None,
|
|
480
|
+
last_modified=None,
|
|
481
|
+
size=0,
|
|
482
|
+
fetched_at=_now(),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
return source.with_suffix(".md")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _outline_markdown(title: str, page: OutlinePage) -> bytes:
|
|
489
|
+
if page.pdf is not None:
|
|
490
|
+
text = f"# {title}\n\nRendered outline source: `{paths.safe_name(title)}.pdf`\n"
|
|
491
|
+
else:
|
|
492
|
+
body = re.sub(r"(?is)<(script|style|iframe|object|embed).*?</\1\s*>", " ", page.html)
|
|
493
|
+
body = re.sub(r"(?s)<[^>]+>", "\n", body)
|
|
494
|
+
body = html.unescape(body)
|
|
495
|
+
body = re.sub(r"\n{3,}", "\n\n", body).strip()
|
|
496
|
+
text = f"# {title}\n\n{body}\n"
|
|
497
|
+
return text.encode("utf-8")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _install_bytes(destination: Path, data: bytes, *, root: Path | None = None) -> None:
|
|
501
|
+
# Outline bytes are generated locally and cheap to recreate. The generated-writer cleanup
|
|
502
|
+
# semantics are therefore intentional; downloaded course .part files use ingest's
|
|
503
|
+
# atomic_install_temp path and survive a failed install.
|
|
504
|
+
paths.ensure_dir(destination.parent, root=root)
|
|
505
|
+
paths.atomic_write_bytes(destination, data, root=root)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _update_topic_map(
|
|
509
|
+
vault: Vault,
|
|
510
|
+
metadata: CourseMetadata,
|
|
511
|
+
topic: TopicRecord,
|
|
512
|
+
entry: ManifestEntry,
|
|
513
|
+
school: School,
|
|
514
|
+
) -> None:
|
|
515
|
+
rows = course_index.read_content_map(metadata.directory).get("topics")
|
|
516
|
+
if not isinstance(rows, list):
|
|
517
|
+
return
|
|
518
|
+
derived = entry.derived.get("markdown")
|
|
519
|
+
for row in rows:
|
|
520
|
+
if isinstance(row, dict) and row.get("source_key") == topic.source_key:
|
|
521
|
+
row.update(
|
|
522
|
+
{
|
|
523
|
+
"availability": "markdown_ready",
|
|
524
|
+
"source_path": entry.path,
|
|
525
|
+
"path": derived.path if derived else None,
|
|
526
|
+
"sha256": entry.sha256,
|
|
527
|
+
"source_sha256": entry.sha256,
|
|
528
|
+
"size": entry.size,
|
|
529
|
+
"next_action": "ready for citation",
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
rows = course_index.reconcile_content_map(vault, rows)
|
|
533
|
+
_write_content_map(metadata.directory, rows, root=vault.root)
|
|
534
|
+
topics = tuple(
|
|
535
|
+
_topic_from_row(row, course=metadata.course) for row in rows if isinstance(row, dict)
|
|
536
|
+
)
|
|
537
|
+
_write_index(
|
|
538
|
+
metadata.directory,
|
|
539
|
+
school=school,
|
|
540
|
+
course=metadata.course,
|
|
541
|
+
topics=topics,
|
|
542
|
+
root=vault.root,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _status(topic: TopicRecord, status: str, reason: str) -> dict[str, object]:
|
|
547
|
+
return {"source_key": topic.source_key, "status": status, "reason": reason}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _close_target(browser: OutlineBrowser) -> str | None:
|
|
551
|
+
try:
|
|
552
|
+
browser.close_target()
|
|
553
|
+
except Exception as exc:
|
|
554
|
+
return type(exc).__name__
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _now() -> str:
|
|
559
|
+
return clock.stamp()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _write_json(destination: Path, payload: object, *, root: Path | None = None) -> None:
|
|
563
|
+
paths.ensure_dir(destination.parent, root=root)
|
|
564
|
+
text = (
|
|
565
|
+
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2, separators=(",", ": "))
|
|
566
|
+
+ "\n"
|
|
567
|
+
)
|
|
568
|
+
paths.atomic_write_text(destination, text, root=root)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class _PageConnectionFactory(Protocol):
|
|
572
|
+
def open_page(self) -> Any:
|
|
573
|
+
"""Return a fresh page-target CDP connection."""
|
|
574
|
+
|
|
575
|
+
def close(self) -> None:
|
|
576
|
+
"""Release endpoint/process resources after all page targets are closed."""
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class CDPOutlineBrowserFactory:
|
|
580
|
+
"""Adapt a dedicated-profile page factory to one fresh outline browser per target."""
|
|
581
|
+
|
|
582
|
+
def __init__(self, pages: _PageConnectionFactory) -> None:
|
|
583
|
+
self._pages = pages
|
|
584
|
+
|
|
585
|
+
def open_browser(self) -> OutlineBrowser:
|
|
586
|
+
return CDPOutlineBrowser(self._pages.open_page())
|
|
587
|
+
|
|
588
|
+
def close(self) -> None:
|
|
589
|
+
self._pages.close()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def dedicated_profile_outline_factory() -> OutlineBrowserFactory:
|
|
593
|
+
"""Build the production factory for Agent2Learn's dedicated persistent profile."""
|
|
594
|
+
|
|
595
|
+
from agent2learn.auth.cdp import DedicatedPageFactory
|
|
596
|
+
|
|
597
|
+
return CDPOutlineBrowserFactory(DedicatedPageFactory())
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class CDPOutlineBrowser:
|
|
601
|
+
"""Render one outline through one newly-created dedicated-profile page connection."""
|
|
602
|
+
|
|
603
|
+
def __init__(self, connection: Any) -> None:
|
|
604
|
+
self.connection = connection
|
|
605
|
+
self._blocked: str | None = None
|
|
606
|
+
self._popup: str | None = None
|
|
607
|
+
|
|
608
|
+
def render_outline(
|
|
609
|
+
self,
|
|
610
|
+
url: str,
|
|
611
|
+
*,
|
|
612
|
+
allowed_hosts: Sequence[str],
|
|
613
|
+
timeout: float,
|
|
614
|
+
) -> OutlinePage:
|
|
615
|
+
gate = _CDPGate(self.connection, allowed_hosts)
|
|
616
|
+
self.connection.call("Page.enable", event_handler=gate.handle)
|
|
617
|
+
self.connection.call("Network.enable", event_handler=gate.handle)
|
|
618
|
+
self.connection.call(
|
|
619
|
+
"Fetch.enable",
|
|
620
|
+
{"patterns": [{"urlPattern": "*", "requestStage": "Request"}]},
|
|
621
|
+
event_handler=gate.handle,
|
|
622
|
+
)
|
|
623
|
+
self.connection.call("Page.navigate", {"url": url}, event_handler=gate.handle)
|
|
624
|
+
deadline = time.monotonic() + timeout
|
|
625
|
+
while time.monotonic() < deadline:
|
|
626
|
+
gate.raise_if_blocked()
|
|
627
|
+
result = self.connection.call(
|
|
628
|
+
"Runtime.evaluate",
|
|
629
|
+
{"expression": "document.readyState", "returnByValue": True},
|
|
630
|
+
event_handler=gate.handle,
|
|
631
|
+
)
|
|
632
|
+
value = result.get("result") if isinstance(result, dict) else None
|
|
633
|
+
if isinstance(value, dict) and value.get("value") in {"interactive", "complete"}:
|
|
634
|
+
break
|
|
635
|
+
time.sleep(0.05)
|
|
636
|
+
else:
|
|
637
|
+
raise TimeoutError("outline render timed out")
|
|
638
|
+
gate.raise_if_blocked()
|
|
639
|
+
result = self.connection.call(
|
|
640
|
+
"Runtime.evaluate",
|
|
641
|
+
{
|
|
642
|
+
"expression": "({html: document.documentElement.outerHTML, url: location.href})",
|
|
643
|
+
"returnByValue": True,
|
|
644
|
+
},
|
|
645
|
+
event_handler=gate.handle,
|
|
646
|
+
)
|
|
647
|
+
value = result.get("result", {}).get("value") if isinstance(result, dict) else None
|
|
648
|
+
if not isinstance(value, dict):
|
|
649
|
+
raise ValueError("browser returned no outline DOM")
|
|
650
|
+
return OutlinePage(
|
|
651
|
+
html=cast(str, value.get("html", "")),
|
|
652
|
+
canonical_url=cast(str, value.get("url", url)),
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def close_target(self) -> None:
|
|
656
|
+
force_close = getattr(self.connection, "close_target", None)
|
|
657
|
+
if callable(force_close):
|
|
658
|
+
force_close()
|
|
659
|
+
return
|
|
660
|
+
try:
|
|
661
|
+
self.connection.call("Page.close")
|
|
662
|
+
finally:
|
|
663
|
+
self.connection.close()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class _CDPGate:
|
|
667
|
+
def __init__(self, connection: Any, allowed_hosts: Sequence[str]) -> None:
|
|
668
|
+
self.connection = connection
|
|
669
|
+
self.allowed_hosts = tuple(allowed_hosts)
|
|
670
|
+
self.blocked: str | None = None
|
|
671
|
+
self.popup: str | None = None
|
|
672
|
+
|
|
673
|
+
def handle(self, message: dict[str, Any]) -> None:
|
|
674
|
+
if message.get("method") == "Fetch.requestPaused":
|
|
675
|
+
params = message.get("params")
|
|
676
|
+
request = params.get("request") if isinstance(params, dict) else None
|
|
677
|
+
request_id = params.get("requestId") if isinstance(params, dict) else None
|
|
678
|
+
target = request.get("url") if isinstance(request, dict) else None
|
|
679
|
+
if not isinstance(request_id, str) or not isinstance(target, str):
|
|
680
|
+
return
|
|
681
|
+
if _allowed_host_from_list(target, self.allowed_hosts):
|
|
682
|
+
self.connection.send_without_wait(
|
|
683
|
+
"Fetch.continueRequest", {"requestId": request_id}
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
self.blocked = target
|
|
687
|
+
self.connection.send_without_wait(
|
|
688
|
+
"Fetch.failRequest",
|
|
689
|
+
{"requestId": request_id, "errorReason": "BlockedByClient"},
|
|
690
|
+
)
|
|
691
|
+
elif message.get("method") == "Target.targetCreated":
|
|
692
|
+
params = message.get("params")
|
|
693
|
+
info = params.get("targetInfo") if isinstance(params, dict) else None
|
|
694
|
+
if isinstance(info, dict) and info.get("type") == "page":
|
|
695
|
+
url = info.get("url")
|
|
696
|
+
if isinstance(url, str):
|
|
697
|
+
self.popup = url
|
|
698
|
+
|
|
699
|
+
def raise_if_blocked(self) -> None:
|
|
700
|
+
if self.blocked is not None:
|
|
701
|
+
raise ValueError("outline requested an undeclared dependency")
|
|
702
|
+
if self.popup is not None:
|
|
703
|
+
raise ValueError("outline opened a popup")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _allowed_host_from_list(value: str, allowed_hosts: Sequence[str]) -> bool:
|
|
707
|
+
"""Apply the exact LEARN origin plus declared-host boundary policy at request time."""
|
|
708
|
+
|
|
709
|
+
if not allowed_hosts:
|
|
710
|
+
return False
|
|
711
|
+
try:
|
|
712
|
+
parsed = urlsplit(value)
|
|
713
|
+
if (
|
|
714
|
+
parsed.scheme.casefold() != "https"
|
|
715
|
+
or parsed.hostname is None
|
|
716
|
+
or parsed.username is not None
|
|
717
|
+
or parsed.password is not None
|
|
718
|
+
):
|
|
719
|
+
return False
|
|
720
|
+
host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
|
|
721
|
+
port = parsed.port or 443
|
|
722
|
+
base_host, base_port = _declared_host(allowed_hosts[0])
|
|
723
|
+
if host == base_host and port == base_port:
|
|
724
|
+
return True
|
|
725
|
+
for declared in allowed_hosts[1:]:
|
|
726
|
+
declared_host, declared_port = _declared_host(declared)
|
|
727
|
+
if port == declared_port and (
|
|
728
|
+
host == declared_host or host.endswith(f".{declared_host}")
|
|
729
|
+
):
|
|
730
|
+
return True
|
|
731
|
+
except (TypeError, UnicodeError, ValueError):
|
|
732
|
+
return False
|
|
733
|
+
return False
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _declared_host(value: str) -> tuple[str, int]:
|
|
737
|
+
candidate = value if "://" in value else f"https://{value}"
|
|
738
|
+
parsed = urlsplit(candidate)
|
|
739
|
+
if parsed.scheme.casefold() != "https" or parsed.hostname is None:
|
|
740
|
+
raise ValueError("outline host declaration must be HTTPS")
|
|
741
|
+
host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
|
|
742
|
+
return host, parsed.port or 443
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
__all__ = [
|
|
746
|
+
"CDPOutlineBrowser",
|
|
747
|
+
"CDPOutlineBrowserFactory",
|
|
748
|
+
"OUTLINE_TIMEOUT_SECONDS",
|
|
749
|
+
"OutlineBrowser",
|
|
750
|
+
"OutlineBrowserFactory",
|
|
751
|
+
"OutlinePage",
|
|
752
|
+
"dedicated_profile_outline_factory",
|
|
753
|
+
"ingest_outlines",
|
|
754
|
+
]
|