Spaces:
Runtime error
Runtime error
Discovery Lens β Data Contracts
Source of truth for all pipeline module I/O. Last updated: Apr 29, 2026. If a contract needs to change, open a GitHub Issue and tag Lucas first.
Pipeline flow
UploadedFile β extractor.py β raw_text (str)
β chunker.py β chunks (list[dict])
β embedder.py β embeddings (np.ndarray, n_chunks Γ 384)
β clusterer.py β clusters (list[dict])
β source_map.py β source_map (dict) β st.session_state["source_map"]
β odi_scorer.py β scored_clusters (list[dict]) β st.session_state["scored_clusters"]
β llm.py β ost (dict) β st.session_state["ost"]
extractor.py
# Input
file: UploadedFile # Streamlit UploadedFile object
source_type: str # one of: "interview" | "review" | "ticket" | "usability" | "social" | "internal"
# Output
raw_text: str # full extracted text, plain string
chunker.py
# Input
raw_text: str
filename: str
source_type: str # same enum as extractor.py
# Output β list of dicts, one per chunk
[
{
"chunk_id": str, # format: "{safe_filename}_{zero_padded_index}" e.g. "interview_01_001"
"text": str, # 2β4 sentences
"filename": str,
"source_type": str
},
...
]
embedder.py
# Input
chunks: list[dict] # output of chunker.py
# Output
embeddings: np.ndarray # shape: (n_chunks, 384)
Notes: chunks[i] and embeddings[i] share the same index β never reorder independently.
clusterer.py
# Input
chunks: list[dict] # output of chunker.py
embeddings: np.ndarray # output of embedder.py
# Output β list of dicts, one per cluster
[
{
"cluster_id": int,
"representative_chunks": list[dict], # top 3 chunks by HDBSCAN membership probability (cluster core)
"boundary_chunks": list[dict], # 1 chunk with lowest membership probability (outlier signal β consumed by T-10)
"all_chunk_ids": list[str], # all chunk_ids belonging to this cluster
"membership_scores": dict[str, float], # chunk_id β HDBSCAN membership probability, range 0.0β1.0
},
...
]
Notes:
- Algorithm: BERTopic with UMAP (5 dims, cosine metric,
min_dist=0.0) + HDBSCAN (min_cluster_size=15,min_samples=5,cluster_selection_method="eom"). Replaces KMeans+silhouette sweep (T-09, May 14 2026). Prototype notebook:notebooks/bertopic_hdbscan_prototype.ipynb. PM sign-off: Lucas (pending PR review). - Why HDBSCAN: density-based, no predefined
k, handles variable-size themes, produces membership probabilities natively (feed into T-10 hybrid chunk selection). Prototype on Notion synthetic corpus: 8 clusters at silhouette 0.48 (UMAP-5D) vs KMeans 0.47 with only 4 forced-equal partitions. - Noise fallback: HDBSCAN refuses to assign ~7β18% of chunks (
topic_id = -1). Each noise chunk is reassigned to its nearest cluster by cosine similarity to the cluster mean embedding, with membership capped at 0.5 so true core members stay ranked higher inrepresentative_chunks. This preserves the "every chunk has a cluster_id" contract thatsource_map.pyandodi_scorer.pyrely on. - KMeans fallback: Corpora with fewer than 50 chunks fall back to the legacy KMeans path (same output shape, rank-based pseudo-membership in
[0.1, 1.0]). HDBSCAN cannot find density-rich regions reliably below this size. representative_chunkssemantics changed. Previously "top 3 closest to centroid" (KMeans). Now "top 3 by HDBSCAN membership probability". Type and length are unchanged βllm.pycontinues to work without modification.boundary_chunksandmembership_scoresare new (T-09). Both will be consumed by T-10 for hybrid chunk selection. Older modules can ignore these fields safely (they are extra keys in the dict, no contract break).- Determinism:
random_state=42fixed. UMAP's parallel implementation introduces Β±2β3% silhouette variance run-to-run; cluster membership of any given chunk is stable for the same input.
source_map.py
# Input
chunks: list[dict] # output of chunker.py
clusters: list[dict] # output of clusterer.py
# Output β flat dict for chunk-level traceability
{
"<chunk_id>": {
"text": str,
"filename": str,
"source_type": str,
"cluster_id": int | None # None if chunk was not assigned to any cluster
},
...
}
Notes:
- Must be called after clusterer.py and before llm.py.
- Stored in st.session_state["source_map"].
- Used by the results page to show source quotes per opportunity.
- No LLM, no external API. Pure dict construction.
odi_scorer.py
# Input
clusters: list[dict] # output of clusterer.py
chunks: list[dict] # output of chunker.py
goal_embedding: np.ndarray | None # 384-dim goal embedding β pass None to skip goal_relevance
chunk_embeddings: np.ndarray | None # shape (n_chunks, 384), same index order as chunks
# pass None to skip goal_relevance
# total_chunks and total_source_types are derived internally β no extra args needed
# Output β list of dicts, one per cluster, sorted by priority_score descending
[
{
"cluster_id": int,
"cluster_size": int,
# --- Raw signals (available for UI display and debugging) ---
"importance": float, # cluster_size / total_chunks, range 0.0β1.0
"avg_sentiment": float, # lxyuan compound mean (positiveβ+score, negativeβ-score, neutralβ0), range -1.0 to 1.0
"satisfaction": float, # (avg_sentiment + 1) / 2, range 0.0β1.0
"source_type_diversity": float, # unique source types in cluster / 6 (fixed denominator), range 0.0β1.0
# --- Four scores shown independently in UI ---
"odi_score": float, # importance * (1 - satisfaction), range 0.0β1.0
"evidence_robustness": float, # (source_type_diversity * 0.65) + (importance * 0.35), range 0.0β1.0
"goal_relevance": float, # mean cosine_sim(goal_embedding, chunk_embeddings) per cluster, range 0.0β1.0
# 1.0 if goal_embedding=None (no dampening)
"priority_score": float, # [(odi_score * 0.60) + (evidence_robustness * 0.40)]
# Γ max(goal_relevance, 0.20), range 0.0β1.0
# --- Recommendation label (D-02) ---
"recommendation": str, # "Act" | "Validate" | "Monitor" | "Deprioritise"
},
...
]
Notes:
- Deterministic β no LLM, no external API.
- Sentiment model: lxyuan/distilbert-base-multilingual-cased-sentiments-student (replaced VADER May 13 2026, T-08).
source_type_diversityuses a fixed denominator of 6 (all recognised source types). Stable across sessions.goal_relevanceuses unweighted mean cosine similarity until T-09 (BERTopic + HDBSCAN) lands. After T-09, replace with membership-weighted mean. Requires revalidation of D-03 floor value.priority_scoreincorporates goal_relevance as a multiplicative dampening factor floored at 0.20. A cluster is never zeroed out β it stays visible in the UI but ranked lower. D-03, May 14 2026.- Sort key is
priority_scoredescending. - Weights confirmed stable by T-16 sensitivity analysis (May 14 2026): min tau=0.9048, mean tau=0.9947.
Score definitions
| Score | Formula | What it answers |
|---|---|---|
odi_score |
importance Γ (1 - satisfaction) |
How underserved is this need? |
evidence_robustness |
(source_type_diversity Γ 0.65) + (importance Γ 0.35) |
How robustly evidenced across source types? |
goal_relevance |
mean cosine_sim(goal_embedding, chunk_embeddings) clipped to [0, 1] |
How directly does this cluster address the stated goal? |
priority_score |
[(odi_score Γ 0.60) + (evidence_robustness Γ 0.40)] Γ max(goal_relevance, 0.20) |
What should a PM act on first? |
Recommendation label thresholds (D-02, May 14 2026)
| Label | Condition | Meaning |
|---|---|---|
Act |
odi_score β₯ 0.10 AND evidence_robustness β₯ 0.40 |
High unmet need, well-evidenced β prioritise for roadmap |
Validate |
odi_score β₯ 0.10 AND evidence_robustness < 0.40 |
Strong signal, thin evidence β run more research first |
Monitor |
odi_score < 0.10 AND evidence_robustness β₯ 0.40 |
Well-evidenced but not urgently underserved β keep on radar |
Deprioritise |
odi_score < 0.10 AND evidence_robustness < 0.40 |
Weak signal, sparse evidence β not worth roadmap space now |
Thresholds calibrated to synthetic dataset score distributions (May 14 2026). Re-evaluate when real PM data is loaded.
llm.py
# Input
clusters: list[dict] # output of clusterer.py
scored_clusters: list[dict] # output of odi_scorer.py
goal: str # from st.session_state["goal"]
context_block: str # from st.session_state["context_block"], default ""
# max 500 words β truncated at upload step before storage
# injected into user message after cluster evidence; omitted if empty
# LLM generates via Groq β JTBD and solutions only, no score fields
{
"goal": str,
"opportunities": [
{
"jtbd": str, # strictly: "When I [situation], I want to [motivation], so I can [outcome]."
"job_type": str, # "functional" | "emotional" | "social" β LLM-generated, not injected
"jtbd_confidence": str, # "high" | "medium" | "low" β LLM-generated; overridden to "low" for clusters with <= 3 chunks
"jtbd_confidence_reason": str, # one sentence β LLM-generated; deterministic for overridden clusters (states chunk count)
"cluster_id": int,
"solutions": [
{
"label": str,
"assumptions": [
{
"text": str,
"risk": str # "low" | "medium" | "high"
}
]
}
]
}
]
}
# After parsing, llm.py merges scored_clusters on cluster_id to produce the final OST:
{
"goal": str,
"opportunities": [
{
"jtbd": str,
"job_type": str, # passed through from LLM
"jtbd_confidence": str, # passed through from LLM, or "low" if overridden
"jtbd_confidence_reason": str, # passed through from LLM, or chunk-count sentence if overridden
"cluster_id": int,
# --- Injected from scored_clusters, never LLM-generated ---
"importance": float | None,
"satisfaction": float | None,
"source_type_diversity": float | None,
"odi_score": float | None,
"evidence_robustness": float | None,
"priority_score": float | None,
"solutions": [...]
}
]
}
Notes:
- Score fields are never generated by the LLM β injected post-parse by merging with
scored_clustersoncluster_id. job_typeis LLM-generated and passed through unchanged. Valid values:functional|emotional|social. Rubric inprompts/system_prompt.txtRule 8. PM sign-off: Lucas (May 14 2026).- If a
cluster_idfrom the LLM has no match inscored_clusters, set all score fields tonullβ do not crash. - Always instruct the model to return only valid JSON β no preamble, no markdown fences.
- On JSON parse or validation failure, retry once with
llama-3.1-8b-instant(fallback).
session_state keys
st.session_state["goal"] # str β product goal statement
st.session_state["product_name"] # str β product name
st.session_state["context_block"] # str β stakeholder/constraint context, max 500 words, default ""
st.session_state["chunks"] # list[dict] β output of chunker.py
st.session_state["embeddings"] # np.ndarray β output of embedder.py
st.session_state["clusters"] # list[dict] β output of clusterer.py
st.session_state["scored_clusters"] # list[dict] β output of odi_scorer.py
st.session_state["ost"] # dict β merged OST JSON (LLM output + injected scores)
st.session_state["source_map"] # dict β chunk_id β {text, filename, source_type, cluster_id}
Notes: scored_clusters must be populated before llm.py is called so the merge step can do a simple dict lookup without a second pass through raw data.