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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
@@ -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
+ ]