Skip to content

Commit 627eec4

Browse files
committed
feat: implement extractive text summarization with MMR
1 parent 6bd6096 commit 627eec4

3 files changed

Lines changed: 352 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ data: create seed dataset of 50 code snippets
512512

513513
#### Week 3 (Apr 27 – May 2): Summarizer + FastAPI + Proposal Defense
514514

515-
- [ ] **Day 10 — Mon, Apr 27:** Implement `summarizer.py``extractive_summarize(snippets, max_points)` with TF-IDF scoring + MMR diversity selection. Write `test_summarizer.py`. Commit: `feat: implement extractive text summarization with MMR`
515+
- [x] **Day 10 — Mon, Apr 27:** Implement `summarizer.py``extractive_summarize(snippets, max_points)` with TF-IDF scoring + MMR diversity selection. Write `test_summarizer.py`. Commit: `feat: implement extractive text summarization with MMR`
516516

517517
- [ ] **Day 11 — Tue, Apr 28:** Create Pydantic models in `models/schemas.py`. Create `routes/` for all 5 endpoints. Wire into `main.py` with CORS. Test with Swagger UI at /docs. Commit: `feat: create FastAPI routes and Pydantic schemas`
518518

@@ -720,7 +720,7 @@ NEXT_PUBLIC_API_URL=http://localhost:8000
720720
## Status
721721

722722
**Current Phase:** Phase 1 — Algorithm Core
723-
**Current Day:** Day 10 (Mon, Apr 27, 2026)
724-
**Last Updated:** Sat, Apr 25, 2026
725-
**Total Progress:** 9/68 tasks completed
723+
**Current Day:** Day 11 (Tue, Apr 28, 2026)
724+
**Last Updated:** Mon, Apr 27, 2026
725+
**Total Progress:** 10/68 tasks completed
726726
**Next milestone:** Proposal Defense (~early May 2026)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Extractive text summarization with Maximal Marginal Relevance (MMR).
2+
3+
Scores sentences by TF-IDF weight, then selects diverse points using MMR
4+
to avoid redundancy across the generated thread draft.
5+
6+
Reference: Carbonell, J. & Goldstein, J. (1998). The use of MMR, diversity-based
7+
reranking for reordering documents and producing summaries. SIGIR '98.
8+
"""
9+
10+
from .similarity import cosine_similarity
11+
from .tfidf import TFIDFEngine, tokenize, compute_tf, tfidf_vector
12+
13+
14+
def _sentence_tfidf_score(sentence: str, idf: dict[str, float]) -> float:
15+
"""Score a sentence by summing its TF-IDF weights.
16+
17+
Args:
18+
sentence: Raw sentence string.
19+
idf: Fitted IDF table from the corpus.
20+
21+
Returns:
22+
Sum of TF-IDF weights for all terms in the sentence.
23+
"""
24+
tokens = tokenize(sentence)
25+
if not tokens:
26+
return 0.0
27+
tf = compute_tf(tokens)
28+
vec = tfidf_vector(tf, idf)
29+
return sum(vec.values())
30+
31+
32+
def _sentence_vector(sentence: str, idf: dict[str, float]) -> dict[str, float]:
33+
"""Produce a TF-IDF vector for a single sentence.
34+
35+
Args:
36+
sentence: Raw sentence string.
37+
idf: Fitted IDF table.
38+
39+
Returns:
40+
Sparse TF-IDF vector.
41+
"""
42+
tokens = tokenize(sentence)
43+
if not tokens:
44+
return {}
45+
tf = compute_tf(tokens)
46+
return tfidf_vector(tf, idf)
47+
48+
49+
def _split_sentences(text: str) -> list[str]:
50+
"""Split text into sentences on period, newline, or semicolon boundaries.
51+
52+
Args:
53+
text: Raw input text.
54+
55+
Returns:
56+
List of non-empty sentence strings.
57+
"""
58+
import re
59+
parts = re.split(r"(?<=[.!?])\s+|\n+|;\s*", text)
60+
return [s.strip() for s in parts if s.strip()]
61+
62+
63+
def extractive_summarize(
64+
snippets: list[dict],
65+
max_points: int = 5,
66+
mmr_lambda: float = 0.6,
67+
) -> list[dict]:
68+
"""Summarize a cluster of snippets using TF-IDF scoring + MMR diversity.
69+
70+
For each snippet, picks the highest-scoring sentence that is least similar
71+
to already-selected sentences (Maximal Marginal Relevance).
72+
73+
Args:
74+
snippets: List of snippet dicts, each with at least a 'description'
75+
or 'notes' key and an 'id' key. Example:
76+
[{"id": "abc", "description": "...", "notes": "..."}]
77+
max_points: Maximum number of thread points to return.
78+
mmr_lambda: Trade-off between relevance (1.0) and diversity (0.0).
79+
Higher → more relevant, lower → more diverse.
80+
81+
Returns:
82+
Ordered list of dicts: [{"point": str, "source_snippet_id": str}]
83+
Returns empty list if snippets is empty.
84+
"""
85+
if not snippets:
86+
return []
87+
88+
# Build corpus: one document per snippet (description + notes combined)
89+
def _snippet_text(s: dict) -> str:
90+
parts = [s.get("description", ""), s.get("notes", "")]
91+
return " ".join(p for p in parts if p)
92+
93+
corpus_texts = [_snippet_text(s) for s in snippets]
94+
95+
# Fit IDF on the full cluster corpus
96+
engine = TFIDFEngine()
97+
engine.fit(corpus_texts)
98+
idf = engine._idf
99+
100+
selected: list[dict] = [] # final thread points
101+
selected_vecs: list[dict[str, float]] = []
102+
103+
for snippet in snippets:
104+
if len(selected) >= max_points:
105+
break
106+
107+
text = _snippet_text(snippet)
108+
sentences = _split_sentences(text)
109+
110+
if not sentences:
111+
continue
112+
113+
# Score each sentence in this snippet using MMR
114+
best_sentence: str | None = None
115+
best_mmr: float = -1.0
116+
117+
for sent in sentences:
118+
relevance = _sentence_tfidf_score(sent, idf)
119+
120+
if not selected_vecs:
121+
# First selection — pick purely by relevance
122+
mmr_score = relevance
123+
else:
124+
sent_vec = _sentence_vector(sent, idf)
125+
max_sim = max(
126+
cosine_similarity(sent_vec, sv) for sv in selected_vecs
127+
)
128+
mmr_score = mmr_lambda * relevance - (1 - mmr_lambda) * max_sim
129+
130+
if mmr_score > best_mmr:
131+
best_mmr = mmr_score
132+
best_sentence = sent
133+
134+
if best_sentence:
135+
selected.append({
136+
"point": best_sentence,
137+
"source_snippet_id": snippet["id"],
138+
})
139+
selected_vecs.append(_sentence_vector(best_sentence, idf))
140+
141+
return selected

apps/api/tests/test_summarizer.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Tests for extractive text summarization with MMR."""
2+
3+
import pytest
4+
5+
from app.algorithms.summarizer import extractive_summarize, _split_sentences
6+
7+
8+
# ---------------------------------------------------------------------------
9+
# _split_sentences
10+
# ---------------------------------------------------------------------------
11+
12+
def test_split_sentences_basic():
13+
text = "First sentence. Second sentence. Third sentence."
14+
parts = _split_sentences(text)
15+
assert len(parts) == 3
16+
assert parts[0] == "First sentence."
17+
18+
19+
def test_split_sentences_newlines():
20+
text = "Line one\nLine two\nLine three"
21+
parts = _split_sentences(text)
22+
assert len(parts) == 3
23+
24+
25+
def test_split_sentences_empty_string():
26+
assert _split_sentences("") == []
27+
28+
29+
def test_split_sentences_ignores_blank_parts():
30+
text = "Hello.\n\n\nWorld."
31+
parts = _split_sentences(text)
32+
assert "" not in parts
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# extractive_summarize — edge cases
37+
# ---------------------------------------------------------------------------
38+
39+
def test_empty_snippets_returns_empty():
40+
assert extractive_summarize([]) == []
41+
42+
43+
def test_single_snippet_returns_one_point():
44+
snippets = [{"id": "a1", "description": "React hooks simplify state. Use useState for local state management."}]
45+
result = extractive_summarize(snippets, max_points=5)
46+
assert len(result) == 1
47+
assert result[0]["source_snippet_id"] == "a1"
48+
assert isinstance(result[0]["point"], str)
49+
assert len(result[0]["point"]) > 0
50+
51+
52+
def test_snippet_with_no_description_is_skipped():
53+
snippets = [
54+
{"id": "x1", "description": ""},
55+
{"id": "x2", "description": "Python list comprehensions are concise and fast."},
56+
]
57+
result = extractive_summarize(snippets, max_points=5)
58+
# Only the non-empty snippet should produce a point
59+
ids = [r["source_snippet_id"] for r in result]
60+
assert "x2" in ids
61+
62+
63+
def test_max_points_is_respected():
64+
snippets = [
65+
{"id": str(i), "description": f"Snippet {i} talks about topic {i} in detail."}
66+
for i in range(10)
67+
]
68+
result = extractive_summarize(snippets, max_points=3)
69+
assert len(result) <= 3
70+
71+
72+
def test_result_structure_has_required_keys():
73+
snippets = [{"id": "s1", "description": "TypeScript generics enable reusable typed functions."}]
74+
result = extractive_summarize(snippets)
75+
assert "point" in result[0]
76+
assert "source_snippet_id" in result[0]
77+
78+
79+
def test_notes_field_used_when_description_missing():
80+
snippets = [{"id": "n1", "notes": "Tailwind utility classes reduce custom CSS overhead."}]
81+
result = extractive_summarize(snippets, max_points=5)
82+
assert len(result) == 1
83+
assert result[0]["source_snippet_id"] == "n1"
84+
85+
86+
def test_both_description_and_notes_combined():
87+
snippets = [{
88+
"id": "b1",
89+
"description": "useEffect runs after render.",
90+
"notes": "Cleanup function prevents memory leaks.",
91+
}]
92+
result = extractive_summarize(snippets, max_points=5)
93+
assert len(result) == 1
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# MMR diversity — selected points should not be identical
98+
# ---------------------------------------------------------------------------
99+
100+
def test_selected_points_are_diverse():
101+
"""Three snippets with different topics should yield 3 different sentences."""
102+
snippets = [
103+
{"id": "r1", "description": "React useState hook manages component state. Call it at top level only."},
104+
{"id": "p1", "description": "Python generators use yield keyword. They are memory efficient for large data."},
105+
{"id": "s1", "description": "SQL JOIN merges rows from two tables. Use ON clause to specify condition."},
106+
]
107+
result = extractive_summarize(snippets, max_points=5)
108+
points = [r["point"] for r in result]
109+
# All three selected sentences should be distinct
110+
assert len(set(points)) == len(points)
111+
112+
113+
def test_source_ids_map_correctly():
114+
"""Each result point must trace back to the snippet it came from."""
115+
snippets = [
116+
{"id": "alpha", "description": "Git rebase rewrites commit history cleanly."},
117+
{"id": "beta", "description": "Docker containers isolate application environments."},
118+
]
119+
result = extractive_summarize(snippets, max_points=5)
120+
ids = {r["source_snippet_id"] for r in result}
121+
assert ids == {"alpha", "beta"}
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# MMR lambda boundary values
126+
# ---------------------------------------------------------------------------
127+
128+
def test_mmr_lambda_zero_still_runs():
129+
"""lambda=0.0 is purely diversity-based — should not crash."""
130+
snippets = [
131+
{"id": "1", "description": "TypeScript interfaces define object shapes."},
132+
{"id": "2", "description": "TypeScript generics allow reusable typed code."},
133+
]
134+
result = extractive_summarize(snippets, max_points=5, mmr_lambda=0.0)
135+
assert len(result) >= 1
136+
137+
138+
def test_mmr_lambda_one_still_runs():
139+
"""lambda=1.0 is purely relevance-based — should not crash."""
140+
snippets = [
141+
{"id": "1", "description": "Next.js app router uses file-based routing."},
142+
{"id": "2", "description": "Next.js server components reduce client bundle size."},
143+
]
144+
result = extractive_summarize(snippets, max_points=5, mmr_lambda=1.0)
145+
assert len(result) >= 1
146+
147+
148+
# ---------------------------------------------------------------------------
149+
# Real-world-like cluster: 5 related React snippets
150+
# ---------------------------------------------------------------------------
151+
152+
REACT_SNIPPETS = [
153+
{
154+
"id": "rc1",
155+
"description": (
156+
"useState returns a state value and a setter. "
157+
"Calling the setter triggers a re-render with the new value."
158+
),
159+
},
160+
{
161+
"id": "rc2",
162+
"description": (
163+
"useEffect runs after every render by default. "
164+
"Pass a dependency array to control when it fires."
165+
),
166+
},
167+
{
168+
"id": "rc3",
169+
"description": (
170+
"useContext provides global state without prop drilling. "
171+
"Wrap your tree in a Provider to supply the value."
172+
),
173+
},
174+
{
175+
"id": "rc4",
176+
"description": (
177+
"useReducer is better than useState for complex state logic. "
178+
"Dispatch actions to a reducer function instead of calling setters."
179+
),
180+
},
181+
{
182+
"id": "rc5",
183+
"description": (
184+
"useMemo caches expensive computed values between renders. "
185+
"Only recomputes when its dependency array changes."
186+
),
187+
},
188+
]
189+
190+
191+
def test_react_cluster_returns_one_point_per_snippet():
192+
result = extractive_summarize(REACT_SNIPPETS, max_points=5)
193+
assert len(result) == 5
194+
195+
196+
def test_react_cluster_all_source_ids_present():
197+
result = extractive_summarize(REACT_SNIPPETS, max_points=5)
198+
ids = {r["source_snippet_id"] for r in result}
199+
expected = {"rc1", "rc2", "rc3", "rc4", "rc5"}
200+
assert ids == expected
201+
202+
203+
def test_react_cluster_points_are_non_empty_strings():
204+
result = extractive_summarize(REACT_SNIPPETS, max_points=5)
205+
for item in result:
206+
assert isinstance(item["point"], str)
207+
assert len(item["point"]) > 0

0 commit comments

Comments
 (0)