Skip to content

docs(feedback): spec set for Feedback Aggregation & Clustering System - #2422

Merged
blarghmatey merged 12 commits into
mainfrom
worktree-feedback-aggregation-spec
Aug 19, 2026
Merged

blarghmatey merged 12 commits into
mainfrom
worktree-feedback-aggregation-spec

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What & why

Advances the Feedback Aggregation & Clustering System through its spec phase. The architecture RFC (mitodl/hq#12210, supersedes #10793) settled the two decisions — contract-first interim landing and one common tfact_feedback. This PR turns that direction into build-ready, repo-grounded specs so implementation can start without re-deriving source columns, dbt conventions, or the ML/orchestration shape.

The dimensional-model design existed only as an uncommitted draft in an unrelated worktree; it is recovered here and joined by five new specs.

Contents (docs/design/)

Doc Scope
README_feedback_aggregation.md Spec index, key decisions, phasing
feedback_dimensional_model.md tfact_feedback schema (recovered)
feedback_event_contract_spec.md Common event contract + migration-proof business keys
feedback_zendesk_mvp_spec.md Build-ready Zendesk MVP dbt models w/ exact repo columns
feedback_ml_approach.md Embedding / UMAP+HDBSCAN clustering / category discovery / sentiment
feedback_dagster_asset_spec.md Batch ML asset cloning student_risk_probability; Vault LLM resource
feedback_consumption_ux_spec.md Audiences × altitude, Superset surfaces, access control

Key decisions

  1. Contract-first, interim learn-ai landing → migrate to the analytics-api/StarRocks data bus later; durable artifact is the event contract + stable business keys, so migration = source-swap + backfill + parity.
  2. One common tfact_feedback mirroring tfact_discussion_events, with sparse nullable conformed FKs; explicit stable feedback_pk (diverges from precedent) for migration + late-arriving category/sentiment.
  3. ML is additive, not a prerequisite — the fact ships useful with tag-seeded categories + CSAT-derived sentiment; embeddings persisted once.
  4. Local, PII-safe embeddings by default; Presidio redaction mandatory pre-embed.
  5. Superset-first consumption; support + engineering are the MVP-served audiences.

Scope

Design/tracking only — no pipeline code. MVP ships the fact first; the ML Dagster asset is a separate, additive follow-up.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 10, 2026 11:44
Comment thread docs/design/feedback_zendesk_mvp_spec.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a build-oriented spec set for the “Feedback Aggregation & Clustering System”, defining the event contract, the tfact_feedback dimensional model, an MVP Zendesk ingestion plan, an ML clustering/sentiment approach, a Dagster asset shape, and Superset-first consumption/UX guidance.

Changes:

  • Added a spec index and phased roadmap for the feedback aggregation initiative.
  • Defined a common feedback event contract + migration-proof business keys and a tfact_feedback Kimball model.
  • Described MVP Zendesk dbt model DAG plus an additive Dagster ML asset plan and consumption surfaces.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/design/README_feedback_aggregation.md Indexes the spec set, key decisions, and phasing for implementation.
docs/design/feedback_dimensional_model.md Defines the tfact_feedback dimensional model and related conformed/new dimensions.
docs/design/feedback_event_contract_spec.md Specifies the source-agnostic event contract and stable business-key strategy.
docs/design/feedback_zendesk_mvp_spec.md Provides a Zendesk-only MVP dbt model plan grounded in repo conventions/macros.
docs/design/feedback_ml_approach.md Specifies embedding, clustering, category discovery, and sentiment approach as additive consumers.
docs/design/feedback_dagster_asset_spec.md Specifies a Dagster ML code location/asset graph to generate embeddings/clusters/labels.
docs/design/feedback_consumption_ux_spec.md Specifies Superset-first consumption, personas, and access-control expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/design/feedback_dimensional_model.md Outdated
Comment thread docs/design/feedback_dimensional_model.md Outdated
Comment thread docs/design/feedback_dagster_asset_spec.md Outdated
@blarghmatey
blarghmatey force-pushed the worktree-feedback-aggregation-spec branch from cb23bf4 to 9b0a34f Compare July 11, 2026 00:55
@pdpinch

pdpinch commented Aug 3, 2026

Copy link
Copy Markdown
Member

This seems to be missing a unambiguous expression of what the feedback is about. This data will be source-specific, but it's highly valuable to aggregating feedback and understanding it. For example:

  • For the data coming from the edX feedback system, we will capture the edX courseware block id that the feedback is about.
  • For some ZenDesk tickets, there is metdata about the course that the ticket is about.
  • For ZenDesk tickets that originate with Appzi, the URL the user was viewing at the time of feedback is captured (although encoded)

The schema should have a place to store this when available.

Comment thread docs/design/feedback_zendesk_mvp_spec.md
blarghmatey added a commit that referenced this pull request Aug 7, 2026
The spec set described the schema only as prose column lists, which made the
star hard to review and let two gaps sit unnoticed until @pdpinch found them.

Adds feedback_erd.md (Mermaid ERDs for the star, the event contract, the ML
sidecar and the Phase-1 subset) and applies three schema deltas:

- Subject reference. The model captured who/when/which-conversation but had no
  slot for what the feedback is *about* — the axis you actually aggregate on.
  Adds a polymorphic subject_type/subject_ref/subject_url triple, resolved to a
  conformed FK where one exists: content_block_fk -> dim_course_content for edX
  blocks, courserun_fk for runs. No new subject dimension, because the subject
  is polymorphic across dims that already exist.
- Zendesk brand/group facets (hq#12607). Pass-through: int__zendesk__ticket
  already carries both.
- ML sidecar grain split. Vectors are per (feedback_pk, model_version) but
  cluster assignments are per (feedback_pk, cluster_run_id); one table meant a
  re-cluster could rewrite vectors, forfeiting the property the sidecar exists
  to buy. Splits into feedback_embeddings / feedback_cluster_run /
  feedback_cluster_assignment, drops the redundant embedding_id from the fact.

Also records that organization_fk is structurally unresolvable for Zendesk
under dim_organization's ['platform','source_id'] key, so it is a decision
rather than a build-time surprise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6XP15iW4epMuLG7xszojk
@blarghmatey

Copy link
Copy Markdown
Member Author

Addressed in 1c2d314, along with a set of ERDs — thanks, both comments landed on real gaps.

"What the feedback is about" — you're right, and it's the worst gap to have because it's the axis you aggregate on. The contract had who, when, and in what conversation, and nothing for the subject. Added as a polymorphic degenerate triple on both the contract and the fact:

  • subject_type — courseware_block | course_run | course | program | page_url | resource | unspecified
  • subject_ref — the source-native id of that thing
  • subject_url — canonical/decoded deep link

Deliberately not a new dim_feedback_subject: the subject is polymorphic across dimensions that already exist (a block is dim_course_content, a run is dim_course_run, a program is dim_program), so a new dim would shadow them. Instead the triple is the universal fallback that always works — including for URLs that resolve to nothing in the warehouse — and the fact resolves it to a conformed FK where one exists. courserun_fk already covered the course case; the one new conformed join is content_block_fk → dim_course_content.content_block_pk, the existing Open edX block SCD that tfact_problem_events and tfact_course_navigation_events already use. So edX-plugin and ORA feedback lands in the courseware star with zero new dimensional work.

On your three cases specifically: the plugin's courseware block id → content_block_fk; Zendesk course metadata → subject_type='course_run' and courserun_fk; Appzi's encoded URL → decoded in int__feedback__zendesk, populating subject_url. Decoding is explicitly the adapter's job now, not every consumer's query.

Brand & Group (hq#12607) — cheap, because int__zendesk__ticket already carries brand_name and group_name (joined from stg__zendesk__brand / stg__zendesk__group). They ride in source_metadata at the contract boundary and project to source_brand / source_group facet columns on the fact, so Superset filters a plain varchar instead of a JSON extract. One thing worth flagging: brand is effectively "which help centre/product the ticket came in through" — the closest thing Zendesk has to a platform. If brands map cleanly onto dim_platform, platform_fk need not be null for Zendesk at all; noted as an implementation-time check.


Also in this push: docs/design/feedback_erd.md — Mermaid ERDs for the star schema, the event contract, the ML sidecar and the Phase-1 subset, since the specs described everything as prose column lists. Drawing it surfaced two more things:

  • The ML sidecar had a grain bug. Vectors are one row per (feedback_pk, model_version); cluster assignments are one row per (feedback_pk, cluster_run_id). Holding both in a single feedback_embeddings table means re-clustering against an unchanged model rewrites or duplicates vector rows — forfeiting the exact "re-clustering never touches the durable artifact" property the sidecar exists to buy. Split into feedback_embeddings / feedback_cluster_run / feedback_cluster_assignment. Consequence: embedding_id is dropped from the fact (the sidecar joins on feedback_pk, so it added no reachability while forcing a fact write when embeddings land), and dim_feedback_category gains a cluster_run_id provenance column.
  • organization_fk is structurally unresolvable for Zendesk. dim_organization.organization_pk = generate_surrogate_key(['platform','source_id']) and Zendesk supplies neither. It stays null at MVP with the org/group carried as a facet — recorded as an explicit decision rather than something discovered at build time. If we want it resolved, dim_organization needs a Zendesk-aware alternate key.

The ERDs are also posted as an addendum on RFC #12210. The business-key strategy is unchanged, so the interim → data-bus migration is still "swap source + backfill + parity".

@blarghmatey

Copy link
Copy Markdown
Member Author

rev. 2 — turn grain + a conformance rule (e5a62be)

Second round of review feedback folded in, from @KatelynGit on RFC #12210 (full reply there). This one is larger than the last and supersedes parts of the rev. 1 ERD addendum, so flagging the reversals explicitly.

The rule

An attribute earns a column on the fact, or a conformed dimension, only if two or more sources can populate it. Everything else lives in a source_metadata variant column, and is promoted when a second source starts supplying it.

tfact_feedback aggregates Zendesk plus forum, tutor, ORA and the edX plugin; shaping the core tables around what Zendesk exposes makes every later source pay for it. Applying this rule is what produced most of the changes below — and reversed two of my own from last week.

Grain moved from ticket to turn

The stated grain was already "one atomic utterance", but Zendesk was modelled at ticket grain (first comment only) — the only source where "utterance" silently meant "whole conversation". int__learn_ai__chatbot is already one row per djangocheckpoint; the forum sources are one row per tracking-log event; int__zendesk__ticket_comment already exists at comment grain.

So Zendesk moves to comment grain, and first-comment-only becomes a filter (is_conversation_opening) rather than a grain decision. Exclusions are now one rule stated per source — the author must be the person giving feedback, not the platform answering.

This moves the business key: Zendesk source_record_ref goes ticket_id → comment_id. The feedback_pk formula is unchanged (so the data-bus migration story holds), but the value is not — which is why it had to land before the fact ships rather than after it has consumers.

It also closes a recall gap: under ticket grain, a problem first articulated in turn 4 is never embedded, so the clustering can't find it.

Schema changes

Added dim_feedback_channel (conformed — every source has a channel; carries is_solicited), dim_feedback_tag + bridge_feedback_tag (replaces the array<varchar> on the fact), afact_feedback_conversation (accumulating snapshot at conversation grain), turn_index, is_conversation_opening, source_metadata variant, created/updated role-playing FKs
Renamed date_fk/time_fk → occurred_date_fk/occurred_time_fk; csat_score → explicit_rating
Removed from the fact source_status, source_priority, source_channel, source_brand, source_group → source_metadata; source_tags → the bridge
Withdrawn from rev. 1 the dim_feedback_context junk dimension (Zendesk-shaped — forum/tutor/ORA would all key to one "everything null" row) and the source_brand/source_group facet columns added for hq#12607. @pdpinch — both fields are still carried and filterable, just unpacked from the variant rather than pre-flattened

source_metadata persists as a varchar holding a JSON string (Iceberg v2 has no native JSON type), read with the repo's existing json_query_string / json_extract_value macros. Precedent: dim_course_content.block_metadata. StarRocks has a native JSON type and the macros already dispatch to it, so that migration is a column-type change, not a redesign.

Lifecycle timestamps — right instinct, blocked on ingestion

"How long did tickets stay open" can't be answered today. The Zendesk streams in the lake are exactly seven — tickets, ticket_comments, ticket_fields, brands, groups, organizations, users. solved_at / closed_at / first_resolution_time live in ticket_metrics; state history lives in ticket_audits; neither is synced. afact_feedback_conversation ships with turn_count, participant_count, opened_date_fk, final_status, explicit_rating, and gains the durations additively once the stream lands.

Prerequisites (recorded, not assumed)

Item Blocking?
Carry comment_author_user_id through int__zendesk__ticket_comment — it's in staging, but the int model exposes only the author name. Needed to separate requester from agent turns and to resolve user_fk yes
Measure public requester comments per ticket — the ~198K figure was tickets and no longer applies; it sizes both the fact and the embedding budget yes
Run the PII profiler over comment_plain_body — only ticket_description is classified today, and follow-up turns are where people paste order numbers and alternate emails before first embed
Add the ticket_metrics Airbyte stream no
Confirm the conformed channel_slug value set against each source's real channel values no

Cost

MVP goes from 3 new dimensions to 4 dimensions + 1 bridge + 1 conversation fact. The additions are select distinct cheap or a straight aggregation, so the build cost is small — but it is a real increase and worth confirming deliberately rather than absorbing quietly. Two knock-ons for the ML spec: cluster ranking must use distinct conversation_id, not row count (or one talkative user manufactures a systemic issue), and short follow-up turns make the semantic-normalization eval arm more relevant, not less.

Diagrams: docs/design/feedback_erd.md, which now carries a change log.

@blarghmatey

Copy link
Copy Markdown
Member Author

rev. 3 — the conversation is the analysis unit

Pushed in e376498, following RFC #12210 (rewritten today).

What changed. afact_feedback_conversation becomes the analysis fact: one row per conversation (Zendesk ticket, forum thread, tutor thread, ORA submission) carrying the LLM summary, the embedding vector, category_fk, sentiment_fk and cluster membership alongside the lifecycle columns it already had in rev. 2.

category_fk/sentiment_fk are removed from tfact_feedback, which makes that fact insert-only — there is no post-insert write path to it at all, so the entire inferred layer can be rebuilt without touching the record of what people actually wrote. The per-turn ML sidecar (feedback_embeddings, feedback_cluster_assignment) is withdrawn; feedback_cluster_run survives and a small feedback_cluster_candidate covers run-vs-run comparison for the embedding bake-off.

Why. A support ticket's actual complaint usually emerges over several exchanges. Embedding turns independently splits one issue into several weak cluster members, scores sentiment off a fragment ("still not working"), and makes cluster size a function of how talkative the reporter was.

This is not a return to rev. 1's ticket grain. Rev. 2 fixed what gets recorded — a problem first articulated in turn 4 now lands in the warehouse. Rev. 3 fixes what gets analyzed — turn 4 is part of the same analysis unit as turn 1 rather than a separate one. The turn grain is what makes assembling a complete conversation possible, so it is load-bearing for this change, not undone by it.

Two rev. 2 caveats resolve for free:

  • min_cluster_size counts conversations again, so the rank-by-distinct-conversation_id workaround and afact_feedback_cluster_daily's twin count columns are gone.
  • Zendesk's ticket-level CSAT is now grain-matched to the sentiment it seeds, instead of a weak label propagated across every turn of a rated ticket.

The cost, stated up front. The summary is the one genuinely per-record LLM call in the design — roughly low hundreds of dollars one-time for Zendesk, tens per year steady-state, skipped entirely for single-turn and short conversations (ORA and the edX plugin are free by construction). That is one to two orders of magnitude above the $2–16 embedding backfill and a real departure from the "trivial batch cost" posture this project has carried since discovery, so feedback_zendesk_mvp_spec.md §0 now asks for the multi-turn share to be sample-measured before the backfill. Offsetting it: the embedding input drops from ~1.18M turns to ~600K conversations, and the MVP figure is back to a known ~198K rather than an unmeasured comment multiplier.

Zendesk sourcing is unchanged and confirmed. int__zendesk__ticket_comment exists at one row per comment with ticket_id and comment_plain_body; the comment_author_user_id prerequisite is verified real (it is in stg__zendesk__ticket_comment, and the int model joins it away, exposing only comment_author as a name).

Files: all eight design docs. feedback_dimensional_model.md §4f/§5a/§5b and feedback_erd.md §4/§5 are where the substance is; the rest is propagation plus new tests in feedback_zendesk_mvp_spec.md §8 (notably: summary_model_version must be null exactly where the skip rule applies, so a silent summarizer failure can't read as "short conversation").

One consumption-layer sharpening worth a reviewer's eye: conversation_summary is generated text and inherits the redacted-text classification, so "leadership sees aggregates only" can no longer be read as "leadership sees anything prefixed afact_" — they get afact_feedback_cluster_daily, not the conversation fact.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 13 comments.

Suppressed comments (18)

docs/design/adr_embedding_compute_strategy.md:139

  • This summary reintroduces decisions that the ADR's rev. 3 text explicitly replaced: provider selection is by task effectiveness rather than PII policy, and vectors live on afact_feedback_conversation rather than a sidecar. Update the net-change section so implementers do not follow the superseded architecture.
- `feedback_ml_approach.md` §B default: **engine-external Fenic pipeline (Apache-2.0), embedding
  provider chosen by PII policy** (in-account Bedrock via boto3/contrib preferred; managed
  provider on redacted text acceptable if policy allows). Vectors → Iceberg `ARRAY<float>` sidecar.
- `feedback_dagster_asset_spec.md`: embedding/sentiment run via Fenic in the Dagster asset (not
  torch, not a Starburst SQL function); clustering stays sklearn HDBSCAN; no `trino_only` AI models.

docs/design/adr_embedding_compute_strategy.md:142

  • This final sentence still describes the withdrawn sidecar and late-arriving FKs on tfact_feedback. Rev. 3 moved vectors/category/sentiment to the conversation fact and made the turn fact insert-only.
- Schema, contract, and business keys unchanged — compute location was always meant to be
  swappable (vectors in a sidecar; category/sentiment late-arriving FKs).

docs/design/feedback_zendesk_mvp_spec.md:97

  • Ordering only by a second-precision timestamp is not deterministic when two comments share comment_created_at; turn_index, is_conversation_opening, and assembled text order can then change between builds. Use the stable comment_id as a tie-breaker.
`turn_index` = `row_number() over (partition by ticket_id order by comment_created_at)` over the *kept*
comments; `is_conversation_opening` = `turn_index = 1`.

docs/design/feedback_zendesk_mvp_spec.md:409

  • conversation_id is source-native, so the same value can occur in Zendesk, forum, and tutor data. Relationship tests on that column alone can falsely pass against another source's conversation and permit missing or cross-joined conversations. Test the (feedback_source_fk, conversation_id) pair in both directions (or test feedback_conversation_pk) instead.
- `afact_feedback_conversation`: `unique` + `not_null` on `feedback_conversation_pk`; `not_null` on
  `conversation_id` and `feedback_source_fk`; `turn_count >= 1`; `relationships` from `conversation_id` to
  `tfact_feedback.conversation_id` **and the reverse** — every turn's `conversation_id` must resolve here, so
  a conversation cannot go missing from the analysis fact and quietly drop its turns out of every cluster.

docs/design/feedback_zendesk_mvp_spec.md:340

  • The row count is not known to equal the ticket count: this model is built from kept requester-authored turns, and the validation section explicitly acknowledges tickets that the filter can remove. The turn_count >= 1 and bidirectional relationship requirements also exclude zero-turn tickets. Treat 198K as an upper bound until the prerequisite measurement runs, or redefine the fact/tests to include every ticket.
**Row count is known:** one row per Zendesk ticket, ~198K. Unlike the turn fact, this table needs no volume
measurement before it can be sized.

docs/design/feedback_dagster_asset_spec.md:214

  • Step 1 says the conversation fact has lifecycle columns only, so it cannot simultaneously ship category or sentiment FKs. The Zendesk spec describes those two as no-model dbt derivations; include those columns/derivations in Step 1, or remove the claim that they are available before Step 7 adds generated columns.
1. dbt models (`feedback_zendesk_mvp_spec.md`) — `tfact_feedback` + dims +
   `int__feedback__conversation` + `afact_feedback_conversation` with its **lifecycle columns only**.
   **Ships and is useful with tag-seeded categories + CSAT-derived sentiment, no ML.**

docs/design/feedback_dimensional_model.md:661

  • The closing fence has no matching opening fence in the change-log section, so it renders the document ending incorrectly.
**docs/design/feedback_consumption_ux_spec.md:142**
* This claims leadership is served in MVP, but §2 and §4 require leadership to use `afact_feedback_cluster_daily`, which is explicitly Phase 2, and the PR scope names only support and engineering as MVP audiences. Either move the aggregate into MVP or state that leadership waits for Phase 2.

Given the MVP is Zendesk-only and not course-scoped: support + engineering get working
cluster-triage dashboards (Superset); a curator gets the label-approval loop (Marimo);
leadership gets Zendesk-only category/sentiment trends; instructors get nothing until Phase 2
course-scoped sources land.
That is the honest scope — the model supports all four audiences and
all three surfaces, but only two-and-a-half audiences are served by the first data slice. This is

**docs/design/feedback_dagster_asset_spec.md:148**
* The dependency section does not reflect this document's adopted ADR: it omits the required `fenic` package and still makes local `sentence-transformers` the default, even though §0 calls it a fallback and selects a Fenic/provider bake-off. Following this table would implement the superseded architecture.

Confirmed absent repo-wide today (no openai/anthropic/sentence-transformers/transformers/
torch/hdbscan/umap/faiss/qdrant/pgvector anywhere). Add to the new project's
pyproject.toml only (keep them out of the shared lib and other locations):

| Purpose | Dep | Notes |

**docs/design/feedback_erd.md:58**
* The ERD labels the rollup relationship with only `conversation_id`, but that identifier is source-native. The logical relationship is the composite source/conversation pair; otherwise Phase-2 sources with the same ID can cross-join.
afact_feedback_conversation ||--o{ tfact_feedback : "conversation_id (turns roll up)"
**docs/design/feedback_erd.md:435**
* The Phase-1 diagram includes five dimensions (`source`, `channel`, `category`, `sentiment`, and `tag`), so this cost check undercounts the tables to implement.

MVP cost check. Phase 1 is 4 dimensions + 1 bridge + 2 facts. The dimension additions are select distinct cheap and the conversation fact is a straight aggregation, so the dbt build cost is small. Rev. 3

**docs/design/feedback_dimensional_model.md:582**
* The MVP list immediately above contains five dimensions, so the rev. 2 cost summary still undercounts by one.

Cost note. Rev. 2 moved MVP from 3 new dimensions to 4 dimensions + 1 bridge + 1 conversation fact — all
select distinct cheap or a straight aggregation, so the dbt build cost is small. Rev. 3 adds no new

**docs/design/feedback_consumption_ux_spec.md:76**
* The summarization skip rule leaves single-turn/short conversations without an LLM summary, yet this UI always renders `conversation_summary`. Since the conversation fact has no fallback text column, many MVP rows can display blank. Specify that skipped rows copy the redacted assembled text into `conversation_summary`, or define an explicit fallback join/coalesce for consumers.

distinct-count workaround is gone), filterable by dim_feedback_channel, tags via bridge_feedback_tag,
and status/priority extracted from source_metadata with json_query_string. Each cluster lists its
conversations by conversation_summary — readable without expanding anything — and each conversation
drills through to its constituent (redacted) turns in tfact_feedback, with source_url deep-links back

**docs/design/README_feedback_aggregation.md:100**
* The MVP schema listed in this spec contains five dimensions (`source`, `channel`, `category`, `sentiment`, and `tag`), not four. This undercounts implementation scope.
  • MVP (Phase 1): Zendesk-only fact at turn grain + 4 dims + bridge_feedback_tag +
**docs/design/feedback_dagster_asset_spec.md:200**
* `feedback_conversation_pk` alone cannot identify changed conversations: a ticket keeps the same key when new turns arrive. An anti-join on “already summarized” rows would therefore leave summaries/embeddings stale, while recomputing every existing key incurs the stated full-corpus cost. Persist and compare a deterministic conversation-content hash (and propagate invalidation downstream) to implement this incrementality safely.

assets use — no cron to maintain. Caveat now that summarization costs money per conversation: make the
summarizer incremental on feedback_conversation_pk (only unsummarized or changed conversations), or an
upstream refresh re-pays for the whole corpus.

**docs/design/feedback_event_contract_spec.md:33**
* The contract defines an explicit `unspecified` discriminator, so allowing `subject_type` to be null creates a second undocumented “unknown” state and bypasses the accepted-values test. Consumers switching on this field cannot distinguish it safely. Require the field and use `unspecified` when no subject is known.

| subject_type | string | what the feedback is about: courseware_block | course_run | course | program | page_url | resource | unspecified | no |

**docs/design/feedback_ml_approach.md:269**
* This is stale after rev. 3: cluster labeling is one call per cluster, but the design now also performs per-conversation summarization, so total LLM cost is not bounded to hundreds of calls and the per-record summary is no longer rejected. Scope this statement to labeling and point to §A.1 for summarization cost.
  • LLM cost is bounded: one LLM call per cluster, not per record (there are hundreds
    of clusters, not millions of tickets). This is the critical cost distinction from the
    rejected per-record semantic-summary approach.
**docs/design/adr_embedding_compute_strategy.md:89**
* Rev. 3 says vectors live in the `embedding_vector` column on `afact_feedback_conversation`, not in a sidecar. Keeping the old storage target here makes the ADR contradict its own revised decision and the Dagster write plan.

This issue also appears in the following locations of the same file:
- line 135
- line 141

Either way the vectors land in an open Iceberg ARRAY<float> sidecar, so when StarRocks
becomes the serving/OLAP + data-bus engine it reads those vectors and builds an HNSW index
over them (a load, not a re-embed) — StarRocks ANN is the intended vector-serving tier,
reached with no rework. dbt models stay pure and portable (existing
default__/duckdb__/starrocks__ dispatch); **no engine-native AI SQL functions, no

</details>

Comment thread docs/design/feedback_zendesk_mvp_spec.md
Comment thread docs/design/feedback_zendesk_mvp_spec.md
Comment thread docs/design/feedback_zendesk_mvp_spec.md Outdated
Comment thread docs/design/feedback_event_contract_spec.md Outdated
Comment thread docs/design/feedback_dagster_asset_spec.md Outdated
Comment thread docs/design/feedback_erd.md Outdated
Comment thread docs/design/feedback_dimensional_model.md
Comment thread docs/design/feedback_zendesk_mvp_spec.md Outdated
Comment thread docs/design/feedback_event_contract_spec.md Outdated
Comment thread docs/design/feedback_event_contract_spec.md
Comment thread docs/design/feedback_zendesk_mvp_spec.md Outdated
Comment thread docs/design/feedback_zendesk_mvp_spec.md Outdated
blarghmatey added a commit that referenced this pull request Aug 18, 2026
… (rev. 4)

Copilot and @rachellougee's review surfaced real bugs in the spec set: a
second missing prerequisite column, a redaction placement that created a
dependency cycle, a hash-of-a-hash PK, a nonexistent dim_sentiment member,
a participant_count that was structurally always 1, a source-native join
key that can collide across sources, an unversioned dim_course_content
lookup, a stale event-contract line reverting to ticket grain, two missing
contract fields, PII gaps in source_metadata, a non-numeric column averaged
as a float, a single-output asset with two writes, and a stale dim_user.user_pk
formula plus an unanswered identity re-match question. No grain, business-key,
or fact/asset-shape changes — same class of fix as rev. 2/3, narrower scope.
@blarghmatey

Copy link
Copy Markdown
Member Author

rev. 4 — implementation-blocking fixes (d2c894f)

Addressed all 15 open threads from this round (@copilot-pull-request-reviewer, @rachellougee) — 13 fixes, all narrow, no grain/business-key/fact-shape changes:

  • Second missing prerequisite column: ticket_requester_user_id on int__zendesk__ticket (same class as comment_author_user_id — the join already exists, only the id wasn't selected).
  • Redaction dependency cycle: feedback_redacted is now its own Phase-1 Dagster asset, upstream of tfact_feedback and distinct from the summarize/embed/cluster pipeline — rev. 3's "same asset embeds and redacts" would have made the fact depend on the ML asset while the ML asset's input depends on the fact.
  • category_slug hash-of-hash, non-existent unknown sentiment member, participant_count always 1 (derived from filtered/kept turns instead of the ticket's full comment history), unqualified conversation_id join (now (feedback_source_fk, conversation_id) everywhere), unversioned dim_course_content lookup (now is_latest = true + platform/courserun), stale source_record_ref = ticket_id in the event contract, conversation_ref/source_url missing from the contract, PII gaps in source_metadata (ticket_satisfaction_rating_comment/custom_fields excluded pending profiling rather than redacted-and-included), avg_explicit_rating on a non-numeric column (replaced with rated_conversation_count), and feedback_clusters as a plain @asset with two writes (now a @multi_asset) — all fixed.
  • dim_user.user_pk's formula corrected to match current main (coalesce(user_global_id, id_source_user_id, email), not email-only), and subject_user_ref retained on tfact_feedback so identity can be re-matched later without a rebuild — answers @rachellougee's re-match question directly rather than deferring to an unspecified task.

Each doc's own change-log (rev. 4, or rev. 5 in feedback_dagster_asset_spec.md) has the full rationale. All 15 threads replied to individually with the specific fix and resolved.

blarghmatey and others added 6 commits August 18, 2026 13:35
Advance the Feedback Aggregation & Clustering System project through its
spec phase. The dimensional-model design (previously drafted but never
committed) is recovered and joined by concrete, repo-grounded specs that
resolve the open items it handed to downstream tasks.

Why: the RFC (mitodl/hq#12210) settled the two architecture decisions
(contract-first interim landing; one common tfact_feedback). This turns
that direction into build-ready specs so implementation can start without
re-deriving source columns, dbt conventions, or the ML/orchestration shape.

Adds under docs/design/:
- README_feedback_aggregation.md  — spec index + key decisions + phasing
- feedback_dimensional_model.md   — recovered schema design (tfact_feedback)
- feedback_event_contract_spec.md — common event contract + business keys
- feedback_zendesk_mvp_spec.md    — build-ready Zendesk MVP dbt models/columns
- feedback_ml_approach.md         — embedding/clustering/category/sentiment
- feedback_dagster_asset_spec.md  — batch ML asset (clones student_risk_probability)
- feedback_consumption_ux_spec.md — audiences, Superset surfaces, access control

Design/tracking only; no pipeline code. MVP ships the fact first; ML is
an additive consumer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJXxq9oEabCbJiXFzSTqkY
Evaluate in-engine AI functions and a DataFrame AI framework against the
original local-torch embedding plan. Decisive repo fact: production runs
Trino on Starburst Galaxy (StarRocks is not deployed), whose in-SQL AI
functions (generate_embedding → Iceberg ARRAY(DOUBLE), public preview) can
embed inside the engine we already run, on AWS Bedrock in-account.

New default: Starburst AI generate_embedding as a trino_only dbt model —
eliminates the net-new torch/sentence-transformers service and removes the
third-party-egress objection (Bedrock-in-account over redacted text). Only
clustering + LLM cluster-labeling remain a (now lightweight) Python asset,
since neither Trino nor DuckDB does density clustering and we want HDBSCAN's
noise class. Fenic and local self-hosted embeddings kept as fallbacks;
StarRocks vector index folded into the Phase-3 data-bus note.

Adds adr_embedding_compute_strategy.md; revises ml_approach §B,
dagster_asset_spec header, and the README index/decisions accordingly.
Schema, contract, and business keys unchanged (compute location was always
meant to be swappable — vectors in a sidecar, category/sentiment late-arriving).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJXxq9oEabCbJiXFzSTqkY
…arRocks)

Strategic direction is to retire Trino for StarRocks (transforms + data bus +
OLAP serving), so no Galaxy-only functionality may sit on the critical path.
This withdraws rev.1's Starburst generate_embedding recommendation: it is a
Galaxy-proprietary SQL function in exactly the layer being migrated, and
StarRocks has no embedding-generation equivalent to migrate it to.

New decision: keep all AI/embedding compute engine-external (Dagster/Python)
reading/writing open Iceberg, and generate embeddings by calling AWS Bedrock
(amazon.titan-embed-text-v2:0) DIRECTLY via boto3 — same in-account/PII-safe
Bedrock substrate Starburst's function merely wraps, minus the lock-in and
minus torch. Vectors land in an Iceberg ARRAY<float> sidecar that StarRocks
later reads to build an HNSW index (a load, not a re-embed), making StarRocks
ANN the intended serving tier. Sentiment stays the engine-agnostic CSAT-seed +
embedding-kNN path (no analyze_sentiment); clustering stays Python HDBSCAN.
dbt models stay portable (no trino_only AI models).

Fenic (cloud-egress, K-means) and local sentence-transformers kept as
fallbacks. Schema/contract/business-keys unchanged — compute location was
always meant to be swappable.
…mework

Verified against fenic source (main): Fenic is Apache-2.0 and its shipped
embedding providers are OpenAI/Cohere/Google (Developer+Vertex) — genuinely
open source and provider-agnostic, so it's a first-class fit for the portable
engine-external layer (not the downgraded footnote rev.2 made it). Correction
to the record: there is NO BedrockEmbeddingModel config class yet — "Bedrock"
in config.py is only an OpenRouter LLM-routing target (not embeddings), and
ROADMAP lists Bedrock as planned. So native in-account Bedrock embeddings via
Fenic are roadmap-not-shipped.

Decision: use Fenic as the engine-external framework (embed + classify/
sentiment + labeling; batching/caching/lineage free), with the embedding
PROVIDER as a PII-policy choice decoupled from the framework — in-account
Bedrock (via boto3 today, or contribute the provider; Apache-2.0 allows it)
preferred, or a Fenic-native managed provider over redacted text if policy
allows. Clustering stays our own sklearn UMAP+HDBSCAN (Fenic is K-means only,
no noise class). Vectors → open Iceberg ARRAY<float>; StarRocks HNSW is the
intended serving tier (load, not re-embed). No engine-native AI SQL functions;
no trino_only AI models. Schema/contract/business-keys unchanged.
…rness)

Owner direction: Bedrock/in-account is not a requirement — choose the
embedding model on task effectiveness (clustering + retrieval on our own
feedback corpus). Egress of Presidio-redacted text to a managed provider is
acceptable; redaction stays as data-minimization regardless.

Adds §B.1: an effectiveness-driven selection method — use MTEB to narrow,
then benchmark the shortlist on a labeled Zendesk sample by clustering
agreement (ARI/NMI vs. ticket_tags) + silhouette + human coherence, sweeping
Matryoshka dims and cost. Candidate shortlist: Fenic-native managed
(gemini-embedding-001, Cohere embed-v4, OpenAI text-embedding-3-large) and
self-hosted open (Qwen3-Embedding, BGE-M3) if we host for effectiveness.
No hardcoded winner; choice reversible via model_version.

Also upgrades LLM semantic-normalization-before-embedding from "rejected
default" to a first-class eval arm in §C — 2026 support-ticket-clustering
evidence reports it is the single largest lever on cluster quality for
short/noisy text; gate adoption by measured lift vs. per-record LLM cost.

Syncs ADR + dagster_asset_spec + README notes to rev.4.
- feedback_zendesk_mvp_spec §8: compound uniqueness test referenced
  source_slug, which is not a fact column; use ['feedback_source_fk',
  'source_record_id'] (both exist; same business grain since
  feedback_source_fk = surrogate_key([source_slug])). Drop the stray
  "source_slug equivalent" from the not_null list. (sentry)
- feedback_dimensional_model §2/§6: align business-key terminology to the
  rest of the spec set — feedback_source/source_natural_key →
  source_slug/source_record_ref. (Copilot)
- feedback_dimensional_model §8: remove stray leading "+" diff artifact that
  rendered oddly in the build-path prose. (Copilot)
- feedback_dagster_asset_spec §5: type the PrivateAttr in the LLMClientFactory
  example (_client: Anthropic | None) per Pydantic v2 + repo convention, so
  the client-caching pattern is correct. (sentry)
blarghmatey and others added 6 commits August 18, 2026 13:35
The consumption surface was written Superset-first; broaden it to a real
per-audience choice, since the model is surface-agnostic (every surface just
reads the modeled tables). Add Marimo notebook-as-webapp as the interactive/
curation/prototyping surface and net-new app as the write-back/embedding
option, each grounded in tooling the platform already operates:
- Superset is managed as code in src/ol_superset/ with programmatic RLS
  (apply_rls.py) — lowest-effort MVP dashboards.
- images/marimo-jupyterlab/ already runs marimo with Keycloak OIDC + per-user
  Trino token + IRSA, so a deployed notebook-as-webapp reuses that plumbing and
  can push row-level authz to the engine (Lakekeeper/Cedar) instead of the app.

Recommend phased/audience-matched: Superset for MVP trend/cluster dashboards,
a Marimo webapp for cluster exploration + the category-curation loop and to
prototype the UX, net-new app only when write-back/product-embedding justifies
it. Choice stays reversible. Updates §2/§3/§4/§5 + README.
The spec set described the schema only as prose column lists, which made the
star hard to review and let two gaps sit unnoticed until @pdpinch found them.

Adds feedback_erd.md (Mermaid ERDs for the star, the event contract, the ML
sidecar and the Phase-1 subset) and applies three schema deltas:

- Subject reference. The model captured who/when/which-conversation but had no
  slot for what the feedback is *about* — the axis you actually aggregate on.
  Adds a polymorphic subject_type/subject_ref/subject_url triple, resolved to a
  conformed FK where one exists: content_block_fk -> dim_course_content for edX
  blocks, courserun_fk for runs. No new subject dimension, because the subject
  is polymorphic across dims that already exist.
- Zendesk brand/group facets (hq#12607). Pass-through: int__zendesk__ticket
  already carries both.
- ML sidecar grain split. Vectors are per (feedback_pk, model_version) but
  cluster assignments are per (feedback_pk, cluster_run_id); one table meant a
  re-cluster could rewrite vectors, forfeiting the property the sidecar exists
  to buy. Splits into feedback_embeddings / feedback_cluster_run /
  feedback_cluster_assignment, drops the redundant embedding_id from the fact.

Also records that organization_fk is structurally unresolvable for Zendesk
under dim_organization's ['platform','source_id'] key, so it is a decision
rather than a build-time surprise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6XP15iW4epMuLG7xszojk
From @KatelynGit's RFC review (mitodl/hq#12210) and the design directive that
the core tables must not be shaped by Zendesk's attribute set.

Two changes drive everything else:

1. Grain moves from ticket to turn. The spec's stated grain was already "one
   atomic utterance", but Zendesk was quietly modelled at ticket grain (first
   comment only) — the only source where "utterance" meant "whole
   conversation". int__learn_ai__chatbot is already per-turn and the forum
   sources are per-event; int__zendesk__ticket_comment already exists. So
   Zendesk moves to comment grain and first-comment-only becomes the
   is_conversation_opening filter. This moves the business key
   (source_record_ref: ticket_id -> comment_id), which is why it had to land
   before the fact ships rather than after it has consumers.

2. Adopted the conformance rule: an attribute earns a column or a conformed
   dimension only if >=2 sources can populate it; everything else lives in a
   source_metadata variant and is promoted when a second source needs it.
   Re-auditing every attribute against all five sources added
   dim_feedback_channel (the one genuinely conformed facet) and
   dim_feedback_tag/bridge_feedback_tag, and withdrew the dim_feedback_context
   junk dimension and the source_brand/source_group facet columns proposed in
   rev. 1 — both were Zendesk-shaped.

Also from the review: renamed date_fk/time_fk to occurred_* and added
created/updated role-playing FKs (due is Zendesk-only, so it went to the
variant); added afact_feedback_conversation for lifecycle, since "how long was
this open" is conversation grain, not utterance grain — and its duration
measures are blocked on the ticket_metrics Airbyte stream, which we do not
sync today.

Prerequisites recorded rather than assumed: carry comment_author_user_id
through int__zendesk__ticket_comment (blocking), measure the turn-count
multiplier (the ~198K figure was tickets), profile comment_plain_body for PII,
and confirm the channel value set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6XP15iW4epMuLG7xszojk
The turn fact answered "what was said" but nothing answered "what was this
conversation about". Embedding turns independently splits one complaint into
several weak cluster members, scores sentiment off a fragment ("still not
working"), and makes cluster size a function of how talkative the reporter was.

afact_feedback_conversation becomes the analysis fact: one row per conversation
carrying the LLM summary, the embedding vector, category_fk, sentiment_fk and
cluster membership alongside the lifecycle columns it already had. category_fk
and sentiment_fk leave tfact_feedback, which makes that fact insert-only -- no
post-insert write path to it exists any more, so the whole inferred layer can be
rebuilt without touching the record of what people actually wrote.

This is not a return to rev. 1's ticket grain. The fact still records every turn
and the assembled conversation is built from all of them; rev. 2 fixed what gets
recorded, this fixes what gets analyzed.

Two rev. 2 caveats resolve as a consequence: min_cluster_size counts
conversations again (no rank-by-distinct-conversation_id workaround), and
Zendesk's ticket-level CSAT is now grain-matched to the sentiment it seeds
rather than propagated across turns.

The cost is stated rather than buried: the summary is the one per-record LLM
call in the design -- low hundreds of dollars one-time for Zendesk, skipped
entirely for single-turn and short conversations, and to be sample-measured
before the backfill.

RFC: mitodl/hq#12210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKQCFVcYxwWAmNN3stpwfk
Four specs and the RFC described tk-fix-dim-user-null-email-identity-collapse
as an open p0. It was closed 2026-07-06 and merged in #2400 on 2026-07-15 --
before the specs that cite it were written. All six union branches in
dim_user.sql now filter `email is not null`, with a not_null regression test.

The identity risk itself does not go away, so the warning stays; what changes is
its basis. It is structural rather than a known defect: dim_user.user_pk is
keyed on lower(email) and Zendesk offers no other identifier, so every unmatched
or shared address is a miss or a merge. Citing a fixed bug for it would have
sent a reviewer to a closed ticket and implied the risk was someone else's to
retire.

Also drops the "no cross-source identity rollups until b7ca16 is re-derived"
scope note, which read as an MVP blocker but is post-migration work; the MVP
constraint is simply that Zendesk resolves identity by email alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKQCFVcYxwWAmNN3stpwfk
… (rev. 4)

Copilot and @rachellougee's review surfaced real bugs in the spec set: a
second missing prerequisite column, a redaction placement that created a
dependency cycle, a hash-of-a-hash PK, a nonexistent dim_sentiment member,
a participant_count that was structurally always 1, a source-native join
key that can collide across sources, an unversioned dim_course_content
lookup, a stale event-contract line reverting to ticket grain, two missing
contract fields, PII gaps in source_metadata, a non-numeric column averaged
as a float, a single-output asset with two writes, and a stale dim_user.user_pk
formula plus an unanswered identity re-match question. No grain, business-key,
or fact/asset-shape changes — same class of fix as rev. 2/3, narrower scope.
@blarghmatey
blarghmatey force-pushed the worktree-feedback-aggregation-spec branch from d2c894f to b345334 Compare August 18, 2026 17:35

@rachellougee rachellougee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@blarghmatey
blarghmatey merged commit 024f76c into main Aug 19, 2026
6 checks passed
@blarghmatey
blarghmatey deleted the worktree-feedback-aggregation-spec branch August 19, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants