diff --git a/src/components/ResearchBriefPanel.tsx b/src/components/ResearchBriefPanel.tsx
index e1ff04d..5b08fa5 100644
--- a/src/components/ResearchBriefPanel.tsx
+++ b/src/components/ResearchBriefPanel.tsx
@@ -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 (
@@ -121,8 +121,12 @@ function SourceMapSection({
{sourceMap && sourceMap.consensus.length > 0 && (
- Consensus
+ Shared terms
+
+ Word matches help you compare sources; agreement or disagreement has not been
+ assessed.
+
{sourceMap.consensus.map((item) => (
-
@@ -133,25 +137,6 @@ function SourceMapSection({
)}
- {sourceMap && sourceMap.contradictions.length > 0 && (
-
-
- Contradictions
-
-
- {sourceMap.contradictions.map((item) => (
-
-
- {item.topic}
-
-
-
{item.claimA}
-
{item.claimB}
-
- ))}
-
-
- )}
)}
diff --git a/src/lib/__tests__/research-brief.test.ts b/src/lib/__tests__/research-brief.test.ts
index 770358f..805aa3e 100644
--- a/src/lib/__tests__/research-brief.test.ts
+++ b/src/lib/__tests__/research-brief.test.ts
@@ -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';
@@ -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,
{
@@ -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,
@@ -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([]);
});
});
diff --git a/src/lib/research-brief.ts b/src/lib/research-brief.ts
index f6d2dc1..d894003 100644
--- a/src/lib/research-brief.ts
+++ b/src/lib/research-brief.ts
@@ -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[];
}
@@ -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));
@@ -155,8 +142,10 @@ 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;
@@ -164,8 +153,9 @@ export function buildSourceRelationshipMap(
const sourcesById = new Map();
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),
@@ -282,7 +272,6 @@ interface SourceCandidate {
interface ClaimCandidate {
text: string;
keywords: string[];
- polarity: number;
}
function articleToCandidate(article: Article): SourceCandidate {
@@ -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),
@@ -317,7 +306,7 @@ function articleToCandidate(article: Article): SourceCandidate {
};
}
-function buildConsensus(candidates: SourceCandidate[]): SourceMapItem[] {
+function buildSharedTerms(candidates: SourceCandidate[]): SourceMapItem[] {
const groups = new Map }>();
candidates.forEach((candidate) => {
@@ -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),
};
}
@@ -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);
}
@@ -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);
}