course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

Description

@AndresL230

services/course_context_service.py::update_course_context is the single write chokepoint for the
class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
— they predate it — so they're filed here rather than fixed in that PR.

F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
(course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
aggregates are left standing — stale data keeps being served with no equivalent purge.

Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
concept_metrics for concepts present in this refresh — it has no delete step for concepts that
dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
offering_concept_stats forever, orphaned.

F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

_fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
"this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
(services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
isn't used here).

It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
transient failure this time silently overwrites them with [] instead of leaving the previous
(good) values in place or skipping the write.

F4 — the parse loop has no legacy-shape guards; its sibling reader does

_parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
quiz_context.context_json shape and explicitly guards this
(agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
"legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
either key iterates character-by-character and sprays single-character "misconceptions" into the
class aggregate; a dict under the key raises TypeError inside the for loop. That exception
propagates out of update_course_context, but every call site wraps it in a bare
try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
:889-890), so the failure is silently dropped rather than logged.

F11/F12 — N+1 reads and N+1 writes in the per-concept loop

_fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
step-7 loop (course_context_service.py:321), even though every node id across every concept is
already known before the loop starts (concept_data, built in step 4). It could be fetched once for
the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
several, given the existing chunking) per concept.

Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
For a course with many concepts this is 2N extra round-trips per update_course_context call.

Not fixing here

These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
filing rather than scope-creeping that PR.

Refs #587 (the deletion PR whose review turned these up).
Origin: PR #587/code-review medium pass, 2026-08-26.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

      Description

      @AndresL230

      services/course_context_service.py::update_course_context is the single write chokepoint for the
      class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
      pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
      the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
      — they predate it — so they're filed here rather than fixed in that PR.

      F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

      if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
      (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
      a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
      offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
      returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
      nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
      aggregates are left standing — stale data keeps being served with no equivalent purge.

      Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
      concept_metrics for concepts present in this refresh — it has no delete step for concepts that
      dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
      offering_concept_stats forever, orphaned.

      F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

      _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
      the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
      the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
      "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
      (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
      isn't used here).

      It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
      unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
      on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
      already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
      transient failure this time silently overwrites them with [] instead of leaving the previous
      (good) values in place or skipping the write.

      F4 — the parse loop has no legacy-shape guards; its sibling reader does

      _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
      for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
      isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
      quiz_context.context_json shape and explicitly guards this
      (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
      "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
      either key iterates character-by-character and sprays single-character "misconceptions" into the
      class aggregate; a dict under the key raises TypeError inside the for loop. That exception
      propagates out of update_course_context, but every call site wraps it in a bare
      try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
      :889-890), so the failure is silently dropped rather than logged.

      F11/F12 — N+1 reads and N+1 writes in the per-concept loop

      _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
      step-7 loop (course_context_service.py:321), even though every node id across every concept is
      already known before the loop starts (concept_data, built in step 4). It could be fetched once for
      the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
      several, given the existing chunking) per concept.

      Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
      call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
      straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
      for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
      For a course with many concepts this is 2N extra round-trips per update_course_context call.

      Not fixing here

      These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
      filing rather than scope-creeping that PR.

      Refs #587 (the deletion PR whose review turned these up).
      Origin: PR #587/code-review medium pass, 2026-08-26.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

          Description

          @AndresL230

          services/course_context_service.py::update_course_context is the single write chokepoint for the
          class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
          pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
          the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
          — they predate it — so they're filed here rather than fixed in that PR.

          F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

          if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
          (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
          a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
          offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
          returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
          nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
          aggregates are left standing — stale data keeps being served with no equivalent purge.

          Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
          concept_metrics for concepts present in this refresh — it has no delete step for concepts that
          dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
          offering_concept_stats forever, orphaned.

          F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

          _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
          the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
          the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
          "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
          (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
          isn't used here).

          It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
          unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
          on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
          already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
          transient failure this time silently overwrites them with [] instead of leaving the previous
          (good) values in place or skipping the write.

          F4 — the parse loop has no legacy-shape guards; its sibling reader does

          _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
          for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
          isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
          quiz_context.context_json shape and explicitly guards this
          (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
          "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
          either key iterates character-by-character and sprays single-character "misconceptions" into the
          class aggregate; a dict under the key raises TypeError inside the for loop. That exception
          propagates out of update_course_context, but every call site wraps it in a bare
          try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
          :889-890), so the failure is silently dropped rather than logged.

          F11/F12 — N+1 reads and N+1 writes in the per-concept loop

          _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
          step-7 loop (course_context_service.py:321), even though every node id across every concept is
          already known before the loop starts (concept_data, built in step 4). It could be fetched once for
          the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
          several, given the existing chunking) per concept.

          Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
          call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
          straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
          for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
          For a course with many concepts this is 2N extra round-trips per update_course_context call.

          Not fixing here

          These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
          filing rather than scope-creeping that PR.

          Refs #587 (the deletion PR whose review turned these up).
          Origin: PR #587/code-review medium pass, 2026-08-26.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

              Description

              @AndresL230

              services/course_context_service.py::update_course_context is the single write chokepoint for the
              class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
              pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
              the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
              — they predate it — so they're filed here rather than fixed in that PR.

              F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

              if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
              (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
              a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
              offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
              returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
              nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
              aggregates are left standing — stale data keeps being served with no equivalent purge.

              Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
              concept_metrics for concepts present in this refresh — it has no delete step for concepts that
              dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
              offering_concept_stats forever, orphaned.

              F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

              _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
              the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
              the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
              "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
              (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
              isn't used here).

              It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
              unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
              on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
              already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
              transient failure this time silently overwrites them with [] instead of leaving the previous
              (good) values in place or skipping the write.

              F4 — the parse loop has no legacy-shape guards; its sibling reader does

              _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
              for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
              isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
              quiz_context.context_json shape and explicitly guards this
              (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
              "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
              either key iterates character-by-character and sprays single-character "misconceptions" into the
              class aggregate; a dict under the key raises TypeError inside the for loop. That exception
              propagates out of update_course_context, but every call site wraps it in a bare
              try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
              :889-890), so the failure is silently dropped rather than logged.

              F11/F12 — N+1 reads and N+1 writes in the per-concept loop

              _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
              step-7 loop (course_context_service.py:321), even though every node id across every concept is
              already known before the loop starts (concept_data, built in step 4). It could be fetched once for
              the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
              several, given the existing chunking) per concept.

              Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
              call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
              straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
              for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
              For a course with many concepts this is 2N extra round-trips per update_course_context call.

              Not fixing here

              These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
              filing rather than scope-creeping that PR.

              Refs #587 (the deletion PR whose review turned these up).
              Origin: PR #587/code-review medium pass, 2026-08-26.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

                  Description

                  @AndresL230

                  services/course_context_service.py::update_course_context is the single write chokepoint for the
                  class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
                  pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
                  the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
                  — they predate it — so they're filed here rather than fixed in that PR.

                  F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

                  if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
                  (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
                  a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
                  offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
                  returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
                  nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
                  aggregates are left standing — stale data keeps being served with no equivalent purge.

                  Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
                  concept_metrics for concepts present in this refresh — it has no delete step for concepts that
                  dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
                  offering_concept_stats forever, orphaned.

                  F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

                  _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
                  the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
                  the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
                  "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
                  (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
                  isn't used here).

                  It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
                  unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
                  on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
                  already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
                  transient failure this time silently overwrites them with [] instead of leaving the previous
                  (good) values in place or skipping the write.

                  F4 — the parse loop has no legacy-shape guards; its sibling reader does

                  _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
                  for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
                  isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
                  quiz_context.context_json shape and explicitly guards this
                  (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
                  "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
                  either key iterates character-by-character and sprays single-character "misconceptions" into the
                  class aggregate; a dict under the key raises TypeError inside the for loop. That exception
                  propagates out of update_course_context, but every call site wraps it in a bare
                  try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
                  :889-890), so the failure is silently dropped rather than logged.

                  F11/F12 — N+1 reads and N+1 writes in the per-concept loop

                  _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
                  step-7 loop (course_context_service.py:321), even though every node id across every concept is
                  already known before the loop starts (concept_data, built in step 4). It could be fetched once for
                  the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
                  several, given the existing chunking) per concept.

                  Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
                  call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
                  straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
                  for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
                  For a course with many concepts this is 2N extra round-trips per update_course_context call.

                  Not fixing here

                  These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
                  filing rather than scope-creeping that PR.

                  Refs #587 (the deletion PR whose review turned these up).
                  Origin: PR #587/code-review medium pass, 2026-08-26.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

                      Description

                      @AndresL230

                      services/course_context_service.py::update_course_context is the single write chokepoint for the
                      class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
                      pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
                      the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
                      — they predate it — so they're filed here rather than fixed in that PR.

                      F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

                      if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
                      (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
                      a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
                      offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
                      returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
                      nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
                      aggregates are left standing — stale data keeps being served with no equivalent purge.

                      Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
                      concept_metrics for concepts present in this refresh — it has no delete step for concepts that
                      dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
                      offering_concept_stats forever, orphaned.

                      F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

                      _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
                      the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
                      the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
                      "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
                      (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
                      isn't used here).

                      It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
                      unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
                      on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
                      already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
                      transient failure this time silently overwrites them with [] instead of leaving the previous
                      (good) values in place or skipping the write.

                      F4 — the parse loop has no legacy-shape guards; its sibling reader does

                      _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
                      for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
                      isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
                      quiz_context.context_json shape and explicitly guards this
                      (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
                      "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
                      either key iterates character-by-character and sprays single-character "misconceptions" into the
                      class aggregate; a dict under the key raises TypeError inside the for loop. That exception
                      propagates out of update_course_context, but every call site wraps it in a bare
                      try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
                      :889-890), so the failure is silently dropped rather than logged.

                      F11/F12 — N+1 reads and N+1 writes in the per-concept loop

                      _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
                      step-7 loop (course_context_service.py:321), even though every node id across every concept is
                      already known before the loop starts (concept_data, built in step 4). It could be fetched once for
                      the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
                      several, given the existing chunking) per concept.

                      Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
                      call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
                      straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
                      for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
                      For a course with many concepts this is 2N extra round-trips per update_course_context call.

                      Not fixing here

                      These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
                      filing rather than scope-creeping that PR.

                      Refs #587 (the deletion PR whose review turned these up).
                      Origin: PR #587/code-review medium pass, 2026-08-26.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

                          Description

                          @AndresL230

                          services/course_context_service.py::update_course_context is the single write chokepoint for the
                          class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
                          pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
                          the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
                          — they predate it — so they're filed here rather than fixed in that PR.

                          F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

                          if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
                          (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
                          a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
                          offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
                          returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
                          nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
                          aggregates are left standing — stale data keeps being served with no equivalent purge.

                          Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
                          concept_metrics for concepts present in this refresh — it has no delete step for concepts that
                          dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
                          offering_concept_stats forever, orphaned.

                          F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

                          _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
                          the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
                          the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
                          "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
                          (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
                          isn't used here).

                          It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
                          unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
                          on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
                          already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
                          transient failure this time silently overwrites them with [] instead of leaving the previous
                          (good) values in place or skipping the write.

                          F4 — the parse loop has no legacy-shape guards; its sibling reader does

                          _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
                          for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
                          isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
                          quiz_context.context_json shape and explicitly guards this
                          (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
                          "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
                          either key iterates character-by-character and sprays single-character "misconceptions" into the
                          class aggregate; a dict under the key raises TypeError inside the for loop. That exception
                          propagates out of update_course_context, but every call site wraps it in a bare
                          try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
                          :889-890), so the failure is silently dropped rather than logged.

                          F11/F12 — N+1 reads and N+1 writes in the per-concept loop

                          _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
                          step-7 loop (course_context_service.py:321), even though every node id across every concept is
                          already known before the loop starts (concept_data, built in step 4). It could be fetched once for
                          the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
                          several, given the existing chunking) per concept.

                          Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
                          call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
                          straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
                          for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
                          For a course with many concepts this is 2N extra round-trips per update_course_context call.

                          Not fixing here

                          These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
                          filing rather than scope-creeping that PR.

                          Refs #587 (the deletion PR whose review turned these up).
                          Origin: PR #587/code-review medium pass, 2026-08-26.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              course_context rollup: opt-out purge gap, silent-empty overwrite, unguarded legacy shapes, N+1 reads/writes #595

                              Description

                              @AndresL230

                              services/course_context_service.py::update_course_context is the single write chokepoint for the
                              class-aggregate rollup (offering_concept_stats / offering_summary). PR #587's /code-review
                              pass (medium effort, 2026-08-26) turned up five pre-existing debts in the function while reviewing
                              the unrelated effective_explanations dead-read deletion. None of these are regressions from #587
                              — they predate it — so they're filed here rather than fixed in that PR.

                              F1 — two early exits skip the purge-on-empty / cache-clear the #72 write chokepoint otherwise guarantees

                              if not abstract_course_id: return (course_context_service.py:199) and if not node_rows: return
                              (course_context_service.py:214-215) both bail with a bare return. Contrast the sibling early exits
                              a few lines up (:174-179 "no enrollment" and :184-192 "all opted out"), which purge the stale
                              offering_concept_stats/offering_summary rows and call clear_course_context_cache() before
                              returning. If a course's abstract-course resolution goes empty, or every enrolled student's graph
                              nodes for the course disappear (e.g. the last node deleted), the previous refresh's published
                              aggregates are left standing — stale data keeps being served with no equivalent purge.

                              Separately, the step-7 upsert loop (course_context_service.py:319-338) only iterates
                              concept_metrics for concepts present in this refresh — it has no delete step for concepts that
                              dropped out since the last refresh (e.g. a concept's only node got removed). Those rows sit in
                              offering_concept_stats forever, orphaned.

                              F2 — two silent-empty catches turn a transient failure into "no quiz context", and the caller then overwrites good data with []

                              _fetch_quiz_context_rows's except Exception: rows = [] (course_context_service.py:270-271) and
                              the decrypt fallback except Exception: cj = {} (course_context_service.py:301-302) both swallow
                              the exception with no logging — a transient PostgREST error or a decrypt failure looks identical to
                              "this concept genuinely has no quiz context yet." That's the #529/#548 silent-empty class
                              (services/tool_signals.py::report_empty_result exists for exactly this shape of ambiguity and
                              isn't used here).

                              It compounds with the upsert: _parse_quiz_context_to_arrays's output (cm, pg) is written
                              unconditionally into the per-concept upsert (course_context_service.py:324-338), which runs
                              on_conflict="offering_id,concept_name" — a merge-duplicates UPDATE on an existing row. If the row
                              already had real common_misconceptions/prerequisite_gaps from a previous successful refresh, a
                              transient failure this time silently overwrites them with [] instead of leaving the previous
                              (good) values in place or skipping the write.

                              F4 — the parse loop has no legacy-shape guards; its sibling reader does

                              _parse_quiz_context_to_arrays's two loops (course_context_service.py:304-314) do
                              for m in cj.get("common_mistakes", []): / for w in cj.get("weak_areas", []): with no
                              isinstance(..., list) check. agents/tools/quiz_history.py::_coerce_summary reads the same
                              quiz_context.context_json shape and explicitly guards this
                              (agents/tools/quiz_history.py:103-104: if not isinstance(raw, list): continue) because
                              "legacy free-form rows can hold a string (or dict) under a list-shaped key." Here, a string under
                              either key iterates character-by-character and sprays single-character "misconceptions" into the
                              class aggregate; a dict under the key raises TypeError inside the for loop. That exception
                              propagates out of update_course_context, but every call site wraps it in a bare
                              try: ... except Exception: pass (e.g. services/graph_service.py:447-450, :504-507, :540-541,
                              :889-890), so the failure is silently dropped rather than logged.

                              F11/F12 — N+1 reads and N+1 writes in the per-concept loop

                              _fetch_quiz_context_rows (course_context_service.py:257-273) is called once per concept inside the
                              step-7 loop (course_context_service.py:321), even though every node id across every concept is
                              already known before the loop starts (concept_data, built in step 4). It could be fetched once for
                              the full node-id set and grouped by concept afterward, instead of one quiz_context SELECT (or
                              several, given the existing chunking) per concept.

                              Symmetrically, each concept issues its own single-row table("offering_concept_stats").upsert(...)
                              call (course_context_service.py:324-338). db/connection.py::SupabaseTable.upsert posts data
                              straight through to PostgREST (Prefer: resolution=merge-duplicates), which accepts a JSON array
                              for a native batch upsert — so all of this refresh's concept rows could go in one call instead of N.
                              For a course with many concepts this is 2N extra round-trips per update_course_context call.

                              Not fixing here

                              These are all pre-existing in the touched function, not introduced by #587's dead-read deletion —
                              filing rather than scope-creeping that PR.

                              Refs #587 (the deletion PR whose review turned these up).
                              Origin: PR #587/code-review medium pass, 2026-08-26.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions