agent2learn 0.1.6__tar.gz → 0.1.7__tar.gz

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 (49) hide show
  1. {agent2learn-0.1.6 → agent2learn-0.1.7}/PKG-INFO +1 -1
  2. {agent2learn-0.1.6 → agent2learn-0.1.7}/pyproject.toml +1 -1
  3. {agent2learn-0.1.6 → agent2learn-0.1.7}/pyproject.toml.orig +1 -1
  4. {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-coursework/SKILL.md +1 -1
  5. {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-setup/SKILL.md +1 -1
  6. {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-study/SKILL.md +1 -1
  7. {agent2learn-0.1.6 → agent2learn-0.1.7}/skills/a2l-sync/SKILL.md +1 -1
  8. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/__init__.py +1 -1
  9. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/audit.py +26 -3
  10. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/calendar.py +17 -4
  11. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/cli.py +21 -7
  12. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/doctor.py +43 -3
  13. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/ingest.py +119 -16
  14. agent2learn-0.1.7/src/agent2learn/metadata_coverage.py +345 -0
  15. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/pipeline.py +1 -0
  16. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/session.py +131 -20
  17. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/snapshot.py +28 -3
  18. agent2learn-0.1.6/src/agent2learn/metadata_coverage.py +0 -137
  19. {agent2learn-0.1.6 → agent2learn-0.1.7}/LICENSE +0 -0
  20. {agent2learn-0.1.6 → agent2learn-0.1.7}/README.md +0 -0
  21. {agent2learn-0.1.6 → agent2learn-0.1.7}/skills.sh.json +0 -0
  22. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/_release.py +0 -0
  23. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/aipolicy.py +0 -0
  24. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/api.py +0 -0
  25. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/__init__.py +0 -0
  26. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/cdp.py +0 -0
  27. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/auth/paste.py +0 -0
  28. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/calibrate.py +0 -0
  29. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/check.py +0 -0
  30. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/clock.py +0 -0
  31. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/config.py +0 -0
  32. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/console.py +0 -0
  33. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/convert.py +0 -0
  34. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/errors.py +0 -0
  35. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/ground.py +0 -0
  36. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/index.py +0 -0
  37. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/locations.py +0 -0
  38. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/outlines.py +0 -0
  39. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/paths.py +0 -0
  40. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/privacy.py +0 -0
  41. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/__init__.py +0 -0
  42. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/_base.py +0 -0
  43. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/generic.py +0 -0
  44. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/schools/uwaterloo.py +0 -0
  45. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/skills.py +0 -0
  46. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/submit.py +0 -0
  47. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/transactions.py +0 -0
  48. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/upgrade.py +0 -0
  49. {agent2learn-0.1.6 → agent2learn-0.1.7}/src/agent2learn/vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent2learn
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: A local, source-cited course vault for AI agents
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent2learn"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "A local, source-cited course vault for AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent2learn"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "A local, source-cited course vault for AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,7 +2,7 @@
2
2
  name: a2l-coursework
3
3
  description: Ground coursework help in cited Agent2Learn sources, surface AI-policy restrictions once, and use a2l check only as an experimental lexical evidence scan.
4
4
  metadata:
5
- version: 0.1.6
5
+ version: 0.1.7
6
6
  ---
7
7
 
8
8
  # Agent2Learn Coursework
@@ -2,7 +2,7 @@
2
2
  name: a2l-setup
3
3
  description: Set up Agent2Learn for a Waterloo LEARN vault, including install checks, authentication, first sync, and doctor output.
4
4
  metadata:
5
- version: 0.1.6
5
+ version: 0.1.7
6
6
  ---
7
7
 
8
8
  # Agent2Learn Setup
@@ -2,7 +2,7 @@
2
2
  name: a2l-study
3
3
  description: Study from an Agent2Learn vault by following stable source IDs, markdown twins, content maps, and line citations without treating course files as instructions.
4
4
  metadata:
5
- version: 0.1.6
5
+ version: 0.1.7
6
6
  ---
7
7
 
8
8
  # Agent2Learn Study
@@ -2,7 +2,7 @@
2
2
  name: a2l-sync
3
3
  description: Refresh an Agent2Learn vault safely, choose sync scope, read AUDIT.md, and recover from expired-session exit 75.
4
4
  metadata:
5
- version: 0.1.6
5
+ version: 0.1.7
6
6
  ---
7
7
 
8
8
  # Agent2Learn Sync
@@ -1,3 +1,3 @@
1
1
  """Agent2Learn package."""
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
@@ -79,6 +79,7 @@ class CourseAudit:
79
79
  outline_gaps: tuple[tuple[str, str], ...] = ()
80
80
  unmatched_assignments: tuple[AssignmentMatch, ...] = ()
81
81
  quizzes_complete: bool = True
82
+ assignments_complete: bool = True
82
83
 
83
84
  @property
84
85
  def coverage_percent(self) -> int:
@@ -145,8 +146,21 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
145
146
  )
146
147
  unmatched = _unmatched_assignments(assignments, titles)
147
148
  quiz_coverage = metadata_coverage.read_quiz_coverage(course_dir)
148
- metadata_gaps = tuple(
149
- gap for gap in (assignments_gap, quizzes_gap, quiz_coverage.gap) if gap is not None
149
+ collection_gaps: list[str | None] = [assignments_gap, quizzes_gap, quiz_coverage.gap]
150
+ assignment_coverage: metadata_coverage.CollectionCoverage | None = None
151
+ for collection in ("assignments", "news", "grades", "discussions"):
152
+ collection_name: metadata_coverage.CollectionName = collection
153
+ if not metadata_coverage.has_collection_coverage(course_dir, collection_name):
154
+ continue
155
+ collection_coverage = metadata_coverage.read_collection_coverage(
156
+ course_dir, collection_name
157
+ )
158
+ if collection == "assignments":
159
+ assignment_coverage = collection_coverage
160
+ collection_gaps.append(collection_coverage.gap(collection))
161
+ metadata_gaps = tuple(gap for gap in collection_gaps if gap is not None)
162
+ assignments_complete = assignments_gap is None and (
163
+ assignment_coverage is None or assignment_coverage.status == "complete"
150
164
  )
151
165
 
152
166
  first = next((row for row in rows if isinstance(row, Mapping)), {})
@@ -166,6 +180,7 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
166
180
  outline_gaps=outline_gaps,
167
181
  unmatched_assignments=unmatched,
168
182
  quizzes_complete=quiz_coverage.status == "complete" and quizzes_gap is None,
183
+ assignments_complete=assignments_complete,
169
184
  )
170
185
 
171
186
 
@@ -339,8 +354,16 @@ def _render(audits: Sequence[CourseAudit], stamp: str) -> str:
339
354
  else f"- Quiz inventory not confirmed; {audit.quizzes} cached quiz record(s)"
340
355
  f" ({audit.quizzes_with_due_dates} with due dates)"
341
356
  )
357
+ assignment_inventory = (
358
+ f"- {_plural(audit.assignments, 'assignment')}"
359
+ if audit.assignments_complete
360
+ else (
361
+ f"- Assignment inventory not confirmed; {audit.assignments} cached assignment "
362
+ "record(s)"
363
+ )
364
+ )
342
365
  counts = [
343
- f"- {_plural(audit.assignments, 'assignment')}",
366
+ assignment_inventory,
344
367
  quiz_inventory,
345
368
  f"- {_plural(audit.media, 'media file')}",
346
369
  ]
@@ -87,6 +87,18 @@ QUIZ_COVERAGE_WARNING = (
87
87
  "Quiz coverage is incomplete or unknown; deadlines may be missing and cached quiz dates "
88
88
  "are not confirmed current. Check LEARN."
89
89
  )
90
+ METADATA_COVERAGE_WARNING = (
91
+ "Metadata coverage is incomplete or unavailable; deadlines may be missing and cached "
92
+ "dates are not confirmed current. Check LEARN."
93
+ )
94
+
95
+
96
+ def coverage_warning(gaps: Sequence[str]) -> str:
97
+ """Return a stable warning without echoing server-provided diagnostics."""
98
+
99
+ if gaps and all(gap.startswith("quizzes ") for gap in gaps):
100
+ return QUIZ_COVERAGE_WARNING
101
+ return METADATA_COVERAGE_WARNING
90
102
 
91
103
 
92
104
  def collect_events(vault: Vault, school: School) -> tuple[CalendarEvent, ...]:
@@ -153,8 +165,9 @@ def render_ics(vault: Vault, school: School, *, now: datetime | None = None) ->
153
165
  "METHOD:PUBLISH",
154
166
  f"X-WR-TIMEZONE:{_escape_text(school.timezone)}",
155
167
  ]
156
- if metadata_coverage.vault_quiz_gaps(vault):
157
- lines.append(f"X-A2L-COVERAGE-WARNING:{_escape_text(QUIZ_COVERAGE_WARNING)}")
168
+ metadata_gaps = metadata_coverage.vault_metadata_gaps(vault)
169
+ if metadata_gaps:
170
+ lines.append(f"X-A2L-COVERAGE-WARNING:{_escape_text(coverage_warning(metadata_gaps))}")
158
171
  for event in collect_events(vault, school):
159
172
  lines.extend(_event_lines(event, timezone, stamp))
160
173
  lines.append("END:VCALENDAR")
@@ -236,7 +249,7 @@ def build_today(
236
249
  overdue=tuple(overdue),
237
250
  exam_countdowns=tuple(exam_countdowns),
238
251
  changes=snapshot.diff_vault(vault, include_grades=include_grades),
239
- metadata_gaps=metadata_coverage.vault_quiz_gaps(vault),
252
+ metadata_gaps=metadata_coverage.vault_metadata_gaps(vault),
240
253
  )
241
254
 
242
255
 
@@ -252,7 +265,7 @@ def render_today(report: TodayReport, *, include_grades: bool = False) -> str:
252
265
  [
253
266
  "Metadata coverage is incomplete:",
254
267
  *[f"- {gap}" for gap in report.metadata_gaps],
255
- "Deadlines may be missing, and cached quiz dates may be outdated.",
268
+ "Deadlines may be missing, and cached metadata dates may be outdated.",
256
269
  "",
257
270
  ]
258
271
  )
@@ -390,8 +390,9 @@ def calendar(
390
390
  else:
391
391
  written = calendar_module.write_ics(vault, school, output)
392
392
  typer.echo(f"calendar exported: {_display_path(written)}")
393
- if metadata_coverage.vault_quiz_gaps(vault):
394
- typer.echo(f"Warning: {calendar_module.QUIZ_COVERAGE_WARNING}", err=True)
393
+ metadata_gaps = metadata_coverage.vault_metadata_gaps(vault)
394
+ if metadata_gaps:
395
+ typer.echo(f"Warning: {calendar_module.coverage_warning(metadata_gaps)}", err=True)
395
396
  except A2LError as exc:
396
397
  typer.echo(str(exc), err=True)
397
398
  raise typer.Exit(code=exc.exit_code) from None
@@ -1862,14 +1863,14 @@ def _print_metadata_summary(
1862
1863
  include_grades: bool,
1863
1864
  ) -> None:
1864
1865
  gaps = (
1865
- metadata_coverage.vault_quiz_gaps(Vault(root))
1866
+ metadata_coverage.vault_metadata_gaps(Vault(root))
1866
1867
  if report is None
1867
1868
  else getattr(report, "gaps", ())
1868
1869
  )
1869
1870
  for gap in gaps:
1870
1871
  typer.echo(
1871
- f"{console.GLYPH['warn']} {gap}; quiz/deadline coverage is not confirmed. "
1872
- "See .a2l/AUDIT.md and check quiz availability in LEARN.",
1872
+ f"{console.GLYPH['warn']} {gap}; metadata/deadline coverage is not confirmed. "
1873
+ "See .a2l/AUDIT.md and check the affected collection in LEARN.",
1873
1874
  err=True,
1874
1875
  )
1875
1876
  if report is None:
@@ -1888,6 +1889,7 @@ def _print_metadata_summary(
1888
1889
 
1889
1890
  assignment_count = 0
1890
1891
  quiz_count = 0
1892
+ assignments_complete = True
1891
1893
  quizzes_complete = True
1892
1894
  deadlines: list[tuple[str, str, str]] = []
1893
1895
  if isinstance(reports, Sequence):
@@ -1900,6 +1902,13 @@ def _print_metadata_summary(
1900
1902
  assignments = _read_metadata_rows(directory / "_meta" / "assignments.json")
1901
1903
  quizzes = _read_metadata_rows(directory / "_meta" / "quizzes.json")
1902
1904
  coverage = metadata_coverage.read_quiz_coverage(directory)
1905
+ assignment_coverage = metadata_coverage.read_collection_coverage(
1906
+ directory, "assignments"
1907
+ )
1908
+ if metadata_coverage.has_collection_coverage(directory, "assignments"):
1909
+ assignments_complete = assignments_complete and (
1910
+ assignment_coverage.status == "complete"
1911
+ )
1903
1912
  quizzes_complete = quizzes_complete and coverage.status == "complete"
1904
1913
  assignment_count += len(assignments)
1905
1914
  quiz_count += len(quizzes)
@@ -1911,11 +1920,16 @@ def _print_metadata_summary(
1911
1920
 
1912
1921
  grade_text = "grades synced" if include_grades else "grades not synced"
1913
1922
  detail = f"{deadline_count} {'deadlines' if quizzes_complete else 'known deadlines'}"
1914
- if assignment_count or quiz_count:
1923
+ if assignment_count or quiz_count or not assignments_complete:
1924
+ assignment_inventory = (
1925
+ f"{assignment_count} assignments"
1926
+ if assignments_complete
1927
+ else "assignment inventory unconfirmed"
1928
+ )
1915
1929
  quiz_inventory = (
1916
1930
  f"{quiz_count} quizzes" if quizzes_complete else "quiz inventory unconfirmed"
1917
1931
  )
1918
- detail = f"{assignment_count} assignments · {quiz_inventory} · {detail}"
1932
+ detail = f"{assignment_inventory} · {quiz_inventory} · {detail}"
1919
1933
  typer.echo(
1920
1934
  f"{console.GLYPH['ok']} {course_count} courses · {topic_count} topics · "
1921
1935
  f"{detail} · {grade_text}"
@@ -92,7 +92,10 @@ _COURSE_SOURCE_DIRECTORIES = frozenset(
92
92
  )
93
93
  _SAFE_PUBLIC_NOTES = {
94
94
  "fs.vault": "vault root `~`",
95
- "metadata.quizzes": "Quiz collection coverage is incomplete or unknown.",
95
+ "metadata.collections": (
96
+ "One or more optional metadata collections are unavailable or incomplete."
97
+ ),
98
+ "metadata.quizzes": "Quiz collection coverage is incomplete, unavailable, or unknown.",
96
99
  "metadata.quizzes.forbidden": "Quiz collection unavailable (HTTP 403 Forbidden).",
97
100
  }
98
101
  _PUBLIC_CHECK_NAMES = frozenset(
@@ -110,6 +113,7 @@ _PUBLIC_CHECK_NAMES = frozenset(
110
113
  "fs.longest_path",
111
114
  "fs.unavailable",
112
115
  "fs.vault",
116
+ "metadata.collections",
113
117
  "metadata.quizzes",
114
118
  "metadata.quizzes.forbidden",
115
119
  "session.age",
@@ -640,8 +644,10 @@ def _vault(vault: Vault | None) -> list[Check]:
640
644
  unusable_twins = 0
641
645
  unreadable_maps = 0
642
646
  quiz_gaps: set[str] = set()
647
+ collection_gaps: set[str] = set()
643
648
  quizzes_forbidden = False
644
649
  quiz_sync_needed = False
650
+ collection_sync_needed = False
645
651
  term_stats: dict[str, list[int]] = {}
646
652
  try:
647
653
  map_paths = sorted(
@@ -665,8 +671,25 @@ def _vault(vault: Vault | None) -> list[Check]:
665
671
  coverage = metadata_coverage.read_quiz_coverage(map_path.parent.parent)
666
672
  if coverage.gap is not None:
667
673
  quiz_gaps.add(coverage.gap)
668
- quizzes_forbidden = quizzes_forbidden or coverage.status == "unavailable"
674
+ quizzes_forbidden = quizzes_forbidden or (
675
+ coverage.status == "unavailable" and coverage.http_status == 403
676
+ )
669
677
  quiz_sync_needed = quiz_sync_needed or coverage.status in {"unknown", "incomplete"}
678
+ for collection in ("assignments", "news", "grades", "discussions"):
679
+ collection_name: metadata_coverage.CollectionName = collection
680
+ if not metadata_coverage.has_collection_coverage(
681
+ map_path.parent.parent, collection_name
682
+ ):
683
+ continue
684
+ collection_coverage = metadata_coverage.read_collection_coverage(
685
+ map_path.parent.parent, collection_name
686
+ )
687
+ if (gap := collection_coverage.gap(collection)) is not None:
688
+ collection_gaps.add(gap)
689
+ collection_sync_needed = collection_sync_needed or collection_coverage.status in {
690
+ "unknown",
691
+ "incomplete",
692
+ }
670
693
  term = map_path.parent.parent.parent.name
671
694
  stats = term_stats.setdefault(term, [0, 0, 0, 0, 0, 0])
672
695
  stats[0] += 1
@@ -786,6 +809,22 @@ def _vault(vault: Vault | None) -> list[Check]:
786
809
  public=_SAFE_PUBLIC_NOTES[quiz_check] if quiz_gaps else None,
787
810
  )
788
811
  )
812
+ if collection_gaps:
813
+ collection_fix = (
814
+ "run: a2l sync"
815
+ if collection_sync_needed
816
+ else "see .a2l/AUDIT.md; check the affected collection in LEARN"
817
+ )
818
+ checks.append(
819
+ Check(
820
+ "Vault",
821
+ "metadata.collections",
822
+ "warn",
823
+ "; ".join(sorted(collection_gaps)),
824
+ collection_fix,
825
+ public=_SAFE_PUBLIC_NOTES["metadata.collections"],
826
+ )
827
+ )
789
828
  checks.append(
790
829
  Check(
791
830
  "Vault",
@@ -927,7 +966,8 @@ def next_command(checks: Sequence[Check]) -> str | None:
927
966
  return f"run: {candidate}"
928
967
  if any(
929
968
  check.status == "warn"
930
- and check.name in {"vault.terms", "vault.citable", "metadata.quizzes.forbidden"}
969
+ and check.name
970
+ in {"vault.terms", "vault.citable", "metadata.quizzes.forbidden", "metadata.collections"}
931
971
  for check in checks
932
972
  ):
933
973
  # All actionable fixes were considered above. Viewing cached information is useful;
@@ -155,6 +155,7 @@ class FileReport:
155
155
  interrupted: bool = False
156
156
  errors: tuple[str, ...] = ()
157
157
  exit_code: int = 0
158
+ gaps: tuple[str, ...] = ()
158
159
 
159
160
 
160
161
  @dataclass(frozen=True)
@@ -223,6 +224,53 @@ class IngestClient(Protocol):
223
224
  ) -> DownloadResult: ...
224
225
 
225
226
 
227
+ def _record_optional_collection(
228
+ course_dir: Path,
229
+ vault: Vault,
230
+ collection: metadata_coverage.CollectionName,
231
+ *,
232
+ complete: bool,
233
+ error: BaseException | None,
234
+ errors: list[str],
235
+ gaps: list[str],
236
+ ) -> metadata_coverage.CollectionCoverage:
237
+ """Persist safe coverage for an optional collection and classify its failure.
238
+
239
+ A 404 means that the optional D2L tool or collection is not available for this course. It is
240
+ not evidence of an empty list, but it also must not prevent independent content from syncing.
241
+ All other failures remain metadata errors. A successful retry clears a prior recorded gap.
242
+ """
243
+
244
+ coverage = metadata_coverage.collection_coverage(collection, complete, error)
245
+ if error is not None:
246
+ if coverage.status == "unavailable":
247
+ if (gap := coverage.gap(collection)) is not None:
248
+ gaps.append(gap)
249
+ else:
250
+ errors.append(_safe_error(collection, error))
251
+ if error is not None or metadata_coverage.has_collection_coverage(course_dir, collection):
252
+ metadata_coverage.write_collection_coverage(
253
+ course_dir, collection, coverage, root=vault.root
254
+ )
255
+ return coverage
256
+
257
+
258
+ def _discussion_failure(
259
+ course_dir: Path,
260
+ vault: Vault,
261
+ error: BaseException,
262
+ ) -> tuple[str | None, str | None]:
263
+ """Persist a discussion failure and return ``(error, recorded_gap)``."""
264
+
265
+ coverage = metadata_coverage.collection_coverage("discussions", False, error)
266
+ metadata_coverage.write_collection_coverage(
267
+ course_dir, "discussions", coverage, root=vault.root
268
+ )
269
+ if coverage.status == "unavailable":
270
+ return None, coverage.gap("discussions")
271
+ return _safe_error("discussions", error), None
272
+
273
+
226
274
  def ingest_metadata(
227
275
  client: IngestClient,
228
276
  vault: Vault,
@@ -272,8 +320,15 @@ def ingest_metadata(
272
320
  assignments, assignments_complete, assignments_error = _fetch_collection(
273
321
  client, _endpoint_path(client, course, "dropbox/folders/")
274
322
  )
275
- if assignments_error is not None:
276
- errors.append(_safe_error("assignments", assignments_error))
323
+ _record_optional_collection(
324
+ course_dir,
325
+ vault,
326
+ "assignments",
327
+ complete=assignments_complete,
328
+ error=assignments_error,
329
+ errors=errors,
330
+ gaps=gaps,
331
+ )
277
332
  existing_map = _read_content_map(course_dir)
278
333
  attachment_topics = _assignment_attachment_topics(assignments, course=course, school=school)
279
334
  merged_topics = _merge_topic_records(
@@ -334,8 +389,15 @@ def ingest_metadata(
334
389
  news, news_complete, news_error = _fetch_collection(
335
390
  client, _endpoint_path(client, course, "news/")
336
391
  )
337
- if news_error is not None:
338
- errors.append(_safe_error("news", news_error))
392
+ _record_optional_collection(
393
+ course_dir,
394
+ vault,
395
+ "news",
396
+ complete=news_complete,
397
+ error=news_error,
398
+ errors=errors,
399
+ gaps=gaps,
400
+ )
339
401
  news_rows = _project_news(news)
340
402
  news_rows = _merge_rows(
341
403
  _read_list(course_dir / "_meta" / "news.json"),
@@ -370,8 +432,15 @@ def ingest_metadata(
370
432
  grades, grades_complete, grades_error = _fetch_collection(
371
433
  client, _endpoint_path(client, course, "grades/values/myGradeValues/")
372
434
  )
373
- if grades_error is not None:
374
- errors.append(_safe_error("grades", grades_error))
435
+ _record_optional_collection(
436
+ course_dir,
437
+ vault,
438
+ "grades",
439
+ complete=grades_complete,
440
+ error=grades_error,
441
+ errors=errors,
442
+ gaps=gaps,
443
+ )
375
444
  grades_path = course_dir / "_meta" / "my_grades.json"
376
445
  if grades_complete:
377
446
  grade_rows = _merge_rows(
@@ -393,7 +462,8 @@ def ingest_metadata(
393
462
  complete=False,
394
463
  )
395
464
  _write_list(grades_path, grade_rows, root=vault.root)
396
- errors.append("grades: incomplete response")
465
+ if grades_error is None:
466
+ errors.append("grades: incomplete response")
397
467
 
398
468
  merged_topics = course_index.reconcile_content_map(vault, merged_topics)
399
469
  _write_content_map(course_dir, merged_topics, root=vault.root)
@@ -469,6 +539,16 @@ def load_metadata_report(
469
539
  coverage = metadata_coverage.read_quiz_coverage(course_dir)
470
540
  if coverage.gap is not None:
471
541
  (errors if coverage.status == "incomplete" else gaps).append(coverage.gap)
542
+ for collection in ("assignments", "news", "grades", "discussions"):
543
+ collection_name: metadata_coverage.CollectionName = collection
544
+ if not metadata_coverage.has_collection_coverage(course_dir, collection_name):
545
+ continue
546
+ collection_state = metadata_coverage.read_collection_coverage(
547
+ course_dir, collection_name
548
+ )
549
+ collection_gap = collection_state.gap(collection_name)
550
+ if collection_gap is not None:
551
+ (errors if collection_state.status == "incomplete" else gaps).append(collection_gap)
472
552
  deadline_count += sum(
473
553
  1
474
554
  for row in [
@@ -546,6 +626,7 @@ def ingest_files(
546
626
  selected = _selected_courses(client, term=term, only=only)
547
627
  downloaded = skipped = failed = metadata_only = download_gaps = 0
548
628
  errors: list[str] = []
629
+ gaps: list[str] = []
549
630
 
550
631
  for course in selected:
551
632
  course_dir = _course_directory(vault, school, course)
@@ -569,7 +650,7 @@ def ingest_files(
569
650
  else list(planned)
570
651
  )
571
652
  if include_discussions:
572
- discussion_error = _ingest_discussions(
653
+ discussion_error, discussion_gap = _ingest_discussions(
573
654
  client,
574
655
  course,
575
656
  course_dir,
@@ -578,6 +659,8 @@ def ingest_files(
578
659
  )
579
660
  if discussion_error is not None:
580
661
  errors.append(discussion_error)
662
+ if discussion_gap is not None:
663
+ gaps.append(discussion_gap)
581
664
 
582
665
  for topic in chosen:
583
666
  if topic.availability == "external_link":
@@ -651,6 +734,7 @@ def ingest_files(
651
734
  interrupted=True,
652
735
  errors=tuple(errors),
653
736
  exit_code=130,
737
+ gaps=tuple(sorted(set(gaps))),
654
738
  )
655
739
  except SessionExpired:
656
740
  raise
@@ -695,6 +779,7 @@ def ingest_files(
695
779
  metadata_only=metadata_only,
696
780
  download_gaps=download_gaps,
697
781
  errors=tuple(errors),
782
+ gaps=tuple(sorted(set(gaps))),
698
783
  )
699
784
 
700
785
 
@@ -2982,27 +3067,39 @@ def _ingest_discussions(
2982
3067
  vault: Vault,
2983
3068
  *,
2984
3069
  include_authors: bool,
2985
- ) -> str | None:
2986
- """Fetch opt-in discussions while keeping raw author identity out of the vault."""
3070
+ ) -> tuple[str | None, str | None]:
3071
+ """Fetch opt-in discussions while keeping raw author identity out of the vault.
3072
+
3073
+ The return value is ``(error, gap)``. A missing optional discussion tool is a recorded gap;
3074
+ malformed data, authentication failures, and other unexpected failures remain errors.
3075
+ """
2987
3076
 
2988
3077
  values, complete, error = _fetch_collection(
2989
3078
  client, _endpoint_path(client, course, "discussions/forums/")
2990
3079
  )
2991
3080
  if error is not None:
2992
- return _safe_error("discussions", error)
3081
+ return _discussion_failure(course_dir, vault, error)
2993
3082
  if not complete:
2994
- return _safe_error("discussions", A2LError("incomplete response"))
3083
+ return _discussion_failure(course_dir, vault, A2LError("incomplete response"))
2995
3084
  if any(
2996
3085
  not isinstance(value, dict) or not _discussion_forum_is_valid(value) for value in values
2997
3086
  ):
2998
- return _safe_error("discussions", A2LError("metadata endpoint returned an invalid forum"))
3087
+ return _discussion_failure(
3088
+ course_dir,
3089
+ vault,
3090
+ A2LError("metadata endpoint returned an invalid forum"),
3091
+ )
2999
3092
  forum_ids = [value["ForumId"] for value in values if isinstance(value, dict)]
3000
3093
  if len({str(value) for value in forum_ids}) != len(forum_ids):
3001
- return _safe_error("discussions", A2LError("metadata endpoint returned duplicate forums"))
3094
+ return _discussion_failure(
3095
+ course_dir,
3096
+ vault,
3097
+ A2LError("metadata endpoint returned duplicate forums"),
3098
+ )
3002
3099
  try:
3003
3100
  existing_rows = _read_discussion_rows(course_dir / "_meta" / "discussions.json")
3004
3101
  except A2LError as exc:
3005
- return _safe_error("discussions", exc)
3102
+ return _discussion_failure(course_dir, vault, exc)
3006
3103
  key = _discussion_key(vault)
3007
3104
  posts = [
3008
3105
  post for value in values if isinstance(value, dict) for post in _discussion_posts(value)
@@ -3067,7 +3164,13 @@ def _ingest_discussions(
3067
3164
  paths.atomic_write_text(
3068
3165
  discussion_dir / "discussions.md", "\n".join(markdown_lines), root=vault.root
3069
3166
  )
3070
- return None
3167
+ metadata_coverage.write_collection_coverage(
3168
+ course_dir,
3169
+ "discussions",
3170
+ metadata_coverage.CollectionCoverage("complete"),
3171
+ root=vault.root,
3172
+ )
3173
+ return None, None
3071
3174
 
3072
3175
 
3073
3176
  def _discussion_key(vault: Vault) -> bytes: