Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 6 additions & 21 deletions src/components/ResearchBriefPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function SourceMapSection({
isLoading: boolean;
styles: ThemeStyles;
}) {
if (!isLoading && !sourceMap?.consensus.length && !sourceMap?.contradictions.length) return null;
if (!isLoading && !sourceMap?.consensus.length) return null;
return (
<div className={`rounded-md border ${styles.border} ${styles.panelBg} p-4`}>
<h4 className={`mb-3 flex items-center gap-2 text-sm font-semibold ${styles.text}`}>
Expand All @@ -121,8 +121,12 @@ function SourceMapSection({
{sourceMap && sourceMap.consensus.length > 0 && (
<div>
<div className={`mb-2 text-xs font-semibold uppercase ${styles.muted}`}>
Consensus
Shared terms
</div>
<p className={`mb-2 text-xs ${styles.muted}`}>
Word matches help you compare sources; agreement or disagreement has not been
assessed.
</p>
<ul className="space-y-2">
{sourceMap.consensus.map((item) => (
<li key={item.id} className={`text-sm leading-6 ${styles.text}`}>
Expand All @@ -133,25 +137,6 @@ function SourceMapSection({
</ul>
</div>
)}
{sourceMap && sourceMap.contradictions.length > 0 && (
<div>
<div className={`mb-2 text-xs font-semibold uppercase ${styles.muted}`}>
Contradictions
</div>
<div className="space-y-3">
{sourceMap.contradictions.map((item) => (
<div key={item.id} className={`rounded-md border ${styles.border} p-3`}>
<div className={`mb-2 text-sm font-medium ${styles.text}`}>
{item.topic}
<SourceCount count={item.sourceIds.length} className={styles.muted} />
</div>
<p className={`text-xs leading-5 ${styles.muted}`}>{item.claimA}</p>
<p className={`mt-2 text-xs leading-5 ${styles.muted}`}>{item.claimB}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
Expand Down
53 changes: 48 additions & 5 deletions src/lib/__tests__/research-brief.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ResearchBriefPanel } from '../../components/ResearchBriefPanel';

import type { Article } from '../../types';
import { buildResearchBrief, buildSourceRelationshipMap } from '../research-brief';
Expand Down Expand Up @@ -45,7 +48,44 @@ describe('buildResearchBrief', () => {
});

describe('buildSourceRelationshipMap', () => {
it('maps consensus across saved sources that share a topic', () => {
it('keeps unrelated changes excerpts neutral and does not duplicate the focused source', () => {
const left = {
...baseArticle,
id: 'physics',
notes: [],
content:
'A tiny early deflection changes its momentum and therefore its eventual destination.',
};
const right = {
...baseArticle,
id: 'people',
notes: [],
content: 'This changes not only their odds of success, but also their personality.',
};
const map = buildSourceRelationshipMap([left, right], left.id);
expect(map.contradictions).toEqual([]);
const rendered = renderToStaticMarkup(
createElement(ResearchBriefPanel, {
brief: buildResearchBrief(left),
sourceMap: map,
})
);
expect(rendered).toContain('Shared terms');
expect(rendered).toContain('agreement or disagreement has not been assessed');
expect(rendered).not.toContain('Consensus');
expect(rendered).not.toContain('Contradictions');
expect(map.sources.map((source) => source.id)).toEqual(['physics', 'people']);
expect(map.consensus).toContainEqual({
id: expect.any(String),
topic: 'Changes',
summary: 'Saved sources contain the term "changes".',
sourceIds: ['physics', 'people'],
});
expect(buildSourceRelationshipMap([left], left.id).consensus).toEqual([]);
expect(buildSourceRelationshipMap([left, right, left], left.id)).toEqual(map);
});

it('maps shared terms without claiming consensus', () => {
const map = buildSourceRelationshipMap([
baseArticle,
{
Expand All @@ -58,10 +98,13 @@ describe('buildSourceRelationshipMap', () => {
]);

expect(map.consensus[0].sourceIds).toEqual(['article-1', 'article-2']);
expect(map.consensus[0].summary).toContain('feedback');
expect(map.consensus[0].summary).toBe(
`Saved sources contain the term "${map.consensus[0].topic.toLowerCase()}".`
);
expect(map.contradictions).toEqual([]);
});

it('maps contradictions across saved sources on shared topics', () => {
it('does not infer contradictions from shared terms and negation', () => {
const map = buildSourceRelationshipMap([
{
...baseArticle,
Expand All @@ -77,7 +120,7 @@ describe('buildSourceRelationshipMap', () => {
},
]);

expect(map.contradictions[0].sourceIds).toEqual(['article-positive', 'article-negative']);
expect(map.contradictions[0].topic).toBe('Automation');
expect(map.consensus.some((item) => item.topic === 'Automation')).toBe(true);
expect(map.contradictions).toEqual([]);
});
});
91 changes: 11 additions & 80 deletions src/lib/research-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface SourceMapContradiction extends SourceMapItem {

export interface SourceRelationshipMap {
sources: SourceMapSource[];
/** Legacy response key: shared terms only, never verified agreement. */
consensus: SourceMapItem[];
contradictions: SourceMapContradiction[];
}
Expand Down Expand Up @@ -103,20 +104,6 @@ const STOP_WORDS = new Set([
'without',
'would',
]);
const NEGATION_TERMS = ['not', 'no ', 'never', 'without', 'cannot', "can't", 'failed to'];
const CONTRAST_TERMS = ['however', 'but', 'contrary', 'instead', 'although', 'despite', 'whereas'];
const OPPOSING_TERM_PAIRS: Array<[string, string]> = [
['increase', 'decrease'],
['increased', 'decreased'],
['improve', 'worsen'],
['improved', 'worsened'],
['support', 'challenge'],
['supports', 'challenges'],
['benefit', 'risk'],
['benefits', 'risks'],
['effective', 'ineffective'],
['reliable', 'unreliable'],
];

export function buildResearchBrief(article: Article): ResearchBrief {
const text = normalizeWhitespace(stripHtml(article.extractedText || article.content));
Expand Down Expand Up @@ -155,17 +142,20 @@ export function buildSourceRelationshipMap(
const scopedCandidates = focus
? [
focus,
...candidates.filter((candidate) =>
candidate.claims.some((claim) => hasSharedTopic(claim, focus.claims))
...candidates.filter(
(candidate) =>
candidate.source.id !== focus.source.id &&
candidate.claims.some((claim) => hasSharedTopic(claim, focus.claims))
),
]
: candidates;

const sourcesById = new Map<string, SourceMapSource>();
scopedCandidates.forEach((candidate) => sourcesById.set(candidate.source.id, candidate.source));

const consensus = buildConsensus(scopedCandidates);
const contradictions = buildContradictions(scopedCandidates);
const consensus = buildSharedTerms(scopedCandidates);
// Keyword overlap and sentence polarity cannot establish conflicting claims.
const contradictions: SourceMapContradiction[] = [];
const usedSourceIds = new Set([
...consensus.flatMap((item) => item.sourceIds),
...contradictions.flatMap((item) => item.sourceIds),
Expand Down Expand Up @@ -282,7 +272,6 @@ interface SourceCandidate {
interface ClaimCandidate {
text: string;
keywords: string[];
polarity: number;
}

function articleToCandidate(article: Article): SourceCandidate {
Expand All @@ -296,7 +285,7 @@ function articleToCandidate(article: Article): SourceCandidate {
article.content,
];
const text = normalizeWhitespace(stripHtml(textParts.filter(Boolean).join('. ')));
const claims = splitSentences(text)
const claims = Array.from(new Set(splitSentences(text)))
.map((sentence) => ({
sentence,
score: scoreSentence(sentence),
Expand All @@ -317,7 +306,7 @@ function articleToCandidate(article: Article): SourceCandidate {
};
}

function buildConsensus(candidates: SourceCandidate[]): SourceMapItem[] {
function buildSharedTerms(candidates: SourceCandidate[]): SourceMapItem[] {
const groups = new Map<string, { claims: ClaimCandidate[]; sourceIds: Set<string> }>();

candidates.forEach((candidate) => {
Expand All @@ -340,47 +329,15 @@ function buildConsensus(candidates: SourceCandidate[]): SourceMapItem[] {
.map(([topic, group], index) => ({
id: `consensus-${index + 1}`,
topic: toTopicLabel(topic),
summary: summarizeConsensus(topic, group.claims),
summary: `Saved sources contain the term "${topic}".`,
sourceIds: Array.from(group.sourceIds),
}));
}

function buildContradictions(candidates: SourceCandidate[]): SourceMapContradiction[] {
const contradictions: SourceMapContradiction[] = [];

for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
const left = candidates[leftIndex];
const right = candidates[rightIndex];

for (const leftClaim of left.claims) {
for (const rightClaim of right.claims) {
const shared = sharedKeywords(leftClaim, rightClaim);
if (shared.length === 0 || !claimsConflict(leftClaim, rightClaim)) continue;

contradictions.push({
id: `contradiction-${contradictions.length + 1}`,
topic: toTopicLabel(shared[0]),
summary: `Saved sources diverge on ${shared[0]}.`,
sourceIds: [left.source.id, right.source.id],
claimA: toClaimText(leftClaim.text),
claimB: toClaimText(rightClaim.text),
});
break;
}
if (contradictions.length >= MAX_MAP_ITEMS) return contradictions;
}
}
}

return contradictions;
}

function toClaimCandidate(sentence: string): ClaimCandidate {
return {
text: trimExcerpt(sentence),
keywords: extractKeywords(sentence),
polarity: getPolarity(sentence),
};
}

Expand All @@ -401,13 +358,6 @@ function extractKeywords(value: string) {
.map(([word]) => word);
}

function getPolarity(value: string) {
const lower = value.toLowerCase();
const hasNegative = NEGATION_TERMS.some((term) => lower.includes(term));
const hasContrast = CONTRAST_TERMS.some((term) => lower.includes(term));
return hasNegative || hasContrast ? -1 : 1;
}

function hasSharedTopic(claim: ClaimCandidate, focusClaims: ClaimCandidate[]) {
return focusClaims.some((focusClaim) => sharedKeywords(claim, focusClaim).length > 0);
}
Expand All @@ -417,25 +367,6 @@ function sharedKeywords(left: ClaimCandidate, right: ClaimCandidate) {
return left.keywords.filter((keyword) => rightKeywords.has(keyword));
}

function claimsConflict(left: ClaimCandidate, right: ClaimCandidate) {
const leftLower = left.text.toLowerCase();
const rightLower = right.text.toLowerCase();
const hasOpposingTerms = OPPOSING_TERM_PAIRS.some(
([a, b]) =>
(leftLower.includes(a) && rightLower.includes(b)) ||
(leftLower.includes(b) && rightLower.includes(a))
);

return hasOpposingTerms || left.polarity !== right.polarity;
}

function summarizeConsensus(topic: string, claims: ClaimCandidate[]) {
const sample = claims.find((claim) => claim.keywords.includes(topic)) ?? claims[0];
return sample
? `Multiple saved sources point to ${topic}: ${toClaimText(sample.text)}`
: `Multiple saved sources mention ${topic}.`;
}

function toTopicLabel(topic: string) {
return topic.charAt(0).toUpperCase() + topic.slice(1);
}
Loading