Repository navigation
docs(feedback): spec set for Feedback Aggregation & Clustering System - #2422
Conversation
There was a problem hiding this comment.
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_feedbackKimball 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.
cb23bf4 to
9b0a34f
Compare
|
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:
The schema should have a place to store this when available. |
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
|
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:
Deliberately not a new On your three cases specifically: the plugin's courseware block id → Brand & Group (hq#12607) — cheap, because Also in this push:
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". |
rev. 2 — turn grain + a conformance rule (
|
| 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.
rev. 3 — the conversation is the analysis unitPushed in e376498, following RFC #12210 (rewritten today). What changed.
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:
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 Zendesk sourcing is unchanged and confirmed. Files: all eight design docs. One consumption-layer sharpening worth a reviewer's eye: |
There was a problem hiding this comment.
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_conversationrather 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 stablecomment_idas 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_idis 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 testfeedback_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 >= 1and 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>
… (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.
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:
Each doc's own change-log (rev. 4, or rev. 5 in |
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)
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.
d2c894f to
b345334
Compare
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/)README_feedback_aggregation.mdfeedback_dimensional_model.mdtfact_feedbackschema (recovered)feedback_event_contract_spec.mdfeedback_zendesk_mvp_spec.mdfeedback_ml_approach.mdfeedback_dagster_asset_spec.mdstudent_risk_probability; Vault LLM resourcefeedback_consumption_ux_spec.mdKey decisions
tfact_feedbackmirroringtfact_discussion_events, with sparse nullable conformed FKs; explicit stablefeedback_pk(diverges from precedent) for migration + late-arriving category/sentiment.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