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/pipeline.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Typed production orchestration for metadata-first vault synchronization.
|
|
2
|
+
|
|
3
|
+
The public sync command and golden-vault harness share this sequence; onboarding can adopt the same
|
|
4
|
+
entry point in its dedicated integration task. Metadata is completed and exposed to an optional
|
|
5
|
+
observer before any browser or file work begins, and later phases use that stable local projection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, NoReturn, cast
|
|
16
|
+
|
|
17
|
+
from agent2learn import aipolicy, clock, paths
|
|
18
|
+
from agent2learn.api import Client
|
|
19
|
+
from agent2learn.audit import write_audit
|
|
20
|
+
from agent2learn.convert import DEFAULT_OCR_WORDS_PER_PAGE, ConversionReport, convert_vault
|
|
21
|
+
from agent2learn.errors import AuthenticationError, NotConfigured
|
|
22
|
+
from agent2learn.index import read_content_map, reconcile_content_map, write_content_map
|
|
23
|
+
from agent2learn.ingest import (
|
|
24
|
+
FileReport,
|
|
25
|
+
MetadataReport,
|
|
26
|
+
OutlineReport,
|
|
27
|
+
_topic_from_row,
|
|
28
|
+
_write_index,
|
|
29
|
+
ingest_files,
|
|
30
|
+
ingest_metadata,
|
|
31
|
+
)
|
|
32
|
+
from agent2learn.outlines import (
|
|
33
|
+
OutlineBrowserFactory,
|
|
34
|
+
dedicated_profile_outline_factory,
|
|
35
|
+
ingest_outlines,
|
|
36
|
+
)
|
|
37
|
+
from agent2learn.schools import School
|
|
38
|
+
from agent2learn.snapshot import write_snapshot
|
|
39
|
+
from agent2learn.vault import Vault
|
|
40
|
+
|
|
41
|
+
SyncScope = Literal["all", "priority"]
|
|
42
|
+
MetadataObserver = Callable[[MetadataReport], None]
|
|
43
|
+
DEFAULT_SYNC_SCOPE: SyncScope = "all"
|
|
44
|
+
_INIT_STATE_SCHEMA = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SyncPreferences:
|
|
49
|
+
"""Validated initializer choices that a later unflagged sync may reuse."""
|
|
50
|
+
|
|
51
|
+
scope: SyncScope = DEFAULT_SYNC_SCOPE
|
|
52
|
+
term: str | None = None
|
|
53
|
+
only: tuple[int, ...] | None = None
|
|
54
|
+
profile_consent: bool | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _UnavailableOutlineFactory:
|
|
58
|
+
"""Record discovered outlines as unavailable without opening a declined browser profile."""
|
|
59
|
+
|
|
60
|
+
def open_browser(self) -> NoReturn:
|
|
61
|
+
raise AuthenticationError("dedicated outline profile is not enabled")
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class PipelineReport:
|
|
69
|
+
"""Deterministic, privacy-bounded result of one production synchronization pass."""
|
|
70
|
+
|
|
71
|
+
scope: SyncScope
|
|
72
|
+
include_media: bool
|
|
73
|
+
include_grades: bool
|
|
74
|
+
include_discussions: bool
|
|
75
|
+
metadata: MetadataReport
|
|
76
|
+
outlines: OutlineReport
|
|
77
|
+
files: FileReport
|
|
78
|
+
conversion: ConversionReport
|
|
79
|
+
indexed_courses: int
|
|
80
|
+
snapshot_path: str
|
|
81
|
+
audit_path: str
|
|
82
|
+
gaps: tuple[str, ...] = ()
|
|
83
|
+
errors: tuple[str, ...] = ()
|
|
84
|
+
exit_code: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_sync_preferences(
|
|
88
|
+
vault: Vault, *, scope_override: SyncScope | None = None
|
|
89
|
+
) -> SyncPreferences:
|
|
90
|
+
"""Read validated init choices without letting corrupt state broaden a course selection.
|
|
91
|
+
|
|
92
|
+
``later`` describes an onboarding deferral, not a reusable file-transfer scope. When the user
|
|
93
|
+
later invokes ``a2l sync`` without an override, it falls back to the recommended full document
|
|
94
|
+
archive, still excluding media. Missing or invalid selection state fails closed so a sync can
|
|
95
|
+
never broaden silently from selected stable offering IDs to every active course.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
fallback_scope = scope_override or DEFAULT_SYNC_SCOPE
|
|
99
|
+
if fallback_scope not in {"all", "priority"}:
|
|
100
|
+
raise ValueError("scope override must be 'all' or 'priority'")
|
|
101
|
+
destination = vault.state() / "init.json"
|
|
102
|
+
if paths.is_link(destination):
|
|
103
|
+
raise ValueError("saved sync preferences must not be a symlink")
|
|
104
|
+
try:
|
|
105
|
+
exists = paths.long_path(destination).exists()
|
|
106
|
+
is_file = paths.long_path(destination).is_file()
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
raise ValueError("saved sync preferences are unavailable") from exc
|
|
109
|
+
if not exists:
|
|
110
|
+
raise NotConfigured("saved sync selection is unavailable · run: a2l init")
|
|
111
|
+
if not is_file:
|
|
112
|
+
raise ValueError("saved sync preferences are not a regular file")
|
|
113
|
+
try:
|
|
114
|
+
with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
|
|
115
|
+
raw: Any = json.load(handle)
|
|
116
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
117
|
+
raise ValueError("saved sync preferences are unreadable") from exc
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ValueError("saved sync preferences must be an object")
|
|
120
|
+
schema_version = raw.get("schema_version")
|
|
121
|
+
if isinstance(schema_version, bool) or schema_version != _INIT_STATE_SCHEMA:
|
|
122
|
+
raise ValueError("saved sync preferences use an unsupported schema")
|
|
123
|
+
|
|
124
|
+
stored_scope = raw.get("file_scope")
|
|
125
|
+
if stored_scope not in (None, "full", "priority", "later"):
|
|
126
|
+
raise ValueError("saved sync preferences have an invalid file scope")
|
|
127
|
+
if scope_override is not None:
|
|
128
|
+
scope = scope_override
|
|
129
|
+
elif stored_scope == "priority":
|
|
130
|
+
scope = "priority"
|
|
131
|
+
else:
|
|
132
|
+
scope = DEFAULT_SYNC_SCOPE
|
|
133
|
+
|
|
134
|
+
term_value = raw.get("term")
|
|
135
|
+
if term_value is not None and (not isinstance(term_value, str) or not term_value):
|
|
136
|
+
raise ValueError("saved sync preferences have an invalid term")
|
|
137
|
+
term = term_value
|
|
138
|
+
|
|
139
|
+
offering_value = raw.get("selected_offering_ids")
|
|
140
|
+
if offering_value is None:
|
|
141
|
+
raise NotConfigured("saved course selection is incomplete · run: a2l init")
|
|
142
|
+
only = _offering_ids(offering_value)
|
|
143
|
+
if only is None:
|
|
144
|
+
raise ValueError("saved sync preferences have invalid offering IDs")
|
|
145
|
+
|
|
146
|
+
consent_value = raw.get("profile_consent")
|
|
147
|
+
if consent_value is not None and not isinstance(consent_value, bool):
|
|
148
|
+
raise ValueError("saved sync preferences have invalid profile consent")
|
|
149
|
+
profile_consent = consent_value
|
|
150
|
+
return SyncPreferences(scope=scope, term=term, only=only, profile_consent=profile_consent)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_pipeline(
|
|
154
|
+
client: Client,
|
|
155
|
+
vault: Vault,
|
|
156
|
+
school: School,
|
|
157
|
+
*,
|
|
158
|
+
scope: SyncScope = DEFAULT_SYNC_SCOPE,
|
|
159
|
+
include_media: bool = False,
|
|
160
|
+
include_grades: bool = False,
|
|
161
|
+
include_discussions: bool = False,
|
|
162
|
+
ocr_words_per_page: int = DEFAULT_OCR_WORDS_PER_PAGE,
|
|
163
|
+
term: str | None = None,
|
|
164
|
+
only: Iterable[int | str] | None = None,
|
|
165
|
+
metadata_observer: MetadataObserver | None = None,
|
|
166
|
+
outline_factory: OutlineBrowserFactory | None = None,
|
|
167
|
+
profile_consent: bool | None = None,
|
|
168
|
+
render_outlines: bool = True,
|
|
169
|
+
rebuild_indexes: bool = True,
|
|
170
|
+
metadata: MetadataReport | None = None,
|
|
171
|
+
download_files: bool = True,
|
|
172
|
+
) -> PipelineReport:
|
|
173
|
+
"""Run the single metadata → outlines → files → local-artifact production sequence."""
|
|
174
|
+
|
|
175
|
+
if scope not in {"all", "priority"}:
|
|
176
|
+
raise ValueError("scope must be 'all' or 'priority'")
|
|
177
|
+
if profile_consent is not None and not isinstance(profile_consent, bool):
|
|
178
|
+
raise ValueError("profile_consent must be a boolean or None")
|
|
179
|
+
selection = tuple(only) if only is not None else None
|
|
180
|
+
if metadata is None:
|
|
181
|
+
metadata = ingest_metadata(
|
|
182
|
+
client,
|
|
183
|
+
vault,
|
|
184
|
+
school,
|
|
185
|
+
term=term,
|
|
186
|
+
only=selection,
|
|
187
|
+
include_grades=include_grades,
|
|
188
|
+
create_snapshot=False,
|
|
189
|
+
)
|
|
190
|
+
if metadata_observer is not None:
|
|
191
|
+
metadata_observer(metadata)
|
|
192
|
+
|
|
193
|
+
outlines = OutlineReport()
|
|
194
|
+
files = FileReport()
|
|
195
|
+
conversion = ConversionReport()
|
|
196
|
+
consent_limited_outlines = (
|
|
197
|
+
render_outlines and profile_consent is not True and outline_factory is None
|
|
198
|
+
)
|
|
199
|
+
metadata_complete = not metadata.errors and metadata.exit_code == 0
|
|
200
|
+
if metadata_complete:
|
|
201
|
+
if render_outlines:
|
|
202
|
+
if consent_limited_outlines:
|
|
203
|
+
factory: OutlineBrowserFactory = _UnavailableOutlineFactory()
|
|
204
|
+
else:
|
|
205
|
+
factory = outline_factory or dedicated_profile_outline_factory()
|
|
206
|
+
outlines = ingest_outlines(factory, vault, school, metadata)
|
|
207
|
+
if download_files:
|
|
208
|
+
files = ingest_files(
|
|
209
|
+
client,
|
|
210
|
+
vault,
|
|
211
|
+
school,
|
|
212
|
+
term=term,
|
|
213
|
+
only=selection,
|
|
214
|
+
scope=scope,
|
|
215
|
+
include_media=include_media,
|
|
216
|
+
include_discussions=include_discussions,
|
|
217
|
+
)
|
|
218
|
+
# This remains unconditional: already-local sources still need conversion after an
|
|
219
|
+
# interrupted, deferred, priority, or fully unchanged download pass.
|
|
220
|
+
conversion = convert_vault(vault, ocr_words_per_page=ocr_words_per_page)
|
|
221
|
+
|
|
222
|
+
indexed_courses = refresh_indexes(vault, school, metadata) if rebuild_indexes else 0
|
|
223
|
+
timestamp = clock.stamp()
|
|
224
|
+
course_dirs = tuple(course.directory for course in metadata.courses)
|
|
225
|
+
snapshot_path = write_snapshot(
|
|
226
|
+
vault,
|
|
227
|
+
course_dirs,
|
|
228
|
+
include_grades=include_grades,
|
|
229
|
+
timestamp=timestamp,
|
|
230
|
+
)
|
|
231
|
+
audit_path = write_audit(vault, timestamp=timestamp)
|
|
232
|
+
gaps, errors, exit_code = _result_status(
|
|
233
|
+
metadata,
|
|
234
|
+
outlines,
|
|
235
|
+
files,
|
|
236
|
+
conversion,
|
|
237
|
+
outline_failure=not consent_limited_outlines,
|
|
238
|
+
)
|
|
239
|
+
return PipelineReport(
|
|
240
|
+
scope=scope,
|
|
241
|
+
include_media=include_media,
|
|
242
|
+
include_grades=include_grades,
|
|
243
|
+
include_discussions=include_discussions,
|
|
244
|
+
metadata=metadata,
|
|
245
|
+
outlines=outlines,
|
|
246
|
+
files=files,
|
|
247
|
+
conversion=conversion,
|
|
248
|
+
indexed_courses=indexed_courses,
|
|
249
|
+
snapshot_path=paths.rel_posix(snapshot_path, vault.root),
|
|
250
|
+
audit_path=paths.rel_posix(audit_path, vault.root),
|
|
251
|
+
gaps=gaps,
|
|
252
|
+
errors=errors,
|
|
253
|
+
exit_code=exit_code,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def refresh_indexes(vault: Vault, school: School, metadata: MetadataReport) -> int:
|
|
258
|
+
"""Reconcile every selected content map and rebuild its index from current trusted state."""
|
|
259
|
+
|
|
260
|
+
refreshed = 0
|
|
261
|
+
for course_metadata in sorted(
|
|
262
|
+
metadata.courses,
|
|
263
|
+
key=lambda value: paths.rel_posix(value.directory, vault.root),
|
|
264
|
+
):
|
|
265
|
+
payload = read_content_map(course_metadata.directory)
|
|
266
|
+
raw_rows = payload.get("topics")
|
|
267
|
+
if not isinstance(raw_rows, list):
|
|
268
|
+
raise ValueError("content map topics must be a list")
|
|
269
|
+
rows = reconcile_content_map(vault, raw_rows)
|
|
270
|
+
write_content_map(course_metadata.directory, rows, root=vault.root)
|
|
271
|
+
topics = tuple(
|
|
272
|
+
_topic_from_row(row, course=course_metadata.course)
|
|
273
|
+
for row in rows
|
|
274
|
+
if isinstance(row, Mapping)
|
|
275
|
+
)
|
|
276
|
+
_write_index(
|
|
277
|
+
course_metadata.directory,
|
|
278
|
+
school=school,
|
|
279
|
+
course=course_metadata.course,
|
|
280
|
+
topics=topics,
|
|
281
|
+
root=vault.root,
|
|
282
|
+
)
|
|
283
|
+
_restore_ai_policy_line(course_metadata.directory, root=vault.root)
|
|
284
|
+
refreshed += 1
|
|
285
|
+
return refreshed
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_report(report: PipelineReport) -> str:
|
|
289
|
+
"""Render stable terminal counts without echoing course names, IDs, paths, or source errors."""
|
|
290
|
+
|
|
291
|
+
lines = [
|
|
292
|
+
(
|
|
293
|
+
f"metadata · {len(report.metadata.courses)} courses · "
|
|
294
|
+
f"{report.metadata.topic_count} topics · {report.metadata.deadline_count} deadlines"
|
|
295
|
+
),
|
|
296
|
+
(
|
|
297
|
+
f"outlines · {report.outlines.rendered} rendered · "
|
|
298
|
+
f"{report.outlines.unavailable} unavailable"
|
|
299
|
+
),
|
|
300
|
+
(
|
|
301
|
+
f"files · {report.files.downloaded} downloaded · {report.files.skipped} skipped · "
|
|
302
|
+
f"{report.files.metadata_only} metadata only"
|
|
303
|
+
),
|
|
304
|
+
(
|
|
305
|
+
f"conversion · {report.conversion.converted} converted · "
|
|
306
|
+
f"{report.conversion.skipped} current · {report.conversion.gaps} gaps"
|
|
307
|
+
),
|
|
308
|
+
f"index · {report.indexed_courses} courses",
|
|
309
|
+
f"audit · {report.audit_path}",
|
|
310
|
+
]
|
|
311
|
+
if report.errors:
|
|
312
|
+
lines.append("sync incomplete · " + ", ".join(report.errors))
|
|
313
|
+
elif report.gaps:
|
|
314
|
+
lines.append(
|
|
315
|
+
"sync completed with recorded gaps · "
|
|
316
|
+
+ ", ".join(report.gaps)
|
|
317
|
+
+ f" · details: {report.audit_path}"
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
lines.append("sync complete")
|
|
321
|
+
return "\n".join(lines) + "\n"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _offering_ids(value: object) -> tuple[int, ...] | None:
|
|
325
|
+
if not isinstance(value, list):
|
|
326
|
+
return None
|
|
327
|
+
if any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value):
|
|
328
|
+
return None
|
|
329
|
+
if len(set(value)) != len(value):
|
|
330
|
+
return None
|
|
331
|
+
return tuple(cast(list[int], value))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _restore_ai_policy_line(course_dir: Path, *, root: Path | None = None) -> None:
|
|
335
|
+
destination = course_dir / "_meta" / "ai_policy.json"
|
|
336
|
+
try:
|
|
337
|
+
with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
|
|
338
|
+
raw: Any = json.load(handle)
|
|
339
|
+
except FileNotFoundError:
|
|
340
|
+
return
|
|
341
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
342
|
+
raise ValueError("AI-policy metadata is unreadable") from exc
|
|
343
|
+
if not isinstance(raw, dict):
|
|
344
|
+
raise ValueError("AI-policy metadata has an invalid root")
|
|
345
|
+
status = raw.get("status")
|
|
346
|
+
if status not in {"found", "not_found_in_scanned_outline", "outline_unavailable"}:
|
|
347
|
+
raise ValueError("AI-policy metadata has an invalid status")
|
|
348
|
+
aipolicy._surface_index_line(course_dir, cast(dict[str, object], raw), root=root)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _result_status(
|
|
352
|
+
metadata: MetadataReport,
|
|
353
|
+
outlines: OutlineReport,
|
|
354
|
+
files: FileReport,
|
|
355
|
+
conversion: ConversionReport,
|
|
356
|
+
*,
|
|
357
|
+
outline_failure: bool = True,
|
|
358
|
+
) -> tuple[tuple[str, ...], tuple[str, ...], int]:
|
|
359
|
+
gaps: list[str] = []
|
|
360
|
+
errors: list[str] = []
|
|
361
|
+
if metadata.errors or metadata.exit_code:
|
|
362
|
+
errors.append("metadata incomplete")
|
|
363
|
+
if outline_failure and outlines.unavailable:
|
|
364
|
+
gaps.append("outline gaps")
|
|
365
|
+
if outlines.errors:
|
|
366
|
+
errors.append("outline renderer incomplete")
|
|
367
|
+
if files.download_gaps:
|
|
368
|
+
gaps.append("download gaps")
|
|
369
|
+
if files.failed or files.errors or files.exit_code:
|
|
370
|
+
errors.append("file sync incomplete")
|
|
371
|
+
if conversion.errors:
|
|
372
|
+
errors.append("conversion incomplete")
|
|
373
|
+
if files.interrupted or files.exit_code == 130:
|
|
374
|
+
return tuple(gaps), tuple(errors), 130
|
|
375
|
+
if metadata.exit_code:
|
|
376
|
+
return tuple(gaps), tuple(errors), metadata.exit_code
|
|
377
|
+
if files.exit_code:
|
|
378
|
+
return tuple(gaps), tuple(errors), files.exit_code
|
|
379
|
+
return tuple(gaps), tuple(errors), 1 if errors else 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
__all__ = [
|
|
383
|
+
"DEFAULT_SYNC_SCOPE",
|
|
384
|
+
"MetadataObserver",
|
|
385
|
+
"PipelineReport",
|
|
386
|
+
"SyncPreferences",
|
|
387
|
+
"SyncScope",
|
|
388
|
+
"load_sync_preferences",
|
|
389
|
+
"refresh_indexes",
|
|
390
|
+
"render_report",
|
|
391
|
+
"run_pipeline",
|
|
392
|
+
]
|