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
3 changes: 2 additions & 1 deletion src/__tests__/CitationDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,6 @@ describe("useCitationDrawer", () => {
describe("getStatusPriority", () => {
it("returns 1 for verified statuses", () => {
expect(getStatusPriority({ status: "found" })).toBe(1);
expect(getStatusPriority({ status: "found_context_missed_source_match" })).toBe(1);
});

it("returns 2 for pending/null statuses", () => {
Expand All @@ -872,6 +871,8 @@ describe("getStatusPriority", () => {
expect(getStatusPriority({ status: "found_on_other_line" })).toBe(3);
expect(getStatusPriority({ status: "first_word_found" })).toBe(3);
expect(getStatusPriority({ status: "found_source_match_only" })).toBe(3);
// issue-228: found_context_missed_source_match is a partial match (sourceMatch ⊄ sourceContext)
expect(getStatusPriority({ status: "found_context_missed_source_match" })).toBe(3);
});

it("returns 4 for not_found status", () => {
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/citationParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,15 +294,16 @@ ${CITATION_DATA_END_DELIMITER}`;
expect(Object.keys(citations).length).toBe(0);
});

it("skips citations without sourceContext", () => {
it("admits citations with sourceMatch but no sourceContext (issue-235)", () => {
const response = `Test [1].

${CITATION_DATA_START_DELIMITER}
[{"id": 1, "attachment_id": "abc", "source_match": "test"}]
${CITATION_DATA_END_DELIMITER}`;

const citations = getAllCitationsFromNumericResponse(response);
expect(Object.keys(citations).length).toBe(0);
// sourceMatch-only entries are admitted instead of dropped
expect(Object.keys(citations).length).toBe(1);
});
});

Expand Down
9 changes: 5 additions & 4 deletions src/__tests__/citationParsingEdgeCases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,16 @@ describe("Citation Parsing Edge Cases", () => {
});

describe("Edge cases with incomplete data", () => {
it("skips citations without source_context", () => {
it("admits citations with sourceMatch but no source_context (issue-235)", () => {
const input = makeNumericResponse("Test [1] [2]", [
{ id: 1, attachment_id: "test123", source_match: "no phrase" },
{ id: 2, attachment_id: "test123", source_context: "has phrase", source_match: "phrase", page_id: "1_0" },
]);
const result = getAllCitationsFromLlmOutput(input);
// Only citations with sourceContext are included
expect(Object.keys(result).length).toBe(1);
expect(Object.values(result)[0].sourceContext).toBe("has phrase");
// Both citations are admitted — sourceMatch-only entries are no longer dropped
expect(Object.keys(result).length).toBe(2);
const withContext = Object.values(result).find(c => c.sourceContext === "has phrase");
expect(withContext).toBeDefined();
});

it("handles empty input", () => {
Expand Down
12 changes: 7 additions & 5 deletions src/__tests__/parseCitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,13 @@ describe("getCitationStatus", () => {
expect(status.isVerified).toBe(true); // Partial matches ARE verified (amber checkmark)
});

it("treats found_context_missed_source_match as verified but not partial", () => {
// issue-228: found_context_missed_source_match must downgrade to partial — sourceMatch ⊄ sourceContext
// violates §1 and must never read as fully Verified.
it("treats found_context_missed_source_match as partial match (amber), not fully verified", () => {
const verification: Verification = {
citation: {
sourceMatch: "term",
sourceContext: "term",
sourceMatch: "F43.10",
sourceContext: "Most responsible DSM-5 diagnosis / borderline personality disorder.",
attachmentId: "file",
},
document: {
Expand All @@ -283,8 +285,8 @@ describe("getCitationStatus", () => {
sourceSnippet: "snippet",
};
const status = getCitationStatus(verification);
expect(status.isVerified).toBe(true);
expect(status.isPartialMatch).toBe(false);
expect(status.isVerified).toBe(true); // Partial matches ARE verified (amber checkmark)
expect(status.isPartialMatch).toBe(true);
});

it("treats found_source_match_only as partial match", () => {
Expand Down
78 changes: 78 additions & 0 deletions src/__tests__/parseCitationResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,81 @@ describe("parseCitationResponse — cite link format", () => {
expect(segments[0]).not.toContain("(cite:");
});
});

// ─── Issue-235: orphan marker admission (sourceMatch without sourceContext) ──

describe("parseCitationResponse — sourceMatch-only admission (issue-235)", () => {
it("admits a citation that has sourceMatch but no sourceContext", () => {
// Simulates an LLM output where the citation block has sourceMatch + pageNumber
// but omits sourceContext entirely. Before the fix, this entry was silently
// dropped, leaving [3] in prose with no markerMap entry → permanent pulsing chip.
const response = makeNumericResponse(
"The patient has moderate impairment [3] and follows medication schedule [4].",
[
{
id: 3,
attachment_id: "att_aish_form_123456789",
reasoning: "Selected option for mental health impairment",
// No source_context — only source_match
source_match: "Medium or moderate impairment",
page_id: "page_number_1_index_0",
line_ids: [14],
},
{
id: 4,
attachment_id: "att_aish_form_123456789",
reasoning: "Medication adherence",
source_context: "Patient follows prescribed medication schedule",
source_match: "medication schedule",
page_id: "page_number_2_index_0",
line_ids: [7],
},
],
);

const result = parseCitationResponse(response);

// Both markers must have entries — no orphan
expect(result.markerMap[3]).toBeDefined();
expect(result.markerMap[4]).toBeDefined();

// The sourceMatch-only entry must be in citations
const citationKey3 = result.markerMap[3];
const citation3 = result.citations[citationKey3];
expect(citation3).toBeDefined();
expect(citation3.sourceMatch).toBe("Medium or moderate impairment");
// sourceContext should be absent/empty (not fabricated)
expect(citation3.sourceContext ?? "").toBe("");

// The normal entry is unaffected
const citationKey4 = result.markerMap[4];
expect(result.citations[citationKey4].sourceContext).toBe("Patient follows prescribed medication schedule");
});

it("does not admit an entry with neither sourceContext nor sourceMatch", () => {
// An entry with no searchable text should still be dropped — it provides
// no way to locate or display the citation.
const response = makeNumericResponse("Claim one [1] and claim two [2].", [
{
id: 1,
attachment_id: "att_aish_form_123456789",
reasoning: "First claim",
source_context: "Valid source context",
source_match: "Valid match",
page_id: "page_number_1_index_0",
},
{
id: 2,
attachment_id: "att_aish_form_123456789",
reasoning: "Second claim has no searchable text",
// Both source_context and source_match are absent
page_id: "page_number_1_index_0",
},
]);

const result = parseCitationResponse(response);
expect(result.markerMap[1]).toBeDefined();
// Entry 2 has no searchable text — still silently dropped
expect(result.markerMap[2]).toBeUndefined();
});
});
27 changes: 27 additions & 0 deletions src/__tests__/searchNarrative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ describe("buildSearchNarrative", () => {
expect(narrative.colorScheme).toBe("amber");
});

it("returns partial_match for 'found_context_missed_source_match'", () => {
const attempts: SearchAttempt[] = [
{ method: "source_match_fallback", success: true, searchPhrase: "F43.10", pageSearched: 1 },
];
const narrative = buildSearchNarrative(attempts, "found_context_missed_source_match");
expect(narrative.outcome).toBe("partial_match");
expect(narrative.colorScheme).toBe("amber");
expect(narrative.outcomeSummary).toBe("Partial match");
});

it("returns not_found for 'not_found' status", () => {
const attempts: SearchAttempt[] = [
{ method: "exact_line_match", success: false, searchPhrase: "missing text", pageSearched: 1 },
Expand Down Expand Up @@ -64,6 +74,23 @@ describe("buildSearchNarrative", () => {
expect(narrative.showAllRows).toBe(true);
});

it("is true for 'found_context_missed_source_match' so the partial search trail remains visible", () => {
const attempts: SearchAttempt[] = [
{ method: "exact_line_match", success: false, searchPhrase: "diagnosis", pageSearched: 1 },
{
method: "source_match_fallback",
success: true,
searchPhrase: "F43.10",
pageSearched: 1,
foundLocation: { page: 1 },
},
];
const narrative = buildSearchNarrative(attempts, "found_context_missed_source_match");
expect(narrative.showAllRows).toBe(true);
expect(narrative.groupedAttemptCount).toBe(2);
expect(narrative.rows.map(row => row.kind)).toEqual(["failure", "success"]);
});

it("is true for null status", () => {
const narrative = buildSearchNarrative([], null);
expect(narrative.showAllRows).toBe(true);
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/searchSummaryUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,32 @@ describe("buildIntentSummary", () => {
expect(result.snippets[0].isProximate).toBe(false); // adjacent_pages = distal
});

it("returns related_found for found_context_missed_source_match", () => {
const verification: Verification = {
status: "found_context_missed_source_match",
citation: {
type: "document",
sourceContext: "Most responsible DSM-5 diagnosis / borderline personality disorder.",
sourceMatch: "F43.10",
pageNumber: 1,
},
};
const result = buildIntentSummary(verification, [
attempt({
searchPhrase: "F43.10",
success: true,
matchedText: "F43.10",
method: "source_match_fallback",
foundLocation: { page: 1 },
}),
]);
expect(result).not.toBeNull();
if (result == null) return;
expect(result.outcome).toBe("related_found");
expect(result.snippets).toHaveLength(1);
expect(result.snippets[0].matchedText).toBe("F43.10");
});

it("classifies proximate methods correctly", () => {
const verification: Verification = {
status: "found_on_other_line",
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/urlAccessExplanation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mock.module("react-dom", () => {

import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { CitationComponent } from "../react/Citation";
import { mapSearchStatusToFetchStatus } from "../react/urlAccessExplanation";
import type { Citation } from "../types/citation";
import type { UrlAccessStatus, Verification } from "../types/verification";

Expand Down Expand Up @@ -363,3 +364,9 @@ describe("URL Access Explanation in CitationComponent", () => {
});
});
});

describe("mapSearchStatusToFetchStatus", () => {
it("maps found_context_missed_source_match to partial", () => {
expect(mapSearchStatusToFetchStatus("found_context_missed_source_match")).toBe("partial");
});
});
5 changes: 3 additions & 2 deletions src/analysis/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ export function buildIntentSummary(
};
}

// For found status without displacement → exact_match
if (status === "found" || status === "found_context_missed_source_match") {
// For found status without displacement → exact_match. Other found variants
// can still be related/partial matches even when a source phrase was located.
if (status === "found") {
return {
outcome: "exact_match",
sourceContext,
Expand Down
3 changes: 2 additions & 1 deletion src/analysis/narrative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,9 @@ function getOutcomeSummary(

switch (status) {
case "found":
case "found_context_missed_source_match":
return t("outcome.exactMatch");
case "found_context_missed_source_match":
return t("outcome.partialMatch");
case "found_source_match_only":
return t("outcome.sourceMatchOnly");
case "found_on_other_page":
Expand Down
8 changes: 4 additions & 4 deletions src/analysis/statusRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ export const STATUS_MAP = {
showOnlyHit: false,
},
found_context_missed_source_match: {
outcome: "exact_match",
colorScheme: "green",
headerKey: "status.verified",
showOnlyHit: true,
outcome: "partial_match",
colorScheme: "amber",
headerKey: "status.partialMatch",
Comment on lines +42 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update fallback narrative for downgraded context-miss status

After downgrading found_context_missed_source_match to partial (status.partialMatch/amber) in STATUS_MAP, the fallback path in getOutcomeSummary still maps this status to outcome.exactMatch (src/analysis/narrative.ts, switch at lines 204-207). In cases where searchAttempts is empty or lacks matchedVariation, users will see conflicting signals (partial status header/color but exact-match summary text), which regresses the intended downgrade behavior and can mislead verification interpretation.

Useful? React with 👍 / 👎.

showOnlyHit: false,
},
found_on_other_page: {
outcome: "partial_match",
Expand Down
87 changes: 87 additions & 0 deletions src/parsing/__tests__/parseWorkAround.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import { cleanRepeatingLastSentence, isGeminiGarbage } from "../parseWorkAround";

describe("isGeminiGarbage", () => {
describe("single-character repetition", () => {
it("detects all-same-character strings", () => {
expect(isGeminiGarbage("a".repeat(100))).toBe(true);
});

it("returns false for short all-same-character strings", () => {
expect(isGeminiGarbage("a".repeat(10))).toBe(false);
});

it("returns false for normal text", () => {
expect(isGeminiGarbage("The quick brown fox jumps over the lazy dog.")).toBe(false);
});
});

describe("multi-character repeating unit", () => {
it("detects repeated </font> lines", () => {
const garbage = "</font>\n".repeat(20);
expect(isGeminiGarbage(garbage)).toBe(true);
});

it("detects repeated HTML tags without trailing newline", () => {
const lines = Array(20).fill("</font>").join("\n");
expect(isGeminiGarbage(lines)).toBe(true);
});

it("detects other repeated multi-char tokens", () => {
const garbage = Array(15).fill("<br/>").join("\n");
expect(isGeminiGarbage(garbage)).toBe(true);
});

it("detects repeated markup separated by blank lines", () => {
const garbage = Array(15).fill("</font>\n").join("\n");
expect(isGeminiGarbage(garbage)).toBe(true);
});

it("returns false when lines differ", () => {
const normal = ["First sentence.", "Second sentence.", "Third sentence."].join("\n");
expect(isGeminiGarbage(normal)).toBe(false);
});

it("does not classify legitimate repeated text rows as garbage", () => {
const repeatedRows = Array(30).fill("N/A").join("\n");
expect(isGeminiGarbage(repeatedRows)).toBe(false);
});

it("returns false when fewer than MIN_REPETITIONS lines", () => {
expect(isGeminiGarbage("</font>")).toBe(false);
});
});

describe("edge cases", () => {
it("returns false for empty string", () => {
expect(isGeminiGarbage("")).toBe(false);
});

it("returns false for whitespace-only string", () => {
expect(isGeminiGarbage(" ")).toBe(false);
});
});
});

describe("cleanRepeatingLastSentence", () => {
it("removes trailing repeated sentence", () => {
const repeated = "The cat sat on the mat. The dog ran fast. The dog ran fast.";
expect(cleanRepeatingLastSentence(repeated)).toBe("The cat sat on the mat. The dog ran fast.");
});

it("removes many repetitions keeping one copy", () => {
const base = "Something happened here.";
const repeated = base + " The fog rolled in. The fog rolled in. The fog rolled in.";
expect(cleanRepeatingLastSentence(repeated)).toBe(base + " The fog rolled in.");
});

it("returns text unchanged when no repetition", () => {
const text = "First sentence. Second sentence. Third sentence.";
expect(cleanRepeatingLastSentence(text)).toBe(text);
});

it("returns text unchanged when too short to repeat", () => {
const text = "Hello world.";
expect(cleanRepeatingLastSentence(text)).toBe(text);
});
});
6 changes: 5 additions & 1 deletion src/parsing/citationParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,11 @@ export function getAllCitationsFromNumericResponse(llmResponse: string): {

for (const data of parsed.citations) {
const citation = citationDataToCitation(data);
if (citation.sourceContext) {
// Admit citations that have either sourceContext OR sourceMatch.
// Dropping entries that only have sourceMatch (no sourceContext) caused
// orphan [N] markers in prose to render as permanently-pulsing chips
// because no map entry existed for the marker number. (issue-235)
if (citation.sourceContext || citation.sourceMatch) {
const baseCitationKey = getCitationKey(citation);
const citationKey =
citations[baseCitationKey] && citations[baseCitationKey].citationNumber !== citation.citationNumber
Expand Down
Loading
Loading